From 5e494f5d1dd222ae0b9ab460055a450b35d89fbf Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Fri, 22 May 2026 15:00:44 -0600 Subject: [PATCH] fix(terminal): terminate running process when task is cancelled (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit releaseTerminalsForTask only disassociated the terminal (taskId = undefined) without aborting a still-running command, so cancel (✕) left the process orphaned and the terminal stuck "busy" until a manual kill. Now abort the running process for busy terminals on release. Adds TerminalRegistry tests. --- .changeset/terminate-process-on-cancel.md | 5 ++ src/integrations/terminal/TerminalRegistry.ts | 16 +++++ .../__tests__/TerminalRegistry.spec.ts | 62 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 .changeset/terminate-process-on-cancel.md diff --git a/.changeset/terminate-process-on-cancel.md b/.changeset/terminate-process-on-cancel.md new file mode 100644 index 0000000000..b6983db1cb --- /dev/null +++ b/.changeset/terminate-process-on-cancel.md @@ -0,0 +1,5 @@ +--- +"zoo-code": patch +--- + +Terminate the running command when a task is cancelled or torn down (#245). Pressing cancel (✕) now aborts the underlying terminal process instead of leaving it running while the terminal stays stuck "busy" until a manual kill. diff --git a/src/integrations/terminal/TerminalRegistry.ts b/src/integrations/terminal/TerminalRegistry.ts index 6e0531bebe..358793fc21 100644 --- a/src/integrations/terminal/TerminalRegistry.ts +++ b/src/integrations/terminal/TerminalRegistry.ts @@ -284,6 +284,22 @@ export class TerminalRegistry { public static releaseTerminalsForTask(taskId: string): void { this.terminals.forEach((terminal) => { if (terminal.taskId === taskId) { + // #245: If the terminal is still executing a command when its task is torn + // down (user pressed cancel ✕, or the task was switched/removed), abort the + // process. Otherwise the command keeps running orphaned and the terminal stays + // stuck "busy" — the cancel-doesn't-terminate bug. abort() is safe when idle + // (Ctrl+C is gated on an active stream; Execa abort is idempotent). + if (terminal.busy) { + try { + terminal.process?.abort() + } catch (error) { + console.error( + `[TerminalRegistry] Error aborting process for terminal ${terminal.id} on release:`, + error, + ) + } + } + terminal.taskId = undefined } }) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index f8d35635d9..5039b2a326 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -123,4 +123,66 @@ describe("TerminalRegistry", () => { } }) }) + + describe("releaseTerminalsForTask", () => { + it("aborts a busy terminal's running process and disassociates it from the task (#245)", () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") + const abort = vi.fn() + terminal.taskId = "task-245" + terminal.busy = true + terminal.process = { abort } as any + + TerminalRegistry.releaseTerminalsForTask("task-245") + + expect(abort).toHaveBeenCalledTimes(1) + expect(terminal.taskId).toBeUndefined() + }) + + it("does not abort an idle (not busy) terminal but still disassociates it", () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") + const abort = vi.fn() + terminal.taskId = "task-idle" + terminal.busy = false + terminal.process = { abort } as any + + TerminalRegistry.releaseTerminalsForTask("task-idle") + + expect(abort).not.toHaveBeenCalled() + expect(terminal.taskId).toBeUndefined() + }) + + it("only releases terminals belonging to the given task", () => { + const a = TerminalRegistry.createTerminal("/a", "vscode") + const b = TerminalRegistry.createTerminal("/b", "vscode") + const abortA = vi.fn() + const abortB = vi.fn() + a.taskId = "task-A" + a.busy = true + a.process = { abort: abortA } as any + b.taskId = "task-B" + b.busy = true + b.process = { abort: abortB } as any + + TerminalRegistry.releaseTerminalsForTask("task-A") + + expect(abortA).toHaveBeenCalledTimes(1) + expect(a.taskId).toBeUndefined() + expect(abortB).not.toHaveBeenCalled() + expect(b.taskId).toBe("task-B") + }) + + it("swallows errors thrown by process.abort() and still disassociates the terminal", () => { + const terminal = TerminalRegistry.createTerminal("/test/path", "vscode") + terminal.taskId = "task-throw" + terminal.busy = true + terminal.process = { + abort: vi.fn(() => { + throw new Error("boom") + }), + } as any + + expect(() => TerminalRegistry.releaseTerminalsForTask("task-throw")).not.toThrow() + expect(terminal.taskId).toBeUndefined() + }) + }) })