diff --git a/advanced/websocket-handling.mdx b/advanced/websocket-handling.mdx index 04054116..31bdd79b 100644 --- a/advanced/websocket-handling.mdx +++ b/advanced/websocket-handling.mdx @@ -1045,7 +1045,7 @@ The keepalive loop monitors connection health, matching WhatsApp Web's behavior | `KEEP_ALIVE_INTERVAL_MIN` | 15s | Minimum interval between pings | | `KEEP_ALIVE_INTERVAL_MAX` | 30s | Maximum interval between pings | | `KEEP_ALIVE_RESPONSE_DEADLINE` | 20s | Timeout waiting for pong response | -| `DEAD_SOCKET_TIME` | 20s | Max silence after a send before declaring socket dead | +| `DEAD_SOCKET_TIME` | 20s | Max silence after the watchdog arms before declaring socket dead | ### Timestamp safety @@ -1062,15 +1062,17 @@ The loop runs every 15-30 seconds (randomized, matching WA Web's `15 * (1 + rand ### Dead socket detection -Dead socket detection mirrors WA Web's `deadSocketTimer` pattern: +Dead socket detection mirrors WA Web's `deadSocketTimer.onOrBefore` pattern, which keeps the **earliest** armed deadline rather than the most recent one: -- **Not armed** if nothing was ever sent (both timestamps are zero) -- **Cancelled** if data was received after the last send -- **Fires** if `DEAD_SOCKET_TIME` (20s) has elapsed since the last send with no receive +- **Not armed** if nothing has been sent since the last receive (the anchor is zero) +- **Cancelled** if data was received after the anchor was armed +- **Fires** if `DEAD_SOCKET_TIME` (20s) has elapsed since the anchor with no receive since + +The watchdog is anchored to `SessionStats::first_send_since_recv_ms` — the **first** send since the last receive, not the most recent send. `record_frame_sent` only stores a new anchor when the current one is unset or stale (`<=` the last-received timestamp); once armed, further sends leave it in place. Every receive resets the anchor to zero, and the next send re-arms it. Anchoring on the most recent send instead (the pre-fix behavior) let continued outgoing traffic — messages, receipts, presence — keep pushing the deadline forward, hiding a half-open socket (a peer that silently disappeared while writes still buffer and reads hang) for as long as the app kept emitting frames. The dead-socket check runs on **every** keepalive tick — not just after a failed ping. This catches scenarios where pending IQs caused the ping to be skipped, or where the ping "succeeded" but the connection died immediately after. When a dead socket is detected, the client calls `reconnect_immediately()` and exits the keepalive loop. -WA Web uses an independent 20s `deadSocketTimer` armed on every send and cancelled on every receive. The keepalive loop approximates this by checking `is_dead_socket(last_sent, last_recv)` unconditionally each iteration. +WA Web's `deadSocketTimer.onOrBefore` (`WA/Shift/Timer.js`) arms on the first `callStanza` after a receive and is cancelled by `parseAndHandleStanza`; subsequent sends never push the deadline back out. The keepalive loop approximates this by checking `is_dead_socket(first_send_since_recv, last_recv)` unconditionally each iteration, where `first_send_since_recv` is the armed-anchor value described above (not `last_data_sent_ms`, which still tracks the most recent send for telemetry). ### Error classification diff --git a/concepts/architecture.mdx b/concepts/architecture.mdx index cedf7a60..5b81050b 100644 --- a/concepts/architecture.mdx +++ b/concepts/architecture.mdx @@ -382,7 +382,7 @@ See [WebSocket & Noise Protocol - Message loop](/advanced/websocket-handling#2-m ### Keepalive loop -The keepalive loop runs as a **separate spawned task**, fully decoupled from the read loop. This ensures keepalive pings are never blocked by frame processing — even during large offline sync batches that take seconds to drain. The two loops communicate solely through atomic timestamps (`last_data_received_ms`, `last_data_sent_ms`). +The keepalive loop runs as a **separate spawned task**, fully decoupled from the read loop. This ensures keepalive pings are never blocked by frame processing — even during large offline sync batches that take seconds to drain. The two loops communicate solely through atomic timestamps (`last_data_received_ms`, `last_data_sent_ms`, and `first_send_since_recv_ms` — the dead-socket watchdog anchor). ```rust const KEEP_ALIVE_INTERVAL_MIN: Duration = Duration::from_secs(15); @@ -397,7 +397,7 @@ const DEAD_SOCKET_TIME: Duration = Duration::from_secs(20); - Sends ping *before* dead-socket check to prevent false-positive reconnects on idle-but-healthy connections - Waits up to 20s for response - Checks dead socket on **every** tick (not just after failures) — catches scenarios where pending IQs caused the ping to be skipped, or where the ping succeeded but the connection died immediately after -- Detects dead socket if no data received for 20s after a send, triggering immediate reconnection +- Detects dead socket, triggering immediate reconnection, if no data has been received for 20s since the **first** send after the last receive (`first_send_since_recv_ms`) — matching WA Web's `deadSocketTimer.onOrBefore`; subsequent sends do not push this deadline back out - Fatal errors (`Socket`, `Disconnected`, `NotConnected`, `InternalChannelClosed`) cause the keepalive loop to exit immediately - Error classification is exhaustive and compile-time enforced — adding a new error variant without handling it causes a build failure