fix(coding-agent): keep suspend resume alive on fg closes #2454
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.61.0] - 2026-03-20
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -2700,6 +2700,11 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleCtrlZ(): void {
|
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
|
// Ignore SIGINT while suspended so Ctrl+C in the terminal does not
|
||||||
// kill the backgrounded process. The handler is removed on resume.
|
// kill the backgrounded process. The handler is removed on resume.
|
||||||
const ignoreSigint = () => {};
|
const ignoreSigint = () => {};
|
||||||
@@ -2707,16 +2712,23 @@ export class InteractiveMode {
|
|||||||
|
|
||||||
// Set up handler to restore TUI when resumed
|
// Set up handler to restore TUI when resumed
|
||||||
process.once("SIGCONT", () => {
|
process.once("SIGCONT", () => {
|
||||||
|
clearInterval(suspendKeepAlive);
|
||||||
process.removeListener("SIGINT", ignoreSigint);
|
process.removeListener("SIGINT", ignoreSigint);
|
||||||
this.ui.start();
|
this.ui.start();
|
||||||
this.ui.requestRender(true);
|
this.ui.requestRender(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
// Stop the TUI (restore terminal to normal mode)
|
// Stop the TUI (restore terminal to normal mode)
|
||||||
this.ui.stop();
|
this.ui.stop();
|
||||||
|
|
||||||
// Send SIGTSTP to process group (pid=0 means all processes in group)
|
// Send SIGTSTP to process group (pid=0 means all processes in group)
|
||||||
process.kill(0, "SIGTSTP");
|
process.kill(0, "SIGTSTP");
|
||||||
|
} catch (error) {
|
||||||
|
clearInterval(suspendKeepAlive);
|
||||||
|
process.removeListener("SIGINT", ignoreSigint);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFollowUp(): Promise<void> {
|
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