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

Effects and channels

A plugin never opens a file, never sleeps and never touches a socket. It asks, and the host does it after the call returns. Everything is staged per call and applied in one pass, which is what lets the flush overlap the downstream write.

CallAsks the host to
ctx.side_write(id, bytes)Append to a side channel opened at build time
ctx.log(level, message)Emit a log record, tagged with this stage’s display name
ctx.pace(delay)Wait delay before reading upstream again
ctx.halt(reason)Stop reading upstream, as if it had reached end of stream
ctx.rearm()Restart this stage’s tick schedule from now

Side channels

At build time a stage describes the sink it wants and receives an opaque ChannelId; at run time it queues writes against that id.

let channel = ctx.open_channel(ChannelTarget::file("session.hex"))?;

The host owns the descriptor and de-duplicates identical targets, so two entries naming the same path share one buffered writer and their output interleaves at chunk granularity rather than racing. Each sink has its own lock, so two directions contend only when they genuinely target the same file. A side write costs one extend_from_slice into a staging buffer that is reused across chunks, so there is no allocation and no lock inside the plugin call.

ChannelTarget is Stderr or File { path, append }. There is deliberately no stdout: on a stdio endpoint that stream carries relay payload, and the host refuses a file target that resolves to it. stderr is written unbuffered, since it is shared with the log writer and a dump stranded behind a partial line would be worse than the syscall.

Channels are opened once for the whole process, before any endpoint is touched, so an unwritable dump file is a startup error rather than a surprise on the first byte. That also means a stage cannot ask for a new target after startup.

Logging

ctx.log takes a LogLevel and a message. The host emits it under the plugin target with a stage field, so a user can select plugin output with RUST_LOG=plugin=info and can tell two instances apart by the name as gave them. Do not repeat the stage name in the message.

Pacing and halting

pace is the entire throttling mechanism: the host simply does not read for that long. Nothing is buffered, and on a socket the stalled read closes the receive window and slows the peer at source. It is applied after whatever was emitted has been written, so the bytes in hand are never held hostage by the wait. Where several stages ask on one chunk, the longest request wins rather than the sum.

halt ends the transfer deliberately. Everything already emitted is still written, on_eof still cascades, and the path closes down its normal way, so the relay exits successfully and the reason is logged at info. It is a decision, not a failure, and must not be reported as one. Where several stages ask, the first wins, which is the one nearest upstream.

Both default to doing nothing on the EffectSink trait, so a host with no reader to hold (a test harness, an offline driver) is not obliged to honour them.