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
+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