Skip to content

Commit af5ecf2

Browse files
connor4312Copilot
andcommitted
Agent Host: show reconnect backoff and offer a manual retry
While a protocol client waits out its exponential backoff the banner now reads "Reconnecting to <host> in 5s" and counts down, with a Try Now action that skips the remaining delay. Try Now accelerates the client's in-place retry rather than redialling, so the outbox and session state survive. It falls back to a fresh dial only when there is no client to accelerate, which happens now that a rejected factory retains a client-less entry. The backoff deadline travels on the `reconnecting` status. The client stays in that state across rounds, so the deadline is refreshed through a dedicated `onDidScheduleReconnect` event rather than by re-firing the connection-state event: consumers of that event do real work per transition, and repeating it each round would have unclear blast radius. Also offers a Retry action on the generic "Cannot reach <host>" state, on both the banner and the centered recovery surface. A tunnel that dies is usually transient. This stays manual: unlike a stopped WSL distro there is nothing local to start, so retrying automatically would only hammer an unreachable endpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2c3303b commit af5ecf2

11 files changed

Lines changed: 381 additions & 9 deletions

File tree

src/vs/platform/agentHost/browser/agentHostProtocolClient.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ interface IReconnectState {
156156
attempt: number;
157157
/** Timer for the next scheduled attempt, if any. */
158158
timeoutHandle: ReturnType<typeof setTimeout> | undefined;
159+
/** Deadline for the next scheduled attempt, if any. */
160+
nextAttemptAt: number | undefined;
159161
}
160162

161163
/**
@@ -256,6 +258,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
256258

257259
private readonly _onDidChangeConnectionState = this._register(new Emitter<AgentHostClientState>());
258260
readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event;
261+
private readonly _onDidScheduleReconnect = this._register(new Emitter<void>());
262+
readonly onDidScheduleReconnect = this._onDidScheduleReconnect.event;
259263

260264
/**
261265
* Discriminated state union. Read via narrowing (`_state.kind === ...`);
@@ -329,6 +333,13 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
329333
return this._state.kind;
330334
}
331335

336+
/** Deadline for the next scheduled reconnect attempt, if one is pending. */
337+
get nextReconnectAt(): number | undefined {
338+
return this._state.kind === AgentHostClientState.Reconnecting
339+
? this._state.reconnect.nextAttemptAt
340+
: undefined;
341+
}
342+
332343
/**
333344
* The latest `initialize` response from the host, or `undefined` if
334345
* the handshake has not completed yet. Exposed observably so callers can
@@ -456,6 +467,14 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
456467
if (this._state.kind === next.kind) {
457468
return;
458469
}
470+
if (this._state.kind === AgentHostClientState.Reconnecting) {
471+
const reconnect = this._state.reconnect;
472+
if (reconnect.timeoutHandle !== undefined) {
473+
clearTimeout(reconnect.timeoutHandle);
474+
reconnect.timeoutHandle = undefined;
475+
}
476+
reconnect.nextAttemptAt = undefined;
477+
}
459478
this._state = next;
460479
this._onDidChangeConnectionState.fire(next.kind);
461480
}
@@ -470,7 +489,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
470489
}
471490

472491
private _newReconnectState(): IReconnectState {
473-
return { gate: this._newReconnectGate(), outbox: [], attempt: 0, timeoutHandle: undefined };
492+
return { gate: this._newReconnectGate(), outbox: [], attempt: 0, timeoutHandle: undefined, nextAttemptAt: undefined };
474493
}
475494

476495
override dispose(): void {
@@ -663,6 +682,24 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
663682
return true;
664683
}
665684

685+
/**
686+
* Skips the remaining backoff and retries immediately. Returns `false` when
687+
* there is no pending retry to accelerate.
688+
*/
689+
reconnectNow(): boolean {
690+
if (this._state.kind !== AgentHostClientState.Reconnecting || this._state.reconnect.timeoutHandle === undefined) {
691+
return false;
692+
}
693+
const reconnect = this._state.reconnect;
694+
clearTimeout(reconnect.timeoutHandle);
695+
reconnect.timeoutHandle = undefined;
696+
reconnect.nextAttemptAt = undefined;
697+
reconnect.attempt = 0;
698+
this._onDidScheduleReconnect.fire();
699+
void this._attemptReconnect();
700+
return true;
701+
}
702+
666703
private _scheduleReconnect(userInitiated = false): void {
667704
if (this._state.kind !== AgentHostClientState.Reconnecting || !this._transportFactory) {
668705
return;
@@ -686,12 +723,15 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
686723
const attempt = reconnect.attempt + 1;
687724
const delay = computeReconnectDelay(this._reconnectPolicy, attempt);
688725
this._logService.info(`[RemoteAgentHostProtocol] Reconnecting to ${this._address} in ${delay}ms (attempt ${attempt}).`);
726+
reconnect.nextAttemptAt = Date.now() + delay;
689727
reconnect.timeoutHandle = setTimeout(() => {
690728
if (this._state.kind === AgentHostClientState.Reconnecting) {
691729
this._state.reconnect.timeoutHandle = undefined;
730+
this._state.reconnect.nextAttemptAt = undefined;
692731
}
693732
void this._attemptReconnect();
694733
}, delay);
734+
this._onDidScheduleReconnect.fire();
695735
}
696736

697737
private async _attemptReconnect(): Promise<void> {

src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,17 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
365365
void this._connectTo(entryToReconnect, { userInitiated });
366366
}
367367

368+
/**
369+
* Skips a protocol client's pending backoff, or starts a fresh user-initiated dial.
370+
*/
371+
reconnectNow(address: string): void {
372+
const normalized = normalizeRemoteAgentHostAddress(address);
373+
if (this._entries.get(normalized)?.client?.reconnectNow()) {
374+
return;
375+
}
376+
this.reconnect(normalized, true);
377+
}
378+
368379
async waitForConnection(address: string): Promise<IRemoteAgentHostConnectionInfo> {
369380
if (this._store.isDisposed) {
370381
throw new Error('Remote agent host service is disposed.');
@@ -650,14 +661,23 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
650661

651662
// Surface self-healing transport drops separately so outer reconnect
652663
// loops do not replace the protocol client while it restores itself.
664+
store.add(client.onDidScheduleReconnect(() => {
665+
// The client stays `reconnecting` across backoff rounds, so only this
666+
// event reports that the deadline moved.
667+
if (!isCurrentEntry() || entry.status.kind !== 'reconnecting') {
668+
return;
669+
}
670+
entry.status = RemoteAgentHostConnectionStatus.reconnectingUntil(client.nextReconnectAt);
671+
this._onDidChangeConnections.fire();
672+
}));
653673
store.add(client.onDidChangeConnectionState(state => {
654674
if (!isCurrentEntry()) {
655675
return;
656676
}
657677
switch (state) {
658678
case 'reconnecting':
659679
entry.connected = false;
660-
entry.status = RemoteAgentHostConnectionStatus.reconnecting;
680+
entry.status = RemoteAgentHostConnectionStatus.reconnectingUntil(client.nextReconnectAt);
661681
this._onDidChangeConnections.fire();
662682
break;
663683
case 'connected':

src/vs/platform/agentHost/common/remoteAgentHostService.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ export type RemoteAgentHostConnectionStatus =
3636
* preserving session state. Distinct from `connecting` (initial dial) and
3737
* `disconnected` (no connection, nothing in flight).
3838
*/
39-
| { readonly kind: 'reconnecting' }
39+
| {
40+
readonly kind: 'reconnecting';
41+
/** When the next automatic attempt fires, if one is scheduled. Absent while an attempt is in flight. */
42+
readonly nextAttemptAt?: number;
43+
}
4044
| { readonly kind: 'disconnected'; readonly reason: AgentHostTransportFailureReason }
4145
| {
4246
readonly kind: 'incompatible';
@@ -62,6 +66,12 @@ export namespace RemoteAgentHostConnectionStatus {
6266
export const connecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'connecting' });
6367
/** Singleton "reconnecting" status. */
6468
export const reconnecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'reconnecting' });
69+
/** Build a reconnecting status carrying its backoff deadline. */
70+
export function reconnectingUntil(nextAttemptAt: number | undefined): RemoteAgentHostConnectionStatus {
71+
return nextAttemptAt === undefined
72+
? reconnecting
73+
: Object.freeze({ kind: 'reconnecting', nextAttemptAt });
74+
}
6575
/** Singleton "disconnected" status. */
6676
export const disconnected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'disconnected', reason: AgentHostTransportFailureReason.Unknown });
6777
/** Build a disconnected status with a machine-readable reason. */
@@ -250,9 +260,20 @@ export type RemoteAgentHostProtocolClientState = 'connecting' | 'incompatible' |
250260
*/
251261
export interface IRemoteAgentHostProtocolClient extends IAgentConnection, IDisposable {
252262
readonly defaultDirectory: string | undefined;
263+
/** Deadline for the next scheduled reconnect attempt, if one is pending. */
264+
readonly nextReconnectAt: number | undefined;
253265
readonly onDidClose: Event<AgentHostTransportFailureReason | undefined>;
254266
readonly onDidChangeConnectionState: Event<RemoteAgentHostProtocolClientState>;
267+
/**
268+
* Fires whenever the pending reconnect schedule changes — a backoff being
269+
* armed, or cleared by an immediate retry. Separate from
270+
* {@link onDidChangeConnectionState} because the client state is still
271+
* `reconnecting` throughout, and consumers of that event do real work on
272+
* each transition that must not be repeated per backoff round.
273+
*/
274+
readonly onDidScheduleReconnect: Event<void>;
255275
connect(): Promise<void>;
276+
reconnectNow(): boolean;
256277
notifyTransportClosed(): void;
257278
triggerVscodeUpgrade(method: string): Promise<IVscodeUpgradeResult>;
258279
}
@@ -711,6 +732,12 @@ export interface IRemoteAgentHostService {
711732
* with reset backoff.
712733
*/
713734
reconnect(address: string, userInitiated?: boolean): void;
735+
/**
736+
* Skips a pending reconnect backoff for this address and retries at once.
737+
* Prefers the protocol client's in-place retry, which preserves session
738+
* state, and falls back to a fresh dial when there is no client to accelerate.
739+
*/
740+
reconnectNow(address: string): void;
714741

715742
/**
716743
* Force the protocol client at `address` (if any) to treat its
@@ -775,6 +802,7 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService {
775802
}
776803
async removeRemoteAgentHost(_address: string): Promise<void> { }
777804
reconnect(_address: string, _userInitiated?: boolean): void { }
805+
reconnectNow(_address: string): void { }
778806
notifyConnectionClosed(_address: string): void { }
779807
getEntryByAddress(): IRemoteAgentHostEntry | undefined { return undefined; }
780808
async triggerServerUpgrade(): Promise<IVscodeUpgradeResult> {

src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2439,6 +2439,74 @@ suite('AgentHostProtocolClient', () => {
24392439
});
24402440
});
24412441

2442+
test('reports the deadline for each scheduled reconnect backoff', async function () {
2443+
this.timeout(10_000);
2444+
return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => {
2445+
const reconnectPolicy: IRemoteAgentHostReconnectPolicy = {
2446+
autoRestore: true,
2447+
initialDelayMs: 60_000,
2448+
maxDelayMs: 60_000,
2449+
maxAttempts: 3,
2450+
};
2451+
const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy);
2452+
const connectPromise = client.connect();
2453+
await completeHandshake(transports[0], connectPromise);
2454+
const reconnectDeadlines: (number | undefined)[] = [];
2455+
const stateListener = client.onDidChangeConnectionState(state => {
2456+
if (state === AgentHostClientState.Reconnecting) {
2457+
reconnectDeadlines.push(client.nextReconnectAt);
2458+
}
2459+
});
2460+
2461+
transports[0].fireClose();
2462+
const firstDeadline = client.nextReconnectAt;
2463+
assert.ok(firstDeadline !== undefined);
2464+
await timeout(reconnectPolicy.initialDelayMs);
2465+
transports[1].connectDeferred.error(new Error('reconnect failed'));
2466+
await flushMicrotasks();
2467+
const secondDeadline = client.nextReconnectAt;
2468+
assert.ok(secondDeadline !== undefined);
2469+
2470+
assert.deepStrictEqual(reconnectDeadlines, [undefined, firstDeadline, secondDeadline]);
2471+
stateListener.dispose();
2472+
client.dispose();
2473+
});
2474+
});
2475+
2476+
test('reconnectNow clears a pending backoff and retries immediately', async function () {
2477+
this.timeout(10_000);
2478+
return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => {
2479+
const reconnectPolicy: IRemoteAgentHostReconnectPolicy = {
2480+
autoRestore: true,
2481+
initialDelayMs: 60_000,
2482+
maxDelayMs: 60_000,
2483+
maxAttempts: 3,
2484+
};
2485+
const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy);
2486+
const connectPromise = client.connect();
2487+
await completeHandshake(transports[0], connectPromise);
2488+
2489+
transports[0].fireClose();
2490+
assert.strictEqual(client.reconnectNow(), true);
2491+
await timeout(reconnectPolicy.initialDelayMs - 1);
2492+
2493+
assert.deepStrictEqual({
2494+
nextReconnectAt: client.nextReconnectAt,
2495+
transportCount: transports.length,
2496+
}, {
2497+
nextReconnectAt: undefined,
2498+
transportCount: 2,
2499+
});
2500+
client.dispose();
2501+
});
2502+
});
2503+
2504+
test('reconnectNow returns false when no reconnect backoff is pending', () => {
2505+
const { client } = createFactoryClient();
2506+
2507+
assert.strictEqual(client.reconnectNow(), false);
2508+
});
2509+
24422510
test('does not automatically reconnect when the policy disables automatic restore', async () => {
24432511
const reconnectPolicy: IRemoteAgentHostReconnectPolicy = {
24442512
autoRestore: false,

0 commit comments

Comments
 (0)