Skip to content

A throw inside a crank commits the partial crank instead of rolling it back #1016

Description

@grypez

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions