Skip to content

fix(keepalive): anchor dead-socket watchdog to first send, not last - #995

Merged
jlucaso1 merged 3 commits into
mainfrom
claude/fix-keepalive-dead-socket-anchor
Jul 7, 2026
Merged

fix(keepalive): anchor dead-socket watchdog to first send, not last#995
jlucaso1 merged 3 commits into
mainfrom
claude/fix-keepalive-dead-socket-anchor

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

Anchor the 20s dead-socket watchdog to the first send since the last receive, not the most-recent send.

Why (bug)

is_dead_socket measured ms_since(last_data_sent_ms), and record_frame_sent overwrites last_data_sent_ms on every outgoing frame (via the single send chokepoint in noise_socket.rs). So on a half-open socket (peer silently gone, writes buffer, reads hang), any app frame — message/receipt/presence — landing inside the 20s window keeps ms_since(last_sent) < 20s, and the watchdog returns false.

Since is_dead_socket is the only steady-state reconnect trigger (a pong timeout only bumps an unread error_count; a silently half-open TCP socket never surfaces a transport error), detection is suppressed for as long as the app keeps emitting traffic (~every 10–18s) — a stall / silent-loss window.

WA Web's deadSocketTimer.onOrBefore (WA/Shift/Timer.js) keeps the earliest armed deadline: the first callStanza after a receive arms a 20s deadline and subsequent sends never push it back; parseAndHandleStanzacancel() clears it. The Rust port anchored on the last send — the exact divergence.

How

  • New SessionStats::first_send_since_recv_ms: set only on the first send after a receive (compare_exchange 0 -> now, so later sends don't move it) and reset to 0 on every receive (mark_recv_activity / record_recv_batch, and on teardown).
  • is_dead_socket now takes that anchor (armed_ms) instead of the last-send stamp; the keepalive loop feeds first_send_since_recv_ms(). last_data_sent_ms stays for its telemetry role.

Tests

  • dead_socket_anchor_holds_across_continued_sends — the first send arms the anchor; continued sends keep it put (while last_data_sent_ms advances); a stale anchor with no receive since is detected as dead; a receive cancels it; the next send re-arms with a fresh instant.
  • Existing is_dead_socket pure tests still pass (signature/logic unchanged, param renamed). cargo fmt / clippy clean.

…t the last

is_dead_socket measured elapsed since last_data_sent_ms, which record_frame_sent
overwrites on every outgoing frame. On a half-open socket (peer gone, writes
buffer, reads hang) any app frame landing inside the 20s window kept
ms_since(last_sent) < 20s, so the watchdog — the only steady-state reconnect
trigger — never fired while the app kept emitting traffic (~every 10-18s).

WA Web's deadSocketTimer.onOrBefore keeps the EARLIEST armed deadline: the first
send after a receive arms a 20s deadline and later sends never push it back;
parseAndHandleStanza cancels it. Track that anchor: a new first_send_since_recv_ms
set only on the first send after a receive (CAS 0->now) and reset to 0 on any
receive, and evaluate is_dead_socket against it.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f56058ba-37f7-4844-88bf-6b9f0411cc32

📥 Commits

Reviewing files that changed from the base of the PR and between b8baed3 and cf82819.

📒 Files selected for processing (1)
  • wacore/src/stats.rs

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved dead-socket detection by timing watchdog expiration from the first outbound send after the last inbound receive, preventing false “alive” states during send bursts without traffic.
    • Receiving activity more reliably cancels the watchdog anchor to keep reconnect decisions accurate.
  • New Features

    • Added session-level tracking for the first-send-since-receive watchdog anchor, including a new public accessor.
  • Tests

    • Added tests covering anchor arming/cancellation behavior and a regression for stale send/receive timing.

Walkthrough

The keepalive watchdog now anchors dead-socket timing on the first outbound send since the last receive. SessionStats stores and clears that anchor, is_dead_socket now consumes armed_ms, and keepalive_loop uses the new anchor for its timeout decision.

Changes

Dead socket watchdog anchor rework

Layer / File(s) Summary
SessionStats anchor field and lifecycle
wacore/src/stats.rs
Adds first_send_since_recv_ms, arms it on the first send after it is cleared, clears it on receive activity and reset, and exposes a getter. The added tests cover continued sends, receive cancellation, and re-arming after a stale anchor.
is_dead_socket contract update
wacore/src/protocol/keepalive.rs
Renames the parameter to armed_ms and updates the documentation to describe the armed-instant semantics while keeping the same branching behavior.
keepalive_loop wiring
src/keepalive.rs
Reads first_send_since_recv_ms from stats, passes it to is_dead_socket, and measures elapsed time from that anchor.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KeepaliveLoop
  participant SessionStats
  participant IsDeadSocket

  KeepaliveLoop->>SessionStats: first_send_since_recv_ms()
  SessionStats-->>KeepaliveLoop: armed_ms
  KeepaliveLoop->>IsDeadSocket: is_dead_socket(armed_ms, last_received_ms)
  IsDeadSocket-->>KeepaliveLoop: dead or alive
Loading

Possibly related PRs

Suggested labels: breaking-change

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main keepalive fix by anchoring the dead-socket watchdog to the first send.
Description check ✅ Passed The description is directly related to the changeset and explains the bug, fix, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-keepalive-dead-socket-anchor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wacore/src/stats.rs`:
- Around line 108-118: The arming path in stats.rs reads the clock twice on the
first send, once for the sent timestamp and again in the compare_exchange for
first_send_since_recv_ms. Update the send/arm logic in the relevant stats method
so it captures Self::now_ms() once, reuses that same timestamp for both
last_data_sent_ms and the first-send anchor, and keeps the existing CAS
gate/arm-once behavior intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c0c2684a-5e94-4ac5-8851-915076008242

📥 Commits

Reviewing files that changed from the base of the PR and between 4eecf0e and be4430c.

📒 Files selected for processing (3)
  • src/keepalive.rs
  • wacore/src/protocol/keepalive.rs
  • wacore/src/stats.rs

Comment thread wacore/src/stats.rs Outdated
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes the dead-socket watchdog by anchoring it to the first send after a receive rather than the most-recent send, matching WA Web's deadSocketTimer.onOrBefore semantics that keep the earliest deadline. Without this fix, any outgoing frame landing inside the 20 s window continuously reset the timer, allowing a silently half-open TCP socket to go undetected indefinitely.

  • wacore/src/stats.rs: Adds first_send_since_recv_ms: AtomicU64 with arm-on-first-send / reset-on-receive logic, a stale-anchor self-heal (anchor <= last_recv re-arms on the next send to cover the receive-reset race), and two new unit tests that directly verify the anchor-holds and stale-heal invariants.
  • wacore/src/protocol/keepalive.rs: Renames the is_dead_socket parameter from last_sent_ms to armed_ms and updates the second guard to last_received_ms >= armed_ms, keeping the predicate's logic unchanged.
  • src/keepalive.rs: Switches the keepalive loop to feed first_send_since_recv_ms() instead of last_data_sent_ms() to both the dead-socket predicate and the reconnect log message.

Confidence Score: 5/5

Safe to merge — the change is narrowly scoped to the dead-socket watchdog path and all relaxed-atomic reads remain statistical (no synchronization semantics are introduced).

The implementation correctly replicates WA Web's onOrBefore earliest-deadline semantics. The stale-anchor self-heal handles the one non-trivial race (a send whose now was captured before a concurrent receive-reset), and is_dead_socket independently catches the stale case via its own last_received_ms >= armed_ms guard. The two new tests directly exercise both invariants. No pre-existing behaviour outside the watchdog path is touched.

No files require special attention.

Important Files Changed

Filename Overview
wacore/src/stats.rs Adds first_send_since_recv_ms AtomicU64 to SessionStats with arm/reset logic in record_frame_sent, record_recv_batch, mark_recv_activity, and reset_connection_activity; stale-anchor self-heal via anchor <= last_recv guard; backed by two new tests.
wacore/src/protocol/keepalive.rs Renames last_sent_ms parameter to armed_ms in is_dead_socket and updates the cancellation guard to last_received_ms >= armed_ms; doc updated to clarify the anchor semantics.
src/keepalive.rs Switches the dead-socket check from last_data_sent_ms to first_send_since_recv_ms for both the predicate call and the elapsed-time log message; comment updated to reflect the corrected WA Web semantics.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App as App (send path)
    participant SS as SessionStats
    participant KA as Keepalive loop
    participant Peer as Peer (TCP)

    Note over App,Peer: Normal connected state — receive resets the anchor

    App->>SS: record_frame_sent() (first send after recv)
    SS->>SS: "first_send_since_recv_ms = now  [armed]"
    App->>SS: record_frame_sent() (subsequent sends)
    SS->>SS: "anchor != 0 and anchor > last_recv → no-op [stays put]"

    Peer->>SS: mark_recv_activity()
    SS->>SS: "first_send_since_recv_ms = 0  [cancelled]"

    Note over App,Peer: Half-open socket — peer silently gone

    App->>SS: record_frame_sent()  [armed again]
    App->>SS: record_frame_sent()  [no-op, anchor holds]
    App->>SS: record_frame_sent()  [no-op, anchor holds]

    KA->>SS: first_send_since_recv_ms()
    SS-->>KA: armed_ms (unchanged)
    KA->>KA: is_dead_socket(armed_ms, last_recv)
    Note right of KA: > 20s since armed, no recv → true → reconnect
    KA->>App: reconnect_immediately()
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant App as App (send path)
    participant SS as SessionStats
    participant KA as Keepalive loop
    participant Peer as Peer (TCP)

    Note over App,Peer: Normal connected state — receive resets the anchor

    App->>SS: record_frame_sent() (first send after recv)
    SS->>SS: "first_send_since_recv_ms = now  [armed]"
    App->>SS: record_frame_sent() (subsequent sends)
    SS->>SS: "anchor != 0 and anchor > last_recv → no-op [stays put]"

    Peer->>SS: mark_recv_activity()
    SS->>SS: "first_send_since_recv_ms = 0  [cancelled]"

    Note over App,Peer: Half-open socket — peer silently gone

    App->>SS: record_frame_sent()  [armed again]
    App->>SS: record_frame_sent()  [no-op, anchor holds]
    App->>SS: record_frame_sent()  [no-op, anchor holds]

    KA->>SS: first_send_since_recv_ms()
    SS-->>KA: armed_ms (unchanged)
    KA->>KA: is_dead_socket(armed_ms, last_recv)
    Note right of KA: > 20s since armed, no recv → true → reconnect
    KA->>App: reconnect_immediately()
Loading

Reviews (3): Last reviewed commit: "fix(stats): re-arm the dead-socket ancho..." | Re-trigger Greptile

Comment thread wacore/src/stats.rs Outdated
Comment thread wacore/src/stats.rs Outdated
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.80 MiB 10.80 MiB +64 B (+0.00%) 🔺
bin .text 8.81 MiB 8.81 MiB +64 B (+0.00%) 🔺
bin allocated (text+data+bss) 10.80 MiB 10.80 MiB 0
llvm-lines wacore 504,308 504,310 +2 (+0.00%) 🔺
llvm-lines wacore copies 17,277 17,277 0
llvm-lines whatsapp-rust lib 757,266 757,283 +17 (+0.00%) 🔺
llvm-lines whatsapp-rust lib copies 24,574 24,574 0
deps crates (Cargo.lock) 466 466 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.62 MiB 1.62 MiB +72 B (+0.00%) 🔺
.text wacore 531.22 KiB 531.22 KiB 0
.text wacore_binary 157.70 KiB 157.70 KiB 0
.text wacore_libsignal 178.73 KiB 178.73 KiB 0
.text wacore_appstate 156.45 KiB 156.45 KiB 0
.text wacore_noise 26.05 KiB 26.05 KiB 0
.text waproto 1.60 MiB 1.60 MiB 0
.text whatsapp_rust_sqlite_storage 512.98 KiB 512.98 KiB 0
.text whatsapp_rust_tokio_transport 43.61 KiB 43.61 KiB 0
.text whatsapp_rust_ureq_http_client 10.47 KiB 10.47 KiB 0
.text std 1.00 MiB 1.00 MiB 0
.text other deps 2.95 MiB 2.95 MiB 0

Baseline: 27417af3a (latest main run) · Head: dbac1390c · Graphs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files

Confidence score: 4/5

  • The biggest risk is test flakiness in wacore/src/stats.rs: the regression test depends on a 2ms sleep and a strict > millisecond check, which can fail on coarse clocks or busy CI and create noisy red builds despite correct behavior — make the assertion tolerant (e.g., >= or a larger/mocked time advance) before merging.
  • Also in wacore/src/stats.rs, the arming-send path calls now_ms() twice (for last_data_sent_ms and compare_exchange), so a 1ms skew can record inconsistent timestamps for one logical event and slightly distort stats behavior — read now_ms() once and reuse that value in both places before merging.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread wacore/src/stats.rs Outdated
Comment thread wacore/src/stats.rs Outdated
…lake test

Reuse one now_ms() for both last_data_sent_ms and the first-send CAS so they
share the same instant. Shorten the field doc to why-only. Drop the test's
clock-tick-dependent strict > asserts (flaky on coarse timers): the anchor-holds
check stays == armed regardless of whether the clock ticked. (review feedback)
greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 7, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread wacore/src/stats.rs
…ng race

The arm-once CAS could stick the anchor at a pre-receive timestamp: a send that
loaded anchor==0 and captured `now`, then had a concurrent receive store its
timestamp and reset the anchor to 0, would still win its compare_exchange and
write the pre-receive `now`. Because the anchor was then non-zero, no later send
re-armed it, and is_dead_socket's `last_received >= anchor` guard stayed true
forever — silently disabling dead-socket detection for the rest of the connection.

Re-arm whenever the anchor is unset OR stale (`anchor <= last_received`), so a
send that lost the race self-heals on the next send instead of sticking.
@greptile-apps
greptile-apps Bot dismissed their stale review July 7, 2026 02:12

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@coderabbitai coderabbitai Bot removed the api-design label Jul 7, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Bug fix in dead-socket detection: changes anchor from last send to first send since last receive, adding new atomic field and updating is_dead_socket logic. Critical reconnect logic, requires human review.

Re-trigger cubic

@jlucaso1
jlucaso1 merged commit 5f07cb9 into main Jul 7, 2026
18 checks passed
@jlucaso1
jlucaso1 deleted the claude/fix-keepalive-dead-socket-anchor branch July 7, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant