Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
7
import { isWeb } from '../../../../base/common/platform.js';
8
import { mainWindow } from '../../../../base/browser/window.js';
9
import * as nls from '../../../../nls.js';
10
import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
11
import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
12
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
13
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
14
import { ILogService } from '../../../../platform/log/common/log.js';
15
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
16
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
17
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
18
import { AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
19
import { logTunnelConnectAttempt, logTunnelConnectResolved, TunnelConnectErrorCategory, TunnelConnectFailureReason } from '../../../common/sessionsTelemetry.js';
20
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
21
import { IAgentHostFilterService } from '../common/agentHostFilter.js';
22
import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';
23
24
/** Minimum interval between silent status checks (5 minutes). */
25
const STATUS_CHECK_INTERVAL = 5 * 60 * 1000;
26
27
/** Initial auto-reconnect delay after an unexpected tunnel disconnect. */
28
const RECONNECT_INITIAL_DELAY = 1000;
29
/** Maximum auto-reconnect backoff delay. */
30
const RECONNECT_MAX_DELAY = 30_000;
31
/**
32
* Consecutive failures before pausing auto-reconnect. We resume immediately
33
* on a network-online event or when the tab becomes visible, so this is
34
* mostly a guard against a permanently dead tunnel.
35
*/
36
const RECONNECT_MAX_ATTEMPTS = 10;
37
38
/** Minimum gap between wake/visibility-triggered resumes. */
39
const RESUME_RATE_LIMIT_MS = 10_000;
40
41
export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution {
42
43
static readonly ID = 'sessions.contrib.tunnelAgentHostContribution';
44
45
private readonly _providerStores = this._register(new DisposableMap<string /* address */, DisposableStore>());
46
private readonly _providerInstances = new Map<string, RemoteAgentHostSessionsProvider>();
47
private readonly _pendingConnects = new Map<string, Promise<void>>();
48
private _lastStatusCheck = 0;
49
/**
50
* `false` until the first {@link _silentStatusCheck} resolves. Until then
51
* we keep newly-created providers in the `Connecting` state so the picker
52
* doesn't briefly show every cached tunnel as "Offline" on startup.
53
*/
54
private _initialStatusChecked = false;
55
56
/** Previous connection status per address — used to detect Connected→Disconnected transitions. */
57
private readonly _previousStatuses = new Map<string, RemoteAgentHostConnectionStatus>();
58
/** Pending auto-reconnect timer per address. */
59
private readonly _reconnectTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
60
/** Consecutive failed auto-reconnect attempts per address. */
61
private readonly _reconnectAttempts = new Map<string, number>();
62
/** Addresses whose auto-reconnect loop has paused after too many failures. */
63
private readonly _reconnectPaused = new Set<string>();
64
/** Addresses paused specifically because the remote host is offline. */
65
private readonly _hostOfflinePaused = new Set<string>();
66
/** Timestamp of the last wake-triggered resume, to rate-limit rapid tab toggles. */
67
private _lastResumeAt = 0;
68
69
/**
70
* Per-address connect sessions for telemetry. A session starts at the
71
* first attempt of a connect cycle (initial or reconnect) and ends on
72
* terminal resolution (connected, host-offline, max-attempts).
73
*/
74
private readonly _connectSessions = new Map<string, { startedAt: number; attempts: number; isReconnect: boolean }>();
75
76
constructor(
77
@ITunnelAgentHostService private readonly _tunnelService: ITunnelAgentHostService,
78
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
79
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
80
@IConfigurationService private readonly _configurationService: IConfigurationService,
81
@IInstantiationService private readonly _instantiationService: IInstantiationService,
82
@INotificationService private readonly _notificationService: INotificationService,
83
@ILogService private readonly _logService: ILogService,
84
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
85
@ITelemetryService private readonly _telemetryService: ITelemetryService,
86
@IAgentHostFilterService agentHostFilterService: IAgentHostFilterService,
87
) {
88
super();
89
90
// Create providers for cached tunnels
91
this._reconcileProviders();
92
93
// Plug our silent status check into the shared host picker UX so
94
// the user-triggered "Re-discover hosts" action runs the same
95
// discovery routine.
96
this._register(agentHostFilterService.registerDiscoveryHandler(() => this._silentStatusCheck()));
97
98
// Update connection statuses when connections change
99
this._register(this._remoteAgentHostService.onDidChangeConnections(() => {
100
this._handleConnectionChanges();
101
this._updateConnectionStatuses();
102
this._wireConnections();
103
}));
104
105
// Reconcile providers when the tunnel cache changes
106
this._register(this._tunnelService.onDidChangeTunnels(() => {
107
this._reconcileProviders();
108
// Stop any reconnect loops for tunnels that no longer exist
109
this._pruneReconnectState();
110
}));
111
112
// Re-run discovery when a GitHub session becomes available,
113
// and tear down tunnel state bound to that provider if its session
114
// is removed.
115
this._register(this._authenticationService.onDidChangeSessions(e => {
116
if (e.providerId !== 'github') {
117
return;
118
}
119
this._handleSessionsChange(e);
120
}));
121
122
// Wake-triggered retry: when the browser regains connectivity or
123
// the tab becomes visible again, immediately attempt to reconnect
124
// any disconnected tunnels. This covers laptop-sleep / Wi-Fi-drop
125
// scenarios where we may have paused the reconnect loop.
126
if (isWeb) {
127
const onWake = () => this._resumeReconnects('wake');
128
mainWindow.addEventListener('online', onWake);
129
this._register(toDisposable(() => mainWindow.removeEventListener('online', onWake)));
130
131
const onVisibilityChange = () => {
132
if (mainWindow.document.visibilityState === 'visible') {
133
this._resumeReconnects('visible');
134
}
135
};
136
mainWindow.document.addEventListener('visibilitychange', onVisibilityChange);
137
this._register(toDisposable(() => mainWindow.document.removeEventListener('visibilitychange', onVisibilityChange)));
138
}
139
140
// Cancel any pending reconnect timers on disposal.
141
this._register(toDisposable(() => {
142
for (const timer of this._reconnectTimeouts.values()) {
143
clearTimeout(timer);
144
}
145
this._reconnectTimeouts.clear();
146
}));
147
148
// Silently check status of cached tunnels on startup. Routed
149
// through the filter service's `rediscover` so the host pill
150
// pulses while the initial automatic discovery is in flight,
151
// then switches to a static label once we know what hosts exist.
152
agentHostFilterService.rediscover();
153
}
154
155
/**
156
* Called by the workspace picker when it opens. Silently re-checks
157
* tunnel statuses if more than 5 minutes have elapsed since the last check.
158
*/
159
async checkTunnelStatuses(): Promise<void> {
160
if (Date.now() - this._lastStatusCheck < STATUS_CHECK_INTERVAL) {
161
return;
162
}
163
await this._silentStatusCheck();
164
}
165
166
// -- Provider management --
167
168
private _reconcileProviders(): void {
169
const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);
170
const cached = enabled ? this._tunnelService.getCachedTunnels() : [];
171
const desiredAddresses = new Set(cached.map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`));
172
173
// Remove providers no longer cached
174
for (const [address] of this._providerStores) {
175
if (!desiredAddresses.has(address)) {
176
this._providerStores.deleteAndDispose(address);
177
this._providerInstances.delete(address);
178
}
179
}
180
181
// Add providers for cached tunnels
182
for (const tunnel of cached) {
183
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
184
if (!this._providerStores.has(address)) {
185
this._createProvider(address, tunnel.name);
186
}
187
}
188
}
189
190
private _createProvider(address: string, name: string): void {
191
const store = new DisposableStore();
192
const provider = this._instantiationService.createInstance(
193
RemoteAgentHostSessionsProvider, {
194
address,
195
name,
196
connectOnDemand: () => this._connectTunnel(address, { userInitiated: true }),
197
disconnectOnDemand: () => this._disconnectTunnel(address),
198
},
199
);
200
// Surface as "Connecting" until the first silent status check or an
201
// auto-connect attempt determines the real state; otherwise the picker
202
// flashes "Offline" for every cached tunnel on startup.
203
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);
204
store.add(provider);
205
store.add(this._sessionsProvidersService.registerProvider(provider));
206
this._providerInstances.set(address, provider);
207
store.add(toDisposable(() => this._providerInstances.delete(address)));
208
this._providerStores.set(address, store);
209
}
210
211
// -- Connection status --
212
213
private _updateConnectionStatuses(): void {
214
for (const [address, provider] of this._providerInstances) {
215
const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address);
216
if (connectionInfo) {
217
provider.setConnectionStatus(connectionInfo.status);
218
} else if (this._pendingConnects.has(address)) {
219
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);
220
} else if (!this._initialStatusChecked) {
221
// Keep the initial "Connecting" state so the picker doesn't
222
// flash "Offline" before the first silent status check runs.
223
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);
224
} else {
225
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);
226
}
227
}
228
}
229
230
/**
231
* Wire live connections to their providers so session operations work.
232
*/
233
private _wireConnections(): void {
234
for (const [address, provider] of this._providerInstances) {
235
const connectionInfo = this._remoteAgentHostService.connections.find(
236
c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected
237
);
238
if (connectionInfo) {
239
const connection = this._remoteAgentHostService.getConnection(address);
240
if (connection) {
241
provider.setConnection(connection, connectionInfo.defaultDirectory);
242
}
243
}
244
}
245
}
246
247
// -- On-demand connection --
248
249
/**
250
* Establish a relay connection to a cached tunnel. Called on demand
251
* when the user invokes the browse action on an online-but-not-connected tunnel.
252
*/
253
private _connectTunnel(address: string, options: { readonly userInitiated: boolean }): Promise<void> {
254
const existing = this._pendingConnects.get(address);
255
if (existing) {
256
return existing;
257
}
258
259
const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);
260
const cached = this._tunnelService.getCachedTunnels().find(t => t.tunnelId === tunnelId);
261
if (!cached) {
262
return Promise.resolve();
263
}
264
265
// A new attempt is starting — cancel any scheduled reconnect timer;
266
// success/failure of this attempt will drive the next decision.
267
this._cancelReconnect(address);
268
269
const { attemptNumber, attemptStart, session, isReconnect } = this._beginConnectAttempt(address);
270
271
const promise = (async () => {
272
// Show a progress notification after a short delay so quick
273
// connects don't flash a notification. Only show for user-initiated
274
// connects; background auto-connects and reconnects stay silent.
275
let handle: { close(): void } | undefined;
276
const timer = options.userInitiated ? setTimeout(() => {
277
handle = this._notificationService.notify({
278
severity: Severity.Info,
279
message: nls.localize('tunnelConnecting', "Connecting to tunnel '{0}'...", cached.name),
280
progress: { infinite: true },
281
});
282
}, 1000) : undefined;
283
284
this._updateConnectionStatuses();
285
try {
286
const tunnelInfo: ITunnelInfo = {
287
tunnelId: cached.tunnelId,
288
clusterId: cached.clusterId,
289
name: cached.name,
290
tags: [],
291
protocolVersion: 5,
292
hostConnectionCount: 0,
293
};
294
await this._tunnelService.connect(tunnelInfo, cached.authProvider);
295
this._finishConnectAttempt(address, { success: true, attemptNumber, attemptStart, session, isReconnect });
296
} catch (err) {
297
this._logService.warn(`[TunnelAgentHost] Connect to ${cached.name} failed:`, err);
298
const errorCategory = this._categorizeError(err);
299
this._finishConnectAttempt(address, { success: false, attemptNumber, attemptStart, session, isReconnect, error: err });
300
// Clear the pending-connect entry BEFORE deciding what to do
301
// next; otherwise `_scheduleReconnect`'s in-flight guard
302
// (`_pendingConnects.has(address)`) would silently bail and
303
// we'd never re-arm the timer, leaving the tunnel stuck.
304
this._pendingConnects.delete(address);
305
306
// Auth failures are not worth retrying — a fresh token must
307
// be acquired by the user or by a session-change event. Pause
308
// immediately and let `_handleSessionsChange` resume us when
309
// a new session appears.
310
if (errorCategory === 'authExpired' || errorCategory === 'auth') {
311
this._pauseReconnect(address, errorCategory);
312
throw err;
313
}
314
315
const hostOnline = await this._probeHostOnline(cached.tunnelId);
316
if (hostOnline === false) {
317
this._pauseReconnect(address, 'hostOffline');
318
} else {
319
this._logService.info(`[TunnelAgentHost] Scheduling reconnect for ${address}`);
320
this._scheduleReconnect(address);
321
}
322
throw err;
323
} finally {
324
if (timer !== undefined) {
325
clearTimeout(timer);
326
}
327
handle?.close();
328
this._pendingConnects.delete(address);
329
this._updateConnectionStatuses();
330
}
331
})();
332
333
// Swallow the promise rejection here so unhandled rejection noise
334
// doesn't bubble up for the background reconnect path; callers that
335
// await `_connectTunnel` directly will still see it via their own `await`.
336
promise.catch(() => { /* handled via _scheduleReconnect */ });
337
338
this._pendingConnects.set(address, promise);
339
return promise;
340
}
341
342
/**
343
* Tear down the active tunnel relay for {@link address} and cancel any
344
* pending auto-reconnect. The cached tunnel entry is kept so the user
345
* can re-connect later; only the live WebSocket is closed.
346
*/
347
private async _disconnectTunnel(address: string): Promise<void> {
348
this._cancelReconnect(address);
349
this._resetReconnectState(address);
350
// Mark as explicitly disconnected so `_handleConnectionChanges` does
351
// not treat the impending Connected→(removed) transition as a
352
// reconnect-worthy drop.
353
this._previousStatuses.delete(address);
354
await this._tunnelService.disconnect(address);
355
}
356
357
/**
358
* Detect tunnel connections that transitioned from Connected to
359
* Disconnected and schedule an auto-reconnect.
360
*
361
* Important: we only trigger on a Connected → Disconnected transition
362
* where the connection entry is still present. If the entry has been
363
* removed from the service (e.g. the user clicked "Remove Remote"),
364
* we do NOT schedule a reconnect — that would override their intent.
365
*/
366
private _handleConnectionChanges(): void {
367
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
368
return;
369
}
370
371
const cachedAddresses = new Set(
372
this._tunnelService.getCachedTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`)
373
);
374
const currentStatuses = new Map<string, RemoteAgentHostConnectionStatus>();
375
for (const conn of this._remoteAgentHostService.connections) {
376
currentStatuses.set(conn.address, conn.status);
377
}
378
379
for (const address of cachedAddresses) {
380
const previous = this._previousStatuses.get(address);
381
const current = currentStatuses.get(address);
382
383
// Only schedule a reconnect on an explicit Connected→Disconnected
384
// transition. If the address is absent from the connection list,
385
// the user (or another code path) removed it — honour that.
386
const wasConnected = previous === RemoteAgentHostConnectionStatus.Connected;
387
const isExplicitlyDisconnected = current === RemoteAgentHostConnectionStatus.Disconnected;
388
389
if (wasConnected && isExplicitlyDisconnected && !this._pendingConnects.has(address)) {
390
this._logService.info(`[TunnelAgentHost] Connection lost for ${address}, scheduling reconnect`);
391
if (!this._connectSessions.has(address)) {
392
this._connectSessions.set(address, { startedAt: Date.now(), attempts: 0, isReconnect: true });
393
}
394
this._scheduleReconnect(address, /*immediate*/ true);
395
}
396
397
// Only track previous status while the entry is present so a
398
// future re-registration starts from a clean slate. If the
399
// entry disappeared (e.g. user-initiated removal), also cancel
400
// any already-scheduled reconnect and clear its backoff state
401
// so the removal is honoured even if a timer was already armed.
402
if (current !== undefined) {
403
this._previousStatuses.set(address, current);
404
} else {
405
this._previousStatuses.delete(address);
406
this._resetReconnectState(address);
407
}
408
}
409
410
// Drop previous-status entries for addresses no longer cached.
411
for (const address of [...this._previousStatuses.keys()]) {
412
if (!cachedAddresses.has(address)) {
413
this._previousStatuses.delete(address);
414
}
415
}
416
}
417
418
private _scheduleReconnect(address: string, immediate = false): void {
419
// Respect enablement and tunnel-still-cached.
420
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
421
return;
422
}
423
const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);
424
const cached = this._tunnelService.getCachedTunnels().find(t => t.tunnelId === tunnelId);
425
if (!cached) {
426
return;
427
}
428
429
// Already connected or a connect is in flight — nothing to do.
430
if (this._pendingConnects.has(address)) {
431
return;
432
}
433
const live = this._remoteAgentHostService.connections.find(c => c.address === address);
434
if (live && live.status === RemoteAgentHostConnectionStatus.Connected) {
435
this._clearReconnectBackoff(address);
436
return;
437
}
438
439
// Cancel any existing timer — we're rescheduling.
440
this._cancelReconnect(address);
441
442
const attempt = this._reconnectAttempts.get(address) ?? 0;
443
444
if (attempt >= RECONNECT_MAX_ATTEMPTS) {
445
this._pauseReconnect(address, 'maxAttemptsReached');
446
return;
447
}
448
449
const delay = immediate
450
? 0
451
: Math.min(RECONNECT_INITIAL_DELAY * Math.pow(2, attempt), RECONNECT_MAX_DELAY);
452
453
this._logService.info(
454
`[TunnelAgentHost] Scheduling reconnect for ${address} in ${delay}ms (attempt ${attempt + 1}/${RECONNECT_MAX_ATTEMPTS})`
455
);
456
457
const timer = setTimeout(() => {
458
this._reconnectTimeouts.delete(address);
459
460
// A manual (or other) connect may have started or completed while
461
// we were waiting. Re-check before counting this as a new attempt,
462
// otherwise `_connectTunnel` would just return the in-flight promise
463
// and we'd inflate the backoff counter without really trying again.
464
if (this._pendingConnects.has(address)) {
465
return;
466
}
467
const live = this._remoteAgentHostService.connections.find(c => c.address === address);
468
if (live && live.status === RemoteAgentHostConnectionStatus.Connected) {
469
this._clearReconnectBackoff(address);
470
return;
471
}
472
473
this._reconnectAttempts.set(address, attempt + 1);
474
this._connectTunnel(address, { userInitiated: false }).catch(() => { /* _connectTunnel already re-schedules on failure */ });
475
}, delay);
476
this._reconnectTimeouts.set(address, timer);
477
}
478
479
/**
480
* Best-effort probe of whether the host backing `tunnelId` is online
481
* (has any host connections). Returns `undefined` if we couldn't
482
* determine — caller should treat as "retry normally" in that case.
483
*/
484
private async _probeHostOnline(tunnelId: string): Promise<boolean | undefined> {
485
try {
486
const tunnels = await this._tunnelService.listTunnels({ silent: true });
487
if (!tunnels) {
488
return undefined;
489
}
490
const info = tunnels.find(t => t.tunnelId === tunnelId);
491
if (!info) {
492
return false;
493
}
494
return info.hostConnectionCount > 0;
495
} catch {
496
return undefined;
497
}
498
}
499
500
private _cancelReconnect(address: string): void {
501
const timer = this._reconnectTimeouts.get(address);
502
if (timer !== undefined) {
503
clearTimeout(timer);
504
this._reconnectTimeouts.delete(address);
505
}
506
}
507
508
/** Clear retry-backoff and pause state for an address. */
509
private _clearReconnectBackoff(address: string): void {
510
this._reconnectAttempts.delete(address);
511
this._reconnectPaused.delete(address);
512
this._hostOfflinePaused.delete(address);
513
}
514
515
/** Drop all reconnect + telemetry state for an address (e.g. on removal). */
516
private _resetReconnectState(address: string): void {
517
this._cancelReconnect(address);
518
this._clearReconnectBackoff(address);
519
this._connectSessions.delete(address);
520
}
521
522
/**
523
* React to auth session add/remove. Additions re-run discovery (a fresh
524
* token may unblock a previously auth-paused tunnel). Removals drop any
525
* tunnel state that depended on that provider — otherwise we'd sit on a
526
* stale auth pause forever, or hammer a provider whose session is gone.
527
*/
528
private _handleSessionsChange(e: { providerId: string; label: string; event: AuthenticationSessionsChangeEvent }): void {
529
const added = (e.event.added?.length ?? 0) > 0;
530
const removed = (e.event.removed?.length ?? 0) > 0;
531
532
if (removed) {
533
const cached = this._tunnelService.getCachedTunnels();
534
for (const tunnel of cached) {
535
if (tunnel.authProvider !== e.providerId) {
536
continue;
537
}
538
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
539
this._logService.info(
540
`[TunnelAgentHost] Auth session removed for ${e.providerId}; tearing down ${address}.`
541
);
542
this._resetReconnectState(address);
543
// Best-effort disconnect — the transport may already be dead.
544
this._tunnelService.disconnect(address).catch(() => { /* ignore */ });
545
}
546
}
547
548
if (added) {
549
this._logService.info(`[TunnelAgentHost] ${e.providerId} session added; resuming reconnects and rediscovering.`);
550
this._resumeReconnects('sessionAdded');
551
this._silentStatusCheck();
552
}
553
}
554
555
/**
556
* Stop auto-reconnecting for an address until a wake/online/visibility
557
* event resumes us, and close out any active telemetry session.
558
*/
559
private _pauseReconnect(address: string, reason: TunnelConnectFailureReason): void {
560
this._cancelReconnect(address);
561
this._reconnectAttempts.delete(address);
562
this._reconnectPaused.add(address);
563
if (reason === 'hostOffline') {
564
this._hostOfflinePaused.add(address);
565
} else {
566
this._hostOfflinePaused.delete(address);
567
}
568
this._logService.info(
569
`[TunnelAgentHost] Pausing auto-reconnect for ${address} (${reason}); ` +
570
`will resume on network-online, tab-visible, session change, or next status check.`
571
);
572
const session = this._connectSessions.get(address);
573
if (session) {
574
logTunnelConnectResolved(this._telemetryService, {
575
isReconnect: session.isReconnect,
576
totalAttempts: session.attempts,
577
totalDurationMs: Date.now() - session.startedAt,
578
success: false,
579
failureReason: reason,
580
});
581
this._connectSessions.delete(address);
582
}
583
}
584
585
/**
586
* Begin (or continue) a connect telemetry session for `address` and
587
* return the bookkeeping needed to later finish the attempt. A session
588
* already exists if `_handleConnectionChanges` marked this as a
589
* reconnect cycle; otherwise this starts a fresh initial-connect session.
590
*/
591
private _beginConnectAttempt(address: string): { session: { startedAt: number; attempts: number; isReconnect: boolean }; attemptNumber: number; attemptStart: number; isReconnect: boolean } {
592
let session = this._connectSessions.get(address);
593
if (!session) {
594
session = { startedAt: Date.now(), attempts: 0, isReconnect: false };
595
this._connectSessions.set(address, session);
596
}
597
session.attempts++;
598
return { session, attemptNumber: session.attempts, attemptStart: Date.now(), isReconnect: session.isReconnect };
599
}
600
601
/**
602
* Finalize the telemetry for a single connect attempt. On success, also
603
* clears backoff state and closes the session; on failure, only the
604
* per-attempt event is emitted (the caller decides whether to retry).
605
*/
606
private _finishConnectAttempt(address: string, args: {
607
success: boolean;
608
attemptNumber: number;
609
attemptStart: number;
610
session: { startedAt: number; attempts: number; isReconnect: boolean };
611
isReconnect: boolean;
612
error?: unknown;
613
}): void {
614
const { success, attemptNumber, attemptStart, session, isReconnect, error } = args;
615
const durationMs = Date.now() - attemptStart;
616
if (success) {
617
this._clearReconnectBackoff(address);
618
logTunnelConnectAttempt(this._telemetryService, { isReconnect, attempt: attemptNumber, durationMs, success: true });
619
logTunnelConnectResolved(this._telemetryService, { isReconnect, totalAttempts: attemptNumber, totalDurationMs: Date.now() - session.startedAt, success: true });
620
this._connectSessions.delete(address);
621
} else {
622
logTunnelConnectAttempt(this._telemetryService, { isReconnect, attempt: attemptNumber, durationMs, success: false, errorCategory: this._categorizeError(error) });
623
}
624
}
625
626
private _categorizeError(err: unknown): TunnelConnectErrorCategory {
627
const message = err instanceof Error ? err.message : String(err);
628
// Expired / invalid credential — callers short-circuit this category
629
// to avoid burning retry budget on a token the user has to refresh.
630
if (/\b(401|403)\b|token.*expired|expired.*token|invalid[_ -]?grant/i.test(message)) {
631
return 'authExpired';
632
}
633
// Match authentication-specific language but NOT "connection token"
634
// or other protocol uses of the word "token".
635
if (/authenticat|unauthoriz|auth.*(fail|error|invalid)/i.test(message)) {
636
return 'auth';
637
}
638
if (/WebSocket relay connection failed|failed to connect to relay/i.test(message)) {
639
return 'relayConnectionFailed';
640
}
641
if (/network|fetch|offline|ECONN|ENOTFOUND|ETIMEDOUT/i.test(message)) {
642
return 'network';
643
}
644
return 'other';
645
}
646
647
/**
648
* Invoked on `online` / `visibilitychange→visible`. Kicks off an
649
* immediate attempt for any disconnected cached tunnel.
650
*
651
* Rate-limited: at most one resume per RESUME_RATE_LIMIT_MS so that
652
* rapid tab toggling can't hammer a permanently broken endpoint with
653
* an unbounded number of attempt bursts. Resumes the normal backoff
654
* sequence (by clearing the pause flag) rather than zeroing the
655
* attempt counter.
656
*/
657
private _resumeReconnects(trigger: 'wake' | 'visible' | 'sessionAdded'): void {
658
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
659
return;
660
}
661
662
// Rate-limit rapid wake/visibility events (e.g. alt-tab bursts or
663
// flaky Wi-Fi toggling online/offline) so we don't hammer the relay
664
// with immediate retries. This is an event-smoothing gate, not an
665
// error-backoff — that's handled by `_scheduleReconnect`.
666
const now = Date.now();
667
if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) {
668
return;
669
}
670
this._lastResumeAt = now;
671
672
const cached = this._tunnelService.getCachedTunnels();
673
for (const tunnel of cached) {
674
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
675
if (this._pendingConnects.has(address)) {
676
continue;
677
}
678
const live = this._remoteAgentHostService.connections.find(c => c.address === address);
679
if (live && live.status === RemoteAgentHostConnectionStatus.Connected) {
680
continue;
681
}
682
683
this._logService.info(`[TunnelAgentHost] Resuming reconnect for ${address} (trigger: ${trigger})`);
684
// If we were paused (exhausted the backoff budget), give a fresh
685
// budget since the wake event is itself evidence the environment
686
// has changed. Otherwise keep the current attempt counter so an
687
// in-progress backoff isn't short-circuited.
688
if (this._reconnectPaused.has(address)) {
689
this._clearReconnectBackoff(address);
690
}
691
this._scheduleReconnect(address, /*immediate*/ true);
692
}
693
}
694
695
/** Drop reconnect state for addresses whose tunnel is no longer cached. */
696
private _pruneReconnectState(): void {
697
const cachedAddresses = new Set(
698
this._tunnelService.getCachedTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`)
699
);
700
const tracked = new Set<string>([
701
...this._reconnectTimeouts.keys(),
702
...this._reconnectAttempts.keys(),
703
...this._reconnectPaused,
704
...this._connectSessions.keys(),
705
]);
706
for (const address of tracked) {
707
if (!cachedAddresses.has(address)) {
708
this._resetReconnectState(address);
709
}
710
}
711
}
712
713
// -- Silent status check --
714
715
private async _silentStatusCheck(): Promise<void> {
716
const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);
717
if (!enabled) {
718
this._initialStatusChecked = true;
719
this._updateConnectionStatuses();
720
return;
721
}
722
723
this._lastStatusCheck = Date.now();
724
725
// Fetch tunnel list silently to check online status
726
let onlineTunnels: ITunnelInfo[] | undefined;
727
try {
728
onlineTunnels = await this._tunnelService.listTunnels({ silent: true });
729
} catch {
730
// No cached token or network error — leave statuses as-is
731
this._initialStatusChecked = true;
732
this._updateConnectionStatuses();
733
return;
734
}
735
736
const cached = this._tunnelService.getCachedTunnels();
737
if (onlineTunnels) {
738
const onlineIds = new Set(onlineTunnels.map(t => t.tunnelId));
739
// Remove cached tunnels that no longer exist on the account
740
for (const tunnel of cached) {
741
if (!onlineIds.has(tunnel.tunnelId)) {
742
this._tunnelService.removeCachedTunnel(tunnel.tunnelId);
743
}
744
}
745
746
// Auto-cache online tunnels that aren't cached yet so they
747
// appear in the UI on first discovery (e.g. fresh web session).
748
// Pass 'github' as authProvider so _handleSessionsChange can
749
// match these tunnels for teardown on session removal.
750
const cachedIds = new Set(cached.map(t => t.tunnelId));
751
for (const tunnel of onlineTunnels) {
752
if (!cachedIds.has(tunnel.tunnelId) && tunnel.hostConnectionCount > 0) {
753
this._tunnelService.cacheTunnel(tunnel, 'github');
754
}
755
}
756
757
// Update online/offline status based on hostConnectionCount.
758
// For tunnels, Connected means "host is online" (clickable to connect),
759
// Disconnected means "host is offline". Actual relay connection
760
// establishment happens when the user clicks the tunnel (or via
761
// auto-connect below when enabled).
762
const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t]));
763
for (const [address, provider] of this._providerInstances) {
764
// Skip tunnels that already have an active relay connection
765
const hasConnection = this._remoteAgentHostService.connections.some(
766
c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected
767
);
768
if (hasConnection) {
769
continue;
770
}
771
772
const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);
773
const info = onlineTunnelMap.get(tunnelId);
774
if (info && info.hostConnectionCount > 0) {
775
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected);
776
777
// If we paused reconnects because the host had gone
778
// offline, the status check is our cue to resume —
779
// don't wait for a wake/visibility event. Covers the
780
// common "my laptop came back, the remote host came
781
// back first" scenario deterministically.
782
if (this._hostOfflinePaused.has(address)) {
783
this._logService.info(
784
`[TunnelAgentHost] Host came back online for ${address}; auto-resuming reconnect.`
785
);
786
this._clearReconnectBackoff(address);
787
this._scheduleReconnect(address, /*immediate*/ true);
788
}
789
} else {
790
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);
791
// Host is not online — drop any cached sessions we were
792
// showing for it so the UI doesn't list stale entries.
793
provider.unpublishCachedSessions();
794
}
795
}
796
797
// Auto-connect online tunnels that aren't connected yet when the
798
// user has opted into auto-connect (default on). This mirrors the
799
// web embedder behaviour where no workspace picker is available
800
// to trigger manual connection.
801
const autoConnect = this._configurationService.getValue<boolean>(RemoteAgentHostAutoConnectSettingId);
802
if (autoConnect) {
803
for (const tunnel of onlineTunnels) {
804
if (tunnel.hostConnectionCount > 0) {
805
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
806
const alreadyConnected = this._remoteAgentHostService.connections.some(
807
c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected
808
);
809
if (!alreadyConnected) {
810
this._connectTunnel(address, { userInitiated: false });
811
}
812
}
813
}
814
}
815
}
816
817
this._initialStatusChecked = true;
818
this._updateConnectionStatuses();
819
}
820
}
821
822
registerWorkbenchContribution2(TunnelAgentHostContribution.ID, TunnelAgentHostContribution, WorkbenchPhase.AfterRestored);
823
824