From 3f1d5913e77614047ed029ed8a879598e61a2d8f Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 12:09:30 +0000 Subject: [PATCH] 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. --- docs/architecture/websocket.md | 45 ++++++++++++++++++++++++++- src/server/adapter.rs | 19 +++++++++--- src/websocket/byte_adapter.rs | 57 +++++++++++++++++++++++++++------- 3 files changed, 103 insertions(+), 18 deletions(-) diff --git a/docs/architecture/websocket.md b/docs/architecture/websocket.md index 8b5da9a..c8db82e 100644 --- a/docs/architecture/websocket.md +++ b/docs/architecture/websocket.md @@ -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: diff --git a/src/server/adapter.rs b/src/server/adapter.rs index 57b1480..838499f 100644 --- a/src/server/adapter.rs +++ b/src/server/adapter.rs @@ -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) -> Self { diff --git a/src/websocket/byte_adapter.rs b/src/websocket/byte_adapter.rs index 2cafabb..0ac4bb8 100644 --- a/src/websocket/byte_adapter.rs +++ b/src/websocket/byte_adapter.rs @@ -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