Files
alksocks/docs/architecture/decisions/014-idle-timeout-posture.md
T
glm-5.3-flash 986bd13ce7 adr-014: the idle-timeout posture — teardown-governed phases, no protocol-layer idle bound
- 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
2026-09-24 19:16:30 +00:00

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_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 (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)