Skip to content

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

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

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

Conversation

@sirtimid

@sirtimid sirtimid commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #1006.

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.

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.

Two judgment calls worth review

The gc.ts:169 assert is not re-enabled. The issue asks for it; I believe it would fire legitimately. When the last holder drops and retires before GC runs, dropExport and retireExport are queued in the same pass and the owner's flag is still set until the first is delivered — drops an object once the last of several importers lets go demonstrates exactly this. Upstream SwingSet also leaves it disabled with the same TODO. I replaced the dead line and stale TODO with the reason. The audit is what validates the accounting now.

Settled promises' c-list entries are still not torn down on notify. SwingSet does this (translateNotify), and I had it working, but it breaks the debug UI: kernel-ui discovers exported ocap URLs by scraping settled promise values found through c-list entries, and issueOcapURL is stateless — nothing persists issued URLs, so it has no other source. The refcount corrections in that function are all kept; only the record-freeing cleanup is deferred, with a TODO. This is pre-existing behaviour, not a regression, and the audit is green without it. Giving the UI a real source is separate work.

Verification

  • auditRefCounts clean across all of kernel-test, which now runs it after every crank
  • Full monorepo unit suite: 52/52 tasks
  • yarn test:e2e:ci: 17/17 in extension
  • Symptoms 1–4, cleanupTerminatedVat (previously covered only by a name-export assertion), and a ≥3-endpoint topology all have regression tests — plus an end-to-end two-importer test in kernel-test proving the shared object survives the first importer letting go

Test expectation changes, and why

  • object.test.ts, store/index.test.ts: (1,1)(0,0) at birth, as the issue predicted
  • clist.test.ts: an import entry is born un-flagged
  • promise.test.ts getPromisesByDecider: rewritten against the real key layout — it had mocked getPrefixedKeys to return the stale cle. keys, which is what hid the prefix bug
  • persistence.test.ts: a hand-written refCount fixture encoded the old accounting
  • control-panel.test.ts (e2e): dropped the ko6.refCount assertion. Root pinning ties that value to vat liveness, so it now flips between 1,1 and 2,2 depending on whether carol's termination has been processed when the dump is taken. The semantics are covered deterministically in clist-accounting.test.ts instead.

Note on #994

#994 says translateRefKtoE(remoteId, kref, true) "allocates a c-list entry and increments the refcount". Before this PR no increment occurred, so its pinned-refcount consequence was unfounded; after this PR the increment does happen. Its other two consequences were always unaffected.

🤖 Generated with Claude Code


Note

High Risk
Touches core capability refcounting, garbage collection, and message delivery; includes breaking accounting semantics and many paths that can prematurely collect or leak live capabilities if wrong.

Overview
Makes c-list import accounting symmetric so creating an import entry takes a reference (matching teardown), new objects start at (0,0) instead of a phantom (1,1), and owner-side “baseline” decrements are removed. Adds setReachableFlag and orphanKernelObject, pins vat roots for the vat’s lifetime, and updates GC delivery so the kernel’s own c-list moves on dropExports / retireExports / retireImports.

Adds reference-count auditing (auditRefCounts / Kernel.make({ auditRefCounts }), run after each crank) plus recomputeRefCounts for migration; kernel-test enables auditing by default.

Router / queue fixes: charge send targets against the run-queue item (not the post-routing kref), transfer refs when re-queuing onto unresolved promises, fix notify refcount leaks, and reject retireExports from non-owners. Run loop: record terminal failures, reject pending queueMessage waiters, and surface audit errors instead of hanging.

Tests add multi-importer GC coverage, c-list accounting regressions, refcount-audit behavior, and updated expectations for the new counting model; fixes getPromisesByDecider to scan the real ${endpoint}.c. layout (not stale cle. keys).

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

sirtimid and others added 2 commits August 5, 2026 15:29
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>
- Drop the refCountScheme migration: no production stores exist with
  the old counting scheme, so the recompute-on-open path is dead code
- Update changelog PR links from #1006 (issue) to #1010 (this PR)
- Fix changelog formatting: add blank lines before sub-bullets of the
  @@name and 'Fix the stale cle./clk.' entries to satisfy auto-changelog
  --prettier validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 71.74%
⬆️ +0.38%
9069 / 12641
🔵 Statements 71.56%
⬆️ +0.37%
9220 / 12883
🔵 Functions 72.69%
⬆️ +0.21%
2162 / 2974
🔵 Branches 65.51%
⬆️ +0.62%
3699 / 5646
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.91%
⬆️ +1.42%
78.57%
⬆️ +0.80%
84.78%
⬆️ +2.18%
89.91%
⬆️ +1.42%
343, 367, 448-458, 546, 614, 680-683, 696, 706-707, 760, 779
packages/ocap-kernel/src/KernelQueue.ts 96.46%
⬇️ -1.72%
90%
🟰 ±0%
100%
🟰 ±0%
96.46%
⬇️ -1.72%
89, 245-246, 361
packages/ocap-kernel/src/KernelRouter.ts 94.83%
⬆️ +0.90%
81.81%
⬆️ +3.35%
100%
🟰 ±0%
94.83%
⬆️ +0.90%
114, 177, 194, 268, 323, 383, 401, 404
packages/ocap-kernel/src/garbage-collection/gc-handlers.ts 77.27%
⬇️ -0.50%
68.75%
⬆️ +2.09%
100%
🟰 ±0%
77.27%
⬇️ -0.50%
45-47, 50, 77-79, 88-90, 94
packages/ocap-kernel/src/store/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
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.47%
⬆️ +1.43%
80.35%
⬆️ +8.01%
100%
🟰 ±0%
90.47%
⬆️ +1.43%
61, 170, 182, 224-231
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% 93.1% 100% 100%
packages/ocap-kernel/src/store/methods/translators.ts 98.43%
⬆️ +0.05%
96.42%
🟰 ±0%
100%
🟰 ±0%
98.43%
⬆️ +0.05%
151
packages/ocap-kernel/src/store/methods/vat.ts 98.44%
⬆️ +1.18%
89.47%
⬆️ +6.14%
100%
🟰 ±0%
98.43%
⬆️ +1.19%
289-290
packages/ocap-kernel/src/vats/VatManager.ts 93.33%
⬇️ -6.67%
88%
⬇️ -12.00%
100%
🟰 ±0%
93.33%
⬇️ -6.67%
143-160
Generated in workflow #4583 for commit 1b61834 by the Vitest Coverage Report Action

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.

Nothing reported a dead run loop. `assertRefCountsIfAuditing` throws from inside
a crank, and the only handler logs and swallows it, so a violation's sole
symptom was a test hanging to its timeout with no mention of reference counts —
which made the audit useless as the build gate it was added to be. The kernel
now records why the loop stopped, rejects the messages it was carrying, and
reports it from `queueMessage`.

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 two crash paths, 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>
@sirtimid
sirtimid marked this pull request as ready for review August 5, 2026 16:40
@sirtimid
sirtimid requested a review from a team as a code owner August 5, 2026 16:40
Comment thread packages/ocap-kernel/src/KernelRouter.ts
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>

@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 bdb489f. Configure here.

Comment thread packages/ocap-kernel/src/KernelRouter.ts Outdated
@sirtimid
sirtimid marked this pull request as draft August 5, 2026 17:16
…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>

@grypez grypez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the accounting change and the two judgment calls. The core fix reads as correct to me, and the checker-first ordering clearly earned its keep. Both judgment calls are sound; my notes below are on the reasoning around them, not the decisions.

One inline comment on a stale invariant claim, plus the notes here. Everything else I found is pre-existing rather than introduced by this PR, and I've written those up as separate issues rather than pile them onto this diff — links at the end.

The disabled gc.ts assert

I traced this and agree. At gc.ts:210-216, when the last holder drops and retires in one crank, clearReachableFlag takes reachable to 0 and forgetKref takes recognizable to 0 before collectGarbage runs, while the owner's own flag is untouched until the first delivery — so both actions get queued with vatConsidersReachable === true and recognizable === 0, exactly the assert's negation. Leaving it off and replacing the stale TODO with the reason is the right call.

The thing worth drawing out: "The audit is what validates the accounting now" makes the audit load-bearing for correctness, while the Kernel.make JSDoc scopes it as "intended for tests and debugging." Those pull in different directions, and the coverage suggests the first framing is currently ahead of the artifact:

File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
 refcount-audit.ts |   95.57 |    89.65 |     100 |   95.57 | 156,207-210

Line 156 is credit(message.result, …) — no test queues a message with a non-null result. Lines 207-210 are the entire promise-queue branch; nothing in the audit tests calls enqueuePromiseMessage. And drift is asserted in both directions for only one of the eight credit sources (c-list import, refcount-audit.test.ts:124-162); the other seven are exercised only in the "audit is clean" direction, so six of the rules could be off by a constant and the suite would stay green. Since this is the artifact inheriting the assert's job, per-credit-source drift coverage seems worth having before it carries that weight.

Settled promises' c-list entries

The UI constraint is a real product call and I'm not arguing with it; the TODO states the cost accurately. But one inference in the description doesn't hold:

and the audit is green without it

The audit is green here by construction, not as evidence. The retained c-list entry is itself a credited holder (refcount-audit.ts:179-186), so the stored count and the recomputed count agree — and they would agree at any value, as long as an entry exists to justify it. The auditor's ground truth is the holder set, so it can detect a count that disagrees with a holder but structurally cannot detect a leaked holder. Worth knowing precisely because this is the one leak the PR knowingly retains.

Same reason the CHANGELOG line reads broader than the behaviour: "counts too high with no holder (a leak)" catches an orphaned count, not an orphaned reference. Might be worth a sentence in the audit's doc comment saying which of the two it finds.

Follow-ups filed separately

Three things I believe are pre-existing and out of scope here, written up with reproductions so they can be judged independently:

  • #1015retireKernelObjects never notifies remote importers, leaving a dangling c-list entry. Latent today; the topology is not covered by kernel-test, so it does not contradict the clean-audit claim in the description.
  • #1016 — a throw inside a crank commits the partial crank rather than rolling it back. Identical try/finally shape on main; this PR only adds one new throw source.
  • #1017 — GC deliveries to remotes carry rrefs in the sender's frame, so the receiver mints a phantom object and the action has no effect. Also pre-existing; this PR's new isVatId catch actually improves the surrounding failure handling.

// A vat is local and reliable, so a refusal means it is broken. Undo the
// teardown rather than commit it: leaving the two disagreeing would have
// the vat mint fresh krefs for objects the kernel thinks it let go of.
// Aborting restores the entries and the action; terminating the vat is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

rollbackCrank doesn't restore the consumed GC action, so this comment's second clause is inverted.

Aborting restores the entries and the action; terminating the vat is what stops that restored action from being retried forever.

The entries, yes — the DB rollback covers those. The action, no. gcActions is a provideCachedStoredValue (store/index.ts:147), which keeps the value in a closure and writes through to kv (base.ts:98-117). rollbackCrank (crank.ts:44-62) rolls the database back and then refreshes only the run queue. The gcActions closure still holds the post-processGCActionSet value, so the reduced set wins and the next set persists the loss.

Reproduction, against a real store:

AssertionError: expected [] to strictly equal [ 'v1 dropExport ko1' ]

reapQueue is cached the same way and behaves the same way; that exposure is pre-existing.

So the causality is the other way round from what the comment says: terminating the vat isn't what stops the restored action being retried — it's what makes losing the action harmless, because the action was going to a vat that no longer exists. Since every abort this function returns is paired with terminate, there's no live bug. I'm flagging it because the comment is the thing a future reader will trust when they add an abort path that isn't paired with a termination.

Fix is one line: re-provide both cached values in rollbackCrank, as reset() already does at store/index.ts:217-218. I have the failing test written and can hand it over.

Adjacent, same function: rollbackCrank doesn't clear ctx.maybeFreeKrefs either, which store/index.ts:140-144 states as an invariant. The GC rollback paths happen to survive it because collectGarbage re-reads counts, but gc.ts:161 getKernelPromise throws for a promise a rollback deleted.

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

2 participants