Prune the channels spec to reflect the stream-unification resolution (docs/research/stream-unification/findings.md): the channels wire format goes from 9 bytes to 8 bytes, the channels layer no longer carries a stream_type concept, into_sub_streams() is removed, and TTY always uses its 5-byte format (carried transparently in the channels payload). ADR-093 is the umbrella decision (the channels-layer consequence of ADR-092's BiStream handler leaf): every channel is a BiStream, the handler owns its sub-stream multiplexing, the channels layer routes by channel_id only. Amends ADR-071 (8-byte header, no stream_type), ADR-074 (into_sub_streams removed, accept_bi yields BiStream), reverses ADR-077 (TTY always 5-byte), and the channels-facing clauses of ADR-072/073/075/076/080/081. Adds ADR-092 forward-reference note (into_sub_streams preservation subsequently reversed by ADR-093) and the missing ADR-092 cross-reference on ADR-070. Adds OQ-68 (add/strip API shape — built-in vs utility; the contract is decided in ADR-093, the function surface is open; two-way door, low priority, decision-ready when the channels crate's implementation begins). Rewrites the 7 channels spec docs (README, overview, channels-wire, channels-connection, channels-adapter, channel-operations, channel-client) to describe the post-amendment shape as current, with the 8-byte header, the add/strip composition, single accept_bi accessor, BiStream per channel, and TTY-always-5-byte. Touch-up cross-references in hub README, client README, ADR-085, and the OQ-45/47/65 question files (TTY-internal stream_type 3 → STREAM_CTRL_IN; channels 9-byte → 8-byte).
6.3 KiB
ADR-078: Two-Pump Shutdown-on-Completion Pattern
Status
Accepted
Context
The de-risk POC (docs/research/alknet-channels/poc-summary.md §Issues
Surfaced #7) surfaced a deadlock in the tunnel handler's two-pump shape.
The naive tokio::try_join!(c2t, t2c) deadlocks: each pump waits for the
other's EOF, which only comes once the opposite pump completes and shuts
down its sink. The TTY adapter avoids this because its three pumps
coordinate via the exit_code future — a third signal. A two-pump handler
(tunnel, SSH direct-tcpip) has no such third signal.
The fix the POC found: shut down the peer's sink when one pump completes.
c2t (client→target) shuts down tcp_write on EOF; t2c (target→client)
shuts down send on EOF. This is the pump_session shape with an explicit
shutdown-on-completion step that the TTY adapter doesn't need (because
TTY's three pumps coordinate via the exit future).
This pattern will recur — any handler with a pump-driven two-direction
shape (tunnel, SSH direct-tcpip, future port-forward) needs it. Getting it
wrong hangs channels silently. The POC hung; the spec must pin the pattern.
Decision
The two-pump pattern is a documented contract
A two-pump handler (two tokio::io::copy pumps, one per direction) MUST
shut down the opposite sink when one pump completes. tokio::try_join!
alone deadlocks because each pump waits for the other's EOF, which only
comes after the opposite pump shuts down its sink.
// Correct two-pump shape:
let (mut send, mut recv) = connection.accept_bi().await?;
let mut tcp = TcpStream::connect(target).await?;
let (mut tcp_read, mut tcp_write) = tcp.into_split();
let c2t = async {
tokio::io::copy(&mut recv, &mut tcp_write).await?;
tcp_write.shutdown().await.ok(); // shut down the peer's sink
Result::<_, std::io::Error>::Ok(())
};
let t2c = async {
tokio::io::copy(&mut tcp_read, &mut send).await?;
send.shutdown().await.ok(); // shut down the peer's sink
Result::<_, std::io::Error>::Ok(())
};
tokio::try_join!(c2t, t2c)?;
When c2t completes (recv EOF), it shuts down tcp_write, which causes
t2c's tcp_read to eventually EOF, completing t2c. When t2c
completes (tcp_read EOF), it shuts down send, which causes c2t's recv
to eventually EOF. Either pump completing unblocks the other.
Where the pattern lives
The pattern is a handler-level contract, not a channels-layer concern. The channels layer routes chunks; the handler owns its pump logic. This ADR documents the pattern so handlers don't reimplement it incorrectly.
The channels spec (channels-adapter.md) documents the pattern in the
handler-integration section. The tunnel handler (the first two-pump
consumer) implements it. Future two-pump handlers (SSH direct-tcpip)
follow the same shape.
Consideration: a helper in alknet-core
The POC summary suggested "a helper in alknet-core that encapsulates the
'two-pump with shutdown-on-completion' shape so handlers don't reimplement
it." This is an implementation convenience, not an architecture decision.
The contract is the shutdown-on-completion pattern; whether it's a helper
function or inline code in each handler is a two-way-door implementation
detail.
Decision: do not add a core helper yet. The pattern is ~10 lines of
inline code. A helper would be called from handler crates (alknet-tty,
the future tunnel crate, the future SSH crate), which means the helper's
signature (fn pump_bidi<R, W>(recv: R, send: W, ...) -> impl Future) is a
cross-crate API surface. Extracting it prematurely (with one consumer — the
POC's tunnel) would bake in a shape that the second consumer (SSH
direct-tcpip) might not fit. The pattern is documented; the helper is
extracted when two real consumers exist and their shapes converge. This is
a genuine deferral (blocked on: a second two-pump handler existing), not a
hedge — the contract is decided (shutdown-on-completion), only the
extraction is deferred.
The three-pump pattern (TTY) is unaffected
The TTY adapter's pump_session (three pumps: stdout, stderr,
client→backend, coordinating via the exit_code future) does not have this
deadlock because the exit_code future is the third signal that unblocks
the pumps. This ADR applies only to two-pump handlers. The TTY adapter is
unchanged.
Consequences
Positive:
- The two-pump deadlock is documented as a contract, not left as a POC finding. Handlers that follow the pattern don't hang.
- The pattern is handler-level — the channels layer stays a re-framing proxy, not a pump-logic owner.
- The three-pump pattern (TTY) is unaffected — the ADR scopes itself to two-pump handlers.
Negative:
- Each two-pump handler implements the shutdown-on-completion inline (~10 lines). Until a core helper is extracted (deferred, blocked on a second consumer), the pattern is copy-paste with documentation. This is the correct trade-off: the contract is decided, the extraction is deferred on a real blocker (shape convergence across consumers), not hedged.
Door type
One-way. The shutdown-on-completion contract is a correctness invariant — two-pump handlers MUST shut down the opposite sink on pump completion, or they deadlock. This is not a preference; it is a correctness requirement.
The core helper extraction is a deferred decision (OQ-57, deferred(scope)), not a door-type attribute. Its door type is two-way (a helper function is additive), but the extraction is not decided in this ADR — see OQ-57 for the blocking condition (a second two-pump handler existing, so shape convergence is observable).
References
- ADR-074: ChannelBidiStreamSource (the
accept_bithat yields theBiStreamthe pumps operate on, as amended by ADR-093) - ADR-093: channels pure channel multiplexing (the
BiStream-only accessor decision this ADR's two-pump pattern builds on) - ADR-092:
BiStreamas the handler leaf (the transport-leaf decision —accept_bireturnsBiStream; the two-pump pattern callstokio::io::split(bidi)for its halves) - ADR-055: exit-chunk-is-last (the three-pump TTY invariant — the pattern this ADR does NOT touch)
docs/research/alknet-channels/poc-summary.md§Issues Surfaced #7 (the deadlock the POC found and fixed)crates/alknet-tty/src/adapter.rs—pump_session(the three-pump reference shape)