Path: blob/main/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts
13394 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*--------------------------------------------------------------------------------------------*/45// Service implementation that manages WebSocket connections to remote agent6// host processes. Reads addresses from the `chat.remoteAgentHosts` setting7// and maintains connections, reconnecting as the setting changes.89import { Emitter } from '../../../base/common/event.js';10import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';11import { DeferredPromise, raceTimeout } from '../../../base/common/async.js';12import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js';13import { IInstantiationService } from '../../instantiation/common/instantiation.js';14import { ILogService } from '../../log/common/log.js';1516import type { IAgentConnection } from '../common/agentService.js';17import {18IRemoteAgentHostService,19RemoteAgentHostConnectionStatus,20RemoteAgentHostEntryType,21RemoteAgentHostsEnabledSettingId,22RemoteAgentHostsSettingId,23entryToRawEntry,24getEntryAddress,25rawEntryToEntry,26type IRawRemoteAgentHostEntry,27type IRemoteAgentHostConnectionInfo,28type IRemoteAgentHostEntry,29} from '../common/remoteAgentHostService.js';30import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';31import { WebSocketClientTransport } from './webSocketClientTransport.js';32import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js';33import { isDefined } from '../../../base/common/types.js';3435/** Tracks a single remote connection through its lifecycle. */36interface IConnectionEntry {37readonly store: DisposableStore;38readonly client: RemoteAgentHostProtocolClient;39connected: boolean;40/** Current connection status for UI display. */41status: RemoteAgentHostConnectionStatus;42}4344export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService {45private static readonly ConnectionWaitTimeout = 10000;46/** Initial reconnect delay in milliseconds. */47private static readonly ReconnectInitialDelay = 1000;48/** Maximum reconnect delay in milliseconds. */49private static readonly ReconnectMaxDelay = 30000;5051declare readonly _serviceBrand: undefined;5253private readonly _onDidChangeConnections = this._register(new Emitter<void>());54readonly onDidChangeConnections = this._onDidChangeConnections.event;5556private readonly _entries = new Map<string, IConnectionEntry>();57private readonly _names = new Map<string, string>();58private readonly _tokens = new Map<string, string | undefined>();59/**60* Stores the original {@link IRemoteAgentHostEntry} for connections61* registered via {@link addManagedConnection}. This is needed because62* tunnel entries are not persisted to settings and therefore don't63* appear in {@link configuredEntries}.64*/65private readonly _registeredEntries = new Map<string, IRemoteAgentHostEntry>();66private readonly _pendingConnectionWaits = new Map<string, DeferredPromise<IRemoteAgentHostConnectionInfo>>();67/** Pending reconnect timeouts, keyed by normalized address. */68private readonly _reconnectTimeouts = new Map<string, ReturnType<typeof setTimeout>>();69/** Current reconnect attempt count per address for exponential backoff. */70private readonly _reconnectAttempts = new Map<string, number>();7172constructor(73@IConfigurationService private readonly _configurationService: IConfigurationService,74@IInstantiationService private readonly _instantiationService: IInstantiationService,75@ILogService private readonly _logService: ILogService,76) {77super();7879// React to setting changes80this._register(this._configurationService.onDidChangeConfiguration(e => {81if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) {82this._reconcileConnections();83}84}));8586// Initial connection87this._reconcileConnections();88}8990get connections(): readonly IRemoteAgentHostConnectionInfo[] {91const result: IRemoteAgentHostConnectionInfo[] = [];92for (const [address, entry] of this._entries) {93result.push({94address,95name: this._names.get(address) ?? address,96clientId: entry.client.clientId,97defaultDirectory: entry.client.defaultDirectory,98status: entry.status,99});100}101return result;102}103104get configuredEntries(): readonly IRemoteAgentHostEntry[] {105return this._getConfiguredEntries().map(e => {106if (e.connection.type === RemoteAgentHostEntryType.Tunnel) {107return e;108}109return { ...e, connection: { ...e.connection, address: normalizeRemoteAgentHostAddress(e.connection.address) } };110});111}112113getConnection(address: string): IAgentConnection | undefined {114const normalized = normalizeRemoteAgentHostAddress(address);115const entry = this._entries.get(normalized);116return entry?.connected ? entry.client : undefined;117}118119getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined {120const normalized = normalizeRemoteAgentHostAddress(address);121// Check dynamically registered entries first (e.g. tunnel connections122// that are not persisted to settings).123const registered = this._registeredEntries.get(normalized);124if (registered) {125return registered;126}127// Fall back to configured entries from settings.128return this.configuredEntries.find(129e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized130);131}132133reconnect(address: string): void {134const normalized = normalizeRemoteAgentHostAddress(address);135136// SSH/tunnel entries are reconnected by their respective services137const configuredEntry = this._getConfiguredEntries().find(138e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized139);140if (configuredEntry && configuredEntry.connection.type !== RemoteAgentHostEntryType.WebSocket) {141return;142}143144const token = this._tokens.get(normalized);145146// Cancel any pending reconnect147this._cancelReconnect(normalized);148this._reconnectAttempts.delete(normalized);149150// Tear down existing connection if present151const entry = this._entries.get(normalized);152if (entry) {153this._entries.delete(normalized);154entry.store.dispose();155}156157// Start fresh connection attempt158this._connectTo(normalized, token);159}160161async addRemoteAgentHost(input: IRemoteAgentHostEntry): Promise<IRemoteAgentHostConnectionInfo> {162if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {163throw new Error('Remote agent host connections are not enabled.');164}165166const entry: IRemoteAgentHostEntry = input.connection.type === RemoteAgentHostEntryType.Tunnel167? input168: { ...input, connection: { ...input.connection, address: normalizeRemoteAgentHostAddress(input.connection.address) } };169const address = getEntryAddress(entry);170const existingConnection = this._getConnectionInfo(address);171await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));172173if (existingConnection) {174return {175...existingConnection,176name: entry.name,177};178}179180// SSH entries are connected externally — just persist181// the entry and return a disconnected placeholder. The connection182// will be established by the SSH contribution.183if (entry.connection.type === RemoteAgentHostEntryType.SSH) {184return {185address,186name: entry.name,187clientId: '',188status: RemoteAgentHostConnectionStatus.Disconnected,189};190}191192const connectedConnection = this._getConnectionInfo(address);193if (connectedConnection) {194return connectedConnection;195}196197const wait = this._getOrCreateConnectionWait(address);198const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => {199this._pendingConnectionWaits.delete(address);200});201if (!connection) {202throw new Error(`Timed out connecting to ${address}`);203}204205return connection;206}207208async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise<IRemoteAgentHostConnectionInfo> {209const address = getEntryAddress(entry);210211// Dispose any existing entry for this address to avoid leaking212// old protocol clients and relay transports on reconnect.213const existingEntry = this._entries.get(address);214if (existingEntry) {215this._entries.delete(address);216existingEntry.store.dispose();217}218219const store = new DisposableStore();220221// Create a connection entry wrapping the pre-connected client222const protocolClient = connection as RemoteAgentHostProtocolClient;223store.add(protocolClient);224// Tear the underlying transport (e.g. SSH/tunnel relay) down with225// the entry. This is what makes "Remove Remote" actually close the226// shared-process tunnel and stop the remote agent host process.227if (transportDisposable) {228store.add(transportDisposable);229}230const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected };231this._entries.set(address, connEntry);232this._names.set(address, entry.name);233this._registeredEntries.set(address, entry);234if (entry.connectionToken) {235this._tokens.set(address, entry.connectionToken);236}237238store.add(protocolClient.onDidClose(() => {239if (this._entries.get(address) === connEntry) {240connEntry.connected = false;241connEntry.status = RemoteAgentHostConnectionStatus.Disconnected;242this._onDidChangeConnections.fire();243}244}));245246// Persist entries — await so that the config is written before247// onDidChangeConnections fires, ensuring _reconcile creates the provider.248// Tunnel entries are filtered out by _storeConfiguredEntries automatically.249await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));250251this._onDidChangeConnections.fire();252253return {254address,255name: entry.name,256clientId: protocolClient.clientId,257defaultDirectory: protocolClient.defaultDirectory,258status: RemoteAgentHostConnectionStatus.Connected,259};260}261262async removeRemoteAgentHost(address: string): Promise<void> {263const normalized = normalizeRemoteAgentHostAddress(address);264// This setting is only used in the sessions app (user scope), so we265// don't need to inspect per-scope values like _upsertConfiguredEntry does.266const entries = this._getConfiguredEntries().filter(267e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) !== normalized268);269await this._storeConfiguredEntries(entries);270271// Eagerly clear in-memory state so the UI updates immediately272// (the config change listener will reconcile, but this is instant).273this._names.delete(normalized);274this._tokens.delete(normalized);275this._registeredEntries.delete(normalized);276this._cancelReconnect(normalized);277this._reconnectAttempts.delete(normalized);278this._removeConnection(normalized);279}280281private _removeConnection(address: string): void {282const entry = this._entries.get(address);283if (entry) {284this._entries.delete(address);285entry.store.dispose();286this._rejectPendingConnectionWait(address, new Error(`Connection closed: ${address}`));287this._onDidChangeConnections.fire();288}289}290291private _reconcileConnections(): void {292if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {293// Disconnect all when disabled294for (const address of [...this._entries.keys()]) {295this._cancelReconnect(address);296this._removeConnection(address);297}298this._names.clear();299this._tokens.clear();300this._reconnectAttempts.clear();301return;302}303304const rawEntries = (this._configurationService.getValue<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined);305const entriesWithAddress = rawEntries.map(e => ({ entry: e, address: normalizeRemoteAgentHostAddress(getEntryAddress(e)) }));306const desired = new Set(entriesWithAddress.map(e => e.address));307308this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`);309310// Update name map and detect name changes for existing connections311let namesChanged = false;312const oldNames = new Map(this._names);313this._names.clear();314this._tokens.clear();315for (const { entry, address } of entriesWithAddress) {316this._names.set(address, entry.name);317this._tokens.set(address, entry.connectionToken);318if (this._entries.has(address) && oldNames.get(address) !== entry.name) {319namesChanged = true;320}321}322323// Remove connections no longer in the setting324for (const address of [...this._entries.keys()]) {325if (!desired.has(address)) {326this._logService.info(`[RemoteAgentHost] Disconnecting from ${address}`);327this._cancelReconnect(address);328this._reconnectAttempts.delete(address);329this._removeConnection(address);330}331}332333// Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService,334// and skip tunnel entries — those are handled by ITunnelAgentHostService)335for (const { entry, address } of entriesWithAddress) {336if (!this._entries.has(address) && entry.connection.type === RemoteAgentHostEntryType.WebSocket) {337this._connectTo(address, entry.connectionToken);338}339}340341// If only names changed (no add/remove), notify so the UI updates342if (namesChanged) {343this._onDidChangeConnections.fire();344}345}346347private _connectTo(address: string, connectionToken?: string): void {348// Dispose any existing entry for this address before creating a new one349// to avoid leaking disposables on reconnect.350const existingEntry = this._entries.get(address);351if (existingEntry) {352this._entries.delete(address);353existingEntry.store.dispose();354}355356const store = new DisposableStore();357const transport = store.add(new WebSocketClientTransport(address, connectionToken));358const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport));359const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting };360this._entries.set(address, entry);361362// Guard against stale callbacks: only act if the363// current entry for this address is still the one we created.364const isCurrentEntry = () => this._entries.get(address) === entry;365366store.add(client.onDidClose(() => {367if (!isCurrentEntry()) {368return;369}370this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`);371entry.connected = false;372entry.status = RemoteAgentHostConnectionStatus.Disconnected;373this._onDidChangeConnections.fire();374// Schedule reconnect if the address is still configured375this._scheduleReconnect(address, connectionToken);376}));377378this._logService.info(`[RemoteAgentHost] Connecting to ${address}`);379this._onDidChangeConnections.fire();380client.connect().then(() => {381if (store.isDisposed) {382return; // removed before connect resolved383}384this._logService.info(`[RemoteAgentHost] Connected to ${address}`);385entry.connected = true;386entry.status = RemoteAgentHostConnectionStatus.Connected;387this._reconnectAttempts.delete(address);388this._resolvePendingConnectionWait(address);389this._onDidChangeConnections.fire();390}).catch((err: unknown) => {391if (!isCurrentEntry()) {392return;393}394this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}. Verify address and connectionToken`, err);395entry.status = RemoteAgentHostConnectionStatus.Disconnected;396// Clean up the failed entry397this._entries.delete(address);398entry.store.dispose();399this._rejectPendingConnectionWait(address, err);400this._onDidChangeConnections.fire();401// Schedule reconnect if the address is still configured402this._scheduleReconnect(address, connectionToken);403});404}405406/**407* Schedule a reconnect attempt with exponential backoff.408* Only reconnects if the address is still in the configured entries.409*/410private _scheduleReconnect(address: string, connectionToken?: string): void {411// Don't reconnect if the address was removed from settings412if (!this._isAddressConfigured(address)) {413this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: no longer configured`);414return;415}416417const attempt = (this._reconnectAttempts.get(address) ?? 0) + 1;418this._reconnectAttempts.set(address, attempt);419const delay = Math.min(420RemoteAgentHostService.ReconnectInitialDelay * Math.pow(2, attempt - 1),421RemoteAgentHostService.ReconnectMaxDelay,422);423424this._logService.info(`[RemoteAgentHost] Scheduling reconnect to ${address} in ${delay}ms (attempt ${attempt})`);425426this._cancelReconnect(address);427const timeout = setTimeout(() => {428this._reconnectTimeouts.delete(address);429if (this._isAddressConfigured(address)) {430this._connectTo(address, connectionToken ?? this._tokens.get(address));431}432}, delay);433this._reconnectTimeouts.set(address, timeout);434}435436/** Cancel a pending reconnect timeout for the given address. */437private _cancelReconnect(address: string): void {438const timeout = this._reconnectTimeouts.get(address);439if (timeout !== undefined) {440clearTimeout(timeout);441this._reconnectTimeouts.delete(address);442}443}444445/** Check whether the given normalized address is still in the configured entries. */446private _isAddressConfigured(address: string): boolean {447const entries = this._getConfiguredEntries();448return entries.some(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === address);449}450451private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined {452return this.connections.find(connection => connection.address === address && connection.status === RemoteAgentHostConnectionStatus.Connected);453}454455private _getConfiguredEntries(): IRemoteAgentHostEntry[] {456return (this._configurationService.getValue<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined);457}458459private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] {460// Read from the same scope we'll write to, so we don't accidentally461// merge entries from an overriding scope (e.g. workspace) into the462// user scope and then lose them on the next read.463const target = this._getConfigurationTarget();464const inspected = this._configurationService.inspect<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId);465let configuredRaw: readonly IRawRemoteAgentHostEntry[];466switch (target) {467case ConfigurationTarget.USER_LOCAL:468configuredRaw = inspected.userLocalValue ?? [];469break;470case ConfigurationTarget.USER_REMOTE:471configuredRaw = inspected.userRemoteValue ?? [];472break;473default:474configuredRaw = inspected.userValue ?? [];475break;476}477478const configuredEntries = configuredRaw.map(rawEntryToEntry).filter((e): e is IRemoteAgentHostEntry => e !== undefined);479const normalizedAddress = normalizeRemoteAgentHostAddress(getEntryAddress(entry));480const existingIndex = configuredEntries.findIndex(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalizedAddress);481if (existingIndex === -1) {482return [...configuredEntries, entry];483}484485return configuredEntries.map((e, index) => index === existingIndex ? entry : e);486}487488private _getConfigurationTarget(): ConfigurationTarget {489const inspected = this._configurationService.inspect<IRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId);490if (inspected.userLocalValue !== undefined) {491return ConfigurationTarget.USER_LOCAL;492}493if (inspected.userRemoteValue !== undefined) {494return ConfigurationTarget.USER_REMOTE;495}496if (inspected.userValue !== undefined) {497return ConfigurationTarget.USER;498}499return ConfigurationTarget.USER;500}501502private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise<void> {503const raw = entries.map(entryToRawEntry).filter(isDefined);504await this._configurationService.updateValue(RemoteAgentHostsSettingId, raw, this._getConfigurationTarget());505}506507private _getOrCreateConnectionWait(address: string): DeferredPromise<IRemoteAgentHostConnectionInfo> {508let wait = this._pendingConnectionWaits.get(address);509if (wait) {510return wait;511}512513// If the connection is already available (fast connect resolved before514// the caller called us), return an immediately-completed wait.515const existingConnection = this._getConnectionInfo(address);516if (existingConnection) {517const immediateWait = new DeferredPromise<IRemoteAgentHostConnectionInfo>();518immediateWait.complete(existingConnection);519return immediateWait;520}521522wait = new DeferredPromise<IRemoteAgentHostConnectionInfo>();523this._pendingConnectionWaits.set(address, wait);524return wait;525}526527private _resolvePendingConnectionWait(address: string): void {528const wait = this._pendingConnectionWaits.get(address);529const connection = this._getConnectionInfo(address);530if (!wait || !connection) {531return;532}533534this._pendingConnectionWaits.delete(address);535void wait.complete(connection);536}537538private _rejectPendingConnectionWait(address: string, err: unknown): void {539const wait = this._pendingConnectionWaits.get(address);540if (!wait) {541return;542}543544this._pendingConnectionWaits.delete(address);545void wait.error(err);546}547548override dispose(): void {549for (const timeout of this._reconnectTimeouts.values()) {550clearTimeout(timeout);551}552this._reconnectTimeouts.clear();553this._reconnectAttempts.clear();554for (const [address, wait] of this._pendingConnectionWaits) {555void wait.error(new Error(`Remote agent host service disposed before connecting to ${address}`));556}557this._pendingConnectionWaits.clear();558for (const entry of this._entries.values()) {559entry.store.dispose();560}561this._entries.clear();562super.dispose();563}564}565566567