fix(coding-agent): keep suspend resume alive on fg closes #2454

This commit is contained in:
Mario Zechner
2026-03-20 11:55:43 +01:00
parent 8705fbee54
commit 75fc6d088d
3 changed files with 135 additions and 4 deletions

View File

@@ -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

View File

@@ -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<void> {

View File

@@ -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();
});
});