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
This commit is contained in:
glm-5.3-flash committed 2026-09-24 19:16:30 +00:00
1 parent ff3295422c
commit 986bd13ce7
5 files changed
+309 -28

No files matched your search

@@ -0,0 +1,185 @@
# 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)
+6 -4
View File
@@ -1,9 +1,10 @@
---
status: draft
last_updated: 2026-09-22 (the targeted fork — ADR-013, the module is
`src/rfc1928/`; the vendor extraction amendment — no `net` feature —
2026-09-21; Phase 1 initial draft 2026-09-20 — the OQ-SK ledger
promoted into ADRs 001-012 and open-questions.md)
last_updated: 2026-09-24 (ADR-014 indexed — the idle-timeout posture;
the targeted fork — ADR-013, the module is `src/rfc1928/`; the vendor
extraction amendment — no `net` feature — 2026-09-21; Phase 1 initial
draft 2026-09-20 — the OQ-SK ledger promoted into ADRs 001-012 and
open-questions.md)
---
# alksocks — Overview
@@ -216,6 +217,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [011](decisions/011-inline-vendor.md) | The extraction | the `T`-generic subset copied in-tree; two carried changes (BIND, `new_reply`); v1.0.0 noted (superseded as a posture by ADR-013) |
| [013](decisions/013-targeted-fork.md) | The targeted fork | the extracted region is owned; `src/rfc1928/`; fork point `5b18d4d`; hand-port replaces re-vendor |
| [012](decisions/012-target-policy.md) | Target policy | dialer-refuses; domain targets pass through unresolved; virtual subnets = assembly policy |
| [014](decisions/014-idle-timeout-posture.md) | Idle-timeout posture | every phase teardown-governed, no protocol-layer idle bound; doors own the accept-path defense; revisitable additively |
## Open Questions
+23 -3
View File
@@ -1,6 +1,7 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft)
last_updated: 2026-09-24 (the open handler's phase lifetimes carry the
decided timeout posture — ADR-014; Phase 1 initial draft 2026-09-20)
---
# alksocks — The Producer Half
@@ -45,7 +46,16 @@ behavior, but the wiring requirement is implicit.
## The Open Handler: RFC Conversation + Command Dispatch
The open handler runs the forked typestate over the channel's
`BiStream` (`T`-generic — the POC #1 fit):
`BiStream` (`T`-generic — the POC #1 fit). Every phase is
teardown-governed, never idle-expired (ADR-014): the handler parks on
the handshake / stage reads like any other phase — no protocol-layer
inactivity timer exists (the alkcall 10s establishment bound covers
only the open-op round trip, ADR-049 §2); a silent peer holds the
task + channel entry until the channels-level sentinels arrive, and
the cap regime (ADR-040/041) bounds the aggregate. A deployment that
wants an idle bound enforces it outside the protocol layer (an
accept-path bound at a door; a caller-side timeout around a
substrate-free `serve_socks5_over`):
1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)` —
NoAuth as a no-op (ADR-005: the producer side is identity-only;
@@ -128,6 +138,13 @@ clients reach a locally-served SOCKS5 resource through it. The door
surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is the
consumer door's code shape (see [consumer.md](consumer.md)).
The door's accept path owns the idle defense the protocol layer does
not provide (ADR-014): an accepted connection that sends no first
byte within a configured window is closed door-side (an accepted
socket is not a channel — the cap regime does not bound it), plus
per-credential/per-peer session caps and accept-queue bounds as the
deployment's policy.
The producer-side local *backend* for the I/O planes (the
local-socket providers — TCP dial, UDP egress, BIND listener) is
ADR-008's variant set, also feature `local`.
@@ -164,6 +181,7 @@ are feature-gated constructors over the same seams (ADR-008/ADR-009):
| [008](decisions/008-provider-defaults.md) | Providers | local + composed, feature-gated |
| [011](decisions/011-inline-vendor.md) | The extraction | the typestate subset in-tree |
| [013](decisions/013-targeted-fork.md) | The targeted fork | the core is owned (`src/rfc1928/`) |
| [014](decisions/014-idle-timeout-posture.md) | Idle-timeout posture | every phase teardown-governed; no protocol-layer idle bound; doors own the accept-path defense |
## Open Questions
@@ -180,4 +198,6 @@ translation) rides the dial callback without touching this surface.
seam); alktunnels ADR-004 (no-trait), ADR-007/008 (direct op;
bind-first + forwarded)
- [wire.md](wire.md) (the normative wire),
[consumer.md](consumer.md) (the other half)
[consumer.md](consumer.md) (the other half),
[ADR-014](decisions/014-idle-timeout-posture.md) (the
teardown-governed phase lifetimes)
+26 -12
View File
@@ -1,7 +1,9 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft — the POC-validated wire
shapes promoted into the normative spec)
last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided —
teardown-governed, no protocol-layer idle bound, ADR-014; Phase 1
initial draft 2026-09-20 — the POC-validated wire shapes promoted
into the normative spec)
---
# alksocks — Wire Format
@@ -17,8 +19,8 @@ pass-through, with exactly one framed stage (ASSOCIATE datagrams).
Design decisions live in the ADRs ([001](decisions/001-alpn-and-open-op.md),
[003](decisions/003-udp-handle-swap.md), [007](decisions/007-bind-in-base.md),
[011](decisions/011-inline-vendor.md), [013](decisions/013-targeted-fork.md));
this document is the normative
[011](decisions/011-inline-vendor.md), [013](decisions/013-targeted-fork.md),
[014](decisions/014-idle-timeout-posture.md)); this document is the normative
WHAT. RFC 1928's own wire rules (method negotiation, command encoding,
reply encoding, the RFC UDP datagram header) are specified by the RFC
itself — this document covers the channels-specific surface and the
@@ -89,9 +91,13 @@ ADR-003/ADR-007):
data plane (POC #5 — the RFC 1928 §3 reading).
- **No in-band control vocabulary.** The alktunnels resolution
("no in-band control path, ever — the escape hatch is a new ALPN")
holds: no KEEPALIVE, no error frames, no flow-expiry frames.
Errors are EOF-shaped (channels-level) or reply-code-shaped
(in-stream, pre-data-plane).
holds: no KEEPALIVE, no error frames, no flow-expiry frames. No
idle/inactivity vocabulary either — every phase's lifetime is
teardown-governed (EOF / garbage / error sentinels), never
protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet
wait's ends the same way). A deployment enforces idle policy at
its edges, not in-band. Errors are EOF-shaped (channels-level) or
reply-code-shaped (in-stream, pre-data-plane).
- **One channel per SOCKS5 session** — no sub-demux, no flow key
(AGENTS.md convention 10; the ASSOCIATE handle swap makes the
channel the relay handle, ADR-003).
@@ -150,7 +156,12 @@ the same channel:
- **Association lifetime = channel lifetime.** Channel EOF (the
channels-level sentinel) ends the association; no extra control
vocabulary (POC #2 test 4: a fresh associate succeeds immediately
after a half-close teardown).
after a half-close teardown). No inactivity timer exists at the
protocol layer — every phase (the pre-command handshake, the BIND
quiet wait, the association) is teardown-governed, never
idle-expired (ADR-014); a deployment wanting an idle bound enforces
it outside the protocol layer (door accept paths, caller-side
wrappers).
- **The stage is `select!`-driven** (channel reads ∥ egress replies),
with the egress readiness contract: `next_reply` is `Pending` when
nothing is ready (an instantly-ready `Ok(None)` starves a biased
@@ -234,15 +245,18 @@ BIND (the phase sequence, one channel's view):
Open questions are tracked in [open-questions.md](open-questions.md).
None currently open against this document — the datagram framing
(the one-way door) is decided (ADR-003); OQ-SK2-07 (the
virtual-subnet translation layer) rides the dial callback and does
not touch this surface.
(the one-way door) is decided (ADR-003); the phase lifetimes'
timeout posture is decided (ADR-014 — teardown-governed, no
protocol-layer idle bound); OQ-SK2-07 (the virtual-subnet
translation layer) rides the dial callback and does not touch this
surface.
## References
- ADR-001 (op/params), ADR-003 (the handle swap + frame),
ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND),
ADR-011 (the forked codecs)
ADR-011 (the forked codecs), ADR-014 (the teardown-governed
phase lifetimes — no protocol-layer idle timeout)
- POC findings: `poc-connect-wasm-findings.md` (CONNECT pass-through,
refusal mapping), `poc-udp-associate-findings.md` (the stage,
readiness contract, framing-once invariant, association lifetime),