Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Decoupling these allows copilot sessions from different providers (local CLI, re
- SSH config host connections use resolved `IdentityFile` and `IdentityAgent` values from `ssh -G`; encrypted private keys are prompted for a passphrase through the same quick-input bridge as keyboard-interactive auth.
- Startup SSH auto-reconnect treats keyboard-interactive cancellation as an intentional pause and does not schedule another reconnect attempt. Host key denial pauses until an explicit reconnect so background retries cannot repeatedly reject a key that requires user review.
- A manual SSH reconnect from the host picker bypasses that paused auto-reconnect state and starts a fresh reconnect attempt for stored SSH hosts; host-picker disconnect/cancel for SSH uses the SSH service instead of removing the stored host.
- Tunnel auto-reconnect preserves why each host paused: focus or browser network recovery resumes only exhausted retry budgets, authentication additions resume only authentication pauses, and an offline host resumes only after discovery confirms it is online. Merely focusing the window never attempts an untracked or known-offline cached tunnel.
- `vscodeAgents.sshConnect/attempt` records each complete SSH plus AHP initialization attempt from the initial connection and stored-host reconnect paths, with connect/reconnect, user-initiated, attempt number, duration, success, retry intent, and a bounded failure category. It never records host names, addresses, aliases, or raw error messages.
- VS Code remote transports declare their route in AHP initialize metadata (`dev_tunnel`, `ssh`, `wsl`, `remote_extension_host`, `direct_websocket`, or `web_pub_sub`). Agent Host product telemetry combines that declaration with the host-observed physical transport and launcher kind; message telemetry retains the initiating client id and route.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const RECONNECT_MAX_ATTEMPTS = 10;
/** Minimum gap between event-triggered reconnect resumes. */
const RESUME_RATE_LIMIT_MS = 10_000;

type TunnelReconnectTrigger = 'wake' | 'focus' | 'sessionAdded';

export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution {

static readonly ID = 'sessions.contrib.tunnelAgentHostContribution';
Expand All @@ -63,16 +65,16 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
private readonly _reconnectTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
/** Consecutive failed auto-reconnect attempts per address. */
private readonly _reconnectAttempts = new Map<string, number>();
/** Addresses whose auto-reconnect loop has paused after too many failures. */
private readonly _reconnectPaused = new Set<string>();
/** Why auto-reconnect is paused for each address. */
private readonly _reconnectPauseReasons = new Map<string, TunnelConnectFailureReason>();
/**
* Addresses whose provider currently holds a live connection. Tracked
* separately from {@link _previousStatuses} so a drop is still detected when
* the connection passes through an intermediate `connecting` state on its
* way down.
*/
private readonly _wiredAddresses = new Set<string>();
/** Timestamp of the last wake-triggered resume, to rate-limit rapid tab toggles. */
/** Timestamp of the last focus/wake-triggered resume, to rate-limit rapid tab toggles. */
private _lastResumeAt = 0;

/**
Expand Down Expand Up @@ -616,7 +618,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
/** Clear retry-backoff and pause state for an address. */
private _clearReconnectBackoff(address: string): void {
this._reconnectAttempts.delete(address);
this._reconnectPaused.delete(address);
this._reconnectPauseReasons.delete(address);
}

/** Drop all reconnect + telemetry state for an address (e.g. on removal). */
Expand Down Expand Up @@ -665,10 +667,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
private _pauseReconnect(address: string, reason: TunnelConnectFailureReason): void {
this._cancelReconnect(address);
this._reconnectAttempts.delete(address);
this._reconnectPaused.add(address);
this._reconnectPauseReasons.set(address, reason);
const resumeCondition = reason === 'hostOffline'
? 'a status check that confirms the host is online'
: reason === 'auth' || reason === 'authExpired'
? 'an authentication session change'
: `${isWeb ? 'network-online or ' : ''}window focus`;
this._logService.info(
`[TunnelAgentHost] Pausing auto-reconnect for ${address} (${reason}); ` +
`will resume on ${isWeb ? 'network-online, ' : ''}window focus, session change, or a status check that confirms the host is online.`
`will resume on ${resumeCondition}.`
);
const session = this._connectSessions.get(address);
if (session) {
Expand Down Expand Up @@ -746,60 +753,60 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
}

/**
* Invoked on a browser network, window-focus, or authentication event. Kicks off an
* immediate attempt for any disconnected cached tunnel.
* Resume paused reconnects that the given recovery signal can resolve.
*
* Rate-limited: at most one resume per RESUME_RATE_LIMIT_MS so that
* rapid tab toggling can't hammer a permanently broken endpoint with
* an unbounded number of attempt bursts. Resumes the normal backoff
* sequence (by clearing the pause flag) rather than zeroing the
* attempt counter.
* rapid focus/network events cannot start unbounded retry bursts.
*/
private _resumeReconnects(trigger: 'wake' | 'focus' | 'sessionAdded'): void {
private _resumeReconnects(trigger: TunnelReconnectTrigger): void {
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
return;
}

// Rate-limit rapid recovery events (e.g. alt-tab bursts or
// flaky Wi-Fi toggling online/offline) so we don't hammer the relay
// with immediate retries. This is an event-smoothing gate, not an
// error-backoff — that's handled by `_scheduleReconnect`.
const now = Date.now();
if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) {
return;
}
this._lastResumeAt = now;

const cached = this._getProviderTunnels();
for (const tunnel of cached) {
const resumableAddresses: string[] = [];
for (const tunnel of this._getProviderTunnels()) {
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
if (this._pendingConnects.has(address)) {
const reason = this._reconnectPauseReasons.get(address);
if (!reason || !this._canResumeReconnect(reason, trigger) || this._pendingConnects.has(address)) {
continue;
}
const live = this._remoteAgentHostService.connections.find(c => c.address === address);
if (live && RemoteAgentHostConnectionStatus.isConnected(live.status)) {
continue;
const live = this._remoteAgentHostService.connections.find(connection => connection.address === address);
if (!live || !RemoteAgentHostConnectionStatus.isConnected(live.status)) {
resumableAddresses.push(address);
}
}
if (resumableAddresses.length === 0) {
return;
}

this._logService.info(`[TunnelAgentHost] Resuming reconnect for ${address} (trigger: ${trigger})`);
// If we were paused (exhausted the backoff budget), give a fresh
// budget since the wake event is itself evidence the environment
// has changed. Otherwise keep the current attempt counter so an
// in-progress backoff isn't short-circuited.
if (this._reconnectPaused.has(address)) {
this._clearReconnectBackoff(address);
if (trigger !== 'sessionAdded') {
const now = Date.now();
if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) {
return;
}
this._lastResumeAt = now;
}

for (const address of resumableAddresses) {
this._logService.info(`[TunnelAgentHost] Resuming reconnect for ${address} (trigger: ${trigger})`);
this._clearReconnectBackoff(address);
this._scheduleReconnect(address, /*immediate*/ true);
}
}

private _canResumeReconnect(reason: TunnelConnectFailureReason, trigger: TunnelReconnectTrigger): boolean {
return trigger === 'sessionAdded'
? reason === 'auth' || reason === 'authExpired'
: reason === 'maxAttemptsReached';
}

/** Drop reconnect state for addresses whose tunnel is no longer cached. */
private _pruneReconnectState(): void {
const cachedAddresses = new Set(this._getProviderTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`));
const tracked = new Set<string>([
...this._reconnectTimeouts.keys(),
...this._reconnectAttempts.keys(),
...this._reconnectPaused,
...this._reconnectPauseReasons.keys(),
...this._connectSessions.keys(),
]);
for (const address of tracked) {
Expand Down Expand Up @@ -896,7 +903,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
if (info && info.hostConnectionCount > 0) {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected);

if (this._reconnectPaused.has(address)) {
if (this._reconnectPauseReasons.get(address) === 'hostOffline') {
this._logService.info(
`[TunnelAgentHost] Confirmed host online for paused ${address}; auto-resuming reconnect.`
);
Expand All @@ -923,6 +930,9 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc
if (this._tunnelService.isAutoConnectSuppressed(tunnel.tunnelId)) {
continue;
}
if (this._reconnectPauseReasons.has(address)) {
continue;
}
const alreadyConnected = this._remoteAgentHostService.connections.some(
c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t
import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js';
import { IHostService } from '../../../../../../workbench/services/host/browser/host.js';
import { ITunnelHostService } from '../../../../../../workbench/contrib/chat/common/tunnelHost.js';
import type { TunnelConnectFailureReason } from '../../../../../common/sessionsTelemetry.js';
import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js';
import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js';
import { IAgentHostFilterService } from '../../../../../services/agentHostFilter/common/agentHostFilter.js';
Expand Down Expand Up @@ -385,7 +386,7 @@ suite('TunnelAgentHostContribution', () => {
);
});

test('resumes a max-attempts pause on focus and rate-limits repeated focus changes', () => {
test('recovery signals resume only compatible pause reasons', () => {
const tunnelService = store.add(new StubTunnelService());
const remoteService = store.add(new StubRemoteAgentHostService());
const providersService = store.add(new StubSessionsProvidersService());
Expand All @@ -404,41 +405,70 @@ suite('TunnelAgentHostContribution', () => {
instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService()));
instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService);
const contribution = store.add(instantiationService.createInstance(TestTunnelContribution));
const address = `${TUNNEL_ADDRESS_PREFIX}tunnel-focus`;
tunnelService.setCached([{ tunnelId: 'tunnel-focus', clusterId: 'use', name: 'Focus Tunnel' }]);
const maxAttemptsAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-max-attempts`;
const offlineAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-offline`;
const authAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-auth`;
tunnelService.setCached([
{ tunnelId: 'tunnel-max-attempts', clusterId: 'use', name: 'Max Attempts Tunnel' },
{ tunnelId: 'tunnel-offline', clusterId: 'use', name: 'Offline Tunnel' },
{ tunnelId: 'tunnel-auth', clusterId: 'use', name: 'Auth Tunnel' },
{ tunnelId: 'tunnel-idle', clusterId: 'use', name: 'Idle Tunnel' },
]);
const testable = contribution as unknown as {
_reconnectPaused: Set<string>;
_reconnectPauseReasons: Map<string, TunnelConnectFailureReason>;
_reconnectTimeouts: Map<string, ReturnType<typeof setTimeout>>;
_resumeReconnects(trigger: 'sessionAdded'): void;
};

testable._reconnectPaused.add(address);
testable._reconnectPauseReasons.set(maxAttemptsAddress, 'maxAttemptsReached');
testable._reconnectPauseReasons.set(offlineAddress, 'hostOffline');
testable._reconnectPauseReasons.set(authAddress, 'authExpired');
hostService.fireFocus(true);
const firstResume = {
paused: testable._reconnectPaused.has(address),
paused: [...testable._reconnectPauseReasons],
timers: [...testable._reconnectTimeouts.keys()],
};

testable._reconnectPaused.add(address);
testable._reconnectPauseReasons.set(maxAttemptsAddress, 'maxAttemptsReached');
hostService.fireFocus(true);
const rateLimitedResume = {
paused: testable._reconnectPaused.has(address),
paused: [...testable._reconnectPauseReasons],
timers: [...testable._reconnectTimeouts.keys()],
};

testable._resumeReconnects('sessionAdded');
const sessionResume = {
paused: [...testable._reconnectPauseReasons],
timers: [...testable._reconnectTimeouts.keys()],
};

assert.deepStrictEqual(
{ firstResume, rateLimitedResume },
{ firstResume, rateLimitedResume, sessionResume },
{
firstResume: { paused: false, timers: [address] },
rateLimitedResume: { paused: true, timers: [address] },
firstResume: {
paused: [[offlineAddress, 'hostOffline'], [authAddress, 'authExpired']],
timers: [maxAttemptsAddress],
},
rateLimitedResume: {
paused: [[offlineAddress, 'hostOffline'], [authAddress, 'authExpired'], [maxAttemptsAddress, 'maxAttemptsReached']],
timers: [maxAttemptsAddress],
},
sessionResume: {
paused: [[offlineAddress, 'hostOffline'], [maxAttemptsAddress, 'maxAttemptsReached']],
timers: [maxAttemptsAddress, authAddress],
},
},
);
});

test('confirmed online tunnel resumes a max-attempts pause during status check', async () => {
test('status checks resume only host-offline pauses and auto-connect preserves other pauses', async () => {
const tunnelService = store.add(new StubTunnelService());
const remoteService = store.add(new StubRemoteAgentHostService());
const providersService = store.add(new StubSessionsProvidersService());
const configurationService = new TestConfigurationService({ [RemoteAgentHostsEnabledSettingId]: true });
const configurationService = new TestConfigurationService({
[RemoteAgentHostsEnabledSettingId]: true,
[RemoteAgentHostAutoConnectSettingId]: true,
});
const hostService = new StubHostService();
const instantiationService = store.add(new TestInstantiationService());
instantiationService.stub(ITunnelAgentHostService, tunnelService as unknown as ITunnelAgentHostService);
Expand All @@ -453,22 +483,42 @@ suite('TunnelAgentHostContribution', () => {
instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService()));
instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService);
const contribution = store.add(instantiationService.createInstance(TestTunnelContribution));
const tunnelId = 'tunnel-online';
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`;
tunnelService.setCached([{ tunnelId, clusterId: 'use', name: 'Online Tunnel' }]);
tunnelService.setListed([{ tunnelId, clusterId: 'use', name: 'Online Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 }]);
const offlineAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-offline`;
const authAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-auth`;
const maxAttemptsAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-max-attempts`;
tunnelService.setCached([
{ tunnelId: 'tunnel-offline', clusterId: 'use', name: 'Offline Tunnel' },
{ tunnelId: 'tunnel-auth', clusterId: 'use', name: 'Auth Tunnel' },
{ tunnelId: 'tunnel-max-attempts', clusterId: 'use', name: 'Max Attempts Tunnel' },
]);
tunnelService.setListed([
{ tunnelId: 'tunnel-offline', clusterId: 'use', name: 'Offline Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 },
{ tunnelId: 'tunnel-auth', clusterId: 'use', name: 'Auth Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 },
{ tunnelId: 'tunnel-max-attempts', clusterId: 'use', name: 'Max Attempts Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 },
]);
const testable = contribution as unknown as {
_reconnectPaused: Set<string>;
_reconnectPauseReasons: Map<string, TunnelConnectFailureReason>;
_reconnectTimeouts: Map<string, ReturnType<typeof setTimeout>>;
_silentStatusCheck(): Promise<void>;
};

testable._reconnectPaused.add(address);
testable._reconnectPauseReasons.set(offlineAddress, 'hostOffline');
testable._reconnectPauseReasons.set(authAddress, 'authExpired');
testable._reconnectPauseReasons.set(maxAttemptsAddress, 'maxAttemptsReached');
await testable._silentStatusCheck();
await Promise.resolve();

assert.deepStrictEqual(
{ paused: testable._reconnectPaused.has(address), timers: [...testable._reconnectTimeouts.keys()] },
{ paused: false, timers: [address] },
{
paused: [...testable._reconnectPauseReasons],
connects: tunnelService.connectCalls.map(call => call.tunnel.tunnelId),
timers: [...testable._reconnectTimeouts.keys()],
},
{
paused: [[authAddress, 'authExpired'], [maxAttemptsAddress, 'maxAttemptsReached']],
connects: ['tunnel-offline'],
timers: [],
},
);
});

Expand Down
Loading