Skip to content

feat(ocap-kernel): carry out a peer incarnation change on the run loop - #1104

Open
sirtimid wants to merge 4 commits into
sirtimid/remote-inbound-run-queue-itemfrom
sirtimid/peer-incarnation-run-queue-item
Open

sirtimid wants to merge 4 commits into
sirtimid/remote-inbound-run-queue-itemfrom
sirtimid/peer-incarnation-run-queue-item

Conversation

@sirtimid

@sirtimid sirtimid commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1103. Review that one first; this branch's base is sirtimid/remote-inbound-run-queue-item.

A peer's incarnation change took a peerIncarnation_* savepoint of its own, which nested inside whichever crank happened to be open — the same defect #1103 just removed from the inbound message path. It becomes a run queue item, carried out by the run loop in a crank of its own.

The handshake still gets its answer immediately. Whether this is a restart is a read of what the store already says, and the transport awaits that answer to decide whether to reset the connection; it cannot wait for a crank. Only the writes are queued.

Changes

  • RemoteManager.#handleIncarnationChange answers from getPeerIncarnation and calls KernelQueue.acceptPeerIncarnation. applyIncarnationChange does the writes in a crank.
  • The change is queued behind anything that peer has already sent, in the same in-memory arrival buffer as inbound messages. That ordering is what keeps the two incarnations apart: a message ahead of the change belongs to the incarnation that is ending and is recorded against it, one behind it to the incarnation that is starting. So feat(ocap-kernel): take an inbound remote message in a crank of its own #1103's eager discard of queued arrivals goes away — with the change itself ordered, the discard would now throw away the new incarnation's messages instead of the old one's.
  • applyIncarnationChange re-reads the stored incarnation and returns early if it already matches, so a peer that re-dials while its first change is still waiting does not get its c-list torn down twice.
  • An incarnation change that cannot be recorded is logged and dropped rather than killing the run loop — the containment feat(ocap-kernel): take an inbound remote message in a crank of its own #1103 added for inbound messages.

Carries the incarnation half of #1079.

What review changed

afterCommit was doing the promise rejections, and resolvePromises writes the kernel store — promise state, reference counts, and a notify row per subscriber, all in autocommit once endCrank has committed. That is exactly what the contract added in #1101 forbids, and I had made the same mistake there. Two reviewers found it independently, and one traced a second consequence: the transport's give-up handling rejects the same promises from a send continuation, so whichever arrived second would Fail out of a post-commit hook and take the kernel with it.

The rejections now happen inside the crank, buffered with immediate: false so the notifies they produce still wait for the commit — the mechanism a vat's own syscalls already use. afterCommit keeps only finalizePeerRestart, which is in-memory counters and nothing else.

Restoring the router's exhaustiveness check also came out of review: this PR had deleted the @ts-expect-error on KernelRouter.deliver's default case, because peerIncarnation made the never reachable. deliver now takes Exclude<RunQueueItem, RunQueueItemPeerIncarnation>, which says in the type what the kernel does at runtime and brings the check back.

Testing

The handshake is answered without its writes having happened; the rejections are buffered and finalizePeerRestart waits for the commit; a peer's messages and its incarnation change come out in arrival order; a change queued twice is recorded once; the kernel carries out the item itself rather than routing it, and survives one it cannot record; and acceptPeerIncarnation is refused once the run loop has died. Every fix was mutation-checked.

Six existing incarnation tests now go through a handshakeAndRunCrank helper that does what the transport and the run loop do between them.

@metamask/ocap-kernel is green, as is @ocap/kernel-test's remote-comms suite against a rebuilt dist. That suite intermittently crashes the Node worker with Assertion failed: (env) != nullptr; it reproduces on origin/main and is not from this change — two consecutive clean runs here.

🤖 Generated with Claude Code


Note

High Risk
Changes remote peer restart bookkeeping, promise rejection timing, and message/incarnation ordering—core kernel run-loop and distributed comms correctness paths with breaking semantics.

Overview
BREAKING: Peer incarnation changes no longer run inside a nested peerIncarnation_* savepoint during an arbitrary open crank. They become a peerIncarnation run-queue item, delivered in its own crank like remoteInbound messages.

The transport handshake still answers immediately from getPeerIncarnation (restart vs not). Only the durable work is queued via KernelQueue.acceptPeerIncarnation, ordered in the same in-memory arrival buffer behind that peer’s already-accepted inbound messages—so old-incarnation sequence numbers are recorded before the change, and new-incarnation traffic after it. discardRemoteInbound on restart is removed in favor of that ordering.

RemoteManager.#handleIncarnationChange queues the change; applyIncarnationChange performs persist/restart logic without the old savepoint wrapper, skips duplicate work if incarnation already matches, rejects decider promises inside the crank with buffered notifies (immediate: false), and defers finalizePeerRestart to afterCommit. Kernel’s run-loop deliver handles peerIncarnation itself (not KernelRouter); recording failures log and return { abort: true } instead of killing the loop.

Adds RunQueueItemPeerIncarnation and narrows KernelRouter.deliver to exclude it. CHANGELOG documents the breaking behavior shift.

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

sirtimid and others added 2 commits September 15, 2026 23:48
The change took a `peerIncarnation_*` savepoint of its own, which nested
inside whichever crank was open — the same defect the inbound message path
just shed. It becomes a run queue item, carried out in a crank of its own.

The handshake still gets its answer immediately: whether this is a restart is
a read of what the store already says, and the transport needs it to decide
whether to reset the connection. Only the writes are queued.

Queued behind anything that peer has already sent, which is what keeps the two
incarnations apart: the messages ahead of the change belong to the one that is
ending and are recorded against it, the ones behind it to the one that is
starting. So the eager discard the previous branch needed goes away — it would
now throw away the new incarnation's messages rather than the old one's.

Rejecting the promises the restarted remote was deciding, and resetting its
in-memory state, move to `afterCommit`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up, and the same mistake the branch below it made: `afterCommit`
must not write the kernel store, and `resolvePromises` writes — promise state,
reference counts, and a notify row per subscriber, all in autocommit once
`endCrank` has committed. It also raced the transport's own give-up handling,
which rejects the same promises from a send continuation; whichever arrived
second would `Fail` out of a post-commit hook and kill the kernel.

The rejections move into the crank, buffered with `immediate: false` so the
notifies they produce still wait for the commit, the way a vat's syscalls do.
`afterCommit` keeps only the in-memory counter reset.

An incarnation change that cannot be recorded no longer kills the run loop —
the same containment the inbound message path already has — and the router's
exhaustiveness check comes back by excluding the item type the kernel handles
itself, rather than by deleting the directive that proved it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid requested a review from a team as a code owner September 15, 2026 22:15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@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 using high effort and found 3 potential issues.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 29dae26. Configure here.

// it to finish the handshake and cannot wait for a crank. The writes it
// implies are queued, and ordered against anything this peer has sent.
this.#kernelQueue.acceptPeerIncarnation(peerId, observedIncarnation);
return stored !== undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-dial still reports a restart

High Severity

#handleIncarnationChange still returns restart whenever the store lags the observed incarnation, including on the re-dial the transport makes after that first verdict. The transport closes the new channel and expects the next handshake to see the persisted value and proceed; that write only happens later in applyIncarnationChange, so inbound re-dials keep being rejected and outbound sends keep throwing PeerRestartedError until a later crank commits. Each of those failures is terminal on the send path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 29dae26. Configure here.

// it to finish the handshake and cannot wait for a crank. The writes it
// implies are queued, and ordered against anything this peer has sent.
this.#kernelQueue.acceptPeerIncarnation(peerId, observedIncarnation);
return stored !== undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Give-up now precedes restart writes

High Severity

The handshake now returns restart before persistPeerRestart or the in-crank PEER_RESTARTED rejections run. The transport then throws PeerRestartedError, and the send continuation’s give-up path actually runs: it writes startSeq and rejects decider promises as CONNECTION_LOST outside the incarnation crank. Those kernel-store writes are autocommitted, and the later crank finds nothing left to reject as a peer restart.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 29dae26. Configure here.

error,
);
return { abort: true };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Failed change drops the incarnation barrier

Medium Severity

A peerIncarnation item that applyIncarnationChange cannot record is shifted off #arrivedFromRemotes and then aborted with no re-queue. Messages already sitting behind that item — the new incarnation’s — are then delivered against the old seq and c-list. The handshake already returned restart, so the peer will not replay those arrivals.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 29dae26. Configure here.

An arrival is the one kind of work that does not go through `#enqueueRun`, so
its wake is its own. Deleting either call left every test green; a parked loop
with work waiting is a permanent wedge.

`does not reject promises when there are none` also asserted only a negative,
and passed whether or not the restart it describes had happened.

Co-Authored-By: Claude Opus 5 (1M context) <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.

1 participant