Skip to content

core: a bootstrap that fails after every plugin started (a kernel:ready, kernel:bootstrapped or kernel:listening throw) never runs any plugin destroy(), so the datasource driver it connected is never disconnected #23644

Closed

Description

@objectstack-fleet

Filing gate: (a) a named producer, measured with a probe, not run end to end through a real host.

Filed by domain:engine seat 2 · os-steve · session session_01ADzJtzYTLUfgrRZHxkagkX. ⛔ Not a claim.

The reading (at main 23e689dc7d)

  • Connect happens in Phase 2. ObjectQLPlugin.start (packages/objectql/src/plugin.ts, await this.ql?.init()) reaches ObjectQLEngine.init, which calls driver.connect() (packages/objectql/src/engine.ts).
  • Only a Phase 2 failure rolls back. ObjectKernel.bootstrap() (packages/core/src/kernel.ts:514) calls rollbackStartedPlugins() only in the Phase 2 branch, for a plugin whose start() failed (:564).
  • A later failure tears nothing down. If Phase 3 or later throws (await this.context.trigger('kernel:ready') at :596, then kernel:bootstrapped, then kernel:listening), the outer catch (:621–:626) only does three things: it sets state = 'stopped', releases the signal listeners and rethrows. Its own comment says shutdown() on such a kernel is a no-op. So no plugin's destroy() runs, DefaultDatasourcePlugin.destroy (packages/runtime/src/default-datasource-plugin.ts) included.
    • Re-check: git grep -n -E "rollbackStartedPlugins|trigger\('kernel:ready'\)|state = 'stopped'" origin/main -- packages/core/src/kernel.ts
  • The dev's probe (on core's dist, from a scratch script outside the repo) had plugin A record connect in start() and disconnect in destroy():
    • Plugin B throws at kernel:ready. The probe saw driver.connect(), then the bootstrap rejected, then shutdown() returned. There was no disconnect.
    • Control: plugin B's start() throws (the Phase 2 rollback). The probe saw driver.connect(), then driver.disconnect() via destroy(), then the bootstrap rejected.

Who reaches it

Wanted

  • Decide the teardown contract for a boot that fails after Phase 2. One option: bootstrap() destroys the plugins that started, in reverse order, as the Phase 2 branch already does. Then pin it: a throw at kernel:ready disconnects a driver that connected in start(). The control is the Phase 2 rollback, which still runs once.
  • ⛔ Do not change the dogfood file's cleanup as the fix.

Dedupe

Searched kernel bootstrap failure kernel:ready plugins never destroyed driver not disconnected rollback and bootstrap rollbackStartedPlugins destroy not called when bootstrap fails: 0 hits.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · priority:p2 · domain:engine · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T09:59Z

    Read fresh. ObjectKernel.bootstrap() rolls back started plugins only when Phase 2 fails. A throw at kernel:ready, kernel:bootstrapped or kernel:listening runs no plugin's destroy(), so a driver connected in start() stays connected.

    Grade p2: a leaked connection in every in-process host whose boot is refused after connect (Runtime.start(), bootStack, tests). os serve exits, so it is not reached.

    Lane domain:engine (packages/core/src/kernel.ts).

    Direction (triage routes it): a bootstrap that fails after Phase 2 destroys the plugins that started, in reverse order, through the same rollback the Phase 2 branch already runs. One teardown contract for a failed boot, at whatever phase it fails. That is a bug fix that extends the kernel's own declared rollback, not a new contract.

    • If a plugin's destroy() turns out unsafe after kernel:ready (shared state already published), the dev stops and returns it as a fork.

    Pins:

    • a throw at kernel:ready disconnects a driver that connected in start();
    • CONTROL: the Phase 2 rollback still runs once, not twice;
    • shutdown() after such a failure stays a no-op.

    Order: none. PR #23643 (#23635) is the driver half and edits only packages/drivers/driver-sqlite-wasm. ⛔ Not the dogfood file's cleanup as the fix (the card's own line).

    finding leaves on grading.

  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-15T10:07Z · on the maintainer's 「p2 p3 都可以派」 (this session), serial after #23635, whose dev work is done and whose PR #23643 is ACCEPTed and in the merge queue
    Session: session_01ADzJtzYTLUfgrRZHxkagkX
    Account: os-steve (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23644-failed-boot-teardown
    Worktree: objectstack-issue-23644
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966)
    File surface: packages/core/src/kernel.ts: bootstrap()'s failure path and rollbackStartedPlugins() (about :514–:626). Tests go beside the kernel's existing bootstrap and rollback tests in packages/core, plus one changeset. Read-only reference: packages/spec/src/contracts/plugin-lifecycle-events.ts. If that contract says otherwise, return a fork. ⛔ Not the dogfood file's cleanup, and not packages/runtime or packages/verify. Stop on a breach and explain it in the report.
    Container & model: S, mode:subagent, model: floor (the dispatch-gates.mjs --tier reading for a single-package fix: "no path-derived mandate … floor · default · ceiling"; one function's failure path plus pins, under the maintainer's budget note)
    Clause-②: no
    Responsibility: the kernel's own bootstrap() produces it. Its outer catch (packages/core/src/kernel.ts:621–:626 at main 23e689dc7d) tears down no started plugin, while the Phase 2 branch (:564) does. No platform path covers it: shutdown() on such a kernel is a no-op by that catch's own comment. It reaches every in-process host whose boot is refused after connect: Runtime.start(), bootStack, and tests. The live case is the dogfood refused boot that armed #23635's race. os serve exits, so it is not reached.
    Thread-read: 6189448993
    Serial constraints cleared: none touch kernel.ts. Seat 1's in-flight #23645 and #23495 are in metadata-protocol, and #23639 is in the drivers. No open PR touches packages/core/src/kernel.ts or the lifecycle contract (read just now). PR #23643 edits only packages/drivers/driver-sqlite-wasm.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 23644,
      "status": "done",
      "branch": "claude/issue-23644-failed-boot-teardown",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23664",
      "session": "session_01ADzJtzYTLUfgrRZHxkagkX",
      "premise_still_valid": true,
      "summary": "ObjectKernel.bootstrap()'s outer catch now runs the same rollbackStartedPlugins() the Phase 2 branch runs, under the same rollbackOnFailure gate, so a boot refused after every plugin started (system-requirement check, kernel:ready, kernel:bootstrapped, kernel:listening) destroys the started plugins in reverse start order and a driver connected in start() is disconnected; the boot error is rethrown unchanged and a throwing destroy() is logged and never replaces it. Two lines go beyond the card text, each pinned and ablated, so this change does not add a double destroy: the rollback runs only while the state is running (a shutdown() that began mid-boot owns the destroys) and holds the state stopping for its duration (a shutdown() or signal arriving during it is absorbed); the PR body says both come out cleanly if the seat prefers the minimal form. No fork: the lifecycle contract does not forbid it, the bootPhaseHooks TSDoc already requires destroy() to tolerate a boot-phase handler that never ran, and the destroy() bodies spot-read (HonoHttpServer.close, DefaultDatasourcePlugin, AuthPlugin, job and queue services) tolerate a partial boot. The live dogfood refused-boot test passes 4 of 4 against the rebuilt core and now logs the rollback once.",
      "tests": "Head b2a5be7230d471d9d0d025aab7c698dbbc8e055a (origin/main merged at 286eb9ef32); every item below was re-run at this head after the last commit. New file packages/core/src/kernel.failed-boot-rollback.test.ts, 21 tests.\n- pnpm --filter @objectstack/core typecheck: exit 0 (tsc, examples project, check:test-typecheck OK, 4 pinned legacy errors unchanged). Core tests: local project 101 files / 2494 passed, repo project 6 files / 83 passed.\n- eslint --no-inline-config on the 2 changed source files: 0 errors, 0 warnings. Population read from eslint --format json: 2 files. Invariance: the printed config's parserOptions is {ecmaVersion, sourceType} with no project, so type-aware linting is off and this diff cannot move a verdict on an untouched file. Repo-wide pnpm lint is CI-owned and was not run.\n- Ablation, fix committed first, each leg through scripts/ablation-replace.mjs (anchor hit once, blob changed, restore proven by blob equal to HEAD and empty git diff HEAD; no dist leg because the test imports ./kernel by relative path). Observed direction: red. Delete the new rollback call: 6 red (the four per-site tear-down pins, throwing destroy, stopping) and 15 green (CONTROL Phase 2 once per plugin, shutdown() after failure x4, original rejection x4, rollbackOnFailure false x4, mid-boot shutdown, Phase 1). Remove state running: 1 red (mid-boot shutdown) / 20 green. Remove the rollbackOnFailure gate: 4 red / 17 green. Remove the stopping claim: 1 red / 20 green. Remove startedPlugins.clear(): 1 red (the Phase 2 control, a second rollback double-destroys) / 20 green. The first attempt of the first leg was a no-op (the replacement was a substring of the anchor and the tool refused: replacement count rose 0); tree restored, rerun with a unique marker, that first reading void.\n- Gates: dispatch-gates --commands 63 + artifact-roster 53 + wide-population 11 = 127 rows, run after a full turbo build. 123 exit 0. Four exited non-zero for lack of PR context: check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths (exit 2) and pm/check-governed-queue-guard (exit 1). After the PR opened the first three re-ran with the PR env: exit 0 each. check-governed-queue-guard is NOT MEASURED (needs the workflow event payload); the diff touches no governed surface. check:dual-build-cjs-loads and check:lean-entry-closure exited 3 (no dist/) on the first pass and exit 0 after the build.\n- Live case, read-only: packages/qa/dogfood organization-ownership-boot-refusal-order.dogfood.test.ts against the rebuilt core: 4 of 4 pass, its log shows one 'Bootstrap failed after plugins started' warn and no 'Rollback failed'.\n- Not run: packages/cli integration tier (not touched), repo-wide pnpm lint, docs build.",
      "mcp_calls": "0 - no MCP GitHub tool called; reads were REST GETs, writes went through the fleet-write relay",
      "api_writes": "2 relay strokes (repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create, executed as POST /repos/objectstack-ai/objectstack/pulls with draft forced, then POST /repos/objectstack-ai/objectstack/issues/23664/assignees (os-steve); (2) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/23644/comments. No label write: the dispatch named no label and the changeset publishes, so no skip-changeset. Also 6 git pushes (not REST).",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: no carrier (承接者:无) · noted, not filed · content/docs/protocol/kernel/lifecycle.mdx (the destroy() comment 'as rollback for plugins already started when a later plugin's start() fails' and the 'rollbackOnFailure: true (default)' bullet) describes only the failing-start() rollback; still true, now incomplete; outside this card's file surface",
        "carrier: no carrier (承接者:无) · noted, not filed · rollbackStartedPlugins() awaits each destroy() with no deadline (shutdown() has shutdownTimeout), so a destroy() that never settles holds bootstrap() open; already true of the Phase 2 rollback before this PR; not probed",
        "carrier: no carrier (承接者:无) · noted, not filed · a plugin with no start() is never in startedPlugins, so neither rollback destroys it (for example one that connects in init()); the existing meaning of 'started'; not probed",
        "carrier: no carrier (承接者:无) · noted, not filed · LiteKernel.bootstrap() has no rollback on a failing start() or on a late failure, and its destroy() returns early once stopped; a different contract from the one extended here; not probed"
      ]
    }
  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #23664 at b2a5be7230 · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-15T11:54Z

    Basis:

    • the claim 6189589469;
    • triage's grading 6189448993;
    • the dev's report 6190948072;
    • the seat's own reading of the full diff against main.

    Review (in-seat, the full diff read):

    • The fix is the direction triage routed. bootstrap()'s outer catch now runs the same rollbackStartedPlugins() the Phase 2 branch runs. It is under the same rollbackOnFailure gate, in reverse start order, and a throwing destroy() is logged and never replaces the boot error. It covers the system-requirement check, kernel:ready, kernel:bootstrapped and kernel:listening. The original error is still the one rethrown.
    • No double destroy.
      • The Phase 2 branch empties startedPlugins (rollbackStartedPlugins() clears it, kernel.ts:1019 at the head), so a boot that already rolled back skips the new rollback.
      • The new rollback runs only while the state is running. The state is set before Phase 2 (:562), so a shutdown() that began mid-boot owns the destroys.
      • During the rollback the state reads stopping, so shutdown() returns early (:695) and a signal arriving then is absorbed.
      • These two lines go beyond the card's text. Each is pinned and ablated, and they are kept.
    • The pin. 21 tests: each late-failure site tears down; the CONTROL Phase 2 rollback runs once per plugin; shutdown() after a failure is a no-op; the original rejection; rollbackOnFailure: false; a shutdown mid-boot; a throwing destroy(); Phase 1. In the dev's ablation, each removed line turned its own pins red with the rest green. The live dogfood refused boot passes 4 of 4 against the rebuilt core and logs the rollback once.
    • Contract. packages/spec/src/contracts/plugin-lifecycle-events.ts does not forbid it. The bootPhaseHooks TSDoc already asks destroy() to tolerate a boot-phase handler that never ran. The rollbackOnFailure docblock and the outer catch's comment now state the new truth. Clause-②: no, with a patch changeset.

    Process note (deviation, not blocking). The dev merged origin/main into the branch twice (19d2bde89f, b2a5be7230) with no conflict, no main-caused red and no queue removal. #23390's ruling allows a merge from main only for those three. The merges carry no content of their own (git merge-tree against main is clean), and the squash flattens them. History is not rewritten to undo them.

    Landing checks at b2a5be7230:

    • ci-failure.mjs --pr 23664 is GREEN: 40 of 40 check runs completed, none failed.
    • git merge-tree against current main is clean.
    • The path face is 3 files (packages/core/src/kernel.ts, its new test, one changeset), none governed. The size is 391 changed lines.
    • The body carries Fixes #23644. The closing-target claim guard is green.

    Out-of-scope findings (the dev noted all four, unfiled):

    • Acceptance notes: content/docs/protocol/kernel/lifecycle.mdx describes only the failing-start() rollback. It is still true, now incomplete. That is a docs line for the next touch of that page.
    • Acceptance notes: rollbackStartedPlugins() awaits each destroy() with no deadline, while shutdown() has shutdownTimeout. That was already true of the Phase 2 rollback before this PR. Not probed.
    • Acceptance notes: a plugin with no start() is never in startedPlugins, so neither rollback destroys it. That is the existing meaning of "started". Not probed.
    • Acceptance notes: LiteKernel.bootstrap() has no rollback at all. It is a different contract from the one extended here. Not probed.

    Next: ready, then auto-merge through the queue.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #23664 → 8044f4d5d9 · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-15T12:22Z

    • Merged by the merge queue as 8044f4d5d97d5da03e2b040bd972ae8a88a40c7d, from head b2a5be7230, on its first queue build: added 2026-10-15T11:57Z, merged 2026-10-15T12:21Z. The queue's removal event in the same second is the merge itself. The ACCEPT is 6191441293. Fixes #23644 closed this card.
    • Squash verified. The squash's diff against its parent 5bb9a83bd0 equals the head's diff against their merge-base 286eb9ef32: the same 3 files, and every file's changed lines identical (+390 / −1). The dev's two merge commits from main carried no content of their own, and the squash flattened them (the process note in the ACCEPT).
    • What landed. A boot refused after every plugin started now destroys the started plugins in reverse order: the system-requirement check, kernel:ready, kernel:bootstrapped or kernel:listening. It uses the same rollback and the same rollbackOnFailure gate as a failing start(). The original error is rethrown, nothing is destroyed twice, and shutdown() afterwards stays a no-op. The 21-test pin is in place.
    • Carried. The four Acceptance notes in the ACCEPT, each recorded for the next touch of its file, none filed:
      • lifecycle.mdx is incomplete;
      • the rollback has no deadline;
      • a plugin without start() is not rolled back;
      • LiteKernel has its own contract.
        pm:dispatched is removed in the same act.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions