Files
alkcall/docs/architecture/decisions/050-pump-bidi-two-pump-helper.md
T
glm-5.3-flash 9c6fec17ca feat(review 007 Unit 3): pump_bidi two-pump helper (R-03, ADR-050)
Extracts the two-pump data-plane helper alknet ADR-078 deferred until
the shapes converged (they have: alktunnels POC pump_halves + alktty's
channels session). Purely additive.

- channels::pump::pump_bidi(channel, peer_read, peer_write) -> (u64, u64):
  two joined pumps, shutdown-on-completion per direction; copy counts
  for observability. The channel side is a single AsyncRead +
  AsyncWrite value (the accept_bi BiStream); the peer side takes split
  halves — the establisher's natural dial result (into_split).
- Return (u64, u64), not the review sketch's io::Result<(u64, u64)>:
  both pumps swallow copy errors by contract (mid-stream error =
  abrupt close, no error channel mid-stream per ADR-049 §6), so an
  Err state would be dead code. Deviation recorded in ADR-050.
- alktty's three-pump session does not fit (exit future as a third
  signal) and stays as-is, per the review's scope.
- Tests reproduce the POC's two-pump semantics through the helper:
  bidirectional flow with exact counts, EOF-from-one-side completes
  the other's shutdown (clean EOF at the far end), dead-source =
  EOF-shaped teardown.
- ADR-050 records the decision, deviations, and two-way door type.

Verification: cargo test (625 passed, +2), clippy -D warnings, fmt
--check, doc clean, test --all-features clean, wasm32-unknown-unknown
check clean.
2026-09-07 08:47:46 +00:00

4.4 KiB

ADR-050: pump_bidi — the two-pump helper, extracted upstream

Status

Accepted — implemented in alkcall 0.6.0 (review 007 Unit 3 / R-03). Pins alknet ADR-078's two-pump contract in one place.

Context

alknet ADR-078 defined the two-pump data-plane contract for forwarding channels (one pump per direction; each pump shuts the opposite sink down on completion — try_join! alone deadlocks) and deferred helper extraction until a second two-pump consumer existed and the shapes converged. Review 007 (R-03) supplied both halves of that test from the alktunnels UDP POC:

  • Producer side: the POC's pump_halves — two tokio::io::copy pumps over split channel-vs-substrate halves, each shutting down the opposite sink on completion, joined.
  • Consumer side: TunnelSession::take_halves + the assembly layer's copy — the same shape modulo channel side.

alktty's channels session already implements the shape (its drive_session_pre_negotiated awaited inline); alkhttp's ferry does not pump. The shapes converged; the review asked this sweep to either extract the helper or record the decision not to — an un-extracted helper would surface during alktunnels Phase 1 as the same fix→publish→update treadmill for a purely additive change.

Decision

Extract the helper as channels::pump_bidi:

pub async fn pump_bidi<C, R, W>(channel: C, peer_read: R, mut peer_write: W) -> (u64, u64)
where
    C: AsyncRead + AsyncWrite + Unpin,
    R: AsyncRead + Unpin,
    W: AsyncWrite + Unpin,

Two pumps, joined: channel → peer_write and peer_read → channel. On each pump's completion the opposite sink is shut down (shutdown-on-completion — the half-close the contract specifies). Returns the two copy counts for observability.

Three deliberate deviations from the review's sketch, all semantics-first:

  1. Return (u64, u64), not io::Result<(u64, u64)>. There is no meaningful Err state: both pumps swallow copy errors by contract — a mid-stream error is indistinguishable from an abrupt close (no error channel exists mid-stream, per ADR-049 §6's pinned EOF-shaped semantics), and shutdown-of-the-opposite-sink runs either way. A returned Err would be dead code; the counts (with unwrap_or(0) on error) are the observability.
  2. Peer side takes split halves (peer_read: R, peer_write: W), not one AsyncRead + AsyncWrite value. The dominant producer shape is the establisher's dial result: TcpStream::into_split halves (tunnels), pty pair halves (TTY). Taking halves matches the POC (pump_halves) and avoids forcing consumers to re-join halves just to satisfy a bound. The channel side stays a single value — that is what the pump handler's accept_bi yields.
  3. Not async fn over AsyncWriteExt bounds on both sides. The channel side needs tokio::io::split (one value, two pumps), so its bound is AsyncRead + AsyncWrite + Unpin; the AsyncWriteExt bound the sketch had on every parameter is the extension trait only where shutdown is called.

alktty's three-pump session does not fit the helper (the exit future is a third signal) and stays as-is — the helper serves the two-pump shape, exactly as the review scoped.

Compatibility

Purely additive: a new pub fn in channels::pump, re-exported in the module docs; no existing surface changes. Consumers may adopt it at their own pace (alktunnels Phase 1 gets it for free; alktty's channels session can migrate later if it wants; the POC's shape is now canonical).

Door type

Two-way. A helper function is trivially removable or re-shapable; no wire surface, no trait, no state.

Verification gate

The POC's two-pump semantics reproduced through the helper, as unit tests in src/channels/pump.rs: data flows both directions with exact copy counts; EOF from one side completes the other's shutdown-on-completion (the far end observes clean EOF, not an error); a dead source is EOF-shaped teardown, not an Err.

References

  • Review 007 R-03 (the extraction ask + the convergence evidence)
  • alknet ADR-078 (the two-pump contract; shutdown-on-completion)
  • alktunnels poc-summary.md §Issues Surfaced #4 (the producer-side shape; pump_halves in the POC's producer.rs)
  • alktty src/channels.rs (the channels session shape; three-pump carve-out)
  • ADR-049 amendment 2 (R-02 — the lifetime contract the helper's callers must respect: await pump_bidi(..).await inline inside the handler task)