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 when and how peer restarts are persisted, promises rejected, and remote state reset relative to crank commit—core remote comms consistency that can affect duplicate detection and double-rejection races with transport give-up handling.

Overview
Peer incarnation changes now run on the kernel queue in their own crank, instead of taking a nested peerIncarnation_* savepoint during whatever crank was already open (same class of bug #1103 fixed for inbound messages).

The transport still answers the handshake immediately from getPeerIncarnation; only the durable work is queued via KernelQueue.acceptPeerIncarnation, behind any inbound messages that peer already sent, so old- and new-incarnation traffic stay separated by arrival order rather than discarding queued arrivals.

RemoteManager.#handleIncarnationChange only queues; applyIncarnationChange performs persist/finalize logic in the crank. Promise rejections for the restarted remote’s decider promises run inside the crank with buffered notifies (immediate: false); finalizePeerRestart (in-memory reset) moves to afterCommit. Duplicate queued changes no-op if the store already matches. Failures to record are logged and return { abort: true } so the run loop survives. The run loop callback handles peerIncarnation directly (not KernelRouter); discardRemoteInbound is removed.

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

@sirtimid
sirtimid requested a review from a team as a code owner September 15, 2026 22:15

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

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts
Comment thread packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts
Comment thread packages/ocap-kernel/src/Kernel.ts
@sirtimid

Copy link
Copy Markdown
Contributor Author

Heads-up for this branch's next rebase — #1103 gained a commit that collides with this one.

Bugbot flagged that discardRemoteInbound only ran from handlePeerRestart, which nothing in production calls, so a live restart never discarded the old incarnation's arrivals. #1103 now carries fix(ocap-kernel): discard the old incarnation's arrivals on a live restart (7095b49), which moves that call into RemoteHandle.finalizePeerRestart.

This PR deletes KernelQueue.discardRemoteInbound outright. The collision will not show up as a merge conflict — the deletion is in KernelQueue.ts and the new call site is in RemoteHandle.ts — so the rebase will look clean and then fail the build. When rebasing onto the new #1103 head, drop:

  • this.#kernelQueue.discardRemoteInbound(this.remoteId);, now the first line of finalizePeerRestart (RemoteHandle.ts)
  • discardRemoteInbound: vi.fn(), in test/remotes-mocks.ts, which this branch currently leaves behind as the method's only remaining reference

Two textual conflicts to expect as well, both in prose only: #1103 rewrote the stale paragraph in #handleIncarnationChange's JSDoc (it pointed at handleRemoteMessage, which #1103 deletes), and added a paragraph to discardRemoteInbound's JSDoc recording what the filter cannot reach. Take this branch's version of both — the second describes a method that is going away.

Worth saying plainly, since it argues for this PR: the approach here is the better fix. Holding the incarnation change behind the arrivals that peer already sent carries the old incarnation's messages out instead of discarding them, and running it as a crank closes a race the discard cannot reach — an arrival already shifted off the list by #getNextRunQueueItem, whose crank is suspended in deliverInbound's await when the handshake lands, still records the old incarnation's sequence number. #1103's fix exists only so that PR is correct if it lands first.

@sirtimid
sirtimid force-pushed the sirtimid/peer-incarnation-run-queue-item branch from 2e949a4 to 15a742a Compare September 23, 2026 16:57

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

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/KernelQueue.ts
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 73.33%
⬆️ +0.06%
9968 / 13592
🔵 Statements 73.13%
⬆️ +0.06%
10098 / 13808
🔵 Functions 73.72%
🟰 ±0%
2334 / 3166
🔵 Branches 67.74%
⬆️ +0.13%
4112 / 6070
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/ocap-kernel/src/Kernel.ts 90.37%
⬆️ +0.45%
80.43%
⬆️ +0.89%
85.71%
⬆️ +0.30%
90.37%
⬆️ +0.45%
324-326, 417, 441, 516-526, 614, 682, 758-761, 774, 784-785, 838, 861
packages/ocap-kernel/src/KernelQueue.ts 98.84%
⬆️ +0.09%
90.9%
⬆️ +0.21%
100%
🟰 ±0%
98.84%
⬆️ +0.09%
176, 674
packages/ocap-kernel/src/KernelRouter.ts 94.83%
⬆️ +0.35%
81.94%
⬆️ +0.25%
100%
🟰 ±0%
94.83%
⬆️ +0.35%
122, 185, 202, 304, 357, 417, 435, 438
packages/ocap-kernel/src/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts 97.56%
⬆️ +0.55%
94.73%
⬆️ +1.68%
98.55%
⬆️ +0.09%
97.54%
⬆️ +0.54%
519, 526-531, 576, 709, 978, 1505
packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts 99.06%
⬆️ +0.01%
100%
🟰 ±0%
96%
⬆️ +0.35%
99.06%
⬆️ +0.01%
465-467
Generated in workflow #4999 for commit ff8bb85 by the Vitest Coverage Report Action

@sirtimid
sirtimid force-pushed the sirtimid/peer-incarnation-run-queue-item branch from 15a742a to e8b6cb0 Compare September 23, 2026 17: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.

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts
@sirtimid
sirtimid force-pushed the sirtimid/peer-incarnation-run-queue-item branch from e8b6cb0 to 0b446fa Compare September 23, 2026 21:47

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

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts
@sirtimid
sirtimid force-pushed the sirtimid/peer-incarnation-run-queue-item branch from 0b446fa to 9b38dfc Compare September 23, 2026 22:26

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

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts
Comment thread packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts
@sirtimid
sirtimid force-pushed the sirtimid/peer-incarnation-run-queue-item branch 2 times, most recently from 33674dc to fcbeaa2 Compare September 24, 2026 15:10

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

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 fcbeaa2. Configure here.

// The in-memory counters only, which no rollback could put back and
// which must not be visible before the writes above are durable.
afterCommit: async () => remote.finalizePeerRestart(),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Restart cleanup races the transport

High Severity

#handleIncarnationChange answers the handshake and releases the transport before any restart cleanup. persistPeerRestart then runs in the crank, but finalizePeerRestart waits for afterCommit — after await deliver has already yielded. The send continuation's give-up path can run in that gap and write old sequence counters back over clearRemoteSeqState, while new-connection ACKs and delayed-ACK timers still operate on pre-restart in-memory state.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fcbeaa2. Configure here.

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>
sirtimid and others added 3 commits September 25, 2026 18:56
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>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
@sirtimid
sirtimid force-pushed the sirtimid/peer-incarnation-run-queue-item branch from fcbeaa2 to ff8bb85 Compare September 25, 2026 16:59
@cursor

cursor Bot commented Sep 25, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

This branch has not been deployed

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