--- id: tunnels/local-socket-halves name: "local feature — real socket halves functions (TCP dial, UDP connect, unix)" status: completed depends_on: [tunnels/producer-open-op, tunnels/wire-codec] scope: moderate risk: medium impact: component level: implementation tags: [local, backend, sockets, feature-gate] --- ## Description Add the `local` feature module (`src/local/`) per overview.md §Feature Gates + ADR-004: the real-socket halves functions the assembly layer injects as `DialFn`/`AcceptFn` closures. The POCs' substrate adapters are the reference (they ran all of this); the task is packaging them into the feature-gated module with the crate's conventions. ### Surface ```rust // src/local/mod.rs (feature = "local"; non-wasm by design) pub async fn dial_tcp(target: &str) -> Result; // TcpStream::connect (5s timeout per the POCs) → into_split → boxed halves pub async fn connect_udp(target: &str) -> Result; // UdpSocket::bind(("0.0.0.0", 0)) → connect(target) → the FRAMED adapter // (UdpHalf + codec — ADR-003; truncation fail-loud per OQ-TN-13) pub async fn dial_unix(path: &str) -> Result; // UnixStream::connect → into_split (OQ-TN-14: ships with local v1 — cheap, // same halves shape as TCP) pub struct TcpListenerHalves { listener: tokio::net::TcpListener } impl TcpListenerHalves { pub async fn bind(addr: &str) -> Result; pub fn accept_fn(&self, queue: &AcceptQueue) -> impl Future; // the accept loop feeding the queue } pub struct UdpAssociateHalves { sock: Arc } // the POC's associate shape ``` - **UDP adapter (`UdpHalf`)**: port from the forward POC (`params.rs`'s `UdpHalfRead`/`UdpHalfWrite`) with the truncation fix: `poll_recv` into a too-small caller buffer must fail loud (OQ-TN-13's resolved posture — ADR-003). The codec wraps at this boundary; the pump never sees UDP specifics. - **Unix**: `dial_unix` ships (OQ-TN-14 resolved — same halves shape as TCP). **Stdio does NOT** — a spawned process's stdio is alktty's pipe mode (exit codes + signals are tty semantics, not tunnel semantics); remote command execution composes via alktty, not here. - **Error mapping** to `TunnelEstablishError` variants (the POC's `Into` path). - `TargetHandle` re-used from producer.rs (the halves type lives in producer.rs; local/ depends on it, never vice versa). ### Feature wiring ```toml [features] default = [] local = ["tokio/net"] ``` - `tokio/net` is the only dep addition (no new external crates; the POCs proved `rt+sync+io-util+macros+time+net` covers everything). - The default crate stays wasm-clean: `local`-gated code must not be importable from `producer.rs`/`consumer.rs` (convention 16 — backend modules are never imported from the shared/producer/consumer modules). - Unix is IN v1 (OQ-TN-14's lean-yes posture; same halves shape as TCP); stdio bridging is NOT (different lifecycle — deferred). ### Tests Behind the feature: TCP dial round-trip through the real establisher path, UDP associate + datagram round-trip with the framed adapter, unix dial round-trip, truncation fail-loud (a short recv surfaces as an error), the 1400-byte MTU datagram through bounded buffers (the POC's sizing test). ## Acceptance Criteria - [ ] `cargo test --features local` green; `cargo test` (default) unaffected - [ ] `cargo test --all-features` green (AGENTS.md convention 14) - [ ] wasm32 check on the DEFAULT crate passes; `local` is non-wasm by design (documented) - [ ] Truncation fails loud (the OQ-TN-13 test) - [ ] No socket type appears outside `src/local/` - [ ] Clippy/fmt clean ## References - docs/architecture/overview.md §Feature Gates, §Dependencies - docs/architecture/decisions/004-no-backend-trait.md (the injection point), 003 (the framed adapter) - POC reference: `/workspace/alktunnels-udp-poc/src/producer.rs` (`UdpHalf`, the dial paths), `params.rs` (the UDP adapter) ## Notes > Agent fills during implementation. - **`bind_tcp` + `TcpListenerHalves::accept_loop(queue)`** complete the listen side (the task surface sketched `bind`/`accept_fn`): the accept loop is a spawned task feeding an [`AcceptQueue`] — the protocol-side queue contract from producer-listen. The assembly layer aborts the task on teardown; accept errors end the loop. - **`UdpHalf` truncation probe** (OQ-TN-13): `poll_read` recvs into a 64KiB scratch buffer first, then size-checks against the caller's buffer — a >buffer datagram is `io::ErrorKind::InvalidData`, never a silent truncation. (`tokio`'s `poll_recv`-into-ReadBuf truncates silently, which is exactly the invariant F-2/OQ-TN-13 forbids.) WouldBlock polls `poll_recv_ready` for waker registration. - **The task sketch's `TcpListenerHalves::accept_fn(&self, queue)` became `accept_loop(&self, queue)`** (an owned async loop to spawn) — the `accept_fn` borrow-shape fought the spawn model; the loop returning on listener failure is the assembly-abort contract. - **Test plumbing note** (tests): the listen-over-real-socket test drives the client socket via std `try_clone` BEFORE `from_std` (O_NONBLOCK is shared across dup — both ends go async); the session uses borrowed halves (the producer's wrapper-managed pump already pumps channel <-> accepted handle; `pump_against` there would loop the same socket back into itself). ## Summary > Agent fills this on completion. Implemented the `local` feature (src/local/mod.rs, feature = `["tokio/net"]`): `dial_tcp` (5s timeout), `connect_udp` (ephemeral bind + connect + the FRAMED `UdpHalf` adapter — truncation fails loud), `dial_unix` (OQ-TN-14 in v1), `bind_tcp`/`TcpListenerHalves:: accept_loop` (the listen shape's assembly half), and `local_dial()` (the one `DialFn` covering all three substrates). No socket type appears outside `src/local/` (convention 16: the module is never imported from producer/consumer/shared; `TargetHandle` re-used from producer.rs). Stdio bridging is NOT here (alktty's pipe mode owns process stdio). Tests: `tests/local_halves.rs` (7, `#![cfg(feature = "local")]`) — TCP dial round-trip through the real establisher path, UDP framed round-trip, the 1400-byte MTU datagram, unix dial round-trip, the OQ-TN-13 truncation fail-loud probe (60k datagram vs 16KiB read → InvalidData), dial-refusal → `dial_failed`, and the listen producer over a real TCP listener end-to-end. Verified: `cargo test` (default) and `cargo test --all-features` green (44 + 7), clippy `-D warnings` clean (default + all-features + wasm32), fmt clean, wasm32 check passes (default crate stays wasm-clean; `local` is non-wasm by design).