Skip to content

Commit f02677d

Browse files
sirtimidclaude
andauthored
fix(ocap-kernel): make c-list import accounting symmetric (#1020)
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`: ```js // 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**: counts too low, which lets a live capability be collected, and counts too high, which keeps a dead one alive (the issue's symptom 4 would pass an underflow-only check). It compares 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. 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**. The audit reports by throwing, which kills the run loop, and the kernel hands run loop death to `onRunLoopFailure` rather than rethrowing it — so `kernel-test` passes a handler that fails the test, and a violation on a GC-only crank or after a test's last assertion fails the build too. **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. `krefsToExistingErefs` → `krefsToErefs`, which throws rather than silently dropping an unmapped kref. ## Migration **There is none, and none is planned at this version: a store written before this change must be reset.** `kernel-store` has no schema version and no migration path, so such a store opens under this code with every object still at `(1, 1)`, no pin recorded for any vat root, and its pin and retention records in a layout this code does not read. Both consequences land on the crank path, against an existing user's database: - the second importer's `dropImports` throws `"ko1" underflow -1,1` from inside `performDropImports`; - `initializeAllVats` uses `runVat`, which does not pin, and relies on the persisted pin a legacy store does not have — so the last importer's drop can retire a live vat's root. `recomputeRefCounts` rebuilds the counts from ground truth, but it cannot restore the root pins, so it is a diagnostic for a drifted store rather than an upgrade path. Reach it by calling `makeKernelStore` over the kernel's own database; `RefCountViolation` is now exported from the package root. ## 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. ## Changes since review @grypez's seven in-scope items and @FUDCo's, one commit each. 1. **`incrementRefCount` guards at the primitive.** It now `Fail`s on a missing object row, symmetric with the decrement's guard — the guard was at two call sites, so `pinObject`, `resolve|slot` and every other path could still resurrect a deleted object. The call-site guards stay: they refuse before an eref is allocated or a ledger entry is written, and name what was attempted. 2. **The audit actually fails the build.** `kernel-test` passes an `onRunLoopFailure` that reports the failure to `afterEach`/`afterAll` hooks, so the test fails with the message naming the drifted kref. An async rethrow was the first attempt and is worse: under `endoify-node` it exits the worker with `process.exit unexpectedly called with "-1"` and the real error nowhere in sight. `io.test.ts` and `endowment-globals.test.ts` build kernels directly and are audited now too. Verified by injecting a double increment into `pinObject`: two `cluster-launch` tests fail with the violation, where before they passed. 3. **The audit compares the raw refcount row** instead of reading it back through `getObjectRefCount`, which `Fail`s on `reachable > recognizable` — one of the two drifts it exists to report. A malformed row is now reported as it stands. 4. **Both headline fixes are pinned by tests.** A send routed through a promise that fulfilled to an object, where the queued and routed targets differ; and the notify release on both early returns, plus a batch retiring a sibling promise. 5. **`resolvePromises` charges `data.slots` after the state and decider checks**, so an illegal `syscall.resolve` leaves nothing behind. 6. **Migration decision stated above.** 7. **Changelog:** the rename moved to `### Changed` as its own **BREAKING** bullet, the "counts too high (a leak)" claim corrected to name the blind spot, `undoOcapURLRetention` added, and the blank lines my formatting commit put inside the #984 entry reverted. 8. **Retentions and pins are counted per object**, not listed in one row. Which objects get URLs is the holder's choice, so neither list was bounded by anything the kernel controls, and each issuance rewrote the whole row. A count per object is one write per issuance and keeps the per-issuance semantics: overlapping issuances share the one pin. And ending a retention is two operations, not one — `undoOcapURLRetention` unwinds a single failed issuance, `releaseOcapURLRetentions` drops the object's whole retention for a disavowal. `getPinnedObjects` names each object once; `getPinCount` gives the count. The follow-ups from the reviews that are not this PR's — the settled-promise requeue, `unpinVatRoot`, `addCListEntry` idempotency, `incRefCount`/`decRefCount`, and wiring revocation to `releaseOcapURLRetentions` — are noted and will be raised separately. ## 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. - **GC-delivery hardening** (retired-export freeing, the disowning guard, remote-GC starvation, the restarting-vat GC release) — stacked directly on this PR. - **Crank-rollback and transaction-boundary semantics** — merges #1012 with #1018 and this branch's rollback commit into one PR. They had to be one: #1012 rewrote `rollbackCrank`'s `finally` into a `try/catch`, this branch changed `ctx.savepoints` from `string[]` to `{name, maybeFreeKrefs}[]` on the same lines, and composed naively the rethrow fires *before* the `maybeFreeKrefs` restore — a hole neither PR could see alone. - **Vat-lifecycle consistency** — was #1019, rebasing onto the end of the stack. 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 and a violation now failing the test that provoked it. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, `README.md`, `CHANGELOG.md`) as appropriate <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Touches core capability GC, refcount invariants, and persistent store layout with a mandatory reset for existing databases; incorrect accounting can collect live objects or leak capabilities. > > **Overview** > Fixes **#1006** by making import c-list creation/release symmetric: new objects start at **`(0, 0)`**, **`addCListEntry`** takes a reference (with **`setReachableFlag`** for re-handoffs), and owner-side baseline decrements are removed. **Vat roots are pinned** for the vat lifetime; **GC deliveries** now update the kernel’s own c-list (`dropExports` / retire paths). > > Adds **reference-count auditing** (`auditRefCounts`, `recomputeRefCounts`, …) and optional **`Kernel.make({ auditRefCounts: true })`** checks after each crank; **`kernel-test`** enables this via **`makeAuditedKernelOptions`** so drift fails tests through **`onRunLoopFailure`**. > > Corrects several refcount leaks: send delivery charges **`item.target`** (not the routed object), promise requeue **transfers** refs, notify releases early, promise-queue messages are charged/released consistently, **`resolvePromises`** only increments slots after legal resolve, and **`getPromisesByDecider`** scans the real **`${endpoint}.c.`** layout. > > **Ocap URL issuance** retains targets (per-URL issuance counts, **`pinned.${kref}`** pin counts); **`krefsToExistingErefs`** → **`krefsToErefs`** (throws if unmapped); **`incrementRefCount`** refuses deleted krefs. > > **BREAKING:** existing stores must be **reset** (no migration); tests/assertions updated for new baselines (e.g. createObject refcounts, v3 root pin in e2e). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2f9ef11. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 37db357 commit f02677d

48 files changed

Lines changed: 2714 additions & 434 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/extension/test/e2e/control-panel.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,6 @@ test.describe('Control Panel', () => {
252252
`{"key":"v3.c.o+0","value":"${v3Root}"}`,
253253
`{"key":"v3.c.${v3Promise}","value":"R p-1"}`,
254254
`{"key":"v3.c.p-1","value":"${v3Promise}"}`,
255-
`{"key":"${v3Root}.refCount","value":"1,1"}`,
256255
`{"key":"${v3Promise}.refCount","value":"2"}`,
257256
];
258257
// Derived too: v1 imports the two roots as the bootstrap's calls are
@@ -282,6 +281,21 @@ test.describe('Control Panel', () => {
282281
popupPage.locator('[data-testid="message-output"]'),
283282
).toContainText(value);
284283
}
284+
// A live vat's root is pinned once, by `launchVat`, and its count is that
285+
// pin plus v1's import. Both are asserted only while v3 is alive, since
286+
// terminating it releases the pin — which is the point of the pair of
287+
// assertions after the termination below. Worth asserting at all because a
288+
// pin is the audit's own ground truth: a root that lost its pin agrees with
289+
// its own refcount, so the audit stays silent while the last importer's
290+
// drop can retire a live vat's root.
291+
for (const value of [
292+
`{"key":"pinned.${v3Root}","value":"1"}`,
293+
`{"key":"${v3Root}.refCount","value":"2,2"}`,
294+
]) {
295+
await expect(
296+
popupPage.locator('[data-testid="message-output"]'),
297+
).toContainText(value);
298+
}
285299
await popupPage.click('button:text("Control Panel")');
286300
await popupPage.locator('[data-testid="accordion-header"]').first().click();
287301
await popupPage
@@ -307,6 +321,14 @@ test.describe('Control Panel', () => {
307321
popupPage.locator('[data-testid="message-output"]'),
308322
).toContainText(value);
309323
}
324+
// Terminating the vat released the pin its launch took, leaving the root
325+
// held only by v1's import — so it can now be collected once v1 lets go.
326+
await expect(
327+
popupPage.locator('[data-testid="message-output"]'),
328+
).not.toContainText(`{"key":"pinned.${v3Root}"`);
329+
await expect(
330+
popupPage.locator('[data-testid="message-output"]'),
331+
).toContainText(`{"key":"${v3Root}.refCount","value":"1,1"}`);
310332
await popupPage.click('button:text("Control Panel")');
311333

312334
await popupPage.click('button:text("Collect Garbage")');

packages/kernel-test/src/endowment-globals.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import type { AllowedGlobalName, KRef, VatId } from '@metamask/ocap-kernel';
1212
import { getWorkerFile } from '@ocap/nodejs-test-workers';
1313
import { describe, expect, it } from 'vitest';
1414

15-
import { extractTestLogs, getBundleSpec } from './utils.ts';
15+
import {
16+
extractTestLogs,
17+
getBundleSpec,
18+
makeAuditedKernelOptions,
19+
} from './utils.ts';
1620

1721
describe('global endowments', () => {
1822
const vatId: VatId = 'v1';
@@ -38,6 +42,7 @@ describe('global endowments', () => {
3842
resetStorage: true,
3943
logger,
4044
allowedGlobalNames,
45+
...makeAuditedKernelOptions(),
4146
});
4247

4348
await kernel.launchSubcluster({

packages/kernel-test/src/garbage-collection.test.ts

Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import {
2121
/**
2222
* Make a test subcluster with vats for GC testing
2323
*
24+
* @param extraImporters - Names of additional importer vats to include, for
25+
* topologies where more than one vat shares the same exported object.
2426
* @returns The test subcluster
2527
*/
26-
function makeTestSubcluster(): ClusterConfig {
28+
function makeTestSubcluster(extraImporters: string[] = []): ClusterConfig {
2729
return {
2830
bootstrap: 'exporter',
2931
forceReset: true,
@@ -40,6 +42,15 @@ function makeTestSubcluster(): ClusterConfig {
4042
name: 'Importer',
4143
},
4244
},
45+
...Object.fromEntries(
46+
extraImporters.map((name) => [
47+
name,
48+
{
49+
bundleSpec: getBundleSpec('importer-vat'),
50+
parameters: { name },
51+
},
52+
]),
53+
),
4354
},
4455
};
4556
}
@@ -81,10 +92,11 @@ describe('Garbage Collection', () => {
8192
[objectId],
8293
);
8394
const createObjectRef = createObjectData.slots[0] as KRef;
84-
// Verify initial reference counts from database
85-
const initialRefCounts = kernelStore.getObjectRefCount(createObjectRef);
86-
expect(initialRefCounts.reachable).toBe(2);
87-
expect(initialRefCounts.recognizable).toBe(2);
95+
// Held only by the resolved promise's value, which still carries the slot
96+
expect(kernelStore.getObjectRefCount(createObjectRef)).toStrictEqual({
97+
reachable: 1,
98+
recognizable: 1,
99+
});
88100
// Send the object to the importer vat
89101
const objectRef = kunser(createObjectData);
90102
await kernel.queueMessage(importerKRef, 'storeImport', [objectRef]);
@@ -116,10 +128,10 @@ describe('Garbage Collection', () => {
116128
await waitUntilQuiescent();
117129
const createObjectRef = createObjectData.slots[0] as KRef;
118130

119-
// Store initial reference count information
120-
const initialRefCounts = kernelStore.getObjectRefCount(createObjectRef);
121-
expect(initialRefCounts.reachable).toBe(2);
122-
expect(initialRefCounts.recognizable).toBe(2);
131+
expect(kernelStore.getObjectRefCount(createObjectRef)).toStrictEqual({
132+
reachable: 1,
133+
recognizable: 1,
134+
});
123135

124136
// Store the reference in the importer vat
125137
const objectRef = kunser(createObjectData);
@@ -201,4 +213,116 @@ describe('Garbage Collection', () => {
201213
);
202214
expect(parseReplyBody(exporterFinalCheck.body)).toBe(false);
203215
}, 40000);
216+
217+
describe('an object shared by two importers', () => {
218+
let secondImporterKRef: KRef;
219+
let secondImporterVatId: VatId;
220+
221+
beforeEach(async () => {
222+
kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:' });
223+
kernelStore = makeKernelStore(kernelDatabase);
224+
kernel = await makeKernel(kernelDatabase, true, makeMockLogger());
225+
await runTestVats(kernel, makeTestSubcluster(['Importer2']));
226+
227+
const vats = kernel.getVats();
228+
const idOf = (name: string): VatId =>
229+
vats.find((row) => row.config.parameters?.name === name)?.id as VatId;
230+
exporterVatId = idOf('Exporter');
231+
importerVatId = idOf('Importer');
232+
secondImporterVatId = idOf('Importer2');
233+
exporterKRef = kernelStore.getRootObject(exporterVatId) as KRef;
234+
importerKRef = kernelStore.getRootObject(importerVatId) as KRef;
235+
secondImporterKRef = kernelStore.getRootObject(
236+
secondImporterVatId,
237+
) as KRef;
238+
});
239+
240+
/**
241+
* Give an importer a chance to notice a dropped object and tell the kernel.
242+
*
243+
* @param vatId - The vat to reap.
244+
* @param rootKRef - That vat's root, to poke with cranks afterwards.
245+
*/
246+
async function reapAndSettle(vatId: VatId, rootKRef: KRef): Promise<void> {
247+
kernel.reapVats((id) => id === vatId);
248+
for (let i = 0; i < 3; i++) {
249+
await kernel.queueMessage(rootKRef, 'noop', []);
250+
await waitUntilQuiescent(500);
251+
}
252+
}
253+
254+
it('survives until both importers let go', async () => {
255+
const objectId = 'shared-object';
256+
const createObjectData = await kernel.queueMessage(
257+
exporterKRef,
258+
'createObject',
259+
[objectId],
260+
);
261+
const sharedKRef = createObjectData.slots[0] as KRef;
262+
const objectRef = kunser(createObjectData);
263+
264+
for (const importer of [importerKRef, secondImporterKRef]) {
265+
await kernel.queueMessage(importer, 'storeImport', [
266+
objectRef,
267+
objectId,
268+
]);
269+
}
270+
await waitUntilQuiescent();
271+
272+
expect(kernelStore.getImporters(sharedKRef)).toStrictEqual(
273+
[importerVatId, secondImporterVatId].sort(),
274+
);
275+
// Two importers, plus the resolved createObject promise whose value
276+
// still carries the slot
277+
expect(kernelStore.getObjectRefCount(sharedKRef)).toStrictEqual({
278+
reachable: 3,
279+
recognizable: 3,
280+
});
281+
282+
await kernel.queueMessage(importerKRef, 'makeWeak', [objectId]);
283+
await kernel.queueMessage(importerKRef, 'forgetImport', []);
284+
await waitUntilQuiescent();
285+
await reapAndSettle(importerVatId, importerKRef);
286+
287+
// The exporter must not have been told to drop it: the second importer
288+
// legitimately still holds it
289+
expect(kernelStore.getReachableFlag(exporterVatId, sharedKRef)).toBe(
290+
true,
291+
);
292+
expect(kernelStore.getImporters(sharedKRef)).toStrictEqual([
293+
secondImporterVatId,
294+
]);
295+
expect(
296+
parseReplyBody(
297+
(
298+
await kernel.queueMessage(exporterKRef, 'isObjectPresent', [
299+
objectId,
300+
])
301+
).body,
302+
),
303+
).toBe(true);
304+
305+
expect(
306+
parseReplyBody(
307+
(
308+
await kernel.queueMessage(secondImporterKRef, 'useImport', [
309+
objectId,
310+
])
311+
).body,
312+
),
313+
).toBe(objectId);
314+
315+
await kernel.queueMessage(secondImporterKRef, 'makeWeak', [objectId]);
316+
await kernel.queueMessage(secondImporterKRef, 'forgetImport', []);
317+
await waitUntilQuiescent();
318+
await reapAndSettle(secondImporterVatId, secondImporterKRef);
319+
320+
expect(kernelStore.getImporters(sharedKRef)).toStrictEqual([]);
321+
// Only the createObject result's stored value still names it
322+
expect(kernelStore.getObjectRefCount(sharedKRef)).toStrictEqual({
323+
reachable: 1,
324+
recognizable: 1,
325+
});
326+
}, 60000);
327+
});
204328
});

packages/kernel-test/src/io.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import * as os from 'node:os';
66
import * as path from 'node:path';
77
import { describe, it, expect, afterEach } from 'vitest';
88

9-
import { getBundleSpec, makeTestLogger } from './utils.ts';
9+
import {
10+
getBundleSpec,
11+
makeAuditedKernelOptions,
12+
makeTestLogger,
13+
} from './utils.ts';
1014

1115
function tempSocketPath(): string {
1216
return path.join(
@@ -79,6 +83,7 @@ async function makeIoKernel(
7983
resetStorage: true,
8084
logger,
8185
ioListenerFactory: makeIOListenerFactory(),
86+
...makeAuditedKernelOptions(),
8287
},
8388
);
8489

packages/kernel-test/src/persistence.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ describe('persistent storage', { timeout: 20_000 }, () => {
176176
// Enqueue a send message into the database
177177
kv1.set('queue.run.head', '4');
178178
kv1.set('nextPromiseId', '4');
179-
kv1.set(`${v1Root}.refCount`, '3,3');
179+
// The root's pin, plus the send being injected below.
180+
kv1.set(`${v1Root}.refCount`, '2,2');
180181
kv1.set('queue.kp3.head', '1');
181182
kv1.set('queue.kp3.tail', '1');
182183
kv1.set('kp3.state', 'unresolved');

packages/kernel-test/src/utils.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,61 @@ import {
1111
} from '@metamask/logger';
1212
import type { LogEntry } from '@metamask/logger';
1313
import { Kernel, kunser } from '@metamask/ocap-kernel';
14-
import type { ClusterConfig, PlatformServices } from '@metamask/ocap-kernel';
15-
import { vi } from 'vitest';
14+
import type {
15+
ClusterConfig,
16+
OnRunLoopFailure,
17+
PlatformServices,
18+
} from '@metamask/ocap-kernel';
19+
import { afterAll, afterEach, vi } from 'vitest';
20+
21+
/**
22+
* The first run loop death seen since it was last reported, held here rather
23+
* than passed to a test because the crank that kills the loop is often one no
24+
* test is awaiting — a garbage collection or reap crank, or one that lands
25+
* after the last assertion. The hooks below are the only thing guaranteed to
26+
* look, so they are registered for every file that imports this module.
27+
*/
28+
let runLoopFailure: Error | undefined;
29+
30+
/**
31+
* Fail the current test if a kernel's run loop has died since the last check.
32+
*/
33+
function assertRunLoopAlive(): void {
34+
const failure = runLoopFailure;
35+
runLoopFailure = undefined;
36+
if (failure) {
37+
throw failure;
38+
}
39+
}
40+
41+
afterEach(assertRunLoopAlive);
42+
afterAll(assertRunLoopAlive);
43+
44+
/**
45+
* Kernel options under which reference count drift fails the test run.
46+
*
47+
* Drift is invisible to ordinary assertions until something gets collected out
48+
* from under a live holder, so the audit runs every crank. It reports by
49+
* throwing, which kills the run loop — and the kernel hands run loop death to
50+
* `onRunLoopFailure` rather than rethrowing it, deliberately, so that an
51+
* embedder can decide what to do. Without a handler a violation therefore
52+
* surfaces only if the killed crank happened to have a caller waiting on it.
53+
*
54+
* @returns Options to pass to `Kernel.make`.
55+
*/
56+
export function makeAuditedKernelOptions(): {
57+
auditRefCounts: true;
58+
onRunLoopFailure: OnRunLoopFailure;
59+
} {
60+
return {
61+
auditRefCounts: true,
62+
// The first failure is the informative one: a dead loop cannot process
63+
// anything, so whatever follows is downstream of it.
64+
onRunLoopFailure: (failure: Error): void => {
65+
runLoopFailure ??= failure;
66+
},
67+
};
68+
}
1669

1770
/**
1871
* Construct a bundle path URL from a bundle name.
@@ -93,6 +146,7 @@ export async function makeKernel(
93146
resetStorage,
94147
logger,
95148
keySeed,
149+
...makeAuditedKernelOptions(),
96150
});
97151
return kernel;
98152
}

0 commit comments

Comments
 (0)