Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions advanced/websocket-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Comment thread
jlucaso1 marked this conversation as resolved.

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

Expand Down
4 changes: 2 additions & 2 deletions concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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

Expand Down