mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
d977bcecb1
Opening a plan review from Pi on Windows could exit the entire Pi host with an uncaught `write EPIPE` raised inside `PiProcessNode.send()`. The provider checked `stdin.destroyed` and then wrote, which cannot close the race: the nested `pi --mode rpc` child can close the pipe between the check and the write. Node then reports EPIPE either as a synchronous throw or as an `error` event on the stream, and because no stream had an `error` listener that became an `uncaughtException` and terminated the host agent process. Add a shared guard (`packages/ai/providers/child-io.ts`) and apply it to both JSONL/JSON-RPC providers: - `guardChildStreams` attaches `error` listeners to the child and every pipe immediately after spawn, so a stream error can never escalate. The previous one-shot spawn listener was removed on success, leaving the child with no `error` listener for the rest of its life. - `writeChildLine` reports a synchronous failure through its return value and an asynchronous one through the write callback, so both paths converge. - A failure now resolves as a provider failure: in-flight requests reject, the process end is broadcast to listeners so a streaming query terminates, the child is reaped, and `alive` flips false so the next query re-spawns. Previously a failed write also left `sendAndWait` pending forever, because `send()` was fire-and-forget and the Pi provider has no RPC timeout. Also guards the Bun variant's FileSink write/flush symmetrically, and switches Pi's Node stderr from an un-drained "pipe" to "ignore", matching the deadlock reasoning already documented in codex-app-server.ts. Regression test runs the provider in a real `node` child against a fake Pi that closes its own stdin; the child installs no `uncaughtException` handler, so surviving to print its results is the proof. Against the unfixed provider that child dies with `Error: write EPIPE`, exit 1. Reported by @Kaelenx.
146 lines
5.0 KiB
TypeScript
146 lines
5.0 KiB
TypeScript
/**
|
|
* Test-only child entry for pi-stdio-failure.test.ts (#1378).
|
|
*
|
|
* Runs the Pi Node provider in a REAL `node` process, because that is the
|
|
* runtime under Pi (jiti on Node) and because the whole bug is a Node stream
|
|
* behavior: an `error` event with no listener becomes an `uncaughtException`.
|
|
* Bun's node:child_process / node:stream shims do not reproduce that faithfully,
|
|
* so an in-process `bun test` could pass against code that kills the host in
|
|
* production (the same lesson live-proxy-node.child.ts was created for).
|
|
*
|
|
* This entry deliberately installs NO `uncaughtException` handler: if the
|
|
* provider lets a stream error escape, this process dies and the suite sees a
|
|
* non-zero exit with no result line. Surviving to print the result line IS the
|
|
* "the host stays alive" assertion.
|
|
*
|
|
* Env contract: FAKE_PI (path to the executable fake Pi), FAKE_PI_MODE_FILE
|
|
* (file whose contents select the fake's behavior). Prints one JSON line of
|
|
* results on stdout, then exits 0.
|
|
*/
|
|
|
|
import { writeFileSync } from "node:fs";
|
|
import { PiProcessNode, PiSDKNodeProvider } from "./pi-sdk-node.ts";
|
|
import type { AIMessage } from "../types.ts";
|
|
|
|
const fakePi = process.env.FAKE_PI;
|
|
const modeFile = process.env.FAKE_PI_MODE_FILE;
|
|
if (!fakePi || !modeFile) throw new Error("FAKE_PI and FAKE_PI_MODE_FILE are required");
|
|
|
|
const cwd = process.cwd();
|
|
const setMode = (mode: string) => writeFileSync(modeFile, mode);
|
|
|
|
/** Resolve once the fake has announced itself on stdout. */
|
|
function waitForEvent(
|
|
proc: PiProcessNode,
|
|
type: string,
|
|
timeoutMs = 10_000,
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
off();
|
|
reject(new Error(`timed out waiting for ${type}`));
|
|
}, timeoutMs);
|
|
const off = proc.onEvent((event) => {
|
|
if (event.type === type) {
|
|
clearTimeout(timer);
|
|
off();
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function collect(iter: AsyncIterable<AIMessage>, capMs = 15_000): Promise<AIMessage[]> {
|
|
const out: AIMessage[] = [];
|
|
const deadline = Date.now() + capMs;
|
|
for await (const msg of iter) {
|
|
out.push(msg);
|
|
if (Date.now() > deadline) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const results: Record<string, unknown> = {};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. A broken stdin pipe rejects the in-flight request and kills nothing else.
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
setMode("closed-stdin");
|
|
const proc = new PiProcessNode();
|
|
await proc.spawn(fakePi, cwd);
|
|
// The fake has closed the read end of its stdin by the time it says this,
|
|
// so the very next write hits a pipe with no reader: EPIPE.
|
|
await waitForEvent(proc, "ready_broken");
|
|
|
|
let sawProcessExited = false;
|
|
proc.onEvent((event) => {
|
|
if (event.type === "process_exited") sawProcessExited = true;
|
|
});
|
|
|
|
try {
|
|
await proc.sendAndWait({ type: "get_state" });
|
|
results.brokenRejected = false;
|
|
} catch (err) {
|
|
results.brokenRejected = true;
|
|
results.brokenMessage = err instanceof Error ? err.message : String(err);
|
|
}
|
|
results.brokenAliveAfter = proc.alive;
|
|
results.brokenSawProcessExited = sawProcessExited;
|
|
|
|
// A fire-and-forget send on the already-failed process must not throw either.
|
|
try {
|
|
proc.send({ type: "abort" });
|
|
results.postFailureSendThrew = false;
|
|
} catch {
|
|
results.postFailureSendThrew = true;
|
|
}
|
|
proc.kill();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. The provider is restartable: a fresh process talks normally afterwards.
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
setMode("echo");
|
|
const proc = new PiProcessNode();
|
|
await proc.spawn(fakePi, cwd);
|
|
const state = await proc.sendAndWait({ type: "get_state" });
|
|
results.recoveredSessionId = state.sessionId;
|
|
results.recoveredAlive = proc.alive;
|
|
proc.kill();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. End to end through the session: the failure surfaces to the Ask AI UI as
|
|
// an error message, and a later query on the SAME session re-spawns and
|
|
// completes normally.
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// "break-after-first" answers the session's opening get_state and closes its
|
|
// stdin in the same tick, so the prompt write that follows is guaranteed to
|
|
// hit a dead pipe. That is the reported shape: a session that came up fine
|
|
// and then lost the pipe mid-conversation.
|
|
setMode("break-after-first");
|
|
const provider = new PiSDKNodeProvider({ type: "pi-sdk", piExecutablePath: fakePi, cwd });
|
|
const session = await provider.createSession({
|
|
context: { mode: "plan-review", plan: { plan: "# Test plan" } },
|
|
cwd,
|
|
});
|
|
|
|
const failed = await collect(session.query("hello"));
|
|
results.sessionFailedMessages = failed.map((m) => ({
|
|
type: m.type,
|
|
code: (m as { code?: string }).code,
|
|
}));
|
|
|
|
setMode("echo");
|
|
const recovered = await collect(session.query("hello again"));
|
|
results.sessionRecoveredTypes = recovered.map((m) => m.type);
|
|
|
|
provider.dispose();
|
|
}
|
|
|
|
process.stdout.write(`${JSON.stringify(results)}\n`);
|
|
process.exit(0);
|