mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): let a started RPC or print shutdown finish; only a repeated signal forces exit (#11752)
Follow-up to #11746 for the case @fcatuhe reported on #11599: a second shutdown trigger cut off cleanup. Problem: Paseo stops pi by closing stdin and sending SIGTERM in the same tick (SIGKILL 2 s later); systemd sends SIGTERM and stdin closes after. In RPC mode: - stdin EOF, then SIGTERM: `shutdown()` removed pi's SIGTERM handler when it started, so the signal killed pi (exit 143) mid-`dispose()`. - SIGTERM, then stdin EOF: the second `shutdown()` call ran `process.exit()`. A pending MCP token refresh was lost: the server had rotated the refresh token, the next pi sent the old one, and the server revoked the grant. Rule after this change: once a shutdown has started, a stdin EOF or a single signal does not interrupt it; a second signal exits at once. SIGKILL still always works. - **RPC mode** (`rpc-mode.ts`): signal handlers stay registered until exit; `shutdown()` runs once and later calls do nothing; the signal handler counts signals and exits only on a repeated one. - **Print mode** (`print-mode.ts`): the final `finally` removed the signal handlers before `disposeRuntime()`, so a signal during the final disposal killed pi. Handlers are now removed after it, `disposeRuntime()` returns the shared in-flight disposal (a signal then waits for it), and a repeated signal exits at once, as before. - **Interactive mode**: unchanged. It already lets a started shutdown finish. Docs (`docs/rpc.md`) and the CHANGELOG are updated. ## Testing - `test/rpc-prompt-response-semantics.test.ts`: EOF then SIGTERM, SIGTERM then EOF (both wait for a slow dispose, exit once with 0 and 143), and EOF then two SIGTERMs (exits at once with 143). All three fail on main. - `test/print-mode.test.ts`: SIGTERM during the final disposal waits for it, a second SIGTERM exits with 143, handlers are removed afterwards. Fails on main. - Real process, `pi --mode rpc` with a `session_shutdown` handler that takes 1 s and real signals: - EOF then SIGTERM: cleanup done, exit 0 (main: no cleanup, exit 143) - SIGTERM then EOF: cleanup done, exit 143 - EOF then two SIGTERMs: exit 143 at once - `npm run check` passes; all `test/rpc*` and `test/print-mode` tests pass.
This commit is contained in:
parent
efb45b51d4
commit
ace560c302
6 changed files with 167 additions and 26 deletions
|
|
@ -12,6 +12,7 @@
|
|||
- A failed token refresh of an MCP connection no longer registers an OAuth client or replaces the PKCE verifier of a sign-in in progress, which made that sign-in fail. MCP sign-in now refreshes the stored tokens with the stored client even when the loopback callback port changed, instead of registering a new client and opening the browser. Ctrl+C in `/mcp` and the `--timeout` of `pi mcp login` now also stop a sign-in while it waits up to 25 seconds for another pi process's token refresh, and a refresh lock that fails for another reason than that wait now fails the sign-in instead of opening the browser.
|
||||
- Fixed RPC mode ignoring an extension's `ctx.shutdown()` while idle, for example from a timer: the process kept running until the client sent another command. pi now shuts down right away when idle, and a shutdown requested by an extension command waits until the command's `prompt` response is written ([#11599](https://github.com/earendil-works/pi/issues/11599))
|
||||
- Fixed changelog notes repeating on every fresh launch when the installed package version has a prerelease suffix, such as `0.1.5-20261020.7`: the saved version was read as `0.1.0`, so the `0.1.1` to `0.1.5` notes counted as new each time. A saved version without `major.minor.patch` now shows no notes and is replaced by the installed version, instead of showing the whole changelog ([#11703](https://github.com/earendil-works/pi/issues/11703))
|
||||
- Fixed RPC mode cutting off shutdown cleanup, such as a pending MCP token refresh, when stdin closed and SIGTERM arrived together, which process managers like Paseo and systemd do. Pi lost the refreshed token and the MCP server revoked the sign-in. Once a shutdown has started, a stdin EOF or a single signal no longer interrupts it; a second signal still exits at once. Print mode likewise no longer dies from a signal that arrives during its final cleanup ([#11599](https://github.com/earendil-works/pi/issues/11599))
|
||||
|
||||
|
||||
## [1.5.0] - 2026-11-02
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ Clients must also handle child-process startup failures, unexpected exits, stder
|
|||
|
||||
Close the child's stdin to request an orderly shutdown. Pi disposes the active runtime before exiting. Clients should still handle process signals and unexpected exits.
|
||||
|
||||
SIGTERM and SIGHUP also start an orderly shutdown. Once a shutdown has started, from either stdin EOF or a signal, a stdin EOF or a single signal does not interrupt it; a second signal exits at once. Closing stdin and sending SIGTERM together is safe.
|
||||
|
||||
An extension can also request shutdown with `ctx.shutdown()`. If the session is idle and no command is being handled, Pi shuts down right away. Otherwise it shuts down once the current command finishes (for `prompt`, once the prompt settles) or the active run emits `agent_settled`. (A request made while an extension-started compaction is running waits for the next command or run to finish.)
|
||||
|
||||
## Minimal client
|
||||
|
|
|
|||
|
|
@ -36,15 +36,19 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr
|
|||
let session = runtimeHost.session;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let unsubscribeBackpressure: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let disposing: Promise<void> | undefined;
|
||||
// SIGTERM/SIGHUP received so far. Only a repeated signal forces exit during disposal.
|
||||
let signalCount = 0;
|
||||
const signalCleanupHandlers: Array<() => void> = [];
|
||||
|
||||
const disposeRuntime = async (): Promise<void> => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
unsubscribeBackpressure?.();
|
||||
await runtimeHost.dispose();
|
||||
// Callers share one disposal, so a signal during the final disposal waits for it to finish.
|
||||
const disposeRuntime = (): Promise<void> => {
|
||||
disposing ??= (async () => {
|
||||
unsubscribe?.();
|
||||
unsubscribeBackpressure?.();
|
||||
await runtimeHost.dispose();
|
||||
})();
|
||||
return disposing;
|
||||
};
|
||||
|
||||
const registerSignalHandlers = (): void => {
|
||||
|
|
@ -54,10 +58,13 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr
|
|||
}
|
||||
|
||||
for (const signal of signals) {
|
||||
const exitCode = signal === "SIGHUP" ? 129 : 143;
|
||||
const handler = () => {
|
||||
signalCount++;
|
||||
killTrackedDetachedChildren();
|
||||
if (signalCount > 1) process.exit(exitCode);
|
||||
void disposeRuntime().finally(() => {
|
||||
process.exit(signal === "SIGHUP" ? 129 : 143);
|
||||
process.exit(exitCode);
|
||||
});
|
||||
};
|
||||
process.on(signal, handler);
|
||||
|
|
@ -164,10 +171,12 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr
|
|||
console.error(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
} finally {
|
||||
// Keep the signal handlers until disposal is done: without one, a signal would kill the
|
||||
// process mid-dispose (for example during a pending MCP token refresh).
|
||||
await disposeRuntime();
|
||||
for (const cleanup of signalCleanupHandlers) {
|
||||
cleanup();
|
||||
}
|
||||
await disposeRuntime();
|
||||
await flushRawStdout();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||
let shuttingDown = false;
|
||||
let inputReady = false;
|
||||
let activeCommands = 0;
|
||||
const signalCleanupHandlers: Array<() => void> = [];
|
||||
// SIGTERM/SIGHUP received so far. Only a repeated signal forces exit during shutdown.
|
||||
let signalCount = 0;
|
||||
|
||||
/** Helper for dialog methods with signal/timeout support */
|
||||
function createDialogPromise<T>(
|
||||
|
|
@ -376,12 +377,20 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||
}
|
||||
|
||||
for (const signal of signals) {
|
||||
const handler = () => {
|
||||
const exitCode = signal === "SIGHUP" ? 129 : 143;
|
||||
// Handlers stay registered until exit: without one, a signal during shutdown would kill
|
||||
// the process before dispose() finishes (for example a pending MCP token refresh).
|
||||
process.on(signal, () => {
|
||||
signalCount++;
|
||||
killTrackedDetachedChildren();
|
||||
void shutdown(signal === "SIGHUP" ? 129 : 143, signal);
|
||||
};
|
||||
process.on(signal, handler);
|
||||
signalCleanupHandlers.push(() => process.off(signal, handler));
|
||||
if (!shuttingDown) {
|
||||
void shutdown(exitCode, signal);
|
||||
} else if (signalCount > 1) {
|
||||
// Process managers often close stdin and send SIGTERM together, so one signal
|
||||
// during shutdown is not a request to skip cleanup; a repeated one is.
|
||||
process.exit(exitCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -731,14 +740,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||
|
||||
let detachInput = () => {};
|
||||
|
||||
async function shutdown(exitCode = 0, signal?: NodeJS.Signals): Promise<never> {
|
||||
if (shuttingDown) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
/** Dispose the runtime and exit. Runs once; later calls (stdin EOF after a signal) do nothing. */
|
||||
async function shutdown(exitCode = 0, signal?: NodeJS.Signals): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
for (const cleanup of signalCleanupHandlers) {
|
||||
cleanup();
|
||||
}
|
||||
unsubscribe?.();
|
||||
unsubscribeBackpressure?.();
|
||||
await runtimeHost.dispose();
|
||||
|
|
@ -753,8 +758,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||
/**
|
||||
* Shut down if an extension requested it and nothing still needs the process: input is attached,
|
||||
* no command (including a prompt that has not settled) is being handled, and the session is idle.
|
||||
* Each of those ending calls this again. A shutdown already in progress is left alone, because a
|
||||
* second shutdown() call exits immediately.
|
||||
* Each of those ending calls this again.
|
||||
*/
|
||||
function checkShutdownRequested(): void {
|
||||
if (!shutdownRequested || shuttingDown || !inputReady || activeCommands > 0 || !session.isIdle) return;
|
||||
|
|
|
|||
|
|
@ -152,4 +152,37 @@ describe("runPrintMode", () => {
|
|||
expect(exitCode).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A signal during the final disposal used to hit no handler and kill pi mid-dispose.
|
||||
it("waits for the final disposal when SIGTERM arrives during it, and exits on a repeated SIGTERM", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }));
|
||||
let finishDispose = () => {};
|
||||
runtimeHost.dispose.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDispose = resolve;
|
||||
}),
|
||||
);
|
||||
const listenersBefore = process.listenerCount("SIGTERM");
|
||||
|
||||
const run = runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
|
||||
mode: "text",
|
||||
initialMessage: "Say done",
|
||||
});
|
||||
await vi.waitFor(() => expect(runtimeHost.dispose).toHaveBeenCalledTimes(1));
|
||||
expect(process.listenerCount("SIGTERM")).toBe(listenersBefore + 1);
|
||||
|
||||
process.emit("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
expect(runtimeHost.dispose).toHaveBeenCalledTimes(1);
|
||||
|
||||
process.emit("SIGTERM");
|
||||
expect(exitSpy).toHaveBeenCalledWith(143);
|
||||
|
||||
finishDispose();
|
||||
await run;
|
||||
expect(process.listenerCount("SIGTERM")).toBe(listenersBefore);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
getModel,
|
||||
type Model,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.ts";
|
||||
import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
|
|
@ -162,6 +162,7 @@ async function createRuntimeHost(options: {
|
|||
|
||||
async function startRpcMode(options: Parameters<typeof createRuntimeHost>[0]): Promise<{
|
||||
lineHandler: (line: string) => void;
|
||||
runtimeHost: AgentSessionRuntime;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
rpcIo.outputLines = [];
|
||||
|
|
@ -171,7 +172,7 @@ async function startRpcMode(options: Parameters<typeof createRuntimeHost>[0]): P
|
|||
void runRpcMode(runtimeHost);
|
||||
await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined());
|
||||
|
||||
return { lineHandler: rpcIo.lineHandler!, cleanup };
|
||||
return { lineHandler: rpcIo.lineHandler!, runtimeHost, cleanup };
|
||||
}
|
||||
|
||||
describe("RPC prompt response semantics", () => {
|
||||
|
|
@ -550,3 +551,94 @@ describe("RPC extension shutdown requests", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RPC shutdown triggers", () => {
|
||||
// Earlier tests leave RPC instances listening (process.exit never runs). Detach every signal and
|
||||
// stdin "end" listener so emitted triggers reach only this test's instance, then restore them.
|
||||
const signals: NodeJS.Signals[] = ["SIGTERM", "SIGHUP"];
|
||||
let savedSignalListeners: Array<[NodeJS.Signals, NodeJS.SignalsListener[]]> = [];
|
||||
let savedEndListeners: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
savedSignalListeners = signals.map((signal) => [signal, process.listeners(signal)]);
|
||||
savedEndListeners = process.stdin.listeners("end") as Array<() => void>;
|
||||
for (const signal of signals) process.removeAllListeners(signal);
|
||||
process.stdin.removeAllListeners("end");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const signal of signals) process.removeAllListeners(signal);
|
||||
process.stdin.removeAllListeners("end");
|
||||
for (const [signal, listeners] of savedSignalListeners) {
|
||||
for (const listener of listeners) process.on(signal, listener);
|
||||
}
|
||||
for (const listener of savedEndListeners) process.stdin.on("end", listener);
|
||||
rpcIo.outputLines = [];
|
||||
rpcIo.lineHandler = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function startWithSlowDispose() {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin);
|
||||
const started = await startRpcMode({ withAuth: false, responseDelayMs: 0 });
|
||||
let finishDispose = () => {};
|
||||
const disposeFinished = new Promise<void>((resolve) => {
|
||||
finishDispose = resolve;
|
||||
});
|
||||
vi.mocked(started.runtimeHost.dispose).mockImplementation(() => disposeFinished);
|
||||
return { ...started, exitSpy, finishDispose };
|
||||
}
|
||||
|
||||
// fcatuhe's report on #11599: Paseo closes stdin and sends SIGTERM together.
|
||||
it("lets a shutdown started by stdin EOF finish when one SIGTERM follows", async () => {
|
||||
const { runtimeHost, exitSpy, finishDispose, cleanup } = await startWithSlowDispose();
|
||||
try {
|
||||
process.stdin.emit("end");
|
||||
await vi.waitFor(() => expect(runtimeHost.dispose).toHaveBeenCalledTimes(1));
|
||||
// Without a listener, a real SIGTERM would kill the process mid-dispose.
|
||||
expect(process.listenerCount("SIGTERM")).toBe(1);
|
||||
process.emit("SIGTERM");
|
||||
await sleep(10);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
|
||||
finishDispose();
|
||||
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(0));
|
||||
expect(exitSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a shutdown started by SIGTERM finish when stdin EOF follows", async () => {
|
||||
const { runtimeHost, exitSpy, finishDispose, cleanup } = await startWithSlowDispose();
|
||||
try {
|
||||
process.emit("SIGTERM");
|
||||
await vi.waitFor(() => expect(runtimeHost.dispose).toHaveBeenCalledTimes(1));
|
||||
process.stdin.emit("end");
|
||||
await sleep(10);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
|
||||
finishDispose();
|
||||
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(143));
|
||||
expect(exitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeHost.dispose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("exits at once on a repeated signal during shutdown", async () => {
|
||||
const { runtimeHost, exitSpy, cleanup } = await startWithSlowDispose();
|
||||
try {
|
||||
process.stdin.emit("end");
|
||||
await vi.waitFor(() => expect(runtimeHost.dispose).toHaveBeenCalledTimes(1));
|
||||
process.emit("SIGTERM");
|
||||
process.emit("SIGTERM");
|
||||
expect(exitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(143);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue