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:
- Registration Stage: Initialize the loop with
L_uv_initand add handles usingL_uv_add_*. Note:L_uv_initwill panic if called on an already running loop without a scopedlocal L_UV. - 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 :
(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_UVarray. 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_uvuses a hash map bucket system (at offset29,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 likekill -0 $pidsandwait -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 -
-hShow 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 -
-hShow help
Arguments:
-
$1PID 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:
-
-dDelimiter character (defaults to newline) -
-v <var>Variable to assign the handle ID to -
-cClose file descriptor on EOF -
-hShow help
Arguments:
-
$1Target 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 -
-hShow 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 -
-hShow help
Argument:
$@
Callback function and its arguments
L_uv_set
¶
Set a callback for a specific handle ID in the loop.
Arguments:
-
$1Handle 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) -
-1Run only one iteration of the loop. -
-t <duration>Timeout (e.g., 1s, 500ms; bare number is seconds) (defaults to none) -
-cSet sigchild trap. -
-hShow 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.