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
39 changes: 39 additions & 0 deletions packages/ocap-kernel/src/vats/VatManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,45 @@ 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);
});

it('omits the parenthetical when launched without a vat name', async () => {
const config = createMockVatConfig();
makeVatHandleMock.mockRejectedValueOnce(new Error('boom'));

const error = await vatManager
.launchVat(config)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused. The signature of launchVat requires a vat name, and this invocation ought to be illegal.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — that invocation violated the required signature. Removed the no-name test and fixed the other launchVat(config) call (the pre-existing one at line 152) to pass a name, in e3bba71.

.catch((reason: unknown) => reason);

expect((error as Error).message).toBe('Failed to launch vat v1');
});
});

describe('runVat', () => {
Expand Down
8 changes: 7 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,13 @@ 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.
const label = vatName ? `${vatId} (${vatName})` : vatId;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can vatName be the empty string?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — vatName is a required string and its only production caller (SubclusterManager) always passes a real key from ClusterConfig.vats, so the empty-string case the ternary guarded against can only arise from a degenerate config. I dropped the guard and always format the failure as Failed to launch vat <vatId> (<vatName>) in e3bba71.

throw new Error(`Failed to launch vat ${label}`, { cause: error });
}
this.#kernelStore.initEndpoint(vatId);
const rootRef = this.#kernelStore.exportFromEndpoint(
vatId,
Expand Down
Loading