docs(websocket): record WS-13 no-keepalive decision + progress semantics (WS-13)

Decides the WS-13 legitimate-silence question as option (b): 60s of no
chunk progress is an intentional eviction line even for silent
subscriptions; no WS ping/pong keepalive is added because a keepalive
can only rescue app-silence by re-arming the deadline, which reopens
the dribble hole the knob exists to seal. Documented in the byte_adapter
module doc, on DEFAULT_WS_IDLE_TIMEOUT, on the unchanged
HttpAdapter::with_ws_idle_timeout knob, and in websocket.md (new
'Idle-read timeout' section, including the FWD-15 SSE-keepalive
layering note). Deployment posture for long-lived silent sessions:
with_ws_idle_timeout(None) + WsSessions abort + write-side caps.
This commit is contained in:
2026-08-30 12:09:30 +00:00
parent f834835b8b
commit 3f1d5913e7
3 changed files with 103 additions and 18 deletions
+44 -1
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-08-27
last_updated: 2026-08-30
---
# WebSocket — the Browser Bidirectional Path (Channels over WS)
@@ -176,6 +176,49 @@ The adapter is shared with the `from_wss` consumer path
([ADR-070](decisions/070-from-wss-consumer-adapter.md)) — one
implementation, both directions.
#### Idle-read timeout: progress semantics + the no-keepalive decision (WS-01, WS-13)
The WS pumps carry an **idle-read eviction knob**
(`HttpAdapter::with_ws_idle_timeout`, default
`DEFAULT_WS_IDLE_TIMEOUT` = 60 s, disable with `None`). Its semantics,
decided in review-002 (WS-13), are **progress-based, deliberately
strict**:
- The deadline resets on **demux progress** — bytes actually forwarded
into the byte stream that *complete* an inbound chunk (a full
8-byte header + its declared payload). WS message arrival resets
nothing.
- Therefore the dribble stall (declare a chunk, deliver its payload a
byte per message) hits the deadline and is evicted with a `1001`
(GoingAway) close — even though messages keep arriving — while a
peer delivering complete chunks, however slowly per message, re-arms
the window with each chunk and survives.
- **There is no WS ping/pong keepalive, on purpose.** A keepalive
rescues app-silence only by re-arming the deadline — and a pong is
indistinguishable from the dribble's almost-invisible arrivals, so
adding one would reopen the stall it exists to seal. The recorded
decision (module doc of `src/websocket/byte_adapter.rs`, option (b)
of the two WS-13 alternatives): **60 s of no chunk progress is an
intentional eviction line, even for a silent subscription.**
- A deployment running long-lived silent-but-alive sessions (quiet
subscriptions that outlast the window) disables the knob with
`with_ws_idle_timeout(None)` and leans on the remaining levers:
the session registry's forced-eviction
([`WsSessions::abort`](#connection-local-overlay)), the
inbound caps (WS-06), and the write-side caps (WS-04/05/06). This is
the same deployment posture as `from_wss`'s drop monitor
([ADR-070](decisions/070-from-wss-consumer-adapter.md)): the idle
knob bounds *demux parking*, not app liveness.
- Layered note (the WS-13/FWD-15 interaction recorded here): the
*HTTP* SSE path (`/subscribe`) sends server-side keep-alive comment
frames every 15 s — see
[http-server.md](http-server.md)) — because its idle enemy is
LB/proxy timeouts, and its keep-alive does not reset any
progress deadline, it cannot reopen the WS-13 hole. The two live at
different layers: SSE keep-alive fights transport fires; the WS idle
knob bounds demux parking. Both documented in the same pass per
review-002 Unit-2 sequencing.
### Dispatch: channel 0 = the shared `Dispatcher`, unchanged
Channel 0's session is the alknet design's native session, verbatim:
+14 -5
View File
@@ -218,11 +218,20 @@ impl HttpAdapter {
self
}
/// The WS idle-read timeout (WS-01): the read pump closes the
/// connection with a 1001 (GoingAway) close frame after this long
/// without an inbound WS message — bounding the demux stall a
/// dribbling (or silently-stalled) peer can pin. `None` disables
/// the knob (not recommended: the stall window is then unbounded).
/// The WS idle-read timeout (WS-01, WS-13 semantics): the read
/// pump closes the connection with a 1001 (GoingAway) close frame
/// after this long producing **no completed inbound chunk** — the
/// deadline resets on demux progress (complete chunks forwarded
/// into the byte stream), not on WS message arrival, so a
/// dribbling peer (slow message arrivals inside a declared chunk)
/// is bounded while productive-but-slow peers survive. This is an
/// intentional no-progress eviction line, not a transport-idle
/// bound: there is no WS ping/pong keepalive, and app-silence that
/// outlasts the window (a quiet subscription) is evicted by design
/// — see `websocket::byte_adapter`'s module doc. `None` disables
/// the knob (not recommended: the stall window is then unbounded;
/// long-lived silent subscriptions are the intended `None` case,
/// leaning on `WsSessions::abort` and the write-side caps).
///
/// Default: [`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`] (60 s).
pub fn with_ws_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
+45 -12
View File
@@ -32,6 +32,32 @@
//! Text WS messages are rejected with a protocol-level close (code
//! 1002); all frames are binary (websocket.md §Framing).
//!
//! Idle-read timeout (WS-01, WS-13 semantics): the knob
//! (`DEFAULT_WS_IDLE_TIMEOUT`, deployment-adjustable via
//! `HttpAdapter::with_ws_idle_timeout`, disable via `None`) evicts a
//! connection whose inbound stream produces **no completed chunk for
//! the whole window** — the deadline resets on demux progress (bytes
//! forwarded into `read_tx` that complete 8-byte-header-framed chunks),
//! never on WS message arrival, so a forever-dribble inside a declared
//! chunk hits the deadline even though messages keep arriving, while a
//! peer delivering complete chunks — however slowly per-message —
//! re-arms the window with each one.
//!
//! Legitimate silence (WS-13 decision, recorded — option (b)): there
//! is deliberately **no WS ping/pong keepalive**. A keepalive can only
//! rescue app-silence by re-arming the deadline, which would reopen the
//! dribble hole it exists to seal (pong = traffic from the attacker's
//! point of view); instead, 60 s of *no chunk progress* is an
//! intentional eviction line even for a silent subscription — a
//! long-lived quiet subscription that must survive past the window
//! (with server-side pushes; see the keep-alive discussion in
//! `websocket.md`) is exactly the deployment that dials
//! `with_ws_idle_timeout(None)` and leans on the other bounds
//! (`WsSessions::abort` eviction, the write-side caps). Read eviction
//! closes with 1001 (Going Away) — a normal connection end from the
//! demux's point of view (EOF → channels cleared, pendings failed),
//! not a protocol error.
//!
//! Close mapping: WS close (either side) → read EOF → the demux clears
//! all channels (REQ-CH-02) and the dispatch loop fails outstanding
//! pendings. `AsyncWrite::shutdown` closes the WS sink after the queued
@@ -107,20 +133,27 @@ pub const WS_PROTOCOL_ERROR: u16 = 1002;
/// violation it cannot recover from (WS-04/HY-09, WS-05).
pub const WS_INTERNAL_ERROR: u16 = 1011;
/// Idle-read close code (WS-01): a read side that stays silent past the
/// configured idle timeout is closed with 1001 (Going Away) — a normal
/// connection end from the demux's point of view (EOF → channels
/// cleared, pendings failed), not a protocol error.
/// Idle-read close code (WS-01/WS-13): a read side that produces no
/// demux progress (no completed inbound chunk) past the configured
/// window is closed with 1001 (Going Away) — a normal connection end
/// from the demux's point of view (EOF → channels cleared, pendings
/// failed), not a protocol error.
pub const WS_GOING_AWAY: u16 = 1001;
/// Default idle-read timeout for the WS pumps (WS-01): a peer whose
/// inbound byte stream stops producing **complete chunks** cannot
/// park the single demux loop longer than this (WS-13 progress
/// semantics — the deadline resets when a complete chunk's bytes are
/// forwarded into `read_tx`, not when WS messages arrive, so a
/// forever-dribble inside a declared chunk still hits it). Zero-wait
/// is spelled `None`; this duration is the deployment default
/// (`HttpAdapter::with_ws_idle_timeout`).
/// Default idle-read timeout for the WS pumps (WS-01, WS-13): a
/// connection whose inbound stream completes **no chunk** within this
/// window is evicted — the deadline resets on demux progress (complete
/// chunks forwarded into `read_tx`), not on WS message arrival, so a
/// forever-dribble inside a declared chunk still hits it.
///
/// This is an intentional no-progress eviction line, *not* a
/// transport-idle bound: there is no WS ping/pong keepalive, and
/// app-silence that outlasts the window (a quiet subscription) is
/// evicted with 1001 by design — see the module doc's "Legitimate
/// silence" decision. A deployment running long-lived silent
/// subscriptions disables the knob with
/// `HttpAdapter::with_ws_idle_timeout(None)` (`None`, not zero — zero
/// is not a meaningful window).
pub const DEFAULT_WS_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// Observes the inbound byte stream for chunk framing (WS-13): counts