pi-mono/packages/coding-agent/test/print-mode.test.ts
Petr Houška ace560c302
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.
2026-11-03 22:21:44 +01:00

188 lines
6.3 KiB
TypeScript

import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SessionShutdownEvent } from "../src/index.ts";
import { runPrintMode } from "../src/modes/print-mode.ts";
type EmitEvent = SessionShutdownEvent;
type FakeExtensionRunner = {
hasHandlers: (eventType: string) => boolean;
emit: ReturnType<typeof vi.fn<(event: EmitEvent) => Promise<void>>>;
};
type FakeSession = {
sessionManager: { getHeader: () => object | undefined };
agent: { waitForIdle: () => Promise<void>; subscribe: ReturnType<typeof vi.fn> };
state: { messages: AssistantMessage[] };
extensionRunner: FakeExtensionRunner;
bindExtensions: ReturnType<typeof vi.fn>;
subscribe: ReturnType<typeof vi.fn>;
prompt: ReturnType<typeof vi.fn>;
reload: ReturnType<typeof vi.fn>;
};
type FakeRuntimeHost = {
session: FakeSession;
newSession: ReturnType<typeof vi.fn>;
fork: ReturnType<typeof vi.fn>;
switchSession: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
setRebindSession: ReturnType<typeof vi.fn>;
};
function createAssistantMessage(options?: {
text?: string;
stopReason?: AssistantMessage["stopReason"];
errorMessage?: string;
}): AssistantMessage {
return {
role: "assistant",
content: options?.text ? [{ type: "text", text: options.text }] : [],
api: "openai-responses",
provider: "openai",
model: "gpt-4o-mini",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: options?.stopReason ?? "stop",
errorMessage: options?.errorMessage,
timestamp: Date.now(),
};
}
function createRuntimeHost(assistantMessage: AssistantMessage): FakeRuntimeHost {
const extensionRunner: FakeExtensionRunner = {
hasHandlers: (eventType: string) => eventType === "session_shutdown",
emit: vi.fn(async () => {}),
};
const state = { messages: [assistantMessage] };
const session: FakeSession = {
sessionManager: { getHeader: () => undefined },
agent: { waitForIdle: async () => {}, subscribe: vi.fn(() => () => {}) },
state,
extensionRunner,
bindExtensions: vi.fn(async () => {}),
subscribe: vi.fn(() => () => {}),
prompt: vi.fn(async () => {}),
reload: vi.fn(async () => {}),
};
return {
session,
newSession: vi.fn(async () => undefined),
fork: vi.fn(async () => ({ selectedText: "" })),
switchSession: vi.fn(async () => undefined),
dispose: vi.fn(async () => {
await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
}),
setRebindSession: vi.fn(),
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("runPrintMode", () => {
it("emits session_shutdown in text mode", async () => {
const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }));
const { session } = runtimeHost;
const images: ImageContent[] = [{ type: "image", mimeType: "image/png", data: "abc" }];
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
mode: "text",
initialMessage: "Say done",
initialImages: images,
});
expect(exitCode).toBe(0);
expect(session.prompt).toHaveBeenCalledWith("Say done", { images });
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
});
it("emits session_shutdown in json mode", async () => {
const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }));
const { session } = runtimeHost;
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
mode: "json",
messages: ["hello"],
});
expect(exitCode).toBe(0);
expect(session.prompt).toHaveBeenCalledWith("hello");
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
});
it("emits session_shutdown and returns non-zero on assistant error", async () => {
const runtimeHost = createRuntimeHost(
createAssistantMessage({ stopReason: "error", errorMessage: "provider failure" }),
);
const { session } = runtimeHost;
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
mode: "text",
});
expect(exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith("provider failure");
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
});
// #10799
it("warns on stderr and returns non-zero when the response was truncated", async () => {
const runtimeHost = createRuntimeHost(createAssistantMessage({ stopReason: "length" }));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
mode: "text",
});
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);
});
});