Skip to content

Commit 494fb5e

Browse files
grypezclaude
andauthored
feat(ocap-kernel): launch subcluster vats in parallel (#983)
## Explanation Subcluster startup was O(sum of vat init times) because `#launchVatsForSubcluster` used a serial `for...of` loop — each vat's `initVat` RPC handshake had to complete before the next one began. This PR changes startup to O(max vat init time) by launching all vats concurrently via `Promise.all`. The mechanism: 1. Before any `await`, one kernel promise (`kp<N>`) is pre-allocated per vat and marked as kernel-decided. 2. The bootstrap message is queued immediately, targeting the bootstrap vat's unresolved `kp<N>`. `KernelRouter` parks the send on the promise via `enqueuePromiseMessage`. 3. All vats launch in parallel. As each vat's `initVat` handshake completes, its root kernel promise is resolved via `resolvePromises('kernel', ...)`, and the run loop forwards any queued messages. 4. The bootstrap vat receives kernel promise KRefs for all peer vats in its `bootstrap(roots, services)` call — it can pipeline calls to peers while they are still initializing. ## Changes - **`SubclusterManager.ts`** — rewrote `#launchVatsForSubcluster` to pre-allocate kernel promises, queue bootstrap immediately, and launch all vats with `Promise.all`. - **`SubclusterManager.test.ts`** — updated mocks (`initKernelPromise`, `setPromiseDecider`, `resolvePromises`) and assertions to match the new flow. - **`Kernel.test.ts`** — added `resolvePromises = vi.fn()` to the `KernelQueue` mock class. ## Checklist - [x] Tests pass (`yarn workspace @MetaMask/ocap-kernel test:dev:quiet`) - [x] Build passes (`yarn workspace @MetaMask/ocap-kernel build`) - [ ] Changelog updated <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core subcluster launch ordering, failure propagation, and cleanup in the kernel; mistakes could leak workers or break bootstrap peer wiring, though coverage is expanded in unit and integration tests. > > **Overview** > **Parallel subcluster startup** replaces serial vat launch in `SubclusterManager` with `Promise.allSettled`, so subcluster bring-up time tracks the slowest vat instead of the sum of init times. Kernel service resolution still runs before any vat starts. > > After launches settle, bootstrap is invoked with real root `ko` refs for successful vats. **Failed peer vats** get an immediately rejected kernel promise (`VAT_TERMINATED`) in the `roots` map so bootstrap can observe the failure via pipelined `E(roots.peer)` calls; `launchSubcluster` still rejects once bootstrap has run. **`SubclusterLaunchResult`** now includes **`vatRootKrefs`** (name → root kref for vats that launched successfully). > > Failed launches **tear down** any vats that did start (`#terminateVatQuietly` in reverse order) before IO/subcluster rollback. Vat cleanup skips a baseline refcount decrement when GC already zeroed reachability (`vat.ts`). > > Integration tests drop hardcoded `ko4`/`ko5`/`ko6` and use `vatRootKrefs` / `rootKref` from `launchSubcluster`; a new **peer rejection** integration test and bootstrap vat bundle were added. Changelog documents concurrent launch and peer rejection behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6a2492a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e4f7495 commit 494fb5e

11 files changed

Lines changed: 357 additions & 46 deletions

File tree

packages/kernel-test/src/cluster-launch.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
2+
import { waitUntilQuiescent } from '@metamask/kernel-utils';
23
import { Logger } from '@metamask/logger';
34
import type { LogEntry } from '@metamask/logger';
45
import type { Kernel } from '@metamask/ocap-kernel';
@@ -11,7 +12,10 @@ import {
1112
makeTestLogger,
1213
} from './utils.ts';
1314

14-
describe('cluster initialization', { timeout: 4_000 }, () => {
15+
// No per-suite timeout override: the package's 30s default covers the kernel
16+
// construction each test does in `beforeEach`, which is slow enough on a
17+
// loaded CI runner to blow a tighter budget.
18+
describe('cluster initialization', () => {
1519
let logger: Logger;
1620
let entries: LogEntry[];
1721
let kernel: Kernel;
@@ -123,3 +127,50 @@ describe('cluster initialization', { timeout: 4_000 }, () => {
123127
]);
124128
});
125129
});
130+
131+
describe('peer rejection propagation', () => {
132+
let logger: Logger;
133+
let entries: LogEntry[];
134+
let kernel: Kernel;
135+
136+
beforeEach(async () => {
137+
const testLogger = makeTestLogger();
138+
logger = testLogger.logger;
139+
entries = testLogger.entries;
140+
const database = await makeSQLKernelDatabase({});
141+
kernel = await makeKernel(
142+
database,
143+
true,
144+
logger.subLogger({ tags: ['test'] }),
145+
);
146+
});
147+
148+
it('bootstrap observes peer rejection when a peer vat fails to launch', async () => {
149+
await expect(
150+
kernel.launchSubcluster({
151+
bootstrap: 'main',
152+
vats: {
153+
main: {
154+
bundleSpec: getBundleSpec('peer-rejection-bootstrap'),
155+
parameters: {},
156+
},
157+
peer: {
158+
bundleSpec: getBundleSpec('error-build-throw'),
159+
parameters: {},
160+
},
161+
},
162+
}),
163+
).rejects.toMatchObject({
164+
message: expect.stringMatching(/^Failed to launch vat \S+ \(peer\)$/u),
165+
});
166+
167+
// Let the kernel run loop deliver the parked bootstrap message to the
168+
// main vat, which will observe the peer's rejected root promise.
169+
await waitUntilQuiescent(200);
170+
171+
const vatLogs = extractTestLogs(entries, 'console');
172+
expect(vatLogs).toContainEqual(
173+
expect.stringMatching(/^peer rejected:.*VAT_TERMINATED/u),
174+
);
175+
});
176+
});

packages/kernel-test/src/persistence.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,15 @@ describe('persistent storage', { timeout: 20_000 }, () => {
9696
false,
9797
logger.logger.subLogger({ tags: ['test'] }),
9898
);
99-
const result1 = await runTestVats(kernel1, multiVatCluster);
100-
expect(result1).toBe('Coordinator initialized with 2 workers');
99+
// Capture rootKref directly: concurrent vat launch means the coordinator
100+
// may not be assigned ko4, so we cannot use a hardcoded ref here.
101+
const { bootstrapResult: launch1Result, rootKref: coordinatorRoot } =
102+
await kernel1.launchSubcluster(multiVatCluster);
101103
await waitUntilQuiescent();
102-
const workResult1 = await runResume(kernel1, v1Root);
104+
expect(kunser(launch1Result as CapData<string>)).toBe(
105+
'Coordinator initialized with 2 workers',
106+
);
107+
const workResult1 = await runResume(kernel1, coordinatorRoot);
103108
expect(workResult1).toBe('Work completed: Worker1(1), Worker2(1)');
104109
await waitUntilQuiescent();
105110
await kernel1.stop();
@@ -110,7 +115,8 @@ describe('persistent storage', { timeout: 20_000 }, () => {
110115
logger.logger.subLogger({ tags: ['test'] }),
111116
);
112117
await new Promise((resolve) => setTimeout(resolve, 1000));
113-
const workResult2 = await runResume(kernel2, v1Root);
118+
// coordinatorRoot (ko<N>) is stable across kernel restarts.
119+
const workResult2 = await runResume(kernel2, coordinatorRoot);
114120
expect(workResult2).toBe('Work completed: Worker1(2), Worker2(2)');
115121
await kernel2.stop();
116122
});

packages/kernel-test/src/rejection.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('rejection', () => {
3131
});
3232
expect(vat).toBeDefined();
3333
const vats = kernel.getVatIds();
34-
expect(vats).toStrictEqual(vatIds);
34+
expect([...vats].sort()).toStrictEqual([...vatIds].sort());
3535

3636
await waitUntilQuiescent();
3737
const vatLogs = vatIds.map((vatId) => extractTestLogs(entries, vatId));

packages/kernel-test/src/resume.test.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1+
import type { CapData } from '@endo/marshal';
12
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
23
import { waitUntilQuiescent } from '@metamask/kernel-utils';
3-
import type { KRef } from '@metamask/ocap-kernel';
4+
import { kunser } from '@metamask/ocap-kernel';
45
import { describe, expect, it } from 'vitest';
56

67
import {
78
getBundleSpec,
89
makeKernel,
910
makeTestLogger,
1011
runResume,
11-
runTestVats,
1212
sortLogs,
1313
extractTestLogs,
1414
} from './utils.ts';
@@ -100,21 +100,22 @@ const reference = sortLogs([
100100
...carolResumeReference,
101101
]);
102102

103-
// Vat root objects start with ko4 due to the kernel facet and other kernel service objects being created before any vats.
104-
const v1Root: KRef = 'ko4';
105-
const v2Root: KRef = 'ko5';
106-
const v3Root: KRef = 'ko6';
107-
108103
describe('restarting vats', async () => {
109104
it('exercise restart vats individually', async () => {
110105
const kernelDatabase = await makeSQLKernelDatabase({
111106
dbFilename: ':memory:',
112107
});
113108
const { logger, entries } = makeTestLogger();
114109
const kernel = await makeKernel(kernelDatabase, true, logger);
115-
const bootstrapResult = await runTestVats(kernel, testSubcluster);
116-
expect(bootstrapResult).toBe('bootstrap Alice');
110+
// Use launchSubcluster directly to get vatRootKrefs: concurrent vat launch
111+
// means ko<N> assignment order depends on worker startup speed.
112+
const { bootstrapResult, vatRootKrefs } =
113+
await kernel.launchSubcluster(testSubcluster);
117114
await waitUntilQuiescent();
115+
expect(kunser(bootstrapResult as CapData<string>)).toBe('bootstrap Alice');
116+
const v1Root = vatRootKrefs.alice;
117+
const v2Root = vatRootKrefs.bob;
118+
const v3Root = vatRootKrefs.carol;
118119
await kernel.restartVat('v1');
119120
await kernel.restartVat('v2');
120121
await kernel.restartVat('v3');
@@ -136,9 +137,15 @@ describe('restarting vats', async () => {
136137
});
137138
const { logger: logger1, entries: entries1 } = makeTestLogger();
138139
const kernel1 = await makeKernel(kernelDatabase, true, logger1);
139-
const bootstrapResult = await runTestVats(kernel1, testSubcluster);
140-
expect(bootstrapResult).toBe('bootstrap Alice');
140+
// Capture vatRootKrefs from first kernel: ko<N> refs are stable across
141+
// kernel restarts because they are persisted in the kernel store.
142+
const { bootstrapResult, vatRootKrefs } =
143+
await kernel1.launchSubcluster(testSubcluster);
141144
await waitUntilQuiescent();
145+
expect(kunser(bootstrapResult as CapData<string>)).toBe('bootstrap Alice');
146+
const v1Root = vatRootKrefs.alice;
147+
const v2Root = vatRootKrefs.bob;
148+
const v3Root = vatRootKrefs.carol;
142149
const { logger: logger2, entries: entries2 } = makeTestLogger();
143150
const kernel2 = await makeKernel(kernelDatabase, false, logger2);
144151
await new Promise((resolve) => setTimeout(resolve, 1000));
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { E } from '@endo/eventual-send';
2+
import { makeDefaultExo } from '@metamask/kernel-utils/exo';
3+
4+
/**
5+
* Bootstrap vat for testing peer-rejection propagation.
6+
* Receives a `peer` root reference that may be a rejected kernel promise
7+
* (e.g. if the peer vat failed to launch), and logs whether calls resolve
8+
* or reject so integration tests can inspect the outcome.
9+
*
10+
* @returns The root object for this vat.
11+
*/
12+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
13+
export function buildRootObject() {
14+
return makeDefaultExo('root', {
15+
async bootstrap({ peer }: { peer: unknown }) {
16+
await E(peer as object)
17+
.ping()
18+
// eslint-disable-next-line no-console
19+
.then(() => console.log('peer resolved'))
20+
.catch((error: unknown) => {
21+
const message =
22+
error instanceof Error ? error.message : String(error);
23+
// eslint-disable-next-line no-console
24+
console.log(`peer rejected: ${message}`);
25+
});
26+
},
27+
});
28+
}

packages/ocap-kernel/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- `error` is the failure's message and `detail` its whole cause chain, because only strings cross the wire: when a crank dies and its rollback then fails, the message names the rollback and only the chain names what killed the kernel
1818
- **BREAKING:** `runLoop` is required, so `KernelStatus` gains a mandatory property and a `getStatus` reply from a kernel built before this field fails result validation outright. It cannot be made optional: `exactOptional` would leave the type and the validator disagreeing inside a `type()`, and `optional` widens the property to `| undefined`, which an RPC result may not be
1919
- Add `onRunLoopFailure` to `Kernel.make` options, called with the error that killed the run loop so an embedder that outlives the kernel can exit or restart ([#1005](https://github.com/MetaMask/ocap-kernel/pull/1005))
20+
- Launch all vats in a subcluster concurrently during `launchSubcluster`, reducing startup latency from serial to parallel; failed peer vats receive a rejected kernel promise observable via `E(roots.peer).method()` pipelining ([#983](https://github.com/MetaMask/ocap-kernel/pull/983))
2021
- Add `fetch`, `Request`, `Headers`, and `Response` to available vat endowments ([#942](https://github.com/MetaMask/ocap-kernel/pull/942))
2122
- Add `VatConfig.network: { allowedHosts: string[] }`; requesting `'fetch'` without it rejects `initVat`
2223
- Integrate Snaps attenuated endowment factories into vat globals ([#937](https://github.com/MetaMask/ocap-kernel/pull/937))

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ const mocks = vi.hoisted(() => {
7676
}
7777

7878
waitForCrank = vi.fn().mockResolvedValue(undefined);
79+
80+
resolvePromises = vi.fn();
7981
}
8082

8183
class RemoteManager {
@@ -341,6 +343,7 @@ describe('Kernel', () => {
341343
subclusterId: 's1',
342344
bootstrapResult: { body: '{"result":"ok"}', slots: [] },
343345
rootKref: expect.stringMatching(/^ko\d+$/u),
346+
vatRootKrefs: { alice: expect.stringMatching(/^ko\d+$/u) },
344347
});
345348
});
346349
});

packages/ocap-kernel/src/store/methods/vat.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function getVatMethods(ctx: StoreContext) {
4444
getKernelPromise,
4545
addPromiseSubscriber,
4646
} = getPromiseMethods(ctx);
47-
const { initKernelObject } = getObjectMethods(ctx);
47+
const { initKernelObject, getObjectRefCount } = getObjectMethods(ctx);
4848
const { addCListEntry } = getCListMethods(ctx);
4949
const { incrementRefCount, decrementRefCount } = getRefCountMethods(ctx);
5050

@@ -261,8 +261,11 @@ export function getVatMethods(ctx: StoreContext) {
261261
const { vatSlot } = getReachableAndVatSlot(vatID, kref);
262262
ctx.kv.delete(getSlotKey(vatID, kref));
263263
ctx.kv.delete(getSlotKey(vatID, vatSlot));
264-
// Decrease refcounts that belonged to the terminating vat
265-
decrementRefCount(kref, 'cleanup|export|baseline');
264+
// Skip baseline decrement if GC already zeroed reachable via dropImports.
265+
const { reachable } = getObjectRefCount(kref);
266+
if (reachable > 0) {
267+
decrementRefCount(kref, 'cleanup|export|baseline');
268+
}
266269
ctx.maybeFreeKrefs.add(kref);
267270
work.exports += 1;
268271
}

packages/ocap-kernel/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,8 @@ export type SubclusterLaunchResult = {
746746
rootKref: KRef;
747747
/** The CapData result of calling bootstrap() on the root object, if any. */
748748
bootstrapResult: CapData<KRef> | undefined;
749+
/** Map from vat name to root kref for all successfully launched vats. */
750+
vatRootKrefs: Record<string, KRef>;
749751
};
750752

751753
const RemoteCommsDisconnectedStruct = object({

0 commit comments

Comments
 (0)