Repository navigation
NodeChildProcessSpawner: interrupting a child's stdin stream crashes the process with an unhandled EPIPE #9002
Description
What version of Effect is running?
effect 4.0.2, @effect/platform-node-shared 4.0.2 (the code is unchanged on main today). Bun 1.4.3 and Node 24.11.1, macOS.
What steps can reproduce the bug?
A child process gets a stdin stream, and the program is interrupted while a write to the child's stdin is still buffered, for example a pager the user quits, or a timeout. When the child goes away, the buffered write fails with EPIPE, and Node emits error on the child's stdin with no listener: an uncaught exception that ends the process.
bun repro.ts (or node repro.ts):
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node-shared"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess } from "effect/process"
process.on("exit", (code) => console.log(`process exit ${code}`))
const program = Effect.scoped(
Effect.gen(function*() {
// `sleep` never reads its input, like a pager the user quits.
const handle = yield* ChildProcess.make("sleep", ["5"], {
stdin: Stream.make(new Uint8Array(1024 * 1024))
})
return yield* handle.exitCode.pipe(Effect.timeout("100 millis"))
})
)
const result = await Effect.runPromiseExit(
program.pipe(
Effect.provide(NodeChildProcessSpawner.layer.pipe(Layer.provide([NodeFileSystem.layer, NodePath.layer])))
)
)
console.log("program finished:", result._tag, result._tag === "Failure" ? String(result.cause).split("\n")[0] : "")
await new Promise((resolve) => setTimeout(resolve, 500))
console.log("still alive after 500ms")What is the expected behavior?
The program ends with the timeout failure and the process exits normally:
program finished: Failure Cause([Fail(TimeoutError: Operation timed out after '100ms')])
still alive after 500ms
process exit 0
What do you see instead?
Node 24.11.1 (exit code 1, before the program finishes):
process exit 1
node:events:486
throw er; // Unhandled 'error' event
^
Error: write EPIPE
at WriteWrap.onWriteComplete [as oncomplete] (node:internal/stream_base_commons:87:19)
Emitted 'error' event on Socket instance at:
at emitErrorNT (node:internal/streams/destroy:170:8)
at emitErrorCloseNT (node:internal/streams/destroy:129:3)
at process.processTicksAndRejections (node:internal/process/task_queues:89:21) {
errno: -32,
code: 'EPIPE',
syscall: 'write'
}
Bun 1.4.3 reports the same uncaught EPIPE: broken pipe, write and exits with code 1. Deterministic in 5 of 5 runs on each runtime.
Additional information
The stdin sink (NodeSink.fromWritable) listens for the writable's error event only while it runs (NodeSink.ts L132-138 at 4.0.2: once("error"), then off("error") in the finalizer). When the stdin fiber is interrupted, the listener is removed while Node still holds the buffered write. NodeChildProcessSpawner.ts L301-308 creates that sink without any other listener on childProcess.stdin, while the child's stdout and stderr keep a listener of their own (L332, L343). The additional input fds (L247) look the same.
Adding a no-op listener when the stdin sink is created fixes the repro on both runtimes (the sink still fails on a write error while it runs; the child's exit code reports a child that left early):
if (Predicate.isNotNull(childProcess.stdin)) {
+ // A child that exits before it reads all of its input fails a write
+ // still buffered after the sink stopped; that must not throw as an
+ // unhandled "error" event.
+ childProcess.stdin.on("error", () => {})
sink = NodeSink.fromWritable({
evaluate: () => childProcess.stdin!,With it, the repro prints the expected output and exits 0 on Bun and Node.
Activity