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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user