From 42e2dde73972b40fd8c567ed97f77071b7bd3ea7 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 17 Jul 2026 14:46:46 +0200 Subject: [PATCH] fix(cli): prevent Node stdout EPIPE failures Keep draining standalone server stdout after reading its readiness message so later server output does not write into a closed pipe. Install the Node CLI broken-pipe handler before loading the main entrypoint. Ignore only EPIPE while preserving all unrelated stdout errors. This prevents Windows Node builds from failing when the TUI prints its exit epilogue after renderer teardown. --- packages/cli/src/node/index.ts | 8 +++++++- packages/cli/src/services/standalone.ts | 14 +++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/node/index.ts b/packages/cli/src/node/index.ts index b7bb7db5414..3559a614ca1 100644 --- a/packages/cli/src/node/index.ts +++ b/packages/cli/src/node/index.ts @@ -1,3 +1,9 @@ import "./plugin-runtime.promise" import "./plugin-runtime.effect" -import "../index" + +process.stdout.on("error", (error) => { + if ("code" in error && error.code === "EPIPE") return + throw error +}) + +await import("../index") diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index 273cccbf495..d1ea7fc9c57 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -1,7 +1,7 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Effect, Schema, Stream } from "effect" +import { Deferred, Effect, Schema, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { randomBytes } from "node:crypto" import { selfCommand } from "../util/process" @@ -36,8 +36,16 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")( const password = randomBytes(32).toString("base64url") const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const proc = yield* spawner.spawn(command(password, options)) - const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) - if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) + const readyLine = yield* Deferred.make() + // Keep draining stdout after readiness so later server writes cannot hit EPIPE. + yield* proc.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => Deferred.succeed(readyLine, line)), + Effect.ensuring(Deferred.fail(readyLine, new Error("Standalone server exited before reporting readiness"))), + Effect.forkScoped, + ) + const output = yield* Deferred.await(readyLine) const ready = yield* Effect.tryPromise(() => decodeReady(output)) return { url: ready.url,