diff --git a/packages/kernel-test/src/cluster-launch.test.ts b/packages/kernel-test/src/cluster-launch.test.ts index e304fbf8e..bc28f7224 100644 --- a/packages/kernel-test/src/cluster-launch.test.ts +++ b/packages/kernel-test/src/cluster-launch.test.ts @@ -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']); @@ -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']); diff --git a/packages/kernel-test/src/endowment-globals.test.ts b/packages/kernel-test/src/endowment-globals.test.ts index 61f30f512..4eafeb90f 100644 --- a/packages/kernel-test/src/endowment-globals.test.ts +++ b/packages/kernel-test/src/endowment-globals.test.ts @@ -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 () => { @@ -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 () => { @@ -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) }, + }); }); }); }); diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index 6b7583524..de6c99c0e 100644 --- a/packages/ocap-kernel/CHANGELOG.md +++ b/packages/ocap-kernel/CHANGELOG.md @@ -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)) diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index 165da7fb3..43c15657c 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts @@ -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, @@ -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).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).mockResolvedValue( + kser(new Error('bootstrap threw')), + ); + + await expect( + subclusterManager.launchSubcluster(config), + ).rejects.toThrow('bootstrap threw'); + + expect(mockKernelStore.deleteSubcluster).toHaveBeenCalledWith('s1'); + }); + }); }); describe('terminateSubcluster', () => { diff --git a/packages/ocap-kernel/src/vats/VatManager.test.ts b/packages/ocap-kernel/src/vats/VatManager.test.ts index eb4318af8..d5e92b1aa 100644 --- a/packages/ocap-kernel/src/vats/VatManager.test.ts +++ b/packages/ocap-kernel/src/vats/VatManager.test.ts @@ -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); @@ -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', () => { diff --git a/packages/ocap-kernel/src/vats/VatManager.ts b/packages/ocap-kernel/src/vats/VatManager.ts index 700176f72..b90f6f30c 100644 --- a/packages/ocap-kernel/src/vats/VatManager.ts +++ b/packages/ocap-kernel/src/vats/VatManager.ts @@ -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,