Count a worker as replaced when its replacement is serving, not when it exits - #2363
Conversation
There was a problem hiding this comment.
Code Review
This pull request modifies the worker restart logic in server/threads/manageThreads.js to ensure a worker is only considered fully replaced once its replacement is accepting connections, rather than immediately upon the old worker's exit. This prevents the worker pool from being depleted during sequential restarts. The feedback suggests using .finally() instead of .then() when resolving the replacement promise to guarantee that the worker is removed from the waitingToFinish tracking array even if the replacement or shutdown fails.
…ing, not when it exits restartWorkers() throttled on maxWorkersDown by counting a worker as "down" until it exited, not until its replacement was actually serving. Where the replacement can't be pre-started (Windows/macOS/Bun) it only boots after the old worker is gone, so throttling on exit alone let the loop race ahead of booting replacements and take the whole pool down at once. - waitingToFinish now tracks a worker until its replacement is confirmed serving (or given up on), not merely until the old worker exits. - A new replacementsFailedToStart counter stops the restart, leaving the remaining pool on the old code, once too many replacements in a row fail to come up — readiness throttling bounds how many workers are down at once, but not how many outright fail to return. This is the pool-availability throttle rework called out as shipping separately when #2341 (harper#2335) landed.
…de, avoid index shadow
6baa7e7 to
cb6c70d
Compare
|
Reviewed; no blockers found. |
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving — the readiness/exit synchronization holds, including the failure and auto-restart paths.
🧊 In plain terms: on platforms that can't start a replacement worker before the old one lets go of its port, the restart loop used to count a worker as "handled" the moment it exited. Old workers die in milliseconds and replacements take seconds, so the loop could walk the whole pool down before anything was serving again. Now a worker only counts once its replacement is actually accepting connections.
I traced the parts that looked fragile and they aren't:
- The circuit breaker can actually fire. It increments on
if (!started), which only works ifwhenWorkerStartedresolves falsy rather than rejecting — otherwise the.catchonreplacedwould swallow it and the counter would sit at 0 forever. It resolves:new Promise((resolve) => …)with no reject path, returningfalseboth on boot timeout and onexit-before-CHILD_STARTED. replacedreferencing itself inside its own.thento splice out ofwaitingToFinishis fine — the callback runs long after the binding completes.- The splice is in the chain that resolves
replaced, so by the timePromise.race(waitingToFinish)wakes, the array is already trimmed. No stale entries accumulating in the throttle.
Two comments overclaim, and I'd fix the comments rather than the code.
replacementsFailedToStart is only incremented inside the !canPreStartReplacement branch, and OVERLAPPING_RESTART_TYPES is HTTP-only — so the breaker covers exactly one combination: HTTP workers on Windows/macOS/Bun. On Linux the counter never leaves 0 and manageThreads.js:718 is dead code.
That's defensible rather than broken: on Linux the HTTP path pre-starts and awaits the replacement, so a failure leaves the old worker serving and increments workersKeptOnOldCode instead — no capacity is lost, so there's nothing for a breaker to stop. The gap is the other half. Non-HTTP workers are shut down with their replacement fired unawaited from the exit handler (:697), on every platform, with no readiness tracking and no contribution to the counter. If those replacements never come up the loop still walks the entire non-HTTP pool and drains it — which is exactly the scenario the comment at :715-717 says is now prevented. Same for :701-703: "a worker counts as replaced only once its replacement is accepting connections" holds for both overlapping paths, not for the non-overlapping one, where replaced is whenDone alone.
One observation rather than a finding: the failure timeout is Math.max(threadTerminationTimeout * 2, 60000), and the breaker needs maxWorkersDown failures to trip, so a fleet where every replacement wedges in boot can sit in a throttled restart for minutes before giving up. Bounded by the Promise.race throttle, so nothing runs away — just slower than "stops rather than continuing" suggests.
Context for whoever reads this next: this does not touch the harper#1585 self-restart deadlock, whose root cause is the !started branch clearing worker.wasShutdown = false on a worker an outer shutdown is about to terminate. That path is unchanged here. If anything this is mildly protective, since the breaker terminates fewer workers when replacements are failing.
Coverage: read the restart loop and both platform paths, whenWorkerStarted's resolve contract, the splice/throttle interaction, and the untouched accounting on the break. Not executed.
— DAIvid (Claude Opus 5) · cross-model: Codex (graded) + Gemini + Harper domain adjudication
|
CI triage: all three red checks are pre-existing, none are caused by this PR. This PR's diff is a single file (
The decisive evidence that the unit legs are ambient rather than PR-caused: re-running the failed unit jobs against the identical sha ( Local verification of this branch (Linux, Node 26):
No changes were pushed. The Windows integration leg will stay red on this branch until #2410 is fixed; the two unit legs should clear on a re-run. — Claude Opus 5 (dev-agent) |
Split out of #2341 at Kris's request. #2341 has since merged (squashed, as c4dd962) and this branch is now rebased directly onto
main; it builds on that PR'swhenWorkerStartedhelper, which is why this diff is the restart-pacing change alone.restartWorkers()advanced itsmaxWorkersDownthrottle when a worker exited. On the platforms that cannot pre-start a replacement into the predecessor's listening port — Windows, macOS, Bun — the replacement only starts once that worker is gone, and old workers exit in milliseconds while a replacement takes seconds to boot, so the loop could walk the whole pool down before any replacement was serving. A worker now counts as replaced only once its replacement reportsCHILD_STARTED, which is what the throttle was there to bound; and if replacements keep failing to start, the restart stops rather than continuing to take down workers that are still serving. On SO_REUSEPORT platforms the replacement is already awaited before its predecessor is shut down, so nothing changes there.For the human reviewer
restart_service httpwere refused on 2.5% of attempts with this change and 2.6% without (690/695 attempts, 8 threads), andget_status's per-worker roster never dipped below 8 on either build. The first is explained by a dedicated worker-owned listener having a single owner worker — the client-visible gap is that owner's replacement time, which no throttle shrinks; the second by the roster counting registered workers rather than ready ones. So the review's original "total downtime during a deploy" framing is overstated for macOS. What the change buys is a bound on how many worker threads are simultaneously not-yet-serving, which matters most for load on Windows/Bun, where CI's shards are the only signal I have.maxWorkersDown, logs it, and reports the untouched workers as left on the previous code (workersKeptOnOldCode, which Wait for the restart a component deploy triggers, and tell MQTT clients why a publish was refused #2341 already surfaces in the log). The alternative is to keep rolling and end with an empty pool. Failed slots do free their throttle entry as they settle, so up tomaxWorkersDownworkers can be unavailable before the stop trips — that is the bound the parameter already promises, not a new one.CHILD_STARTEDcan still certify a worker whose dedicated listener failed to bind (harper#1813) — readiness is "the worker reported started", not "every listener is bound". And this is a no-op for job workers: they are the!overlappingpath, their replacement is still started without being awaited, and they serve no connections. The gemini leg read that path as Windows/Bun and concluded "total downtime"; the platform axis iscanPreStartReplacement, notoverlapping, so that conclusion does not hold.git rebase origin/mainreconflicted on content main already had (verbatim, in every file but this one). Resolved by squashing this branch's unique commits into one tree state and rebasing that single commit ontomain— one 3-way merge instead of 24, verified by diffing the result againstmain(clean everywhere butmanageThreads.js) and reading through the two real conflicts to confirm the branch's version was a strict superset of whatmainalready had. The rebase's own pre-push review caught a real bug in the process: the abort accounting'suntouchedcount (point 3 above) didn't exclude a worker that exited on its own mid-restart — it's spliced out ofworkersand auto-restarted onto the new code by the ordinary exit handler, but was still being counted as "left on old code". Fixed atmanageThreads.js:724.Verification
npm run buildandnpm run lint:requiredclean.integrationTests/{deploy,mqtt}80 passed / 0 failed / 1 skipped and thedeploy-restart-topic-availabilitysuite 4/4 on macOS, which is a non-pre-start platform, so every restart in those suites exercises this path.Re-verified after the rebase, on Linux:
npm run buildclean;unitTests/components/{awaitRestart,requestRestart}.test.jsandunitTests/server/threads/*.test.js(58 passing);integrationTests/mqtt/deploy-restart-topic-availability.test.ts(4/4) andintegrationTests/components/acl-connect.test.ts(13/13). Linux pre-starts replacements (canPreStartReplacement), so these don't exercise the readiness-throttle path either — see point 5 above, unchanged by the rebase.Pre-existing macOS breakage found while verifying, not caused by this PR:
integrationTests/components/shutdown-drain-e2e.test.tsfails 2/2 on a cleanmaincheckout with a fresh build, with the EADDRINUSE-during-drain signature that file's own comment documents for Bun (Failed to bind TLS listener for component 'mqtt' … address already in use, 4 occurrences). It is skipped only under Bun, and CI runs Linux and Windows, so macOS red goes unseen. Worth its own issue — say the word and I'll file it.Complexity: medium
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=3 @ cb6c70d
Human-Review-Need: 3 @ cb6c70d