Skip to content

Commit 600cd72

Browse files
authored
feat(ocap-kernel): Throw if subcluster launch fails (#566)
Closes: #571 Previously, the invocation which launched a subcluster would assume success in many cases where it ought not. This PR causes the promise returned by `launchSubcluster` to reject if an error occurs in any of the following places. - In the global namespace of a vat in the cluster - During the buildRootObject call of any vat in the cluster - During the bootstrap call of the cluster's bootstrap vat, if one was designated. It is also possible that an uncaught promise rejects in any of the above scopes. Although the promise specification may not consider this to be an error condition, vat developers may nonetheless appreciate this scenario surfacing to them in some way. This PR adds placeholder tests for detecting these situations, but does not implement them or the functionality they imply.
1 parent 1bc0d18 commit 600cd72

12 files changed

Lines changed: 238 additions & 9 deletions
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
2+
import { Logger } from '@metamask/logger';
3+
import type { LogEntry } from '@metamask/logger';
4+
import type { Kernel } from '@metamask/ocap-kernel';
5+
import { beforeEach, describe, expect, it } from 'vitest';
6+
7+
import {
8+
extractTestLogs,
9+
getBundleSpec,
10+
makeKernel,
11+
makeTestLogger,
12+
} from './utils.ts';
13+
14+
describe('cluster initialization', { timeout: 4_000 }, () => {
15+
let logger: Logger;
16+
let entries: LogEntry[];
17+
let kernel: Kernel;
18+
19+
type When = 'global' | 'build' | 'bootstrap';
20+
type What = 'throw' | 'uncaught-rejection';
21+
22+
beforeEach(async () => {
23+
const testLogger = makeTestLogger();
24+
logger = testLogger.logger;
25+
entries = testLogger.entries;
26+
const database = await makeSQLKernelDatabase({});
27+
kernel = await makeKernel(
28+
database,
29+
true,
30+
logger.subLogger({ tags: ['test'] }),
31+
);
32+
});
33+
34+
const launch = async (scenario: `${When}-${What}`) =>
35+
kernel.launchSubcluster({
36+
bootstrap: 'main',
37+
vats: {
38+
main: {
39+
bundleSpec: getBundleSpec(`error-${scenario}`),
40+
parameters: {},
41+
},
42+
},
43+
});
44+
45+
it('throws if globals scope throws', async () => {
46+
await expect(launch('global-throw')).rejects.toThrow(/from global scope/u);
47+
48+
const vatLogs = extractTestLogs(entries, 'console');
49+
expect(vatLogs).toStrictEqual(['global throw']);
50+
});
51+
52+
it.todo('throws if global scope has an uncaught rejection', async () => {
53+
await expect(launch('global-uncaught-rejection')).rejects.toThrow(
54+
expect.objectContaining({
55+
message: expect.stringContaining('Subcluster initialization failed'),
56+
cause: expect.stringMatching(
57+
/[Uu]nknown(.)+uncaught promise rejection/u,
58+
),
59+
}),
60+
);
61+
62+
const vatLogs = extractTestLogs(entries, 'console');
63+
expect(vatLogs).toStrictEqual(['global uncaught rejection']);
64+
});
65+
66+
it('throws if buildRootObject throws', async () => {
67+
await expect(launch('build-throw')).rejects.toThrow(
68+
/from buildRootObject/u,
69+
);
70+
71+
const vatLogs = extractTestLogs(entries, 'console');
72+
expect(vatLogs).toStrictEqual(['build throw', 'buildRootObject']);
73+
});
74+
75+
it.todo('throws if buildRootObject has an uncaught rejection', async () => {
76+
await expect(launch('build-uncaught-rejection')).rejects.toThrow(
77+
expect.objectContaining({
78+
message: expect.stringContaining('Subcluster initialization failed'),
79+
cause: expect.stringMatching(
80+
/[Uu]nknown(.)+uncaught promise rejection/u,
81+
),
82+
}),
83+
);
84+
85+
const vatLogs = extractTestLogs(entries, 'console');
86+
expect(vatLogs).toStrictEqual([
87+
'build uncaught rejection',
88+
'buildRootObject',
89+
'bootstrap',
90+
]);
91+
});
92+
93+
it('throws if bootstrap throws', async () => {
94+
await expect(launch('bootstrap-throw')).rejects.toThrow(/from bootstrap/u);
95+
96+
const vatLogs = extractTestLogs(entries, 'console');
97+
expect(vatLogs).toStrictEqual([
98+
'bootstrap throw',
99+
'buildRootObject',
100+
'bootstrap',
101+
]);
102+
});
103+
104+
it.todo('throws if bootstrap has an uncaught rejection', async () => {
105+
await expect(launch('bootstrap-uncaught-rejection')).rejects.toThrow(
106+
expect.objectContaining({
107+
message: expect.stringContaining('Subcluster initialization failed'),
108+
cause: expect.stringMatching(
109+
/[Uu]nknown(.)+uncaught promise rejection/u,
110+
),
111+
}),
112+
);
113+
114+
const vatLogs = extractTestLogs(entries, 'console');
115+
expect(vatLogs).toStrictEqual([
116+
'bootstrap uncaught rejection',
117+
'buildRootObject',
118+
'bootstrap',
119+
]);
120+
});
121+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Far } from '@endo/marshal';
2+
3+
console.log('bootstrap throw');
4+
5+
/**
6+
* Build function for vats that will throw an error during bootstrap.
7+
*
8+
* @returns {object} The root object for the new vat.
9+
*/
10+
export function buildRootObject() {
11+
console.log('buildRootObject');
12+
return Far('root', {
13+
bootstrap: () => {
14+
console.log('bootstrap');
15+
throw new Error('from bootstrap');
16+
},
17+
});
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Far } from '@endo/marshal';
2+
import { makePromiseKit } from '@endo/promise-kit';
3+
4+
console.log('bootstrap uncaught rejection');
5+
6+
/**
7+
* Build function for vats that will reject a promise during bootstrap.
8+
*
9+
* @returns {object} The root object for the new vat.
10+
*/
11+
export function buildRootObject() {
12+
console.log('buildRootObject');
13+
return Far('root', {
14+
bootstrap: () => {
15+
console.log('bootstrap');
16+
const { reject } = makePromiseKit();
17+
reject('from bootstrap');
18+
},
19+
});
20+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
console.log('build throw');
2+
3+
/**
4+
* Build function for vats that will throw an error during buildRootObject.
5+
*
6+
* @returns {never} Always throws an error.
7+
*/
8+
export function buildRootObject() {
9+
console.log('buildRootObject');
10+
throw new Error('from buildRootObject');
11+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Far } from '@endo/marshal';
2+
import { makePromiseKit } from '@endo/promise-kit';
3+
4+
console.log('build uncaught rejection');
5+
6+
/**
7+
* Build function for vats that will reject a promise during buildRootObject.
8+
*
9+
* @returns {object} The root object for the new vat.
10+
*/
11+
export function buildRootObject() {
12+
console.log('buildRootObject');
13+
14+
const { reject } = makePromiseKit();
15+
reject('from buildRootObject');
16+
17+
return Far('root', {
18+
bootstrap: () => {
19+
console.log('bootstrap');
20+
},
21+
});
22+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// eslint-disable-next-line import-x/unambiguous
2+
console.log('global throw');
3+
4+
throw new Error('thrown from global scope');
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Far } from '@endo/marshal';
2+
import { makePromiseKit } from '@endo/promise-kit';
3+
4+
console.log('global uncaught rejection');
5+
6+
const { reject } = makePromiseKit();
7+
8+
reject('from global scope');
9+
10+
/**
11+
* Build function for vats that will reject a promise in global scope.
12+
*
13+
* @returns {object} The root object for the new vat.
14+
*/
15+
export function buildRootObject() {
16+
return Far('root', { bootstrap: () => console.log('bootstrap') });
17+
}

packages/kernel-test/src/vats/logger-vat.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export function buildRootObject(vatPowers, parameters, _baggage) {
1313
const logger = vatPowers.logger.subLogger({ tags: ['test'] });
1414

1515
return Far('root', {
16+
bootstrap() {
17+
// do nothing
18+
},
1619
foo() {
1720
logger.log(`foo: ${name}`);
1821
console.log(`bar: ${name}`);

packages/ocap-kernel/src/Kernel.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,15 @@ export class Kernel {
397397
}
398398
const bootstrapRoot = rootIds[config.bootstrap];
399399
if (bootstrapRoot) {
400-
return this.queueMessage(bootstrapRoot, 'bootstrap', [roots, services]);
400+
const result = await this.queueMessage(bootstrapRoot, 'bootstrap', [
401+
roots,
402+
services,
403+
]);
404+
const unserialized = kunser(result);
405+
if (unserialized instanceof Error) {
406+
throw unserialized;
407+
}
408+
return result;
401409
}
402410
return undefined;
403411
}

packages/ocap-kernel/src/VatHandle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe('VatHandle', () => {
6161
mockKernelStore = makeKernelStore(makeMapKernelDatabase());
6262
sendVatCommandMock = vi
6363
.spyOn(VatHandle.prototype, 'sendVatCommand')
64-
.mockResolvedValueOnce('fake');
64+
.mockResolvedValueOnce(['fake', null] as unknown as VatDeliveryResult);
6565
});
6666

6767
describe('init', () => {

0 commit comments

Comments
 (0)