Skip to content

L_uv: High-Performance Bash Event Loop

L_uv is an event loop implementation for Bash. It allows polling for timers, file descriptors, and child processes asynchronously within a single process.

Quick Start

# 1. Initialize the event loop state
L_uv_init

# 2. Create a background process with a pipe
L_pipe pipe_fds
L_array_extract pipe_fds read_fd write_fd
( while true; do echo "SERVICE_HEARTBEAT"; sleep 1; done >&"$write_fd" ) & bg_pid=$!
# Close the write end in the parent, the child process will keep it open.
exec {write_fd}>&-

# 3. Define callbacks
# Reader callback: receives [args] fd [line]
# On EOF/error: line argument is omitted
reader_cb() {
  if (( $# == 2 )); then
    echo "[READER]: Received: $2"
  else
    echo "[READER]: Pipe closed"
  fi
}

# Waiter callback: receives [args] pid status
waiter_cb() {
  echo "[WAITER]: PID $1 exited with $2"
}

# Timer callback: receives [args]
timer_cb() {
  echo "[TIMER]: 5s elapsed. Stopping service."
  kill "$bg_pid" 2>/dev/null || :
}

# 4. Add handles to the loop
L_uv_add_reader -v reader_id "$read_fd" reader_cb
L_uv_add_waiter -v waiter_id "$bg_pid" waiter_cb
L_uv_add_timer -d 5 timer_cb # One-shot timer after 5s

# 5. Run the loop
echo "Event loop started. Monitoring service PID $bg_pid..."
L_uv_run
echo "Event loop finished."

Expected Output:

Event loop started. Monitoring service PID 2690731...
[READER]: Received: SERVICE_HEARTBEAT
[READER]: Received: SERVICE_HEARTBEAT
[READER]: Received: SERVICE_HEARTBEAT
[READER]: Received: SERVICE_HEARTBEAT
[READER]: Received: SERVICE_HEARTBEAT
[TIMER]: 5s elapsed. Stopping service.
[READER]: Pipe closed
[WAITER]: PID 2690731 exited with 143
Event loop finished.

Usage Stages

Working with L_uv involves two distinct stages:

  1. Registration Stage: Initialize the loop with L_uv_init and add handles using L_uv_add_*. Note: L_uv_init will panic if called on an already running loop without a scoped local L_UV.
  2. Running Stage: Start the loop with L_uv_run. During this stage, handles can be added or removed dynamically from within callbacks.

Common Patterns

Repeating Timers

Timers are the core of time-based scheduling.

count=0
tick() {
  echo "Tick $((++count))"
  if (( count >= 5 )); then
    L_uv_current_remove # Remove this specific handle
  fi
}
# Start immediately (no -d), repeat every 0.5s
L_uv_add_timer -r 0.5 tick
L_uv_run

# Expected Output:
# Tick 1
# Tick 2
# Tick 3
# Tick 4
# Tick 5

High-Frequency Tasks

Tasks execute on every iteration of the loop. They are useful for continuous polling but can consume CPU if not managed.

poll_sensor() {
  # Perform a fast, non-blocking check
  if [[ -f /tmp/sensor_ready ]]; then
    echo "Sensor is ready!"
    L_uv_current_remove
  fi
}
L_uv_add_task poll_sensor

# Add a timer to simulate the sensor becoming ready after 2s
L_uv_add_timer -d 2 'touch /tmp/sensor_ready'
# Add a timer to stop the loop
L_uv_add_timer -d 3 L_uv_break

rm -f /tmp/sensor_ready
L_uv_run

# Expected Output (after 2 seconds):
# Sensor is ready!

Handling Signals

The L_uv functions (such as L_uv_add_timer, L_uv_remove, etc.) are not signal-safe or reentrant. For example, if a signal interrupts the internal timer heap modification and the trap handler also attempts to register a new handle, the internal memory structures will almost certainly become corrupted. Therefore, you must never manipulate the loop state directly from within a Bash trap handler.

Instead, trap handlers should only set state variables or call L_uv_poke to wake up the loop. You can then use a persistent task (L_uv_add_task) to poll those variables safely outside of the interrupt context.

Signal Responsiveness By default, the event loop's waiting functions (waitpid, sleep, read) are blocking calls. While an external process execution blocks the loop, Bash will not process signals from the queue. To ensure signals are handled in a timely fashion, the loop must be prevented from blocking indefinitely.

This is achieved by forcing the optimizer to choose a "capped" delayer. By registering a minimal, empty task:

L_uv_add_task :
You ensure the loop will wake up at least every 50ms (the default cap) to process pending signals or other events.

(Note on L_uv_run -c): The -c option registers a SIGCHLD trap that calls L_uv_poke. This is mildly useful to skip the 50ms delay when a child exits. However, because Bash lacks sigprocmask (signal blocking), a race condition exists: if the signal is received exactly between the loop checking the poked flag and entering the sleep command, the loop will still sleep. It is generally not advertised or recommended for critical logic.

Replacing Callbacks

Handles can replace their own callback and arguments dynamically.

phase_two() { echo "$1: Phase two complete."; L_uv_current_remove; }
phase_one() { echo "$1: Phase one complete."; L_uv_current_set phase_two "$1"; }

L_uv_add_timer -r 1 phase_one "Task1"
L_uv_run


Programmer Documentation: Internals

Architectural Rationale

The design of L_uv is the result of several core architectural decisions aimed at maximizing performance and compatibility within the Bash environment.

The Single Global L_UV Array

A single global array, L_UV, is used to hold all state. This simplifies usage, as it avoids the need to pass a loop context variable to every function. Alternative methods, such as nameref or eval-based indirection, would break compatibility with older Bash versions.

This design also allows for nested loops. A function can declare a local L_UV to create a fresh, scoped event loop. The state of an outer loop can be preserved and restored by using L_array_copy to copy the L_UV array to a temporary variable and back.

Registration vs. Running Stage

An early design conflict was whether to register all callbacks at once via arguments to L_uv_run, or to provide a unified L_uv_add_* interface. The add interface was chosen for its flexibility and clarity.

This creates two distinct phases: 1. Registration Stage: Before L_uv_run is called, the L_uv_add_* functions populate the L_UV array with handle data. 2. Running Stage: After L_uv_run is called, functions like L_uv_break, L_uv_poke, and L_uv_current_* become meaningful as they interact with the active loop.

L_UV Memory Layout and Performance

The L_UV array emulates an array of structs by using large integer offsets for each handle type (e.g., ID * X + N). Bash internally uses three pointers for sparse arrays: first, last, and lastref (last referenced index). To optimize lookup speed, struct-like members for a given handle are placed at adjacent indexes with the intention of improving data locality.

  • The optimizer flag (L_UV[1]) is placed first for fast access on every loop iteration.
  • Task callbacks are placed at the end of the array (99,000,000+) to leverage the fast ${L_UV[*]:99000000} expansion, which retrieves all subsequent elements without needing to store a separate task counter.

High-Performance Lookups & Data Structures

  • Timer Heap: To efficiently find the next timer to fire, timer handles are organized in a min-heap structure within the L_UV array. This provides fast access to the timer with the lowest timeout.
  • PID-to-Waiter Hash Map: To quickly map an exited PID to its callback, L_uv uses a hash map bucket system (at offset 29,000,000). This gives an $O(1)$ lookup, avoiding a slow linear scan through all registered waiters.
  • Pre-built PID List: A space-separated list of all active waiter PIDs is maintained (at index 20000002) for efficient batch syscalls like kill -0 $pids and wait -n $pids.

Handle ID Management

"Next available ID" counters for each handle type allow for fast insertions. These counters can wrap around after 1 million registrations, so the same ID can be reused over the lifetime of a long-running script. There is an absolute limit of 1 million concurrent handles of each type (1M timers, 1M waiters, etc.), though Bash array access performance will degrade significantly before these limits are reached.

The Optimizer

The optimizer (_L_uv_run_optimizer) is used to pre-calculate the loop's behavior. If multiple handles are added or removed within a single iteration of the L_uv_run loop, the optimizer is still only run once. This simplifies the optimization logic and is faster than re-evaluating on every single modification.

Full Memory Architecture

Each handle type is allocated from a block of 1 million slots. The maximum number of concurrent timers, readers, waiters, or tasks is 1 million each.

Index Range Description Handle Type
1 Optimizer State Flag (0=Dirty, 1=Optimized) Core
2 1 if uv is running Global
10000000 Next available Timer handle ID (relative) Timer
11000000 Timer Min-Heap metadata (size at base) Timer
11000001+ Timer Min-Heap elements (expiry_usec:TID) Timer
12000000 + (TID*3) + 0 Timer: User callback string Timer
12000000 + (TID*3) + 1 Timer: Repeat interval (usec) Timer
12000000 + (TID*3) + 2 Timer: Heap inverse map pointer Timer
20000000 Next available Waiter handle ID (relative) Waiter
20000001 Active Waiter handle IDs cache ( rel_id ) Waiter
20000002 List of space-separated PIDs Waiter
21000000 + (WID*2) + 0 Waiter: User callback string Waiter
21000000 + (WID*2) + 1 Waiter: PID to monitor Waiter
29000000 + (PID % 1M) PID-to-WID Hash Map Bucket Waiter
30000000 Next available Reader handle ID (relative) Reader
30000001 Active Reader handle IDs cache ( rel_id ) Reader
31000000 + (RID*5) + 0 Reader: User callback string Reader
31000000 + (RID*5) + 1 Reader: Separator (delimiter) Reader
31000000 + (RID*5) + 2 Reader: File descriptor (FD) Reader
31000000 + (RID*5) + 3 Reader: Accumulation buffer Reader
31000000 + (RID*5) + 4 Reader: Close on EOF flag Reader
98000000 Next available Task handle ID (relative) Task
99000000 - 99999999 User task callbacks Task

The 7-Delayer Strategy

L_uv uses an optimized decision tree to select the most efficient waiting method (the "delayer") for the current state.

Case Active Handles Wait Method Timeout
Single Reader 1 FD read -u FD Indefinite
Single Waiter 1 PID wait -n / waitpid / tail --pid Indefinite
Single Timer Timers L_sleep Next Timer
Reader + Timer 1 FD + Timers read -t $timer -u FD Next Timer
Waiter + Timer PID + Timers waitpid -t $timer / timeout $timer tail --pid Next Timer
Capped Reader Multi-FD or Tasks read -t 0.05 -u FD min(Timer, 50ms)
Capped Waiter Tasks + Waiter waitpid -t 0.05 / timeout 0.05 tail --pid min(Timer, 50ms)

Nested L_uv_run Usage

It is possible to call L_uv_run from within a callback of another L_uv_run instance.

Behavior: The outer loop is effectively paused. A new, independent inner loop starts and will run until it is empty or broken. Once the inner loop completes, control returns to the outer loop's callback.

Warning: This should be used with caution. Handles from the outer loop (timers, readers, etc.) will not be serviced while the inner loop is running. This can be useful for modal dialogs or sub-tasks that must complete before the main loop continues, but can also lead to unexpected latency if not managed carefully.

Reader Timeout and Race Conditions

By default, L_uv_manager_reader uses a 1-second timeout for read -d. This relatively high value is a mitigation for a race condition in the Bash read builtin's internal timer framework (introduced in Bash 5.2).

When read -t is used with a delimiter (-d), a timeout that expires exactly as data (including the delimiter) arrives can cause Bash to return a partial line and lose the delimiter from its internal buffer. In the L_uv reader, this would result in the next read's data being concatenated directly to the previous partial record without a separator, mangling the input (e.g., record1 and record2 becoming record1record2).

Using a 1-second timeout significantly reduces the probability of hitting this timing window under high system load. For high-performance stream processing where 1-second latency is unacceptable, applications should consider implementing their own raw chunked reading logic to bypass Bash's delimited read entirely.

uv

The global uv loop variable.

L_UV=()

L_uv_init

Initialize a loop array.

Note

Panics if trying to initialize a currently running L_UV loop (detected via L_UV[2]).

L_uv_add_timer

Add a timer to the loop.

Options:

  • -r <duration> Repeat interval (e.g., 1s, 500ms; bare number is seconds) (defaults to 0)
  • -d <duration> Initial delay (e.g., 1s, 500ms; bare number is seconds) (defaults to 0)
  • -v <var> Variable to assign the handle ID to
  • -h Show help

Argument: $@ Callback function and its arguments. The callback is invoked with its arguments only.

L_uv_add_waiter

Add a process wait handle to the loop.

Options:

  • -v <var> Variable to assign the handle ID to
  • -h Show help

Arguments:

  • $1 PID to wait for
  • $@ Callback function and its arguments. The callback is invoked with its arguments, followed by the PID and exit status.

L_uv_add_reader

Add a line-buffered read handle to the loop.

Options:

  • -d Delimiter character (defaults to newline)
  • -v <var> Variable to assign the handle ID to
  • -c Close file descriptor on EOF
  • -h Show help

Arguments:

  • $1 Target file descriptor
  • $@ Callback function and its arguments. The callback is invoked with its arguments, followed by the FD and the line read. On EOF or error, the callback is invoked with its arguments and the FD only (no line argument).

L_uv_add_task

Add a task callback to the loop.

Options:

  • -v <var> Variable to assign the handle ID to
  • -h Show help

Argument: $@ Callback function and its arguments. The callback is invoked with its arguments only.

L_uv_add_once

Add a callback that runs once when a condition is met.

Options:

  • -c <condition> Condition command to evaluate (defaults to 'true')
  • -v <var> Variable to assign the handle ID to
  • -h Show help

Argument: $@ Callback function and its arguments

L_uv_set

Set a callback for a specific handle ID in the loop.

Arguments:

  • $1 Handle ID to set
  • $@ Callback function and its arguments

L_uv_remove

Remove a callback from the loop by handle ID.

Argument: $1 Handle ID to remove

L_uv_current_set

Update the callback of the currently running handle.

Argument: $@ New callback function and its arguments. This replaces both the function and any previously assigned arguments.

Uses environment variable: L_UV_CURRENT The ID of the currently executing handle.

L_uv_current_remove

Remove the current executing callback.

Uses environment variable: L_UV_CURRENT

L_uv_run

Run the event loop until it's empty or timed out.

Note

You can call L_uv_add functions while the loop is running to add more tasks dynamically.

Example

L_uv_add_timer 1 echo "hello"; L_uv_run

Options:

  • -s <duration> Polling interval (e.g., 1s, 500ms; bare number is seconds) (defaults to 0.1)
  • -1 Run only one iteration of the loop.
  • -t <duration> Timeout (e.g., 1s, 500ms; bare number is seconds) (defaults to none)
  • -c Set sigchild trap.
  • -h Show help

Shellcheck disable= SC2120

Return: 0 on success, 124 on timeout, or task exit code.

L_uv_break

Break the current event loop.

Note

This sets a flag that causes L_uv_run to exit after the current iteration completes. It has no effect if the loop is not running.

L_uv_poke

Wake up the event loop immediately.

Note

This skips the next polling delay (sleep), causing the loop to proceed immediately to the next iteration. Useful for signaling state changes from traps or async callbacks.