--- id: implementation/composed-egress name: "Composed: the direct-op egress (lazy destination→tunnel table)" status: completed depends_on: [] scope: moderate risk: medium impact: component level: implementation --- ## Description The `composed` feature's egress provider (ADR-008's composed variants): the ASSOCIATE egress that dials each destination through an alktunnels 0.2.0 direct-op channel (`TunnelSession::open_direct`, `Substrate::Udp`): per-target ACL for free (each destination rides its own `tunnel:direct`-governed channel), the lazy destination→tunnel table LRU-evicted at a cap (POC #2's `ComposedEgress` shape). Deliverables: 1. **`Cargo.toml`**: `composed = ["dep:alktunnels"]` (the only feature that adds the alktunnels dependency, ADR-009); alktunnels pinned `"0.2.0"` (the sibling pin posture; convention 11's deliberate-bump rule applies). Verify the composed graph stays wasm-clean (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface, and the wasm check with `--features composed` is a hard gate). 2. **`src/composed/mod.rs`** (feature `composed`): the composed providers, named per producer.md §Wiring: - `direct_egress(...)` → `EgressFactory`: the POC #2 `ComposedEgress` shape ported to the landed seam types: a lazy destination→tunnel table: each new destination opens one alktunnels UDP-tunnel channel (`open_direct`, `Substrate::Udp`), a relay task per tunnel drains inbound datagrams into a shared queue, the write half stays in the table (LRU-capped; dropping a write half EOFs the tunnel producer-side; the session's Drop reaps the channel entry (alktunnels ADR-005). - **The Sync-bound wrinkle (the batch-1 review's seam contract vs alktunnels' surface)**: the landed `TargetHandle`/`BindTarget Handle` halves are `Box`, but alktunnels' `TakenHalves` yields `Send + Unpin` only. The composed providers need an **owner-wrapper inside the handle payloads** (egress.rs's documented posture: "keep-alive state a substrate needs rides inside the halves; store a provider-side owner wrapper"). For the egress the wrapper is the table entry's session (the egress owns it, not a handle payload); for the composed *accept* (the sibling task) the wrapper is load-bearing, so design it here and share it. - **The readiness contract** (load-bearing): `next_reply` parks on the shared inbound queue (`recv().await`, Pending until a relay task delivers); a closed queue (all relays ended) returns `Err`, never a hot `Ok(None)` (the POC's closed-queue arm; the stage's zero-yield defense, review 002 R-1, is the backstop, not the design). Replies carry *inner* bytes (`addr + data`; the reply address is the entry's recorded destination; framing-once). - **Per-datagram `send` is awaited inline** (the stage interleaves sends with reply drains), so a slow egress applies backpressure to the association, not to other channels (wire.md's note: "the batch-2 composed-egress author inherits this bound knowingly"). The table's open cost rides the send path (lazy open on first datagram; the POC's measured ~0.8 ms). - The resource/target mapping: `open_direct` takes a `SubstrateAddr`: the destination's `TargetAddr` renders to it (IP forms direct; domain form passes through unresolved, producer-side resolution, the ssh `-D` semantic; ADR-012). Constructor config carries the LRU cap (the 256-channel default cap bounds fan-out, ADR-010's posture; the cap is config, not wire). - Module doc: names the ADRs (008/009), the per-target-ACL benefit, the readiness contract, the framing-once invariant, the backpressure bound. 3. **Tests** (`tests/composed-egress.rs`): the POC #2 shape end to end over channels: a tunnel-producing peer (the alktunnels registry shape, `register_tunnel_direct_openable`), the composed egress against it, datagram round-trips through the tunnel (multiple destinations → multiple tunnels; the LRU eviction at cap pinned by test); the readiness contract re-pinned (park-then- datagram, no spin); the closed-queue → association-end shape; framing-once (no double prefix, the POC #2 bug class). ## Acceptance Criteria - [x] `cargo test` passes; `cargo test --all-features` passes - [x] `cargo check --features composed --target wasm32-unknown- unknown` clean; the composed graph is wasm-clean (hard gate) - [x] `cargo clippy --all-targets --all-features -- -D warnings` clean; `cargo fmt --check` clean - [x] `alktunnels` pinned `"0.2.0"`; the dep rides `composed` alone (default graph unchanged, per the `cargo tree` spot-check) - [x] The lazy table + LRU eviction land per the POC shape (pinned by test) - [x] The readiness contract honored (real async park; closed queue → `Err`, never a hot `Ok(None)`), pinned by test - [x] Framing-once (replies are inner bytes; no double prefix), pinned by test - [x] Per-datagram send backpressure documented on the constructor ## References - docs/architecture/decisions/008-provider-defaults.md (the composed variants), 009-feature-gates.md, 003 (the handle swap), 010 (the cap), 012 (resolution) - docs/architecture/producer.md §Wiring (`composed::direct_egress`), §The Three Seams - POC reference: `/workspace/alksocks-udp-poc/src/egress.rs` (`ComposedEgress`: the table, the relay tasks, the closed-queue posture) - Landed surfaces: `src/egress.rs` (the seam contracts + the owner-wrapper posture), alktunnels 0.2.0 (`TunnelSession::open_direct`, `TakenHalves`, `register_tunnel_direct_openable`) ## Notes > Landed as `src/composed/mod.rs` (feature `composed`) + > `tests/composed-egress.rs`, with a `wire_tunnel_direct` topology > added to `tests/harness.rs` (the consumer-side topology with > alktunnels' direct op on the producer's serving registry; the > composed egress's `ChannelClient` is the direct-op caller). > > Two deviations from the POC shape, both deliberate: > > - **The closed-queue condition is a live-relay count, not a queue > close.** The egress holds its own factory `Sender`, so the shared > `mpsc` queue can never fully disconnect: `recv()` would never > return `None` and the POC's closed-queue arm was unreachable > through this surface. Relays instead emit an `Ended` marker on > their tunnel's EOF; `next_reply` counts them (`live_relays`) and > returns the association-end `Err` when the count reaches zero and > the queue is drained. The readiness contract is unchanged (the > wait is a real async park; a drained-but-live relay set parks). > - **The tunnel codec is alktunnels' `wire::DatagramReader` > (`[len]` + payload), not this crate's RFC-framed one**; the POC > fed the tunnel read half into the SOCKS wire reader (a bug class > the round-trip test caught during bring-up); the composed egress > frames the tunnel wire once with the tunnel codec and the RFC > wire once downstream (framing-once holds at both boundaries). > > The Sync-bound wrinkle resolves as designed: the shared > `SessionOwner` wrapper (half + session in one owner) is exported > for the composed accept (the sibling task); the egress itself owns > the session in the table entry (the POC's shape) and the relay task > owns the bare read half. > > `cargo tree --edges normal` spot-check: `alktunnels` rides > `composed` alone (absent from the default normal graph; the > dev-dep copy is dev-only). ## Summary > The composed egress landed: `composed = ["dep:alktunnels"]` > (alktunnels pinned "0.2.0"; dep rides `composed` alone), > `src/composed/mod.rs` (`direct_egress`, the `EgressFactory`), the > lazy destination→tunnel table with LRU eviction at cap (`DEFAULT_ > MAX_TUNNELS` = 256), per-tunnel relay tasks draining into a shared > inbound queue, `SessionOwner` (the owner wrapper shared with the > composed accept), the readiness contract (real async park; ended > relays → association-end `Err`, never a hot `Ok(None)`), framing- > once, per-target ACL via the direct op, domain pass-through > unresolved (ADR-012), and the backpressure bound documented on the > constructor. `tests/composed-egress.rs` (6 tests over the > `wire_tunnel_direct` harness topology): round-trips per > destination → one tunnel each, table reuse, LRU eviction/re-dial, > the readiness park, the closed-relay-set `Err`, framing-once > through the wire layer, and the domain pass-through. Verification: > `cargo test` (default) + `--all-features` pass; the wasm check with > `--features composed` clean; clippy (`--all-targets --all-features -- -D warnings`) clean; `cargo fmt --check` clean; `cargo doc > --no-deps` clean.