Recover from terminal hangs when shell integration breaks or the pty exits (#313312)

This commit is contained in:
Megan Rogge
2026-04-29 16:41:37 -04:00
committed by GitHub
parent 9d9b679f33
commit ade6340a22
2 changed files with 43 additions and 1 deletions
@@ -243,6 +243,23 @@ export async function trackIdleOnPrompt(
scheduler.schedule();
}
}, 30_000));
// Hard wall-clock safety net for the case where shell integration never
// engages at all — no OSC `C`/`D` is ever parsed so state never advances
// to Executing, and yet the existing data-idle fallbacks somehow fail to
// fire (e.g. because data arrives in a single event before listeners are
// active, or because `onData` throttling masks idle periods). Without
// this, trackIdleOnPrompt can block the rich strategy for the lifetime
// of the chat request. Genuinely long-running commands with working
// shell integration reach Executing/PromptAfterExecuting before this
// fires, so this is purely a fallback for the broken-handshake path.
const hardCapScheduler = store.add(new RunOnceScheduler(() => {
if (state === TerminalState.Initial || state === TerminalState.Prompt) {
log?.(`Hard cap fired after 60s in state ${stateNames[state]} (dataEvents=${dataEventCount})`);
setState(TerminalState.PromptAfterExecuting, 'hardCap');
scheduler.schedule();
}
}, 60_000));
hardCapScheduler.schedule();
// Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents
// cases where the command is executed before the prompt is written. While not perfect, sitting
// on an A without a C following shortly after is a very good indicator that the command is done
@@ -2282,9 +2282,34 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
}));
// Clean up all background resources when the terminal is disposed
// (e.g. user closes the terminal) to avoid leaking listeners and monitors.
// (e.g. user closes the terminal). Send a completion notification so
// the agent isn't left waiting for an `onCommandFinished` event that
// will never fire — the pty exited before shell integration could
// emit the end marker. Output captured here is whatever was buffered
// up until disposal.
// Capture the execution reference now — by the time onDisposed fires,
// onDidDisposeInstance listeners may have already removed it from
// _activeExecutions.
const executionForDisposal = RunInTerminalTool._activeExecutions.get(termId);
store.add(terminalInstance.onDisposed(() => {
if (handleSessionCancelled()) {
return;
}
const currentOutput = executionForDisposal?.getOutput() ?? '';
const exitCode = terminalInstance.exitCode;
const exitCodeText = exitCode !== undefined ? ` with exit code ${exitCode}` : '';
disposeNotification();
const message = `[Terminal ${termId} notification: terminal exited${exitCodeText}. The terminal process ended before the command could complete normally; further commands cannot be sent to this terminal ID.]\nTerminal output:\n${currentOutput}`;
this._logService.debug(`RunInTerminalTool: Background terminal ${termId} disposed${exitCodeText}, notifying chat session`);
this._chatService.sendRequest(chatSessionResource, message, {
...sendOptions,
queue: ChatRequestQueueKind.Steering,
isSystemInitiated: true,
systemInitiatedLabel: localize('terminalProcessExited', "`{0}` terminal exited", commandName),
terminalExecutionId: termId,
}).catch(e => {
this._logService.warn(`RunInTerminalTool: Failed to send terminal-exited notification for terminal ${termId}`, e);
});
}));
// When a checkpoint is restored, requests are removed from the model.