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
|
status: draft
|
||||||
last_updated: 2026-08-27
|
last_updated: 2026-08-30
|
||||||
---
|
---
|
||||||
|
|
||||||
# WebSocket — the Browser Bidirectional Path (Channels over WS)
|
# 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
|
([ADR-070](decisions/070-from-wss-consumer-adapter.md)) — one
|
||||||
implementation, both directions.
|
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
|
### Dispatch: channel 0 = the shared `Dispatcher`, unchanged
|
||||||
|
|
||||||
Channel 0's session is the alknet design's native session, verbatim:
|
Channel 0's session is the alknet design's native session, verbatim:
|
||||||
|
|||||||
+14
-5
@@ -218,11 +218,20 @@ impl HttpAdapter {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The WS idle-read timeout (WS-01): the read pump closes the
|
/// The WS idle-read timeout (WS-01, WS-13 semantics): the read
|
||||||
/// connection with a 1001 (GoingAway) close frame after this long
|
/// pump closes the connection with a 1001 (GoingAway) close frame
|
||||||
/// without an inbound WS message — bounding the demux stall a
|
/// after this long producing **no completed inbound chunk** — the
|
||||||
/// dribbling (or silently-stalled) peer can pin. `None` disables
|
/// deadline resets on demux progress (complete chunks forwarded
|
||||||
/// the knob (not recommended: the stall window is then unbounded).
|
/// 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).
|
/// Default: [`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`] (60 s).
|
||||||
pub fn with_ws_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
|
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
|
//! Text WS messages are rejected with a protocol-level close (code
|
||||||
//! 1002); all frames are binary (websocket.md §Framing).
|
//! 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
|
//! Close mapping: WS close (either side) → read EOF → the demux clears
|
||||||
//! all channels (REQ-CH-02) and the dispatch loop fails outstanding
|
//! all channels (REQ-CH-02) and the dispatch loop fails outstanding
|
||||||
//! pendings. `AsyncWrite::shutdown` closes the WS sink after the queued
|
//! 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).
|
/// violation it cannot recover from (WS-04/HY-09, WS-05).
|
||||||
pub const WS_INTERNAL_ERROR: u16 = 1011;
|
pub const WS_INTERNAL_ERROR: u16 = 1011;
|
||||||
|
|
||||||
/// Idle-read close code (WS-01): a read side that stays silent past the
|
/// Idle-read close code (WS-01/WS-13): a read side that produces no
|
||||||
/// configured idle timeout is closed with 1001 (Going Away) — a normal
|
/// demux progress (no completed inbound chunk) past the configured
|
||||||
/// connection end from the demux's point of view (EOF → channels
|
/// window is closed with 1001 (Going Away) — a normal connection end
|
||||||
/// cleared, pendings failed), not a protocol error.
|
/// from the demux's point of view (EOF → channels cleared, pendings
|
||||||
|
/// failed), not a protocol error.
|
||||||
pub const WS_GOING_AWAY: u16 = 1001;
|
pub const WS_GOING_AWAY: u16 = 1001;
|
||||||
|
|
||||||
/// Default idle-read timeout for the WS pumps (WS-01): a peer whose
|
/// Default idle-read timeout for the WS pumps (WS-01, WS-13): a
|
||||||
/// inbound byte stream stops producing **complete chunks** cannot
|
/// connection whose inbound stream completes **no chunk** within this
|
||||||
/// park the single demux loop longer than this (WS-13 progress
|
/// window is evicted — the deadline resets on demux progress (complete
|
||||||
/// semantics — the deadline resets when a complete chunk's bytes are
|
/// chunks forwarded into `read_tx`), not on WS message arrival, so a
|
||||||
/// forwarded into `read_tx`, not when WS messages arrive, so a
|
/// forever-dribble inside a declared chunk still hits it.
|
||||||
/// forever-dribble inside a declared chunk still hits it). Zero-wait
|
///
|
||||||
/// is spelled `None`; this duration is the deployment default
|
/// This is an intentional no-progress eviction line, *not* a
|
||||||
/// (`HttpAdapter::with_ws_idle_timeout`).
|
/// 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);
|
pub const DEFAULT_WS_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// Observes the inbound byte stream for chunk framing (WS-13): counts
|
/// Observes the inbound byte stream for chunk framing (WS-13): counts
|
||||||
|
|||||||
Reference in New Issue
Block a user