Skip to content

Commit fc0ec74

Browse files
rekmarksclaude
andcommitted
fix(ocap-kernel): enforce one delivery per crank, fix rollback cache staleness
- Restructure run queue generator to yield exactly one item per startCrank/endCrank pair, preventing rollback from undoing unrelated earlier deliveries in the same crank - Refresh StoredQueue after rollback so cached head/tail pointers are re-read from DB, fixing dequeue returning undefined - Invalidate runQueueLengthCache after rollback - Bypass VatManager.terminateVat() in KernelQueue callback to avoid waitForCrank() deadlock when terminating from within a crank - Handle vanished endpoints in KernelRouter.deliverSend with try/catch, treating as splat instead of crashing - Change KernelQueue subscriptions to {resolve, reject} so aborted sends can reject the caller's JS promise immediately - Distinguish rejected vs fulfilled in invokeKernelSubscription - Improve splat error messages to describe cause without leaking internal identifiers (krefs, endpoint IDs) - Add integration test for orphaned ephemeral exo rejection - Standardize KernelQueue test loop-exit pattern using sentinel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95f2884 commit fc0ec74

17 files changed

Lines changed: 468 additions & 87 deletions

docs/glossary.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ runtime environment for vat code and handles object persistence, promise managem
6666
### crank
6767

6868
A single execution cycle in the kernel's [run queue](#run-queue). Each crank processes one
69-
item from the run queue, delivering a single message or notification to [a vat](#vat).
70-
Cranks can be aborted and rolled back if errors occur. See the
69+
item from the run queue, delivering a single message or notification to [a vat](#vat). The
70+
"message or notification" is whatever item is taken of the run queue. Cranks can be
71+
aborted and rolled back if errors occur. See the
7172
[KernelQueue](../packages/ocap-kernel/src/KernelQueue.ts) for the run loop implementation.
7273

7374
### syscall

packages/kernel-node-runtime/test/e2e/orphaned-ephemeral-exo.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ describe('Orphaned ephemeral exo', { timeout: 30_000 }, () => {
5555
// The consumer's E(ephemeral).increment() targets an orphaned vref.
5656
// Liveslots in the provider throws "I don't remember allocating",
5757
// which terminates the provider and rejects the caller's promise.
58+
// This is surfaced to the caller as "target object has no owner".
5859
await expect(
5960
kernel.queueMessage(rootKref, 'useEphemeral', []),
6061
).rejects.toMatchObject({
61-
body: expect.stringContaining("I don't remember allocating"),
62+
body: expect.stringContaining('target object has no owner'),
6263
});
6364
} finally {
6465
await kernel.stop();
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
2+
import { waitUntilQuiescent } from '@metamask/kernel-utils';
3+
import { kunser } from '@metamask/ocap-kernel';
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { getBundleSpec, makeKernel, makeTestLogger } from './utils.ts';
7+
8+
describe('orphaned ephemeral exo', () => {
9+
it('rejects when provider vat restarts', async () => {
10+
const { logger } = makeTestLogger();
11+
const database = await makeSQLKernelDatabase({});
12+
const kernel = await makeKernel(database, true, logger);
13+
14+
const { rootKref, subclusterId } = await kernel.launchSubcluster({
15+
bootstrap: 'consumer',
16+
vats: {
17+
provider: {
18+
bundleSpec: getBundleSpec('orphaned-ephemeral-provider'),
19+
parameters: {},
20+
},
21+
consumer: {
22+
bundleSpec: getBundleSpec('orphaned-ephemeral-consumer'),
23+
parameters: {},
24+
},
25+
},
26+
});
27+
await waitUntilQuiescent();
28+
29+
// Works before restart
30+
const r1 = await kernel.queueMessage(rootKref, 'useEphemeral', []);
31+
expect(kunser(r1)).toBe(999);
32+
33+
// Restart only the provider — the consumer still holds the
34+
// ephemeral ref, but the exo behind it no longer exists.
35+
const subcluster = kernel.getSubcluster(subclusterId);
36+
expect(subcluster).toBeDefined();
37+
await kernel.restartVat(subcluster!.vats.provider);
38+
await waitUntilQuiescent();
39+
40+
// The consumer's E(ephemeral).increment() targets an orphaned vref.
41+
// Liveslots in the provider throws "I don't remember allocating",
42+
// which terminates the provider vat. The message is retried in a new
43+
// crank, but the endpoint is gone — so it splats and rejects.
44+
await expect(
45+
kernel.queueMessage(rootKref, 'useEphemeral', []),
46+
).rejects.toMatchObject({
47+
body: expect.stringContaining('has no owner'),
48+
});
49+
});
50+
});

packages/kernel-test/src/syscall-validation.test.ts

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
9494
// Revoke the object
9595
kernelStore.setRevoked(objectKRef, true);
9696
await waitUntilQuiescent();
97-
// Try to send message to revoked object
98-
const revokedResult = await kernel.queueMessage(objectKRef, 'getValue', []);
99-
// Should get proper error response for revoked object
100-
expect(revokedResult.body).toContain('revoked object');
97+
// Try to send message to revoked object — kernel rejects the promise
98+
await expect(
99+
kernel.queueMessage(objectKRef, 'getValue', []),
100+
).rejects.toMatchObject({
101+
body: expect.stringContaining('target object has been revoked'),
102+
});
101103
// Verify kernel doesn't crash and exporter vat remains operational
102104
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
103105
expect(exporterStatus.body).toContain('noop');
@@ -140,21 +142,22 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
140142
// Revoke the object
141143
kernelStore.setRevoked(objectKRef, true);
142144
await waitUntilQuiescent();
143-
// Send message to revoked object that would return a promise
144-
const promiseResult = await kernel.queueMessage(objectKRef, 'getValue', []);
145-
// Verify the promise is rejected with revocation error
146-
expect(promiseResult.body).toContain('revoked object');
145+
// Send message to revoked object — kernel rejects the promise
146+
await expect(
147+
kernel.queueMessage(objectKRef, 'getValue', []),
148+
).rejects.toMatchObject({
149+
body: expect.stringContaining('target object has been revoked'),
150+
});
147151
// Verify exporter vat is still operational
148152
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
149153
expect(exporterStatus.body).toContain('noop');
150154
// Verify kernel can handle multiple revoked object accesses
151155
for (let i = 0; i < 5; i++) {
152-
const multipleResult = await kernel.queueMessage(
153-
objectKRef,
154-
'getValue',
155-
[],
156-
);
157-
expect(multipleResult.body).toContain('revoked object');
156+
await expect(
157+
kernel.queueMessage(objectKRef, 'getValue', []),
158+
).rejects.toMatchObject({
159+
body: expect.stringContaining('target object has been revoked'),
160+
});
158161
}
159162
// Verify kernel remains stable
160163
const finalStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
@@ -210,17 +213,14 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
210213
}
211214
await waitUntilQuiescent();
212215

213-
// Try to access all revoked objects
214-
const revokedResults = await Promise.all(
215-
objectKRefs.map(async (objectKRef) =>
216+
// Try to access all revoked objects — all should reject
217+
for (const objectKRef of objectKRefs) {
218+
await expect(
216219
kernel.queueMessage(objectKRef, 'getValue', []),
217-
),
218-
);
219-
220-
// All should return revocation errors
221-
revokedResults.forEach((result) => {
222-
expect(result.body).toContain('revoked object');
223-
});
220+
).rejects.toMatchObject({
221+
body: expect.stringContaining('target object has been revoked'),
222+
});
223+
}
224224

225225
// Verify exporter vat is still operational
226226
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);

packages/kernel-test/src/vat-lifecycle.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
22
import { waitUntilQuiescent } from '@metamask/kernel-utils';
3-
import { kunser, makeKernelStore } from '@metamask/ocap-kernel';
3+
import { makeKernelStore } from '@metamask/ocap-kernel';
44
import { describe, expect, it, beforeEach } from 'vitest';
55

66
import {
@@ -131,13 +131,12 @@ describe('Vat Lifecycle', { timeout: 30_000 }, () => {
131131
expect(remainingVats).toHaveLength(1);
132132
expect(remainingVats[0]?.id).toBe(liveVatId);
133133

134-
// Try to send a message to the terminated vat's root object
135-
const messageResult = await kernel.queueMessage(
136-
deadRootObject,
137-
'resume',
138-
[],
139-
);
140-
expect(kunser(messageResult)).toBe('no endpoint');
134+
// Try to send a message to the terminated vat's root object — rejects
135+
await expect(
136+
kernel.queueMessage(deadRootObject, 'resume', []),
137+
).rejects.toMatchObject({
138+
body: expect.stringContaining('has no owner'),
139+
});
141140

142141
// Verify that messaging works as expected
143142
expect(await runResume(kernel, liveRootObject)).toBe(
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { E } from '@endo/eventual-send';
2+
import { makeDefaultExo } from '@metamask/kernel-utils/exo';
3+
4+
/**
5+
* A consumer vat that obtains an ephemeral exo reference from the provider
6+
* during bootstrap and calls it on demand.
7+
*
8+
* @returns The root object.
9+
*/
10+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
11+
export function buildRootObject() {
12+
let ephemeralRef: unknown;
13+
14+
return makeDefaultExo('root', {
15+
async bootstrap(vats: { provider: unknown }) {
16+
ephemeralRef = await E(vats.provider).getEphemeral();
17+
},
18+
19+
async useEphemeral() {
20+
return E(ephemeralRef).increment();
21+
},
22+
});
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { makeDefaultExo } from '@metamask/kernel-utils/exo';
2+
3+
/**
4+
* A provider vat that exposes a single ephemeral (non-durable) exo.
5+
* The exo will not survive a vat restart.
6+
*
7+
* @returns The root object.
8+
*/
9+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
10+
export function buildRootObject() {
11+
const ephemeral = makeDefaultExo('EphemeralCounter', {
12+
increment() {
13+
return 999;
14+
},
15+
});
16+
17+
return makeDefaultExo('root', {
18+
getEphemeral() {
19+
return ephemeral;
20+
},
21+
});
22+
}

packages/ocap-kernel/src/Kernel.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,14 @@ export class Kernel {
128128
this.#resetKernelState({ resetIdentity: Boolean(options.mnemonic) });
129129
}
130130

131+
// Bypass VatManager.terminateVat() here because it calls waitForCrank(),
132+
// which would deadlock — this callback is invoked from within a crank.
131133
this.#kernelQueue = new KernelQueue(
132134
this.#kernelStore,
133-
async (vatId, reason) => this.#vatManager.terminateVat(vatId, reason),
135+
async (vatId, reason) => {
136+
await this.#vatManager.stopVat(vatId, true, reason);
137+
this.#kernelStore.markVatAsTerminated(vatId);
138+
},
134139
);
135140

136141
this.#vatManager = new VatManager({

0 commit comments

Comments
 (0)