Skip to content

fix(replication): recover ping-alive setup stalls before DB_SCHEMA (#642) - #646

Draft
kriszyp wants to merge 9 commits into
mainfrom
fix/642-setup-watchdog
Draft

fix(replication): recover ping-alive setup stalls before DB_SCHEMA (#642)#646
kriszyp wants to merge 9 commits into
mainfrom
fix/642-setup-watchdog

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 3, 2026

Copy link
Copy Markdown
Member

What / why

Fixes harper-pro#642: a subscription's sender-side setup (dynamic authorization
subscription + database subscription placeholder) can never settle while the
WebSocket stays healthy at the transport layer (ping/pong keeps flowing), so
DB_SCHEMA/replay never start and no application progress is made — invisible
to the connection-truth machinery in #431 because that machinery correctly
sees a live socket.

Root-caused and reproduced while triaging a first-occurrence Node.js v22
nightly Integration Tests failure on main
(connectedBitRestartChurn.test.mjs, 2026-08-03 run): the false-green
signature (connected: true, a fresh post-recovery write never replicating)
matches this issue's shape.

What changed

  • Bound both sender-side setup gates (dynamic authorization subscription,
    database subscription placeholder) with a shared timeout; classify and log
    which gate stuck; close transiently on timeout so the peer retries from its
    last durable cursor.
  • Add a one-shot, worker-local receiver watchdog (createSubscriptionSetupWatchdog)
    armed on a non-empty outbound SUBSCRIPTION_REQUEST, satisfied only by the
    matching DB_SCHEMA response (never by ping/pong/NODE_NAME), that forces a
    reconnect if setup never progresses.
  • Negotiate a mixed-version-safe setup budget between peers (resolveSubscriptionSetupCapability),
    capped so an old or misbehaving peer can't disable the local recovery net.
  • Correlate the outbound request and inbound acknowledgement by request id so
    a superseding request restarts the full timeout window rather than being
    satisfied by a stale response.
  • The watchdog now tracks remaining (unpaused) budget explicitly, using a
    monotonic clock, and only resets to a full window on arm() for a
    superseding request — recurring short back-pressure pauses can't keep
    re-granting a full window forever (found in cross-model pre-push review,
    fixed in this branch).

Test plan

  • unitTests/replication/subscriptionSetupWatchdog.test.mjs — 25 fake-timer
    unit tests: gate classification, capability negotiation/capping, request
    correlation, cancellation/supersession, and pause/resume budget accounting
    (including a repeated flapping-pause regression case).
  • integrationTests/cluster/subscriptionSetupRecovery.test.mjs — 3 two-node
    integration tests proving receiver-driven and sender-driven recovery from a
    deliberately-stalled setup gate, and healthy-idle stability (no
    reconnect-churn on a caught-up, zero-traffic subscription). All pass locally.
  • Independent cross-model pre-push review (Codex/Gemini/Grok, graded by
    Codex): round 1 found one major issue (pause/resume resetting the full
    timeout window instead of preserving remaining budget) — fixed, with a new
    regression test. Round 2 (delta) verdict: COMMENTS, one minor (wall
    clock vs monotonic clock) — fixed. No blockers remaining.
  • I was not able to get a clean local signal on the specific chaos scenario
    from connectedBitRestartChurn.test.mjs — this dev box is running ~15
    unrelated harper.js processes from other concurrent work at 20-90% CPU
    each, and the test's timing assertions (25s reconnect threshold) are not
    reliable under that contention (confirmed the same failure shape reproduces
    against unmodified origin/main under the same load). Relying on this PR's
    CI run for that signal.

Risks & open questions

  • The mixed-version capability negotiation assumes an older peer without
    subscriptionSetupBudgetMs falls back to the local timeout — not tested
    against a real older-version peer, only via the capability-resolution unit
    tests.
  • subscriptionSetupRecovery.test.mjs does not exercise recurring
    backpressure, mixed-version interop, or an end-to-end database-gate
    timeout (only the authorization gate is exercised end-to-end) — noted by
    the independent review, left as future coverage rather than scope creep on
    this PR.

Refs #642

🤖 Generated with Claude Code

kriszyp and others added 9 commits August 3, 2026 06:52
…e/resume

Cross-model pre-push review on this branch (harper-pro#642 fix) flagged that
pause()/resume() reset the full setup-timeout window instead of preserving
remaining budget, so recurring short back-pressure pauses could re-grant a
full window forever and let a stuck sender-side setup gate stay ping-alive
indefinitely — defeating the watchdog's bounded-recovery guarantee. Track
remaining budget explicitly and only reset it on arm() (a superseding
request); condense a few review-flagged narration comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… tracking

Delta pre-push review flagged that the pause/resume remaining-budget
calculation used Date.now(), so a wall-clock adjustment (NTP step, manual
clock change) could extend or prematurely exhaust the watchdog. Switch to
performance.now(), which is monotonic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp requested a review from cb1kenobi August 3, 2026 14:09
@kriszyp

kriszyp commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Guided tour for review

Reading order:

  1. replication/DESIGN.md:135 — read this first for the failure-mode framing (ping-alive
    but no application progress) and the mixed-version budget contract before diving into code.
  2. replication/replicationConnection.ts:1149 (createSubscriptionSetupWatchdog) — the
    core primitive: a one-shot timer armed on a non-empty SUBSCRIPTION_REQUEST, satisfied only
    by the matching DB_SCHEMA. Look hardest here: pause()/resume() track remaining
    budget (not a full reset) so recurring short back-pressure pauses can't indefinitely defer
    firing — this was a major finding from independent pre-push review (round 1 had it backwards;
    fixed in f0f693bf), and the elapsed-time math uses performance.now() (monotonic), not
    Date.now() (a second review finding, fixed in 8c034b92).
  3. replication/replicationConnection.ts:1117 (isSubscriptionSetupProgressFrame) — the
    correlation guard: only a DB_SCHEMA for the same database and request id retires setup;
    ping/pong, NODE_NAME, and stale/unsolicited schema traffic must not.
  4. replication/replicationConnection.ts:3326 — sender side: the two setup gates
    (authorization subscription, database subscription placeholder) are now bounded and
    classified on timeout, closing transiently so the peer retries from its durable cursor.
  5. replication/replicationConnection.ts:2175 and :5333 — wiring: per-connection setup
    request state, backpressure/close-cleanup integration, and request/ack correlation on the
    receive side.
  6. Tests last: unitTests/replication/subscriptionSetupWatchdog.test.mjs (fake-timer unit
    coverage — read the two pause/resume tests near the end, they're what review round 1 caught)
    and integrationTests/cluster/subscriptionSetupRecovery.test.mjs (two-node end-to-end).

What the tests prove, and don't: the integration test exercises the authorization gate
end-to-end (receiver-driven and sender-driven recovery, post-reconnect convergence, healthy-idle
stability). It does not exercise the database gate timeout end-to-end, recurring
backpressure during setup, or true mixed-version interop (only via the capability-resolution
unit tests) — noted by independent review, left as follow-up rather than scope creep here.

Coverage: cross-model pre-push review (Codex graded leg + Gemini + Grok) ran twice — round 1
on the original 7 commits (verdict CHANGES, 1 major + 1 nit), round 2 delta after the fix
(verdict COMMENTS, 1 minor + 1 nit, both addressed). No independent-runner build/test execution
was possible in the review sandbox (no node_modules, uninitialized core submodule there) —
verification was git diff --check + Node syntax checks; the unit/integration test runs above
were done directly in this worktree.

Honest gap: I could not get a clean local reproduction/verdict on the actual nightly chaos
scenario (connectedBitRestartChurn.test.mjs) — this dev box has ~15 unrelated harper.js
processes from other concurrent agent work at 20-90% CPU each, and I confirmed the same failure
shape reproduces against unmodified origin/main under that load, so I can't attribute it to
this fix one way or the other locally. Watching this PR's CI run for that signal.

@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 implements a subscription setup recovery mechanism to prevent connections from remaining ping-alive indefinitely when sender-side setup gates stall. It introduces a subscription-setup watchdog, correlated request IDs, and timeout-bounded gates on the sender side, along with comprehensive integration and unit tests. The review feedback suggests wrapping individual node teardowns in try-catch blocks within the test cleanup hooks to prevent resource leaks, and optimizing the asynchronous subscription resolution in replicationConnection.ts to check the connection status before assigning the subscription reference.

Comment on lines +124 to +126
after(async () => {
await Promise.all([ctx.source, ctx.receiver].filter(Boolean).map((node) => teardownHarper({ harper: node })));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To prevent process and resource leaks, individual process termination steps in the after cleanup hook should be wrapped in try-catch blocks. If one node teardown fails, it shouldn't prevent the other node from being cleaned up.

	after(async () => {
		await Promise.all(
			[ctx.source, ctx.receiver].filter(Boolean).map(async (node) => {
				try {
					await teardownHarper({ harper: node });
				} catch (error) {
					console.error('Failed to teardown Harper node:', error);
				}
			})
		);
	});
References
  1. In test cleanup hooks (such as after or afterEach), wrap individual process termination or cleanup steps in try-catch blocks to ensure that a failure in one step does not prevent subsequent critical cleanup steps from executing, thereby avoiding resource and process leaks.

Comment on lines +208 to +210
after(async () => {
await Promise.all([ctx.source, ctx.receiver].filter(Boolean).map((node) => teardownHarper({ harper: node })));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To prevent process and resource leaks, individual process termination steps in the after cleanup hook should be wrapped in try-catch blocks. If one node teardown fails, it shouldn't prevent the other node from being cleaned up.

	after(async () => {
		await Promise.all(
			[ctx.source, ctx.receiver].filter(Boolean).map(async (node) => {
				try {
					await teardownHarper({ harper: node });
				} catch (error) {
					console.error('Failed to teardown Harper node:', error);
				}
			})
		);
	});
References
  1. In test cleanup hooks (such as after or afterEach), wrap individual process termination or cleanup steps in try-catch blocks to ensure that a failure in one step does not prevent subsequent critical cleanup steps from executing, thereby avoiding resource and process leaks.

Comment on lines +262 to +264
after(async () => {
await Promise.all([ctx.source, ctx.receiver].filter(Boolean).map((node) => teardownHarper({ harper: node })));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To prevent process and resource leaks, individual process termination steps in the after cleanup hook should be wrapped in try-catch blocks. If one node teardown fails, it shouldn't prevent the other node from being cleaned up.

	after(async () => {
		await Promise.all(
			[ctx.source, ctx.receiver].filter(Boolean).map(async (node) => {
				try {
					await teardownHarper({ harper: node });
				} catch (error) {
					console.error('Failed to teardown Harper node:', error);
				}
			})
		);
	});
References
  1. In test cleanup hooks (such as after or afterEach), wrap individual process termination or cleanup steps in try-catch blocks to ensure that a failure in one step does not prevent subsequent critical cleanup steps from executing, thereby avoiding resource and process leaks.

Comment on lines +3383 to +3393
.then(async (subscription) => {
subscriptionToHdbNodes = subscription;
// The setup wait below is bounded. If it timed out and closed this socket before the
// subscription promise eventually resolved, retire the late subscription immediately
// instead of leaking a global hdb_nodes listener after the WS close event already fired.
if (closed || wsClosed) {
const lateSubscription = subscriptionToHdbNodes;
subscriptionToHdbNodes = undefined;
lateSubscription?.end();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Ensure that asynchronous loops or deferred operations check if the connection or context has already been closed (e.g., using a closed flag) immediately before executing authorization checks or close actions, preventing redundant teardowns and spurious warnings.

									.then(async (subscription) => {
										// The setup wait below is bounded. If it timed out and closed this socket before the
										// subscription promise eventually resolved, retire the late subscription immediately
										// instead of leaking a global hdb_nodes listener after the WS close event already fired.
										if (closed || wsClosed) {
											subscription?.end();
											return;
										}
										subscriptionToHdbNodes = subscription;
References
  1. Ensure that asynchronous loops or deferred operations check if the connection or context has already been closed (e.g., using a closed flag) immediately before executing authorization checks or close actions, preventing redundant teardowns and spurious warnings.

@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

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