Skip to content

fix(ocap-kernel): free retired exports and harden GC delivery - #1022

Open
sirtimid wants to merge 34 commits into
sirtimid/crank-rollback-integrityfrom
sirtimid/gc-delivery-hardening
Open

fix(ocap-kernel): free retired exports and harden GC delivery#1022
sirtimid wants to merge 34 commits into
sirtimid/crank-rollback-integrityfrom
sirtimid/gc-delivery-hardening

Conversation

@sirtimid

@sirtimid sirtimid commented Aug 13, 2026

Copy link
Copy Markdown
Member

Stacked on #1021, which is stacked on #1020. Review those first. Third of four; split out of the old #1010.

The refcount audit landed in #1020 as an invariant checker. This is what it caught: four independent ways the kernel could lose track of an object during garbage-collection delivery, three of which killed the run loop outright.

What's fixed

An owner's owner and refCount records outlived the c-list entry they were reachable through. When an owner stops naming its own export — a delivered retireExport, or a retireExports/abandonExports syscall — nothing dropped the ownership record. They leaked, and the next collection to visit such a kref read a c-list entry that was no longer there and died:

Error: No value found for key v1.c.ko1.

This reproduces on main (494fb5ee9), so it predates this whole stack. New orphanKernelObject hands the object to the collector instead, and collectGarbage now checks hasCListEntry before reading the owner's reachable flag.

A vat could disown an object it did not own. Nothing upstream of performExportCleanup checked that the vref it was handed is even an export — translateSyscallVtoK maps both directions alike — so a vat could pass an import to abandonExports, which needs no precondition at all, and erase a different live vat's claim to an object it was still exporting. Sends to that object then failed with OBJECT_DELETED, and terminating the victim tripped cleanupTerminatedVat's ownership assertion and took the run loop with it. The audit could not see any of it, because an export entry carries no count. The expected owner is now a required argument and must match; the syscall path rejects a mismatch outright.

This was the most security-relevant change here and had zero coverage — gc-handlers.test.ts is new, 10 tests, mutation-verified.

GC action delivery hid its own failures. Two cases:

  • The vanished-endpoint path returned before the teardown, but processGCActionSet had already consumed the action, so neither the kernel nor the durable set remembered the object — a permanent leak, also invisible to the audit. The kernel's side is now released whether or not anyone is left to tell.
  • The delivery-failure path committed the teardown after the endpoint failed to hear about it, so the endpoint would go on to mint a fresh kref for an object the kernel believed it had let go of — the same object with two identities. It now aborts, restoring both the entries and the action, and terminates the vat that could not accept the delivery.

It releases only where the endpoint is genuinely gone: a terminated vat, whose cleanup tears the whole c-list down anyway, or a remote, which reconciles on its next incarnation. A vat that is absent yet not terminated is one restartVat has taken out of the kernel's reach while keeping its c-list, so the crank fails there rather than committing a release the returning incarnation would disagree with.

A failed GC delivery to a remote escaped the crank and stopped the run loop. Now logged and survived.

A partial vat launch left records nobody reclaimed. launchVat's cleanup stopped the worker without marking the vat terminated.

Note on the audit's Added entry

This branch narrows what #1020's changelog claims the audit can detect. It compares counts against the holders it finds, so a holder that should have been torn down but wasn't justifies its own count and is not detectable this way — "a leak" overstated it. RefCountViolation also becomes a discriminated union over kind: 'mismatch' | 'dangling'.

Merge order matters

This must not merge before #1021. Its abort path depends on refreshCachedValues(), which lands there. A database rollback restores the c-list entry and the gcActions row, but gcActions is a provideCachedStoredValue closure the rollback never refreshes — so the audit would build its retiring exemption from a stale cache, credit the restored entry as a holder of a deleted kref, and kill the run loop. Harmless with auditing off; fatal with it on, which is every kernel-test kernel. Verified against a real SQLite store.

Issues

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.

The export-ownership guard is mutation-tested: deleting it fails exactly two cases.

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 GC, c-list teardown, and ownership invariants; mistakes can corrupt capability accounting or kill the run loop, and the PR notes a merge-order dependency on #1021 when refcount auditing is enabled.

Overview
Hardens kernel garbage collection and object ownership so retired exports do not leak owner records or crash the run loop, and GC delivery behaves correctly when endpoints vanish or refuse work.

Adds orphanKernelObject so when an owner stops naming an export (retireExport delivery or retireExports/abandonExports syscalls), the owner mapping is dropped and the object is handed to the collector. performExportCleanup now requires the caller to be the owner, blocking a vat from disowning another endpoint’s export via an import vref. collectGarbage repairs orphaned owner mappings when the owner’s c-list entry is already gone.

KernelRouter.#deliverGCAction is reworked: filter krefs with hasCListEntry, release the kernel c-list even if the endpoint is gone (terminated vat or remote), but fail the crank for a vat that is absent yet not terminated (e.g. mid-restartVat). Local vat delivery failures abort and terminate; remote failures are logged and not retried (avoids GC starvation). Notify/send refcount fixes and RefCountViolation kind: 'mismatch' | 'dangling' tighten the audit story.

VatManager.launchVat stops the worker and marks the vat terminated if kernel-side registration fails after the worker starts. Integration tests cover audit errors surfacing to callers and GC settling in multi-importer scenarios.

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

@sirtimid
sirtimid requested a review from a team as a code owner August 13, 2026 15:49
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 72.59%
⬆️ +0.54%
9595 / 13217
🔵 Statements 72.42%
⬆️ +0.53%
9749 / 13460
🔵 Functions 73.11%
⬆️ +0.24%
2254 / 3083
🔵 Branches 66.61%
⬆️ +0.81%
3915 / 5877
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/ocap-kernel/src/Kernel.ts 89.92%
⬆️ +0.16%
79.54%
⬆️ +0.97%
85.41%
🟰 ±0%
89.92%
⬆️ +0.16%
328-330, 401, 425, 500-510, 598, 666, 742-745, 758, 768-769, 822, 845
packages/ocap-kernel/src/KernelRouter.ts 94.93%
⬆️ +1.00%
83.13%
⬆️ +4.67%
100%
🟰 ±0%
94.93%
⬆️ +1.00%
114, 177, 194, 268, 323, 383, 401, 404
packages/ocap-kernel/src/garbage-collection/gc-handlers.ts 90.9%
⬆️ +13.13%
87.5%
⬆️ +20.84%
100%
🟰 ±0%
90.9%
⬆️ +13.13%
45-47, 50
packages/ocap-kernel/src/store/methods/clist.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/gc.ts 91.66%
⬆️ +2.62%
83.92%
⬆️ +9.46%
100%
🟰 ±0%
91.66%
⬆️ +2.62%
170, 182, 224-231
packages/ocap-kernel/src/store/methods/refcount-audit.ts 100% 93.1% 100% 100%
packages/ocap-kernel/src/store/methods/vat.ts 98.44%
⬆️ +1.15%
89.47%
⬆️ +7.66%
100%
🟰 ±0%
98.43%
⬆️ +1.16%
289-290
packages/ocap-kernel/src/vats/VatManager.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #4647 for commit 1099770 by the Vitest Coverage Report Action

sirtimid and others added 26 commits August 13, 2026 19:37
Creating an import c-list entry changed no refcount while tearing one
down decremented both, and `initKernelObject` compensated by minting
every object at (1, 1). That constant is correct for exactly one
importer, which is why nothing caught it: with two importers a live
capability gets dropped and retired out from under a holder, and the
same unit is claimed by both an importer's drop and the owner's
termination, so cleanup underflows and leaves a vat half-cleaned.

Restore the increment and rebase the baseline to (0, 0), matching
SwingSet, so `collectGarbage` — already a faithful port — receives the
inputs it was written for.

Build the invariant checker first, since every existing compensation
becomes a double-count the moment the increment lands. It recomputes
each kref's counts from ground truth (c-list entries and their reachable
flags, run-queue and promise-queue messages, promise resolution values,
pins) and reports drift in both directions: too low collects a live
capability, too high leaks it. Enabled via `Kernel.make`'s
`auditRefCounts` and run after every crank; on in kernel-test.

The audit found four more unbalanced paths that the phantom baseline had
been absorbing, each fixed here: a delivered message charged its target
against the routed kref rather than the run-queue item's own, so a
message routed through a resolved promise decremented an object nobody
charged and leaked the promise; a notification leaked its reference on
both early-return paths and decremented promises retired alongside it
that nobody had taken; a message queued on an unresolved promise
duplicated every reference it carried on re-enqueue; and `resolve|kpid`
incremented with no matching release.

Two things the baseline was silently standing in for, now explicit: vat
roots are pinned for the lifetime of their vat (a root is addressable
whether or not anyone imports it), and GC action delivery moves the
kernel's own c-list so a dropped export's flag clears and retired
entries don't outlive their objects.

Also fixes the stale `cle.`/`clk.` key prefixes in
`getPromisesByDecider` and `deleteEndpoint`, which stopped matching the
`${endpointId}.c.` layout. `getPromisesByDecider` matched nothing, so
promises a terminating vat was deciding were never rejected — load
bearing here, because releasing a promise's unsettled reference is what
makes the cleanup path's accounting add up.

Refcounts are persisted, so counts written under the old scheme are
recomputed from ground truth on first open, keyed off a new
`refCountScheme` entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prettier wanted a blank line before the entry following a nested bullet,
and the entries still cited #1010, which this PR replaces.
…ring

`retireKernelObjects` deletes an object and queues a `retireImport` for each
importer in the same breath, so until that action is delivered an importer's
c-list entry names a kref the kernel has already dropped. The audit counted
those entries as holders and reported a violation against the collector's own
output — and since `assertRefCountsIfAuditing` throws from inside the crank,
that killed the run loop for good.

Reachable from an ordinary `terminateVat` while a surviving vat holds the
dying vat's export in liveslots' dropped-but-recognizable state. No current
test produced it; found by Cursor Bugbot on #1020 and reproduced against the
real store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing the baseline to (0, 0) made every reference explicit, which
exposed the holders that were never references at all. An ocap URL
carries its kref inside an encrypted bearer token and nothing else, so
the kernel cannot discover from its own state that a holder exists:
`issueOcapURL` took no reference of any kind. Under the old baseline
nothing exported was collectable and it never showed; at (0, 0) the
target is collected as soon as the message that carried it to the issuer
is delivered, and the URL names a dead capability. The audit is silent
on it by construction — the object genuinely has no holder it can see.

Retain the target when the URL is issued, before the token exists, since
the token is unretractable once it does. One pin per kref however many
URLs name it, and no release: the token is persistent and unexpiring, so
`revoke` is how the capability dies. Pinning also puts the holder inside
the reference graph, so the audit can see it rather than being taught to
excuse it.

The same shape had a second door. `incrementRefCount` has no
`kernelRefExists` guard where `decrementRefCount` does, so importing a
deleted kref read its missing counts as (0, 0) and wrote them back,
resurrecting a live-looking object with no owner — deliverable to by
nobody, and endorsed by the audit, since the new c-list entry is a
legitimate holder for exactly the count it finds. Reached by redeeming a
URL issued for an object since collected. Guard the point of corruption,
`translateRefKtoE`, rather than `incrementRefCount` itself: creating an
entry for a deleted kref is the invariant, and releasing a reference to
something already gone is how GC teardown is allowed to race deletion.

Also release a vat's root pin when `deleteSubcluster` retires vats that
never ran here. It bypasses `stopVat`, so nothing released the pin
`launchVat` took in the incarnation that did run them, leaving the root's
count permanently above zero and `pinnedObjects` naming a vat that no
longer exists. `stopVat` and `deleteSubcluster` now share
`releaseVatRootPin`.

Vat root pinning had no unit coverage at all, so pin-on-launch,
release-on-terminate and keep-across-restart are asserted now; the last
is what the comment claims and what would break silently. Restores the
`maybeFreeKrefs` assertion on `forgetEndpointImports`' ownership-migrated
branch, which lost its `not.toHaveBeenCalled` when that branch stopped
returning early.

Corrects three claims that the (0, 0) birth falsified and that shipped as
documentation: both `KernelServiceManager` comments asserting its delete
branch cannot fire, when it now does, and a changelog entry asserting
(1, 1) birth two dozen lines above one asserting (0, 0). `recomputeRefCounts`
no longer describes itself as a migration; nothing calls it, and opening
an existing store does not migrate one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Retaining before minting is right: minting awaits, so a collection crank can run in that window. But nothing undid the retention when minting then failed. A rejected kernel-service call is reported to the caller rather than thrown out of the crank, so the crank commits and the pin outlives the kernel that took it, naming a URL that never existed.

retainForOcapURL now reports whether this call took the pin, and undoOcapURLRetention unwinds one that never backed a URL. Guarded on the ledger rather than the pin list, so it can only remove the pin it put there: a kref some live URL already names keeps the pin that URL depends on, and a vat root keeps its lifetime pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retention was deduplicated by kref, and the failure path undid it if this
call was the one that took it. Minting awaits, though, so issuances for the
same target overlap: a second `issue` can mint a URL while the first is still
in flight, having taken no retention of its own because the ledger already
named the kref. If the first then fails it unwinds the retention the second's
live URL depends on, and collection can take the capability out from under it.

The ledger is a multiset now, one entry and one pin per issuance, so a failed
mint releases only what it took. Pins were already a multiset, and each pin
here is either released by its own failure or held by its own live URL, so
none is left unreleasable — the concern that motivated deduplicating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight tests, all currently failing, for three defects that landed with
#1005. They change no production code: each one states the invariant the
fix has to restore, so the diff that repairs them is the specification
being met rather than a claim about it.

`releaseSavepoint` was never hardened the way `rollbackSavepoint` was in
that PR. A RELEASE that throws leaves the savepoint on the stack and the
transaction open with nothing that will ever commit or abort it, so every
later write on the connection joins it, reports success, and vanishes on
close — verbatim the failure mode #1005 documents for the other door. The
driver tests sit beside their rollback counterparts so the asymmetry is
visible in place. `endCrank` gets the companion case: it now settles its
waiters in a `finally`, which is right, but it also leaves the savepoint
listed, so the next crank numbers its savepoint `t1` against a database
that still has `t0`.

`#processCrankResult` does fallible work after the crank's transactional
boundary has already been crossed. On the success path `#flushCrankBuffer`
settles the promise `enqueueMessage` handed an external caller, and only
then can `#terminateVat` throw and have the new catch roll the crank back
— so the caller keeps an answer computed from state the store discarded,
and a restart delivers the message again. On the abort path the rollback
ends the transaction, so `#terminateVat` and `collectGarbage` autocommit
piecemeal and the second rollback the flag correctly suppresses would
have had nothing left to undo either way. The invariant is stated as "the
rollback is the last thing the crank asks of the store", which leaves the
choice of remedy open.

The wasm driver tracks `_inTx` itself rather than reading it from SQLite,
so a failed abort inside the new catch is the one case that can leave it
disagreeing with the database. Left true, `beginIfNeeded` is a no-op from
then on and the next `createSavepoint` runs in autocommit mode, where the
matching RELEASE commits (Agoric/agoric-sdk#8423, already cited two lines
above the code) and no rollback can undo the delivery. The second test
runs that next `createSavepoint` and asserts the BEGIN, so the corruption
path is observable instead of argued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three transaction-integrity defects, all in the same family: a store call
fails, and the layer above goes on as though its bookkeeping still matched
the database.

- `releaseSavepoint` (both SQLite drivers) discards the enclosing
  transaction when `RELEASE` fails, as `rollbackSavepoint` already does
  when `ROLLBACK TO` fails. Left as it was, the savepoint stayed on the
  stack and the transaction open with nothing to ever commit or abort it,
  so every later write on the connection joined it, reported success, and
  vanished on `close()`.
- `releaseAllSavepoints` forgets its savepoints even if the release
  throws, as `rollbackCrank` already does. A savepoint left listed had the
  next crank number its savepoint `t1` while the database still had `t0`,
  from which point every release and rollback aimed one crank past the one
  it meant to end.
- The wasm driver stops believing it is in a transaction when an abort
  fails. `_inTx` is tracked in the driver rather than read from SQLite, and
  an abort usually fails because SQLite already rolled back on its own.
  Left true, `beginIfNeeded` was a no-op from then on and the next
  `createSavepoint` ran in autocommit mode, where its `RELEASE` commits
  (Agoric/agoric-sdk#8423) and no later rollback could undo the delivery.

And the crank boundary itself, in two parts:

- A crank now takes two savepoints. Rolling back to the outermost one
  discards the enclosing transaction, so the work an aborted crank still
  owes — terminating the vat whose delivery failed, collecting garbage —
  was autocommitting statement by statement, beyond the reach of any later
  rollback. That work has to follow the rollback, since the worker is gone
  and the store must not go on believing the vat is alive, so it is the
  rollback that spares the transaction. Releasing the outer savepoint in
  `endCrank` is now a crank's one commit point.
- `#flushCrankBuffer` runs last, after everything that can still fail.
  It settles the promise `enqueueMessage` handed an external caller,
  reading the result out of the store; rolling the crank back after that
  left the caller holding an answer computed from state the store had
  discarded, and a restart would deliver the message again.

Tests for the first three defects are Ryan's, from #1011. The two crank
tests there specify the remedy as "the rollback is the last thing the
crank asks of the store", which reordering the fallible work before it
would satisfy — but that rollback would then undo the vat termination.
They are restated here as the invariant the fix does hold.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`should trigger GC syscalls through bringOutYourDead` scheduled one reap
and then ran three cranks. `scheduleReap` dedupes, so that bought one
`bringOutYourDead`, not three — and an import is only reported as dropped
once the engine has collected the vat's presence and run its finalizer,
which the forced GC pass inside `bringOutYourDead` cannot guarantee on the
first attempt. When it hadn't, no further reap was ever scheduled and the
refcount stayed where it was: `expected 2 to be 1`, as on main in
31081630878.

Each attempt now schedules its own reap and stops as soon as the kernel's
bookkeeping catches up, so the common case is one crank rather than three.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A failed `ROLLBACK TO` discards the whole transaction, taking every
savepoint with it — not just the one rolled back to. `rollbackCrank`
truncated `ctx.savepoints` to the rolled-back ordinal regardless, which
was correct while a crank took one savepoint at ordinal 0 and cleared the
list, but leaves `['crank']` listed now that the delivery sits at ordinal
1.

`endCrank` then releases a `t0` the database no longer has, and throws
"No such savepoint: t0" from the run loop's `finally` — replacing the
failure that actually killed the kernel, with no `cause`. That is the
masking this branch's own error-preservation exists to prevent.

Clear the list on the throwing path, truncate to the ordinal only on
success.

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

Both drivers recover from a failed savepoint operation by discarding the
enclosing transaction, and swallow any error from that abort so the
savepoint failure stays the one reported. That part is right, but it left
the abandoned transaction entirely silent: on the nodejs driver, where
`inTransaction` is read from SQLite, the next crank's `beginIfNeeded`
sees the transaction still open, skips its `BEGIN`, and commits the dead
crank's writes alongside the new crank's.

Nothing here can repair that, so at least record it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving `#invokeKernelSubscription` out of the enqueue loop and after it
was the one production change on this branch with no test: reverting
`#flushCrankBuffer` to its interleaved form left all 2412 ocap-kernel
tests passing.

Same hazard as the crank-level ordering a few tests up, one level down —
`#enqueueRun` is store work and can fail part-way, so answering the first
caller while the second enqueue is still ahead hands out a result the
crank's rollback then discards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five comments on this branch asserted more than the code holds:

- `wasm.ts` claimed a stale `_inTx` meant "no later rollback can undo the
  delivery". False: a savepoint created in autocommit mode does open a
  transaction, and an inner savepoint still rolls back. The real cost is
  that writes outside a savepoint autocommit one statement at a time, and
  the outermost `RELEASE` commits. The "an abort typically fails because
  SQLite already rolled back" premise was unsupported and isn't the
  reason for the reorder — the reason is simply that the abort can throw.
- `#processCrankResult` said "the worker is already gone" ahead of the
  call that kills the worker.
- The flush was described as running "once nothing fallible remains".
  It doesn't: `#terminateVat` resolves the dying vat's promises through
  `resolvePromises`, which defaults to `immediate` and invokes their
  kernel subscriptions before `collectGarbage`. Reachable without an
  abort, via a clean `exitVat`. Recorded rather than fixed — closing it
  changes termination semantics, not crank ordering.
- "Only `delivery` is ever rolled back" is true of the run loop but not
  of the tests. Scoped, and the ordinal coupling it depends on is now
  stated: `endCrank` releases `t0` by position, so `crank` must stay
  first.
- `reapImporterUntil` credited `scheduleReap` deduping for the old
  one-BOYD behaviour; it was `nextReapAction` shifting the single entry
  off, leaving the later cranks nothing to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment the non-obvious why, in the shortest form that carries it. The
two-savepoint rationale was re-argued in full in four places; the tests
now point at `#runLoop` and `#processCrankResult` instead of restating
them, and the hazard block duplicated across both driver test files is a
line. No reasoning removed, only the retelling.

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

A database rollback cannot reach two pieces of state, so `rollbackCrank`
now reverts both itself.

Every `provideCachedStoredValue` answers reads from a closure and only
writes through to kv. Reverting the database therefore left the closure
holding the abandoned crank's value, and the next `set` persisted it.
`processGCActionSet` takes an action out of the set before delivering it,
so an aborted delivery lost the action outright rather than retrying it.
`reapQueue` was exposed the same way.

`maybeFreeKrefs` lives in RAM, so nothing reverted it either. Its entries
are collection candidates only because of the decrements the rollback
undid, and a later `collectGarbage` threw outright on a promise the
rollback had deleted, killing the run loop.

No live bug either way: every `abort` `#deliverGCAction` returns is paired
with a `terminate`, which is what made losing the action harmless. The
comment there claimed the rollback restored the action, which is the thing
a future reader would trust when adding an abort path that isn't paired
with a termination; it now states the real causality.

The cached values are declared once so that initialization and the
refresher cannot disagree about which ones exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Failing repro, not a fix.

## The issue

`rollbackIfNeeded` was corrected in #1012 to clear `_inTx` *before* stepping
the abort, because the abort can throw and `_inTx` is tracked in the driver
rather than read from SQLite. `commitIfNeeded` has the identical shape and was
left alone:

    function commitIfNeeded(): void {
      if (db._inTx && db._spStack.length === 0) {
        sqlCommitTransaction.step();   // can throw
        sqlCommitTransaction.reset();
        db._inTx = false;              // ...so this never runs
      }
    }

A COMMIT that throws leaves `_inTx` true against a database that may hold no
transaction. `beginIfNeeded` is then a no-op forever after, so the next
`createSavepoint` issues its SAVEPOINT outside a transaction — and a savepoint
taken outside a transaction commits when it is released
(Agoric/agoric-sdk#8423). That is the hazard the whole `beginIfNeeded` dance
exists to prevent, and `commitIfNeeded` is reached from `releaseSavepoint`,
which is the crank's commit point. The writes that leak are a whole crank's.

The nodejs driver is unaffected, for the same reason it was unaffected by the
abort case: it reads `db.inTransaction` live from SQLite.

Worth noting that the comment introduced above `stops believing it is in a
transaction when the abort fails too` asserts that a failed abort is "the one
case that can leave `_inTx` disagreeing with the database". This is the second
case, so that comment needs correcting along with the code.

## What we hope to see instead

`releaseSavepoint` still throws the COMMIT failure, but `_inTx` is false
afterwards, so the next `createSavepoint` opens a transaction of its own
instead of creating a bare savepoint. Same two-line reorder as
`rollbackIfNeeded`, and the "one case" comment updated.

## Current failure

    AssertionError: expected true to be false
      packages/kernel-store/src/sqlite/wasm.test.ts
      > stops believing it is in a transaction when the commit fails

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Failing repro, not a fix.

## The issue

#1012 fixes one error-masking path at the start of a dying crank and opens
another at its end.

Before the two-savepoint scheme, `rollbackCrank('start')` emptied
`ctx.savepoints`, so `endCrank` -> `releaseAllSavepoints` was a guaranteed
no-op on the dying path: nothing to release, nothing that could throw. Now
`rollbackCrank('delivery')` truncates to the ordinal and leaves `['crank']`
behind (crank.ts:56, deliberately — that is what keeps the transaction open for
the work an aborted crank still owes). So `endCrank` issues a real
`RELEASE t0`, which commits, which can fail.

`#runLoop` calls it from a bare `finally`:

    } finally {
      this.#kernelStore.endCrank();
      ...
    }

A throw there replaces the pending exception. The disk error that actually
killed the kernel is discarded — not demoted to `cause`, discarded — and
`run()` rejects with the release failure instead. `#failRunLoop` records that,
so `getRunLoopStatus().detail` loses the root cause too, and
`onRunLoopFailure` — what the daemon logs as fatal — gets the wrong error.

A/B against origin/main with the same repro: main reports `crank exploded`,
this branch reports `database is gone` with `cause: undefined`.

This is the same class of bug as the `No such savepoint: t0` masking that
82b88ce fixes, and the same class the `reports both failures when the
rollback also fails` test above already guards on the other path.

## What we hope to see instead

Whatever names the release failure, the error that killed the crank stays
reachable. The rollback path already has the shape to copy:

    throw new Error(
      `Run loop died and its crank could not be rolled back: ${...}`,
      { cause: error },
    );

The assertion is deliberately fix-agnostic — it walks the `cause` chain — so
either wrapping `endCrank`'s failure with the original as `cause`, or reporting
it and rethrowing the original, will satisfy it.

## Current failure

    AssertionError: expected [ Error: database is gone ]
      to include Error: crank exploded
      packages/ocap-kernel/src/KernelQueue.test.ts
      > reports both failures when endCrank also fails

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Failing repro, not a fix.

## The issue

#1012 replaces four silently-swallowed aborts with `logger?.error(...)` in the
SQLite drivers, and its description says: "Four swallowed aborts were silent.
Now logged." They are not. No production call site passes a `logger` to
`makeSQLKernelDatabase`, so every one of those calls is dead code:

    packages/kernel-node-runtime/src/kernel/make-kernel.ts:63
    packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts:47
    packages/kernel-test-local/src/lms-chat.ts:30
    packages/kernel-node-runtime/test/helpers/remote-comms.ts:172

`make-kernel.ts` is the clearest case: it builds a `rootLogger` and hands
sub-loggers to `NodejsPlatformServices` and to `Kernel.make`, then constructs
the store with `{ dbFilename }` alone. The store is the one collaborator that
gets no logger. Nor does any test pass one, which is why the gap survived
review.

This matters more than a missing log line. On the nodejs driver a failed abort
leaves `db.inTransaction` true with nothing that will ever commit or abort it,
so later writes on that connection join a transaction that vanishes on close.
The driver's own comment concedes "Nothing here can repair that" — the log is
the entire remedy, and it does not reach anyone.

`logger?.error` is the right convention for this package; the injection is what
is missing.

## What we hope to see instead

`makeKernel` passes a tagged sub-logger to `makeSQLKernelDatabase`, as it
already does for its other collaborators — something like
`rootLogger.subLogger({ tags: ['store'] })`. The other three call sites want the
same treatment, and are worth covering once this one is fixed.

## Current failure

    AssertionError: expected "vi.fn()" to be called with arguments:
      [ ObjectContaining{…} ]
    -     "logger": Any<Logger>,
      packages/kernel-node-runtime/src/kernel/make-kernel.test.ts
      > gives the kernel store a logger

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Failing repro, not a fix.

## The issue

#1012 hardens `releaseSavepoint` so that a failed `RELEASE` discards the
enclosing transaction, clearing the driver's `_spStack` on the way. Two callers
it does not touch depend on the old behaviour, and both are now worse off than
before the change.

`RemoteHandle.handleRemoteMessage` releases inside the `try` and rolls back in
the `catch`:

    this.#kernelStore.setRemoteHighestReceivedSeq(this.remoteId, seq);
    this.#kernelStore.releaseSavepoint(savepointName);   // fails
  } catch (error) {
    this.#kernelStore.rollbackSavepoint(savepointName);  // "No such savepoint"
    throw error;                                        // never reached
  }

Since the release already cleared the stack, the rollback throws
`No such savepoint: receive_r0_1`, which escapes the `catch` and replaces the
real failure. Not demoted to `cause` — replaced. `RemoteManager` has the same
shape at its `peerIncarnation_*` savepoint.

A/B verified against origin/main with a real driver: main's rollback succeeds
and `database or disk is full` propagates; on this branch the caller gets the
missing-savepoint error instead. So the PR description's "the release failure
still propagates" holds for the crank path it fixed and not for these two.

`crank.ts:57-63` shows the author recognised exactly this hazard — a stale
savepoint list producing `No such savepoint` over the real error — and fixed it
for the crank only. The remote paths were missed because nothing exercised them.

Note the secondary effect these tests don't reach: `ctx.savepoints` still lists
the crank's own savepoints after this, so the next `endCrank` throws
`No such savepoint: t0` over whatever is left of the failure.

## What we hope to see instead

The failure the database reported is what reaches the caller. Any of these does
it, and the assertion doesn't care which:

- move the release out of the `try`, so a release failure isn't followed by a
  rollback attempt at all
- have the `catch` tolerate a rollback that reports a savepoint already
  discarded, rethrowing the original either way
- make the driver's discard leave the name rollback-able as a no-op

The mock models the drivers' bookkeeping rather than the expected outcome, so it
is `RemoteHandle`'s error handling under test, not the mock's.

## Current failure

    AssertionError: expected Error: No such savepoint: receive_r0_1
      to be Error: database or disk is full
      packages/ocap-kernel/src/remotes/kernel/RemoteHandle.test.ts
      > reports the release failure rather than a missing savepoint

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No change to what any of them proves; all four still fail for the reasons
their own commits describe.

- `RemoteHandle`: assert the rollback is still *attempted*. Without this,
  deleting the rollback from the `catch` outright would turn the test green,
  which is not the fix — a `RELEASE` that failed for a reason of its own may
  well have left the savepoint standing.
- `RemoteHandle`: drop an unnecessary `as KernelStore` cast, and say why the
  store is replaced wholesale rather than having its methods assigned over
  (`makeKernelStore` hardens what it returns).
- `make-kernel`: note that `kernel-worker.ts` omits the logger too, so the wasm
  driver's pair of `logger?.error` calls stays dead even once this test passes.
  Use `vi.mocked`, as the sibling `make-kernel-options.test.ts` does.
- `causeChain` returns `Error[]`; every element is already narrowed by the loop
  guard.
- Drop "see the commit message for this test" from the four comment blocks: each
  stands alone, and the reference would not survive a squash-merge. Restate the
  claim the wasm comment made by citing a neighbouring test's title, which would
  have broken silently on rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`rollbackIfNeeded` was corrected for this ordering already; `commitIfNeeded`
still stepped the COMMIT first. `_inTx` is tracked in the driver rather than
read from SQLite, so a throwing COMMIT wedged it true: `beginIfNeeded` became a
permanent no-op, and the next savepoint was created bare — where its RELEASE
commits (Agoric/agoric-sdk#8423) and no later rollback could undo the delivery.

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

Now that the delivery rollback spares the `crank` savepoint, `endCrank`'s
release is a real RELEASE and COMMIT on the dying path, where it used to be a
no-op. `#runLoop` called it from a bare `finally`, so a failing one silently
replaced whatever killed the kernel — and only `error.message` crosses the
wire, so the real failure reached neither `getStatus` nor the daemon log.

Report it with the crank's failure as the `cause`, the shape the rollback path
already uses. The in-flight error is boxed rather than left `undefined`, so a
crank that threw `undefined` stays distinguishable from one that did not throw.

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

`RemoteHandle.handleRemoteMessage` releases its savepoint inside the `try` and
rolls back in the `catch`. Now that a failed RELEASE discards the whole
savepoint stack, that rollback names a savepoint that is already gone and threw
`No such savepoint` out of the `catch` in place of the database failure that
brought it there — not even as `cause`.

Log the rollback failure instead of throwing it. The rollback is still
attempted, because a release that failed for a reason of its own may well have
left the savepoint standing.

`RemoteManager`'s `peerIncarnation_*` savepoint has the identical shape and had
no coverage of it at all, so a fix applied here and forgotten there would have
left its suite green. Fixed alike, and the savepoint-stack model both tests
drive the drivers with is now shared.

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

`rollbackCrank` gained two pieces of work that compose the wrong way round if
the failure path simply rethrows: forgetting every savepoint, and reverting the
caches a database rollback cannot reach. A failed rollback discards the whole
transaction, so the database has moved back at least as far as a successful
rollback would have taken it and those caches are at least as stale — the one
case where skipping the revert leaves the consumed GC action lost and krefs
queued for a collection that then kills the run loop.

The second test pins the other direction: reverting must not become a way to
lose the database error either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No production call site passed one, so every `logger?.` call in the SQLite
drivers was dead code — including the aborts they report while discarding a
transaction, which fire on exactly the path where the kernel is already dying
and a diagnostic is worth most.

The browser worker has a module-level `Logger` already, so both drivers are
covered rather than only the nodejs one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid and others added 8 commits August 17, 2026 11:56
The crank-transaction and rollback work landed here rather than in #1012,
which is closed and replaced. #1021 is a placeholder until the PR exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the c-list accounting fix, addressing defects found in review.

An owner that stops naming its own export left the object behind. Both the
delivered `retireExport` and the `retireExports`/`abandonExports` syscalls tore
down the owner's c-list entry but left `owner` and `refCount` in place, with no
path that could ever reclaim them: `cleanupTerminatedVat` finds krefs by walking
the owner's c-list, and the collector only revisits krefs in `maybeFreeKrefs`.
The records leaked, and the next collection to visit such a kref read the
owner's deleted entry through `getRequired` and took the run loop down with it.
New `orphanKernelObject` drops the owner mapping and hands the object to the
collector, which already knows how to retire an orphan. `collectGarbage` also
treats an owner with no c-list entry as orphaned rather than trusting the
mapping.

Reporting a dead run loop belongs to #1005, which landed on main first. It is
what makes the audit usable at all: `assertRefCountsIfAuditing` throws from
inside a crank, so with the failure logged and swallowed a violation's sole
symptom was a test hanging to its timeout with no mention of reference counts.
The `kernel-test` case here asserts that shape — the caller is told the run loop
died, and the audit error rides along as the `cause`.

Also: GC action delivery survives a vanished endpoint or a failed delivery
instead of stopping the loop; `launchVat` tears down a worker whose kernel-side
registration failed rather than stranding it; `RefCountViolation` discriminates
on `kind` instead of sentinel-matching `stored`; and the store context's
auditing flag no longer shares a name with `auditRefCounts()`.

Tests cover the crash path, the orphan-and-collect sequence, retiring
stragglers, GC-action robustness, and that a violation reaches a caller. The
`item.target` charge and both `deliver|notify` early returns now have assertions
that fail if the fix is reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the previous commit found that four of the five error handlers it
added turned a crash into a state the kernel can no longer detect. Corrects
that, and closes a hole the orphaning opened.

`orphanKernelObject` took an object's owner mapping on trust. Nothing upstream
of `performExportCleanup` checks that the vref it was handed is even an export —
`translateSyscallVtoK` maps both directions alike — so a vat could pass an
import to `abandonExports`, which needs no precondition at all, and erase a
different live vat's claim to an object it was still exporting. Sends to that
object then went splat with OBJECT_DELETED, terminating the victim tripped
`cleanupTerminatedVat`'s ownership assertion and took the run loop with it, and
the audit could not see any of it, because an export entry carries no count.
Disowning is now the owner's own doing: the expected owner is a required
argument and must match, and the syscall path rejects a mismatch outright.

The vanished-endpoint catch returned before the teardown, but
`processGCActionSet` had already consumed the action, so neither the kernel nor
the durable set remembered the object — a permanent leak, also invisible to the
audit. The kernel's side is now released whether or not anyone is left to tell,
and krefs whose entries a cleanup already removed are skipped rather than
assumed present.

The delivery-failure catch committed the teardown after the endpoint had failed
to hear about it, so the endpoint would go on to mint a fresh kref for an object
the kernel believed it had let go of — the same object with two identities. It
now aborts, which restores both the entries and the action, and terminates the
vat that could not accept the delivery.

`launchVat`'s cleanup path stopped the worker without marking the vat
terminated, so nothing ever reclaimed the records a partial launch had written.

The audit counted an importer's c-list entry as a holder during the window
between `retireKernelObjects` deleting an object and delivering the matching
`retireImport`, so the collector's own output failed the end-of-crank check. The
missing assertion in the test covering that sequence is now present.

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

Aborting a failed GC delivery restores the action to the durable set, and
`processGCActionSet` is consulted ahead of all other run-queue work. For a vat
that is fine, because terminating it is what stops the restored action from
coming back. A remote cannot be terminated, so the same item would be selected
every crank and nothing else would ever run. A remote is a separate kernel
across a link that can drop messages anyway, and it reconciles on the next
incarnation change, so its failures no longer abort.

Also stop `orphanKernelObject` throwing on an object that is already orphaned.
Disowning something nobody owns is a no-op, not an error: only a mismatch with a
different, live owner is, which is the case the check exists for. Same for the
syscall path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clean-audit cases prove each rule agrees with whatever the store did,
which stays true if a rule and the code it mirrors are wrong by the same
constant. Six of eight rules could have drifted and the suite would have
stayed green.

Each of the ten credit sources now pins its count and holder labels to
literals and asserts drift in both directions: too low collects a live
capability, too high leaks it. That closes the two coverage gaps as a side
effect — a run-queue send's result promise, and a message parked on an
unresolved promise, neither of which any test reached.

Also states what the audit can and cannot find, which matters because its
ground truth *is* the holder set: a count that disagrees with its holders
is caught either way, but a holder that should have been torn down and
wasn't justifies its own count at any value, so a leaked reference is
invisible to it by construction. That is exactly the case the retained
settled-promise c-list entry leaves behind, so the CHANGELOG no longer
claims the audit would catch it.

The `auditRefCounts` JSDoc no longer scopes the option as "intended for
tests and debugging": it stands in for the invariant `collectGarbage`
cannot assert, and is off by default only because it walks the whole store.

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

Releasing the kernel's side of a garbage-collection action when the endpoint has
vanished is right for an endpoint that is gone, and wrong for one that is merely
out of reach. `restartVat` keeps the vat's c-list and takes the vat out of the
kernel's vat table for as long as launching a worker and negotiating with it
takes, so a GC action selected in that window found the vat absent, released
entries the returning incarnation still holds, and committed — leaving the vat
free to mint fresh krefs for objects the kernel thinks it let go of. That is the
same divergence the failed-delivery path below rolls back to avoid.

The endpoint is now resolved before anything is torn down, so the outcome is
decided rather than discovered halfway through, and the release commits only
where the endpoint is genuinely gone: a vat the store has marked terminated,
whose cleanup tears the whole c-list down regardless, or a remote, which
reconciles on its next incarnation. A vat that is absent yet not terminated fails
the crank instead, which is what this path did before the release was added to
it.

This does not make a vat restart safe, and is not trying to: it stops the GC path
from turning that window into silent corruption. The window itself needs the vat
to stop being unreachable while it restarts — `restartVat` is an RPC handler
mutating kernel state alongside a running run loop, which a send already resolves
as a splat and a `notify` already dies on.

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

Both are new here and neither was reached by a test. The ownership guard is
the one that matters: nothing upstream of `performExportCleanup` checks that a
vref is even an export, so without it a vat can disown another live vat's
object. Removing the guard now fails two cases rather than none.

`launchVat`'s registration failure is covered through the store calls it
makes, since `VatManager` is hardened and cannot be spied on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also narrows the audit's Added entry: this branch turns `RefCountViolation`
into a discriminated union, and the audit compares counts against the holders
it finds, so a holder that should have been torn down but wasn't justifies its
own count. "A leak" overstated what it can detect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants