Files
alktunnels/tasks/tunnels/local-socket-halves.md
glm-5.3-flash 0ceecf582b docs: remove em dashes from repo prose and forbid them going forward
- Rewrote the remaining em dashes outside docs/reviews/ (preserved as
  historical record, matching the alktty precedent): tests/ (313),
  fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in
  fences), docs/research + sdd_process + plans (525 plus 25 in
  fences), docs/architecture/overview.md fence labels (13), README,
  CHANGELOG, AGENTS.md, fuzz/README.md (176), and the
  .opencode/agents prompts (110)
- Rewrote fenced content too: ASCII tree and module-map label
  separators, Rust snippet comments, and template placeholders; wire
  data, JSON/BAST examples, and transcripts survive verbatim
- Rewrote 6 assert/expect message literals in tests/ and 2 format
  strings in fuzz/shared/src; no test data changed
- Added Project Convention 18: no em dashes in prose (doc comments,
  markdown including docs/ and tasks/, agent prompts, commit
  messages); fence content rule, en-dash clause rule, and the
  no-spaced-hyphen prohibition follow alktty's convention 15

Follows 6b20924 (src doc comments) and cd0afe0 (docs/architecture).

Verification: cargo test (141 pass); cargo clippy --all-targets -D
warnings; cargo fmt --check; cargo doc --no-deps; wasm check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
the repo returns matches only in docs/reviews/
2026-11-03 14:12:46 +00:00

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
tunnels/producer-open-op
tunnels/wire-codec
moderate medium component implementation
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

// 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 per ADR-003; truncation fails 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; it is 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's UdpHalfRead/UdpHalfWrite) with the truncation fix: poll_recv into a too-small caller buffer must fail loud (OQ-TN-13's resolved posture, i.e. ADR-003). The codec wraps at this boundary; the pump never sees UDP specifics.
  • Unix: dial_unix ships (OQ-TN-14 resolved, with the same halves shape as TCP). Stdio does NOT, because 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<EstablishmentError> path).
  • TargetHandle re-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/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 (a 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, so 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, while 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, so 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")]) with the 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).