Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Lifecycle and shutdown

Validation before I/O

Relay::new builds both chains once, before any endpoint is opened, purely to discover which side channels the declarations ask for. The instances from that pass are dropped: they are stateful and each connection needs its own. What survives is the frozen channel plan, which is then opened, and which every connection resolves its handles against without touching a lock.

So a misspelled plugin, an option no plugin declares, a detach = false on a subprocess stage or an unwritable dump file fails at startup rather than on the first byte of the first connection. The three warnings described in Architecture are raised in the same pass.

Forking

If either endpoint is a forking listener, that side binds and accepts in a loop while the other is dialled per connection. Concurrency is bounded by a semaphore of max-connections permits, acquired before the accept rather than after, so the listener stops taking connections it cannot serve instead of accepting and queueing them.

Each accepted connection gets its own chain pair, its own buffers and its own tick schedules, and runs on its own task inside a tracing span carrying the peer. Only the channel handles are shared, which is how several connections can dump into one file. Accept errors that are per-connection (aborted, interrupted, would-block) are logged and the loop continues; anything else ends the run.

udp-listen has no accept to run, so a forking one is served by a receive loop that demultiplexes the socket by source address and hands each new sender to the same accept loop as a session. Everything downstream of that is identical; what differs is that a session has no close to end it, so a timeout stage on both paths is what reclaims one, and that the connection ceiling is enforced in the receive loop, since a session exists by the time the accept loop sees it. See The datagram model.

Signals

The first SIGINT or SIGTERM flips a watch channel: the listener stops accepting, every connection still in flight is handed the same watch, and a task tracker waits for them to finish. A second signal exits immediately with status 130.

A connection observes the signal as end of stream, not as cancellation. The handle goes down to the reads that touch an endpoint, which stop returning chunks, and the ordinary end-of-stream path takes it from there: on_eof runs, a stage holding buffered bytes gets to emit them, side channels are applied and the writer is flushed and shut down. Racing the copy against the watch in a select! would skip all of that, which is why only the two paths with no stages do it: the plugin-free copy_bidirectional shortcut, and the synchronous copy.

Within a pipeline, only the head watches. A read from a segment boundary does not: the head stopping closes its outlet, which is end of stream for the segment below it, so on_eof cascades in order and parcels still in the channel are drained rather than dropped. A process stage stops feeding and closes the child’s stdin, which is the only end of stream a subprocess has, and then keeps draining its stdout so a filter holding a block still writes it out.

The synchronous copy path cannot do either, since a spawn_blocking task is not cancellable, so it polls between chunks instead. That leaves one case it cannot cover: a read that never returns, such as a FIFO with no writer. The runtime is therefore shut down with a short grace period rather than being dropped, so those tasks are left behind instead of making the process unkillable by signal.

Cleanup

Two kinds of state outlive the copy loop and have to be released in the right order.

Paths. A bound Unix socket, and a pipe: opened with unlink, hand back a guard that removes the path when it drops. The guard has to outlive the connection using it, or the socket is unlinked while still in use, or the FIFO is removed out from under a producer still writing to it.

Channels. Side-channel writers are buffered, so they are flushed after the relay finishes, whether it finished cleanly or not: on an error path that flush may be the last chance to get the dump to disk. All channels are flushed concurrently, so one failing sink does not cancel the others, and the first failure is reported.

The progress display is taken down before any error is reported, so its summary line lands above the diagnostics rather than wedged between them.

Children are handled differently at the two places they appear. An exec: or system: endpoint is reaped in the background, so the relay ends when the pipes close rather than when the process does, and a non-zero exit is a warning. A process stage is waited on, and a non-zero exit fails that direction, because there the child’s output is mid-pipeline and being wrong matters. Both are spawned with kill-on-drop, which is what ends a child blocked writing to a stdout nobody is draining.

Exit status

StatusMeans
0The relay finished, or was interrupted and drained
1Setup failed, or the relay failed mid-transfer. The error is logged
130A second signal arrived while draining

A stage stopping the transfer deliberately, which is what limit does, is a success: it is upstream end of stream arriving early, not a fault.