Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts
13394 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
// Service implementation that manages WebSocket connections to remote agent
7
// host processes. Reads addresses from the `chat.remoteAgentHosts` setting
8
// and maintains connections, reconnecting as the setting changes.
9
10
import { Emitter } from '../../../base/common/event.js';
11
import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
12
import { DeferredPromise, raceTimeout } from '../../../base/common/async.js';
13
import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js';
14
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
15
import { ILogService } from '../../log/common/log.js';
16
17
import type { IAgentConnection } from '../common/agentService.js';
18
import {
19
IRemoteAgentHostService,
20
RemoteAgentHostConnectionStatus,
21
RemoteAgentHostEntryType,
22
RemoteAgentHostsEnabledSettingId,
23
RemoteAgentHostsSettingId,
24
entryToRawEntry,
25
getEntryAddress,
26
rawEntryToEntry,
27
type IRawRemoteAgentHostEntry,
28
type IRemoteAgentHostConnectionInfo,
29
type IRemoteAgentHostEntry,
30
} from '../common/remoteAgentHostService.js';
31
import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';
32
import { WebSocketClientTransport } from './webSocketClientTransport.js';
33
import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js';
34
import { isDefined } from '../../../base/common/types.js';
35
36
/** Tracks a single remote connection through its lifecycle. */
37
interface IConnectionEntry {
38
readonly store: DisposableStore;
39
readonly client: RemoteAgentHostProtocolClient;
40
connected: boolean;
41
/** Current connection status for UI display. */
42
status: RemoteAgentHostConnectionStatus;
43
}
44
45
export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService {
46
private static readonly ConnectionWaitTimeout = 10000;
47
/** Initial reconnect delay in milliseconds. */
48
private static readonly ReconnectInitialDelay = 1000;
49
/** Maximum reconnect delay in milliseconds. */
50
private static readonly ReconnectMaxDelay = 30000;
51
52
declare readonly _serviceBrand: undefined;
53
54
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
55
readonly onDidChangeConnections = this._onDidChangeConnections.event;
56
57
private readonly _entries = new Map<string, IConnectionEntry>();
58
private readonly _names = new Map<string, string>();
59
private readonly _tokens = new Map<string, string | undefined>();
60
/**
61
* Stores the original {@link IRemoteAgentHostEntry} for connections
62
* registered via {@link addManagedConnection}. This is needed because
63
* tunnel entries are not persisted to settings and therefore don't
64
* appear in {@link configuredEntries}.
65
*/
66
private readonly _registeredEntries = new Map<string, IRemoteAgentHostEntry>();
67
private readonly _pendingConnectionWaits = new Map<string, DeferredPromise<IRemoteAgentHostConnectionInfo>>();
68
/** Pending reconnect timeouts, keyed by normalized address. */
69
private readonly _reconnectTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
70
/** Current reconnect attempt count per address for exponential backoff. */
71
private readonly _reconnectAttempts = new Map<string, number>();
72
73
constructor(
74
@IConfigurationService private readonly _configurationService: IConfigurationService,
75
@IInstantiationService private readonly _instantiationService: IInstantiationService,
76
@ILogService private readonly _logService: ILogService,
77
) {
78
super();
79
80
// React to setting changes
81
this._register(this._configurationService.onDidChangeConfiguration(e => {
82
if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) {
83
this._reconcileConnections();
84
}
85
}));
86
87
// Initial connection
88
this._reconcileConnections();
89
}
90
91
get connections(): readonly IRemoteAgentHostConnectionInfo[] {
92
const result: IRemoteAgentHostConnectionInfo[] = [];
93
for (const [address, entry] of this._entries) {
94
result.push({
95
address,
96
name: this._names.get(address) ?? address,
97
clientId: entry.client.clientId,
98
defaultDirectory: entry.client.defaultDirectory,
99
status: entry.status,
100
});
101
}
102
return result;
103
}
104
105
get configuredEntries(): readonly IRemoteAgentHostEntry[] {
106
return this._getConfiguredEntries().map(e => {
107
if (e.connection.type === RemoteAgentHostEntryType.Tunnel) {
108
return e;
109
}
110
return { ...e, connection: { ...e.connection, address: normalizeRemoteAgentHostAddress(e.connection.address) } };
111
});
112
}
113
114
getConnection(address: string): IAgentConnection | undefined {
115
const normalized = normalizeRemoteAgentHostAddress(address);
116
const entry = this._entries.get(normalized);
117
return entry?.connected ? entry.client : undefined;
118
}
119
120
getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined {
121
const normalized = normalizeRemoteAgentHostAddress(address);
122
// Check dynamically registered entries first (e.g. tunnel connections
123
// that are not persisted to settings).
124
const registered = this._registeredEntries.get(normalized);
125
if (registered) {
126
return registered;
127
}
128
// Fall back to configured entries from settings.
129
return this.configuredEntries.find(
130
e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized
131
);
132
}
133
134
reconnect(address: string): void {
135
const normalized = normalizeRemoteAgentHostAddress(address);
136
137
// SSH/tunnel entries are reconnected by their respective services
138
const configuredEntry = this._getConfiguredEntries().find(
139
e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized
140
);
141
if (configuredEntry && configuredEntry.connection.type !== RemoteAgentHostEntryType.WebSocket) {
142
return;
143
}
144
145
const token = this._tokens.get(normalized);
146
147
// Cancel any pending reconnect
148
this._cancelReconnect(normalized);
149
this._reconnectAttempts.delete(normalized);
150
151
// Tear down existing connection if present
152
const entry = this._entries.get(normalized);
153
if (entry) {
154
this._entries.delete(normalized);
155
entry.store.dispose();
156
}
157
158
// Start fresh connection attempt
159
this._connectTo(normalized, token);
160
}
161
162
async addRemoteAgentHost(input: IRemoteAgentHostEntry): Promise<IRemoteAgentHostConnectionInfo> {
163
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
164
throw new Error('Remote agent host connections are not enabled.');
165
}
166
167
const entry: IRemoteAgentHostEntry = input.connection.type === RemoteAgentHostEntryType.Tunnel
168
? input
169
: { ...input, connection: { ...input.connection, address: normalizeRemoteAgentHostAddress(input.connection.address) } };
170
const address = getEntryAddress(entry);
171
const existingConnection = this._getConnectionInfo(address);
172
await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));
173
174
if (existingConnection) {
175
return {
176
...existingConnection,
177
name: entry.name,
178
};
179
}
180
181
// SSH entries are connected externally — just persist
182
// the entry and return a disconnected placeholder. The connection
183
// will be established by the SSH contribution.
184
if (entry.connection.type === RemoteAgentHostEntryType.SSH) {
185
return {
186
address,
187
name: entry.name,
188
clientId: '',
189
status: RemoteAgentHostConnectionStatus.Disconnected,
190
};
191
}
192
193
const connectedConnection = this._getConnectionInfo(address);
194
if (connectedConnection) {
195
return connectedConnection;
196
}
197
198
const wait = this._getOrCreateConnectionWait(address);
199
const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => {
200
this._pendingConnectionWaits.delete(address);
201
});
202
if (!connection) {
203
throw new Error(`Timed out connecting to ${address}`);
204
}
205
206
return connection;
207
}
208
209
async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise<IRemoteAgentHostConnectionInfo> {
210
const address = getEntryAddress(entry);
211
212
// Dispose any existing entry for this address to avoid leaking
213
// old protocol clients and relay transports on reconnect.
214
const existingEntry = this._entries.get(address);
215
if (existingEntry) {
216
this._entries.delete(address);
217
existingEntry.store.dispose();
218
}
219
220
const store = new DisposableStore();
221
222
// Create a connection entry wrapping the pre-connected client
223
const protocolClient = connection as RemoteAgentHostProtocolClient;
224
store.add(protocolClient);
225
// Tear the underlying transport (e.g. SSH/tunnel relay) down with
226
// the entry. This is what makes "Remove Remote" actually close the
227
// shared-process tunnel and stop the remote agent host process.
228
if (transportDisposable) {
229
store.add(transportDisposable);
230
}
231
const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected };
232
this._entries.set(address, connEntry);
233
this._names.set(address, entry.name);
234
this._registeredEntries.set(address, entry);
235
if (entry.connectionToken) {
236
this._tokens.set(address, entry.connectionToken);
237
}
238
239
store.add(protocolClient.onDidClose(() => {
240
if (this._entries.get(address) === connEntry) {
241
connEntry.connected = false;
242
connEntry.status = RemoteAgentHostConnectionStatus.Disconnected;
243
this._onDidChangeConnections.fire();
244
}
245
}));
246
247
// Persist entries — await so that the config is written before
248
// onDidChangeConnections fires, ensuring _reconcile creates the provider.
249
// Tunnel entries are filtered out by _storeConfiguredEntries automatically.
250
await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));
251
252
this._onDidChangeConnections.fire();
253
254
return {
255
address,
256
name: entry.name,
257
clientId: protocolClient.clientId,
258
defaultDirectory: protocolClient.defaultDirectory,
259
status: RemoteAgentHostConnectionStatus.Connected,
260
};
261
}
262
263
async removeRemoteAgentHost(address: string): Promise<void> {
264
const normalized = normalizeRemoteAgentHostAddress(address);
265
// This setting is only used in the sessions app (user scope), so we
266
// don't need to inspect per-scope values like _upsertConfiguredEntry does.
267
const entries = this._getConfiguredEntries().filter(
268
e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) !== normalized
269
);
270
await this._storeConfiguredEntries(entries);
271
272
// Eagerly clear in-memory state so the UI updates immediately
273
// (the config change listener will reconcile, but this is instant).
274
this._names.delete(normalized);
275
this._tokens.delete(normalized);
276
this._registeredEntries.delete(normalized);
277
this._cancelReconnect(normalized);
278
this._reconnectAttempts.delete(normalized);
279
this._removeConnection(normalized);
280
}
281
282
private _removeConnection(address: string): void {
283
const entry = this._entries.get(address);
284
if (entry) {
285
this._entries.delete(address);
286
entry.store.dispose();
287
this._rejectPendingConnectionWait(address, new Error(`Connection closed: ${address}`));
288
this._onDidChangeConnections.fire();
289
}
290
}
291
292
private _reconcileConnections(): void {
293
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
294
// Disconnect all when disabled
295
for (const address of [...this._entries.keys()]) {
296
this._cancelReconnect(address);
297
this._removeConnection(address);
298
}
299
this._names.clear();
300
this._tokens.clear();
301
this._reconnectAttempts.clear();
302
return;
303
}
304
305
const rawEntries = (this._configurationService.getValue<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined);
306
const entriesWithAddress = rawEntries.map(e => ({ entry: e, address: normalizeRemoteAgentHostAddress(getEntryAddress(e)) }));
307
const desired = new Set(entriesWithAddress.map(e => e.address));
308
309
this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`);
310
311
// Update name map and detect name changes for existing connections
312
let namesChanged = false;
313
const oldNames = new Map(this._names);
314
this._names.clear();
315
this._tokens.clear();
316
for (const { entry, address } of entriesWithAddress) {
317
this._names.set(address, entry.name);
318
this._tokens.set(address, entry.connectionToken);
319
if (this._entries.has(address) && oldNames.get(address) !== entry.name) {
320
namesChanged = true;
321
}
322
}
323
324
// Remove connections no longer in the setting
325
for (const address of [...this._entries.keys()]) {
326
if (!desired.has(address)) {
327
this._logService.info(`[RemoteAgentHost] Disconnecting from ${address}`);
328
this._cancelReconnect(address);
329
this._reconnectAttempts.delete(address);
330
this._removeConnection(address);
331
}
332
}
333
334
// Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService,
335
// and skip tunnel entries — those are handled by ITunnelAgentHostService)
336
for (const { entry, address } of entriesWithAddress) {
337
if (!this._entries.has(address) && entry.connection.type === RemoteAgentHostEntryType.WebSocket) {
338
this._connectTo(address, entry.connectionToken);
339
}
340
}
341
342
// If only names changed (no add/remove), notify so the UI updates
343
if (namesChanged) {
344
this._onDidChangeConnections.fire();
345
}
346
}
347
348
private _connectTo(address: string, connectionToken?: string): void {
349
// Dispose any existing entry for this address before creating a new one
350
// to avoid leaking disposables on reconnect.
351
const existingEntry = this._entries.get(address);
352
if (existingEntry) {
353
this._entries.delete(address);
354
existingEntry.store.dispose();
355
}
356
357
const store = new DisposableStore();
358
const transport = store.add(new WebSocketClientTransport(address, connectionToken));
359
const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport));
360
const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting };
361
this._entries.set(address, entry);
362
363
// Guard against stale callbacks: only act if the
364
// current entry for this address is still the one we created.
365
const isCurrentEntry = () => this._entries.get(address) === entry;
366
367
store.add(client.onDidClose(() => {
368
if (!isCurrentEntry()) {
369
return;
370
}
371
this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`);
372
entry.connected = false;
373
entry.status = RemoteAgentHostConnectionStatus.Disconnected;
374
this._onDidChangeConnections.fire();
375
// Schedule reconnect if the address is still configured
376
this._scheduleReconnect(address, connectionToken);
377
}));
378
379
this._logService.info(`[RemoteAgentHost] Connecting to ${address}`);
380
this._onDidChangeConnections.fire();
381
client.connect().then(() => {
382
if (store.isDisposed) {
383
return; // removed before connect resolved
384
}
385
this._logService.info(`[RemoteAgentHost] Connected to ${address}`);
386
entry.connected = true;
387
entry.status = RemoteAgentHostConnectionStatus.Connected;
388
this._reconnectAttempts.delete(address);
389
this._resolvePendingConnectionWait(address);
390
this._onDidChangeConnections.fire();
391
}).catch((err: unknown) => {
392
if (!isCurrentEntry()) {
393
return;
394
}
395
this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}. Verify address and connectionToken`, err);
396
entry.status = RemoteAgentHostConnectionStatus.Disconnected;
397
// Clean up the failed entry
398
this._entries.delete(address);
399
entry.store.dispose();
400
this._rejectPendingConnectionWait(address, err);
401
this._onDidChangeConnections.fire();
402
// Schedule reconnect if the address is still configured
403
this._scheduleReconnect(address, connectionToken);
404
});
405
}
406
407
/**
408
* Schedule a reconnect attempt with exponential backoff.
409
* Only reconnects if the address is still in the configured entries.
410
*/
411
private _scheduleReconnect(address: string, connectionToken?: string): void {
412
// Don't reconnect if the address was removed from settings
413
if (!this._isAddressConfigured(address)) {
414
this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: no longer configured`);
415
return;
416
}
417
418
const attempt = (this._reconnectAttempts.get(address) ?? 0) + 1;
419
this._reconnectAttempts.set(address, attempt);
420
const delay = Math.min(
421
RemoteAgentHostService.ReconnectInitialDelay * Math.pow(2, attempt - 1),
422
RemoteAgentHostService.ReconnectMaxDelay,
423
);
424
425
this._logService.info(`[RemoteAgentHost] Scheduling reconnect to ${address} in ${delay}ms (attempt ${attempt})`);
426
427
this._cancelReconnect(address);
428
const timeout = setTimeout(() => {
429
this._reconnectTimeouts.delete(address);
430
if (this._isAddressConfigured(address)) {
431
this._connectTo(address, connectionToken ?? this._tokens.get(address));
432
}
433
}, delay);
434
this._reconnectTimeouts.set(address, timeout);
435
}
436
437
/** Cancel a pending reconnect timeout for the given address. */
438
private _cancelReconnect(address: string): void {
439
const timeout = this._reconnectTimeouts.get(address);
440
if (timeout !== undefined) {
441
clearTimeout(timeout);
442
this._reconnectTimeouts.delete(address);
443
}
444
}
445
446
/** Check whether the given normalized address is still in the configured entries. */
447
private _isAddressConfigured(address: string): boolean {
448
const entries = this._getConfiguredEntries();
449
return entries.some(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === address);
450
}
451
452
private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined {
453
return this.connections.find(connection => connection.address === address && connection.status === RemoteAgentHostConnectionStatus.Connected);
454
}
455
456
private _getConfiguredEntries(): IRemoteAgentHostEntry[] {
457
return (this._configurationService.getValue<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined);
458
}
459
460
private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] {
461
// Read from the same scope we'll write to, so we don't accidentally
462
// merge entries from an overriding scope (e.g. workspace) into the
463
// user scope and then lose them on the next read.
464
const target = this._getConfigurationTarget();
465
const inspected = this._configurationService.inspect<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId);
466
let configuredRaw: readonly IRawRemoteAgentHostEntry[];
467
switch (target) {
468
case ConfigurationTarget.USER_LOCAL:
469
configuredRaw = inspected.userLocalValue ?? [];
470
break;
471
case ConfigurationTarget.USER_REMOTE:
472
configuredRaw = inspected.userRemoteValue ?? [];
473
break;
474
default:
475
configuredRaw = inspected.userValue ?? [];
476
break;
477
}
478
479
const configuredEntries = configuredRaw.map(rawEntryToEntry).filter((e): e is IRemoteAgentHostEntry => e !== undefined);
480
const normalizedAddress = normalizeRemoteAgentHostAddress(getEntryAddress(entry));
481
const existingIndex = configuredEntries.findIndex(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalizedAddress);
482
if (existingIndex === -1) {
483
return [...configuredEntries, entry];
484
}
485
486
return configuredEntries.map((e, index) => index === existingIndex ? entry : e);
487
}
488
489
private _getConfigurationTarget(): ConfigurationTarget {
490
const inspected = this._configurationService.inspect<IRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId);
491
if (inspected.userLocalValue !== undefined) {
492
return ConfigurationTarget.USER_LOCAL;
493
}
494
if (inspected.userRemoteValue !== undefined) {
495
return ConfigurationTarget.USER_REMOTE;
496
}
497
if (inspected.userValue !== undefined) {
498
return ConfigurationTarget.USER;
499
}
500
return ConfigurationTarget.USER;
501
}
502
503
private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise<void> {
504
const raw = entries.map(entryToRawEntry).filter(isDefined);
505
await this._configurationService.updateValue(RemoteAgentHostsSettingId, raw, this._getConfigurationTarget());
506
}
507
508
private _getOrCreateConnectionWait(address: string): DeferredPromise<IRemoteAgentHostConnectionInfo> {
509
let wait = this._pendingConnectionWaits.get(address);
510
if (wait) {
511
return wait;
512
}
513
514
// If the connection is already available (fast connect resolved before
515
// the caller called us), return an immediately-completed wait.
516
const existingConnection = this._getConnectionInfo(address);
517
if (existingConnection) {
518
const immediateWait = new DeferredPromise<IRemoteAgentHostConnectionInfo>();
519
immediateWait.complete(existingConnection);
520
return immediateWait;
521
}
522
523
wait = new DeferredPromise<IRemoteAgentHostConnectionInfo>();
524
this._pendingConnectionWaits.set(address, wait);
525
return wait;
526
}
527
528
private _resolvePendingConnectionWait(address: string): void {
529
const wait = this._pendingConnectionWaits.get(address);
530
const connection = this._getConnectionInfo(address);
531
if (!wait || !connection) {
532
return;
533
}
534
535
this._pendingConnectionWaits.delete(address);
536
void wait.complete(connection);
537
}
538
539
private _rejectPendingConnectionWait(address: string, err: unknown): void {
540
const wait = this._pendingConnectionWaits.get(address);
541
if (!wait) {
542
return;
543
}
544
545
this._pendingConnectionWaits.delete(address);
546
void wait.error(err);
547
}
548
549
override dispose(): void {
550
for (const timeout of this._reconnectTimeouts.values()) {
551
clearTimeout(timeout);
552
}
553
this._reconnectTimeouts.clear();
554
this._reconnectAttempts.clear();
555
for (const [address, wait] of this._pendingConnectionWaits) {
556
void wait.error(new Error(`Remote agent host service disposed before connecting to ${address}`));
557
}
558
this._pendingConnectionWaits.clear();
559
for (const entry of this._entries.values()) {
560
entry.store.dispose();
561
}
562
this._entries.clear();
563
super.dispose();
564
}
565
}
566
567