Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';6import { isWeb } from '../../../../base/common/platform.js';7import { mainWindow } from '../../../../base/browser/window.js';8import * as nls from '../../../../nls.js';9import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';10import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js';11import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';12import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';13import { ILogService } from '../../../../platform/log/common/log.js';14import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';15import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';16import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';17import { AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';18import { logTunnelConnectAttempt, logTunnelConnectResolved, TunnelConnectErrorCategory, TunnelConnectFailureReason } from '../../../common/sessionsTelemetry.js';19import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';20import { IAgentHostFilterService } from '../common/agentHostFilter.js';21import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';2223/** Minimum interval between silent status checks (5 minutes). */24const STATUS_CHECK_INTERVAL = 5 * 60 * 1000;2526/** Initial auto-reconnect delay after an unexpected tunnel disconnect. */27const RECONNECT_INITIAL_DELAY = 1000;28/** Maximum auto-reconnect backoff delay. */29const RECONNECT_MAX_DELAY = 30_000;30/**31* Consecutive failures before pausing auto-reconnect. We resume immediately32* on a network-online event or when the tab becomes visible, so this is33* mostly a guard against a permanently dead tunnel.34*/35const RECONNECT_MAX_ATTEMPTS = 10;3637/** Minimum gap between wake/visibility-triggered resumes. */38const RESUME_RATE_LIMIT_MS = 10_000;3940export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution {4142static readonly ID = 'sessions.contrib.tunnelAgentHostContribution';4344private readonly _providerStores = this._register(new DisposableMap<string /* address */, DisposableStore>());45private readonly _providerInstances = new Map<string, RemoteAgentHostSessionsProvider>();46private readonly _pendingConnects = new Map<string, Promise<void>>();47private _lastStatusCheck = 0;48/**49* `false` until the first {@link _silentStatusCheck} resolves. Until then50* we keep newly-created providers in the `Connecting` state so the picker51* doesn't briefly show every cached tunnel as "Offline" on startup.52*/53private _initialStatusChecked = false;5455/** Previous connection status per address — used to detect Connected→Disconnected transitions. */56private readonly _previousStatuses = new Map<string, RemoteAgentHostConnectionStatus>();57/** Pending auto-reconnect timer per address. */58private readonly _reconnectTimeouts = new Map<string, ReturnType<typeof setTimeout>>();59/** Consecutive failed auto-reconnect attempts per address. */60private readonly _reconnectAttempts = new Map<string, number>();61/** Addresses whose auto-reconnect loop has paused after too many failures. */62private readonly _reconnectPaused = new Set<string>();63/** Addresses paused specifically because the remote host is offline. */64private readonly _hostOfflinePaused = new Set<string>();65/** Timestamp of the last wake-triggered resume, to rate-limit rapid tab toggles. */66private _lastResumeAt = 0;6768/**69* Per-address connect sessions for telemetry. A session starts at the70* first attempt of a connect cycle (initial or reconnect) and ends on71* terminal resolution (connected, host-offline, max-attempts).72*/73private readonly _connectSessions = new Map<string, { startedAt: number; attempts: number; isReconnect: boolean }>();7475constructor(76@ITunnelAgentHostService private readonly _tunnelService: ITunnelAgentHostService,77@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,78@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,79@IConfigurationService private readonly _configurationService: IConfigurationService,80@IInstantiationService private readonly _instantiationService: IInstantiationService,81@INotificationService private readonly _notificationService: INotificationService,82@ILogService private readonly _logService: ILogService,83@IAuthenticationService private readonly _authenticationService: IAuthenticationService,84@ITelemetryService private readonly _telemetryService: ITelemetryService,85@IAgentHostFilterService agentHostFilterService: IAgentHostFilterService,86) {87super();8889// Create providers for cached tunnels90this._reconcileProviders();9192// Plug our silent status check into the shared host picker UX so93// the user-triggered "Re-discover hosts" action runs the same94// discovery routine.95this._register(agentHostFilterService.registerDiscoveryHandler(() => this._silentStatusCheck()));9697// Update connection statuses when connections change98this._register(this._remoteAgentHostService.onDidChangeConnections(() => {99this._handleConnectionChanges();100this._updateConnectionStatuses();101this._wireConnections();102}));103104// Reconcile providers when the tunnel cache changes105this._register(this._tunnelService.onDidChangeTunnels(() => {106this._reconcileProviders();107// Stop any reconnect loops for tunnels that no longer exist108this._pruneReconnectState();109}));110111// Re-run discovery when a GitHub session becomes available,112// and tear down tunnel state bound to that provider if its session113// is removed.114this._register(this._authenticationService.onDidChangeSessions(e => {115if (e.providerId !== 'github') {116return;117}118this._handleSessionsChange(e);119}));120121// Wake-triggered retry: when the browser regains connectivity or122// the tab becomes visible again, immediately attempt to reconnect123// any disconnected tunnels. This covers laptop-sleep / Wi-Fi-drop124// scenarios where we may have paused the reconnect loop.125if (isWeb) {126const onWake = () => this._resumeReconnects('wake');127mainWindow.addEventListener('online', onWake);128this._register(toDisposable(() => mainWindow.removeEventListener('online', onWake)));129130const onVisibilityChange = () => {131if (mainWindow.document.visibilityState === 'visible') {132this._resumeReconnects('visible');133}134};135mainWindow.document.addEventListener('visibilitychange', onVisibilityChange);136this._register(toDisposable(() => mainWindow.document.removeEventListener('visibilitychange', onVisibilityChange)));137}138139// Cancel any pending reconnect timers on disposal.140this._register(toDisposable(() => {141for (const timer of this._reconnectTimeouts.values()) {142clearTimeout(timer);143}144this._reconnectTimeouts.clear();145}));146147// Silently check status of cached tunnels on startup. Routed148// through the filter service's `rediscover` so the host pill149// pulses while the initial automatic discovery is in flight,150// then switches to a static label once we know what hosts exist.151agentHostFilterService.rediscover();152}153154/**155* Called by the workspace picker when it opens. Silently re-checks156* tunnel statuses if more than 5 minutes have elapsed since the last check.157*/158async checkTunnelStatuses(): Promise<void> {159if (Date.now() - this._lastStatusCheck < STATUS_CHECK_INTERVAL) {160return;161}162await this._silentStatusCheck();163}164165// -- Provider management --166167private _reconcileProviders(): void {168const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);169const cached = enabled ? this._tunnelService.getCachedTunnels() : [];170const desiredAddresses = new Set(cached.map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`));171172// Remove providers no longer cached173for (const [address] of this._providerStores) {174if (!desiredAddresses.has(address)) {175this._providerStores.deleteAndDispose(address);176this._providerInstances.delete(address);177}178}179180// Add providers for cached tunnels181for (const tunnel of cached) {182const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;183if (!this._providerStores.has(address)) {184this._createProvider(address, tunnel.name);185}186}187}188189private _createProvider(address: string, name: string): void {190const store = new DisposableStore();191const provider = this._instantiationService.createInstance(192RemoteAgentHostSessionsProvider, {193address,194name,195connectOnDemand: () => this._connectTunnel(address, { userInitiated: true }),196disconnectOnDemand: () => this._disconnectTunnel(address),197},198);199// Surface as "Connecting" until the first silent status check or an200// auto-connect attempt determines the real state; otherwise the picker201// flashes "Offline" for every cached tunnel on startup.202provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);203store.add(provider);204store.add(this._sessionsProvidersService.registerProvider(provider));205this._providerInstances.set(address, provider);206store.add(toDisposable(() => this._providerInstances.delete(address)));207this._providerStores.set(address, store);208}209210// -- Connection status --211212private _updateConnectionStatuses(): void {213for (const [address, provider] of this._providerInstances) {214const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address);215if (connectionInfo) {216provider.setConnectionStatus(connectionInfo.status);217} else if (this._pendingConnects.has(address)) {218provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);219} else if (!this._initialStatusChecked) {220// Keep the initial "Connecting" state so the picker doesn't221// flash "Offline" before the first silent status check runs.222provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);223} else {224provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);225}226}227}228229/**230* Wire live connections to their providers so session operations work.231*/232private _wireConnections(): void {233for (const [address, provider] of this._providerInstances) {234const connectionInfo = this._remoteAgentHostService.connections.find(235c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected236);237if (connectionInfo) {238const connection = this._remoteAgentHostService.getConnection(address);239if (connection) {240provider.setConnection(connection, connectionInfo.defaultDirectory);241}242}243}244}245246// -- On-demand connection --247248/**249* Establish a relay connection to a cached tunnel. Called on demand250* when the user invokes the browse action on an online-but-not-connected tunnel.251*/252private _connectTunnel(address: string, options: { readonly userInitiated: boolean }): Promise<void> {253const existing = this._pendingConnects.get(address);254if (existing) {255return existing;256}257258const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);259const cached = this._tunnelService.getCachedTunnels().find(t => t.tunnelId === tunnelId);260if (!cached) {261return Promise.resolve();262}263264// A new attempt is starting — cancel any scheduled reconnect timer;265// success/failure of this attempt will drive the next decision.266this._cancelReconnect(address);267268const { attemptNumber, attemptStart, session, isReconnect } = this._beginConnectAttempt(address);269270const promise = (async () => {271// Show a progress notification after a short delay so quick272// connects don't flash a notification. Only show for user-initiated273// connects; background auto-connects and reconnects stay silent.274let handle: { close(): void } | undefined;275const timer = options.userInitiated ? setTimeout(() => {276handle = this._notificationService.notify({277severity: Severity.Info,278message: nls.localize('tunnelConnecting', "Connecting to tunnel '{0}'...", cached.name),279progress: { infinite: true },280});281}, 1000) : undefined;282283this._updateConnectionStatuses();284try {285const tunnelInfo: ITunnelInfo = {286tunnelId: cached.tunnelId,287clusterId: cached.clusterId,288name: cached.name,289tags: [],290protocolVersion: 5,291hostConnectionCount: 0,292};293await this._tunnelService.connect(tunnelInfo, cached.authProvider);294this._finishConnectAttempt(address, { success: true, attemptNumber, attemptStart, session, isReconnect });295} catch (err) {296this._logService.warn(`[TunnelAgentHost] Connect to ${cached.name} failed:`, err);297const errorCategory = this._categorizeError(err);298this._finishConnectAttempt(address, { success: false, attemptNumber, attemptStart, session, isReconnect, error: err });299// Clear the pending-connect entry BEFORE deciding what to do300// next; otherwise `_scheduleReconnect`'s in-flight guard301// (`_pendingConnects.has(address)`) would silently bail and302// we'd never re-arm the timer, leaving the tunnel stuck.303this._pendingConnects.delete(address);304305// Auth failures are not worth retrying — a fresh token must306// be acquired by the user or by a session-change event. Pause307// immediately and let `_handleSessionsChange` resume us when308// a new session appears.309if (errorCategory === 'authExpired' || errorCategory === 'auth') {310this._pauseReconnect(address, errorCategory);311throw err;312}313314const hostOnline = await this._probeHostOnline(cached.tunnelId);315if (hostOnline === false) {316this._pauseReconnect(address, 'hostOffline');317} else {318this._logService.info(`[TunnelAgentHost] Scheduling reconnect for ${address}`);319this._scheduleReconnect(address);320}321throw err;322} finally {323if (timer !== undefined) {324clearTimeout(timer);325}326handle?.close();327this._pendingConnects.delete(address);328this._updateConnectionStatuses();329}330})();331332// Swallow the promise rejection here so unhandled rejection noise333// doesn't bubble up for the background reconnect path; callers that334// await `_connectTunnel` directly will still see it via their own `await`.335promise.catch(() => { /* handled via _scheduleReconnect */ });336337this._pendingConnects.set(address, promise);338return promise;339}340341/**342* Tear down the active tunnel relay for {@link address} and cancel any343* pending auto-reconnect. The cached tunnel entry is kept so the user344* can re-connect later; only the live WebSocket is closed.345*/346private async _disconnectTunnel(address: string): Promise<void> {347this._cancelReconnect(address);348this._resetReconnectState(address);349// Mark as explicitly disconnected so `_handleConnectionChanges` does350// not treat the impending Connected→(removed) transition as a351// reconnect-worthy drop.352this._previousStatuses.delete(address);353await this._tunnelService.disconnect(address);354}355356/**357* Detect tunnel connections that transitioned from Connected to358* Disconnected and schedule an auto-reconnect.359*360* Important: we only trigger on a Connected → Disconnected transition361* where the connection entry is still present. If the entry has been362* removed from the service (e.g. the user clicked "Remove Remote"),363* we do NOT schedule a reconnect — that would override their intent.364*/365private _handleConnectionChanges(): void {366if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {367return;368}369370const cachedAddresses = new Set(371this._tunnelService.getCachedTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`)372);373const currentStatuses = new Map<string, RemoteAgentHostConnectionStatus>();374for (const conn of this._remoteAgentHostService.connections) {375currentStatuses.set(conn.address, conn.status);376}377378for (const address of cachedAddresses) {379const previous = this._previousStatuses.get(address);380const current = currentStatuses.get(address);381382// Only schedule a reconnect on an explicit Connected→Disconnected383// transition. If the address is absent from the connection list,384// the user (or another code path) removed it — honour that.385const wasConnected = previous === RemoteAgentHostConnectionStatus.Connected;386const isExplicitlyDisconnected = current === RemoteAgentHostConnectionStatus.Disconnected;387388if (wasConnected && isExplicitlyDisconnected && !this._pendingConnects.has(address)) {389this._logService.info(`[TunnelAgentHost] Connection lost for ${address}, scheduling reconnect`);390if (!this._connectSessions.has(address)) {391this._connectSessions.set(address, { startedAt: Date.now(), attempts: 0, isReconnect: true });392}393this._scheduleReconnect(address, /*immediate*/ true);394}395396// Only track previous status while the entry is present so a397// future re-registration starts from a clean slate. If the398// entry disappeared (e.g. user-initiated removal), also cancel399// any already-scheduled reconnect and clear its backoff state400// so the removal is honoured even if a timer was already armed.401if (current !== undefined) {402this._previousStatuses.set(address, current);403} else {404this._previousStatuses.delete(address);405this._resetReconnectState(address);406}407}408409// Drop previous-status entries for addresses no longer cached.410for (const address of [...this._previousStatuses.keys()]) {411if (!cachedAddresses.has(address)) {412this._previousStatuses.delete(address);413}414}415}416417private _scheduleReconnect(address: string, immediate = false): void {418// Respect enablement and tunnel-still-cached.419if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {420return;421}422const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);423const cached = this._tunnelService.getCachedTunnels().find(t => t.tunnelId === tunnelId);424if (!cached) {425return;426}427428// Already connected or a connect is in flight — nothing to do.429if (this._pendingConnects.has(address)) {430return;431}432const live = this._remoteAgentHostService.connections.find(c => c.address === address);433if (live && live.status === RemoteAgentHostConnectionStatus.Connected) {434this._clearReconnectBackoff(address);435return;436}437438// Cancel any existing timer — we're rescheduling.439this._cancelReconnect(address);440441const attempt = this._reconnectAttempts.get(address) ?? 0;442443if (attempt >= RECONNECT_MAX_ATTEMPTS) {444this._pauseReconnect(address, 'maxAttemptsReached');445return;446}447448const delay = immediate449? 0450: Math.min(RECONNECT_INITIAL_DELAY * Math.pow(2, attempt), RECONNECT_MAX_DELAY);451452this._logService.info(453`[TunnelAgentHost] Scheduling reconnect for ${address} in ${delay}ms (attempt ${attempt + 1}/${RECONNECT_MAX_ATTEMPTS})`454);455456const timer = setTimeout(() => {457this._reconnectTimeouts.delete(address);458459// A manual (or other) connect may have started or completed while460// we were waiting. Re-check before counting this as a new attempt,461// otherwise `_connectTunnel` would just return the in-flight promise462// and we'd inflate the backoff counter without really trying again.463if (this._pendingConnects.has(address)) {464return;465}466const live = this._remoteAgentHostService.connections.find(c => c.address === address);467if (live && live.status === RemoteAgentHostConnectionStatus.Connected) {468this._clearReconnectBackoff(address);469return;470}471472this._reconnectAttempts.set(address, attempt + 1);473this._connectTunnel(address, { userInitiated: false }).catch(() => { /* _connectTunnel already re-schedules on failure */ });474}, delay);475this._reconnectTimeouts.set(address, timer);476}477478/**479* Best-effort probe of whether the host backing `tunnelId` is online480* (has any host connections). Returns `undefined` if we couldn't481* determine — caller should treat as "retry normally" in that case.482*/483private async _probeHostOnline(tunnelId: string): Promise<boolean | undefined> {484try {485const tunnels = await this._tunnelService.listTunnels({ silent: true });486if (!tunnels) {487return undefined;488}489const info = tunnels.find(t => t.tunnelId === tunnelId);490if (!info) {491return false;492}493return info.hostConnectionCount > 0;494} catch {495return undefined;496}497}498499private _cancelReconnect(address: string): void {500const timer = this._reconnectTimeouts.get(address);501if (timer !== undefined) {502clearTimeout(timer);503this._reconnectTimeouts.delete(address);504}505}506507/** Clear retry-backoff and pause state for an address. */508private _clearReconnectBackoff(address: string): void {509this._reconnectAttempts.delete(address);510this._reconnectPaused.delete(address);511this._hostOfflinePaused.delete(address);512}513514/** Drop all reconnect + telemetry state for an address (e.g. on removal). */515private _resetReconnectState(address: string): void {516this._cancelReconnect(address);517this._clearReconnectBackoff(address);518this._connectSessions.delete(address);519}520521/**522* React to auth session add/remove. Additions re-run discovery (a fresh523* token may unblock a previously auth-paused tunnel). Removals drop any524* tunnel state that depended on that provider — otherwise we'd sit on a525* stale auth pause forever, or hammer a provider whose session is gone.526*/527private _handleSessionsChange(e: { providerId: string; label: string; event: AuthenticationSessionsChangeEvent }): void {528const added = (e.event.added?.length ?? 0) > 0;529const removed = (e.event.removed?.length ?? 0) > 0;530531if (removed) {532const cached = this._tunnelService.getCachedTunnels();533for (const tunnel of cached) {534if (tunnel.authProvider !== e.providerId) {535continue;536}537const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;538this._logService.info(539`[TunnelAgentHost] Auth session removed for ${e.providerId}; tearing down ${address}.`540);541this._resetReconnectState(address);542// Best-effort disconnect — the transport may already be dead.543this._tunnelService.disconnect(address).catch(() => { /* ignore */ });544}545}546547if (added) {548this._logService.info(`[TunnelAgentHost] ${e.providerId} session added; resuming reconnects and rediscovering.`);549this._resumeReconnects('sessionAdded');550this._silentStatusCheck();551}552}553554/**555* Stop auto-reconnecting for an address until a wake/online/visibility556* event resumes us, and close out any active telemetry session.557*/558private _pauseReconnect(address: string, reason: TunnelConnectFailureReason): void {559this._cancelReconnect(address);560this._reconnectAttempts.delete(address);561this._reconnectPaused.add(address);562if (reason === 'hostOffline') {563this._hostOfflinePaused.add(address);564} else {565this._hostOfflinePaused.delete(address);566}567this._logService.info(568`[TunnelAgentHost] Pausing auto-reconnect for ${address} (${reason}); ` +569`will resume on network-online, tab-visible, session change, or next status check.`570);571const session = this._connectSessions.get(address);572if (session) {573logTunnelConnectResolved(this._telemetryService, {574isReconnect: session.isReconnect,575totalAttempts: session.attempts,576totalDurationMs: Date.now() - session.startedAt,577success: false,578failureReason: reason,579});580this._connectSessions.delete(address);581}582}583584/**585* Begin (or continue) a connect telemetry session for `address` and586* return the bookkeeping needed to later finish the attempt. A session587* already exists if `_handleConnectionChanges` marked this as a588* reconnect cycle; otherwise this starts a fresh initial-connect session.589*/590private _beginConnectAttempt(address: string): { session: { startedAt: number; attempts: number; isReconnect: boolean }; attemptNumber: number; attemptStart: number; isReconnect: boolean } {591let session = this._connectSessions.get(address);592if (!session) {593session = { startedAt: Date.now(), attempts: 0, isReconnect: false };594this._connectSessions.set(address, session);595}596session.attempts++;597return { session, attemptNumber: session.attempts, attemptStart: Date.now(), isReconnect: session.isReconnect };598}599600/**601* Finalize the telemetry for a single connect attempt. On success, also602* clears backoff state and closes the session; on failure, only the603* per-attempt event is emitted (the caller decides whether to retry).604*/605private _finishConnectAttempt(address: string, args: {606success: boolean;607attemptNumber: number;608attemptStart: number;609session: { startedAt: number; attempts: number; isReconnect: boolean };610isReconnect: boolean;611error?: unknown;612}): void {613const { success, attemptNumber, attemptStart, session, isReconnect, error } = args;614const durationMs = Date.now() - attemptStart;615if (success) {616this._clearReconnectBackoff(address);617logTunnelConnectAttempt(this._telemetryService, { isReconnect, attempt: attemptNumber, durationMs, success: true });618logTunnelConnectResolved(this._telemetryService, { isReconnect, totalAttempts: attemptNumber, totalDurationMs: Date.now() - session.startedAt, success: true });619this._connectSessions.delete(address);620} else {621logTunnelConnectAttempt(this._telemetryService, { isReconnect, attempt: attemptNumber, durationMs, success: false, errorCategory: this._categorizeError(error) });622}623}624625private _categorizeError(err: unknown): TunnelConnectErrorCategory {626const message = err instanceof Error ? err.message : String(err);627// Expired / invalid credential — callers short-circuit this category628// to avoid burning retry budget on a token the user has to refresh.629if (/\b(401|403)\b|token.*expired|expired.*token|invalid[_ -]?grant/i.test(message)) {630return 'authExpired';631}632// Match authentication-specific language but NOT "connection token"633// or other protocol uses of the word "token".634if (/authenticat|unauthoriz|auth.*(fail|error|invalid)/i.test(message)) {635return 'auth';636}637if (/WebSocket relay connection failed|failed to connect to relay/i.test(message)) {638return 'relayConnectionFailed';639}640if (/network|fetch|offline|ECONN|ENOTFOUND|ETIMEDOUT/i.test(message)) {641return 'network';642}643return 'other';644}645646/**647* Invoked on `online` / `visibilitychange→visible`. Kicks off an648* immediate attempt for any disconnected cached tunnel.649*650* Rate-limited: at most one resume per RESUME_RATE_LIMIT_MS so that651* rapid tab toggling can't hammer a permanently broken endpoint with652* an unbounded number of attempt bursts. Resumes the normal backoff653* sequence (by clearing the pause flag) rather than zeroing the654* attempt counter.655*/656private _resumeReconnects(trigger: 'wake' | 'visible' | 'sessionAdded'): void {657if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {658return;659}660661// Rate-limit rapid wake/visibility events (e.g. alt-tab bursts or662// flaky Wi-Fi toggling online/offline) so we don't hammer the relay663// with immediate retries. This is an event-smoothing gate, not an664// error-backoff — that's handled by `_scheduleReconnect`.665const now = Date.now();666if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) {667return;668}669this._lastResumeAt = now;670671const cached = this._tunnelService.getCachedTunnels();672for (const tunnel of cached) {673const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;674if (this._pendingConnects.has(address)) {675continue;676}677const live = this._remoteAgentHostService.connections.find(c => c.address === address);678if (live && live.status === RemoteAgentHostConnectionStatus.Connected) {679continue;680}681682this._logService.info(`[TunnelAgentHost] Resuming reconnect for ${address} (trigger: ${trigger})`);683// If we were paused (exhausted the backoff budget), give a fresh684// budget since the wake event is itself evidence the environment685// has changed. Otherwise keep the current attempt counter so an686// in-progress backoff isn't short-circuited.687if (this._reconnectPaused.has(address)) {688this._clearReconnectBackoff(address);689}690this._scheduleReconnect(address, /*immediate*/ true);691}692}693694/** Drop reconnect state for addresses whose tunnel is no longer cached. */695private _pruneReconnectState(): void {696const cachedAddresses = new Set(697this._tunnelService.getCachedTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`)698);699const tracked = new Set<string>([700...this._reconnectTimeouts.keys(),701...this._reconnectAttempts.keys(),702...this._reconnectPaused,703...this._connectSessions.keys(),704]);705for (const address of tracked) {706if (!cachedAddresses.has(address)) {707this._resetReconnectState(address);708}709}710}711712// -- Silent status check --713714private async _silentStatusCheck(): Promise<void> {715const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);716if (!enabled) {717this._initialStatusChecked = true;718this._updateConnectionStatuses();719return;720}721722this._lastStatusCheck = Date.now();723724// Fetch tunnel list silently to check online status725let onlineTunnels: ITunnelInfo[] | undefined;726try {727onlineTunnels = await this._tunnelService.listTunnels({ silent: true });728} catch {729// No cached token or network error — leave statuses as-is730this._initialStatusChecked = true;731this._updateConnectionStatuses();732return;733}734735const cached = this._tunnelService.getCachedTunnels();736if (onlineTunnels) {737const onlineIds = new Set(onlineTunnels.map(t => t.tunnelId));738// Remove cached tunnels that no longer exist on the account739for (const tunnel of cached) {740if (!onlineIds.has(tunnel.tunnelId)) {741this._tunnelService.removeCachedTunnel(tunnel.tunnelId);742}743}744745// Auto-cache online tunnels that aren't cached yet so they746// appear in the UI on first discovery (e.g. fresh web session).747// Pass 'github' as authProvider so _handleSessionsChange can748// match these tunnels for teardown on session removal.749const cachedIds = new Set(cached.map(t => t.tunnelId));750for (const tunnel of onlineTunnels) {751if (!cachedIds.has(tunnel.tunnelId) && tunnel.hostConnectionCount > 0) {752this._tunnelService.cacheTunnel(tunnel, 'github');753}754}755756// Update online/offline status based on hostConnectionCount.757// For tunnels, Connected means "host is online" (clickable to connect),758// Disconnected means "host is offline". Actual relay connection759// establishment happens when the user clicks the tunnel (or via760// auto-connect below when enabled).761const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t]));762for (const [address, provider] of this._providerInstances) {763// Skip tunnels that already have an active relay connection764const hasConnection = this._remoteAgentHostService.connections.some(765c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected766);767if (hasConnection) {768continue;769}770771const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);772const info = onlineTunnelMap.get(tunnelId);773if (info && info.hostConnectionCount > 0) {774provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected);775776// If we paused reconnects because the host had gone777// offline, the status check is our cue to resume —778// don't wait for a wake/visibility event. Covers the779// common "my laptop came back, the remote host came780// back first" scenario deterministically.781if (this._hostOfflinePaused.has(address)) {782this._logService.info(783`[TunnelAgentHost] Host came back online for ${address}; auto-resuming reconnect.`784);785this._clearReconnectBackoff(address);786this._scheduleReconnect(address, /*immediate*/ true);787}788} else {789provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);790// Host is not online — drop any cached sessions we were791// showing for it so the UI doesn't list stale entries.792provider.unpublishCachedSessions();793}794}795796// Auto-connect online tunnels that aren't connected yet when the797// user has opted into auto-connect (default on). This mirrors the798// web embedder behaviour where no workspace picker is available799// to trigger manual connection.800const autoConnect = this._configurationService.getValue<boolean>(RemoteAgentHostAutoConnectSettingId);801if (autoConnect) {802for (const tunnel of onlineTunnels) {803if (tunnel.hostConnectionCount > 0) {804const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;805const alreadyConnected = this._remoteAgentHostService.connections.some(806c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected807);808if (!alreadyConnected) {809this._connectTunnel(address, { userInitiated: false });810}811}812}813}814}815816this._initialStatusChecked = true;817this._updateConnectionStatuses();818}819}820821registerWorkbenchContribution2(TunnelAgentHostContribution.ID, TunnelAgentHostContribution, WorkbenchPhase.AfterRestored);822823824