- src/local/mod.rs (feature = "local" -> tokio/net): dial_tcp (5s timeout), connect_udp (ephemeral bind + connect + the FRAMED UdpHalf adapter), dial_unix (OQ-TN-14 in v1), bind_tcp + TcpListenerHalves::accept_loop (the listen shape's assembly half), local_dial() (one DialFn covering all three substrates). - UdpHalf truncation fails loud (OQ-TN-13): poll_read recvs into a scratch buffer first and size-checks against the caller's buffer — an over-size datagram is io::ErrorKind::InvalidData, never a silent truncation (tokio's poll_recv-into-ReadBuf would truncate). - No socket type appears outside src/local/ (convention 16); the default crate stays wasm-clean; stdio bridging is NOT here (alktty owns process stdio). - Tests (tests/local_halves.rs, 7, feature-gated): TCP/UDP/unix dial round-trips through the real establisher path, the 1400-byte MTU datagram, the truncation fail-loud probe, dial-refusal -> dial_failed, listen producer over a real TCP listener end-to-end. Verified: cargo test green (default + --all-features, 44 + 7), clippy -D warnings (default + all-features + wasm32), fmt clean, cargo check --target wasm32-unknown-unknown passes.
6.6 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| tunnels/local-socket-halves | local feature — real socket halves functions (TCP dial, UDP connect, unix) | completed |
|
moderate | medium | component | implementation |
|
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
// src/local/mod.rs (feature = "local"; non-wasm by design)
pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError>;
// TcpStream::connect (5s timeout per the POCs) → into_split → boxed halves
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError>;
// 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<TargetHandle, TunnelEstablishError>;
// 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<Self, ...>;
pub fn accept_fn(&self, queue: &AcceptQueue) -> impl Future; // the accept loop feeding the queue
}
pub struct UdpAssociateHalves { sock: Arc<UdpSocket> } // the POC's associate shape
- UDP adapter (
UdpHalf): port from the forward POC (params.rs'sUdpHalfRead/UdpHalfWrite) with the truncation fix:poll_recvinto 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_unixships (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
TunnelEstablishErrorvariants (the POC'sInto<EstablishmentError>path). TargetHandlere-used from producer.rs (the halves type lives in producer.rs; local/ depends on it, never vice versa).
Feature wiring
[features]
default = []
local = ["tokio/net"]
tokio/netis the only dep addition (no new external crates; the POCs provedrt+sync+io-util+macros+time+netcovers everything).- The default crate stays wasm-clean:
local-gated code must not be importable fromproducer.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 localgreen;cargo test(default) unaffectedcargo test --all-featuresgreen (AGENTS.md convention 14)- wasm32 check on the DEFAULT crate passes;
localis 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 sketchedbind/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.UdpHalftruncation probe (OQ-TN-13):poll_readrecvs into a 64KiB scratch buffer first, then size-checks against the caller's buffer — a >buffer datagram isio::ErrorKind::InvalidData, never a silent truncation. (tokio'spoll_recv-into-ReadBuf truncates silently, which is exactly the invariant F-2/OQ-TN-13 forbids.) WouldBlock pollspoll_recv_readyfor waker registration.- The task sketch's
TcpListenerHalves::accept_fn(&self, queue)becameaccept_loop(&self, queue)(an owned async loop to spawn) — theaccept_fnborrow-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_cloneBEFOREfrom_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_againstthere 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).