# 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, per 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, per 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)