Skip to content

fix(ocap-kernel): keep the kernel's account of a vat's life in one piece - #1023

Closed
sirtimid wants to merge 18 commits into
sirtimid/gc-delivery-hardeningfrom
sirtimid/vat-lifecycle-consistency-v2
Closed

sirtimid wants to merge 18 commits into
sirtimid/gc-delivery-hardeningfrom
sirtimid/vat-lifecycle-consistency-v2

Conversation

@sirtimid

@sirtimid sirtimid commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1022, which is stacked on #1021; review those first. #1020 has merged. Replaces #1019, rebased onto the split stack.

Last of four. Closes #1015.

The defect

Only kernelStore.deleteVat() removes vatConfig.<vatId>. cleanupTerminatedVat sweeps ${vatId}.-prefixed keys, which never match vatConfig.<vatId>. So the two writes that together mean "this vat is dead" — deleteVat and markVatAsTerminated — both had to land, and nothing made them.

They were spread across four functions with awaits between them: VatManager.stopVat, #endVat's finally, VatHandle.terminate, and a lambda in Kernel.ts. A throw part-way left the vat marked terminated with its config alive. That reads as active again the moment cleanupTerminatedVat calls forgetTerminatedVat, at which point KernelRouter.#resolveEndpoint sees a vat the store calls live and the kernel has no handle for, rethrows, and kills the run loop. initializeAllVats then resurrects the vat on the next process start.

Two more, found alongside it:

  • A vat whose stream failed tore itself down. VatHandle.#init's drain catch called this.terminate(true, …), which did deleteVat but never markVatAsTerminated and never removed the handle from VatManager.#vats. The router went on resolving the handle successfully, the write went nowhere, and since the vat RPC client has no timeout the crank never completed — the run loop hung while getRunLoopStatus() still reported running.
  • A vat reported its dropped imports a bringOutYourDead late. makeGCAndFinalize called gc() without draining the queues first. A pending continuation still holds its closure's objects, so a sweep with work outstanding finds them reachable and the vat reports nothing on the BOYD that provoked it.

Approach

VatManager.#retireVat — the store side of a vat's death, in one synchronous step. Reject the promises it was deciding, unpin its root, deleteVat, markVatAsTerminated, with no await between them, so the half-written state cannot arise. Modelled on SwingSet's terminateVat (kernel.js:345-412) and its comment at :348 about the "synchronous prelude". Killing the worker is deliberately not part of it: that can fail, and a store that says the vat is dead is worth more than a store still waiting to find out.

#endVat and #abandonVat were both partial copies of this and are gone. Kernel.ts's lambda and launchVat lose their trailing markVatAsTerminated. VatHandle.terminate is left with only its own channel to close, and rejects its pending RPCs ahead of ending the stream rather than after, so a stream that will not close does not strand callers on a worker that is already dead.

stopVat(vatId, true, …) tolerates a missing handle. A restart needs a live handle to read its config from; an ending vat does not, and must not — the vat may be one the store still lists while the kernel has lost its handle, which is exactly what SubclusterManager.terminateSubcluster can hand it.

A fatal stream error is routed through the manager, via a new required onCriticalFailure prop on VatHandle. Only the manager can put the vat's death on record and drop the handle.

The run loop carries out a restart itself, as a queued restartVat item, so a vat is never out of the kernel's reach while cranks run.

#1015 is closed here, both halves: getImporters now counts remotes and terminated vats cleanup has not reached, so retiring an object queues a retireImport for each rather than deleting the object and leaving their c-list entries naming nothing. #1022 supplied the exemption site and the dangling discriminant; this supplies the missing importers. The vat half was raised by @grypez in review — see below.

Two conflicts the rebase surfaced, resolved on the merits

Both were real disagreements between this branch and the stack beneath it, not textual noise. They are in their own commit, 363c3e103.

maybeFreeKrefs was being cleared, not restored. #1021's revertStateBeneathRollback empties the set on rollback; this branch restores the savepoint's snapshot. Restoring is correct, and #1021's own comment admits the caveat ("correct only while every rollback discards the whole delivery"): the set is not per-crank — only collectGarbage empties it — so a candidate added while the run loop was idle, which terminateVat unpinning a root produces, was owed a collection and lost it to an unrelated crank's rollback. This branch introduces exactly that path. #1021's unit test had encoded the clear() behaviour and now distinguishes a pre-savepoint kref from one the abandoned crank added.

A fix from #1022 was regressed. #1022 marks a vat terminated unconditionally after a failed launch; this branch relied on stopVat reaching #retireVat to record it. But stopVat refuses a vat the kernel has no handle for and the store does not call active — which is what a partial launch looks like. The unconditional mark is asserted again.

Three fixes from review

Bugbot flagged four things on this branch; three are fixed here and the fourth is argued below.

A vat's death could not be recorded twice, and one path recorded it twice. deleteVat removes the vat's subcluster mapping, and removeVatFromSubcluster fails on a vat that has none, so the second call threw. performVatRestart reaches that whenever a relaunch breaks the stream: onCriticalFailure retires the vat, runVat rethrows, and the catch retires it again — so the throw escaped the one catch in this PR that must not throw, rolling back the termination records and returning the failing restart to the queue to be replayed forever. #retireVat is now a no-op the second time. Invisible to the suite until now, because the mock store's deleteVat was a bare vi.fn(); it refuses a repeat the way the real one does.

Two concurrent restart requests queued two items and kept one waiter. The first crank consumed the waiter and handed its caller a live handle; the leftover item then stopped that worker and, on a failed relaunch, retired the vat the caller had just been told about. A request arriving while one is still queued now takes over its item. The existing supersede test could not catch it — it stubs enqueueRestartVat to a no-op and drives performVatRestart once.

getImporters could not see a terminated vat still holding its imports. @grypez's finding, verified by execution; their repro reproduces byte for byte and their suggested fix is the one taken. Details in the review thread.

Both restart fixes are mutation-verified: revert the production hunk and the named test fails for the stated reason.

The fourth: a rolled-back crank can un-record a vat's death

Not fixed here, and I do not think it should be without a decision. onCriticalFailure drops the handle from #vats — plain RAM — and records the death in the store. Those store writes land inside the delivery savepoint, so a delivery that then throws rolls them back while the handle stays gone. Confirmed against a real SQLite store: after rollbackCrank('delivery') the store reports isVatActive: true, isVatTerminated: false, which is the store-says-live, kernel-has-no-handle disagreement this PR exists to prevent, and #resolveEndpoint turns it into a dead run loop.

#processCrankResult already has the mechanism this wants, and says so at the call site: crankResult.terminate runs after the rollback, inside crank, because "its writes must outlive the rollback above". Routing a mid-delivery stream death through that rather than writing the store from the callback is the fix I would argue for — but it reshapes the boundary between a delivery and the retirement that discovered it, and getting it wrong reintroduces the hang this PR removes. Worth its own change.

Testing

yarn lint clean, yarn build 31/31, changelog:validate clean. @metamask/ocap-kernel and @ocap/kernel-test fully green, with auditRefCounts on for every kernel kernel-test builds.

Note for reviewers of #1021: garbage-collection.test.ts › an object shared by two importers › survives until both importers let go was intermittently flaky on the branches beneath this one. The makeGCAndFinalize fix here is what addresses it.

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, README.md, CHANGELOG.md) as appropriate

Note

High Risk
Changes core run-loop delivery, vat lifecycle/teardown, and store/crank boundaries; mistakes can resurrect vats, hang cranks, or kill the run loop on store/kernel disagreement.

Overview
Unifies vat death into a single synchronous #retireVat path (reject decider promises, unpin root, deleteVat, markVatAsTerminated) so partial termination cannot leave vatConfig alive while the vat is marked dead. Termination runs under #trackFlux / withStoreOutOfCrank instead of interleaved awaits; fatal stream errors go through VatHandle.onCriticalFailure to the manager, with optional reassertion out of crank if the death was rolled back with the handle already dropped.

Moves vat restart onto the run queue (restartVat items, enqueueRestartVat, performVatRestart) so no crank sees a vat between workers. Callers wait via #awaitRestart and KernelQueue.onRunLoopDeath when the loop dies first. Endpoint lookup is async (provideVat) and KernelRouter.#resolveEndpoint only drops work when a vat/remote is gone for good—not when the store still considers the vat active.

Hardens delivery and GC routing: result promises are rejected only if still unresolved; notify/BOYD/GC skip or propagate lookup failures appropriately; GC re-reads the c-list after awaiting the endpoint. makeGCAndFinalize drains microtasks before sweeping so BOYD reports dropped imports on the right round.

Supporting changes include refusing releaseSavepoint inside a crank, tests for transaction _spStack after failed rollbacks, and expanded integration tests for crank rollback vs termination/restart.

Reviewed by Cursor Bugbot for commit 2005001. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread packages/ocap-kernel/src/vats/VatManager.ts
sirtimid added a commit that referenced this pull request Aug 13, 2026
Recording the vat's death only saves the deliveries that come after it. The one
in flight when the worker died stays parked on an RPC client with no timeout, so
its crank never completes — the same hang `onCriticalFailure` exists to prevent,
one delivery earlier. The worker was left running too, since nothing else would
stop it once the handle was off the books.

Found by Cursor Bugbot on #1023.

Also reverts this branch's additions to the extension control-panel e2e test.
They asserted `ko6.refCount` directly, and which vat owns `ko6` is not stable:
#983 launches subcluster vats in parallel, so the root krefs fall in completion
order. The behaviour they checked is covered by the refcount audit, which runs
on every kernel `kernel-test` builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/ocap-kernel/src/vats/VatManager.ts Outdated
Comment thread packages/ocap-kernel/src/vats/VatManager.ts
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 72.72%
⬆️ +0.67%
9665 / 13289
🔵 Statements 72.56%
⬆️ +0.67%
9821 / 13534
🔵 Functions 73.17%
⬆️ +0.30%
2270 / 3102
🔵 Branches 66.78%
⬆️ +0.98%
3943 / 5904
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/ocap-kernel/src/Kernel.ts 89.84%
⬆️ +0.08%
79.54%
⬆️ +0.97%
85.41%
🟰 ±0%
89.84%
⬆️ +0.08%
332-334, 405, 429, 504-514, 602, 676, 752-755, 768, 778-779, 832, 855
packages/ocap-kernel/src/KernelQueue.ts 98.02%
⬇️ -0.54%
89.47%
⬇️ -0.80%
100%
🟰 ±0%
98.02%
⬇️ -0.54%
152, 202, 582
packages/ocap-kernel/src/KernelRouter.ts 94.73%
⬆️ +0.80%
85.26%
⬆️ +6.80%
100%
🟰 ±0%
94.7%
⬆️ +0.77%
127, 190, 207, 282, 337, 397, 424, 427, 541
packages/ocap-kernel/src/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/garbage-collection/gc-finalize.ts 77.27%
⬆️ +2.27%
75%
🟰 ±0%
100%
🟰 ±0%
77.27%
⬆️ +2.27%
27-31, 72-76
packages/ocap-kernel/src/store/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/crank.ts 98%
⬇️ -2.00%
88.88%
⬇️ -4.87%
100%
🟰 ±0%
98%
⬇️ -2.00%
126
packages/ocap-kernel/src/store/methods/remote.ts 98.48%
⬆️ +0.05%
100%
🟰 ±0%
100%
🟰 ±0%
98.48%
⬆️ +0.05%
105-109
packages/ocap-kernel/src/store/methods/vat.ts 98.44%
⬆️ +1.15%
89.47%
⬆️ +7.66%
100%
🟰 ±0%
98.43%
⬆️ +1.16%
298-299
packages/ocap-kernel/src/vats/VatHandle.ts 90.76%
⬆️ +0.62%
86.66%
🟰 ±0%
100%
🟰 ±0%
90.76%
⬆️ +0.62%
383-388, 397-403
packages/ocap-kernel/src/vats/VatManager.ts 97.98%
⬇️ -2.02%
97.5%
⬇️ -2.50%
91.17%
⬇️ -8.83%
97.97%
⬇️ -2.03%
358-360, 392-403
Generated in workflow #4648 for commit c9b917b by the Vitest Coverage Report Action

sirtimid added a commit that referenced this pull request Aug 13, 2026
… exists

`onCriticalFailure` closed over the binding `VatHandle.make` was still to return, so a stream that broke during `#init` threw on the temporal dead zone — after the vat had been retired, and before anything rejected the pending `initVat` that nothing else will ever settle. The handle is now passed to the callback, and `runVat` refuses to put a handle on the books for a vat retired while it was being made.

The teardown also awaited the worker kill before `terminate`, which is what rejects the vat's pending RPCs. A worker slow to die — or one that never does — kept the parked delivery parked, which is the hang this path exists to clear. The two now run alongside each other, `terminate` first.

Found by Cursor Bugbot on #1023.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SherfeyInv pushed a commit to SherfeyInv/ocap-kernel that referenced this pull request Aug 17, 2026
…hem (Consensys-Incorporated#1024)

Fixes a pre-existing e2e flake on `main`, surfaced while rebasing the
Consensys-Incorporated#1020Consensys-Incorporated#1023 stack. Small and self-contained so the whole stack inherits
it.

## The defect

`control-panel.test.ts` › `should collect garbage` asserted that Carol's
root object is `ko6` and Bob's is `ko5`, and that their promises are
`kp4` and `kp3`:

```js
'{"key":"ko6.owner","value":"v3"}',
'{"key":"v3.c.ko6","value":"R o+0"}',
```

Since Consensys-Incorporated#983, subcluster vats launch **in parallel**. Each vat's root is
exported when its own launch finishes, so which of `ko5`/`ko6` belongs
to Bob and which to Carol changes between runs. When they come back the
other way round, the test fails — and `database-inspector.test.ts` fails
alongside it, because it reads the same kv dump.

Observed directly: a failing run had `ko6.owner = v2` and `ko5.owner =
v3`, the exact inverse of what is asserted.

The vat ids themselves are stable — they are handed out in config order,
so alice is always `v1` — so only the object and promise krefs need
deriving.

## Approach

Three small helpers read the dump and look up what the assertions used
to hardcode: `rootKrefOf(dump, vatId)` by owner, `promiseKrefOf(dump,
vatId)` by c-list entry, and `erefOf(dump, vatId, kref)`.

The erefs are derived in **full** rather than matched by prefix. A
c-list entry's reverse direction is keyed by eref and valued by kref, so
a loose `,"value":"ko5"}` also matches the *owning* vat's own `v2.c.o+0`
entry. That passed while both vats were alive and broke the negative
assertions the moment one outlived the other — which is what the test
checks after terminating v3.

## Testing

`yarn lint` clean. Extension e2e run three times: the kref failure is
gone, and the two clean runs finish in ~50s rather than ~2.7m because no
retries are needed.

**What this does not fix.** The extension e2e suite has separate
instability that this change does not touch and does not claim to:
`object-registry.test.ts` failures, and a UI timing flake where
`Terminated vat "v1"` does not render because the panel is still showing
query output. One of the three runs hit those. They are unrelated to
kref assignment and were present before this change.

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, `README.md`, `CHANGELOG.md`) as
appropriate — test-only change, no changelog entry

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only change to e2e assertions and helpers; no production or
runtime behavior is modified.
> 
> **Overview**
> Fixes flaky **`should collect garbage`** assertions in
`control-panel.test.ts` that assumed fixed kernel refs (`ko5`/`ko6`,
`kp3`/`kp4`) for Bob and Carol. Parallel subcluster launches mean those
object and promise krefs can swap between runs while vat ids (`v2`/`v3`)
stay stable.
> 
> Adds helpers to parse the Database Inspector kv dump and **derive**
root krefs (via `.owner`), promise krefs (via c-list), and v1’s
**erefs** (full c-list lookup so reverse entries don’t false-match). The
garbage-collection expectations are built from those values instead of
literals.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d8e81f7. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Aug 17, 2026
Recording the vat's death only saves the deliveries that come after it. The one
in flight when the worker died stays parked on an RPC client with no timeout, so
its crank never completes — the same hang `onCriticalFailure` exists to prevent,
one delivery earlier. The worker was left running too, since nothing else would
stop it once the handle was off the books.

Found by Cursor Bugbot on #1023.

Also reverts this branch's additions to the extension control-panel e2e test.
They asserted `ko6.refCount` directly, and which vat owns `ko6` is not stable:
order. The behaviour they checked is covered by the refcount audit, which runs
on every kernel `kernel-test` builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Aug 17, 2026
… exists

`onCriticalFailure` closed over the binding `VatHandle.make` was still to return, so a stream that broke during `#init` threw on the temporal dead zone — after the vat had been retired, and before anything rejected the pending `initVat` that nothing else will ever settle. The handle is now passed to the callback, and `runVat` refuses to put a handle on the books for a vat retired while it was being made.

The teardown also awaited the worker kill before `terminate`, which is what rejects the vat's pending RPCs. A worker slow to die — or one that never does — kept the parked delivery parked, which is the hang this path exists to clear. The two now run alongside each other, `terminate` first.

Found by Cursor Bugbot on #1023.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/vat-lifecycle-consistency-v2 branch from 93efa62 to c9b917b Compare August 17, 2026 10:08

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/vats/VatManager.ts Outdated
sirtimid added a commit that referenced this pull request Sep 8, 2026
Recording the vat's death only saves the deliveries that come after it. The one
in flight when the worker died stays parked on an RPC client with no timeout, so
its crank never completes — the same hang `onCriticalFailure` exists to prevent,
one delivery earlier. The worker was left running too, since nothing else would
stop it once the handle was off the books.

Found by Cursor Bugbot on #1023.

Also reverts this branch's additions to the extension control-panel e2e test.
They asserted `ko6.refCount` directly, and which vat owns `ko6` is not stable:
order. The behaviour they checked is covered by the refcount audit, which runs
on every kernel `kernel-test` builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Sep 8, 2026
… exists

`onCriticalFailure` closed over the binding `VatHandle.make` was still to return, so a stream that broke during `#init` threw on the temporal dead zone — after the vat had been retired, and before anything rejected the pending `initVat` that nothing else will ever settle. The handle is now passed to the callback, and `runVat` refuses to put a handle on the books for a vat retired while it was being made.

The teardown also awaited the worker kill before `terminate`, which is what rejects the vat's pending RPCs. A worker slow to die — or one that never does — kept the parked delivery parked, which is the hang this path exists to clear. The two now run alongside each other, `terminate` first.

Found by Cursor Bugbot on #1023.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/vat-lifecycle-consistency-v2 branch from c9b917b to b8b2b45 Compare September 8, 2026 09:48

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/vats/VatManager.ts
Comment thread packages/ocap-kernel/src/vats/VatManager.ts
Comment thread packages/ocap-kernel/src/vats/VatManager.ts
@sirtimid
sirtimid requested a review from a team as a code owner September 11, 2026 11:38
sirtimid added a commit that referenced this pull request Sep 11, 2026
Recording the vat's death only saves the deliveries that come after it. The one
in flight when the worker died stays parked on an RPC client with no timeout, so
its crank never completes — the same hang `onCriticalFailure` exists to prevent,
one delivery earlier. The worker was left running too, since nothing else would
stop it once the handle was off the books.

Found by Cursor Bugbot on #1023.

Also reverts this branch's additions to the extension control-panel e2e test.
They asserted `ko6.refCount` directly, and which vat owns `ko6` is not stable:
order. The behaviour they checked is covered by the refcount audit, which runs
on every kernel `kernel-test` builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Sep 11, 2026
… exists

`onCriticalFailure` closed over the binding `VatHandle.make` was still to return, so a stream that broke during `#init` threw on the temporal dead zone — after the vat had been retired, and before anything rejected the pending `initVat` that nothing else will ever settle. The handle is now passed to the callback, and `runVat` refuses to put a handle on the books for a vat retired while it was being made.

The teardown also awaited the worker kill before `terminate`, which is what rejects the vat's pending RPCs. A worker slow to die — or one that never does — kept the parked delivery parked, which is the hang this path exists to clear. The two now run alongside each other, `terminate` first.

Found by Cursor Bugbot on #1023.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/vat-lifecycle-consistency-v2 branch from 5d9bd14 to f624a34 Compare September 11, 2026 13:06

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/vats/VatManager.ts
sirtimid and others added 16 commits September 15, 2026 13:12
The send path caught every endpoint lookup failure and treated it as a splat,
which its own TODO called out: an error that is not "this endpoint is gone"
silently discarded a deliverable message and rejected its result with
ENDPOINT_UNREACHABLE. It is now the last of the four delivery paths to go through
`resolveEndpoint`, so a splat happens where the endpoint will not be back — a
terminated vat, or a remote — and anything else propagates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd rollback

Four ways to kill or wedge the kernel, found reviewing this branch.

`rollbackCrank` emptied `maybeFreeKrefs` rather than restoring it. The set is
not per-crank — only `collectGarbage` empties it, at the end of a crank that had
an item — so a candidate created while the run loop was idle, as `terminateVat`
unpinning a root creates one, was owed a collection that any later crank's
rollback silently cancelled. Savepoints now carry the set as it stood when they
were taken. The audit cannot see this one: the counts stay self-consistent at 0.

A restart that could not relaunch its vat threw, and the run loop's catch rolls
back on any throw — undoing the termination records `performVatRestart` had just
written and returning the request to the run queue. Every subsequent process
start dequeued it and failed the same way. It now terminates the vat and reports
through the waiter, so the crank commits and the request is spent. The comment
claiming the throw preserved those records had the causality backwards.

Terminating a vat left a queued restart for it to be carried out against a vat
that no longer existed; `#restartVatWorker` is the one item type that does not
go through `#resolveEndpoint`, so the resulting `VatNotFoundError` propagated.
Restart-then-terminate is reachable from RPC. The waiter is now rejected when
the vat is terminated and the request dropped when the crank reaches it.

`cleanupTerminatedVat` ends by *unmarking* the vat it finished, so work
outliving it — a `bringOutYourDead` scheduled before it died, which nothing
purges from the reap queue — arrived at an endpoint that was neither present nor
terminated, which `#resolveEndpoint` reserves its throw for. It now asks whether
the store has a live record of the vat at all.

Also fixed, from the same review:

- `getImporters` counted only vats, so retiring an object deleted it without
  telling a remote importer, leaving a c-list entry naming nothing — which the
  audit reports as dangling, taking the run loop with it. Adds `getRemoteIds`.
- `#deliverGCAction` computed the live kref set before awaiting the endpoint and
  used it after. A remote re-handshaking in that window clears its c-list
  without waiting for the crank, and `krefsToErefs` throws rather than returning
  short.
- `#endVat` marks the vat terminated in a `finally`. A teardown that threw left
  it unmarked, which is the state above, and falsified `#trackFlux`'s stated
  invariant that waiters can read "gone" as terminated.
- Comments that no longer described the code: `provideVat` waiting on restarts
  (only teardown is recorded), `stopVat` tearing down "only the worker" (it
  releases the root pin, as of this branch), `clearStorage` terminating vats,
  the audit standing in for the disabled `retireExport` assert, and a stale
  `(1, 1)` baseline rationale. `#vatsInFlux` narrows to `Promise<void>`, which
  removes a branch of `provideVat` that could not be reached.

Tests: each fix has a regression test that fails against the code without it.
Closes the two coverage gaps the review named — the splat path charging the run
queue item's own target when routing went through a promise, and `ko6.refCount`
in the control-panel e2e, restored as three per-checkpoint values rather than
dropped as nondeterministic. Full unit suite, kernel-test with auditing on every
crank, and `test:e2e:ci` at 17/17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only `deleteVat` removes `vatConfig.<vatId>`, and `cleanupTerminatedVat`
sweeps `${vatId}.`-prefixed keys, which never match it. The writes making up
a vat's death were interleaved with awaits across `VatManager.stopVat`,
`#endVat`'s `finally`, `VatHandle.terminate` and a lambda in `Kernel.ts`, so a
throw part-way left the vat marked terminated with its config alive — which
reads as *active* again as soon as cleanup drops the mark, killing the run
loop over the disagreement and resurrecting the vat on the next process start.

`VatManager.#retireVat` now makes all four writes with no await between them,
modelled on SwingSet's synchronous prelude in `kernel.js` `terminateVat`;
worker teardown follows and is best-effort. `#endVat` and `#abandonVat` go as
duplicates of it, and `VatHandle.terminate` is left with only its own channel
to close.

A vat whose stream fails is retired by the manager, via a new
`onCriticalFailure`, rather than tearing itself down: that left the handle in
the manager and the vat live in the store, so the next delivery went to a
worker that could not answer and, the vat RPC client having no timeout, the
crank never completed while the run loop still reported itself running.

`makeGCAndFinalize` drains the queues before sweeping, since a pending
continuation still holds its closure's objects, so a vat reports its dropped
imports on the `bringOutYourDead` that provoked them rather than a later one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tial launch terminated

Two conflicts the rebase onto the GC-hardening stack surfaced, both real
disagreements rather than textual ones.

`revertStateBeneathRollback` cleared `maybeFreeKrefs` outright. The set is not
per-crank — only `collectGarbage` empties it — so a candidate added while the
run loop was idle, which `terminateVat` unpinning a root produces, was owed a
collection and lost it to an unrelated crank's rollback. It now restores the
savepoint's snapshot, which discards the abandoned crank's additions and keeps
everything that predates it. The unit test had encoded the old behaviour and is
updated to distinguish the two cases.

`launchVat`'s cleanup relied on `stopVat` reaching `#retireVat` to record the
death, but `stopVat` refuses a vat the kernel has no handle for and the store
does not call active — which is what a partial launch looks like. The mark is
asserted directly again, as it was before this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recording the vat's death only saves the deliveries that come after it. The one
in flight when the worker died stays parked on an RPC client with no timeout, so
its crank never completes — the same hang `onCriticalFailure` exists to prevent,
one delivery earlier. The worker was left running too, since nothing else would
stop it once the handle was off the books.

Found by Cursor Bugbot on #1023.

Also reverts this branch's additions to the extension control-panel e2e test.
They asserted `ko6.refCount` directly, and which vat owns `ko6` is not stable:
order. The behaviour they checked is covered by the refcount audit, which runs
on every kernel `kernel-test` builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… exists

`onCriticalFailure` closed over the binding `VatHandle.make` was still to return, so a stream that broke during `#init` threw on the temporal dead zone — after the vat had been retired, and before anything rejected the pending `initVat` that nothing else will ever settle. The handle is now passed to the callback, and `runVat` refuses to put a handle on the books for a vat retired while it was being made.

The teardown also awaited the worker kill before `terminate`, which is what rejects the vat's pending RPCs. A worker slow to die — or one that never does — kept the parked delivery parked, which is the hang this path exists to clear. The two now run alongside each other, `terminate` first.

Found by Cursor Bugbot on #1023.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dies

`#retireVat` could not be called twice. `deleteVat` removes the vat's
subcluster mapping, and `removeVatFromSubcluster` fails on a vat that has
none, so the second call threw.

`performVatRestart` calls it twice whenever a relaunch breaks the stream:
`onCriticalFailure` retires the vat, `runVat` rethrows, and the catch
retires it again. The throw escaped the catch that must not throw, so the
crank rolled back the termination records and returned the failing restart
to the run queue for the next process start to replay.

The mock store hid it. `deleteVat` was a bare `vi.fn()` that shrugged at a
repeat; it now refuses one the way the real store does, and
`isVatTerminated` answers for what `markVatAsTerminated` was told.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`restartVat` enqueued an item every time while `#awaitRestart` superseded
only the in-memory waiter, so two concurrent requests left two items and one
waiter. The first crank consumed that waiter and handed its caller a live
handle; the leftover item then ran anyway, stopping that worker and — if the
relaunch failed — retiring the vat the caller had just been told about.

A request arriving while one is still queued now takes over its item. The
waiter is the signal: `performVatRestart` claims it the moment it starts, so
a request landing after that still gets an item of its own.

The existing supersede test could not catch this — it stubs
`enqueueRestartVat` to a no-op and drives `performVatRestart` once, so the
leftover item was never exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… endpoint lookup

#1022 made a GC action for an absent-but-not-terminated vat abort the crank
rather than throw, because at that point `restartVat` really does take a vat
out of the kernel's reach while cranks run, and the abort hands the action to
the incarnation on its way back.

This branch closes that window instead: the run loop carries out the restart
itself and `provideVat` waits out a vat in flux, so a vat that reaches the
lookup's catch is gone with nothing to wait on — and there the abort spins
with no delivery to wait for, which is why this branch keeps the throw. The
tests move with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…th it

`#trackFlux` holds the store outside a crank instead of waiting for the crank
in flight to end. The run loop starts its next crank in the same turn it ends
the last, so the wait resumed with that crank's savepoints already open: the
vat's death was written inside its delivery savepoint, where an unrelated
rollback undid it while the handle stayed deleted — the store calling a vat
alive that the kernel has no handle for, which the next delivery dies of. The
same window let that crank reach the vat before the record of its death
existed and be handed a handle to a worker about to be killed.

`#deliverSend`'s catch no longer resolves a result promise something has
already settled. The decider is set before the delivery, so a vat that loses
its stream mid-delivery has the result rejected by its retirement and then
again here; the second is a `Fail`, and it killed the run loop naming the
promise rather than the dead worker.

A caller awaiting a queued restart is told when the run loop dies. The loop
was checked alive at enqueue time and never again, and a restart has no kernel
promise behind it the way a message result does, so the RPC never returned.

A vat whose stream breaks is torn down even when recording its death throws,
rather than losing the teardown to an unhandled rejection.

The first of these is pinned end to end, against a real store and the real run
loop: mocking the store leaves the turn `terminateVat` resumes in up to the
mock, which is the whole question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven review agents over the previous round found one regression and a set
of claims the code does not support. Fixing both.

The regression: `restartVat` registered its waiter with `onRunLoopDeath`
before enqueuing the request, and `enqueueRestartVat` refuses precisely when
the run loop is dead — which is when that registration fires synchronously.
The catch then rethrew, leaving a rejection nobody was awaiting. Pressing the
restart button on a dead kernel took the process down with it, on the path
added to make a dead run loop reportable.

`onCriticalFailure` marks the vat terminated when the rest of the record
fails. The handle is already gone by then, so without the mark the store goes
on calling the vat active while the kernel has no handle for it — the
disagreement `#resolveEndpoint` kills the run loop over.

`withStoreOutOfCrank` now actually enforces what it claimed. The type refuses
a promise-returning callback where inference reaches it, and a check catches
the rest: the turn is given back the moment the callback returns, so work
that awaits resumes with the run loop free to start a crank, and the savepoint
both callers take inside it would nest in that crank rather than be the commit
point. Nothing detected this before — a mutation making `handleRemoteMessage`'s
callback async passed all 2659 tests.

The rest is accuracy. A vat is not "marked terminated either way"; returning
`flux` bare costs correctness rather than latency; the `sort` that threw was in
`processGCActionSet`; `RemoteManager` has no `#handlePeerIncarnation`; the
changelog described an abort #1023 had already replaced with a throw and named
a `beginOutOfCrank` the same PR removed.

Tests for the six behaviours that were unpinned, each mutation-verified: the
fulfilled result promise, the c-list re-read after the endpoint lookup, the
cleaned-up report when no kref survives, the restart waiter on its rejecting
path, `discardTransaction` clearing the savepoint stack, and the GC delivery
fixture's missing rollback, which was committing away an action the run loop
would have restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng one already is

`createSavepoint`'s guard covers only half the overlap. A caller can be
outside a crank when it opens its savepoint and inside one by the time it
releases — the crank's two savepoints are then above its own on the stack, so
the release takes them with it and commits a delivery still in flight.

Closes what is left of the hole `withStoreOutOfCrank`'s synchronous contract
is there to prevent: the type and the runtime check refuse the callback that
would cause it, and this refuses the effect if one gets through anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…findable

Two Cursor Bugbot findings on this PR, both confirmed.

`onCriticalFailure` retires the vat inline, so its four store writes land in
whichever crank was open when the stream broke while `#vats.delete` does not.
A crank result of `{ abort: true }` rolls the writes back and the run loop
carries on, leaving the store calling the vat live with no handle to deliver
through — the disagreement that kills the run loop at the next message for it,
and resurrects the vat, promises undecided, on the next process start.
`terminateVat` is safe from this because `#trackFlux` records out of crank;
`onCriticalFailure` cannot, because the crank in flight may be parked on the
very RPC its teardown has to reject. So the teardown re-asserts the death out
of crank instead, with the gate taken in the failure's own turn and awaited
after the rejections.

`restartVat` opened with `getVat`, which reads `#vats` alone. Between
`performVatRestart`'s `stopVat` and the worker `runVat` launches there is no
handle, so a second request — the restart button, pressed twice — was told the
vat was gone while the store still listed it and it was about to come back. It
now asks the store too, as `stopVat` already does, and queues an item of its
own that the run loop reaches after the crank in flight.

Both are pinned by tests that fail without the fix; the first runs against a
real in-memory store, since the turn is the whole question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de it

`restartVat` was the one control-plane path with no crank guard: #1022's
`await waitForCrank()` came out and nothing replaced it. The method is
synchronous from entry through `enqueueRestartVat`, so the request's run-queue
rows were written into whichever crank happened to be open, and an ordinary
`{ abort: true }` delivery — an illegal syscall, a delivery error,
`exitWithFailure` — rolled them away with the rest of that crank.

Nothing then carried the request out, while the caller's waiter stayed in RAM:
`Kernel.restartVat` never settled. The state was also sticky, since a later
request read `alreadyQueued` true off that orphaned waiter and enqueued
nothing, leaving the vat un-restartable for the life of the process.

Held out of crank for the write, the way `terminateVat` already does it —
`waitForCrank` alone would not do, for the reason `#trackFlux` gives: the run
loop starts its next crank in the same turn it ends the last. Only
`Kernel.restartVat` reaches here, from the kernel-control RPC and the UI;
the run loop's own path is `performVatRestart`, so there is no in-crank caller
to deadlock.

Pinned over a real store and run loop in `vat-death-and-cranks.test.ts`, and
verified against the mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Sep 15, 2026
…n importer

`getImporters` enumerated `getVatIDs()`, which reads the `vatConfig` rows
`deleteVat` has already dropped, while the dead vat's c-list survives until
`nextTerminatedVatCleanup` reaches it — one vat per crank. So a terminated
importer was invisible, and `retireKernelObjects` deleted the object without
queueing its `retireImport`, leaving a c-list entry naming nothing. With
`auditRefCounts` on, the dangling entry kills the run loop from an audit that
runs after the crank has committed, so the next start replays it.

This PR is what makes the case ordinary: `orphanKernelObject` in
`performExportCleanup` lets a live vat disown its own export, so reaching
`getImporters` no longer needs the owner to have died.

Moved down from #1023, with the two `clist-accounting` blocks that pin it —
the remote importer being this PR's own change, and the terminated one
verified against the mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/vat-lifecycle-consistency-v2 branch from c545f60 to 2005001 Compare September 15, 2026 12:13

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2005001. Configure here.

// `nextTerminatedVatCleanup`, which reclaims the c-list everything above
// needed, and which must not run against a vat still being written.
this.#kernelStore.markVatAsTerminated(vatId);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Partial retire is not idempotent

Medium Severity

#retireVat treats isVatTerminated as “already done”, but that mark is the last write. A first call that fails after resolvePromises or deleteVat leaves the flag unset, so a later call repeats those steps. resolvePromises throws on a settled promise and deleteVat throws once the subcluster mapping is gone. performVatRestart lets that throw escape the catch that is supposed to keep a failed restart from being rolled back and replayed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2005001. Configure here.

@sirtimid
sirtimid marked this pull request as draft September 15, 2026 14:50
@sirtimid

Copy link
Copy Markdown
Contributor Author

Converting to draft. This stack is being split into small, single-purpose PRs against main rather than reviewed as a whole; it stays open as the reference to cherry-pick from and will be closed once its last piece has landed. Please do not spend review time on it in this form.

@sirtimid

Copy link
Copy Markdown
Contributor Author

Closing: this is now fully re-landed as individually reviewed PRs against main.

The split is described in the plan agreed on 2026-09-15; the vat-lifecycle concurrency this PR fought is answered in the accompanying decision record, only the run loop writes the store. So the pieces below are rewritten to that shape rather than cherry-picked: #vatsInFlux, #trackFlux, provideVat and the async #getEndpoint exist in none of them, because with restart and terminate on the run queue there is no window for them to paper over.

What this PR became:

piece PR
makeGCAndFinalize drains the queues before gc(); reapAndSettle polls #1083
#rejectResultIfPending: a settled result promise is not re-rejected from the delivery catch #1084
#retireVat: a vat's death recorded in one synchronous step; stopVat and launchVat teardown #1093
Restart is a run-queue item, with a per-vat waiter array and stale items dropped at startup #1096
Control-plane terminateVat goes through the run loop #1097
Stream death routed through the manager, guarded by handle identity #1100
Endpoint resolution is lookup-and-skip #1098

Each is motivated on its own, reproduces on main where it is a defect, and carries a test named for the behaviour. Nothing here is lost; the branch stays for reference.

@sirtimid sirtimid closed this Sep 15, 2026
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.

retireKernelObjects never notifies remote importers, leaving a dangling c-list entry

1 participant