Raising this as unconfirmed in its impact. The mechanism is confirmed by reading and by a failing test; what I have not established is a reachable case where the committed partial state does observable harm with default settings. Both halves below.
Mechanism (confirmed)
KernelQueue.run() (KernelQueue.ts:75-102) calls endCrank() from its finally:
try {
this.#kernelStore.createCrankSavepoint('start');
const queueItem = this.#getNextRunQueueItem();
if (queueItem) {
this.#kernelStore.nextTerminatedVatCleanup();
const crankResult = await deliver(queueItem);
await this.#processCrankResult(crankResult, queueItem);
}
...
} finally {
this.#kernelStore.endCrank();
...
}
endCrank -> releaseAllSavepoints() -> releaseSavepoint('t0') -> commitIfNeeded() -> COMMIT TRANSACTION (packages/kernel-store/src/sqlite/nodejs.ts:302-314).
rollbackCrank('start') is reachable only from the abort branch of #processCrankResult. A throw skips #processCrankResult entirely, so it never runs. Anything that throws out of deliver — or out of collectGarbage() / assertRefCountsIfAuditing() — therefore commits whatever the crank had already written. Kernel.ts:317 then records #runLoopFailure, and nothing restarts the loop.
Two failing tests demonstrate it:
x rolls back the crank when 'deliver throws'
x rolls back the crank when 'the refcount audit throws'
AssertionError: expected "vi.fn()" to be called with arguments: [ 'start' ]
This is pre-existing
git show 9cdc6ec4d:packages/ocap-kernel/src/KernelQueue.ts has a byte-identical try/finally. #1010 adds one new throw source (the refcount audit) and does not change the shape. Filing separately for that reason.
Why this might not be an issue
- With auditing off (the default), I could not identify a throw source that is both reachable and leaves harmful partial state. The candidates I found are
#getEndpoint in #deliverNotify (KernelRouter.ts:418) and in the bringOutYourDead path (:537) — both narrow, both pre-existing, and both already guarded in the two sibling paths (#deliverSend:239, #deliverGCAction:474).
- If the intent is "a throw here is a fatal invariant violation and the process is over," then commit-versus-rollback may not matter much. The argument for rolling back is that a restart would get a clean store instead of re-reading the same violation — but I have not tested restart behaviour, so treat that as reasoning rather than evidence.
- The existing test
processes items from the run queue and performs cleanup already drives a deliver rejection and asserts endCrank was called. It deliberately does not assert a rollback, which may mean the current behaviour is intended rather than overlooked.
Suggested next step
Before deciding whether to fix: confirm whether #getEndpoint in the notify path is genuinely reachable. My reading of nextTerminatedVatCleanup (store/methods/vat.ts:310-314 — one vat cleaned per crank) says that with two terminated vats the second's c-list still passes the KernelRouter.ts:388 guard while its handle is already gone (VatManager.ts:230 deletes the handle before :243 marks the vat). I have not built that case, so it may not hold.
If it does hold, the fix is to rollbackCrank('start') on the exceptional path in run() before rethrowing, and to guard #getEndpoint in the notify and BOYD paths as the other two already do.
Found while reviewing #1010.
Raising this as unconfirmed in its impact. The mechanism is confirmed by reading and by a failing test; what I have not established is a reachable case where the committed partial state does observable harm with default settings. Both halves below.
Mechanism (confirmed)
KernelQueue.run()(KernelQueue.ts:75-102) callsendCrank()from itsfinally:endCrank->releaseAllSavepoints()->releaseSavepoint('t0')->commitIfNeeded()->COMMIT TRANSACTION(packages/kernel-store/src/sqlite/nodejs.ts:302-314).rollbackCrank('start')is reachable only from theabortbranch of#processCrankResult. A throw skips#processCrankResultentirely, so it never runs. Anything that throws out ofdeliver— or out ofcollectGarbage()/assertRefCountsIfAuditing()— therefore commits whatever the crank had already written.Kernel.ts:317then records#runLoopFailure, and nothing restarts the loop.Two failing tests demonstrate it:
This is pre-existing
git show 9cdc6ec4d:packages/ocap-kernel/src/KernelQueue.tshas a byte-identicaltry/finally. #1010 adds one new throw source (the refcount audit) and does not change the shape. Filing separately for that reason.Why this might not be an issue
#getEndpointin#deliverNotify(KernelRouter.ts:418) and in thebringOutYourDeadpath (:537) — both narrow, both pre-existing, and both already guarded in the two sibling paths (#deliverSend:239,#deliverGCAction:474).processes items from the run queue and performs cleanupalready drives adeliverrejection and assertsendCrankwas called. It deliberately does not assert a rollback, which may mean the current behaviour is intended rather than overlooked.Suggested next step
Before deciding whether to fix: confirm whether
#getEndpointin the notify path is genuinely reachable. My reading ofnextTerminatedVatCleanup(store/methods/vat.ts:310-314— one vat cleaned per crank) says that with two terminated vats the second's c-list still passes theKernelRouter.ts:388guard while its handle is already gone (VatManager.ts:230deletes the handle before:243marks the vat). I have not built that case, so it may not hold.If it does hold, the fix is to
rollbackCrank('start')on the exceptional path inrun()before rethrowing, and to guard#getEndpointin the notify and BOYD paths as the other two already do.Found while reviewing #1010.