Rewrote every prose em dash under docs/architecture/ with natural grammar (sentence splits, colons, semicolons, parentheses, commas); wire-format fences, JSON/BAST examples, and byte diagrams untouched. Verified: grep -rln '—' docs/architecture/ returns only overview.md's fenced dependency/module-map blocks (12 dashes), left verbatim per the fenced-code rule; all other files at zero.
8.8 KiB
ADR-005: The Consumer Session Type Owns Teardown
Status
Accepted (2026-09-07; resolves the reverse POC W3 finding).
Amended (2026-09-08, review 001 N-3 decision): the datagram
take_halves posture is pinned: raw framed halves stay the only
owned-halves shape (take_halves keeps its semantics for every
substrate), the datagram accessors remain the codec path, and a
framed-halves convenience is deferred to the first consumer that
reaches for it. See §Amendment (2026-09-08, N-3).
Context
A tunnel channel's two ends have asymmetric lifecycle machinery:
- The serving side (the side the open op ran on) is
wrapper-managed: the open wrapper awaits the pump handler's
JoinHandle, and its completion triggers channel teardown (drop of the demux sender = EOF to the handler's read half, per alkcall ADR-049- review 007 R-02). Out-of-band
channel/closealso tears it down. Nothing leaks.
- review 007 R-02). Out-of-band
- The adopting side (the side that called the open op and adopted
the channel ID, i.e. the consumer) has NO such machinery:
ChannelManager::adopt_channelinstalls routing state nothing awaits. The consumer-side pump (pump_bidispawned locally) is somebody'sJoinHandle; the adopted channel state is nobody's to reap. If the assembly layer drops both, the channel entry leaks in the manager until transport EOF (clear_all); and worse, a consumer that drops the pump handle mid-flight aborts the pump without reaping, leaving half-open state on the peer.
The reverse POC found this by construction (W3, 2026-09-07): the hub
must hold its pump handle and reap (teardown_channel) itself.
ReverseTunnel::join_and_reap/close in the POC. The lifecycle is
assembly-layer by design (OQ-TN-04: no forced binding means no
protocol-owned listener lifecycle either), but the POC demonstrated
that leaving teardown discipline to each assembly layer's memory is
exactly the kind of gap the spec must close with a type, not a doc
note.
Related constraint from the same POC pass: one session = one channel = one tunnel (the channel ID is the flow key, OQ-TN-02's resolution); the session is also the natural owner of the substrate-shaped data plane (raw halves vs datagram codec, ADR-003).
Decision
The consumer half is a typed session, TunnelSession, and it owns
its teardown:
- Construction:
TunnelSession::open(client, params): calls the open op (ChannelClient::open_channelon the forward path; the reverse path'sopen_reverse_channel+adopt_channelequivalent), adopts the returned channel ID, splits the channelBiStream, and presents the substrate-shaped data plane:- Stream variant: raw halves (
AsyncRead/AsyncWrite); the halves ARE the tunnel. - Datagram variant:
send_datagram/recv_datagramover the mandatory codec (ADR-003), which is boundary-preserving;len=0is an empty datagram,recv_datagramreturnsNoneonly on stream EOF.
- Stream variant: raw halves (
- Pump ownership: the session's
pump_against(accepted_halves)(the reverse-flow use) spawnspump_bidiand holds the returnedJoinHandle. For the pure consumer (no local pump; the session halves ARE handed to the caller), the session does not spawn; the caller drives the halves and the session still owns the channel entry. - Teardown API (the point of the ADR):
close(self): abort the pump (if session-owned), tear down the adopted channel (teardown_channel), consume the session. The abort path (ungraceful).join(self): await pump completion (both directions finished), THEN reap the adopted channel, return the(u64, u64)copy counts frompump_bidifor observability. The graceful path.- Dropping the session without either: the
Dropimpl tears the channel down (the panic-free fallback, never leaking the entry).Dropcannot await, so it aborts the pump and calls the syncteardown_channel; this is semanticallyclose.
- Half-open semantics fall out of
pump_bidi(ADR-050): one direction EOFs → the opposite sink shuts down (the EOF sentinel crosses the mux) → the other pump keeps running until its own EOF. The session'sjoincompletes when BOTH pumps finish. Half-close semantics validated end-to-end (reverse POC W4). - Error surface: a failed open resolves a typed error:
ChannelOpenError::CallFailedcarrying the wireCallError; branch onestablishment_reason()forchannel:open_failed's reason (ADR-049 §4). A failed open never yields a session (no phantom session, mirroring the no-phantom-channel property). TunnelSessiondoes not implementClone. One session = one channel; aliasing a session would alias its teardown. Multi-channel consumers hold aVec<TunnelSession>(or the assembly layer does).
Consequences
Amendment (2026-09-08, the N-3 datagram take_halves posture, decided before the first external consumer)
Review 001 N-3: a datagram session exposes codec-applied accessors
(send_datagram/recv_datagram) until take_halves, at which point the
caller owns RAW halves whose wire bytes carry the [len: u16 BE]
framing. Documented, but a footgun asymmetry the API window
(this ADR's surface becomes ABI-stable at the first external
consumer) forced a decision on. Decided: option 1 (keep as-is,
carried by docs), with the rationale:
- No external consumer exists (verified 2026-09-08; no crate in the workspace depends on alktunnels); the window is open, so a breaking reshape was available, but nothing yet demonstrates a need for a different shape.
- The framing asymmetry is ADR-003's spec, not drift. The
datagram data plane is mandatory-framing by substrate
discrimination; "raw halves of a datagram session carry the
frames" is the same statement as "the codec lives at the substrate
boundary."
take_halvesyielding raw halves keeps ONE semantic across substrates (the tunnel's wire bytes, uninterpreted); a per-substratetake_halves(option 3) diverges semantics by substrate (the worse confusion at the type level even as it removes the byte-level footgun. pump_againstneeds the framed adapter, not framed halves. The adjacent seam (N-11, resolved in-review with the doc note): a UDP session pumped against an accepted local half requires the FRAMED adapter on the accepted side, namely thelocalfeature'sUdpHalf(or an assembly equivalent), not a raw socket. What composes there is the adapter AT the boundary, not a session-side wrapper pair; atake_framed_halvesconvenience would not feed this use.- The convenience is additive and can land later. Option 2's
take_framed_halvesbreaks nothing when added after the first consumer (unlike atake_halvesreshape), so its timing is decoupled from this window, so deferring it costs nothing now, and the first real consumer's reach defines its shape (aDatagramReader-wrapped pair has an open design question: anAsyncReadface re-collides with the F-2 empty-datagram/EOF ambiguity the codec exists to prevent: the codec-side framing wants accessor-shaped surface, which the session already has). - The docs carry the contract.
take_halves's doc states the framing (landed with the implementation); consumer.md's datagram variant section now states the posture explicitly.
Consequence: datagram callers wanting codec-applied I/O after
taking ownership should keep the session (drive send_datagram/
recv_datagram; the session IS the owner) or compose UdpHalf at
their own boundary; take_halves is the raw-wire escape hatch and
stays documented as such. Revisit only on a demonstrated consumer
need (file an OQ at that point).
Consequences (original)
- Assembly layers cannot leak adopted channels by forgetting to reap; the type is the discipline (the W3 gap closes structurally).
- The reverse-flow initiator gets the same session shape as the
forward consumer: the reverse POC's hub-side
ReverseTunnelis the seed; the spec generalizes it so-Land-Rconsumers share one API (role-follows-resource, OQ-TN-03). - Observability for free:
join's copy counts surface the data-plane volumes without extra plumbing. - Serving-side parity is upstream's: the worker's pump handler is already wrapper-managed (R-02); the session only fixes the adopter asymmetry.
Drop-based teardown is best-effort (abort + sync reap, no await): the documented contract; graceful flows calljoinorcloseexplicitly.
References
- Reverse POC:
docs/research/reverse-poc-summary.md§W3 (the finding), §W4 (half-close validation) - OQ-TN-03, OQ-TN-04, OQ-TN-09 (promoted)
- alkcall ADR-049 + review 007 R-02 (wrapper-managed serving side),
ADR-050 (
pump_bidi+ copy counts), ADR-047 §5 (allocation; the initiator adopts) - AGENTS.md convention 10 (limits inherited; the session adds no second bookkeeping layer; it owns exactly one channel's lifecycle)