Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,7 @@ describe.sequential('Peer wallet integration', () => {
// keys so this should reject, not forward to kernel1.
await expect(
kernel2.queueMessage(coordinatorKref2, 'signTransaction', [tx]),
).rejects.toMatchObject({
body: expect.stringContaining(
'No authority to sign this transaction',
),
});
).rejects.toThrow('No authority to sign this transaction');
},
NETWORK_TIMEOUT,
);
Expand All @@ -284,9 +280,7 @@ describe.sequential('Peer wallet integration', () => {
kernel2.queueMessage(coordinatorKref2, 'signMessage', [
'should fail',
]),
).rejects.toMatchObject({
body: expect.stringContaining('No authority to sign message'),
});
).rejects.toThrow('No authority to sign message');
},
NETWORK_TIMEOUT,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,7 @@ describe.sequential('libp2p v3 Features E2E', () => {
'hello',
['Alice'],
]),
).rejects.toMatchObject({
body: expect.stringContaining(
'Message delivery failed after intentional close',
),
});
).rejects.toThrow('Message delivery failed after intentional close');
const elapsed = Date.now() - start;

// Should fail well under the write timeout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ describe('Orphaned ephemeral exo', { timeout: 30_000 }, () => {
// This is surfaced to the caller as an OBJECT_DELETED kernel error.
await expect(
kernel.queueMessage(rootKref, 'useEphemeral', []),
).rejects.toMatchObject({
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
});
).rejects.toThrow('[KERNEL:OBJECT_DELETED]');
} finally {
await kernel.stop();
}
Expand Down
22 changes: 6 additions & 16 deletions packages/kernel-node-runtime/test/e2e/remote-comms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,11 +825,9 @@ describe.sequential('Remote Communications E2E', () => {
kernel2 = restartResult.kernel;

// The message should not have been delivered because we didn't reconnect
await expect(messageAfterClose).rejects.toMatchObject({
body: expect.stringContaining(
'Message delivery failed after intentional close',
),
});
await expect(messageAfterClose).rejects.toThrow(
'Message delivery failed after intentional close',
);
},
NETWORK_TIMEOUT * 2,
);
Expand Down Expand Up @@ -916,11 +914,7 @@ describe.sequential('Remote Communications E2E', () => {
'hello',
['Alice'],
]),
).rejects.toMatchObject({
body: expect.stringContaining(
'Message delivery failed after intentional close',
),
});
).rejects.toThrow('Message delivery failed after intentional close');

// Manually reconnect
await kernel1.reconnectPeer(peerId2);
Expand Down Expand Up @@ -1009,9 +1003,7 @@ describe.sequential('Remote Communications E2E', () => {
'hello',
['Alice'],
]),
).rejects.toMatchObject({
body: expect.stringMatching(/Remote connection lost/u),
});
).rejects.toThrow(/Remote connection lost/u);
},
NETWORK_TIMEOUT * 3,
);
Expand Down Expand Up @@ -1064,9 +1056,7 @@ describe.sequential('Remote Communications E2E', () => {
'hello',
['Alice'],
]),
).rejects.toMatchObject({
body: expect.stringContaining('Remote connection lost'),
});
).rejects.toThrow('Remote connection lost');
},
NETWORK_TIMEOUT * 2,
);
Expand Down
4 changes: 1 addition & 3 deletions packages/kernel-test/src/endowments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ describe('endowments', () => {

await expect(
kernel.queueMessage(v1Root, 'hello', [`https://${badHost}`]),
).rejects.toMatchObject({
body: expect.stringContaining(`Invalid host: ${badHost}`),
});
).rejects.toThrow(`Invalid host: ${badHost}`);

await waitUntilQuiescent();

Expand Down
4 changes: 1 addition & 3 deletions packages/kernel-test/src/orphaned-ephemeral-exo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ describe('orphaned ephemeral exo', () => {
// crank, but the endpoint is gone — so it splats and rejects.
await expect(
kernel.queueMessage(rootKref, 'useEphemeral', []),
).rejects.toMatchObject({
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
});
).rejects.toThrow('[KERNEL:OBJECT_DELETED]');
});
});
16 changes: 4 additions & 12 deletions packages/kernel-test/src/syscall-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
// Try to send message to revoked object — kernel rejects the promise
await expect(
kernel.queueMessage(objectKRef, 'getValue', []),
).rejects.toMatchObject({
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
});
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
// Verify kernel doesn't crash and exporter vat remains operational
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
expect(exporterStatus.body).toContain('noop');
Expand Down Expand Up @@ -145,19 +143,15 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
// Send message to revoked object — kernel rejects the promise
await expect(
kernel.queueMessage(objectKRef, 'getValue', []),
).rejects.toMatchObject({
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
});
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
// Verify exporter vat is still operational
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
expect(exporterStatus.body).toContain('noop');
// Verify kernel can handle multiple revoked object accesses
for (let i = 0; i < 5; i++) {
await expect(
kernel.queueMessage(objectKRef, 'getValue', []),
).rejects.toMatchObject({
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
});
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
}
// Verify kernel remains stable
const finalStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
Expand Down Expand Up @@ -217,9 +211,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
for (const objectKRef of objectKRefs) {
await expect(
kernel.queueMessage(objectKRef, 'getValue', []),
).rejects.toMatchObject({
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
});
).rejects.toThrow('[KERNEL:OBJECT_REVOKED]');
}

// Verify exporter vat is still operational
Expand Down
4 changes: 1 addition & 3 deletions packages/kernel-test/src/vat-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,7 @@ describe('Vat Lifecycle', { timeout: 30_000 }, () => {
// Try to send a message to the terminated vat's root object — rejects
await expect(
kernel.queueMessage(deadRootObject, 'resume', []),
).rejects.toMatchObject({
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
});
).rejects.toThrow('[KERNEL:OBJECT_DELETED]');

// Verify that messaging works as expected
expect(await runResume(kernel, liveRootObject)).toBe(
Expand Down
4 changes: 4 additions & 0 deletions packages/ocap-kernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- 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))

## [0.7.0]

### Added
Expand Down
28 changes: 28 additions & 0 deletions packages/ocap-kernel/src/Kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Mocked, MockInstance } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';

import { Kernel } from './Kernel.ts';
import { kser } from './liveslots/kernel-marshal.ts';
import type {
VatId,
VatConfig,
Expand Down Expand Up @@ -214,6 +215,33 @@ describe('Kernel', () => {
const result = await kernel.queueMessage('ko1', 'hello', []);
expect(result).toStrictEqual({ body: '{"result":"ok"}', slots: [] });
});

it('deserializes CapData rejections into Errors', async () => {
const kernel = await Kernel.make(
mockPlatformServices,
mockKernelDatabase,
);
mocks.KernelQueue.lastInstance.enqueueMessage.mockRejectedValueOnce(
kser(new Error('vat rejection message')),
);
await expect(kernel.queueMessage('ko1', 'hello', [])).rejects.toThrow(
'vat rejection message',
);
});

it('propagates non-CapData rejections as-is', async () => {
const kernel = await Kernel.make(
mockPlatformServices,
mockKernelDatabase,
);
const internalError = new Error('internal kernel error');
mocks.KernelQueue.lastInstance.enqueueMessage.mockRejectedValueOnce(
internalError,
);
await expect(kernel.queueMessage('ko1', 'hello', [])).rejects.toThrow(
'internal kernel error',
);
});
});

describe('launchSubcluster()', () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/ocap-kernel/src/Kernel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CapData } from '@endo/marshal';
import type { KernelDatabase } from '@metamask/kernel-store';
import { isCapData } from '@metamask/kernel-utils';
import { Logger } from '@metamask/logger';

import { IOManager } from './io/IOManager.ts';
Expand All @@ -10,8 +11,8 @@ import { KernelQueue } from './KernelQueue.ts';
import { KernelRouter } from './KernelRouter.ts';
import { KernelServiceManager } from './KernelServiceManager.ts';
import type { KernelService } from './KernelServiceManager.ts';
import { kslot, kunser } from './liveslots/kernel-marshal.ts';
import type { SlotValue } from './liveslots/kernel-marshal.ts';
import { kslot } from './liveslots/kernel-marshal.ts';
import { OcapURLManager } from './remotes/kernel/OcapURLManager.ts';
import { RemoteManager } from './remotes/kernel/RemoteManager.ts';
import type { RemoteCommsOptions } from './remotes/types.ts';
Expand Down Expand Up @@ -386,7 +387,14 @@ export class Kernel {
method: string,
args: unknown[],
): Promise<CapData<KRef>> {
return this.#kernelQueue.enqueueMessage(target, method, args);
try {
return await this.#kernelQueue.enqueueMessage(target, method, args);
} catch (rejection) {
if (isCapData(rejection)) {
throw kunser(rejection as CapData<KRef>);
}
throw rejection;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('queueMessageHandler', () => {
expect(result).toStrictEqual(expectedResult);
});

it('should propagate errors from kernel.queueMessage', async () => {
it('propagates rejections from kernel.queueMessage', async () => {
const error = new Error('Queue message failed');
vi.mocked(mockKernel.queueMessage).mockRejectedValueOnce(error);

Expand Down
21 changes: 4 additions & 17 deletions packages/ocap-kernel/src/vats/SubclusterManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { CapData } from '@endo/marshal';
import { SubclusterNotFoundError } from '@metamask/kernel-errors';
import { isCapData } from '@metamask/kernel-utils';
import { Logger } from '@metamask/logger';

import type { IOManager } from '../io/IOManager.ts';
Expand Down Expand Up @@ -341,22 +340,10 @@ export class SubclusterManager {
`Bootstrap vat "${config.bootstrap}" not found in rootIds`,
);
}
let bootstrapResult: CapData<KRef>;
try {
bootstrapResult = await this.#queueMessage(rootKref, 'bootstrap', [
roots,
services,
]);
} catch (rejection) {
// queueMessage rejects with CapData for rejected kernel promises.
// Deserialize to surface the original Error to the caller.
// If the rejection isn't CapData (e.g., an internal error before the
// kernel promise was created), re-throw as-is.
if (isCapData(rejection)) {
throw kunser(rejection as CapData<KRef>);
}
throw rejection;
}
const bootstrapResult = await this.#queueMessage(rootKref, 'bootstrap', [
roots,
services,
]);
const unserialized = kunser(bootstrapResult);
if (unserialized instanceof Error) {
throw unserialized;
Expand Down
Loading