Skip to content

fix(ocap-kernel): record a vat's death in one synchronous step - #1093

Open
sirtimid wants to merge 3 commits into
mainfrom
sirtimid/retire-vat-synchronously
Open

sirtimid wants to merge 3 commits into
mainfrom
sirtimid/retire-vat-synchronously

Conversation

@sirtimid

@sirtimid sirtimid commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

A vat's death is four store writes: the promises it was deciding rejected, its root unpinned, its config and vat store dropped, the terminated mark set. They were spread across VatManager.stopVat and VatHandle.terminate with awaits between them, so a crank could land in the middle and read a vat that was half dead.

A new private #retireVat does all four in one synchronous step, before the worker is touched. 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 one still waiting to find out. Conversely a record that fails must not leave a worker running, so the teardown happens either way, and it is the store failure — not a channel that will not close — that the caller hears about.

Three things follow from having the death in one place:

- **stopVat no longer insists on a live handle when terminating.** A failed relaunch leaves a vat gone from the running map with its record, its own store and its root pin all still in place. terminateVat went through getVat and threw VatNotFoundError, so such a vat could not be retired at all: the only way to be rid of one was to discard the whole store, and terminateSubcluster — which walks *persisted* membership — gave up part-way through on reaching one. A vat that is neither running nor persisted still throws.
- **The worker a launch leaves behind is stopped.** platformServices.launch spawns it before the handshake that can fail, and a failed handshake records no handle to reach it by. runVat now does this, so initializeAllVats and restartVat stop stranding a worker where only launchVat cleaned up — and a launch that failed before a worker existed no longer asks either runtime to stop one it never registered, which both report as an error over the launch failure an operator is reading the log to find.
- **VatHandle.terminate is only the handle's own business**: reject the callers waiting on commands the worker will never answer, then close the channel. Rejecting first, because a stream that refuses to close must not leave them waiting on a worker that is already dead.

removeVatFromSubcluster no longer reports a vat that belongs to no subcluster. deleteVat reaches it while discarding a vat, which is the one moment a failure cannot be retried past, and such a vat is already in the state it asks for.

### A death nobody asked for

Moving the store side out of VatHandle.terminate left the one caller that had nothing else to fall back on. VatHandle drains its channel to the worker, and the catch on that drain is how the kernel hears that the channel has failed; it used to call terminate(true, …) and get the teardown for free. Left as it was, a channel could fail and the kernel would go on delivering over it, with the vat's vatConfig row intact for the next boot to restore it.

So the handle takes a required onStreamFailure, and does the part that is its own: reject the callers waiting on commands the worker will never answer, since a crank blocked on one of them is what the retirement would wait behind. VatManager.#retireLostVat does the rest, retiring the vat exactly as a requested termination would.

Worth being exact about what "the channel fails" covers, because it is narrower than it sounds. Nothing listens for a worker that dies outright — NodeWorkerReader registers only port.on('message'), and PlatformServices.launch drops the 'error' and 'exit' listeners once the worker is online — so a killed worker leaves the drain quiet rather than failing it. What the kernel can see is a worker still sending: a frame that fails isJsonRpcMessage, or one whose handling throws. Adding the missing 'exit'/'error' plumbing is worth doing and is not done here.

Two things the retirement has to get right:

- **It runs between cranks**, like terminateVat. #retireVat writes synchronously, so run inside an open delivery savepoint the whole death is undone by any crank that goes on to abort — the ordinary vat-error path, not a failure — while the handle it dropped from the running map stays dropped, since nothing rolls memory back. That leaves a vat the store calls alive and the kernel cannot reach, with no error anywhere. Waiting is safe precisely because the handle has already answered the blocked callers.
- **It matches the report against the handle that made it**, not the vat id. A restart puts a new handle under the same id, and closing a channel with an error breaks the read its handle is draining — so a vat the kernel is itself stopping reports the same way. (Before this PR that second teardown ran in full, rejecting the decider promises and calling deleteVat a second time.)

There is no one to retry this path: a store failure ends in a log line, which says so and says that a manual terminateVat is what is left.

### The order of the four writes

The mark goes after the rejections and before deleteVat, and both halves matter.

After the rejections, because the mark is what makes the vat eligible for nextTerminatedVatCleanup, and that cleanup deletes the decider promises' c-list entries on the stated understanding that its caller has already rejected them. #1067 asked for the mark in a finally so that a failed write could not leave the store calling the vat active; I built that, and all three review agents independently showed it is worse than the disease — a vat marked after those rejections failed is one whose subscribers hang for good. One reviewer reproduced the whole sequence against a real store.

Before deleteVat, because deleteVat drops the vatConfig row that stopVat reads to decide there is still a vat here to retire. With the mark last, a failure on that one write left a vat that could never be marked and whose c-list would never be swept — a retry hit VatNotFoundError and there was no other way in. Now every way this can fail leaves either the mark set, or the row that lets the whole step be tried again, so the idempotence guard covers only the writes that may happen once and deleteVat runs again to carry an interrupted retirement to the end.

The converse of that ordering is a deleteVat that throws, leaving a marked vat whose vatConfig row survives — which used to read as active again the moment the cleanup dropped the mark, and be relaunched, gutted, on the next boot. cleanupTerminatedVat now discards that row along with the rest of the vat, so the sweep cannot resurrect what it has just emptied.

Worth saying plainly: synchronous is not atomic. terminateVat runs between cranks, where there is no open transaction, so the four writes are four commits. This removes the interleaving and the dead ends, not the partial-failure window; closing that is the Tier 3 question of routing control-plane termination through the run loop.

### Superseding #1030

Supersedes #1030 by @grypez, which fixed the same "persisted but not running" defect with a dedicated #retirePersistedVat branch in terminateVat. This lands it through the one path instead: stopVat tolerates the missing handle and #retireVat does the same work for a running vat and a persisted one alike. Carried over verbatim in spirit: his defect narrative, his removeVatFromSubcluster change and its test, and his a vat that is persisted but not running cases (terminable at all, record discarded, decider promises rejected carrying the reason, root pin released, and still throwing for a vat that is neither running nor persisted).

## Changes

- VatManager.#retireVat records a vat's death in one synchronous step, marking the vat before the write that would hide it from a retry
- stopVat tolerates a missing handle when terminating, and reports the store failure rather than the channel failure
- VatHandle.terminate keeps only the handle's own work; a new required onStreamFailure reports a channel that has failed
- VatManager.#retireLostVat retires such a vat between cranks, ignoring a report from a handle it no longer keeps
- runVat stops the worker a failed handshake leaves behind; launchVat loses its own teardown and its now-redundant trailing mark
- cleanupTerminatedVat discards the vat's vatConfig row
- Kernel's in-crank termination lambda loses its now-redundant trailing mark
- removeVatFromSubcluster tolerates a vat in no subcluster

## Testing

VatManager.test.ts covers each piece: the four writes all land in stopVat's synchronous prefix (read out of the mocks before the returned promise is awaited, which is what pins the absence of a yield point rather than merely the order); a failure before the mark leaves the vat unmarked, and one on deleteVat leaves it marked with a second attempt finishing the job without repeating the writes that may happen once; the store failure survives a channel that also fails; a handshake failure stops the worker and a launch failure that never had one does not; a reported channel failure retires the vat carrying what the channel failed with, waits for the open crank first, is ignored both when the kernel has already stopped the vat and when a restart has replaced its handle, and logs rather than rejects when the retirement fails; and @grypez's five persisted-but-not-running cases. VatHandle.test.ts pins that pending commands are rejected before the channel closes — against a stream whose end never settles, so the assertion cannot pass by racing — that a failed channel is reported to its owner with its pending commands already rejected, and that they are left alone across a restart.

The claims the unit tests cannot make are the ones about the store, so kernel-test's vat-lifecycle.test.ts gains two integration cases against a real kernel and a real SQLite store: after terminating a vat and draining the cleanup, no vatConfig record survives for the next boot to restore it; and a vat whose worker emits a frame the reader will not take is retired in full, driving the real trigger through the real NodejsPlatformServices rather than a mock.

Every fix was mutation-checked separately, each failing only its own cases: reverting stopVat's handle tolerance, making #retireVat yield between writes, dropping deleteVat from it, swapping the mark back after deleteVat, restoring the wide idempotence guard, removing either launch teardown, restoring launchVat's unconditional mark, rethrowing the channel failure over the store failure, moving rejectAll after the stream end, dropping its terminating guard, making removeVatFromSubcluster strict again, never reporting a channel failure (caught only by the new integration test), dropping waitForCrank from #retireLostVat, and weakening its identity check to the vat id.

Full @metamask/ocap-kernel suite green, with coverage. @ocap/kernel-test's vat-lifecycle, subclusters, persistence, garbage-collection, resume and orphaned-ephemeral-exo all green against a fresh dist.

Closes #1063
Closes #1067

🤖 Generated with [Claude Code](https://claude.com/claude-code)

@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 2 potential issues.

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 c9cc6fb. Configure here.

Comment thread packages/ocap-kernel/src/vats/VatManager.ts
Comment thread packages/ocap-kernel/src/vats/VatHandle.ts
sirtimid and others added 3 commits September 25, 2026 19:05
A vat's death is four writes: the promises it was deciding rejected, its root
unpinned, its config and store dropped, the terminated mark set. They were
spread across `stopVat` and `VatHandle.terminate` with awaits between them, so
a failure part-way left a state nothing recovers from. The sharpest is marked
terminated while `vatConfig` survives — only `deleteVat` removes that row,
while the cleanup the mark schedules sweeps `${vatId}.` keys, which never match
`vatConfig.${vatId}` — and such a vat reads as active again the moment cleanup
drops the mark.

`#retireVat` does all four with no await between them, before the worker is
touched, and the mark happens even if an earlier write throws. 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 one still waiting to find out. It runs in a
`finally`, so a record that fails cannot leave a worker running either.

`stopVat` no longer insists on a live handle when terminating, which is what
lets a vat left behind by a failed relaunch be retired at all, and `launchVat`
tears its worker down when the kernel-side registration fails.

`VatHandle.terminate` is now only the handle's own business: reject the callers
waiting on commands the worker will never answer, then close the channel.

Supersedes #1030 by @grypez.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mark makes the vat eligible for `nextTerminatedVatCleanup`, which deletes
its decider promises' c-list entries on the stated understanding that its
caller has already rejected them. Marking in a `finally` therefore turned a
failed rejection loop into subscribers that hang for good, and the retry that
would have finished the job hit the already-terminated guard and did nothing.
The mark is now simply the last step, so a failure leaves a vat that has not
been retired, with its c-list intact and the step available to be tried again.
`deleteVat` sits immediately before it: the two must not be separated.

`stopVat` no longer lets a channel that will not close stand in for a store
left half-written, and `launchVat`'s first catch stops the worker that
`platformServices.launch` has already spawned — `runVat` records no handle to
reach it by, so nothing else could.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving the store side of a death out of `VatHandle.terminate` left the one
caller that had nothing else to fall back on: the catch on the handle's own
drain, which is how the kernel hears that a channel has failed. Left as it was,
such a vat kept its handle, its `vatConfig` row and the promises it was
deciding, so the kernel went on delivering over a channel that was gone and the
next boot brought it back.

The handle now takes an `onStreamFailure` and does the part that is its own —
rejecting the callers waiting on commands the worker will never answer, since a
crank blocked on one of them is what the retirement would wait behind — and
`VatManager` retires the vat exactly as a requested termination would. Between
cranks, like `terminateVat`: `#retireVat` writes synchronously, so run inside an
open delivery savepoint the whole death is undone by any crank that goes on to
abort, while the handle it dropped stays dropped. Reports are matched against
the handle that made them, since a restart puts a new handle under the same vat
id, and a vat the kernel is itself stopping reports the same way.

The terminated mark moves ahead of `deleteVat`, which drops the `vatConfig` row
`stopVat` reads to decide there is still a vat here to retire: a mark not
reached by then could never be set, and the c-list it gates never swept. Every
way a retirement can now fail leaves either the mark set, or the row that lets
the whole step be tried again — so the idempotence guard covers only the writes
that may happen once, and `deleteVat` runs again to carry an interrupted
retirement to the end. `cleanupTerminatedVat` discards the `vatConfig` row too,
so a vat marked but not discarded cannot read as active again once the sweep
drops its mark.

`runVat` stops a worker left over from a failed handshake, which `launchVat`
alone used to do and only for its own launches, and which asked both runtimes
to stop a worker they had never registered. Its unconditional
`markVatAsTerminated` goes: `stopVat` sets the mark early enough that asserting
it afterwards could only add it where `stopVat` stopped short of `deleteVat`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/retire-vat-synchronously branch from c9cc6fb to 95824dc Compare September 25, 2026 18:14
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 73.34%
⬆️ +0.07%
9973 / 13598
🔵 Statements 73.13%
⬆️ +0.06%
10102 / 13813
🔵 Functions 73.75%
⬆️ +0.03%
2332 / 3162
🔵 Branches 67.72%
⬆️ +0.11%
4115 / 6076
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/ocap-kernel/src/Kernel.ts 89.84%
⬇️ -0.08%
79.54%
🟰 ±0%
85.41%
🟰 ±0%
89.84%
⬇️ -0.08%
322-324, 395, 419, 494-504, 592, 660, 736-739, 752, 762-763, 816, 839
packages/ocap-kernel/src/store/methods/subclusters.ts 98.82%
⬆️ +0.02%
90.62%
⬆️ +0.62%
96.15%
🟰 ±0%
98.78%
⬆️ +0.02%
264
packages/ocap-kernel/src/store/methods/vat.ts 98.51%
⬆️ +0.01%
90%
🟰 ±0%
100%
🟰 ±0%
98.5%
⬆️ +0.01%
308-309
packages/ocap-kernel/src/vats/VatHandle.ts 90%
⬇️ -0.14%
86.66%
🟰 ±0%
100%
🟰 ±0%
90%
⬇️ -0.14%
183-186, 389-394, 403-409
packages/ocap-kernel/src/vats/VatManager.ts 98.33%
⬇️ -1.67%
97.61%
⬇️ -2.39%
96.42%
⬇️ -3.58%
98.33%
⬇️ -1.67%
221-224, 251
Generated in workflow #5001 for commit 95824dc by the Vitest Coverage Report Action

@rekmarks-consensys-1 rekmarks-consensys-1 self-assigned this Sep 25, 2026

@rekmarks-consensys-1 rekmarks-consensys-1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks good except for two essay-length docstrings, resulting from—I suspect—a common Claudish failure mode where it dumps its train of thought into documentation instead of trying to protect the reader's attention.

Comment on lines +398 to +432
/**
* Record a vat's death: everything the kernel has to remember about it, in
* one synchronous step.
*
* Synchronous is the point. A death is four writes — the promises it was
* deciding rejected, its root unpinned, its config and store dropped, the
* terminated mark set — and none means much without the others. Interleaved
* with awaits, as they used to be, a crank lands between them and reads a vat
* that is half dead.
*
* 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 one still waiting to
* find out.
*
* The order is what a partial failure leaves behind. The mark comes after the
* rejections because it is what makes the vat eligible for
* `nextTerminatedVatCleanup`, and that cleanup deletes the decider promises'
* c-list entries on the stated understanding that its caller has already
* rejected them: a vat marked after those rejections failed is one whose
* subscribers hang for good. It comes before `deleteVat` because `deleteVat`
* drops the `vatConfig` row `stopVat` reads to decide there is still a vat
* here to retire — a mark not reached by then could never be set, and the
* c-list it gates never swept. So every way this can fail leaves either the
* mark set, or the row that lets the whole step be tried again.
*
* What that ordering costs: a `deleteVat` that throws leaves a marked vat
* whose `vatConfig` row survives, and `cleanupTerminatedVat` sweeps
* `${vatId}.` keys, which never match `vatConfig.${vatId}` — so once the
* cleanup drops the mark such a vat reads as active again. Retrying is what
* closes that, and the failure propagates so that someone can.
*
* Which is why only the writes that may happen once are guarded: a second
* `releaseVatRootPin` would spend an embedder's `pinVatRoot` pin instead of
* the launch pin this vat no longer holds. `deleteVat` is idempotent, and
* runs again to carry an interrupted retirement to the end.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This comment is explaining everything short of the origin of the universe. It's such an issue with Claude lately. I've had luck with: "ruthlessly edit all prose for clarity and brevity; distill everything to its absolute essence".

Comment on lines +262 to +286
* Retire a vat whose channel to its worker has failed.
*
* Nobody asked for this death, so nobody is waiting to be told of it: the
* store learns of it here or not at all. Untold, the vat keeps its handle,
* its `vatConfig` row and the promises it was deciding, so the kernel goes on
* routing to a worker it cannot reach and the next boot brings it back.
*
* Between cranks, like `terminateVat`, and for a sharper reason. `#retireVat`
* writes synchronously, so run inside an open delivery savepoint the whole
* death is undone by any crank that goes on to abort — the ordinary vat-error
* path, not a failure — while the handle this dropped from the running map
* stays dropped, since nothing rolls memory back. That leaves a vat the store
* calls alive and the kernel cannot reach, and no error anywhere. Waiting is
* safe because the handle has already answered the callers this worker never
* will, so a crank blocked on one of them is free to finish.
*
* A vat the kernel is itself stopping reports the same way, since closing a
* channel with an error breaks the read its handle is draining. The identity
* check covers that along with everything the wait may have let happen: by
* then the vat may have been terminated, or restarted under a new handle.
*
* There is no one to retry this: a store failure here ends in a log line,
* leaving the vat unmarked, undeleted and handle-less until someone
* terminates it by hand. Said plainly in that log, for want of a better
* answer than the caller this path does not have.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also too long.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

2 participants