Repository navigation
fix(core): a bootstrap that fails after every plugin started rolls them back too - #23664
Conversation
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…n the changeset Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 27 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 8670c435508804309246dc7a565c05601b37fc95 && git checkout 8670c435508804309246dc7a565c05601b37fc95
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ced0f82bd93260b660e6f448c7dc5952bc82371 b2a5be7230d471d9d0d025aab7c698dbbc8e055a && git checkout -B drift-repro 1ced0f82bd93260b660e6f448c7dc5952bc82371 && git merge --no-ff b2a5be7230d471d9d0d025aab7c698dbbc8e055a
node scripts/docs-audit/affected-docs.mjs --json 1ced0f82bd93260b660e6f448c7dc5952bc82371 |
Fixes #23644
Clause-②: no
ObjectKernel.bootstrap()rolled back started plugins only in the Phase 2 branch, for a plugin whosestart()failed. A boot refused later (the system-requirement check, or akernel:ready,kernel:bootstrappedorkernel:listeninghandler that throws) fell into the outer catch, which marks the kernelstopped, releases the signal listeners and rethrows.shutdown()on a stopped kernel is a no-op, so no plugin'sdestroy()ran and a driver connected instart()stayed connected. The live case is the dogfood refused boot inorganization-ownership-boot-refusal-order, whose own docblock records the stale handle.What changed
packages/core/src/kernel.ts: the outer catch ofbootstrap()now runs the samerollbackStartedPlugins()the Phase 2 branch runs, so a failed boot destroys the plugins that started, in reverse start order, at whatever phase it fails. Its existing duties are unchanged (statestopped, release the listeners, rethrow the original error). The catch's comment and therollbackOnFailureTSDoc now state the new truth.The rollback runs when
rollbackOnFailure && state === 'running' && startedPlugins.size > 0:rollbackOnFailureis the Phase 2 branch's own gate, sofalsekeeps opting out of both.startedPlugins.size > 0keeps a Phase 1 failure silent (nothing started) and makes the catch a no-op after the Phase 2 branch rolled back (rollbackStartedPlugins()clears the set), so a failingstart()still rolls back exactly once.state === 'running', thenstoppingfor the duration. Two lines beyond the card's text, which I took so this change does not add a double destroy: ashutdown()that began mid-boot already owns the destroys (it destroys every plugin and the set is not cleared), and ashutdown()or signal arriving during the rollback is absorbed by thestoppingstate exactly as during a drain. Each has its own pin and its own ablation below; both come out cleanly if the seat prefers the minimal form.destroy()that throws is handled by what the Phase 2 rollback already does: logged aterrorasRollback failed for NAME, the loop continues, and the boot error is rethrown unchanged.Not changed: the Phase 2 branch,
shutdown(),LiteKernel, the lifecycle contract, the dogfood file,packages/runtime,packages/verify.Fork check (card: stop if the contract forbids it or
destroy()is unsafe afterkernel:ready)packages/spec/src/contracts/plugin-lifecycle-events.tssays nothing about a failed boot and does not forbid destroying plugins after one;content/docs/protocol/kernel/lifecycle.mdxalready namesdestroy()"as rollback for plugins already started when a later plugin's start() fails" and thebootPhaseHooksTSDoc already says that adestroy()which assumes its boot-phase handler ran "must tolerate that it did not".destroy()of the plugins that register a boot-phase hook:HonoHttpServer.close()returns when it never listened,DefaultDatasourcePlugin.destroyis best-effort arounddisconnect,AuthPlugin.destroyonly nulls its manager, the job and queue services use optional chaining. No plugin found unsafe after a partial boot.Pins (
packages/core/src/kernel.failed-boot-rollback.test.ts, 21 tests)kernel:ready,kernel:bootstrapped,kernel:listeningand at the system-requirement check each destroys the started plugins in reverse start order and disconnects the fake driver connected instart()(exact sequence, so a double destroy reads red).destroy()that throws during the rollback neither replaces the boot error (identity) nor stops the plugin started before it.stopping, and ashutdown()called from inside adestroy()is absorbed.start()that throws) destroys each started plugin once, in reverse order, and never the plugin whosestart()threw.shutdown()after a failure at each of the four sites destroys nothing more.rollbackOnFailure: falseleaves the plugins and the driver untouched at each site.shutdown()that began mid-boot owns the destroys: the failed boot adds none.Ablation (committed fix first; each mutation through
scripts/ablation-replace.mjs, which proves the anchor hit, the blob change and the restore by blob equal toHEADand an emptygit diff HEAD)The test imports
./kernelby relative path, so there is nodist/leg to rebuild.await this.rollbackStartedPlugins()destroy()pin, thestoppingpinshutdown()after failure x4, original rejection x4,rollbackOnFailure: falsex4, mid-bootshutdown(), Phase 1state === 'running'shutdown()owns the destroysrollbackOnFailuregaterollbackOnFailure: falsex4this.state = 'stopping'stoppingpinthis.startedPlugins.clear()fromrollbackStartedPluginsThe observed direction is the usual one (red). Direction is not a template here: the first row's 15 greens are by design, each pins what a failed boot must NOT do and holds with or without the new call.
Verification (head b2a5be7,
origin/mainmerged at 286eb9e)pnpm --filter @objectstack/core typecheck: exit 0 (tsc, the examples project,check:test-typecheckOK with the 4 pinned legacy errors unchanged).@objectstack/coretests:localproject 101 files / 2494 tests passed;repoproject 6 files / 83 tests passed.--no-inline-config) on the two changed source files: 0 errors, 0 warnings; 2 files read from--format json; type-aware linting is not enabled (parserOptionscarries noproject), so this diff cannot move a verdict on an untouched file.node scripts/pm/dispatch-gates.mjs --commands(63), the artifact-roster block (53) and the wide-population block (11), run after a fullturbo run build: 123 of 127 exit 0. The four that exit non-zero need a PR number, a PR body or a workflow event payload (check-closing-target-claim,check-partof-closing-keyword,check-single-claim-paths,pm/check-governed-queue-guard): NOT MEASURED locally, their self-tests exit 0. Two gates exited 3 (prerequisite: nodist/) on the first pass and exit 0 after the build.organization-ownership-boot-refusal-order.dogfood.test.tsrun against the rebuilt core: 4 of 4 passed, and its refused boot now logsBootstrap failed after plugins started — rolling back started plugins...once and noRollback failed for.Acceptance notes
content/docs/protocol/kernel/lifecycle.mdx(thedestroy()comment and therollbackOnFailure: truebullet) describes only the failing-start()rollback. Outside this card's file surface; not edited.rollbackStartedPlugins()awaits eachdestroy()with no deadline (shutdown()hasshutdownTimeout), so adestroy()that never settles holdsbootstrap()open. That was already true for the Phase 2 rollback and is now true for a late failure too. Not probed, not filed.start()is never instartedPlugins, so neither rollback destroys it (for example one that connects ininit()). Existing contract of "started", unchanged.LiteKernelhas no rollback at all (not on a failingstart(), not on a late failure; itsdestroy()returns early oncestopped). A different contract from the one this card extends; not read as a defect, not probed.Generated by Claude Code