Skip to content

Commit 8a1b6d0

Browse files
authored
fix(ocap-kernel): deserialize CapData rejections in queueMessage RPC handler (#928)
Vat rejections are serialized as CapData objects before leaving the vat runtime. Previously, callers of Kernel.queueMessage (the RPC handler and SubclusterManager) each had their own isCapData/kunser call-site patches to convert them back into plain Errors. Consolidate the deserialization into Kernel.queueMessage itself so all callers see plain Error objects without needing individual workarounds. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the error propagation contract of `Kernel.queueMessage` to unwrap CapData rejections into plain `Error`s, which can affect all callers and tests that previously inspected serialized rejection shapes. > > **Overview** > **Kernel message errors now surface as plain `Error`s.** `Kernel.queueMessage()` catches CapData-shaped rejections from the run queue and deserializes them via `kunser` before throwing, while non-CapData failures are rethrown unchanged. > > This removes duplicate CapData-unwrapping logic from `SubclusterManager` and updates RPC/unit/e2e/integration tests to assert `rejects.toThrow(...)` (or regex) instead of matching on a serialized `{ body: ... }` rejection payload. The ocap-kernel changelog is updated to document the fix. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b672c99. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b6dc2be commit 8a1b6d0

13 files changed

Lines changed: 64 additions & 73 deletions

File tree

packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,7 @@ describe.sequential('Peer wallet integration', () => {
265265
// keys so this should reject, not forward to kernel1.
266266
await expect(
267267
kernel2.queueMessage(coordinatorKref2, 'signTransaction', [tx]),
268-
).rejects.toMatchObject({
269-
body: expect.stringContaining(
270-
'No authority to sign this transaction',
271-
),
272-
});
268+
).rejects.toThrow('No authority to sign this transaction');
273269
},
274270
NETWORK_TIMEOUT,
275271
);
@@ -284,9 +280,7 @@ describe.sequential('Peer wallet integration', () => {
284280
kernel2.queueMessage(coordinatorKref2, 'signMessage', [
285281
'should fail',
286282
]),
287-
).rejects.toMatchObject({
288-
body: expect.stringContaining('No authority to sign message'),
289-
});
283+
).rejects.toThrow('No authority to sign message');
290284
},
291285
NETWORK_TIMEOUT,
292286
);

packages/kernel-node-runtime/test/e2e/libp2p-v3-features.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,7 @@ describe.sequential('libp2p v3 Features E2E', () => {
241241
'hello',
242242
['Alice'],
243243
]),
244-
).rejects.toMatchObject({
245-
body: expect.stringContaining(
246-
'Message delivery failed after intentional close',
247-
),
248-
});
244+
).rejects.toThrow('Message delivery failed after intentional close');
249245
const elapsed = Date.now() - start;
250246

251247
// Should fail well under the write timeout

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,7 @@ describe('Orphaned ephemeral exo', { timeout: 30_000 }, () => {
5858
// This is surfaced to the caller as an OBJECT_DELETED kernel error.
5959
await expect(
6060
kernel.queueMessage(rootKref, 'useEphemeral', []),
61-
).rejects.toMatchObject({
62-
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
63-
});
61+
).rejects.toThrow('[KERNEL:OBJECT_DELETED]');
6462
} finally {
6563
await kernel.stop();
6664
}

packages/kernel-node-runtime/test/e2e/remote-comms.test.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -825,11 +825,9 @@ describe.sequential('Remote Communications E2E', () => {
825825
kernel2 = restartResult.kernel;
826826

827827
// The message should not have been delivered because we didn't reconnect
828-
await expect(messageAfterClose).rejects.toMatchObject({
829-
body: expect.stringContaining(
830-
'Message delivery failed after intentional close',
831-
),
832-
});
828+
await expect(messageAfterClose).rejects.toThrow(
829+
'Message delivery failed after intentional close',
830+
);
833831
},
834832
NETWORK_TIMEOUT * 2,
835833
);
@@ -916,11 +914,7 @@ describe.sequential('Remote Communications E2E', () => {
916914
'hello',
917915
['Alice'],
918916
]),
919-
).rejects.toMatchObject({
920-
body: expect.stringContaining(
921-
'Message delivery failed after intentional close',
922-
),
923-
});
917+
).rejects.toThrow('Message delivery failed after intentional close');
924918

925919
// Manually reconnect
926920
await kernel1.reconnectPeer(peerId2);
@@ -1009,9 +1003,7 @@ describe.sequential('Remote Communications E2E', () => {
10091003
'hello',
10101004
['Alice'],
10111005
]),
1012-
).rejects.toMatchObject({
1013-
body: expect.stringMatching(/Remote connection lost/u),
1014-
});
1006+
).rejects.toThrow(/Remote connection lost/u);
10151007
},
10161008
NETWORK_TIMEOUT * 3,
10171009
);
@@ -1064,9 +1056,7 @@ describe.sequential('Remote Communications E2E', () => {
10641056
'hello',
10651057
['Alice'],
10661058
]),
1067-
).rejects.toMatchObject({
1068-
body: expect.stringContaining('Remote connection lost'),
1069-
});
1059+
).rejects.toThrow('Remote connection lost');
10701060
},
10711061
NETWORK_TIMEOUT * 2,
10721062
);

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,7 @@ describe('endowments', () => {
5151

5252
await expect(
5353
kernel.queueMessage(v1Root, 'hello', [`https://${badHost}`]),
54-
).rejects.toMatchObject({
55-
body: expect.stringContaining(`Invalid host: ${badHost}`),
56-
});
54+
).rejects.toThrow(`Invalid host: ${badHost}`);
5755

5856
await waitUntilQuiescent();
5957

packages/kernel-test/src/orphaned-ephemeral-exo.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ describe('orphaned ephemeral exo', () => {
4343
// crank, but the endpoint is gone — so it splats and rejects.
4444
await expect(
4545
kernel.queueMessage(rootKref, 'useEphemeral', []),
46-
).rejects.toMatchObject({
47-
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
48-
});
46+
).rejects.toThrow('[KERNEL:OBJECT_DELETED]');
4947
});
5048
});

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

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
9797
// Try to send message to revoked object — kernel rejects the promise
9898
await expect(
9999
kernel.queueMessage(objectKRef, 'getValue', []),
100-
).rejects.toMatchObject({
101-
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
102-
});
100+
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
103101
// Verify kernel doesn't crash and exporter vat remains operational
104102
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
105103
expect(exporterStatus.body).toContain('noop');
@@ -145,19 +143,15 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
145143
// Send message to revoked object — kernel rejects the promise
146144
await expect(
147145
kernel.queueMessage(objectKRef, 'getValue', []),
148-
).rejects.toMatchObject({
149-
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
150-
});
146+
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
151147
// Verify exporter vat is still operational
152148
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
153149
expect(exporterStatus.body).toContain('noop');
154150
// Verify kernel can handle multiple revoked object accesses
155151
for (let i = 0; i < 5; i++) {
156152
await expect(
157153
kernel.queueMessage(objectKRef, 'getValue', []),
158-
).rejects.toMatchObject({
159-
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
160-
});
154+
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
161155
}
162156
// Verify kernel remains stable
163157
const finalStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
@@ -217,9 +211,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
217211
for (const objectKRef of objectKRefs) {
218212
await expect(
219213
kernel.queueMessage(objectKRef, 'getValue', []),
220-
).rejects.toMatchObject({
221-
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
222-
});
214+
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
223215
}
224216

225217
// Verify exporter vat is still operational

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,7 @@ describe('Vat Lifecycle', { timeout: 30_000 }, () => {
134134
// Try to send a message to the terminated vat's root object — rejects
135135
await expect(
136136
kernel.queueMessage(deadRootObject, 'resume', []),
137-
).rejects.toMatchObject({
138-
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
139-
});
137+
).rejects.toThrow('[KERNEL:OBJECT_DELETED]');
140138

141139
// Verify that messaging works as expected
142140
expect(await runResume(kernel, liveRootObject)).toBe(

packages/ocap-kernel/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Deserialize CapData rejections in `Kernel.queueMessage` so vat errors surface as plain `Error` objects to all callers ([#928](https://github.com/MetaMask/ocap-kernel/pull/928))
13+
1014
## [0.7.0]
1115

1216
### Added

packages/ocap-kernel/src/Kernel.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Mocked, MockInstance } from 'vitest';
77
import { describe, it, expect, vi, beforeEach } from 'vitest';
88

99
import { Kernel } from './Kernel.ts';
10+
import { kser } from './liveslots/kernel-marshal.ts';
1011
import type {
1112
VatId,
1213
VatConfig,
@@ -214,6 +215,33 @@ describe('Kernel', () => {
214215
const result = await kernel.queueMessage('ko1', 'hello', []);
215216
expect(result).toStrictEqual({ body: '{"result":"ok"}', slots: [] });
216217
});
218+
219+
it('deserializes CapData rejections into Errors', async () => {
220+
const kernel = await Kernel.make(
221+
mockPlatformServices,
222+
mockKernelDatabase,
223+
);
224+
mocks.KernelQueue.lastInstance.enqueueMessage.mockRejectedValueOnce(
225+
kser(new Error('vat rejection message')),
226+
);
227+
await expect(kernel.queueMessage('ko1', 'hello', [])).rejects.toThrow(
228+
'vat rejection message',
229+
);
230+
});
231+
232+
it('propagates non-CapData rejections as-is', async () => {
233+
const kernel = await Kernel.make(
234+
mockPlatformServices,
235+
mockKernelDatabase,
236+
);
237+
const internalError = new Error('internal kernel error');
238+
mocks.KernelQueue.lastInstance.enqueueMessage.mockRejectedValueOnce(
239+
internalError,
240+
);
241+
await expect(kernel.queueMessage('ko1', 'hello', [])).rejects.toThrow(
242+
'internal kernel error',
243+
);
244+
});
217245
});
218246

219247
describe('launchSubcluster()', () => {

0 commit comments

Comments
 (0)