From 62ac6492c1eeeeab2ad26bd920ae412e7b15131e Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:04:34 -0400 Subject: [PATCH 1/8] feat(ocap-kernel): name the failing vat when a subcluster vat fails to launch When a vat fails to launch (e.g. its `buildRootObject` throws, surfacing from `VatHandle.make`), `VatManager.launchVat` now re-throws attributing the failure to the specific vat by both its kernel id and its `ClusterConfig` name, e.g. `Failed to launch vat v3 (bob)`, preserving the original error as the cause. Previously the underlying error propagated verbatim with no indication of which subcluster vat failed. Also adds regression tests for #572 covering clean bootstrap-failure propagation and rollback in `SubclusterManager`. Refs: #564, #572 Co-Authored-By: Claude Opus 4.8 --- .../src/vats/SubclusterManager.test.ts | 46 +++++++++++++++++++ .../ocap-kernel/src/vats/VatManager.test.ts | 43 +++++++++++++++++ packages/ocap-kernel/src/vats/VatManager.ts | 10 +++- 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index 165da7fb36..1ccd0eb50e 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,51 @@ describe('SubclusterManager', () => { bootstrapResult, }); }); + + // Regression tests for #572 / #564. Previously, a vat that failed to build + // its root object caused `#launchVatsForSubcluster` to leak an unhandled + // rejection surfacing as a cryptic run-queue error, e.g. + // `SES_UNHANDLED_REJECTION: no record matching key 'queue.run.3'`. The + // bootstrap subroutines must now reject cleanly and roll back the + // subcluster instead. + describe('bootstrap failure propagation (#572)', () => { + it('rejects with the underlying error when the bootstrap message rejects', async () => { + const config = createMockClusterConfig(); + // A vat failing to build its root object surfaces as the bootstrap + // delivery rejecting (Kernel.queueMessage deserializes the vat's + // CapData rejection into a plain Error before it reaches us). + (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(); + // A serialized Error result (as a vat returns on a failed bootstrap) + // round-trips through kunser to an Error instance and is rethrown. + (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 eb4318af82..e37a752330 100644 --- a/packages/ocap-kernel/src/vats/VatManager.test.ts +++ b/packages/ocap-kernel/src/vats/VatManager.test.ts @@ -171,6 +171,49 @@ describe('VatManager', () => { ); expect(kref).toBe('ko1'); }); + + // #564: a vat that fails to launch (e.g. its `buildRootObject` throws, + // surfacing from `VatHandle.make`) must be attributed to the specific vat + // by both its kernel id and its ClusterConfig name, with the original + // error preserved as the cause. + 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) + .catch((reason: unknown) => reason); + + expect((error as Error).message).toBe('Failed to launch vat v1'); + }); }); describe('runVat', () => { diff --git a/packages/ocap-kernel/src/vats/VatManager.ts b/packages/ocap-kernel/src/vats/VatManager.ts index 700176f72d..96aeab7806 100644 --- a/packages/ocap-kernel/src/vats/VatManager.ts +++ b/packages/ocap-kernel/src/vats/VatManager.ts @@ -115,7 +115,15 @@ 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 (e.g. a vat whose `buildRootObject` throws) to + // the specific vat by both its kernel id and its ClusterConfig name, so + // the caller reports which vat failed rather than an opaque error. + const label = vatName ? `${vatId} (${vatName})` : vatId; + throw new Error(`Failed to launch vat ${label}`, { cause: error }); + } this.#kernelStore.initEndpoint(vatId); const rootRef = this.#kernelStore.exportFromEndpoint( vatId, From 500b24113e4ea9d42440c7f4440bbb929bdd7202 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:06:15 -0400 Subject: [PATCH 2/8] docs(ocap-kernel): changelog for failed-vat-launch attribution Refs: #564, #572 Co-Authored-By: Claude Opus 4.8 --- packages/ocap-kernel/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index 6b75835249..de6c99c0e5 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)) From 916c9521165b8ae597ae86ada766bd17b599f15c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:41:05 -0400 Subject: [PATCH 3/8] test(kernel-test): assert launch-failure detail on error cause A failed vat launch is now re-thrown by `VatManager.launchVat` as `Failed to launch vat ()` with the original error preserved on `cause`. Update the integration assertions that previously matched the detail (excluded global, `buildRootObject`/global-scope throw) in the top-level message to search the `cause` chain instead, via a shared `causeChainMessage` helper. The bootstrap-throw path is unchanged (it does not flow through `launchVat`). Refs: #564, #572 Co-Authored-By: Claude Opus 4.8 --- .../kernel-test/src/cluster-launch.test.ts | 13 +++-- .../kernel-test/src/endowment-globals.test.ts | 48 +++++++++---------- packages/kernel-test/src/utils.ts | 21 ++++++++ 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/packages/kernel-test/src/cluster-launch.test.ts b/packages/kernel-test/src/cluster-launch.test.ts index e304fbf8ec..acf5d06880 100644 --- a/packages/kernel-test/src/cluster-launch.test.ts +++ b/packages/kernel-test/src/cluster-launch.test.ts @@ -5,6 +5,7 @@ import type { Kernel } from '@metamask/ocap-kernel'; import { beforeEach, describe, expect, it } from 'vitest'; import { + causeChainMessage, extractTestLogs, getBundleSpec, makeKernel, @@ -43,7 +44,11 @@ describe('cluster initialization', { timeout: 4_000 }, () => { }); it('throws if globals scope throws', async () => { - await expect(launch('global-throw')).rejects.toThrow(/from global scope/u); + // The launch failure is re-thrown with the reason preserved on `cause`. + const error = await launch('global-throw').catch( + (reason: unknown) => reason, + ); + expect(causeChainMessage(error)).toMatch(/from global scope/u); const vatLogs = extractTestLogs(entries, 'console'); expect(vatLogs).toStrictEqual(['global throw']); @@ -64,9 +69,11 @@ describe('cluster initialization', { timeout: 4_000 }, () => { }); it('throws if buildRootObject throws', async () => { - await expect(launch('build-throw')).rejects.toThrow( - /from buildRootObject/u, + // The launch failure is re-thrown with the reason preserved on `cause`. + const error = await launch('build-throw').catch( + (reason: unknown) => reason, ); + expect(causeChainMessage(error)).toMatch(/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 61f30f5121..c19f59f7dc 100644 --- a/packages/kernel-test/src/endowment-globals.test.ts +++ b/packages/kernel-test/src/endowment-globals.test.ts @@ -12,7 +12,7 @@ import type { AllowedGlobalName, KRef, VatId } from '@metamask/ocap-kernel'; import { getWorkerFile } from '@ocap/nodejs-test-workers'; import { describe, expect, it } from 'vitest'; -import { extractTestLogs, getBundleSpec } from './utils.ts'; +import { causeChainMessage, extractTestLogs, getBundleSpec } from './utils.ts'; describe('global endowments', () => { const vatId: VatId = 'v1'; @@ -205,13 +205,13 @@ 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 - await expect( - setup({ - globals: ['TextEncoder', 'TextDecoder', 'URL'], - allowedGlobalNames: ['TextEncoder', 'TextDecoder'], - }), - ).rejects.toThrow('unknown global "URL"'); + // Kernel only allows TextEncoder/TextDecoder — vat also requests URL. + // The launch failure is re-thrown with the reason on `cause`. + const error = await setup({ + globals: ['TextEncoder', 'TextDecoder', 'URL'], + allowedGlobalNames: ['TextEncoder', 'TextDecoder'], + }).catch((reason: unknown) => reason); + expect(causeChainMessage(error)).toContain('unknown global "URL"'); }); it('initializes when all vat globals are within allowedGlobalNames', async () => { @@ -240,12 +240,13 @@ describe('global endowments', () => { }); it('rejects every vat global when allowedGlobalNames is empty', async () => { - await expect( - setup({ - globals: ['TextEncoder'], - allowedGlobalNames: [], - }), - ).rejects.toThrow('unknown global "TextEncoder"'); + const error = await setup({ + globals: ['TextEncoder'], + allowedGlobalNames: [], + }).catch((reason: unknown) => reason); + expect(causeChainMessage(error)).toContain( + 'unknown global "TextEncoder"', + ); }); it('rejects unknown names in allowedGlobalNames at the RPC boundary', async () => { @@ -254,16 +255,15 @@ describe('global endowments', () => { // rejects any name outside the literal union, so a caller that bypasses // the type system (e.g., JS client, cast) still cannot smuggle bad names // through. - await expect( - setup({ - globals: ['TextEncoder', 'TextDecoder'], - allowedGlobalNames: [ - 'TextEncoder', - 'TextDecoder', - 'NotARealGlobal' as AllowedGlobalName, - ], - }), - ).rejects.toThrow(/Invalid params/u); + const error = await setup({ + globals: ['TextEncoder', 'TextDecoder'], + allowedGlobalNames: [ + 'TextEncoder', + 'TextDecoder', + 'NotARealGlobal' as AllowedGlobalName, + ], + }).catch((reason: unknown) => reason); + expect(causeChainMessage(error)).toMatch(/Invalid params/u); }); }); }); diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index c255347b89..79c7527d4b 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -159,6 +159,27 @@ export function parseReplyBody(body: string): unknown { } } +/** + * Concatenate an error's message with the messages of its `cause` chain. + * + * A failed vat launch is re-thrown by `VatManager.launchVat` as + * `Failed to launch vat ()` with the original error preserved on + * `cause`, so the detailed reason lives one level down rather than in the + * top-level message. Assertions on the reason should search the whole chain. + * + * @param error - The thrown value to flatten. + * @returns The joined messages of the error and every nested `cause`. + */ +export function causeChainMessage(error: unknown): string { + const messages: string[] = []; + let current: unknown = error; + while (current instanceof Error) { + messages.push(current.message); + current = current.cause; + } + return messages.join('\n'); +} + /** * Debug the database. * From 6a6325af236a8c66f86fac083312da6b7a3e5462 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:05:12 -0400 Subject: [PATCH 4/8] fix(kernel-test): flatten non-Error causes in causeChainMessage A vat launch that fails at the `initVat` RPC boundary (e.g. an excluded global, invalid params) surfaces as a plain JSON-RPC error object (`{ code, message, data }`) rather than an `Error`, which `VatManager.launchVat` then preserves on `cause`. The previous helper gated its walk on `instanceof Error`, so it stopped at such a cause and lost the detail. Walk anything with a `message`/`cause` and fold in `data`, so the launch-failure assertions find the reason regardless of the cause's shape. Add direct unit coverage for the helper. Refs: #564, #572 Co-Authored-By: Claude Opus 4.8 --- packages/kernel-test/src/utils.test.ts | 49 ++++++++++++++++++++++++++ packages/kernel-test/src/utils.ts | 39 +++++++++++++++----- 2 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 packages/kernel-test/src/utils.test.ts diff --git a/packages/kernel-test/src/utils.test.ts b/packages/kernel-test/src/utils.test.ts new file mode 100644 index 0000000000..a25ebda281 --- /dev/null +++ b/packages/kernel-test/src/utils.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { causeChainMessage } from './utils.ts'; + +describe('causeChainMessage', () => { + it('returns the message of a plain error', () => { + expect(causeChainMessage(new Error('boom'))).toBe('boom'); + }); + + it('walks a chain of Error causes', () => { + const root = new Error('root reason'); + const wrapped = new Error('wrapper', { cause: root }); + expect(causeChainMessage(wrapped)).toContain('root reason'); + expect(causeChainMessage(wrapped)).toContain('wrapper'); + }); + + it('reads a non-Error JSON-RPC error object on cause', () => { + // A failure at the initVat RPC boundary surfaces as a plain object, not an + // Error instance — the reason must still be found. + const wrapped = new Error('Failed to launch vat v1 (main)', { + cause: { + code: -32000, + message: 'Vat "v1" requested unknown global "URL"', + }, + }); + expect(causeChainMessage(wrapped)).toContain('unknown global "URL"'); + }); + + it('includes a cause `data` payload', () => { + const wrapped = new Error('Failed to launch vat v1 (main)', { + cause: { + code: -32602, + message: 'Invalid params', + data: { name: 'nope' }, + }, + }); + const flattened = causeChainMessage(wrapped); + expect(flattened).toContain('Invalid params'); + expect(flattened).toContain('nope'); + }); + + it('terminates on a cyclic cause chain', () => { + const a = new Error('a') as Error & { cause?: unknown }; + const b = new Error('b') as Error & { cause?: unknown }; + a.cause = b; + b.cause = a; + expect(causeChainMessage(a)).toBe('a\nb'); + }); +}); diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index 79c7527d4b..d996d41aa0 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -160,24 +160,47 @@ export function parseReplyBody(body: string): unknown { } /** - * Concatenate an error's message with the messages of its `cause` chain. + * Flatten an error and its `cause` chain into a single searchable string. * * A failed vat launch is re-thrown by `VatManager.launchVat` as * `Failed to launch vat ()` with the original error preserved on * `cause`, so the detailed reason lives one level down rather than in the - * top-level message. Assertions on the reason should search the whole chain. + * top-level message. The cause is not always an `Error`: a failure at the + * `initVat` RPC boundary surfaces as a plain JSON-RPC error object + * (`{ code, message, data }`), so this walks anything with a `message`/`cause` + * rather than gating on `instanceof Error`. * * @param error - The thrown value to flatten. - * @returns The joined messages of the error and every nested `cause`. + * @returns The joined messages (and any `data`) of the error and every nested + * `cause`. */ export function causeChainMessage(error: unknown): string { - const messages: string[] = []; + const parts: string[] = []; + const seen = new Set(); let current: unknown = error; - while (current instanceof Error) { - messages.push(current.message); - current = current.cause; + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (typeof current === 'string') { + parts.push(current); + break; + } + if (typeof current !== 'object') { + break; + } + const { message, data, cause } = current as { + message?: unknown; + data?: unknown; + cause?: unknown; + }; + if (typeof message === 'string') { + parts.push(message); + } + if (data !== undefined) { + parts.push(typeof data === 'string' ? data : stringify(data)); + } + current = cause; } - return messages.join('\n'); + return parts.join('\n'); } /** From 218a4e39f8721e8f2ee8641ebffd7d489373a68a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:44:39 -0400 Subject: [PATCH 5/8] test(kernel-test): match launch-failure via object matcher, drop cause helper Assert launch failures with `toMatchObject`: the top-level message is the `Failed to launch vat ()` attribution wrapper and the reason lives on `cause.message`. `toMatchObject` matches both a real `Error` cause (the delivery-error path) and a plain JSON-RPC error object (the initVat RPC boundary), so the `causeChainMessage` helper and its unit test are no longer needed and are removed. Refs: #564, #572 Co-Authored-By: Claude Opus 4.8 --- .../kernel-test/src/cluster-launch.test.ts | 17 +++++-- .../kernel-test/src/endowment-globals.test.ts | 24 ++++++--- packages/kernel-test/src/utils.test.ts | 49 ------------------- packages/kernel-test/src/utils.ts | 44 ----------------- 4 files changed, 29 insertions(+), 105 deletions(-) delete mode 100644 packages/kernel-test/src/utils.test.ts diff --git a/packages/kernel-test/src/cluster-launch.test.ts b/packages/kernel-test/src/cluster-launch.test.ts index acf5d06880..8f50a20d52 100644 --- a/packages/kernel-test/src/cluster-launch.test.ts +++ b/packages/kernel-test/src/cluster-launch.test.ts @@ -5,7 +5,6 @@ import type { Kernel } from '@metamask/ocap-kernel'; import { beforeEach, describe, expect, it } from 'vitest'; import { - causeChainMessage, extractTestLogs, getBundleSpec, makeKernel, @@ -44,11 +43,15 @@ describe('cluster initialization', { timeout: 4_000 }, () => { }); it('throws if globals scope throws', async () => { - // The launch failure is re-thrown with the reason preserved on `cause`. + // The launch failure is re-thrown attributing the failing vat, with the + // reason preserved on `cause`. const error = await launch('global-throw').catch( (reason: unknown) => reason, ); - expect(causeChainMessage(error)).toMatch(/from global scope/u); + expect(error).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']); @@ -69,11 +72,15 @@ describe('cluster initialization', { timeout: 4_000 }, () => { }); it('throws if buildRootObject throws', async () => { - // The launch failure is re-thrown with the reason preserved on `cause`. + // The launch failure is re-thrown attributing the failing vat, with the + // reason preserved on `cause`. const error = await launch('build-throw').catch( (reason: unknown) => reason, ); - expect(causeChainMessage(error)).toMatch(/from buildRootObject/u); + expect(error).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 c19f59f7dc..bc33a4b4cb 100644 --- a/packages/kernel-test/src/endowment-globals.test.ts +++ b/packages/kernel-test/src/endowment-globals.test.ts @@ -12,7 +12,7 @@ import type { AllowedGlobalName, KRef, VatId } from '@metamask/ocap-kernel'; import { getWorkerFile } from '@ocap/nodejs-test-workers'; import { describe, expect, it } from 'vitest'; -import { causeChainMessage, extractTestLogs, getBundleSpec } from './utils.ts'; +import { extractTestLogs, getBundleSpec } from './utils.ts'; describe('global endowments', () => { const vatId: VatId = 'v1'; @@ -206,12 +206,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. - // The launch failure is re-thrown with the reason on `cause`. + // The launch failure is re-thrown attributing the failing vat, with the + // reason on `cause`. const error = await setup({ globals: ['TextEncoder', 'TextDecoder', 'URL'], allowedGlobalNames: ['TextEncoder', 'TextDecoder'], }).catch((reason: unknown) => reason); - expect(causeChainMessage(error)).toContain('unknown global "URL"'); + expect(error).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 () => { @@ -244,9 +248,12 @@ describe('global endowments', () => { globals: ['TextEncoder'], allowedGlobalNames: [], }).catch((reason: unknown) => reason); - expect(causeChainMessage(error)).toContain( - 'unknown global "TextEncoder"', - ); + expect(error).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 +270,10 @@ describe('global endowments', () => { 'NotARealGlobal' as AllowedGlobalName, ], }).catch((reason: unknown) => reason); - expect(causeChainMessage(error)).toMatch(/Invalid params/u); + expect(error).toMatchObject({ + message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u), + cause: { message: expect.stringMatching(/Invalid params/u) }, + }); }); }); }); diff --git a/packages/kernel-test/src/utils.test.ts b/packages/kernel-test/src/utils.test.ts deleted file mode 100644 index a25ebda281..0000000000 --- a/packages/kernel-test/src/utils.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { causeChainMessage } from './utils.ts'; - -describe('causeChainMessage', () => { - it('returns the message of a plain error', () => { - expect(causeChainMessage(new Error('boom'))).toBe('boom'); - }); - - it('walks a chain of Error causes', () => { - const root = new Error('root reason'); - const wrapped = new Error('wrapper', { cause: root }); - expect(causeChainMessage(wrapped)).toContain('root reason'); - expect(causeChainMessage(wrapped)).toContain('wrapper'); - }); - - it('reads a non-Error JSON-RPC error object on cause', () => { - // A failure at the initVat RPC boundary surfaces as a plain object, not an - // Error instance — the reason must still be found. - const wrapped = new Error('Failed to launch vat v1 (main)', { - cause: { - code: -32000, - message: 'Vat "v1" requested unknown global "URL"', - }, - }); - expect(causeChainMessage(wrapped)).toContain('unknown global "URL"'); - }); - - it('includes a cause `data` payload', () => { - const wrapped = new Error('Failed to launch vat v1 (main)', { - cause: { - code: -32602, - message: 'Invalid params', - data: { name: 'nope' }, - }, - }); - const flattened = causeChainMessage(wrapped); - expect(flattened).toContain('Invalid params'); - expect(flattened).toContain('nope'); - }); - - it('terminates on a cyclic cause chain', () => { - const a = new Error('a') as Error & { cause?: unknown }; - const b = new Error('b') as Error & { cause?: unknown }; - a.cause = b; - b.cause = a; - expect(causeChainMessage(a)).toBe('a\nb'); - }); -}); diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index d996d41aa0..c255347b89 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -159,50 +159,6 @@ export function parseReplyBody(body: string): unknown { } } -/** - * Flatten an error and its `cause` chain into a single searchable string. - * - * A failed vat launch is re-thrown by `VatManager.launchVat` as - * `Failed to launch vat ()` with the original error preserved on - * `cause`, so the detailed reason lives one level down rather than in the - * top-level message. The cause is not always an `Error`: a failure at the - * `initVat` RPC boundary surfaces as a plain JSON-RPC error object - * (`{ code, message, data }`), so this walks anything with a `message`/`cause` - * rather than gating on `instanceof Error`. - * - * @param error - The thrown value to flatten. - * @returns The joined messages (and any `data`) of the error and every nested - * `cause`. - */ -export function causeChainMessage(error: unknown): string { - const parts: string[] = []; - const seen = new Set(); - let current: unknown = error; - while (current !== undefined && current !== null && !seen.has(current)) { - seen.add(current); - if (typeof current === 'string') { - parts.push(current); - break; - } - if (typeof current !== 'object') { - break; - } - const { message, data, cause } = current as { - message?: unknown; - data?: unknown; - cause?: unknown; - }; - if (typeof message === 'string') { - parts.push(message); - } - if (data !== undefined) { - parts.push(typeof data === 'string' ? data : stringify(data)); - } - current = cause; - } - return parts.join('\n'); -} - /** * Debug the database. * From d977b8b6bdf4e52dccd0090d106b94af5797f2bf Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:54:54 -0400 Subject: [PATCH 6/8] test(kernel-test): assert launch failures with rejects.toMatchObject Drop the catch-and-store pattern and assert directly on the rejected promise via `rejects.toMatchObject`, matching the `Failed to launch vat ()` attribution message and the reason on `cause.message`. Refs: #564, #572 Co-Authored-By: Claude Opus 4.8 --- .../kernel-test/src/cluster-launch.test.ts | 10 +---- .../kernel-test/src/endowment-globals.test.ts | 41 ++++++++++--------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/kernel-test/src/cluster-launch.test.ts b/packages/kernel-test/src/cluster-launch.test.ts index 8f50a20d52..80184865ba 100644 --- a/packages/kernel-test/src/cluster-launch.test.ts +++ b/packages/kernel-test/src/cluster-launch.test.ts @@ -45,10 +45,7 @@ describe('cluster initialization', { timeout: 4_000 }, () => { it('throws if globals scope throws', async () => { // The launch failure is re-thrown attributing the failing vat, with the // reason preserved on `cause`. - const error = await launch('global-throw').catch( - (reason: unknown) => reason, - ); - expect(error).toMatchObject({ + 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) }, }); @@ -74,10 +71,7 @@ describe('cluster initialization', { timeout: 4_000 }, () => { it('throws if buildRootObject throws', async () => { // The launch failure is re-thrown attributing the failing vat, with the // reason preserved on `cause`. - const error = await launch('build-throw').catch( - (reason: unknown) => reason, - ); - expect(error).toMatchObject({ + await expect(launch('build-throw')).rejects.toMatchObject({ message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u), cause: { message: expect.stringMatching(/from buildRootObject/u) }, }); diff --git a/packages/kernel-test/src/endowment-globals.test.ts b/packages/kernel-test/src/endowment-globals.test.ts index bc33a4b4cb..a4f3794500 100644 --- a/packages/kernel-test/src/endowment-globals.test.ts +++ b/packages/kernel-test/src/endowment-globals.test.ts @@ -208,11 +208,12 @@ describe('global endowments', () => { // Kernel only allows TextEncoder/TextDecoder — vat also requests URL. // The launch failure is re-thrown attributing the failing vat, with the // reason on `cause`. - const error = await setup({ - globals: ['TextEncoder', 'TextDecoder', 'URL'], - allowedGlobalNames: ['TextEncoder', 'TextDecoder'], - }).catch((reason: unknown) => reason); - expect(error).toMatchObject({ + await expect( + setup({ + globals: ['TextEncoder', 'TextDecoder', 'URL'], + allowedGlobalNames: ['TextEncoder', 'TextDecoder'], + }), + ).rejects.toMatchObject({ message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u), cause: { message: expect.stringContaining('unknown global "URL"') }, }); @@ -244,11 +245,12 @@ describe('global endowments', () => { }); it('rejects every vat global when allowedGlobalNames is empty', async () => { - const error = await setup({ - globals: ['TextEncoder'], - allowedGlobalNames: [], - }).catch((reason: unknown) => reason); - expect(error).toMatchObject({ + await expect( + setup({ + globals: ['TextEncoder'], + allowedGlobalNames: [], + }), + ).rejects.toMatchObject({ message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u), cause: { message: expect.stringContaining('unknown global "TextEncoder"'), @@ -262,15 +264,16 @@ describe('global endowments', () => { // rejects any name outside the literal union, so a caller that bypasses // the type system (e.g., JS client, cast) still cannot smuggle bad names // through. - const error = await setup({ - globals: ['TextEncoder', 'TextDecoder'], - allowedGlobalNames: [ - 'TextEncoder', - 'TextDecoder', - 'NotARealGlobal' as AllowedGlobalName, - ], - }).catch((reason: unknown) => reason); - expect(error).toMatchObject({ + await expect( + setup({ + globals: ['TextEncoder', 'TextDecoder'], + allowedGlobalNames: [ + 'TextEncoder', + 'TextDecoder', + 'NotARealGlobal' as AllowedGlobalName, + ], + }), + ).rejects.toMatchObject({ message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u), cause: { message: expect.stringMatching(/Invalid params/u) }, }); From 4fdfb31540f5e13b4d7dcee60ae051b43f4f4879 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:05:13 -0400 Subject: [PATCH 7/8] chore(ocap-kernel): trim redundant comments and issue references Co-Authored-By: Claude Opus 4.8 --- packages/kernel-test/src/cluster-launch.test.ts | 4 ---- packages/kernel-test/src/endowment-globals.test.ts | 2 -- .../ocap-kernel/src/vats/SubclusterManager.test.ts | 13 +------------ packages/ocap-kernel/src/vats/VatManager.test.ts | 4 ---- packages/ocap-kernel/src/vats/VatManager.ts | 4 +--- 5 files changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/kernel-test/src/cluster-launch.test.ts b/packages/kernel-test/src/cluster-launch.test.ts index 80184865ba..bc28f72249 100644 --- a/packages/kernel-test/src/cluster-launch.test.ts +++ b/packages/kernel-test/src/cluster-launch.test.ts @@ -43,8 +43,6 @@ describe('cluster initialization', { timeout: 4_000 }, () => { }); it('throws if globals scope throws', async () => { - // The launch failure is re-thrown attributing the failing vat, with the - // reason preserved on `cause`. 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) }, @@ -69,8 +67,6 @@ describe('cluster initialization', { timeout: 4_000 }, () => { }); it('throws if buildRootObject throws', async () => { - // The launch failure is re-thrown attributing the failing vat, with the - // reason preserved on `cause`. await expect(launch('build-throw')).rejects.toMatchObject({ message: expect.stringMatching(/^Failed to launch vat \S+ \(main\)$/u), cause: { message: expect.stringMatching(/from buildRootObject/u) }, diff --git a/packages/kernel-test/src/endowment-globals.test.ts b/packages/kernel-test/src/endowment-globals.test.ts index a4f3794500..4eafeb90fb 100644 --- a/packages/kernel-test/src/endowment-globals.test.ts +++ b/packages/kernel-test/src/endowment-globals.test.ts @@ -206,8 +206,6 @@ 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. - // The launch failure is re-thrown attributing the failing vat, with the - // reason on `cause`. await expect( setup({ globals: ['TextEncoder', 'TextDecoder', 'URL'], diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index 1ccd0eb50e..43c15657cf 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts @@ -433,18 +433,9 @@ describe('SubclusterManager', () => { }); }); - // Regression tests for #572 / #564. Previously, a vat that failed to build - // its root object caused `#launchVatsForSubcluster` to leak an unhandled - // rejection surfacing as a cryptic run-queue error, e.g. - // `SES_UNHANDLED_REJECTION: no record matching key 'queue.run.3'`. The - // bootstrap subroutines must now reject cleanly and roll back the - // subcluster instead. - describe('bootstrap failure propagation (#572)', () => { + describe('bootstrap failure propagation', () => { it('rejects with the underlying error when the bootstrap message rejects', async () => { const config = createMockClusterConfig(); - // A vat failing to build its root object surfaces as the bootstrap - // delivery rejecting (Kernel.queueMessage deserializes the vat's - // CapData rejection into a plain Error before it reaches us). (mockQueueMessage as ReturnType).mockRejectedValue( new Error('vat failed to build root object'), ); @@ -464,8 +455,6 @@ describe('SubclusterManager', () => { it('rethrows when the bootstrap message resolves to a serialized error', async () => { const config = createMockClusterConfig(); - // A serialized Error result (as a vat returns on a failed bootstrap) - // round-trips through kunser to an Error instance and is rethrown. (mockQueueMessage as ReturnType).mockResolvedValue( kser(new Error('bootstrap threw')), ); diff --git a/packages/ocap-kernel/src/vats/VatManager.test.ts b/packages/ocap-kernel/src/vats/VatManager.test.ts index e37a752330..9cd584a603 100644 --- a/packages/ocap-kernel/src/vats/VatManager.test.ts +++ b/packages/ocap-kernel/src/vats/VatManager.test.ts @@ -172,10 +172,6 @@ describe('VatManager', () => { expect(kref).toBe('ko1'); }); - // #564: a vat that fails to launch (e.g. its `buildRootObject` throws, - // surfacing from `VatHandle.make`) must be attributed to the specific vat - // by both its kernel id and its ClusterConfig name, with the original - // error preserved as the cause. it('attributes a launch failure to the vat by id and config name', async () => { const config = createMockVatConfig(); const cause = new Error( diff --git a/packages/ocap-kernel/src/vats/VatManager.ts b/packages/ocap-kernel/src/vats/VatManager.ts index 96aeab7806..1fc7b9a832 100644 --- a/packages/ocap-kernel/src/vats/VatManager.ts +++ b/packages/ocap-kernel/src/vats/VatManager.ts @@ -118,9 +118,7 @@ export class VatManager { try { await this.runVat(vatId, vatConfig); } catch (error) { - // Attribute the failure (e.g. a vat whose `buildRootObject` throws) to - // the specific vat by both its kernel id and its ClusterConfig name, so - // the caller reports which vat failed rather than an opaque error. + // Attribute the failure to the specific vat by kernel id and name. const label = vatName ? `${vatId} (${vatName})` : vatId; throw new Error(`Failed to launch vat ${label}`, { cause: error }); } From e3bba7163567a0781feccbfb6e025c05400b57b0 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:56:34 -0400 Subject: [PATCH 8/8] refactor(ocap-kernel): treat launchVat vatName as the required param it is `launchVat`'s `vatName` is a required `string`, always supplied by the only production caller (`SubclusterManager`, from `ClusterConfig` keys). Drop the defensive no-name ternary and always format the failure as `Failed to launch vat ()`; fix the tests that called `launchVat` without a name. Co-Authored-By: Claude Opus 4.8 --- packages/ocap-kernel/src/vats/VatManager.test.ts | 13 +------------ packages/ocap-kernel/src/vats/VatManager.ts | 5 +++-- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/ocap-kernel/src/vats/VatManager.test.ts b/packages/ocap-kernel/src/vats/VatManager.test.ts index 9cd584a603..d5e92b1aac 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); @@ -199,17 +199,6 @@ describe('VatManager', () => { 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) - .catch((reason: unknown) => reason); - - expect((error as Error).message).toBe('Failed to launch vat v1'); - }); }); describe('runVat', () => { diff --git a/packages/ocap-kernel/src/vats/VatManager.ts b/packages/ocap-kernel/src/vats/VatManager.ts index 1fc7b9a832..b90f6f30c8 100644 --- a/packages/ocap-kernel/src/vats/VatManager.ts +++ b/packages/ocap-kernel/src/vats/VatManager.ts @@ -119,8 +119,9 @@ export class VatManager { 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; - throw new Error(`Failed to launch vat ${label}`, { cause: error }); + throw new Error(`Failed to launch vat ${vatId} (${vatName})`, { + cause: error, + }); } this.#kernelStore.initEndpoint(vatId); const rootRef = this.#kernelStore.exportFromEndpoint(