Skip to content

fix(ocap-kernel): make c-list import accounting symmetric - #1020

Open
sirtimid wants to merge 5 commits into
mainfrom
sirtimid/clist-refcount-symmetry
Open

fix(ocap-kernel): make c-list import accounting symmetric#1020
sirtimid wants to merge 5 commits into
mainfrom
sirtimid/clist-refcount-symmetry

Conversation

@sirtimid

@sirtimid sirtimid commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes #1006. Replaces #1010, which carried this plus three unrelated fixes; it is split into four PRs, this one first.

The defect

Creating an import c-list entry changed no refcount; tearing one down decremented both reachable and recognizable. initKernelObject compensated by minting every object at (1, 1), which is exactly right for one importer — the only topology our tests exercised. There is no setReachableFlag in the repo; it was never ported.

That single unit was also claimed by two parties: importer-side (object.ts: born at 1 "on the assumption that the new object corresponds to an object that has just been imported") and owner-side (vat.ts: "the baseline decrement below corresponds to the implicit reference exportFromEndpoint installed…"). Both an importer's drop and the owner's termination were entitled to spend it.

All four symptoms in the issue reproduced against the real store before the fix, and are covered by regression tests now.

main has since grown a second compensation for this

While this was in review, #983 landed this in cleanupTerminatedVat:

// Skip baseline decrement if GC already zeroed reachable via dropImports.
const { reachable } = getObjectRefCount(kref);
if (reachable > 0) {
  decrementRefCount(kref, 'cleanup|export|baseline');
}

That is a guard against the phantom baseline, at the same site this PR deletes the baseline decrement outright. This branch removes it; the condition is moot once no phantom unit exists. #983's parallel-launch tests pass unchanged under the audit.

Approach

Followed the issue's proposed path, in order.

Step 1 — the invariant checker, first. store/methods/refcount-audit.ts 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 (the issue's symptom 4 would pass an underflow-only check). The credits mirror incrementRefCount case for case.

Enabled per kernel via Kernel.make({ auditRefCounts: true }), run after every crank, and on for every kernel kernel-test builds — so a violation fails the build.

Step 2 — restore the increment, rebase the baseline. initKernelObject(0, 0); addCListEntry takes the entry's reference, mirroring deleteCListEntry; new setReachableFlag; owner-side baseline decrements deleted. collectGarbage is already a faithful port of processRefcounts, so this hands it the inputs it was written for.

Step 3 — remove the compensations. This is where the checker earned its keep. It found four more unbalanced paths the phantom baseline had been absorbing:

  • #deliverSend charged the target against the routed kref, not the run-queue item's own. For a message routed through a resolved promise those differ, so it decremented an object nobody charged and leaked the promise.
  • #deliverNotify released its reference only on the success path, leaking it on both early returns, and decremented promises retired alongside it that nobody had taken.
  • A message queued on an unresolved promise duplicated every reference it carried when re-enqueued on resolution.
  • resolve|kpid incremented with no matching release. (I had assumed resolve|decider cancelled it; that releases the distinct unsettled-promise reference.)

Two things the baseline was silently standing in for, now explicit:

  • Vat roots are pinned for their vat's lifetime, released on termination. A root is addressable whether or not anyone imports it — SwingSet pins static vat roots for exactly this reason. pinVatRoot already existed and was never called internally.
  • GC action delivery moves the kernel's own c-list: dropExports clears the owner's flag, retireExports/retireImports tear the entry down. krefsToExistingErefskrefsToErefs, which throws rather than silently dropping an unmapped kref.

Judgment call worth review

The gc.ts:169 assert is not re-enabled. The issue asks for it; I believe it would fire legitimately. Left as a comment explaining why, and the audit covers the same ground from outside.

What moved to the other PRs in this stack

This is the first of four. The rest are being prepared now and will be linked here as they open; #1010, #1011, #1012 and #1018 stay open until then, so nothing looks dropped.

Reviewing in order is worthwhile; each one's diff is much smaller than #1010's was.

Testing

yarn lint clean, yarn build 31/31. @metamask/ocap-kernel and @ocap/kernel-test fully green, with auditRefCounts on for every kernel kernel-test builds.

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
Touches core capability GC, refcount invariants, and breaking object birth semantics; incorrect accounting can collect live objects or leak capabilities, though auditing and extensive new tests mitigate regression risk.

Overview
BREAKING: Kernel objects are created at (0, 0) instead of (1, 1), and creating an import c-list entry now takes a reference (matching teardown). Owner-side “baseline” decrements in vat/peer cleanup are removed. krefsToExistingErefs is renamed to krefsToErefs and throws on unmapped krefs.

Adds reference-count auditing (auditRefCounts, recomputeRefCounts, optional Kernel.make({ auditRefCounts })) that recomputes counts from visible holders and runs assertRefCountsIfAuditing after every crank; kernel tests enable this by default.

Reachability and lifetime: New setReachableFlag; translateRefKtoE re-establishes reachability when a dropped import is handed over again. Vat roots are pinned for the vat’s lifetime (released on termination / subcluster delete without stopVat). OCAP URL issuance retains the target via retainForOcapURL (with undo on mint failure); deleted krefs cannot be imported or issued.

Delivery / queue accounting fixes: GC actions now update the kernel’s c-list (dropExports clears reachable; retire tears entries down). Send delivery charges the run-queue item’s target, not the routed target. Promise re-queue transfers refs instead of duplicating; notify and promise-queue paths fix several leak/double-decrement bugs. getPromisesByDecider scans the correct ${endpointId}.c. layout (fixes missed promise rejection on peer restart).

Tests and e2e expectations are updated for the new counts; new regression coverage for multi-importer GC, c-list accounting, and refcount audit.

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

Comment thread packages/ocap-kernel/src/store/methods/refcount-audit.ts
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 72.43%
⬆️ +0.38%
9517 / 13138
🔵 Statements 72.27%
⬆️ +0.38%
9671 / 13381
🔵 Functions 73.04%
⬆️ +0.17%
2247 / 3076
🔵 Branches 66.43%
⬆️ +0.63%
3881 / 5842
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/kernel-test/src/utils.ts 86.95%
🟰 ±0%
70.58%
🟰 ±0%
94.44%
🟰 ±0%
86.66%
🟰 ±0%
43, 115, 120, 161-176
packages/ocap-kernel/src/Kernel.ts 89.92%
⬆️ +0.16%
79.54%
⬆️ +0.97%
85.41%
🟰 ±0%
89.92%
⬆️ +0.16%
324-326, 397, 421, 496-506, 594, 662, 738-741, 754, 764-765, 818, 841
packages/ocap-kernel/src/KernelQueue.ts 98.56%
🟰 ±0%
90.27%
🟰 ±0%
100%
🟰 ±0%
98.56%
🟰 ±0%
148, 522
packages/ocap-kernel/src/KernelRouter.ts 94.2%
⬆️ +0.27%
79.1%
⬆️ +0.64%
100%
🟰 ±0%
94.2%
⬆️ +0.27%
110, 173, 190, 264, 319, 379, 397, 400
packages/ocap-kernel/src/KernelServiceManager.ts 98.52%
⬆️ +2.94%
92.3%
⬆️ +7.69%
100%
🟰 ±0%
98.52%
⬆️ +2.94%
310
packages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/index.ts 98.88%
⬆️ +0.27%
95.23%
⬆️ +4.33%
100%
🟰 ±0%
98.87%
⬆️ +0.28%
364
packages/ocap-kernel/src/store/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/base.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/clist.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/gc.ts 90.41%
⬆️ +1.37%
78.72%
⬆️ +4.26%
100%
🟰 ±0%
90.41%
⬆️ +1.37%
138, 150, 181-188
packages/ocap-kernel/src/store/methods/object.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/promise.ts 100%
🟰 ±0%
95.23%
⬆️ +0.79%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/reachable.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/refcount-audit.ts 100% 94.64% 100% 100%
packages/ocap-kernel/src/store/methods/refcount.ts 100%
🟰 ±0%
100%
⬆️ +3.13%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/translators.ts 98.48%
⬆️ +0.10%
96.66%
⬆️ +0.24%
100%
🟰 ±0%
98.48%
⬆️ +0.10%
162
packages/ocap-kernel/src/store/methods/vat.ts 98.44%
⬆️ +1.15%
89.47%
⬆️ +7.66%
100%
🟰 ±0%
98.43%
⬆️ +1.16%
288-289
packages/ocap-kernel/src/vats/SubclusterManager.ts 96.21%
⬆️ +0.02%
90.12%
🟰 ±0%
100%
🟰 ±0%
96.15%
⬆️ +0.02%
155-158, 236-239, 293, 373, 393, 410
packages/ocap-kernel/src/vats/VatManager.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #4644 for commit 051d772 by the Vitest Coverage Report Action

sirtimid added a commit that referenced this pull request Aug 13, 2026
…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>
@sirtimid

Copy link
Copy Markdown
Member Author

Audit rejects valid orphan retirement (Cursor Bugbot, High)

Confirmed and fixed in 5e3be431f. This was a real latent run-loop kill, not a false positive — thanks Bugbot.

Reproduced against the real store (makeKernelStore(makeMapKernelDatabase()), no mocks): an object owned by a terminated-and-cleaned-up vat, still named by a surviving vat's dropped-but-recognizable import. collectGarbage() then auditRefCounts() yields

ko1: stored (deleted), expected 0,1 (held by: v3 c-list import o-1)

The ordering is as reported: retireKernelObjects queues the retireImport actions and calls deleteKernelObject in the same loop (gc.ts:106-117), deleteKernelObject removes only owner/refCount/revoked and never the importers' c-list entries, and processGCActionSet can only return the action as a future run-queue item. So assertRefCountsIfAuditing() at KernelQueue.ts:347 runs while those entries still exist. And it is fatal rather than noisy: the throw unwinds out of #runLoop into #failRunLoop.

Fix: computeExpectedRefCounts no longer credits an importer entry that has a matching retireImport already queued. Those entries are scheduled for teardown and are not holders.

One correction to the report's framing: retireKernelObjects is reachable only via the orphaned branch (gc.ts:198-204), so it needs an owner that is terminated or already cleaned up — it is not any retirement. That doesn't reduce the severity, because #runLoop calls nextTerminatedVatCleanup() inside the crank, which orphans the dead vat's exports into maybeFreeKrefs, and collectGarbage() consumes them at the end of that same crank. An ordinary terminateVat reaches it.

Exposure: real but previously unexercised. Nothing in the suite produced this state, which is why it was green. Nothing exotic is needed either — terminating a vat while a surviving vat holds its export in liveslots' normal dropped-but-still-recognizing state is enough.

Pinned by tolerates an importer entry that outlives the object it names in clist-accounting.test.ts, mutation-verified: reverting the guard fails that test and only that test, with the error above.

sirtimid and others added 3 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>
@sirtimid
sirtimid force-pushed the sirtimid/clist-refcount-symmetry branch from 5e3be43 to 1f9c888 Compare August 13, 2026 17:46
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>
Comment thread packages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts Outdated
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>

@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 and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 051d772. Configure here.

this.#kernelStore.undoOcapURLRetention(kref);
}
throw error;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Failed mint can unpin live URL

Medium Severity

issueOcapURL treats retainForOcapURL returning true as exclusive ownership of the pin, then undoOcapURLRetention on mint failure. A second overlapping issue for the same kref sees the entry already recorded and takes no pin. Kernel service calls are not awaited, and minting yields on cipher.encrypt, so another crank can mint successfully while the first still holds retained === true. If that first call then fails, it unpins the kref a live URL now depends on, and collection can take the capability.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 051d772. Configure here.

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.

C-list import accounting is asymmetric: the refcount increment on import is missing

1 participant