--- status: draft last_updated: 2026-09-07 --- # alktunnels — Consumer Half The consumer half: `TunnelSession` — the typed client for tunnel channels. The consumer is whoever wants the bytes; it opens tunnel channels toward the producer (forward path), or per local accept toward a connect-side serving producer (the reverse flow). Both are the same session type — role follows the resource, not the connection (OQ-TN-03). Design decisions: [ADR-005](decisions/005-consumer-session-owns-teardown.md) (session owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md) (codec); this document is the normative WHAT. ## Opening a Tunnel ### Forward path (`-L`/direct — the consumer dials the producer's transport) ```rust let session = TunnelSession::open(&channel_client, params).await?; ``` - `params`: `{resource, substrate}` (ADR-001) — the produced resource + substrate discriminator. - Flow: `ChannelClient::open_channel("channels/tunnel/sub", params, "alk/tunnel")` → adopt the returned channel ID → split the channel `BiStream` → present the substrate-shaped data plane. - Errors: a failed open resolves a typed error — `ChannelOpenError::CallFailed` carrying the wire `CallError`; branch on `establishment_reason()` for `channel:open_failed`'s reason (dial_failed / unknown_resource / resource_shortage / handler_error / timeout), or the pre-establishment codes (`FORBIDDEN`, `channel:too_many_channels`). **A failed open never yields a session** — no phantom session, mirroring the no-phantom-channel property (POC-verified). ### Reverse path (`-R` — the consumer initiates toward a connect-side serving producer) The hub/accept-side shape (the reverse POC's `ReverseTunnel`, the seed of this path). Construction differs from the forward path only in how the channel is obtained — the session type and teardown API are identical: ```rust // Per local accept (assembly-owned listener): let channel_id = open_reverse_channel(&hub_call, ¶ms, auth_token).await?; let session = TunnelSession::adopt(hub_manager, channel_id, TUNNEL_ALPN).await?; let pump_handle = session.pump_against(accepted_halves).await; // or drive the halves directly ``` - `open_reverse_channel` — call the worker's open op on channel 0 (`call_with_payload`; optional `auth_token`), extract the worker-allocated `channel_id`. Caller identity rides the transport by default (alkcall 0.7.0 CF-005 (b): `Connection::set_identity` before dialing); the payload `auth_token` is the optional hub-forwarding path (precedence: token > `ServingConfig.identity` > transport). - `TunnelSession::adopt(manager, channel_id, alpn)` — adopt the worker-allocated ID (connection-owner rule, ADR-047 §5 — the SERVING side allocates; the initiator adopts; early-arrival parking covers the adoption race), install the session's data plane from the adopted halves. `adopt` takes the same typed-error surface as `open` (an adopt failure is `AdoptFailed`-class, not an establishment error). - `pump_against(accepted_halves)` — spawn `pump_bidi` against the accepted local connection and hold the pump handle (ADR-005). The session type and its teardown API are identical on both paths — one API, `-L` and `-R` alike. ## The Data Plane (substrate-shaped) - **Stream variant (`tcp`, `unix`):** `stream_halves()` — raw `AsyncRead`/`AsyncWrite` halves; the halves ARE the tunnel (raw pass-through, ADR-003). For owned access (spawning local pumps), `take_halves()` consumes the session's halves. - **Datagram variant (`udp`):** `send_datagram(&[u8])` / `recv_datagram() -> Option` over the mandatory `[len: u16 BE]` codec (ADR-003). `recv_datagram` returns `Some(bytes)` per datagram — possibly empty (`len=0` is a legal empty datagram) — and `None` only on stream EOF (the channels-level sentinel; the codec never collides with it, F-2). A >65535-byte send is rejected at frame time (`Oversize`). ## Lifecycle and Teardown (the point of ADR-005) One session = one channel = one tunnel. The session owns: - **The adopted channel entry** — nothing upstream awaits the adopter's pump (the W3 gap); the session closes it structurally. - **The session-owned pump handle** (when the session spawned one — the reverse path's `pump_against`). Teardown API: - `close(self)` — abort the pump (if session-owned), tear down the adopted channel (`ChannelManager::teardown_channel`), consume the session. The ungraceful path. - `join(self)` — await pump completion (both directions finished; half-close semantics fall out of `pump_bidi` — one direction EOFs, the opposite sink shuts down, the other keeps pumping until its own EOF; W4-validated), THEN reap the adopted channel. Returns the `(u64, u64)` copy counts for observability. The graceful path. **Pump-less sessions** (after `take_halves`, or a datagram session the caller drives directly): there is no session-owned pump — `join` completes immediately (nothing to await) and reaps only; the copy counts are `(0, 0)`. - `Drop` — aborts the pump and sync-reaps the channel (teardown semantics of `close`, best-effort — `Drop` cannot await). Dropping without close/join never leaks the channel entry. - **`TunnelSession` does not implement `Clone`.** Aliasing a session would alias its teardown; multi-channel consumers hold a `Vec` (or the assembly layer does). ## Out-of-band close A peer-initiated `channel/close` (the generic channel op on the producer's serving registry) tears the SERVING side down; the consumer's session still owns its adopted entry — `close`/`join` reaps it. The reverse POC pinned this split (the close ran on the worker; the hub reaped its own entry, W3). EOF propagation is symmetric: either side's teardown reaches the other as the channels-level sentinel. ## Both-Sides Sanity The consumer and producer halves compose on one connection in both roles (alkcall ADR-022 §2): a worker dials the hub (connect side), serves its open ops (serving side), and its own outbound calls still resolve while serving (reverse POC: worker_outbound_calls_still_ resolve_while_serving). The consumer half adds nothing to the connection's serving posture — a pure consumer (`from_connection`) never dispatches inbound requests; a reverse-flow initiator needs no serving registry of its own. ## Open Questions - **OQ-TN-12**: hub re-produce composition (deferred — a hub proxy re-exporting a consumed resource would add a `register_openable_with_establisher` whose establisher opens a consumer channel; no concrete consumer yet) - **OQ-TN-13**: UDP truncation (resolved — fail-loud, ADR-003) ## References - [producer.md](producer.md) (the serving side's shapes), [wire.md](wire.md) (the data plane) - Reverse POC `docs/research/reverse-poc-summary.md` (the seed shape: `ReverseTunnel` open_and_pump / close / join_and_reap; W3, W4) - alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050 (`pump_bidi`), ledger CF-005/CF-006 (identity on the reverse path)