Skip to content

NodeChildProcessSpawner: interrupting a child's stdin stream crashes the process with an unhandled EPIPE #9002

Closed

Description

@cevr

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions