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.
93 lines
3.6 KiB
TypeScript
93 lines
3.6 KiB
TypeScript
/**
|
|
* Stdio guards for the JSONL/JSON-RPC child processes the AI providers drive.
|
|
*
|
|
* Both `PiProcessNode` (pi-sdk-node.ts) and `CodexAppServerProcess`
|
|
* (codex-app-server.ts) talk to a nested agent over pipes, and a pipe can
|
|
* break at any instant — the child exits, is killed, or closes stdin while a
|
|
* command is in flight. Two Node behaviors turn that ordinary condition into
|
|
* a host-killing crash:
|
|
*
|
|
* 1. `stream.write()` on a broken pipe reports `EPIPE` either synchronously
|
|
* (throw) or asynchronously (an `error` event on the stream), and which
|
|
* one you get is a timing race. A `destroyed` check before the write
|
|
* cannot close that race: the child can close the pipe between the check
|
|
* and the write.
|
|
* 2. An `error` event on a Node stream (or on the `ChildProcess` itself)
|
|
* with NO listener is re-thrown as an `uncaughtException`. Inside an
|
|
* embedded extension that is not "the provider failed" — it terminates
|
|
* the HOST agent process.
|
|
*
|
|
* That is issue #1378: a Plannotator plan review opened from Pi on Windows
|
|
* took the whole Pi host down with `write EPIPE` out of `PiProcessNode.send()`.
|
|
* Windows only made it likelier to land on the async path during teardown;
|
|
* the mechanism is platform-independent.
|
|
*
|
|
* The contract these helpers enforce: a broken pipe is a PROVIDER failure.
|
|
* It never escalates past the provider, it is reported exactly like any other
|
|
* process end (in-flight requests reject, listeners see the process end), and
|
|
* the provider is left dead so the next query re-spawns it.
|
|
*/
|
|
|
|
import type { ChildProcess } from "node:child_process";
|
|
|
|
/** Normalize an unknown thrown/emitted value to an Error. */
|
|
export function toChildError(err: unknown): Error {
|
|
return err instanceof Error ? err : new Error(String(err));
|
|
}
|
|
|
|
/**
|
|
* Attach `error` listeners to the child process and every piped stream so a
|
|
* broken pipe can never reach `uncaughtException`.
|
|
*
|
|
* Call this immediately after `spawn()` returns — before awaiting the spawn
|
|
* handshake — so a stream that fails during startup is already covered. The
|
|
* spawn handshake may register its own one-shot `error` listener; Node allows
|
|
* several, and `onFailure` is expected to be idempotent.
|
|
*/
|
|
export function guardChildStreams(
|
|
proc: ChildProcess,
|
|
label: string,
|
|
onFailure: (error: Error) => void,
|
|
): void {
|
|
const report = (stream: string) => (err: unknown) => {
|
|
onFailure(new Error(`${label} ${stream} failed: ${toChildError(err).message}`));
|
|
};
|
|
proc.on("error", report("process"));
|
|
proc.stdin?.on("error", report("stdin"));
|
|
proc.stdout?.on("error", report("stdout"));
|
|
proc.stderr?.on("error", report("stderr"));
|
|
}
|
|
|
|
/**
|
|
* Write one already-newline-terminated line to a child's stdin.
|
|
*
|
|
* Returns the Error the write failed with, or `null` when the bytes were
|
|
* accepted by the stream. Failures that only surface later (the write
|
|
* callback, or an `error` event covered by {@link guardChildStreams}) are
|
|
* reported through `onAsyncFailure` instead, so BOTH the sync-throw and the
|
|
* async-event paths end up at the caller's failure handling.
|
|
*/
|
|
export function writeChildLine(
|
|
proc: ChildProcess | null,
|
|
line: string,
|
|
label: string,
|
|
onAsyncFailure: (error: Error) => void,
|
|
): Error | null {
|
|
const stdin = proc?.stdin;
|
|
if (!stdin || stdin.destroyed || stdin.writableEnded) {
|
|
return new Error(`${label} stdin is closed`);
|
|
}
|
|
try {
|
|
stdin.write(line, (err) => {
|
|
if (err) {
|
|
onAsyncFailure(
|
|
new Error(`${label} stdin write failed: ${toChildError(err).message}`),
|
|
);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
return new Error(`${label} stdin write failed: ${toChildError(err).message}`);
|
|
}
|
|
return null;
|
|
}
|