Skip to content

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

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

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

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>
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