Skip to content

Commit 55909f0

Browse files
sirtimidclaude
andcommitted
fix(ocap-kernel): plug second-round leaks in peer-incarnation restart path
Follow-up review of the issue #944 fix surfaced four real defects, each fixed here: 1. RemoteHandle.#sendRemoteCommand swallowed PeerRestartedError and NetworkStoppedError. Only `intentional close` triggered the cleanup path; the other two terminal verdicts fell through to logger.error with the message persisted to remotePending and #nextSendSeq advanced, leaving it pending until ACK timeout × MAX_RETRIES. The catch now routes all terminal errors through the shared isTerminalSendError predicate. 2. RemoteManager.#handleIncarnationChange enqueued kernelQueue notifications and ran in-memory mutations inside the savepoint window. A kv rollback would not undo either, leaving the kernel with run-queue entries against promises the persisted store still considers unresolved. Split RemoteHandle.handlePeerRestart into `persistPeerRestart` (kv-only, savepoint-safe) and `finalizePeerRestart` (in-memory + redemption rejections); the manager now snapshots `getPromisesByDecider` before any kv mutation, runs `persistPeerRestart` inside the savepoint, and defers `finalizePeerRestart` and `resolvePromises` to after release. 3. doInboundHandshake silently ignored kernelDetectedRestart. The outbound path closes the freshly-dialed channel and throws PeerRestartedError on detected restart; the inbound path only logged. A concurrent in-flight outbound send could then write a pre-restart payload over a fresh-incarnation channel. Inbound now returns false when the kernel detects a restart, and the caller closes the channel without registering it — symmetric with the outbound guard. 4. deleteRemoteInfo left peerIncarnation.{peerId} stranded. A re-established remote with the same peerId would mis-classify its first handshake as a restart against the leftover incarnation. Now reads the info before deletion and clears the peerIncarnation row; corrupt JSON is logged and swallowed so the rest of the cleanup still runs. Other tightening: - #rejectAllPending is a no-op when nothing is pending, so the catch path is safe even when the kernel-side restart cleanup ran upstream. - #handleIncarnationChange logs a warning when the restart verdict fires but no live RemoteHandle exists (boot race / peer teardown scoping issue), instead of silently advancing the persisted incarnation. - Sentinel error matching uses the centralized `isTerminalSendError` imported from @metamask/kernel-errors (this commit's prerequisite), removing the brittle string-match in the predicate. - Outbound PeerRestartedError now logs at info severity, not error — it is an expected, recoverable event. Tests: - New: PeerRestartedError/IntentionalCloseError/NetworkStoppedError from initial #sendRemoteCommand reject pending and fire onGiveUp. - New: handlePeerRestart actually clears c-list state (seeds an exportFromEndpoint pair, asserts both halves gone after restart). - New: PeerRestartedError does not trigger reconnection from the outbound catch path. - New: inbound handshake closes the channel without registration when the kernel detects a restart. - New: deleteRemoteInfo clears the peer-incarnation row. - Updated: RemoteManager savepoint-rollback test now spies on persistPeerRestart and asserts finalizePeerRestart is NOT called when the persisted phase throws. - Updated: #handleIncarnationChange verdict tests assert false on first observation and stable incarnation (not just true on restart). E2E: extension/test/e2e/remote-comms.test.ts had its toBeVisible budget raised to 50s (suite to 90s) to fit inside the 40s URL redemption budget that the new awaited handshake RPC pushes the round-trip into under CI load. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bafe273 commit 55909f0

9 files changed

Lines changed: 375 additions & 217 deletions

File tree

packages/extension/test/e2e/remote-comms.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { rm } from 'node:fs/promises';
55

66
import { loadExtension, sessionPath } from '../helpers.ts';
77

8-
test.describe.configure({ mode: 'serial', timeout: 60_000 });
8+
test.describe.configure({ mode: 'serial', timeout: 90_000 });
99

1010
/**
1111
* End-to-end tests for remote communications functionality.
@@ -140,7 +140,11 @@ test.describe('Remote Communications', () => {
140140
const messageResponse = popupPage1.locator(
141141
'[data-testid="message-response"]',
142142
);
143-
await expect(messageResponse).toBeVisible({ timeout: 30_000 });
143+
// Budget: redemption timeout is `ackTimeoutMs * (MAX_RETRIES + 1)` =
144+
// 40s with prod defaults; the response (success or rejection) is
145+
// guaranteed to render before then. 50s gives 10s headroom for the
146+
// post-redemption render path under CI load.
147+
await expect(messageResponse).toBeVisible({ timeout: 50_000 });
144148
await expect(messageResponse).toContainText(
145149
// eslint-disable-next-line no-useless-escape
146150
`Response:{\"body\":\"#\\\"vat Bob got \\\\\\\"hello\\\\\\\" from remote Alice\\\"\",\"slots\":[]}`,

packages/ocap-kernel/src/remotes/kernel/RemoteHandle.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1674,4 +1674,79 @@ describe('RemoteHandle', () => {
16741674
expect(onGiveUp).not.toHaveBeenCalled();
16751675
});
16761676
});
1677+
1678+
describe('first-send terminal errors', () => {
1679+
it.each([
1680+
[
1681+
'PeerRestartedError',
1682+
Object.assign(new Error('peer restarted'), {
1683+
name: 'PeerRestartedError',
1684+
}),
1685+
],
1686+
[
1687+
'IntentionalCloseError',
1688+
Object.assign(new Error('intentional close'), {
1689+
name: 'IntentionalCloseError',
1690+
}),
1691+
],
1692+
[
1693+
'NetworkStoppedError',
1694+
Object.assign(new Error('Network stopped'), {
1695+
name: 'NetworkStoppedError',
1696+
}),
1697+
],
1698+
])(
1699+
'rejects pending and fires onGiveUp when initial send rejects with %s',
1700+
async (_name, terminalError) => {
1701+
const onGiveUp = vi.fn();
1702+
const remote = RemoteHandle.make({
1703+
remoteId: mockRemoteId,
1704+
peerId: mockRemotePeerId,
1705+
kernelStore: mockKernelStore,
1706+
kernelQueue: mockKernelQueue,
1707+
remoteComms: mockRemoteComms,
1708+
ackTimeoutMs: 100,
1709+
onGiveUp,
1710+
});
1711+
1712+
vi.mocked(mockRemoteComms.sendRemoteMessage).mockRejectedValueOnce(
1713+
terminalError,
1714+
);
1715+
1716+
const redeem = remote.redeemOcapURL('ocap:something@peer,relay');
1717+
// Drain microtasks so the catch handler runs.
1718+
for (let i = 0; i < 5; i += 1) {
1719+
await Promise.resolve();
1720+
}
1721+
1722+
// The redemption rejects with the giveUp/rejectAllPending reason,
1723+
// which is the terminal error's message string.
1724+
await expect(redeem).rejects.toThrow(terminalError.message);
1725+
expect(onGiveUp).toHaveBeenCalledWith(mockRemotePeerId);
1726+
},
1727+
);
1728+
});
1729+
1730+
describe('handlePeerRestart c-list teardown', () => {
1731+
it('clears the peer’s "+"-direction c-list entries via forgetEndpointImports', async () => {
1732+
const remote = makeRemote();
1733+
1734+
// Seed an object export from the peer (peer-allocated eref ro+5
1735+
// mapped to a fresh kernel object).
1736+
const eref = 'ro+5';
1737+
const kref = mockKernelStore.exportFromEndpoint(mockRemoteId, eref);
1738+
1739+
// Sanity: c-list is populated in both directions before restart.
1740+
// Use the raw `krefToEref`/`erefToKref` lookups (not the translating
1741+
// wrappers, which flip RRef polarity for receiver-frame interpretation).
1742+
expect(mockKernelStore.erefToKref(mockRemoteId, eref)).toBe(kref);
1743+
expect(mockKernelStore.krefToEref(mockRemoteId, kref)).toBe(eref);
1744+
1745+
remote.handlePeerRestart();
1746+
1747+
// Both halves of the c-list pair are gone after restart.
1748+
expect(mockKernelStore.erefToKref(mockRemoteId, eref)).toBeUndefined();
1749+
expect(mockKernelStore.krefToEref(mockRemoteId, kref)).toBeUndefined();
1750+
});
1751+
});
16771752
});

packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts

Lines changed: 64 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { VatOneResolution } from '@agoric/swingset-liveslots';
22
import type { CapData } from '@endo/marshal';
33
import { makePromiseKit } from '@endo/promise-kit';
4+
import { isTerminalSendError } from '@metamask/kernel-errors';
45
import { Logger } from '@metamask/logger';
56

67
import {
@@ -34,33 +35,6 @@ const MAX_RETRIES = 3;
3435
/** Maximum number of pending messages awaiting ACK. */
3536
const MAX_PENDING_MESSAGES = 200;
3637

37-
/**
38-
* Send-side errors that should abort retransmit instead of being treated as
39-
* a transient failure to retry. These come from definitive transport-level
40-
* verdicts: the user/peer closed the connection, the network is shutting
41-
* down, or the peer's incarnation changed (so any pending message was
42-
* generated against a now-dead session). Continuing to iterate just produces
43-
* identical failures on every queued seq until MAX_RETRIES exhausts.
44-
*
45-
* Identified by name (`PeerRestartedError`) for the incarnation case so the
46-
* check survives across the platform-services RPC boundary that might
47-
* unwrap the class identity, and by message for the others (which originate
48-
* as plain `Error`s in transport.ts).
49-
*
50-
* @param error - The error thrown by sendRemoteMessage.
51-
* @returns Whether the error indicates retransmit should stop.
52-
*/
53-
function isTerminalSendError(error: unknown): boolean {
54-
if (!(error instanceof Error)) {
55-
return false;
56-
}
57-
return (
58-
error.name === 'PeerRestartedError' ||
59-
error.message.includes('intentional close') ||
60-
error.message === 'Network stopped'
61-
);
62-
}
63-
6438
type RemoteHandleConstructorProps = {
6539
remoteId: RemoteId;
6640
peerId: string;
@@ -448,7 +422,7 @@ export class RemoteHandle implements EndpointHandle {
448422
* Retransmit all pending messages.
449423
*
450424
* Sends sequentially so a peer-restart detection during the first send can
451-
* short-circuit the rest: clearRemoteSeqState (called by handlePeerRestart)
425+
* short-circuit the rest: clearRemoteSeqState (called by persistPeerRestart)
452426
* deletes both the seq counters and the `remotePending.*` payloads, so
453427
* `getPendingMessage` returns undefined for subsequent iterations and the
454428
* loop bound (`seq <= this.#nextSendSeq`, now 0) terminates immediately.
@@ -495,12 +469,17 @@ export class RemoteHandle implements EndpointHandle {
495469
}
496470

497471
/**
498-
* Discard all pending messages due to delivery failure.
472+
* Discard all pending messages due to delivery failure. Safe no-op when
473+
* the queue is already empty — guards against clobbering kv state that a
474+
* prior cleanup (e.g. {@link persistPeerRestart}) already cleared.
499475
*
500476
* @param reason - The reason for failure.
501477
*/
502478
#rejectAllPending(reason: string): void {
503479
const pendingCount = this.#getPendingCount();
480+
if (pendingCount === 0) {
481+
return;
482+
}
504483
for (let i = 0; i < pendingCount; i += 1) {
505484
this.#logger.warn(
506485
`Message ${this.#startSeq + i} delivery failed: ${reason}`,
@@ -635,25 +614,34 @@ export class RemoteHandle implements EndpointHandle {
635614
this.#startAckTimeout();
636615
}
637616

638-
// Send the message (non-blocking - don't wait for ACK)
617+
// Send the message (non-blocking - don't wait for ACK).
618+
//
619+
// Terminal verdicts from the transport (peer restarted, intentional
620+
// close, network stopped) mean the message we just persisted will
621+
// never be delivered as-is: reject all pending now and signal give-up
622+
// rather than letting the message linger until ACK timeout × MAX_RETRIES.
623+
//
624+
// For PeerRestartedError specifically, the kernel-side
625+
// `onIncarnationChange` callback ran `persistPeerRestart` and
626+
// `finalizePeerRestart` synchronously *before* the transport threw, so
627+
// most of the cleanup below is already a no-op. The guards inside
628+
// `#rejectAllPending` and `rejectPendingRedemptions` keep this safe;
629+
// calling `#onGiveUp` again exercises an idempotent path.
639630
this.#remoteComms
640631
.sendRemoteMessage(this.#peerId, messageString)
641632
.catch((error) => {
642-
// Handle intentional close errors specially - reject pending redemptions
643-
if (
644-
error instanceof Error &&
645-
error.message.includes('intentional close')
646-
) {
633+
if (isTerminalSendError(error)) {
634+
const reason = (error as Error).message;
647635
this.#clearAckTimeout();
648-
this.#rejectAllPending('intentional close');
649-
this.rejectPendingRedemptions(
650-
'Message delivery failed after intentional close',
651-
);
652-
// Notify RemoteManager to reject kernel promises for this remote
636+
this.#rejectAllPending(reason);
637+
this.rejectPendingRedemptions(reason);
653638
this.#onGiveUp?.(this.#peerId);
654639
return;
655640
}
656-
this.#logger.error('Error sending remote message:', error);
641+
this.#logger.error(
642+
`${this.#peerId.slice(0, 8)}:: error sending remote message seq=${seq}:`,
643+
error,
644+
);
657645
});
658646
}
659647

@@ -1164,61 +1152,55 @@ export class RemoteHandle implements EndpointHandle {
11641152
}
11651153

11661154
/**
1167-
* Handle a peer restart (incarnation change).
1168-
*
1169-
* Persisted writes happen first so that if `forgetEndpointImports` throws
1170-
* (e.g. corrupt c-list entry, refcount underflow), the caller's savepoint
1171-
* can roll back kv state and our in-memory fields stay consistent with
1172-
* the persisted view. Only after the kv writes succeed do we mutate
1173-
* in-memory counters, cancel timers, and reject pending URL redemption
1174-
* promises — those mutations are not reversible by a savepoint, so we
1175-
* defer them until we know the persisted side committed.
1155+
* Persist the peer-restart side effects (kv-only). Reversible by the
1156+
* caller's savepoint: if `forgetEndpointImports` throws (e.g. corrupt
1157+
* c-list entry, refcount underflow) and the caller rolls back, no
1158+
* in-memory state has been disturbed.
11761159
*
1177-
* Called when the handshake detects that the remote peer has restarted.
1160+
* Pairs with {@link finalizePeerRestart}, which the caller MUST invoke
1161+
* exactly once after `releaseSavepoint` succeeds. Splitting the work
1162+
* keeps non-reversible mutations (timers, rejected redemption promises,
1163+
* in-memory seq counters) out of the savepoint window — those would
1164+
* leave the in-memory view inconsistent with the persisted view if the
1165+
* kv layer rolled back.
11781166
*/
1179-
handlePeerRestart(): void {
1167+
persistPeerRestart(): void {
11801168
this.#logger.log(
11811169
`${this.#peerId.slice(0, 8)}:: handling peer restart, resetting state`,
11821170
);
1183-
1184-
// Snapshot pending state before any mutation, so we can log accurately
1185-
// even though the KV side will be cleared shortly.
1186-
const pendingCount = this.#getPendingCount();
1187-
const hadPending = this.#hasPendingMessages();
1188-
1189-
// --- Persisted writes (transactional with the caller's savepoint) ---
1190-
1191-
// Clear persisted sequence state (counters + remotePending.* payloads).
11921171
this.#kernelStore.clearRemoteSeqState(this.remoteId);
1193-
1194-
// Drop the peer's contributions to our c-list (erefs they allocated)
1195-
// along with the owner / refcount / decider bookkeeping the kernel was
1196-
// holding on their behalf. Without this, fresh incarnations' reused eref
1197-
// labels would route into stale kernel state — e.g. an already-resolved
1198-
// promise from before the restart — and dead kernel objects would never
1199-
// be GC'd. May throw on corrupt state; the caller's savepoint will roll
1200-
// back the clearRemoteSeqState above and we will not have mutated any
1201-
// in-memory fields yet.
12021172
this.#kernelStore.forgetEndpointImports(this.remoteId);
1173+
}
12031174

1204-
// --- In-memory state (only after persisted writes commit) ---
1205-
1206-
// Cancel timers.
1207-
this.#clearAckTimeout();
1208-
this.#clearDelayedAck();
1175+
/**
1176+
* Convenience wrapper that runs the persisted and in-memory phases
1177+
* back-to-back. Use only outside a savepoint window — transactional
1178+
* callers (e.g. {@link RemoteManager.handleIncarnationChange}) must call
1179+
* {@link persistPeerRestart} inside the savepoint and
1180+
* {@link finalizePeerRestart} after release, so a kv rollback can't
1181+
* leave the in-memory view inconsistent with the persisted view.
1182+
*/
1183+
handlePeerRestart(): void {
1184+
this.persistPeerRestart();
1185+
this.finalizePeerRestart();
1186+
}
12091187

1210-
// Reject in-memory URL redemption promises (cannot be undone, so they
1211-
// run after persistence). Pending messages are tracked entirely in KV
1212-
// and have no JS-level promises to reject — clearRemoteSeqState above
1213-
// already removed their persisted records.
1214-
if (hadPending) {
1188+
/**
1189+
* Apply the in-memory side of a peer restart: cancel timers, reject
1190+
* in-flight URL redemption promises, and reset sequence counters. Must
1191+
* be called after {@link persistPeerRestart} and after the caller's
1192+
* savepoint has been released.
1193+
*/
1194+
finalizePeerRestart(): void {
1195+
const pendingCount = this.#getPendingCount();
1196+
if (this.#hasPendingMessages()) {
12151197
this.#logger.log(
12161198
`${this.#peerId.slice(0, 8)}:: discarding ${pendingCount} pending messages due to peer restart`,
12171199
);
12181200
}
1201+
this.#clearAckTimeout();
1202+
this.#clearDelayedAck();
12191203
this.rejectPendingRedemptions('Remote peer restarted');
1220-
1221-
// Reset in-memory sequence counters and flags for fresh start.
12221204
this.#nextSendSeq = 0;
12231205
this.#highestReceivedSeq = 0;
12241206
this.#startSeq = 0;

0 commit comments

Comments
 (0)