fix(coding-agent): keep suspend resume alive on fg closes #2454
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
115
packages/coding-agent/test/interactive-mode-suspend.test.ts
Normal file
115
packages/coding-agent/test/interactive-mode-suspend.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user