# ADR-014: The Idle-Timeout Posture — Teardown-Governed Phases, No Protocol-Layer Idle Bound ## Status Accepted (2026-09-24, review 002 C-2's decision task — the deliverable is this decision, not code; it lands before the batch-2 `local`/ `composed` doors exist, whose tasks may depend on it — ADR-008/ ADR-009's provider posture) ## Context Review 002's C-2 (major): the pre-command RFC handshake and the association have no timeout surface. After the channel opens, the pump handler's spawned task parks on the RFC handshake (`Socks5ServerProtocol::accept_no_auth` → `read_command`), and the datagram stage's loop exits only on EOF/error/data — no timer exists anywhere in the protocol layer. The alkcall 10s establishment bound (`register_openable_with_establisher`'s default, `timeout: None`, alkcall ADR-049 §2) governs only the open-op round trip, not the in-stream conversation. A silent peer therefore holds — per idle channel — one spawned task and one channel entry (bounded buffers, ADR-040) indefinitely. BIND's quiet wait got an explicit decision (ADR-007: teardown-governed — "FTP-length waits stay legal"); the handshake and the association lifetime got "inherited from the channel" — implicit, never decided. The mitigations are real: the 256-channel per-connection cap (ADR-040) and the per-identity cap (ADR-041) bound the count, per-session memory is bounded, and teardown fires on channel close — nothing leaks, nothing grows. The aggregate remains: any identity with `socks5:open` can hold 256 parked tasks + 256 channel entries indefinitely *without sending a byte*. And batch 2's providers (`local`, `composed`, ADR-008) are about to route *vanilla clients* through these same phases via the front doors, where the peer is a kernel socket often outside the channel-cap regime entirely — a slowloris-shaped hold (open, greet, stop) becomes the assembly layer's problem, and the protocol layer is where a timeout seam would live. The review offered two options: (1) idle timeout is the substrate's/ assembly's job — the protocol layer never times out, the DoS posture recorded explicitly; (2) a producer-side idle bound — an optional per-session inactivity timeout at the pump level (config on `Socks5ProducerBuilder`), racing the handshake/stage reads against a sleep, timing out as a clean EOF-shaped teardown. Two facts sharpen the choice beyond the review's sketch: - **The pre-command hold is not a distinct DoS shape.** Every post-reply phase legally parks indefinitely on a silent peer: an idle CONNECT tunnel (`pump_bidi` parked on a quiet target), a quiet-but-alive association (association lifetime = channel lifetime, wire.md §UDP ASSOCIATE), BIND's quiet wait (ADR-007). The resource cost is identical to the pre-command hold — one parked task + one channel entry. A handshake-only idle bound protects only the pre-auth sliver while the dominant hold (the post-reply idle) stays contractually open — incoherent; a bound on both is a full inactivity timeout, which contradicts the landed contracts (association lifetime = channel lifetime; FTP-length BIND waits stay legal, ADR-007). The unbounded-time aggregate is therefore a property of every idle session the protocol serves — bounded by the cap regime, not by a timer. - **The two sides differ in composability.** The consumer sessions are plain futures at the caller — a caller wanting an idle bound wraps the constructor (or the session's reads) in `tokio::time::timeout` itself; no decision is needed consumer-side. The producer's parked task lives inside the alkcall-spawned open handler — not caller-composable; only the producer-side question needed an ADR. Also recorded (an upstream-fidelity correction to the review's sketch): upstream's connect-timeout path maps its *dial* timeout to `ReplyError::ConnectionTimeout` → reply code 0x06 (TTL expired), not 0x01 as the sketch suggested — the mapping table lives in `src/rfc1928/mod.rs` (carried from `/workspace/fast-socks5` `util/stream.rs`). Relevant only if option 2 is ever built. ## Decision **Option 1 — the protocol layer never times out.** Every phase of the RFC 1928 conversation is teardown-governed: the pre-command handshake, the BIND quiet wait, and the association lifetime end on the channels-level sentinels (EOF / garbage / error), never on a protocol-layer inactivity timer. The posture is substrate-uniform — the same for a channel peer and for a kernel socket a door relays. - **No inactivity bound exists in `src/`.** The handshake and stage reads park like any other phase: no idle timer, no keepalive, no in-band expiry vocabulary. "No in-band control vocabulary" (wire.md) holds — this decision adds none. - **The DoS posture is explicit and layered** (the ADR-010 pattern: ACL → per-connection cap → per-identity cap → door/dial policy): - **Channels shapes**: parked-task count is bounded by the per-connection cap (ADR-040) and the per-identity cap (ADR-041); per-session memory is bounded; teardown fires on channel close. Time is unbounded by design — accepted, the same posture every idle-but-open session already has. - **Doors** (the batch-2 obligation this ADR records before they exist): an accepted TCP connection is *not a channel* — the cap regime does not bound a door's accepted-socket hold. The door's accept path owns its defense; the batch-2 door author inherits these obligations knowingly (the wire.md §UDP ASSOCIATE "inherits this bound knowingly" pattern): 1. an **accept-stage idle bound** — an accepted connection that sends no first byte within a configured window is closed (door-observable, protocol-agnostic; this is the slowloris sliver the protocol layer will not bound); 2. **per-credential / per-peer session caps** — the RFC 1929 cred-bearing door's credential→identity config table (ADR-005) is the natural knob; 3. **accept-queue / relay-task bounds** on the door's own accept loop. The same obligations apply to every assembly shape that accepts a kernel socket in front of the protocol: the consumer front doors, the producer-side local backend's accept loop, and a composed bind-first listener's front. - The protocol layer neither provides nor re-derives these knobs (limits are inherited, not redefined — AGENTS.md convention 10); no timeout helper ships speculatively. - **Relationship to ADR-007 (the BIND quiet wait): same regime.** ADR-007 decided the quiet wait's ends as part of BIND's shape — the `select!` needed exactly two exits, and "FTP-length waits stay legal" was the explicit call. ADR-014 records the *same* resolution for the phases ADR-007 left implicit. The principle is one and the same: the protocol layer cannot distinguish malicious silence from a slow legitimate peer (an FTP-length quiet wait, an idle tunnel, a quiet association), so lifetime is teardown-governed and timeout policy belongs to the layer that can see the deployment. The difference is only decidedness: ADR-007 decided one phase's ends by designing BIND; ADR-014 decides the rest by recording the inherited resolution as the decision. No new regime is introduced. - **Revisitable — not a one-way door.** A producer-side idle seam is additive if a deployment ever proves this posture insufficient: an optional per-session inactivity bound (builder config defaulting to `None` = this posture), enforced by racing the handshake/stage reads against a sleep timer (`tokio::time` is already in the wasm-clean dependency subset), timing out as a clean EOF-shaped teardown. No wire change, no API break, no spec contradiction — the spec lines record the *default* posture, and an opt-in bound contradicts nothing. The RFC has no timeout reply code; a pre-reply timeout, if the seam is ever built, would surface as the EOF shape (or the closest mapped code — upstream's dial-timeout precedent is 0x06, see Context). Revisit trigger: a deployment that cannot enforce the bound at its doors or assembly — a peer class outside both the cap regime and any door policy — with measured evidence the aggregate hold is unacceptable. ## Consequences - No code changes this round; the posture is recorded. The spec lines (wire.md §UDP ASSOCIATE's association-lifetime bullet; producer.md §The Open Handler) carry the decided posture — the implicit residue is gone. - The batch-2 door tasks inherit the recorded accept-path obligations; this ADR is the timeout-posture gate those tasks may depend on (it precedes any `local`/`composed` door work). - Review 002 C-2 closes as decided. The adjacent S-1 residual (the datagram stage's hot-`Ok(None)` tolerance — unbounded *rate*, not unbounded *time*) stays its own accepted-uncovered posture; this decision does not touch it. - Consumer-side timeout policy stays where it always was: caller- composable around the session constructors and reads (no new surface, nothing decided away). ## References - docs/reviews/002-protocol-core-implementation-review.md §C-2 (the finding), §S-1 (the adjacent rate posture), §N-2 (the "inherits this bound knowingly" pattern this ADR extends) - [ADR-007](007-bind-in-base.md) (the quiet wait's decision — the contrast case), [ADR-010](010-channel-capacity.md) (the layered-defense + config-not-protocol pattern this follows), [ADR-008](008-provider-defaults.md) / [ADR-009](009-feature-gates.md) (the doors the posture precedes), [ADR-005](005-auth-split-by-door.md) (the cred-bearing door's config table), [ADR-003](003-udp-handle-swap.md) (association lifetime = channel lifetime) - alkcall ADR-040/041 (the caps), ADR-049 §2 (the establishment bound's scope), ADR-050 (`pump_bidi`) - [wire.md](../wire.md) §UDP ASSOCIATE, [producer.md](../producer.md) §The Open Handler (the spec lines updated) - POC findings (the stage's measured shapes — the "sufficient for relay postures" lineage) - `/workspace/fast-socks5` (`src/util/stream.rs` — the dial-timeout mapping precedent, 0x06)