- ADR-014 accepted (review 002 C-2): the pre-command handshake, the BIND quiet wait, and the association lifetime are all teardown-governed; the protocol layer never times out (option 1) - the BIND relationship recorded as same regime (ADR-007 decided one phase's ends by designing BIND; ADR-014 records the inherited resolution — one principle, not two) - the doors' accept-path obligations recorded pre-batch-2 (accept-stage idle bound, per-credential/per-peer caps, accept-queue bounds); revisitable additively (an optional producer-side idle seam would be config, not wire) - spec lines carry the decided posture: wire.md (association-lifetime bullet, no-in-band-control bullet, preamble, frontmatter); producer.md (the open handler, the local-backend section, decision table, frontmatter); overview.md ADR index - one upstream-fidelity correction: upstream's dial timeout maps to 0x06 (TTL expired), not 0x01 as the review sketch suggested - open-questions.md unchanged (C-2 was never tracked there) Verification: cargo test green (357 passed), clippy -D warnings clean, fmt clean, cargo doc clean, wasm check + clippy (wasm32) clean
10 KiB
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_bidiparked 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::timeoutitself; 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):
- 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);
- per-credential / per-peer session caps — the RFC 1929 cred-bearing door's credential→identity config table (ADR-005) is the natural knob;
- 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::timeis 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/composeddoor 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 (the quiet wait's decision — the contrast case), ADR-010 (the layered-defense + config-not-protocol pattern this follows), ADR-008 / ADR-009 (the doors the posture precedes), ADR-005 (the cred-bearing door's config table), ADR-003 (association lifetime = channel lifetime)
- alkcall ADR-040/041 (the caps), ADR-049 §2 (the establishment
bound's scope), ADR-050 (
pump_bidi) - wire.md §UDP ASSOCIATE, 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)