Skip to content

Commit 74bb1b3

Browse files
committed
fix(peer): retain cold-start recovery
Keep persisted Direct targets in the reconnect lifecycle when the first route attempt fails transiently, so later route evidence can revive them without user intervention. Preserve cancellation and permanent-failure boundaries. Generated-by: Codex (gpt-5.6-sol)
1 parent f9fe8c3 commit 74bb1b3

6 files changed

Lines changed: 180 additions & 12 deletions

File tree

apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,63 @@ test('keeps Local explicitly usable without routing default work away from an un
10181018
await manager.close();
10191019
});
10201020

1021+
test('keeps an initially unavailable Direct target live and wakes it on new routes', async () => {
1022+
const local = candidateHarness();
1023+
const remote = candidateHarness({ hostId: 'a'.repeat(64), ownership: 'external' });
1024+
let starts = 0;
1025+
let routeListener: (() => void) | undefined;
1026+
let reportBackoff!: () => void;
1027+
const waitingForBackoff = new Promise<void>((resolve) => {
1028+
reportBackoff = resolve;
1029+
});
1030+
const manager = await startRuntimeHostDesktopManager(
1031+
{
1032+
peerClient: {
1033+
subscribeRoutes: (peerId: string, listener: () => void) => {
1034+
assert.equal(peerId, '12D3KooWpeer');
1035+
routeListener = listener;
1036+
return () => undefined;
1037+
},
1038+
},
1039+
} as DesktopRuntimeHostCandidateStartInput,
1040+
{
1041+
startCandidate: async () => {
1042+
starts += 1;
1043+
if (starts === 1) return ready(local.candidate);
1044+
if (starts <= 3) return { kind: 'failed', reason: 'host_unresponsive' };
1045+
return ready(remote.candidate);
1046+
},
1047+
reconnectBackoff: {
1048+
minMs: 30_000,
1049+
maxMs: 30_000,
1050+
wait: (_delayMs, signal) =>
1051+
new Promise<void>((_resolve, reject) => {
1052+
reportBackoff();
1053+
const onAbort = () => reject(signal.reason);
1054+
signal.addEventListener('abort', onAbort, { once: true });
1055+
if (signal.aborted) onAbort();
1056+
}),
1057+
},
1058+
},
1059+
);
1060+
1061+
await manager.enable(peerTarget('office'));
1062+
await waitingForBackoff;
1063+
assert.equal(
1064+
manager.entries().find((state) => state.target.profile.id === 'office')?.readiness,
1065+
'reconnecting',
1066+
);
1067+
assert.equal(manager.current('office')?.readiness, 'reconnecting');
1068+
assert.equal(manager.current('office')?.candidate, undefined);
1069+
assert.ok(routeListener);
1070+
1071+
routeListener();
1072+
await manager.waitUntilReady('office');
1073+
assert.equal(manager.current('office')?.candidate, remote.candidate);
1074+
assert.equal(starts, 4);
1075+
await manager.close();
1076+
});
1077+
10211078
test('keeps reconnecting through transient startup failures until the Desktop adapter is restored', async () => {
10221079
const first = candidateHarness();
10231080
const replacement = candidateHarness();
@@ -1682,6 +1739,13 @@ function remoteTarget(
16821739

16831740
function peerGuestTarget(
16841741
id: string,
1742+
): NonNullable<DesktopRuntimeHostCandidateStartInput['profileTarget']> {
1743+
return peerTarget(id, 'session_guest');
1744+
}
1745+
1746+
function peerTarget(
1747+
id: string,
1748+
access?: 'session_guest',
16851749
): NonNullable<DesktopRuntimeHostCandidateStartInput['profileTarget']> {
16861750
return {
16871751
profile: {
@@ -1693,7 +1757,7 @@ function peerGuestTarget(
16931757
reachability: testPeerReachability('12D3KooWpeer'),
16941758
},
16951759
rootId: 'a'.repeat(64),
1696-
access: 'session_guest',
1760+
...(access ? { access } : {}),
16971761
},
16981762
credential: 'credential-peer',
16991763
};

apps/desktop/src/main/runtime-host-desktop-manager.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -853,17 +853,24 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
853853
initialSignal?: AbortSignal,
854854
): Promise<RuntimeHostReconnectLifecycle<DesktopRuntimeHostCandidate>> {
855855
let starting = true;
856+
let initialAttempt = true;
857+
const retryInitialFailure =
858+
target.target.profile.kind === 'remote' &&
859+
target.target.profile.transport.kind === 'libp2p-direct';
856860
try {
857861
return await startRuntimeHostReconnectLifecycle({
858-
connect: (signal) =>
859-
this.connect(
862+
connect: (signal) => {
863+
const first = initialAttempt;
864+
initialAttempt = false;
865+
return this.connect(
860866
target,
861-
starting && initialSignal
862-
? AbortSignal.any([signal, initialSignal])
863-
: signal,
864-
starting ? target.input.profileTarget?.sshInteraction : 'batch',
865-
starting ? target.input.onConnectionPhase : undefined,
866-
),
867+
signal,
868+
first ? target.input.profileTarget?.sshInteraction : 'batch',
869+
first ? target.input.onConnectionPhase : undefined,
870+
);
871+
},
872+
retryInitialFailure,
873+
...(initialSignal ? { initialSignal } : {}),
867874
onReconnectError: (error) => {
868875
console.warn('[runtime-host] reconnect attempt failed:', error);
869876
},

docs/architecture/peer-reachability-recovery-plan.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,8 @@ charter. Their evidence is adjudicated into this ledger:
318318
| R11-C1 | correctness | confirmed | Reachability and advertisement anti-entropy now summarize each signed fact as `{ peerId, revision, digest }`, where the digest binds the canonical signed payload. Equal-revision disagreement is rejected during summary comparison instead of remaining silently partitioned between replicas. |
319319
| R11-C2 | correctness | confirmed | Each WebRTC Relay upgrade now has its own fenced identity and child cancellation token. Removing its Relay retires the active attempt, a replacement can start in the same connection attempt, and a late success is closed unless both its identity and Relay membership are still current. |
320320
| R12-C1 | correctness | confirmed | The WebRTC Relay-attempt identity now survives through the libp2p dial lifecycle. Candidate replacement retires and closes stale dials, an established connection is admitted only for the exact current attempt, and a late terminal event from an old dial cannot clear its replacement. |
321+
| R13-C1 | correctness | confirmed | A persisted Direct target now keeps one live reconnect lifecycle when its first connection fails transiently. Route and resume wakeups can therefore recover the same target after a cold-start ordering gap; identity, credential, compatibility, caller cancellation, and other permanent failures still terminate it. `needs_repair` is recoverable state rather than a permanent reconnect verdict. |
322+
| R13-S1 | simplification | rejected | The active WebRTC attempt is the logical flow identity, while the dial origin maps an external libp2p `ConnectionId` back to that identity. Replacing the explicit relation with phase-dependent map scans does not remove a state owner and weakens the clarity of the remove/re-add Relay ABA fence. |
321323

322324
Only findings that affect the merge bar and have a proportionate root fix enter the
323325
stack. Narrow constructed paths and low-value polish do not. A local fix triggers a

packages/runtime-host/src/__tests__/reconnecting-connection.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,72 @@ test('new reachability evidence wakes an existing reconnect loop without startin
508508
await lifecycle.close();
509509
});
510510

511+
test('an initial transient failure keeps a wakeable reconnect lifecycle', async () => {
512+
const replacement = connectionHarness('replacement', () => undefined);
513+
const waitingForBackoff = deferred();
514+
const failures: Error[] = [];
515+
let connectCalls = 0;
516+
const lifecycle = await startRuntimeHostReconnectLifecycle({
517+
retryInitialFailure: true,
518+
connect: async () => {
519+
connectCalls += 1;
520+
if (connectCalls <= 2) throw new Error('the peer is not reachable yet');
521+
return replacement.connection;
522+
},
523+
onReconnectError: (error) => failures.push(error),
524+
backoff: {
525+
minMs: 30_000,
526+
maxMs: 30_000,
527+
wait: (_delayMs, signal) =>
528+
new Promise<void>((_resolve, reject) => {
529+
waitingForBackoff.resolve();
530+
const onAbort = () => reject(signal.reason);
531+
signal.addEventListener('abort', onAbort, { once: true });
532+
if (signal.aborted) onAbort();
533+
}),
534+
},
535+
});
536+
try {
537+
assert.equal(lifecycle.current, undefined);
538+
assert.equal(connectCalls, 2);
539+
assert.match(failures[0]?.message ?? '', /not reachable yet/u);
540+
await waitingForBackoff.promise;
541+
542+
lifecycle.wake();
543+
assert.equal(await lifecycle.waitForCurrent(), replacement.connection);
544+
assert.equal(connectCalls, 3);
545+
} finally {
546+
await lifecycle.close();
547+
}
548+
});
549+
550+
test('initial retry mode does not outlive caller cancellation', async () => {
551+
const controller = new AbortController();
552+
const connecting = deferred();
553+
const cancelled = new Error('initial connection cancelled');
554+
let connectCalls = 0;
555+
const starting = startRuntimeHostReconnectLifecycle({
556+
retryInitialFailure: true,
557+
initialSignal: controller.signal,
558+
connect: async (signal): Promise<RuntimeHostConnection> => {
559+
connectCalls += 1;
560+
connecting.resolve();
561+
return new Promise<never>((_resolve, reject) => {
562+
const onAbort = () => reject(signal.reason);
563+
signal.addEventListener('abort', onAbort, { once: true });
564+
if (signal.aborted) onAbort();
565+
});
566+
},
567+
backoff: { minMs: 0, maxMs: 0 },
568+
});
569+
570+
await connecting.promise;
571+
controller.abort(cancelled);
572+
await assert.rejects(starting, (error: unknown) => error === cancelled);
573+
await yieldToEventLoop();
574+
assert.equal(connectCalls, 1);
575+
});
576+
511577
test('reachability discovered during a failed attempt skips the next reconnect delay', async () => {
512578
const first = connectionHarness('first', () => undefined);
513579
const replacement = connectionHarness('replacement', () => undefined);

packages/runtime-host/src/client/peer-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export interface RuntimeHostPeerRouteResolver {
7373
subscribeRoutes(peerId: string, listener: () => void): () => void;
7474
}
7575

76-
export class RuntimeHostPeerReachabilityUnavailableError extends RuntimeHostPermanentReconnectError {
76+
export class RuntimeHostPeerReachabilityUnavailableError extends Error {
7777
readonly code = 'peer_reachability_needs_repair';
7878

7979
constructor(peerId: string) {

packages/runtime-host/src/client/reconnect-lifecycle.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export async function startRuntimeHostReconnectLifecycle<
7777
>(input: {
7878
readonly initial?: T;
7979
readonly connect: (signal: AbortSignal) => Promise<T>;
80+
readonly retryInitialFailure?: boolean;
81+
readonly initialSignal?: AbortSignal;
8082
readonly onReconnectError?: (error: Error) => void;
8183
readonly onFatalError?: (error: Error) => void;
8284
readonly backoff?: RuntimeHostReconnectBackoff;
@@ -99,6 +101,8 @@ class RuntimeHostReconnectLifecycleImpl<T extends RuntimeHostReconnectResource>
99101
readonly closed: Promise<void>;
100102
readonly #initial: T | undefined;
101103
readonly #connect: (signal: AbortSignal) => Promise<T>;
104+
readonly #retryInitialFailure: boolean;
105+
readonly #initialSignal: AbortSignal | undefined;
102106
readonly #onReconnectError: ((error: Error) => void) | undefined;
103107
readonly #onFatalError: ((error: Error) => void) | undefined;
104108
readonly #minMs: number;
@@ -128,12 +132,16 @@ class RuntimeHostReconnectLifecycleImpl<T extends RuntimeHostReconnectResource>
128132
constructor(input: {
129133
readonly initial?: T;
130134
readonly connect: (signal: AbortSignal) => Promise<T>;
135+
readonly retryInitialFailure?: boolean;
136+
readonly initialSignal?: AbortSignal;
131137
readonly onReconnectError?: (error: Error) => void;
132138
readonly onFatalError?: (error: Error) => void;
133139
readonly backoff?: RuntimeHostReconnectBackoff;
134140
}) {
135141
this.#connect = input.connect;
136142
this.#initial = input.initial;
143+
this.#retryInitialFailure = input.retryInitialFailure ?? false;
144+
this.#initialSignal = input.initialSignal;
137145
this.#onReconnectError = input.onReconnectError;
138146
this.#onFatalError = input.onFatalError;
139147
this.#minMs = requireDelay(input.backoff?.minMs ?? DEFAULT_BACKOFF_MIN_MS, 'minMs');
@@ -165,9 +173,30 @@ class RuntimeHostReconnectLifecycleImpl<T extends RuntimeHostReconnectResource>
165173

166174
async start(): Promise<void> {
167175
try {
168-
this.#install(this.#initial ?? (await this.#connect(this.#abort.signal)));
176+
if (this.#initial) {
177+
this.#install(this.#initial);
178+
return;
179+
}
180+
this.#initialSignal?.throwIfAborted();
181+
const signal = this.#initialSignal
182+
? AbortSignal.any([this.#abort.signal, this.#initialSignal])
183+
: this.#abort.signal;
184+
this.#install(await this.#connect(signal));
169185
} catch (error) {
170-
this.#failPermanently(asError(error));
186+
const failure = asError(error);
187+
if (
188+
this.#retryInitialFailure &&
189+
!this.#closed &&
190+
!this.#abort.signal.aborted &&
191+
!this.#initialSignal?.aborted &&
192+
!(failure instanceof RuntimeHostPermanentReconnectError)
193+
) {
194+
this.#failureCount += 1;
195+
notifyError(this.#onReconnectError, failure);
196+
this.#scheduleReconnect();
197+
return;
198+
}
199+
this.#failPermanently(failure);
171200
throw error;
172201
}
173202
}

0 commit comments

Comments
 (0)