Skip to content

fix(ocap-kernel): keep an absent endpoint from killing the run loop - #1029

Open
grypez wants to merge 6 commits into
mainfrom
grypez/absent-endpoint-deliveries
Open

fix(ocap-kernel): keep an absent endpoint from killing the run loop#1029
grypez wants to merge 6 commits into
mainfrom
grypez/absent-endpoint-deliveries

Conversation

@grypez

@grypez grypez commented Aug 25, 2026

Copy link
Copy Markdown
Member

First of three. Splits #1025, which carried all three of these; each is
reachable on main on its own, so they are worth reviewing separately.

The defect

A vat's ownership entries outlive it. deleteVat takes a terminated vat's
config and subcluster membership, but its c-lists and reachable flags stay in
the store until cleanupTerminatedVat gets to it — and that runs one vat per
crank
, off the top of the run loop. Terminate a subcluster of N vats and you
get a window N cranks wide in which the kernel still addresses a vat that has no
handle.

#deliverNotify, #deliverGCAction and #deliverBringOutYourDead look their
endpoint up bare. In that window the lookup throws VatNotFoundError from
inside the crank, which escapes it and kills the run loop for good. Because the
crank is rolled back the item is restored to the queue, so the next boot
dequeues it and dies too.

Reached by a peer's routine remote GC — demonstrated end to end. A kernel
answers a peer's bringOutYourDead by scheduling a reap against the remote it
came from. That queue is persisted, and scheduleReap does not wake a parked run
loop, so on an otherwise idle kernel the reap simply sits there. Shut that kernel
down and it carries the reap into the next incarnation — which starts its run
loop inside Kernel.make, before an embedder can call initRemoteComms, which
is what restores remote handles. Reaps are taken ahead of the run queue, so the
loop's first act is to deliver one addressed to a remote that does not exist yet:

Run loop died; the kernel can no longer process messages
Error: Remote not found: r1
  at RemoteManager.getRemote -> #getEndpoint -> #deliverBringOutYourDead
  -> deliver -> #runLoop -> KernelQueue.run
  at #init (Kernel.ts:232) -> Kernel.make (Kernel.ts:184)

Dead before Kernel.make returns, and it stays dead: the crank is rolled back,
so the reap goes back on the queue to kill the next boot too. Covered by
'survives a peer reap that outlived the incarnation it was queued in' in
kernel-test/src/remote-comms.test.ts, in the two-kernel harness alongside
#1028's revocation test.

A reap is the delivery that reaches this most easily, because nothing filters
it. A GC action is dropped by shouldProcessAction once the endpoint has no
c-list entry, and a notify short-circuits on the same check — but a reap
carries no kref and is handed back with no liveness check at all, and nothing
purges the queue when its endpoint goes away.

Also reachable while a terminated vat awaits cleanup, which happens one vat
per crank, since deleteVat takes its config and subcluster membership but
leaves its c-lists and reachable flags in place. The callers that reach this are
the boot-time orphan prune (SubclusterManager.deleteSubcluster) and the run
loop's own termination callback, both of which mark vats terminated without
draining.

Not via terminateSubcluster, which calls collectGarbage after each
terminateVat and so drains every pending cleanup before returning — an earlier
revision of this description claimed otherwise and was wrong.

No missing bundle required for any of it — that is the third PR in this stack.

send already tolerated this, rejecting its caller with ENDPOINT_UNREACHABLE
(#1007). None of these three has a caller to reject, so an absent endpoint is
something to skip.

Two halves of a skip that are not interchangeable

A GC action still performs the kernel's own release. #1020 moved
clearReachableFlag / deleteCListEntry into the delivery path, with a comment
noting that otherwise "the same action gets derived again". That half does not
depend on the endpoint being there to be told, and an endpoint that cannot hear
the action is precisely the case where a re-derived action would repeat on every
sweep forever. So it runs whether or not the delivery does.

#deliverNotify looks its endpoint up before translating the resolution.
Both translations import if needed, so committing them mints c-list rows and
takes references in an endpoint that can never release them. The throw and its
rollback were the only reason they did not already survive; skipping without
moving the lookup would have made them permanent. This is the general hazard in
converting a throw into a return inside a crank — every store write upstream of
the old throw site goes from provisional to committed.

Also: the lookup does not swallow an id that names neither a vat nor a remote.
That is corrupt state or a kernel bug, and GC actions pass insistEndpointId
before they are ever queued. Skips are reported at warn rather than on the
@@@@ per-delivery trace channel, since a dropped delivery is the only trace of
a vat that has quietly stopped doing anything.

Relationship to #1023

#1023 reaches the same conclusion independently, via #resolveEndpoint, and its
version discriminates "gone for good" from "in flux" rather than narrowing on id
shape — which is better. If #1023 lands first this PR should be reduced to
whatever it does not already cover; on my read that is the kernel-side release
on a skipped GC action, and the invalid-endpoint-id case. Flagging rather than
resolving it here, since the two were developed in parallel.

Testing

First commit is the failing repro on its own: 8 tests, covering all three
delivery types, both halves above, the invalid id, and the log level. A control
asserts endpoints that are running still receive their deliveries.

@metamask/ocap-kernel and @ocap/kernel-test green, with auditRefCounts on
for every kernel kernel-test builds. lint and build clean.

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed, highlighting breaking changes as necessary
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

Note

Medium Risk
Changes core run-queue delivery and GC/notify side effects; mistakes could affect refcount or GC behavior, though behavior is heavily specified in new tests including reboot scenarios.

Overview
Fixes a permanent run-loop death when housekeeping work (notify, GC actions, or bringOutYourDead) targets an endpoint that persisted state names but is not running yet (terminated vat awaiting cleanup, or a remote not restored until after Kernel.make).

KernelRouter now resolves those deliveries via #getEndpointIfRunning: missing vat/remote endpoints are skipped with a warn instead of throwing inside the crank (which rolled back and re-queued the same item on every boot). Invalid endpoint ids that are neither vat nor remote still throw.

Skipped GC still runs the kernel’s half (clear reachable / delete c-list) when entries exist; skipped notify checks the endpoint before cap-data translation so skipped deliveries do not mint c-list rows. GC actions whose c-list was already torn down in the same crank are skipped without calling krefsToErefs.

Tests add KernelRouter unit coverage, a store case for reaps after vat termination, and an integration test that a peer’s persisted reap no longer bricks kernel restart.

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

grypez and others added 2 commits August 24, 2026 12:33
…loop

A vat's ownership entries outlive it. `deleteVat` takes its config and
subcluster membership when it is terminated, but its c-lists and reachable
flags stay in the store until `cleanupTerminatedVat` gets to it — and that
runs one vat per crank, so terminating a subcluster of N vats leaves a
window N cranks wide in which the kernel still addresses a vat that has no
handle.

`#deliverNotify`, `#deliverGCAction` and `#deliverBringOutYourDead` look
their endpoint up bare. In that window the lookup throws `VatNotFoundError`
from inside the crank, which escapes it and kills the run loop for good.
Because the crank is rolled back the item is restored to the queue, so the
next boot dequeues it and dies too. Reachable as soon as a refcount for one
of the vat's exports hits zero, a promise it subscribed to resolves, or a
reap queued for it comes round.

The remaining cases are what a skip has to get right, and neither is
obvious from the delivery site alone:

Releasing the kernel's own half of a GC action does not depend on the
endpoint being there to be told. Skip it and a dropped export stays flagged
reachable, so the same action is derived again on the next sweep — the
comment already in `#deliverGCAction` says as much.

`#deliverNotify` translates the resolution and its slots before it looks
the endpoint up, and both translations import if needed. Committing those
mints c-list rows and takes references in an endpoint that can never
release them; today the throw and its rollback are the only reason they
don't survive.

Co-Authored-By: Claude Opus 5 <[email protected]>
`#deliverNotify`, `#deliverGCAction` and `#deliverBringOutYourDead` now
tolerate an endpoint that is named by persisted state but not running,
instead of throwing `VatNotFoundError` out of the crank and killing the run
loop for good. `send` already did, rejecting its caller with
`ENDPOINT_UNREACHABLE`; none of these three has a caller to reject, so an
absent endpoint is something to skip.

Two halves of a skip are not interchangeable, and getting either wrong
undoes the point of the fix.

A GC action still performs the kernel's own release — clearing the
reachable flag, or tearing the c-list entry down. That half does not depend
on the endpoint being there to be told, and an endpoint that cannot hear
the action is precisely the case where a re-derived action would repeat on
every sweep forever.

`#deliverNotify` looks its endpoint up before translating the resolution
rather than after. Both translations import if needed, so committing them
mints c-list rows and takes references in an endpoint that can never
release them. The throw and its rollback were the only reason they did not
already survive; skipping without moving the lookup would have made them
permanent.

The lookup does not swallow an id that names neither a vat nor a remote. A
missing vat and a missing remote are ordinary; that is corrupt state or a
kernel bug, and GC actions pass `insistEndpointId` before they are ever
queued. Skips are reported at warn rather than on the per-delivery trace
channel, since a delivery dropped on the floor is not routine traffic and
is the only trace of a vat that has quietly stopped doing anything.

Co-Authored-By: Claude Opus 5 <[email protected]>
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 72.59%
⬆️ +0.05%
9714 / 13381
🔵 Statements 72.44%
⬆️ +0.04%
9877 / 13633
🔵 Functions 73.07%
⬆️ +0.08%
2280 / 3120
🔵 Branches 66.99%
⬆️ +0.10%
4004 / 5977
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/ocap-kernel/src/KernelRouter.ts 94.8%
⬆️ +0.60%
83.95%
⬆️ +3.36%
100%
🟰 ±0%
94.8%
⬆️ +0.60%
111, 174, 191, 265, 320, 430, 457, 460
Generated in workflow #4697 for commit 5d33bc3 by the Vitest Coverage Report Action

@grypez

grypez commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

The test to look at first

KernelRouter.test.tsan endpoint named by persisted state that is not runningskips a %s addressed to it instead of throwing out of the crank

Parameterized over all three housekeeping deliveries. It asserts the delivery is
skipped rather than thrown, which is the whole claim of the PR: the run loop
survives an endpoint the store still names but the kernel has no handle for.

Added in 0f7f109a (test only), passes at c2e1c8cb (the fix). Run it against
either commit:

yarn workspace @metamask/ocap-kernel run test:dev:quiet --coverage=false src/KernelRouter.test.ts

Before — 0f7f109a, test commit alone:

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯
 ... skips a notify addressed to it instead of throwing out of the crank
     Error: Vat not found: v2
 ... skips a dropExports addressed to it instead of throwing out of the crank
     Error: Vat not found: v2
 ... skips a bringOutYourDead addressed to it instead of throwing out of the crank
     Error: Vat not found: v2

      Tests  8 failed | 25 passed (33)

The Vat not found: v2 is the error escaping deliver. In production that is
what leaves the crank, kills the run loop, and — because the crank is rolled
back and the item restored to the queue — kills the next boot too.

After — c2e1c8cb:

All tests pass.

The other five, and why each is there

The three above prove the kernel survives. These prove it survives correctly
each pins a way a naive skip would be wrong, and each fails at 0f7f109a for
its own reason:

Test What a naive skip would get wrong
still releases the kernel side of a skipped dropExports Since #1020 the kernel's own clearReachableFlag runs at delivery time. Skip it and the export stays flagged reachable, so the same action is derived again on every sweep — forever
still tears down the c-list entry of a skipped retireExports / retireImports Same, for the deleteCListEntry half
allocates nothing in the c-list of an endpoint it is skipping #deliverNotify translated the resolution before the lookup, and both translations import if needed. The throw and its rollback were the only reason those rows didn't survive; skipping without moving the lookup makes them permanent
throws for an endpoint id that is neither a vat nor a remote A missing vat and a missing remote are ordinary. An id that is neither is corrupt state, and GC actions pass insistEndpointId before they are ever queued — swallowing it hides the one case worth hearing about
reports the skip above the per-delivery trace level A dropped delivery is the only trace of a vat that has quietly stopped doing anything; on the @@@@ trace channel it is invisible in normal operation

And one control that passes at both commits — still delivers to endpoints that are running — so the suite would catch a "fix" that skipped everything.

The reachability this guard rests on, which the changelog and the JSDoc had
attributed to the wrong caller.

`terminateSubcluster` does not leave a terminated vat addressable: it calls
`collectGarbage` after each `terminateVat`, and that drains every pending
cleanup in a loop, so the c-lists are gone before the run loop sees another
crank — and `shouldProcessAction` then filters out any GC action naming
that vat.

The reap queue is not filtered that way, and nothing purges it when a vat
dies. `nextReapAction` shifts an endpoint off and hands back a
`bringOutYourDead` with no liveness check at all, so a reap scheduled by
`reapVats` before a vat was terminated arrives after it — through
`terminateSubcluster` as readily as any other route. Reaps are taken ahead
of the run queue, so it blocks everything behind it too.

The window does exist for the callers that mark a vat terminated without
draining — the boot-time orphan prune, and the run loop's own termination
callback — which is what the corrected wording now says.

Purging the queue on termination is worth doing on its own, and is tracked
separately; the delivery-side guard is needed either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
@grypez

grypez commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Correction: the reachability claim above named the wrong caller

An earlier revision of this description said terminating a subcluster of N vats
leaves an N-crank window in which the kernel still addresses a vat with no
handle. That is wrong, and it was the motivating example. Raised in review;
worth recording rather than quietly editing.

SubclusterManager.terminateSubcluster calls collectGarbage() after each
terminateVat, and VatManager.collectGarbage drains every pending cleanup:

while (this.#kernelStore.nextTerminatedVatCleanup()) { }

So the c-lists are gone before the run loop sees another crank, and
shouldProcessAction — which requires a c-list entry for all three GC types —
then filters out any GC action naming those vats. That path closes its own
window.

The reachability that does hold

The reap queue, which needs no window at all. nextReapAction shifts an
endpoint off reapQueue and hands back a bringOutYourDead with no liveness
check
— unlike GC actions. And nothing purges that queue when a vat dies.
Verified at store level, and now pinned by a test in this PR
(store/methods/gc.test.tsyields a reap scheduled for a vat that has since been terminated):

scheduleReap('v1') → deleteVat → markVatAsTerminated → drain all cleanups → collectGarbage
  isVatActive('v1')  → false
  nextReapAction()   → { type: 'bringOutYourDead', endpointId: 'v1' }   ← still there

So reapVats() — public on the kernel, and wired to the debug UI — followed by
terminating that vat reaches the throwing lookup, including through
terminateSubcluster
. Reaps are taken ahead of the run queue in
#getNextRunQueueItem, so a stale one also blocks everything behind it, and the
crank rollback restores the shifted queue, so it kills the next boot too.

The cleanup window still exists, just not for that caller — the boot-time
orphan prune (SubclusterManager.deleteSubcluster) and the run loop's own
termination callback both mark vats terminated without draining, and a vat
awaiting cleanup still has the c-lists that let a GC action or notify through
its filters.

Follow-up

That the reap queue is never purged is arguably its own defect: even with this
PR's guard, every terminated vat leaves a stale reap that costs a crank and a
warning line rather than never being scheduled. Filing separately — the
delivery-side guard is needed either way, since the boot-prune window is real
regardless.

The changelog entry and the #getEndpointIfRunning JSDoc have been corrected to
match.

@grypez
grypez marked this pull request as ready for review August 25, 2026 19:51
@grypez
grypez requested a review from a team as a code owner August 25, 2026 19:51

@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 1 potential issue.

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 6b38190. Configure here.

Comment thread packages/ocap-kernel/src/KernelRouter.ts
@cursor
cursor Bot requested review from rekmarks and sirtimid August 25, 2026 19:56
Reported by Bugbot on #1029.

Guarding only the endpoint lookup left the same crash one line further on.
`processGCActionSet` selects an action while the endpoint still has a c-list
entry for each of its krefs, but the run loop calls
`nextTerminatedVatCleanup` between that selection and the delivery, and
cleaning a vat takes its whole c-list. An action selected in that crank
therefore arrives after its entries are gone, and `krefsToErefs` reports an
unmapped kref by throwing — out of the crank, killing the run loop exactly
as the unguarded lookup did, and re-dequeued on the next boot to kill that
one too.

Nothing is owed in that case: the cleanup performed the kernel's half on
its way past. So the krefs are filtered to those the endpoint still holds,
and an action left with none is skipped whole.

The filter applies only when the endpoint is gone. Cleanup runs for a
terminated vat and a terminated vat has no handle, so a running endpoint
cannot be in this state; one that is missing a c-list entry is a real
disagreement and still throws.

Co-Authored-By: Claude Opus 5 <[email protected]>
@grypez

grypez commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

The issue demonstrated on main, with no test doubles

packages/kernel-test/src/absent-endpoint.test.ts (added in 6996006) reaches
this through the public API of a real kernel running real vats. Three steps:

kernel.reapVats();                       // schedule a reap for each running vat
await kernel.terminateVat(doomedVatId);  // one of them then dies
// drive a few cranks

Against main the run loop is dead:

AssertionError: expected { state: 'failed', …(2) } to strictly equal { state: 'running' }

VatNotFoundError: Vat does not exist.
  at VatManager.getVat            (vats/VatManager.ts:195)
  at #getEndpoint                 (Kernel.ts:490)
  at #deliverBringOutYourDead     (KernelRouter.ts:354)
  at KernelRouter.deliver         (KernelRouter.ts:74)
  at #runLoop                     (KernelQueue.ts:105)

With this PR: All tests pass.

Why the reap is the route that works

I tried the GC-action route first and it does not reproduce, which is worth
recording because it is the obvious one to reach for. Terminate two vats sharing
an object and no action is ever addressed to an uncleaned vat: cleanup runs one
vat per crank, and whichever is cleaned last is the one that drops the final
reference, so the object is only collected once every c-list naming it has
already gone. shouldProcessAction then filters the action out.

A reap has neither property. It carries no kref, so cleanup cannot neutralise it,
and nextReapAction hands it back with no liveness check at all — where a GC
action is filtered on a surviving c-list entry and a notify short-circuits on
krefToEref. Nothing purges the reap queue when a vat dies: not terminateVat,
not deleteVat, not cleanupTerminatedVat. And reaps are taken ahead of the run
queue in #getNextRunQueueItem, so it blocks everything behind it.

Honest scoping

Nothing in this repo calls reapVats — I checked, and said so in the test's
comment and commit message. It is shipped public kernel API, so an embedder or a
debug surface reaches it; RemoteHandle also schedules a reap into the same
queue whenever a peer sends bringOutYourDead, though that one arrives via
getRemote rather than getVat.

Two earlier framings in this PR were worse than that and have been corrected:
terminateSubcluster does not reach this (it drains cleanup after each vat),
and I described reapVats as RPC- and UI-wired when it is not.

That the reap queue survives its vat is arguably its own defect — purging it
would remove this route, and the delivery-side guard would still be needed for
the cleanup-window cases. Filing separately.

@grypez
grypez force-pushed the grypez/absent-endpoint-deliveries branch from 6996006 to c603cc4 Compare August 26, 2026 19:00
@grypez

grypez commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Replaced the demonstration; the earlier one was contrived

The previous version scheduled a reap and then immediately terminated a vat.
That reaches the failing line, but the objection in review was right: nobody
does that, so it demonstrated a mechanism rather than a bug anyone encounters.
It has been dropped.

In its place, 'survives a peer reap that outlived the incarnation it was queued in' — in kernel-test/src/remote-comms.test.ts, in the two-kernel harness,
next to #1028's revocation test and modelled on 'remote relationships should survive kernel restart'. Two real kernels, real remote comms, nothing stubbed,
and no step that requires this kernel's embedder to do anything:

  1. Client and server establish a remote relationship over an ocap URL.
  2. The server runs remote GC and asks its peer to bring out its dead. The
    request travels over the wire and the client schedules a reap against the
    remote it arrived from. That is the peer's decision, not ours.
  3. scheduleReap does not wake a parked run loop, so on the now-idle client the
    reap sits in the persisted queue.
  4. The client is shut down — an ordinary shutdown of an idle kernel.
  5. The client boots again.

On main, step 5 kills it inside Kernel.make, before makeTestKernel even
reaches initRemoteComms. With this PR it passes, and the log shows the guard
on exactly that path: Skipped bringOutYourDead for endpoint r1, which is not running.

Two things the test asserts rather than assumes

Both are races I hit while building it, and either one silently turns the test
green:

  • The reap is queued by giving the server local work, so its loop cranks
    and sends the request while the client's loop is never woken. Reaps are taken
    ahead of the run queue, so a client that cranks eats its own reap while the
    remote still exists and the scenario evaporates. My first attempt did exactly
    that and failed its own precondition.
  • After the shutdown the test reads reapQueue straight from the database
    and requires a remote id to be there. A run that loses the race now fails as a
    setup failure instead of passing.

Honest scoping

reapRemotes has no caller in this repo, so today this needs a peer that runs
remote GC. That is a materially weaker requirement than the earlier framings,
though: it is the other kernel's behaviour, which this one neither controls nor
can assume away, and the consequence is a kernel that will not boot rather than a
transient error.

Three earlier reachability claims in this PR were wrong and have been corrected
rather than quietly dropped: terminateSubcluster (it drains cleanup), the
GC-action window (cleanup wins the race — it does not reproduce), and reapVats

  • terminate (real mechanism, contrived scenario).

The changelog and the #getEndpointIfRunning JSDoc now lead with this scenario.

`bringOutYourDead` is an ordinary arm of the remote protocol; any peer can
send one, unsolicited, and it needs no authority beyond being connected.
The kernel answers by scheduling a reap against the remote it came from,
in the persisted reap queue.

`scheduleReap` does not wake a parked run loop, so an idle kernel holds
that reap indefinitely, and carries it into its next incarnation. That
incarnation starts its run loop inside `Kernel.make` — before an embedder
can call `initRemoteComms`, which is what restores remote handles — and
reaps are taken ahead of the run queue, so the first thing the loop does
is deliver one addressed to a remote that does not exist yet.

So one message from a peer is enough to stop a kernel ever booting again,
assuming only that it restarts at some point. On main:

  expected [ { state: 'failed', ... }, ... ] to strictly equal
           [ { state: 'running' }, ... ]

  Error: Remote not found: r1
    at RemoteManager.getRemote → #getEndpoint → #deliverBringOutYourDead
    → deliver → #runLoop → KernelQueue.run
    at #init (Kernel.ts) → Kernel.make

Both boots are asserted together so the failure reports both, because the
point is that the second is no better than the first: the crank that dies
is rolled back, which puts the reap back on the queue.

Two things the test pins rather than assumes. The peer is given local work
so its own loop cranks and sends the request, and nothing touches the
victim afterwards — a victim that cranks eats its own reap while the
remote still exists, and the scenario evaporates. And the reap queue is
read from the database after shutdown, so a run that loses that race fails
as a setup failure rather than passing.

Only the victim needs a file-backed database, since only it restarts.

Co-Authored-By: Claude Opus 5 <[email protected]>
@grypez
grypez force-pushed the grypez/absent-endpoint-deliveries branch from c603cc4 to 5d33bc3 Compare August 26, 2026 19:37
@grypez

grypez commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Reframed: one message from a peer, and the kernel never boots again

The test is now 'is not bricked by a peer asking it to bring out its dead', and
it is the shortest thing that shows the property. Assumption: the victim restarts
at some point. Nothing else.

bringOutYourDead is an ordinary arm of the remote protocol — any connected peer
can send one, unsolicited, needing no authority beyond being a peer. The victim
answers by scheduling a reap against that remote, in the persisted queue.
scheduleReap does not wake a parked run loop, so an idle victim holds it, and
carries it into the next incarnation — which starts its run loop inside
Kernel.make, before an embedder can call initRemoteComms to restore any
remote to deliver it to.

On main, both boots are dead:

expected [ { state: 'failed', … }, … ] to strictly equal
         [ { state: 'running' }, … ]

Error: Remote not found: r1
  at RemoteManager.getRemote -> #getEndpoint -> #deliverBringOutYourDead
  -> deliver -> #runLoop -> KernelQueue.run
  at #init (Kernel.ts:232) -> Kernel.make (Kernel.ts:184)

Both boots are asserted together on purpose: the second being no better than the
first is the point. The crank that dies is rolled back, which puts the reap back
on the queue, so recovery means hand-editing reapQueue out of the database.

With this PR both boots come up, and the log shows the guard on that path:
Skipped bringOutYourDead for endpoint r1, which is not running.

Bounds of the claim

  • A peer cannot crash a running kernel. RemoteManager.#remotes is only ever
    set or cleared wholesale, and the only cleanup() is inside Kernel.stop(), so
    while the victim is up the handle exists and the reap delivers harmlessly. The
    damage is latent and lands on the next restart.
  • Peer status is required — the victim must already hold a RemoteHandle, so
    some relationship was established. But no specific authority beyond that:
    bringOutYourDead is not privileged.
  • Not amplifiable: scheduleReap dedupes on endpoint id, so a flood adds one
    entry.
  • I have not audited how a remote relationship gets established. If a peer can
    provoke one without the victim having issued it anything, the precondition is
    weaker than stated here.

What the test pins rather than assumes

Both are races that would otherwise turn it green for the wrong reason:

  • The peer is given local work so its own loop cranks and sends the request,
    and nothing touches the victim afterwards. A victim that cranks eats its own
    reap while the remote still exists, and the scenario evaporates — my first
    attempt did exactly that.
  • After shutdown the reap queue is read straight from the database and required
    to be non-empty, so a run that loses that race fails as a setup failure.

Only the victim needs a file-backed database, since only it restarts.

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.

1 participant