LEV -- Async I/O Runtime

Overview
This document defines the public contract for the lev module (src/lev/).
Single-threaded, coroutine-based async I/O runtime built on Linux epoll.
Provides cooperative multitasking with timers, UDP sockets, TCP sockets
with TLS, cancellation tokens, and structured concurrency combinators.
The runtime is opt-in and scoped: lev.run() creates an event loop for
its dynamic extent. All networking code (HTTP, DNS, Redis) requires
lev.run() context.
Architecture: C core (lev.core) is the scheduling authority -- only
the event loop resumes coroutines. The Lua wrapper (lev.lua) manages
task lifecycle, cancellation, and combinators.
Event loop
lev.run(fn) creates an epoll-based event loop, spawns fn as the main
coroutine, and runs until all coroutines complete. Returns the main
function's results, or nil, err if it errored.
local lev = require("lev")
local result, err = lev.run(function()
-- async code here
return 42
end)
-- result == 42
Nested lev.run() calls are not allowed (raises a Lua error()).
Error protocol
All I/O operations return nil, err_string on failure. Error strings:
| Error | Meaning |
|---|---|
"timeout" | Operation timed out |
"cancelled" | Cancel token was triggered |
"closed" | Peer closed the connection (TCP/TLS) |
"EAGAIN" | Would block (low-level; wrapped by Lua layer) |
"EINPROGRESS" | Connect in progress (low-level; wrapped by lev.connect()) |
"socket is closed" | Operation on a closed socket |
strerror() | System error string for other errno values |
Timeout semantics
A timeout is an overall deadline for the whole operation: multi-step
operations (send, recv_exactly, recv_until, read_all, the TLS
handshake) compute deadline = now + timeout once and give each internal
wait only the remaining time. A peer trickling one byte per almost-timeout
cannot keep an operation alive past its deadline. timeout = nil means
no time limit.
One waiter per fd
Only one coroutine may be blocked on a given file descriptor at a time
(a second wait_readable/wait_writable on the same fd raises an
error). In particular, do not call accept() on one listener from
multiple coroutines concurrently.
Closing a socket while another coroutine is blocked on it is safe: the
blocked operation returns nil, "closed".
Spawning tasks
lev.spawn(fn, opts) creates a new coroutine that runs concurrently.
Returns a task handle for use with lev.await().
lev.run(function()
local task = lev.spawn(function()
lev.sleep(0.1)
return "done"
end)
-- Detached: errors logged to stderr, never awaited
lev.spawn(function()
handle_request(data)
end, { detached = true })
local result = lev.await(task) -- "done"
end)
Options
| Field | Type | Default | Description |
|---|---|---|---|
detached | boolean | false | If true, task errors are logged automatically when the task completes; the task handle can still be awaited if retained |
name | string | nil | Human-readable name for observability (appears in task_tree()/task_dump()) |
Awaiting tasks
lev.await(task) yields the current coroutine until the task finishes.
Returns the task's return values on success, or nil, err on error.
local result, err = lev.await(task)
Multiple coroutines can await the same task. If the task has already
completed, await returns immediately.
Resource tracking
lev.own(), lev.defer(), and lev.disown() provide per-coroutine
resource cleanup. When a task finishes (success or error), registered
cleanups run automatically in LIFO order before the task status is
updated and awaiters are notified.
lev.own(resource)
Register a resource (any table with a close() method) for auto-close.
Returns the resource for chaining.
lev.spawn(function()
local sock = lev.own(lev.connect("1.1.1.1", 53, { timeout = 5 }))
-- sock:close() called automatically when this coroutine exits,
-- even if an error is thrown
local data = sock:recv(4096, 5)
end, { detached = true })
lev.defer(fn)
Register an arbitrary cleanup function, like Go's defer.
lev.spawn(function()
local counter = get_counter()
counter:increment()
lev.defer(function() counter:decrement() end)
-- counter:decrement() runs when this coroutine exits
do_work()
end)
lev.disown(resource)
Remove a resource from the current task's cleanup list (identity comparison). Use for ownership transfer between coroutines. Returns the resource.
lev.spawn(function()
local sock = lev.own(lev.connect("1.1.1.1", 53))
-- Transfer ownership to another task
lev.disown(sock)
lev.spawn(function()
lev.own(sock)
-- sock is now cleaned up when THIS task exits
end)
end)
Properties
Cleanups run on both success and error paths
LIFO order (last registered runs first)
Each cleanup is wrapped in
pcall-- errors are logged to stderr and don't prevent other cleanups from runningcleanupslist is lazy-initialized (no cost for tasks that don't use it)No-op outside
lev.run()or from non-spawned coroutinesDouble-close safe (all LEV close methods are idempotent)
Timers
lev.sleep(seconds, cancel)
Yields the current coroutine for the given duration (fractional seconds
supported). Returns true on normal completion, or nil, "cancelled"
if a cancel token fires before the timer expires.
lev.sleep(0.5) -- sleep 500ms
-- Cancellable sleep
lev.sleep(5.0, token) -- returns nil, "cancelled" if token fires
lev.now()
Returns the current time from CLOCK_MONOTONIC as a float (seconds with
sub-millisecond precision). Available outside lev.run().
local start = lev.now()
do_work()
local elapsed = lev.now() - start
UDP sockets
lev.udp() creates a non-blocking UDP socket integrated with the event
loop. Must be called within lev.run().
lev.run(function()
local sock = lev.udp()
sock:bind("0.0.0.0", 5353)
sock:setsockopt("reuseaddr", 1)
sock:sendto(data, "1.1.1.1", 53)
-- Yields until data arrives or timeout
local data, addr, port = sock:recvfrom(4096, 5.0)
if not data then
-- addr contains error: "timeout", "cancelled", etc.
end
sock:close()
end)
Methods
| Method | Signature | Description |
|---|---|---|
bind | (addr, port) -> true \| nil, err | Bind to address and port (use port 0 for ephemeral) |
sendto | (data, addr, port) -> nbytes \| nil, err | Send datagram (non-blocking) |
recvfrom | (maxlen, timeout_or_opts) -> data, addr, port \| nil, err | Receive datagram; yields on EAGAIN. Datagrams larger than maxlen are silently truncated |
setsockopt | (name, value) -> true \| nil, err | Set socket option |
close | () | Close socket and deregister from event loop |
fd | () -> int | Return raw file descriptor number |
recvfrom options
The second argument to recvfrom can be a number (timeout in seconds)
or a table:
-- Simple timeout
local data, addr, port = sock:recvfrom(4096, 5.0)
-- Table form with cancel token
local data, addr, port = sock:recvfrom(4096, {
timeout = 5.0,
cancel = token,
})
TCP sockets
lev.tcp()
Creates a raw non-blocking TCP socket. Typically used indirectly via
lev.connect() or lev.listen().
lev.connect(addr, port, opts)
High-level connect: creates a TCP socket, performs non-blocking connect (with async wait for EINPROGRESS), and optionally upgrades to TLS.
lev.run(function()
-- Plain TCP
local sock, err = lev.connect("1.1.1.1", 53, { timeout = 5 })
-- TCP + TLS
local tls_sock, err = lev.connect("1.1.1.1", 853, {
timeout = 5,
tls = {
mode = "client",
server_name = "cloudflare-dns.com",
verify = false,
},
})
end)
Options
| Field | Type | Default | Description |
|---|---|---|---|
timeout | number | nil | Connection timeout in seconds |
tls | table | nil | If present, perform TLS handshake after connect (see TLS config) |
cancel | token | nil | Cancel token; propagated to TLS handshake if tls.cancel is not set |
lev.listen(addr, port, opts)
Creates a bound listening TCP socket. Returns a listener object with
an accept method that yields until a client connects.
lev.run(function()
local listener = lev.listen("0.0.0.0", 8080, { reuseport = true })
while true do
local client, addr, port = listener:accept(5.0)
if client then
lev.spawn(function()
local data = client:recv(4096, 10)
client:send("HTTP/1.0 200 OK\r\n\r\nHello")
client:close()
end, { detached = true })
end
end
end)
Options
| Field | Type | Default | Description |
|---|---|---|---|
reuseport | boolean | false | Enable SO_REUSEPORT |
backlog | number | 128 | Listen backlog |
Listener methods
| Method | Signature | Description |
|---|---|---|
accept | (timeout_or_opts) -> client, addr, port \| nil, err | Accept a connection; yields until one arrives. Accepts a number (timeout) or { timeout = N, cancel = token } |
close | () | Close the listening socket |
fd | () -> int | Return raw file descriptor |
TCP socket methods
| Method | Signature | Description |
|---|---|---|
send | (data, opts?) -> nbytes \| nil, err | Send all data; handles partial sends, EAGAIN, and TLS record flushing internally. opts: { timeout = N, cancel = token } (default timeout: 30s) |
recv | (maxlen, timeout_or_opts) -> data \| nil, err | Receive up to maxlen bytes; yields on EAGAIN |
recv_exactly | (n, timeout_or_opts) -> data \| nil, err | Receive exactly n bytes; accumulates partial reads |
recv_until | (pattern, timeout_or_opts, max_size) -> data \| nil, err | Receive until the Lua pattern matches (e.g. "\r?\n"); returns data including the match |
setsockopt | (name, value) -> true \| nil, err | Set socket option |
getpeername | () -> addr, port | Return remote address and port |
shutdown | (how) -> true \| nil, err | Half-close: "r", "w", or "rw" |
starttls | (config) -> true \| nil, err | Upgrade to TLS (see TLS section) |
close | () | Close socket and deregister from event loop |
fd | () -> int | Return raw file descriptor |
TCP socket options
| Name | Description |
|---|---|
"reuseaddr" | SO_REUSEADDR |
"reuseport" | SO_REUSEPORT |
"rcvbuf" | SO_RCVBUF (receive buffer size) |
"sndbuf" | SO_SNDBUF (send buffer size) |
"keepalive" | SO_KEEPALIVE |
"nodelay" | TCP_NODELAY (disable Nagle's algorithm) |
Unix domain sockets
lev.connect_unix() and lev.listen_unix() provide AF_UNIX stream
sockets for local IPC. They return the same socket and listener objects
as their TCP counterparts, so every stream method (send, recv,
recv_exactly, recv_until, shutdown, close, fd, and even starttls)
behaves identically. Only the addressing differs: a filesystem path instead
of addr + port.
A leading @ selects the Linux abstract namespace — the socket has no
filesystem entry and is cleaned up automatically when the last reference
closes.
lev.connect_unix(path, opts)
Creates an AF_UNIX stream socket and connects to path. Returns a wrapped
socket on success.
lev.run(function()
local sock, err = lev.connect_unix("/run/myservice.sock", { timeout = 5 })
sock:send("ping")
local reply = sock:recv(4096, 5)
sock:close()
end)
Options
| Field | Type | Default | Description |
|---|---|---|---|
timeout | number | nil | Connection timeout in seconds |
tls | table | nil | If present, perform TLS handshake after connect (see TLS config) |
cancel | token | nil | Cancel token; propagated to TLS handshake if tls.cancel is not set |
A nonblocking AF_UNIX connect normally completes immediately. If the kernel
reports EINPROGRESS, the connect is awaited like TCP; a full listen backlog
surfaces as a returned error string.
lev.listen_unix(path, opts)
Creates a bound AF_UNIX stream listener at path. Returns a listener object
with an accept method that yields until a client connects. Because a
connecting unix client is typically unbound, accept returns only the wrapped
client socket (no peer address/port).
lev.run(function()
local listener = lev.listen_unix("/run/myservice.sock")
while true do
local client = listener:accept(5.0)
if client then
lev.spawn(function()
local data = client:recv(4096, 10)
client:send("ok")
client:close()
end, { detached = true })
end
end
end)
Options
| Field | Type | Default | Description |
|---|---|---|---|
backlog | number | 128 | Listen backlog |
unlink | boolean | true | For filesystem paths, remove a stale socket file before binding and on close(). Ignored for abstract (@) paths |
Listener methods
| Method | Signature | Description |
|---|---|---|
accept | (timeout_or_opts) -> client \| nil, err | Accept a connection; yields until one arrives. Accepts a number (timeout) or { timeout = N, cancel = token } |
close | () | Close the listening socket (and unlink the socket file unless unlink = false or abstract) |
fd | () -> int | Return raw file descriptor |
SO_REUSEADDR/SO_REUSEPORT do not apply to AF_UNIX and are not set; the
unlink option handles stale-socket cleanup instead.
TLS
TLS is integrated directly into TCP sockets via LITLS. After
establishing a TCP connection, call sock:starttls(config) to perform
a TLS handshake. Once upgraded, send() and recv() transparently
use TLS -- no API changes needed for data transfer.
Client TLS
local sock = lev.connect("example.com", 443, {
timeout = 5,
tls = {
mode = "client",
server_name = "example.com", -- SNI
verify = true, -- certificate verification (default)
cafile = "/etc/ssl/certs/ca-certificates.crt",
},
})
Server TLS
local listener = lev.listen("0.0.0.0", 443)
local client = listener:accept(5)
client:starttls({
mode = "server",
cert = "/path/to/cert.pem",
key = "/path/to/key.pem",
verify = false, -- don't require client certs (default for server)
})
TLS config fields
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "client" | "client" or "server" |
server_name | string | nil | SNI hostname (client mode) |
verify | boolean | true (client) / false (server) | Certificate verification |
cafile | string | nil | CA certificate bundle path |
cert | string | nil | Certificate file path (PEM) — server identity or client mTLS identity |
key | string | nil | Private key file path (PEM) — server key or client mTLS key |
identity | userdata | nil | Pre-parsed TLS identity (from lev.tls_identity(); server mode alternative to cert/key) |
hosts | table | nil | SNI host map for server mode (see below) |
timeout | number | 10 | TLS handshake timeout in seconds |
Server-side SNI
When running a TLS server that serves multiple domains, the hosts
field in the starttls config provides per-hostname cert/key pairs.
During the TLS handshake, the SNI callback selects the correct
certificate based on the client's requested hostname.
client:starttls({
mode = "server",
cert = "/default/cert.pem",
key = "/default/key.pem",
hosts = {
["example.com"] = { cert = "/certs/example.com.crt", key = "/keys/example.com.key" },
["*.example.org"] = { cert = "/certs/_.example.org.crt", key = "/keys/_.example.org.key" },
},
})
Matching order:
Exact match: hostname matches a key exactly
Wildcard match:
*.domainmatches any single-label prefix (e.g.*.example.orgmatchesapi.example.orgbut notdeep.sub.example.org)Default: if no match, the default cert/key is used
Each entry in the hosts table must have both cert and key paths
pointing to valid PEM files. Invalid entries cause starttls to fail
instead of being silently ignored.
TLS-aware error strings
In addition to the standard error protocol, TLS operations may return:
| Error | Meaning |
|---|---|
"want_read" | TLS needs to read (internal, handled by Lua wrapper) |
"want_write" | TLS needs to write (internal, handled by Lua wrapper) |
"closed" | Peer closed the connection |
Cancellation
lev.cancel_token() returns a token for cooperative cancellation.
Pass it to blocking operations via the options table.
local token = lev.cancel_token()
lev.spawn(function()
lev.sleep(5.0)
token:cancel() -- interrupt waiters
end)
local data, err = sock:recvfrom(4096, { cancel = token })
-- err == "cancelled" if token fired first
The cancel token sets a cancelled flag that blocking operations check
before and after yielding. Cancellation is cooperative -- operations
return nil, "cancelled" and the socket remains open for reuse.
Cancellation is race-safe against completion: if a wait has already
completed (its timer expired or its fd became ready) in the same loop
iteration as token:cancel(), cancelling it is a no-op in the core --
the waiter is resumed exactly once. For I/O waits the completed result
is delivered; sleep re-checks the flag on wake-up and returns
nil, "cancelled" whenever the token has fired by that point.
Token fields
| Field/Method | Description |
|---|---|
token.cancelled | boolean -- read-only flag |
token:cancel() | Set the flag and interrupt registered waiters |
Combinators
lev.race(fns)
Spawns each function concurrently. Returns the first result (success or error) and cancels the remaining tasks. Each function receives a cancel token as its first argument.
local result = lev.race({
function(token)
lev.sleep(0.1)
return "fast"
end,
function(token)
lev.sleep(10.0)
return "slow"
end,
})
-- result == "fast"
lev.all(fns)
Spawns each function and waits for all to complete. Returns a list of
result tables. If any task errors, cancels the rest and returns
nil, err immediately.
local results, err = lev.all({
function(token) return "a" end,
function(token) return "b" end,
})
-- results == { {"a"}, {"b"} }
-- Fail-fast on error
local results, err = lev.all({
function(token) error("boom") end,
function(token) lev.sleep(10) end,
})
-- results == nil, err matches "boom"
Signal handling
lev.on_signal(signum, handler) registers a callback for signal delivery
via signalfd. Must be called within lev.run(). Signal disposition is
restored when lev.run() exits.
lev.run(function()
lev.on_signal(lev.SIGTERM, function(signo)
shutdown = true
end)
while not shutdown do
-- serve requests
end
end)
Stopping the loop
lev.stop() sets the loop's running flag to false, causing lev.run()
to exit on the next iteration regardless of active coroutines. This is
intended for signal handlers that need to enforce a shutdown deadline:
lev.run(function()
lev.on_signal(lev.SIGTERM, function()
shutdown = true
lev.spawn(function()
lev.sleep(5) -- grace period for in-flight work
lev.stop() -- force exit
end, { detached = true })
end)
while not shutdown do
-- serve requests
end
end)
Signal constants
| Constant | Value |
|---|---|
lev.SIGTERM | 15 |
lev.SIGINT | 2 |
lev.SIGHUP | 1 |
lev.SIGUSR1 | 10 |
lev.SIGUSR2 | 12 |
C core API
The lev.core module exposes low-level primitives used by the Lua
wrapper. These are not part of the public API but are documented for
maintainability.
| Function | Description |
|---|---|
loop_new() | Create epoll fd and allocate loop structures |
loop_run(loop, on_complete) | Run event loop until all coroutines finish |
loop_stop(loop) | Signal the loop to stop |
loop_destroy(loop) | Close epoll, free memory (also __gc) |
spawn(loop, co) | Add coroutine to ready queue, increment active count |
yield_to_loop() | Pure lua_yield |
ready_enqueue(loop, co, nargs) | Add coroutine to ready queue |
wait_readable(fd, timeout) | Register fd with epoll + optional timer, yield |
wait_writable(fd, timeout) | Same for write readiness |
fd_deregister(fd) | Remove fd from epoll; a pending waiter is woken with nil, "closed" |
read_fd(fd, maxlen) | Non-blocking read from an arbitrary fd |
set_nonblocking(fd, enable) | Toggle O_NONBLOCK, returns previous state |
timer_sleep(seconds) | Add timer, yield, resume on expiry |
timer_register(seconds) | Add timer, return timer_id (no yield; for cancellable sleep) |
cancel_wait(co, fd, timer_id) | Deregister fd/timer, resume with nil, "cancelled" |
signal_setup(signum, fn) | Add signal to signalfd, register handler |
now() | CLOCK_MONOTONIC as float seconds |
udp_new() | Create non-blocking UDP socket userdata |
tcp_new() | Create non-blocking TCP socket userdata |
unix_new() | Create non-blocking AF_UNIX stream socket userdata (a lev.tcp userdata) |
loop_stats(loop) | Return event loop statistics |
tls_parse_identity(cert, key, hostname?) | Parse cert+key PEM into reusable TLS identity userdata |
TCP userdata methods (C level)
| Method | Description |
|---|---|
connect(addr, port) | Non-blocking connect; returns true or nil, "EINPROGRESS" |
connect_finish() | Check SO_ERROR after writable; returns true or nil, err |
connect_unix(path) | Non-blocking AF_UNIX connect; path starting with @ is abstract; returns true or nil, "EINPROGRESS" |
bind(addr, port) | Bind to address |
bind_unix(path) | Bind to an AF_UNIX path (@-prefixed = abstract) |
listen(backlog) | Start listening |
accept() | Non-blocking accept; returns tcp_ud, addr, port or nil, "EAGAIN" |
accept_unix() | Non-blocking AF_UNIX accept; returns client tcp_ud (no peer address) or nil, "EAGAIN" |
send(data) | Non-blocking send (TLS-aware); returns bytes consumed or nil, err |
recv(maxlen) | Non-blocking recv (TLS-aware); returns data or nil, err |
tls_flush() | Flush buffered TLS records; returns "done", "want_write", or nil, err ("done" for plain TCP) |
setsockopt(name, val) | Set socket option |
getpeername() | Returns addr, port |
shutdown(how) | Half-close: "r", "w", "rw" |
starttls_init(config) | Initialize LITLS TLS context and session |
starttls_step() | Drive TLS handshake; returns "done", "want_read", "want_write", or nil, err |
close() | Cleanup TLS + close fd |
fd() | Return raw fd |
Async subprocess
lev.exec(path, args, opts) spawns an external command as a child process
with non-blocking pipe I/O and pidfd-based exit notification. The child's
stdin/stdout/stderr are connected via pipes, and the pidfd integrates
directly into the epoll event loop for async wait.
lev.run(function()
local proc = lev.exec("/bin/echo", {"hello"})
local output = proc:read_all(5) -- "hello\n"
local code = proc:wait(5) -- 0
proc:close()
end)
Options
| Field | Type | Default | Description |
|---|---|---|---|
cwd | string | nil | Working directory for child |
env | table | nil | Array of "KEY=VALUE" strings; replaces child environment (uses execve) |
stderr_to_stdout | boolean | false | Merge stderr into stdout stream |
Process methods
| Method | Signature | Description |
|---|---|---|
send | (data, opts) -> nbytes \| nil, err | Write to child stdin; handles partial writes |
recv | (maxlen, timeout_or_opts) -> data \| nil, err | Read from child stdout; yields on EAGAIN |
recv_stderr | (maxlen, timeout_or_opts) -> data \| nil, err | Read from child stderr |
read_all | (timeout_or_opts, max_size?) -> data \| nil, err | Read all stdout until EOF; fails with "read_all: max_size exceeded" if output exceeds max_size (also accepted via the opts table) |
wait | (timeout_or_opts) -> exit_code, exit_signal \| nil, err | Async wait via pidfd |
close_stdin | () | Close stdin pipe (sends EOF to child) |
kill | (signal) -> true \| nil, err | Send signal (default SIGTERM) |
pid | () -> int | Child PID |
close | () | Close all fds; reaps the child if it has already exited. A child still running at close() is left unreaped (zombie) — call wait() (or kill() + wait()) first |
All methods accepting timeout_or_opts support: a number (timeout in
seconds), or a table { timeout = N, cancel = token }.
Exec failure detection
If execvp/execve fails (e.g. command not found), lev.exec() returns
nil, err immediately. This uses the self-pipe trick: a CLOEXEC pipe that
the child writes errno to on exec failure; the parent reads it before
returning the userdata.
Bidirectional I/O
For commands that both read stdin and produce output, use separate coroutines to prevent deadlocks:
lev.run(function()
local proc = lev.exec("/bin/cat")
local writer = lev.spawn(function()
proc:send(big_data)
proc:close_stdin()
end)
local reader = lev.spawn(function()
return proc:read_all(10)
end)
lev.await(writer)
local output = lev.await(reader)
proc:wait(5)
proc:close()
end)
Signal constants
| Constant | Value |
|---|---|
lev.SIGKILL | 9 |
(In addition to the existing SIGTERM, SIGINT, SIGHUP, SIGUSR1, SIGUSR2.)
Subprocess C core API
| Function/Method | Description |
|---|---|
subprocess_spawn(path, argv, opts) | Fork+exec with pipes and pidfd |
read(maxlen) | Non-blocking read from stdout |
read_stderr(maxlen) | Non-blocking read from stderr |
write(data) | Non-blocking write to stdin |
close_stdin() | Close stdin pipe |
kill(sig) | Send signal to child |
waitid_pidfd() | Non-blocking waitid via pidfd |
pid() / pidfd() | Return child PID / pidfd |
stdin_fd() / stdout_fd() / stderr_fd() | Return raw fds |
close() / __gc | Close all fds, reap zombie |
Reading arbitrary fds
lev.set_nonblocking(fd, enable)
Sets or clears O_NONBLOCK on an arbitrary fd. Returns the previous
non-blocking state (boolean) so callers can restore it, or nil, err.
Required before cooperatively reading a shared fd (e.g. stdin) through
the event loop. Available outside lev.run().
lev.fd_reader(fd)
Wraps an arbitrary fd (e.g. stdin) for cooperative reads on the event
loop. The fd must be in non-blocking mode (see set_nonblocking) and
must be epoll-able (a TTY or pipe, not a regular file).
lev.run(function()
local was_nb = lev.set_nonblocking(0, true)
local reader = lev.fd_reader(0)
local bytes, err = reader:read(512, 5.0) -- yields until input or timeout
reader:close()
lev.set_nonblocking(0, was_nb)
end)
| Method | Signature | Description |
|---|---|---|
read | (maxlen, timeout_or_opts) -> data \| nil, err | Yield until at least one byte is available; nil, "closed" on EOF |
read_available | (maxlen) -> data, "ok" \| data, "closed" | Drain all currently-readable bytes without yielding |
fd | () -> int | The wrapped fd |
close | () | Deregister the fd from the event loop (does not close the fd itself) |
Deadlock detection
The C event loop detects deadlocks: when all coroutines are blocked but
no file descriptors, timers, or signal handlers are registered, the loop
returns nil, "deadlock: ..." instead of hanging indefinitely.
local ok, err = lev.run(function()
local never = lev.spawn(function()
coroutine.yield() -- yield without registering anything
end)
lev.await(never) -- blocks forever
end)
-- ok == nil, err matches "deadlock"
Signal handlers prevent false positives: a server waiting for SIGTERM with no active I/O is not a deadlock.
Observability
LEV provides introspection APIs for debugging hung servers and monitoring task lifecycle. The primary use case: send SIGUSR1 to a running RECALL or RELIW process to get a full task dump showing what every coroutine is blocked on.
lev.set_logger(logger)
Injects a std.logger instance for structured error reporting. When set,
cleanup errors and detached task errors are routed through the logger
instead of raw io.stderr:write(). The logger persists across run()
calls. Pass nil to revert to stderr.
local std = require("std")
local logger = std.logger.new("info")
lev.set_logger(logger)
lev.task_name(name)
Sets and/or returns the current task's name. With an argument, sets the
name and returns it. Without an argument, returns the current name (or nil).
No-op outside lev.run().
lev.spawn(function()
lev.task_name("request-handler")
-- ...
end)
Tasks can also be named at spawn time via opts.name. The main task is
automatically named "main".
lev.stats()
Returns a table with fast loop counters. Returns nil outside lev.run().
| Field | Description |
|---|---|
active_coros | Number of live coroutines in the event loop |
registered_fds | Number of file descriptors registered with epoll |
timer_count | Number of pending timers in the heap |
ready_count | Number of coroutines in the ready queue |
task_count | Number of entries in the Lua task registry |
lev.run(function()
local s = lev.stats()
print(s.active_coros, s.task_count)
end)
lev.task_tree()
Returns a sorted array of task info tables. Returns nil outside lev.run().
Each entry has: id, name, status, detached, parent_id, co_status.
Sorted by id for stable output. co_status is from coroutine.status().
lev.run(function()
lev.spawn(function() lev.sleep(1) end, { name = "worker" })
for _, t in ipairs(lev.task_tree()) do
print(t.id, t.name, t.status, t.co_status)
end
end)
lev.task_dump()
Like task_tree() but adds a traceback field for suspended coroutines,
showing the exact yield point (e.g. sleep → timer_sleep). Has meaningful
overhead -- use for debugging, not monitoring.
lev.enable_dump_signal(format)
Registers a SIGUSR1 handler that calls task_dump() and outputs the result.
Supports "text" (default) and "json" formats. Uses the injected logger
if set, otherwise stderr. Safe because LEV signal handlers run via signalfd
dispatch, not async-signal context.
lev.run(function()
lev.enable_dump_signal() -- or "json"
lev.spawn(function() lev.sleep(30) end, { name = "sleeper" })
lev.sleep(30)
end)
-- Then: kill -USR1 <pid>
Text output example:
=== LEV task dump ===
task #1 "main" status=pending co=suspended detached=false parent=none
task #2 "sleeper" status=pending co=suspended detached=false parent=1
=== end dump ===