Skip to content
12 changes: 8 additions & 4 deletions packages/kernel-test/src/cluster-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ describe('cluster initialization', { timeout: 4_000 }, () => {
});

it('throws if globals scope throws', async () => {
await expect(launch('global-throw')).rejects.toThrow(/from global scope/u);
await expect(launch('global-throw')).rejects.toMatchObject({
message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u),
cause: { message: expect.stringMatching(/from global scope/u) },
});

const vatLogs = extractTestLogs(entries, 'console');
expect(vatLogs).toStrictEqual(['global throw']);
Expand All @@ -64,9 +67,10 @@ describe('cluster initialization', { timeout: 4_000 }, () => {
});

it('throws if buildRootObject throws', async () => {
await expect(launch('build-throw')).rejects.toThrow(
/from buildRootObject/u,
);
await expect(launch('build-throw')).rejects.toMatchObject({
message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u),
cause: { message: expect.stringMatching(/from buildRootObject/u) },
});

const vatLogs = extractTestLogs(entries, 'console');
expect(vatLogs).toStrictEqual(['build throw', 'buildRootObject']);
Expand Down
19 changes: 15 additions & 4 deletions packages/kernel-test/src/endowment-globals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,16 @@ describe('global endowments', () => {

describe('kernel-level allowedGlobalNames restriction', () => {
it('throws when a vat requests a global excluded by the kernel', async () => {
// Kernel only allows TextEncoder/TextDecoder — vat also requests URL
// Kernel only allows TextEncoder/TextDecoder — vat also requests URL.
await expect(
setup({
globals: ['TextEncoder', 'TextDecoder', 'URL'],
allowedGlobalNames: ['TextEncoder', 'TextDecoder'],
}),
).rejects.toThrow('unknown global "URL"');
).rejects.toMatchObject({
message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u),
cause: { message: expect.stringContaining('unknown global "URL"') },
});
});

it('initializes when all vat globals are within allowedGlobalNames', async () => {
Expand Down Expand Up @@ -245,7 +248,12 @@ describe('global endowments', () => {
globals: ['TextEncoder'],
allowedGlobalNames: [],
}),
).rejects.toThrow('unknown global "TextEncoder"');
).rejects.toMatchObject({
message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u),
cause: {
message: expect.stringContaining('unknown global "TextEncoder"'),
},
});
});

it('rejects unknown names in allowedGlobalNames at the RPC boundary', async () => {
Expand All @@ -263,7 +271,10 @@ describe('global endowments', () => {
'NotARealGlobal' as AllowedGlobalName,
],
}),
).rejects.toThrow(/Invalid params/u);
).rejects.toMatchObject({
message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u),
cause: { message: expect.stringMatching(/Invalid params/u) },
});
});
});
});
1 change: 1 addition & 0 deletions packages/ocap-kernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Attribute a failed subcluster vat launch to the specific vat by kernel id and `ClusterConfig` name (e.g. `Failed to launch vat v3 (bob)`), preserving the original error as the `cause` ([#975](https://github.com/MetaMask/ocap-kernel/pull/975))
- **BREAKING:** Remove `VatConfig.platformConfig.fetch` — migrate to `globals: ['fetch', ...]` + `network.allowedHosts` ([#942](https://github.com/MetaMask/ocap-kernel/pull/942))
- **BREAKING:** `MakeAllowedGlobals` now takes a `{ logger }` options bag ([#942](https://github.com/MetaMask/ocap-kernel/pull/942))
- **BREAKING:** Type `VatConfig.globals` and `Kernel.make`'s `allowedGlobalNames` as `AllowedGlobalName[]` (a literal union) instead of `string[]`; unknown names are now rejected at the `initVat` RPC boundary ([#941](https://github.com/MetaMask/ocap-kernel/pull/941))
Expand Down
35 changes: 35 additions & 0 deletions packages/ocap-kernel/src/vats/SubclusterManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';

import type { KernelQueue } from '../KernelQueue.ts';
import { kser } from '../liveslots/kernel-marshal.ts';
import type { KernelStore } from '../store/index.ts';
import type {
VatId,
Expand Down Expand Up @@ -431,6 +432,40 @@ describe('SubclusterManager', () => {
bootstrapResult,
});
});

describe('bootstrap failure propagation', () => {
it('rejects with the underlying error when the bootstrap message rejects', async () => {
const config = createMockClusterConfig();
(mockQueueMessage as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('vat failed to build root object'),
);

await expect(
subclusterManager.launchSubcluster(config),
).rejects.toThrow('vat failed to build root object');

// Not the old cryptic run-queue message.
await expect(
subclusterManager.launchSubcluster(config),
).rejects.not.toThrow('no record matching key');

// The subcluster is rolled back rather than left half-initialized.
expect(mockKernelStore.deleteSubcluster).toHaveBeenCalledWith('s1');
});

it('rethrows when the bootstrap message resolves to a serialized error', async () => {
const config = createMockClusterConfig();
(mockQueueMessage as ReturnType<typeof vi.fn>).mockResolvedValue(
kser(new Error('bootstrap threw')),
);

await expect(
subclusterManager.launchSubcluster(config),
).rejects.toThrow('bootstrap threw');

expect(mockKernelStore.deleteSubcluster).toHaveBeenCalledWith('s1');
});
});
});

describe('terminateSubcluster', () => {
Expand Down
30 changes: 29 additions & 1 deletion packages/ocap-kernel/src/vats/VatManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe('VatManager', () => {
describe('launchVat', () => {
it('launches a new vat without subcluster', async () => {
const config = createMockVatConfig();
const kref = await vatManager.launchVat(config);
const kref = await vatManager.launchVat(config, 'test');

expect(mockKernelStore.getNextVatId).toHaveBeenCalledOnce();
expect(mockPlatformServices.launch).toHaveBeenCalledWith('v1', config);
Expand All @@ -171,6 +171,34 @@ describe('VatManager', () => {
);
expect(kref).toBe('ko1');
});

it('attributes a launch failure to the vat by id and config name', async () => {
const config = createMockVatConfig();
const cause = new Error(
'Failed to initialize vat v1: buildRootObject threw',
);
makeVatHandleMock.mockRejectedValueOnce(cause);

await expect(vatManager.launchVat(config, 'bob', 's1')).rejects.toThrow(
'Failed to launch vat v1 (bob)',
);

// Downstream setup is skipped once the launch fails.
expect(mockKernelStore.initEndpoint).not.toHaveBeenCalled();
expect(mockKernelStore.setVatConfig).not.toHaveBeenCalled();
});

it('preserves the original error as the cause of a launch failure', async () => {
const config = createMockVatConfig();
const cause = new Error('buildRootObject threw');
makeVatHandleMock.mockRejectedValueOnce(cause);

const error = await vatManager
.launchVat(config, 'bob', 's1')
.catch((reason: unknown) => reason);

expect((error as Error).cause).toBe(cause);
});
});

describe('runVat', () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/ocap-kernel/src/vats/VatManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,14 @@ export class VatManager {
if (subclusterId) {
this.#kernelStore.addSubclusterVat(subclusterId, vatName, vatId);
}
await this.runVat(vatId, vatConfig);
try {
await this.runVat(vatId, vatConfig);
} catch (error) {
// Attribute the failure to the specific vat by kernel id and name.
throw new Error(`Failed to launch vat ${vatId} (${vatName})`, {
cause: error,
});
}
this.#kernelStore.initEndpoint(vatId);
const rootRef = this.#kernelStore.exportFromEndpoint(
vatId,
Expand Down
Loading