Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ 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.
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>
c9cc6fb to
95824dc
Compare
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
rekmarks-consensys-1
left a comment
There was a problem hiding this comment.
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.
| /** | ||
| * 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. |
There was a problem hiding this comment.
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".
| * 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. |
There was a problem hiding this comment.
Also too long.

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.stopVatandVatHandle.terminatewith awaits between them, so a crank could land in the middle and read a vat that was half dead.A new private
#retireVatdoes 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:
- **
stopVatno 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.terminateVatwent throughgetVatand threwVatNotFoundError, so such a vat could not be retired at all: the only way to be rid of one was to discard the whole store, andterminateSubcluster— 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.launchspawns it before the handshake that can fail, and a failed handshake records no handle to reach it by.runVatnow does this, soinitializeAllVatsandrestartVatstop stranding a worker where onlylaunchVatcleaned 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.terminateis 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.removeVatFromSubclusterno longer reports a vat that belongs to no subcluster.deleteVatreaches 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.terminateleft the one caller that had nothing else to fall back on.VatHandledrains its channel to the worker, and the catch on that drain is how the kernel hears that the channel has failed; it used to callterminate(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'svatConfigrow 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.#retireLostVatdoes 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 —
NodeWorkerReaderregisters onlyport.on('message'), andPlatformServices.launchdrops 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 failsisJsonRpcMessage, 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.#retireVatwrites 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
deleteVata 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
terminateVatis 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 afinallyso 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, becausedeleteVatdrops thevatConfigrow thatstopVatreads 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 hitVatNotFoundErrorand 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 anddeleteVatruns again to carry an interrupted retirement to the end.The converse of that ordering is a
deleteVatthat throws, leaving a marked vat whosevatConfigrow survives — which used to read as active again the moment the cleanup dropped the mark, and be relaunched, gutted, on the next boot.cleanupTerminatedVatnow 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.
terminateVatruns 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
#retirePersistedVatbranch interminateVat. This lands it through the one path instead:stopVattolerates the missing handle and#retireVatdoes the same work for a running vat and a persisted one alike. Carried over verbatim in spirit: his defect narrative, hisremoveVatFromSubclusterchange and its test, and hisa vat that is persisted but not runningcases (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.#retireVatrecords a vat's death in one synchronous step, marking the vat before the write that would hide it from a retry-
stopVattolerates a missing handle when terminating, and reports the store failure rather than the channel failure-
VatHandle.terminatekeeps only the handle's own work; a new requiredonStreamFailurereports a channel that has failed-
VatManager.#retireLostVatretires such a vat between cranks, ignoring a report from a handle it no longer keeps-
runVatstops the worker a failed handshake leaves behind;launchVatloses its own teardown and its now-redundant trailing mark-
cleanupTerminatedVatdiscards the vat'svatConfigrow-
Kernel's in-crank termination lambda loses its now-redundant trailing mark-
removeVatFromSubclustertolerates a vat in no subcluster## Testing
VatManager.test.tscovers each piece: the four writes all land instopVat'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 ondeleteVatleaves 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.tspins that pending commands are rejected before the channel closes — against a stream whoseendnever 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'svat-lifecycle.test.tsgains two integration cases against a real kernel and a real SQLite store: after terminating a vat and draining the cleanup, novatConfigrecord 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 realNodejsPlatformServicesrather than a mock.Every fix was mutation-checked separately, each failing only its own cases: reverting
stopVat's handle tolerance, making#retireVatyield between writes, droppingdeleteVatfrom it, swapping the mark back afterdeleteVat, restoring the wide idempotence guard, removing either launch teardown, restoringlaunchVat's unconditional mark, rethrowing the channel failure over the store failure, movingrejectAllafter the stream end, dropping itsterminatingguard, makingremoveVatFromSubclusterstrict again, never reporting a channel failure (caught only by the new integration test), droppingwaitForCrankfrom#retireLostVat, and weakening its identity check to the vat id.Full
@metamask/ocap-kernelsuite green, with coverage.@ocap/kernel-test'svat-lifecycle,subclusters,persistence,garbage-collection,resumeandorphaned-ephemeral-exoall green against a freshdist.Closes #1063
Closes #1067
🤖 Generated with [Claude Code](https://claude.com/claude-code)