diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 57f2a55f..2aaa98a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `ctrl+z` suspend and `fg` resume reliability by keeping the process alive until the `SIGCONT` handler restores the TUI, avoiding immediate process exit in environments with no other live event-loop handles ([#2454](https://github.com/badlogic/pi-mono/issues/2454)) + ## [0.61.0] - 2026-03-20 ### New Features diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3989da32..f42ab501 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2700,6 +2700,11 @@ export class InteractiveMode { } private handleCtrlZ(): void { + // Keep the event loop alive while suspended. Without this, stopping the TUI + // can leave Node with no ref'ed handles, causing the process to exit on fg + // before the SIGCONT handler gets a chance to restore the terminal. + const suspendKeepAlive = setInterval(() => {}, 2 ** 30); + // Ignore SIGINT while suspended so Ctrl+C in the terminal does not // kill the backgrounded process. The handler is removed on resume. const ignoreSigint = () => {}; @@ -2707,16 +2712,23 @@ export class InteractiveMode { // Set up handler to restore TUI when resumed process.once("SIGCONT", () => { + clearInterval(suspendKeepAlive); process.removeListener("SIGINT", ignoreSigint); this.ui.start(); this.ui.requestRender(true); }); - // Stop the TUI (restore terminal to normal mode) - this.ui.stop(); + try { + // Stop the TUI (restore terminal to normal mode) + this.ui.stop(); - // Send SIGTSTP to process group (pid=0 means all processes in group) - process.kill(0, "SIGTSTP"); + // Send SIGTSTP to process group (pid=0 means all processes in group) + process.kill(0, "SIGTSTP"); + } catch (error) { + clearInterval(suspendKeepAlive); + process.removeListener("SIGINT", ignoreSigint); + throw error; + } } private async handleFollowUp(): Promise { diff --git a/packages/coding-agent/test/interactive-mode-suspend.test.ts b/packages/coding-agent/test/interactive-mode-suspend.test.ts new file mode 100644 index 00000000..e9e53c14 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-suspend.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; + +type FakeUi = { + start: () => void; + stop: () => void; + requestRender: (force?: boolean) => void; +}; + +type HandleCtrlZThis = { + ui: FakeUi; +}; + +type ProcessSignalHandler = () => void; + +type InteractiveModePrototypeWithHandleCtrlZ = { + handleCtrlZ(this: HandleCtrlZThis): void; +}; + +function callHandleCtrlZ(context: HandleCtrlZThis): void { + (interactiveModePrototype as InteractiveModePrototypeWithHandleCtrlZ).handleCtrlZ.call(context); +} + +const interactiveModePrototype = InteractiveMode.prototype as unknown; + +describe("InteractiveMode.handleCtrlZ", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("keeps the process alive while suspended and restores the TUI on SIGCONT", () => { + const ui: FakeUi = { + start: vi.fn(), + stop: vi.fn(), + requestRender: vi.fn(), + }; + const context: HandleCtrlZThis = { ui }; + const keepAliveHandle = setTimeout(() => undefined, 0); + clearTimeout(keepAliveHandle); + + let sigintHandler: ProcessSignalHandler | undefined; + let sigcontHandler: ProcessSignalHandler | undefined; + + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockReturnValue(keepAliveHandle); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined); + const processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { + if (event === "SIGINT") { + sigintHandler = listener; + } + return process; + }) as typeof process.on); + const processOnceSpy = vi.spyOn(process, "once").mockImplementation(((event: string, listener: () => void) => { + if (event === "SIGCONT") { + sigcontHandler = listener; + } + return process; + }) as typeof process.once); + const removeListenerSpy = vi + .spyOn(process, "removeListener") + .mockImplementation(((_event: string, _listener: () => void) => process) as typeof process.removeListener); + const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + callHandleCtrlZ(context); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2 ** 30); + expect(processOnSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + expect(processOnceSpy).toHaveBeenCalledWith("SIGCONT", expect.any(Function)); + expect(ui.stop).toHaveBeenCalledTimes(1); + expect(processKillSpy).toHaveBeenCalledWith(0, "SIGTSTP"); + expect(sigintHandler).toBeDefined(); + expect(sigcontHandler).toBeDefined(); + + sigcontHandler?.(); + + expect(clearIntervalSpy).toHaveBeenCalledWith(keepAliveHandle); + expect(removeListenerSpy).toHaveBeenCalledWith("SIGINT", sigintHandler); + expect(ui.start).toHaveBeenCalledTimes(1); + expect(ui.requestRender).toHaveBeenCalledWith(true); + }); + + test("cleans up the temporary handlers if suspension fails", () => { + const ui: FakeUi = { + start: vi.fn(), + stop: vi.fn(), + requestRender: vi.fn(), + }; + const context: HandleCtrlZThis = { ui }; + const keepAliveHandle = setTimeout(() => undefined, 0); + clearTimeout(keepAliveHandle); + const suspendError = new Error("suspend failed"); + + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockReturnValue(keepAliveHandle); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined); + vi.spyOn(process, "on").mockImplementation( + ((_event: string, _listener: () => void) => process) as typeof process.on, + ); + const removeListenerSpy = vi + .spyOn(process, "removeListener") + .mockImplementation(((_event: string, _listener: () => void) => process) as typeof process.removeListener); + vi.spyOn(process, "once").mockImplementation( + ((_event: string, _listener: () => void) => process) as typeof process.once, + ); + vi.spyOn(process, "kill").mockImplementation(() => { + throw suspendError; + }); + + expect(() => callHandleCtrlZ(context)).toThrow(suspendError); + expect(ui.stop).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(clearIntervalSpy).toHaveBeenCalledWith(keepAliveHandle); + expect(removeListenerSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + expect(ui.start).not.toHaveBeenCalled(); + expect(ui.requestRender).not.toHaveBeenCalled(); + }); +});