Skip to content

fix(replication): arm client-side receive watchdog to catch silently-dead WS - #234

Merged
kriszyp merged 2 commits into
mainfrom
fix/replication-stuck-connection-watchdog
Jun 2, 2026
Merged

kriszyp merged 2 commits into
mainfrom
fix/replication-stuck-connection-watchdog

Conversation

@kriszyp

@kriszyp kriszyp commented May 27, 2026

Copy link
Copy Markdown
Member

Summary

  • Arms the receive-side silence watchdog on both client and server replication WS — previously only the server side ran it. Field evidence in Outbound replication WebSocket can silently die without firing 'close', leaving (peer, db) stuck with no retry until process restart #233 showed the client's 30s sendPing tick alone is not sufficient: it can fail silently (event-loop stall, ws.terminate() not propagating to close) and leave the (peer, db) pair stuck on connected: true until the process restarts.
  • Watchdog (60s = PING_INTERVAL * 2) is reset on every incoming ping, pong, and message, and suspended while the WS is intentionally paused for backpressure.
  • Extracted as createReceiveWatchdog for unit testability — 10 new tests in unitTests/replication/receiveWatchdog.test.mjs.

Purpose

Direct follow-up to #233 — addresses the variant where the close handler never fires at all (data db on bza in the diagnosed cluster: connected: true, zero established TCP, no retry attempts in 30+ min). The companion connected: false / no-retry-scheduled variant lives in the close-handler retry path and is not addressed here.

Where to look

  • createReceiveWatchdog tracks bytesRead only. Codex flagged a P2 if both bytesRead and bytesWritten were checked — our own sendPing advances bytesWritten even when the peer is silent, so including it would defeat the watchdog in the exact missed-tick scenario it exists to recover from. The bytesRead-only check is the heart of the correctness argument.
  • addPauseReason / removePauseReason suspend the watchdog. Codex flagged a P2 here too: under sustained backpressure (ws.pause() held for >60s) bytesRead is frozen by design, and the watchdog would terminate a healthy mid-ingest connection. Stop-on-pause / reset-on-resume avoids this.
  • reset() throttles internally. Gemini flagged the timer churn from ws.on('message') calling reset() per frame — at thousands of frames/sec during a full copy, the raw setTimeout/clearTimeout cycle adds GC pressure and event-loop drag. The watchdog now coalesces resets within a ~1s window. Granularity loss is within the noise of the 60s interval and the dedicated test guards correctness.
  • const resetPingTimer = receiveWatchdog.reset is intentional. Keeps the call-site naming continuous with the prior resetPingTimer() function so the message/ping/pong listener registrations stay textually unchanged; tradeoff is one alias line.
  • Out of scope (already called out in Outbound replication WebSocket can silently die without firing 'close', leaving (peer, db) stuck with no retry until process restart #233): the connected: false, no retry variant on oauth/system — separate bug in the close-handler setTimeout retry path, separate fix.

🤖 Generated by Claude Opus 4.7.

…dead WS

The 30s sendPing tick on outbound replication connections can fail silently (event-loop stall,
ws.terminate() not propagating to 'close'), leaving the (peer, db) pair stuck on connected: true
with no retry until process restart — see #233.

Arm the existing receive-side watchdog on the client too, reset it on incoming ping/pong/message,
and suspend it during ws.pause() backpressure to avoid spuriously terminating healthy mid-ingest
connections. Extract as createReceiveWatchdog for unit testability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@kriszyp
kriszyp requested a review from cb1kenobi May 27, 2026 17:57

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a receive-side silence watchdog (createReceiveWatchdog) to detect and terminate dead replication WebSocket connections based on bytesRead inactivity, along with comprehensive unit tests. The review feedback identifies a critical bug in the watchdog's throttling logic where it can become permanently inactive if a throttled reset is followed by silence. A fix is suggested to reschedule the timer when activity is detected, along with a new regression test to verify this behavior.

Comment thread replication/replicationConnection.ts
Comment thread unitTests/replication/receiveWatchdog.test.mjs
Comment thread replication/replicationConnection.ts Outdated
@claude

claude Bot commented May 27, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Prior blocker (throttled-reset-then-silent leaves watchdog permanently inactive) addressed in commit 2bfbaf1 — check() now re-arms from the new byte baseline instead of exiting, and the regression test at receiveWatchdog.test.mjs:213 directly covers that sequence.

…es bytes changed

PR #234 review (Gemini + claude bot) caught a permanently-inactive watchdog bug: when an
external reset() is throttled and the in-flight timer later fires with bytesRead advanced
beyond the snapshot, the previous implementation exited without re-arming. After that, no
external reset() ever arrives (the scenario the watchdog exists to catch is exactly this:
peer goes quiet right after a burst), so the watchdog stays dead permanently.

Re-arm from the new byte baseline in the non-silence branch. Also rename the existing test
that didn't actually exercise the throttle path (1s-spaced resets all bypassed it) and add a
dedicated regression test for the throttled-then-silent sequence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review May 28, 2026 20:38
@kriszyp
kriszyp requested a review from a team as a code owner May 28, 2026 20:38
@kriszyp
kriszyp merged commit a89bb8f into main Jun 2, 2026
33 checks passed
@kriszyp
kriszyp deleted the fix/replication-stuck-connection-watchdog branch June 2, 2026 17:38
kriszyp added a commit that referenced this pull request Jun 10, 2026
…oop yield budget (v5.0 backport of #248)

Backport of #248 onto v5.0. A follower cloning a large table stalled
socket byte movement under legitimate flow control (receiver pausing
reads to drain its queue; sender parked awaiting socket drain). The old
ping check used a "no bytes since last ping => terminate" heuristic, so
it killed the connection mid-copy and the copy restarted from zero,
never converging.

- Measure keep-alive liveness from the last observed socket byte
  movement (shouldTerminateIdlePing): terminate only after no activity
  for the full PING_TIMEOUT. A slow-but-progressing transfer keeps
  moving bytes and survives; a dead peer (incl. a sender stuck on a
  drain that never fires) is still terminated. Sole exemption:
  pauseReasons > 0 (receiver's intentional, self-clearing read-pause),
  during which we keep liveness fresh and still send pings (ws.pause
  stops reads, not writes).
- Yield the receive decode loop on a time budget (RECEIVE_YIELD_INTERVAL,
  default 100 ms) even when the consumer keeps up, so a single large
  message can't decode in one synchronous turn and starve ping responses
  / trip core's event-delay monitor.

Backport notes:

- The new replication.pingInterval / replication.pingTimeout core config
  params (HarperFast/harper#872) are not on the v5.0 core, so PING_INTERVAL
  and PING_TIMEOUT are hardcoded to the same defaults (#248) used: 30 s
  / 60 s. Configurability via replication.pingTimeout lands in v5.1+.
- The receive-side watchdog (createReceiveWatchdog, RECEIVE_SILENCE_THRESHOLD_MS)
  introduced by #234 is NOT on v5.0 and is NOT being backported here.
  v5.0's existing simpler resetPingTimer remains in place; only the
  client-side sendPing path and the decode-loop yield from #248 are
  applied.
- core submodule pointer NOT bumped — this PR contains only the
  harper-pro half of the fix.
- replication/DESIGN.md from #248 not included (the file did not exist
  on v5.0; the document also references v5.1+ features such as
  per-route excludeTables and the configurable ping params).

Refs #324, #241.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant