Files
alknet/docs/architecture/decisions/078-two-pump-shutdown-on-completion.md
T
glm-5.2 a3cb44968e docs(adr): 093 — channels pure channel multiplexing (8-byte header, no stream_type)
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).
2026-07-18 18:10:17 +00:00

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_bi that yields the BiStream the 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: BiStream as the handler leaf (the transport-leaf decision — accept_bi returns BiStream; the two-pump pattern calls tokio::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)