diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index 1e30b80f..61de520c 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 8099a779..f4974e89 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 89aca50e..b6ad4a18 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