Uh oh!
There was an error while loading. Please reload this page.
fix(core): autoRestart fires for a health check that throws or times out, not only for one that returns a failure - #11956
Conversation
…autoRestart handling `performHealthCheck` failed two disjoint ways and only the returned-failure route reached `config.autoRestart`. Because `raceCheckTimeout` rejects rather than resolving, every `timeout` overrun landed in the `catch` — so the severer failure mode could never trigger recovery, and the catch path also skipped the `successCounters` reset its sibling performed. Both routes now funnel into `recordFailedRound`, which owns the counters, the threshold comparison and the restart decision. The per-route status label stays as documented: a throw is still `failed` immediately, with no threshold. Part of #11852 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…torestart-throw-route
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436fcab008049402ed6868f6e4528d696bf0f810 && git checkout 436fcab008049402ed6868f6e4528d696bf0f810
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0cbe79a36d750423381542b49c44ab720f2e0384 f540920189e3eb61d7deac70864406f54f1d1468 && git checkout -B drift-repro 0cbe79a36d750423381542b49c44ab720f2e0384 && git merge --no-ff f540920189e3eb61d7deac70864406f54f1d1468
node scripts/docs-audit/affected-docs.mjs --json 0cbe79a36d750423381542b49c44ab720f2e0384
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11852
PluginHealthMonitor.performHealthCheckreaches its failure handling by two disjointroutes, and only one of them could ever restart the plugin.
A check that returned a failure (
falseor{ status: 'unhealthy' }) incrementedfailureCounters, clearedsuccessCounters, and — oncefailureThresholdconsecutivefailures accumulated — consulted
autoRestartand restarted the plugin. A check thatthrew took a separate
catchblock that incrementedfailureCountersand stoppedthere: it never cleared
successCountersand never readautoRestart. BecauseraceCheckTimeoutbuilds its guard asnew Promise((_, reject) => …)and so rejectsrather than resolving, every
timeoutoverrun lands in thatcatch. A plugin that hungwas therefore marked
failedand never restarted, no matter how many rounds passed orwhat
autoRestartsaid — the severer of the two failure modes was the one that could nottrigger recovery.
config.autoRestartappeared exactly once in the file, inside the returned-failurebranch. It now appears once inside the shared path both routes take.
What changed
Both routes funnel into one
recordFailedRoundstep that owns the failure counter, thesuccessCountersreset, thefailureThresholdcomparison and theautoRestartdecision.A thrown or timed-out check is restart-eligible on exactly the same terms as a returned
failure.
The step sits outside the
try, deliberately: it may await a restart, and a faultraised by restart handling is not a health-check exception. Under the old shape
attemptRestartran inside thetry, so a throw from it would have been relabelled as afailed check and pushed a second
health-checkentry for a check that had actually run.What deliberately did NOT change
The per-route status label. A throw stays the separate
failedstatus appliedimmediately with no threshold. That is not incidental — it is the documented contract, and
it predates this card (the text is already present at
a1c804bc9, the commit the issuemeasured):
It is also pinned by the pre-existing
still reports the timeout when the check never answers, which assertsfailedon round 1 against afailureThresholdof 3.So the fully-unified shape floated in triage — route both failures through the same
threshold handling end to end — would have turned a below-threshold throw into
degraded,contradicting a documented sentence and reddening an existing test. Only the counters and
the restart decision are shared here, because those are what
failureThresholdandautoRestartdeclare, and neither of them names a route. Mirroring was the smaller honestchange; a new pin (
keeps a throw at 'failed' immediately, with no threshold) guards thatboundary against a future re-unification.
The
successCountershalfThe issue's second asymmetry — the
catchpath not clearingsuccessCounters— is fixed(the shared step clears it structurally, on both routes). It has no behavioural pin,
and that is a measurement rather than an omission: the counter's only read site is
unreachable with a stale non-zero value, because the returned-failure route always zeroes
it before
unhealthy/degradedcan be entered, and the throw route setsfailed, whichthe recovery branch short-circuits past. Filed separately as #11955, which is the reason
the read site is unreachable. Writing a test here would have meant one that passes for the
wrong reason.
Tests
Five new pins in
packages/core/src/health-monitor.test.ts, all asserting the observableconsequence —
attemptRestartis the only caller ofplugin.destroy()and the onlywriter of
recovering, so those two readings together mean a restart happened and nothingelse can produce them:
failureThresholdaccumulatestimeout— the severest route, and the one notest covered
worked, so unifying cannot close the throw gap by opening one here)
autoRestartis false — the control, withoutwhich the pins above would also pass if every failure restarted unconditionally
failedimmediately, with no threshold — the documented rule aboveNon-vacuity
The before-state is a silent absence of a restart, so the new pins were proven to go red
against the unfixed code. Direction predicted in advance: reverting
health-monitor.tstoorigin/mainwith the tests untouched should turn exactly 2 of 12 red — the tworestart-on-throw pins — while the returned-failure pin, the
autoRestart: falsecontroland the
failed-immediately pin stay green, since they describe behaviour the unfixed codealready had.
Mutation confirmed on disk by anchored count before any result was read
(
recordFailedRound3 → 0), restore undertrap … EXIT INT TERM, tree verified cleanafter (
git status --porcelainempty,recordFailedRoundback to 3):Observed direction matched the prediction exactly. No rebuild leg applies: the test imports
the subject by relative path (
./health-monitor.js) andpackages/core/vitest.config.tsdeclares no alias, so vitest resolves it to source — the stale-
disthazard only bites whenthe subject resolves through a package's
exports.Restored tree:
Tests 12 passed (12).Gates
Union derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(never recalled),re-derived after merging
origin/mainbecause that merge changed three of the gate scriptsthemselves. All 21 green at
f540920189— 14 path-matched, 6 convention-triggered byediting a test file, plus
check:nul-bytes. Exit codes captured before any pipe.The ratchet half is the load-bearing one:
pnpm check:type-check-debtre-measured all 32ledger entries against the built closure —
@objectstack/corecarries a DEBT entry of 98, so this diff is inside that gate'spopulation rather than beside it.
Generated by Claude Code