From eee7dc918b4201bcca012b08c5ed36d9c5d86967 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 15 Jul 2026 14:19:35 +0200 Subject: [PATCH] Force-kill stuck AI terminal commands that ignore interrupt When an AI terminal command is cancelled or times out, the shell is first sent Ctrl+C to recover the prompt. If the shell does not return to a usable prompt within the grace period (the process ignores SIGINT or is itself hung), the terminal previously stayed stuck forever and, because the automation terminal is reused, every subsequent command hung too. Now, if the interrupt fails to recover the prompt, the child process tree is forcibly killed and the stuck automation terminal is discarded, so the next command always starts from a fresh, responsive terminal. - PseudoTerminalConnection.KillProcess kills the shell and its children. - TerminalViewModel.KillProcess exposes this to the manager. - TerminalManagerViewModel now kills and discards the terminal when interrupt-based recovery fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Provider/PseudoTerminalConnection.cs | 13 ++++++++ .../ViewModels/TerminalViewModel.cs | 7 +++++ .../ViewModels/TerminalManagerViewModel.cs | 30 +++++++++++++++---- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index 1e30b80f0..61de520cd 100644 --- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs +++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs @@ -61,6 +61,19 @@ public void SendData(byte[] data) _ = terminal.WriteAsync(data, 0, data.Length); } + public void KillProcess() + { + try + { + if (!terminal.Process.HasExited) + terminal.Process.Kill(true); + } + catch + { + // Best effort: the process may already have exited or be unkillable. + } + } + public void SuppressOutput(byte[] sequence) { if (sequence.Length == 0) return; diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index 8099a779a..f4974e89a 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -144,6 +144,13 @@ public void SendInterrupt() if (Connection?.IsConnected ?? false) Connection.SendData([0x03]); } + public void KillProcess() + { + // Forcibly terminate the shell and any child processes. Used as a last + // resort when an interrupt (Ctrl+C) fails to free a stuck command. + if (Connection is PseudoTerminalConnection ptc) ptc.KillProcess(); + } + public void SuppressEcho(byte[] data) { if (Connection is IOutputSuppressor suppressor) diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index 89aca50e3..b6ad4a18f 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -170,10 +170,18 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg { var partialOutput = output.ToString(); // The command exceeded its timeout or was cancelled but is still running - // in the shell. Interrupt it so the shell returns to a usable prompt; - // otherwise the foreground command keeps running and every subsequent - // command sent to this (reused) terminal hangs too. - await TryRecoverPromptAsync(terminal, resultTcs.Task); + // in the shell. First try a gentle interrupt (Ctrl+C) so the shell returns + // to a usable prompt and the terminal stays reusable. + var recovered = await TryRecoverPromptAsync(terminal, resultTcs.Task); + if (!recovered) + { + // The interrupt did not free the shell (the process ignores SIGINT or is + // itself hung). Forcibly kill the process tree and discard this terminal + // so it is never reused in a stuck state by a subsequent command. + terminal.KillProcess(); + DiscardAutomationTerminal(terminal); + } + result = new TerminalExecutionResult(partialOutput, -1, true); } finally @@ -185,7 +193,7 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg return result; } - private static async Task TryRecoverPromptAsync(TerminalViewModel terminal, + private static async Task TryRecoverPromptAsync(TerminalViewModel terminal, Task resultTask) { terminal.SendInterrupt(); @@ -194,13 +202,23 @@ private static async Task TryRecoverPromptAsync(TerminalViewModel terminal, // Give the shell a moment to process the interrupt and emit a fresh // prompt marker so the terminal can be reused by the next command. await resultTask.WaitAsync(TimeSpan.FromSeconds(3)); + return true; } catch (TimeoutException) { - // Best-effort recovery: fall through even if the shell did not respond. + return false; } } + private void DiscardAutomationTerminal(TerminalViewModel terminal) + { + Dispatcher.UIThread.Post(() => + { + var tab = Terminals.FirstOrDefault(t => ReferenceEquals(t.Terminal, terminal)); + tab?.Close(); + }); + } + public void ExecScriptInTerminal(string scriptPath, bool elevated, string title) { try