mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-10 16:58:55 -05:00
Agents web: improve connection stability and terminal reconnection (#312189)
* sessions: improve connection stability and terminal reconnection - WebSocketClientTransport: fail-fast on send to dead socket with force-close and _closeFired guard to prevent double onClose events - AgentHostPty: add reconnect() method with 10s hydration timeout and buffer clear before snapshot replay - AgentHostTerminalService: add reconnectTerminals() scoped by oldClientId, track active ptys with dispose cleanup - RemoteAgentHostContribution: trigger terminal reconnection on clientId change during reconnect - Telemetry: add socket/close, socket/sendDropped, socket/visibilityResumed, terminal/recovery events - Fix isMeasurement on boolean fields in existing telemetry * Update src/vs/sessions/common/sessionsTelemetry.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address review: gate terminal reconnect on clientId change, add reconnect tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,9 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans
|
||||
private _ws: WebSocket | undefined;
|
||||
private _malformedFrames = 0;
|
||||
|
||||
/** Guards against firing onClose more than once. */
|
||||
private _closeFired = false;
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this._ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
@@ -42,6 +45,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans
|
||||
private readonly _address: string,
|
||||
private readonly _connectionToken?: string,
|
||||
) {
|
||||
// TODO: @osortega remove console.logs
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -138,20 +142,44 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
this._onClose.fire();
|
||||
if (!this._closeFired) {
|
||||
this._closeFired = true;
|
||||
this._onClose.fire();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
// Error always precedes close - closing is handled in the close handler.
|
||||
this._onClose.fire();
|
||||
// Only fire if close hasn't already been fired (e.g. from send failure).
|
||||
if (!this._closeFired) {
|
||||
this._closeFired = true;
|
||||
this._onClose.fire();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void {
|
||||
/**
|
||||
* Send a message to the remote end. Returns `true` if the message was
|
||||
* sent, `false` if it was dropped (socket not open). On failure, the
|
||||
* transport is force-closed so reconnection is triggered immediately
|
||||
* rather than silently losing messages.
|
||||
*/
|
||||
send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): boolean {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
console.warn(
|
||||
`[WebSocketClientTransport] Message dropped: readyState=${this._ws?.readyState ?? 'no-socket'}`
|
||||
);
|
||||
// Force-close and fire onClose exactly once to trigger reconnection
|
||||
this._ws?.close(4001, 'send-on-dead-socket');
|
||||
if (!this._closeFired) {
|
||||
this._closeFired = true;
|
||||
this._onClose.fire();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
|
||||
@@ -138,10 +138,10 @@ type TunnelConnectAttemptEvent = {
|
||||
type TunnelConnectAttemptClassification = {
|
||||
owner: 'osortega';
|
||||
comment: 'Tracks individual agent-host tunnel connect attempts for performance and reliability.';
|
||||
isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' };
|
||||
isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' };
|
||||
attempt: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Attempt number within the current connect session (1-based).' };
|
||||
durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of this individual attempt in milliseconds.' };
|
||||
success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether this individual attempt succeeded.' };
|
||||
success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether this individual attempt succeeded.' };
|
||||
errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, authExpired, network, other); empty on success.' };
|
||||
};
|
||||
|
||||
@@ -166,10 +166,10 @@ type TunnelConnectResolvedEvent = {
|
||||
type TunnelConnectResolvedClassification = {
|
||||
owner: 'osortega';
|
||||
comment: 'Tracks overall agent-host tunnel connect session outcomes for reliability.';
|
||||
isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' };
|
||||
isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' };
|
||||
totalAttempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of attempts made before resolution.' };
|
||||
totalDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total elapsed time from session start to resolution in milliseconds.' };
|
||||
success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the connect session ultimately succeeded.' };
|
||||
success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the connect session ultimately succeeded.' };
|
||||
failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached, auth, authExpired); empty on success.' };
|
||||
};
|
||||
|
||||
@@ -182,3 +182,98 @@ export function logTunnelConnectResolved(telemetryService: ITelemetryService, da
|
||||
failureReason: data.failureReason ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Socket lifecycle telemetry ---
|
||||
|
||||
export type SocketCloseTrigger =
|
||||
| 'server'
|
||||
| 'sendOnDeadSocket'
|
||||
| 'visibility'
|
||||
| 'offline'
|
||||
| 'malformedFrames'
|
||||
| 'disposed'
|
||||
| 'error';
|
||||
|
||||
type SocketCloseEvent = {
|
||||
closeCode: number;
|
||||
wasClean: boolean;
|
||||
lifetimeMs: number;
|
||||
messagesSent: number;
|
||||
messagesReceived: number;
|
||||
messagesDropped: number;
|
||||
trigger: string;
|
||||
};
|
||||
|
||||
type SocketCloseClassification = {
|
||||
owner: 'osortega';
|
||||
comment: 'Tracks WebSocket close events for agent host connections to measure connection reliability.';
|
||||
closeCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'WebSocket close code.' };
|
||||
wasClean: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the close was clean.' };
|
||||
lifetimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the socket was alive in milliseconds.' };
|
||||
messagesSent: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages sent.' };
|
||||
messagesReceived: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages received.' };
|
||||
messagesDropped: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages dropped due to non-OPEN socket.' };
|
||||
trigger: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'What triggered the close (server, sendOnDeadSocket, visibility, offline, malformedFrames, disposed, error).' };
|
||||
};
|
||||
|
||||
export function logSocketClose(telemetryService: ITelemetryService, data: { closeCode: number; wasClean: boolean; lifetimeMs: number; messagesSent: number; messagesReceived: number; messagesDropped: number; trigger: SocketCloseTrigger }): void {
|
||||
telemetryService.publicLog2<SocketCloseEvent, SocketCloseClassification>('vscodeAgents.socket/close', data);
|
||||
}
|
||||
|
||||
// --- Send dropped telemetry ---
|
||||
|
||||
type SendDroppedEvent = {
|
||||
readyState: number;
|
||||
timeSinceLastReceiveMs: number;
|
||||
timeSinceLastSendMs: number;
|
||||
};
|
||||
|
||||
type SendDroppedClassification = {
|
||||
owner: 'osortega';
|
||||
comment: 'Tracks when a message is silently dropped due to a non-OPEN WebSocket, indicating a zombie socket.';
|
||||
readyState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'WebSocket readyState at drop time (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).' };
|
||||
timeSinceLastReceiveMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last received message.' };
|
||||
timeSinceLastSendMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last sent message.' };
|
||||
};
|
||||
|
||||
export function logSendDropped(telemetryService: ITelemetryService, data: { readyState: number; timeSinceLastReceiveMs: number; timeSinceLastSendMs: number }): void {
|
||||
telemetryService.publicLog2<SendDroppedEvent, SendDroppedClassification>('vscodeAgents.socket/sendDropped', data);
|
||||
}
|
||||
|
||||
// --- Visibility resumed telemetry ---
|
||||
|
||||
type VisibilityResumedEvent = {
|
||||
hiddenDurationMs: number;
|
||||
socketAlive: boolean;
|
||||
forceClosed: boolean;
|
||||
};
|
||||
|
||||
type VisibilityResumedClassification = {
|
||||
owner: 'osortega';
|
||||
comment: 'Tracks tab visibility resume events to measure zombie socket detection effectiveness.';
|
||||
hiddenDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the tab was hidden in milliseconds.' };
|
||||
socketAlive: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was alive after zombie detection check.' };
|
||||
forceClosed: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was force-closed on resume.' };
|
||||
};
|
||||
|
||||
export function logVisibilityResumed(telemetryService: ITelemetryService, data: { hiddenDurationMs: number; socketAlive: boolean; forceClosed: boolean }): void {
|
||||
telemetryService.publicLog2<VisibilityResumedEvent, VisibilityResumedClassification>('vscodeAgents.socket/visibilityResumed', data);
|
||||
}
|
||||
|
||||
// --- Terminal recovery telemetry ---
|
||||
|
||||
type TerminalRecoveryEvent = {
|
||||
recoveredCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
type TerminalRecoveryClassification = {
|
||||
owner: 'osortega';
|
||||
comment: 'Tracks terminal reconnection outcomes after agent host disconnect.';
|
||||
recoveredCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of terminals successfully reconnected.' };
|
||||
totalCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of active terminals at reconnect time.' };
|
||||
};
|
||||
|
||||
export function logTerminalRecovery(telemetryService: ITelemetryService, data: { recoveredCount: number; totalCount: number }): void {
|
||||
telemetryService.publicLog2<TerminalRecoveryEvent, TerminalRecoveryClassification>('vscodeAgents.terminal/recovery', data);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ import { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvide
|
||||
import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';
|
||||
import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js';
|
||||
import { ISSHRemoteAgentHostService } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js';
|
||||
import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js';
|
||||
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
|
||||
import { logTerminalRecovery } from '../../../common/sessionsTelemetry.js';
|
||||
|
||||
/** Per-connection state bundle, disposed when a connection is removed. */
|
||||
class ConnectionState extends Disposable {
|
||||
@@ -99,6 +102,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
@ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService,
|
||||
@IStorageService private readonly _storageService: IStorageService,
|
||||
@IAgentPluginService private readonly _agentPluginService: IAgentPluginService,
|
||||
@IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService,
|
||||
@ITelemetryService private readonly _telemetryService: ITelemetryService,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -260,11 +265,34 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
}
|
||||
const existing = this._connections.get(connectionInfo.address);
|
||||
if (existing) {
|
||||
const nameChanged = existing.name !== connectionInfo.name;
|
||||
const clientIdChanged = existing.loggedConnection.clientId !== connectionInfo.clientId;
|
||||
|
||||
// If the name or clientId changed, tear down and re-register
|
||||
if (existing.name !== connectionInfo.name || existing.loggedConnection.clientId !== connectionInfo.clientId) {
|
||||
this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${existing.name !== connectionInfo.name}`);
|
||||
if (nameChanged || clientIdChanged) {
|
||||
this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${nameChanged}`);
|
||||
const oldClientId = existing.loggedConnection.clientId;
|
||||
this._connections.deleteAndDispose(connectionInfo.address);
|
||||
this._setupConnection(connectionInfo);
|
||||
|
||||
// Reconnect active terminals only when the backing
|
||||
// client changed. Name-only updates don't invalidate
|
||||
// subscriptions and would cause unnecessary buffer
|
||||
// clear/replay flicker.
|
||||
if (clientIdChanged) {
|
||||
const newConnection = this._remoteAgentHostService.getConnection(connectionInfo.address);
|
||||
if (newConnection) {
|
||||
this._agentHostTerminalService.reconnectTerminals(newConnection, oldClientId).then(
|
||||
({ recovered, total }) => {
|
||||
if (total > 0) {
|
||||
this._logService.info(`[RemoteAgentHost] Terminal reconnection: ${recovered}/${total} recovered`);
|
||||
logTerminalRecovery(this._telemetryService, { recoveredCount: recovered, totalCount: total });
|
||||
}
|
||||
},
|
||||
err => this._logService.warn('[RemoteAgentHost] Terminal reconnection failed', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._setupConnection(connectionInfo);
|
||||
|
||||
@@ -120,7 +120,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess {
|
||||
|
||||
constructor(
|
||||
id: number,
|
||||
private readonly _connection: IAgentConnection,
|
||||
private _connection: IAgentConnection,
|
||||
private readonly _terminalUri: URI,
|
||||
private readonly _options?: IAgentHostPtyOptions,
|
||||
) {
|
||||
@@ -378,6 +378,84 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess {
|
||||
// Not applicable
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect this pty to a new agent host connection. Tears down the
|
||||
* old subscription and re-subscribes with the new connection, replaying
|
||||
* content from the server-side snapshot. Terminal output during the
|
||||
* disconnect gap is a stream (not state), so some loss is expected.
|
||||
*
|
||||
* @returns `true` if reconnection succeeded, `false` otherwise.
|
||||
*/
|
||||
async reconnect(newConnection: IAgentConnection): Promise<boolean> {
|
||||
// Clean up old subscription
|
||||
this._subscriptionDisposables.clear();
|
||||
this._subscriptionRef?.dispose();
|
||||
this._subscriptionRef = undefined;
|
||||
|
||||
// Swap connection
|
||||
this._connection = newConnection;
|
||||
|
||||
try {
|
||||
// Re-subscribe to the terminal state
|
||||
this._subscriptionRef = this._connection.getSubscription(StateComponents.Terminal, this._terminalUri);
|
||||
const subscription = this._subscriptionRef.object;
|
||||
|
||||
// Wait for hydration with a timeout — the terminal may no longer
|
||||
// exist on the server (e.g. agent process restarted).
|
||||
if (subscription.value === undefined) {
|
||||
const RECONNECT_HYDRATE_TIMEOUT_MS = 10_000;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
listener.dispose();
|
||||
reject(new Error('Reconnect hydration timed out'));
|
||||
}, RECONNECT_HYDRATE_TIMEOUT_MS);
|
||||
const listener = subscription.onDidChange(() => {
|
||||
clearTimeout(timer);
|
||||
listener.dispose();
|
||||
resolve();
|
||||
});
|
||||
this._subscriptionDisposables.add(listener);
|
||||
});
|
||||
}
|
||||
|
||||
const state = subscription.value as TerminalState;
|
||||
|
||||
if (state.supportsCommandDetection && !this._supportsCommandDetection) {
|
||||
this._supportsCommandDetection = true;
|
||||
this._onSupportsCommandDetection.fire();
|
||||
}
|
||||
|
||||
// Clear the terminal buffer before replaying to avoid duplicate
|
||||
// content. ESC[2J clears the screen, ESC[3J clears scrollback,
|
||||
// ESC[H moves cursor to home position.
|
||||
this.handleData('\x1b[2J\x1b[3J\x1b[H');
|
||||
this._replayContent(state.content);
|
||||
|
||||
// Update cwd/title if they changed
|
||||
if (state.cwd) {
|
||||
this._properties.cwd = state.cwd.toString();
|
||||
}
|
||||
if (state.title) {
|
||||
this._properties.title = state.title;
|
||||
}
|
||||
|
||||
// Wire up action listener for streaming updates
|
||||
this._subscriptionDisposables.add(subscription.onDidApplyAction(envelope => {
|
||||
this._handleAction(envelope);
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[AgentHostPty] Reconnection failed:', err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The terminal URI this pty is subscribed to. */
|
||||
get terminalUri(): URI {
|
||||
return this._terminalUri;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._subscriptionRef?.dispose();
|
||||
this._subscriptionRef = undefined;
|
||||
|
||||
@@ -78,6 +78,13 @@ export interface IAgentHostTerminalService {
|
||||
*/
|
||||
createTerminalForEntry(address: string, options?: IAgentHostTerminalCreateOptions): Promise<ITerminalInstance | undefined>;
|
||||
|
||||
/**
|
||||
* Reconnects all active terminals that belonged to {@link oldClientId}
|
||||
* to a new agent host connection. Only terminals matching the old
|
||||
* client are touched — terminals from other hosts are left alone.
|
||||
*/
|
||||
reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }>;
|
||||
|
||||
/**
|
||||
* Attaches to an existing server-side terminal by subscribing to its
|
||||
* state without creating a new process.
|
||||
@@ -104,6 +111,11 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe
|
||||
|
||||
/** Revived terminal instances, keyed by terminal URI string. */
|
||||
private readonly _revivedInstances = new Map<string, ITerminalInstance>();
|
||||
/**
|
||||
* Active AgentHostPty instances with their owning connection clientId,
|
||||
* keyed by terminal URI string. Used for reconnection scoping.
|
||||
*/
|
||||
private readonly _activePtys = new Map<string, { pty: AgentHostPty; clientId: string }>();
|
||||
private readonly _reviveSequencer = new SequencerByKey<string>();
|
||||
|
||||
constructor(
|
||||
@@ -279,8 +291,9 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe
|
||||
async createTerminal(connection: IAgentConnection, options?: IAgentHostTerminalCreateOptions): Promise<ITerminalInstance> {
|
||||
const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: `/${generateUuid()}` });
|
||||
const name = options?.name ?? localize('agentHostTerminal.default', "Agent Host Terminal");
|
||||
const key = terminalUri.toString();
|
||||
|
||||
return this._terminalService.createTerminal({
|
||||
const instance = await this._terminalService.createTerminal({
|
||||
config: {
|
||||
customPtyImplementation: (id, cols, rows) => {
|
||||
const pty = new AgentHostPty(id, connection, terminalUri, {
|
||||
@@ -290,6 +303,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe
|
||||
if (cols > 0 && rows > 0) {
|
||||
pty.resize(cols, rows);
|
||||
}
|
||||
this._activePtys.set(key, { pty, clientId: connection.clientId });
|
||||
return pty;
|
||||
},
|
||||
name,
|
||||
@@ -298,6 +312,12 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe
|
||||
},
|
||||
location: options?.location,
|
||||
});
|
||||
|
||||
this._register(instance.onDisposed(() => {
|
||||
this._activePtys.delete(key);
|
||||
}));
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
async reviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): Promise<ITerminalInstance> {
|
||||
@@ -328,6 +348,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe
|
||||
commandSource.connect(instance, pty);
|
||||
}
|
||||
|
||||
this._activePtys.set(key, { pty, clientId: connection.clientId });
|
||||
return pty;
|
||||
},
|
||||
name: localize('agentHostTerminal.tool', "Agent Host Terminal"),
|
||||
@@ -341,8 +362,36 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe
|
||||
instance.store.add(store);
|
||||
this._register(instance.onDisposed(() => {
|
||||
this._revivedInstances.delete(key);
|
||||
this._activePtys.delete(key);
|
||||
}));
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
async reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }> {
|
||||
// Only reconnect terminals that belonged to the old connection
|
||||
// identified by oldClientId. In multi-host setups, other hosts'
|
||||
// terminals are left untouched.
|
||||
const entries = [...this._activePtys.entries()].filter(
|
||||
([, entry]) => entry.clientId === oldClientId
|
||||
);
|
||||
const total = entries.length;
|
||||
let recovered = 0;
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [key, entry] of entries) {
|
||||
promises.push(
|
||||
entry.pty.reconnect(newConnection).then(success => {
|
||||
if (success) {
|
||||
recovered++;
|
||||
// Update the clientId to the new connection
|
||||
entry.clientId = newConnection.clientId;
|
||||
} else {
|
||||
console.warn(`[AgentHostTerminalService] Failed to reconnect terminal: ${key}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
return { recovered, total };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,4 +341,123 @@ suite('AgentHostPty', () => {
|
||||
const cwd = await pty.getInitialCwd();
|
||||
assert.strictEqual(cwd, '/home/user');
|
||||
});
|
||||
|
||||
test('reconnect() re-subscribes with new connection and replays content', async () => {
|
||||
const conn1 = new MockAgentConnection({ content: [{ type: 'unclassified', value: 'old output\n' }] });
|
||||
disposables.add(conn1);
|
||||
const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri));
|
||||
|
||||
await pty.start();
|
||||
|
||||
// Create a new connection with different content (simulating server-side changes during disconnect)
|
||||
const conn2 = new MockAgentConnection({
|
||||
content: [{ type: 'unclassified', value: 'old output\nnew output after reconnect\n' }],
|
||||
cwd: '/home/reconnected',
|
||||
title: 'Reconnected Terminal',
|
||||
});
|
||||
disposables.add(conn2);
|
||||
|
||||
const dataReceived: string[] = [];
|
||||
disposables.add(pty.onProcessData!(e => {
|
||||
dataReceived.push(typeof e === 'string' ? e : e.data);
|
||||
}));
|
||||
|
||||
const result = await pty.reconnect(conn2);
|
||||
|
||||
assert.strictEqual(result, true, 'reconnect() should succeed');
|
||||
// Should have clear sequence + replayed content
|
||||
assert.ok(dataReceived.some(d => d.includes('\x1b[2J')), 'should clear buffer before replay');
|
||||
assert.ok(dataReceived.some(d => d.includes('new output after reconnect')), 'should replay new content');
|
||||
|
||||
const cwd = await pty.getCwd();
|
||||
assert.strictEqual(cwd, '/home/reconnected');
|
||||
});
|
||||
|
||||
test('reconnect() streams new actions from new connection', async () => {
|
||||
const conn1 = new MockAgentConnection();
|
||||
disposables.add(conn1);
|
||||
const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri));
|
||||
await pty.start();
|
||||
|
||||
const conn2 = new MockAgentConnection();
|
||||
disposables.add(conn2);
|
||||
|
||||
const dataReceived: string[] = [];
|
||||
disposables.add(pty.onProcessData!(e => {
|
||||
dataReceived.push(typeof e === 'string' ? e : e.data);
|
||||
}));
|
||||
|
||||
await pty.reconnect(conn2);
|
||||
dataReceived.length = 0; // clear replay data
|
||||
|
||||
// New actions from conn2 should be received
|
||||
conn2.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'post-reconnect data' });
|
||||
|
||||
assert.deepStrictEqual(dataReceived, ['post-reconnect data']);
|
||||
|
||||
// Old connection actions should NOT be received
|
||||
conn1.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'stale data' });
|
||||
assert.deepStrictEqual(dataReceived, ['post-reconnect data']);
|
||||
});
|
||||
|
||||
test('reconnect() times out when subscription never hydrates', async () => {
|
||||
const conn1 = new MockAgentConnection();
|
||||
disposables.add(conn1);
|
||||
const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri));
|
||||
await pty.start();
|
||||
|
||||
// Create a connection whose subscription never fires onDidChange
|
||||
const conn2 = new MockAgentConnection();
|
||||
disposables.add(conn2);
|
||||
// Override getSubscription to return a subscription that never hydrates
|
||||
conn2.getSubscription = <T>(_kind: StateComponents, _resource: URI): IReference<IAgentSubscription<T>> => {
|
||||
const onDidChange = new Emitter<TerminalState>();
|
||||
const onDidApplyAction = new Emitter<ActionEnvelope>();
|
||||
disposables.add(onDidChange);
|
||||
disposables.add(onDidApplyAction);
|
||||
const sub: IAgentSubscription<TerminalState> = {
|
||||
value: undefined, // never hydrated
|
||||
verifiedValue: undefined,
|
||||
onDidChange: onDidChange.event,
|
||||
onWillApplyAction: Event.None,
|
||||
onDidApplyAction: onDidApplyAction.event,
|
||||
};
|
||||
return {
|
||||
object: sub as IAgentSubscription<T>,
|
||||
dispose: () => { onDidChange.dispose(); onDidApplyAction.dispose(); },
|
||||
};
|
||||
};
|
||||
|
||||
// Suppress the expected console.warn from reconnect failure
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => { };
|
||||
try {
|
||||
const result = await pty.reconnect(conn2);
|
||||
assert.strictEqual(result, false, 'reconnect() should fail on timeout');
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
}).timeout(15000); // Allow for the 10s hydration timeout
|
||||
|
||||
test('reconnect() dispatches input to new connection', async () => {
|
||||
const conn1 = new MockAgentConnection();
|
||||
disposables.add(conn1);
|
||||
const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri));
|
||||
await pty.start();
|
||||
|
||||
const conn2 = new MockAgentConnection();
|
||||
disposables.add(conn2);
|
||||
await pty.reconnect(conn2);
|
||||
|
||||
pty.input('after reconnect');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const inputActions = conn2.dispatchedActions.filter(a => a.type === ActionType.TerminalInput);
|
||||
assert.strictEqual(inputActions.length, 1);
|
||||
assert.strictEqual((inputActions[0] as { data: string }).data, 'after reconnect');
|
||||
|
||||
// conn1 should not have received the input
|
||||
const oldInputActions = conn1.dispatchedActions.filter(a => a.type === ActionType.TerminalInput);
|
||||
assert.strictEqual(oldInputActions.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user