Skip to content

feat(ocap-kernel): launch subcluster vats in parallel - #983

Open
grypez wants to merge 15 commits into
mainfrom
grypez/concurrent-subcluster-startup
Open

feat(ocap-kernel): launch subcluster vats in parallel#983
grypez wants to merge 15 commits into
mainfrom
grypez/concurrent-subcluster-startup

Conversation

@grypez

@grypez grypez commented Jul 27, 2026

Copy link
Copy Markdown
Member

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

  • Tests pass (yarn workspace @metamask/ocap-kernel test:dev:quiet)
  • Build passes (yarn workspace @metamask/ocap-kernel build)
  • Changelog updated

Note

Medium Risk
Changes core subcluster launch ordering, bootstrap wiring, and promise rejection paths; mistakes could break startup or bootstrap peer discovery, though coverage is broad.

Overview
Subcluster startup now launches every vat in parallel (Promise.allSettled) instead of one-by-one, so init time tracks the slowest vat rather than the sum of all vats. Kernel service resolution runs before launches; bootstrap is queued only after all launch attempts finish.

When a non-bootstrap peer fails, its entry in bootstrap(roots, …) is a rejected kernel promise (VAT_TERMINATED) so the bootstrap vat can observe the failure via pipelined calls; launchSubcluster still rejects afterward with the peer error. SubclusterLaunchResult gains vatRootKrefs (per-name root ko<N> for successful vats).

Integration tests stop assuming fixed ko4/ko5/ko6 order and use returned refs; a new peer-rejection integration test and bootstrap vat cover failed-peer propagation. Terminated-vat export cleanup skips baseline refcount decrement when GC already zeroed reachability.

Reviewed by Cursor Bugbot for commit 6647a0c. Bugbot is set up for automated code reviews on this repo. Configure here.

Previously #launchVatsForSubcluster awaited each vat's initVat
handshake serially, making startup O(sum of vat init times).

Now all vats launch concurrently via Promise.all. Each vat gets a
kernel promise pre-allocated for its root object; the bootstrap
message is queued immediately targeting the bootstrap vat's
(unresolved) promise. KernelRouter parks the send until that promise
resolves. As each vat's handshake completes its root promise is
resolved via resolvePromises('kernel', ...), making startup O(max).

The bootstrap vat receives kernel promises for all other vats' roots
in its bootstrap() call, so it can pipeline calls to those vats while
they are still initializing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grypez

grypez commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Benchmark: serial vs parallel subcluster startup

Measured on real worker processes (NodejsPlatformServices), 5 iterations each. Single-vat launch ≈ 865 ms (worker spawn + initVat handshake).

Vats Serial (before) Parallel (after) Speedup
1 865 ms 868 ms 1.0×
3 2599 ms 880 ms 3.0×
5 4398 ms 987 ms 4.5×

Serial scaled linearly (~865 ms × N). Parallel is bounded by the slowest vat; the ~120 ms overhead above 1-vat for the 5-vat case is kernel promise allocation and resolution bookkeeping.

Raw numbers

Serial (HEAD^)

[bench] 1-vat: avg=865ms  min=851ms  max=889ms  (851, 889, 865, 866, 857ms)
[bench] 3-vat: avg=2599ms  min=2581ms  max=2636ms  (2582, 2602, 2592, 2581, 2636ms)
[bench] 5-vat: avg=4398ms  min=4363ms  max=4424ms  (4424, 4411, 4363, 4376, 4416ms)

Parallel (this PR)

[bench] 1-vat: avg=868ms  min=852ms  max=892ms  (881, 892, 853, 852, 862ms)
[bench] 3-vat: avg=880ms  min=852ms  max=906ms  (906, 863, 852, 872, 905ms)
[bench] 5-vat: avg=987ms  min=974ms  max=1002ms  (979, 987, 1002, 994, 974ms)

Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
grypez and others added 4 commits July 27, 2026 10:55
…nchVatsForSubcluster

Both loops iterate vatEntries identically. Building roots[vatName] from the
locally-scoped kpid in the same pass eliminates the second loop, the
! non-null assertion, and the eslint-disable comment that accompanied it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ise.all result

Instead of mutating an outer variable via a .then() side-effect, collect the
resolved root refs as the return value of Promise.all and extract the bootstrap
vat's ref by index. Promise.all preserves insertion order, so the index is
stable. config.bootstrap is guaranteed present (validated before this method
is called), so the as-KRef cast is correct.

Also removes the dead-code guard that followed — if Promise.all resolves,
every vat (including bootstrap) launched successfully, so rootRefs[idx] is
always defined.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a vat in a multi-vat subcluster fails to launch, its pre-allocated
kernel promise was left unresolved. Any bootstrap message pipelined
through that promise (E(roots.failingPeer).method()) would park forever.

Add a .catch() to the launchVat chain that calls resolvePromises with
rejected=true and a VAT_TERMINATED kernel error, then re-throws so the
outer Promise.all still rejects and launchSubcluster surfaces the error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r rejection

Add a peer-rejection-bootstrap vat that calls E(peer).ping() and logs
whether the call resolves or rejects, making peer-vat-launch failures
observable from the bootstrap vat.

Add an integration test that launches a two-vat cluster where the peer
vat (error-build-throw) always fails to build its root object. The test
asserts that launchSubcluster rejects and that, after the kernel run
loop drains, the bootstrap vat has logged a rejection message containing
'VAT_TERMINATED' — confirming that the rejected kernel promise
propagates to the bootstrap vat via E() pipelining.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
grypez and others added 2 commits July 27, 2026 12:55
…omise] failures

Switch #launchVatsForSubcluster from Promise.all with pre-allocated kp<N> refs
to Promise.allSettled. Wait for all vats to settle, then send bootstrap with
real ko<N> refs for succeeded vats and immediately-rejected kp<N> for failed
peers.

The previous approach sent bootstrap before any vat launched, so bootstrap
received pending kp<N> refs. If bootstrap returned a value pipelined through
those refs, the result CapData contained kp<N> slots, and kunser() would fail
with "value is not durable: '[Promise]'" when trying to deserialize them.

The new approach preserves concurrent vat launch (allSettled, not allSettled-
then-serial) while ensuring bootstrap always gets concrete refs. Failed peer
vats still get an immediately-rejected kernel promise so bootstrap can observe
the failure via E(roots.peer).method() pipelining.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t launch

With concurrent subcluster startup (PR #983), ko<N> refs are assigned in
the order vat workers respond — not the order vats are declared. Tests that
hardcoded ko4/ko5/ko6 for alice/bob/carol broke because those refs are now
non-deterministic.

- Add `vatRootKrefs: Record<string, KRef>` to `SubclusterLaunchResult` so
  callers can look up the root KRef of any vat by name
- persistence.test.ts: use `rootKref` from `launchSubcluster` instead of
  hardcoded ko4 for the multi-vat coordinator test
- resume.test.ts: use `vatRootKrefs.alice/bob/carol` instead of ko4/ko5/ko6
- rejection.test.ts: sort before comparing `getVatIds()` output; Map
  insertion order is non-deterministic with concurrent launch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
grypez and others added 3 commits July 28, 2026 11:01
The local Fail tag is declared as returning Error (not never) to work around
a TypeScript control-flow analysis bug. Using `x ?? Fail` therefore widens
the type to `T | Error`, breaking tsc on CI even though ts-jest's
transpile-only mode accepts it.

Replace each occurrence with an explicit undefined guard and throw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…edVat

When BOYD (bringOutYourDead) runs for an importing vat before
terminateAllVats, dropImports/retireImports decrements the exported
object's refcount to (0,0). cleanupTerminatedVat then tries to
decrement the baseline (1,1) that was set at object creation, causing
an underflow.

Guard: check the current reachable count before the baseline decrement
and skip it if reachable is already zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 71.34%
⬆️ +0.01%
8897 / 12470
🔵 Statements 71.17%
⬆️ +0.02%
9048 / 12712
🔵 Functions 72.38%
⬇️ -0.08%
2142 / 2959
🔵 Branches 64.83%
⬇️ -0.01%
3597 / 5548
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/kernel-test/src/vats/peer-rejection-bootstrap.ts 0% 0% 0% 0% 14-24
packages/ocap-kernel/src/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/vat.ts 97.29%
⬆️ +0.03%
81.81%
⬇️ -1.52%
100%
🟰 ±0%
97.27%
⬆️ +0.03%
224, 297, 304-305
packages/ocap-kernel/src/vats/SubclusterManager.ts 96.51%
⬇️ -0.08%
89.87%
⬇️ -2.43%
100%
🟰 ±0%
96.44%
⬇️ -0.11%
192-195, 249, 324, 344, 361
Generated in workflow #4560 for commit 6647a0c by the Vitest Coverage Report Action

grypez and others added 2 commits July 28, 2026 12:50
…ction

vatName and service names come from ClusterConfig (user-provided). Using
a plain {} as the accumulator allowed prototype pollution if a name like
'__proto__' or 'constructor' was supplied. Object.create(null) removes
the prototype chain, eliminating the risk.

vatRootKrefs is spread into a plain {} at the return site so callers
that use toStrictEqual continue to work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Validate that no vat name shadows Object.prototype built-ins (e.g.
__proto__, constructor) before using vatName as a property key on the
roots/vatRootKrefs maps. Adds // lgtm[js/remote-property-injection]
suppressions on the three write sites as a belt-and-suspenders measure
for CodeQL, which does not track the null-prototype path through the
TypeScript cast.

Object.create(null) was considered but rejected: @endo/marshal requires
Object.prototype-chained objects for CopyRecord serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
Comment thread packages/ocap-kernel/src/vats/SubclusterManager.ts Fixed
grypez and others added 3 commits July 28, 2026 13:10
GitHub Code Scanning requires '// lgtm [rule]' with a space, not
'// lgtm[rule]' without one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Accumulate roots and vatRootKrefs into entry arrays and call
Object.fromEntries at the end, eliminating the obj[taintedKey] = value
write pattern that CodeQL's js/remote-property-injection rule flags.

@endo/marshal requires Object.prototype-chained CopyRecords, so
Object.create(null) was not viable. The // lgtm suppression syntax is
also not active on this repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e arg

- Move the Object.prototype name check into the main loop body,
  eliminating a separate pre-pass over vatEntries.
- Drop the 'vatRoot' iface arg from kslot(kpid): kslot ignores iface
  for kp-prefixed refs (returns makeStandinPromise early), so the arg
  was dead code that implied it had an effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grypez

grypez commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Design note: Promise.allSettled vs. early-bootstrap with peer kernel promises

During review we considered an alternative to the current Promise.allSettled approach: start bootstrap as soon as its own launch resolves, passing a kernel promise (kpid) for each peer vat and resolving/rejecting it when that peer's launch settles. This would let bootstrap overlap with slow peer startups.

Resolved-promise inlining: verified absent. To assess the per-call cost of passing kpid instead of ko<N>, we traced the kernel's message routing. KernelQueue.enqueueSend never checks promise state — routing happens only in KernelRouter.#routeMessage at dequeue time. So even for an already-resolved kpid, a send targeting it re-enters the run queue and takes one extra crank before reaching ko<N>. There is no inlining shortcut.

But the extra crank only hurts awaited calls. The kpid approach also changes the natural programming style: bootstrap has no reason to await E(peer).method() — it can fire-and-forget, letting the kernel queue those messages against the still-unresolved kpid and deliver them once the peer is up. For that pattern the extra crank is essentially free. The cost is real only when code needs a return value from a peer, and it would apply to all callers of those peer refs, not just bootstrap.

Current choice: Promise.allSettled is simpler, has zero per-call overhead for successful peers, and startup latency hasn't been a measured bottleneck. Worth revisiting if that changes.

@grypez
grypez marked this pull request as ready for review July 30, 2026 18:13
@grypez
grypez requested a review from a team as a code owner July 30, 2026 18:13

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6647a0c. Configure here.

!(vatName in Object.prototype) || Fail`invalid vat name '${vatName}'`;
if (result.status === 'fulfilled') {
rootsEntries.push([vatName, kslot(result.value)]);
vatRootKrefsEntries.push([vatName, result.value]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing vatRoot slot iface

Low Severity

Successful vat roots passed into bootstrap are built with kslot(result.value), which defaults the iface to 'undefined'. The previous serial path used kslot(rootRef, 'vatRoot'), so root remotes are now branded Alleged: undefined instead of Alleged: vatRoot.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6647a0c. Configure here.

// chance to observe and handle the failures.
if (firstPeerFailure !== undefined) {
throw firstPeerFailure;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Peer failure leaves orphaned vats

Medium Severity

When a non-bootstrap vat fails, #launchVatsForSubcluster still delivers bootstrap, then throws firstPeerFailure. The launchSubcluster catch path only destroys IO channels and deletes the subcluster record, so successfully launched vats keep running and keep persisted vat configs while losing subcluster membership.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6647a0c. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants