Skip to content

fix(ai): a broken RPC pipe must fail the provider, not kill the host (#1378) - #1379

Merged
backnotprop merged 1 commit into
mainfrom
fix/1378-pi-provider-stdio-epipe
Aug 23, 2026
Merged

fix(ai): a broken RPC pipe must fail the provider, not kill the host (#1378)#1379
backnotprop merged 1 commit into
mainfrom
fix/1378-pi-provider-stdio-epipe

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

TLDR

Opening a Plannotator plan review from Pi could kill the entire Pi host with an uncaught write EPIPE. A broken pipe to the nested pi --mode rpc child is now handled as an ordinary provider failure: the in-flight query rejects with a clear message, the error surfaces in Ask AI, the provider is marked dead so the next query re-spawns it, and the host stays alive.

Closes #1378. Reported by @Kaelenx, who also did the root-cause analysis in the issue and pointed at the exact line.

What was actually going wrong

The provider guarded its write like this:

if (!this.proc?.stdin || this.proc.stdin.destroyed) return;
this.proc.stdin.write(`${JSON.stringify(command)}\n`);

Two separate problems compound into a host kill.

The check cannot close the race. destroyed describes the stream a moment ago. The child can close its end of the pipe between the check and the write, and no amount of pre-checking fixes that; the write itself is the only place the truth is known.

Nothing was listening for the failure. Node reports EPIPE from a pipe write either as a synchronous throw or as an error event on the stream, and which one you get is a timing race. An error event on a Node stream with no listener is not "an error you can ignore"; Node re-throws it as an uncaughtException. Inside an embedded extension that does not fail the provider, it terminates the process that loaded it, which is the user's whole Pi session.

No stream here had an error listener. The ChildProcess did not have one either: the spawn handshake registered a one-shot error listener and then removed it on success, so from the moment a spawn succeeded the child was completely unguarded for the rest of its life.

Windows only made it likelier to land on the async path during teardown, which is why the report came from there. The mechanism is platform-independent.

There was a second, quieter bug behind the same line. send() was fire-and-forget, so a write that failed left the matching sendAndWait promise pending forever rather than rejecting. The Pi provider has no RPC timeout, so nothing else would have released it.

The guard pattern

The two JSONL/JSON-RPC providers (Pi Node, Codex app-server) had byte-for-byte the same unguarded send(), so rather than patching the reported line twice, the pattern is fixed once in a shared module, packages/ai/providers/child-io.ts:

  • guardChildStreams(proc, label, onFailure) attaches error listeners to the child process and every piped stream, immediately after spawn and before the spawn handshake, so nothing can escalate to uncaughtException.
  • writeChildLine(proc, line, label, onAsyncFailure) returns the error for a synchronous failure and routes an asynchronous one through the write callback, so the sync-throw and error-event paths converge on the same handling.
  • failProcess(error) in each provider resolves a broken pipe exactly like any other process end: reject everything in flight, broadcast the process end so a streaming query terminates instead of hanging, reap the child, and leave alive false so the existing lifecycle re-spawns on the next query.

I looked for a stronger pattern to copy first. claude-agent-sdk spawns nothing of its own, opencode-sdk delegates to its SDK, and codex-app-server had the identical defect, so there was no good pattern among the siblings to match; the best guards in the repo are elsewhere (vcs.ts, semantic-diff.ts, call-flow.ts) and are swallow-only, which is right for a fire-and-forget CLI write but wrong here, where the failure has to become a visible provider error.

Scope

In this PR, all in packages/ai/providers/:

Deliberately not in this PR, found while sweeping for the same class and reported separately so the diff stays reviewable: unguarded stdin writes in apps/pi-extension/server/agent-jobs.ts and server/pr.ts, two fire-and-forget spawn("open", ...) calls in server/integrations.ts with no error listener at all (an ENOENT there is an immediate host kill on any machine without open), refusal-branch socket writes in live-proxy-node.ts that return before the socket's error listener is attached, and the missing RPC timeout that lets a silent-but-alive child hang a Pi query indefinitely.

Test evidence

packages/ai/providers/pi-stdio-failure.test.ts, in two layers.

The end-to-end block runs the provider in a real node child against a fake Pi that closes its own stdin, following the harness already used by live-proxy-node.test.ts. Node is the runtime under Pi, and Bun's node:stream shims do not reproduce the unhandled-error-kills-the-process rule, so an in-process test could pass against code that still kills the host. The child installs no uncaughtException handler on purpose: surviving to print its result line is the assertion.

It asserts the in-flight request rejects naming the pipe, alive is false, listeners saw the process end, a later fire-and-forget send does not throw, a fresh process talks normally afterwards, and end to end through a session the failure surfaces as an error message while the next query on the same session re-spawns and completes.

Against the unfixed provider that child dies exactly as reported:

"exitCode": 1,
"stderr": Error: write EPIPE
  code: 'EPIPE',

With the fix, exit 0 and empty stderr.

The unit block drives writeChildLine / guardChildStreams with stream doubles, which is the only way to pin both shapes deterministically, since which one a real pipe produces is a platform and timing race: a synchronous throw from write(), and an asynchronous error delivered to the write callback.

Checks run: 6/6 new tests pass; bun test packages/ai/ 122/122; full bun test 3819 pass with 15 failures that reproduce identically on unmodified origin/main (config-from-disk, improvement-hook and route-404 suites, environmental); bun run typecheck clean across all nine projects; vendor.sh re-run with child-io added to the provider list so generated/ai/providers/ reflects the fix; npm pack --dry-run complete with generated/ai/providers/child-io.ts shipping.

AI-assisted (Claude) under maintainer direction.

…1378)

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.
@Kaelenx

Kaelenx commented Aug 23, 2026

Copy link
Copy Markdown

Thanks for the quick fix and for crediting the report. I’m available to validate it on Windows 11 with Pi 0.84.2 if useful.

@backnotprop

backnotprop commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

That would genuinely help, since the crash manifests most readily on Windows and our CI exercises the guard with simulated pipes. To test the branch build: clone the repo, checkout fix/1378-pi-provider-stdio-epipe (or fetch the PR head), run bash apps/pi-extension/vendor.sh, then point Pi at the local extension path instead of the npm package and repeat your repro from the issue. Expected: the review page failure surfaces as an Ask AI provider error inside the session, Pi stays alive, and the next query works. If you see any exit or stack trace at all, paste it here. It ships in v0.27.7 either way, but a confirmation from the exact machine that crashed is the best evidence we can get.

@Kaelenx

Kaelenx commented Aug 23, 2026

Copy link
Copy Markdown

Verified on the original Windows environment against commit 8445670e180044cd83ad4eeebcb9a95ae693ad7d.

Environment:

  • Windows 11 x64 (build 22621)
  • Pi 0.84.2
  • Node 22.23.1
  • Bun 1.4.0

I built the local Pi extension and exercised the full plan-review startup path under a real Pi host. To make the timing deterministic, I put a native Windows fake pi.exe first on the child-discovery PATH; its first process closes stdin before the provider writes, producing a real Windows broken pipe rather than a mocked stream.

Control result on base db86d38ca46dc5faf91bbec9dd9d2d0e824b2321: the host exits 1 with the exact reported stack:

Error: write EPIPE
  at PiProcessNode.send (.../generated/ai/providers/pi-sdk-node.ts:158:21)
  at PiProcessNode.sendAndWait (.../pi-sdk-node.ts:165:12)
  at PiSDKNodeProvider.fetchModels (.../pi-sdk-node.ts:258:12)
code: 'EPIPE'

PR result with the same harness:

  • the broken child was launched and closed stdin;
  • the plan-review server started and served both /api/plan and the HTML page;
  • the outer Pi host remained alive and answered RPC state requests before and after recovery;
  • no uncaughtException, EPIPE stack, or host exit occurred;
  • after changing the fake child to healthy mode, a new pi-sdk Ask AI session/query returned a successful result, confirming re-spawn/recovery.

I also ran the provider child harness directly with a native Windows broken pipe. It reported the in-flight request rejected with Pi process stdin write failed: write EPIPE, alive: false, process_exited observed, no throw on a later fire-and-forget send, and a successful subsequent query after re-spawn. Process exit was 0 with no uncaught stderr.

Additional checks:

  • bun run build:pi passed
  • bun test packages/ai/: 121 pass, 1 expected Windows skip, 0 fail

So this fixes the reported host-killing failure on the machine/runtime where it occurred.

@backnotprop
backnotprop merged commit d977bce into main Aug 23, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pi plan review crashes host with unhandled EPIPE from Pi RPC stdin on Windows

2 participants