Files
backnotprop__plannotator/packages/ai/providers/pi-stdio-failure.child.ts
Michael Ramos d977bcecb1 fix(ai): contain broken provider pipes instead of crashing the host (#1379)
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.
2026-08-22 22:21:09 -07:00

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);