# LEV -- Async I/O Runtime

![LEV logo](../images/lev_logo.png)

## 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.

```lua
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()`.

```lua
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.

```lua
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.

```lua
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`.

```lua
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.

```lua
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 running
- `cleanups` list is lazy-initialized (no cost for tasks that don't use it)
- No-op outside `lev.run()` or from non-spawned coroutines
- Double-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.

```lua
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()`.

```lua
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()`.

```lua
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:

```lua
-- 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.

```lua
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.

```lua
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.

```lua
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).

```lua
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

```lua
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

```lua
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.

```lua
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:

1. **Exact match**: hostname matches a key exactly
2. **Wildcard match**: `*.domain` matches any single-label prefix
   (e.g. `*.example.org` matches `api.example.org` but not
   `deep.sub.example.org`)
3. **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.

```lua
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.

```lua
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.

```lua
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.

```lua
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:

```lua
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.

```lua
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:

```lua
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).

```lua
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.

```lua
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.

```lua
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()`.

```lua
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 |

```lua
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()`.

```lua
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.

```lua
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 ===
```

