From b34292a4d2e949cd6a6529088dfc6acad7bfa691 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 22 Mar 2026 11:29:47 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20Handle=20EPIPE=20on=20stdin?= =?UTF-8?q?=20in=20POSIX=20process=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POSIX createPosixProcess had no error handler on childProcess.stdin. When a child exits before a parent stdin write completes, Node emits EPIPE which surfaced as an uncaught exception. The Windows implementation already handled this. This adds the same suppression for POSIX and routes non-EPIPE stdin errors through the existing processResult error path. Co-Authored-By: Claude Opus 4.6 --- process/package.json | 2 +- process/src/exec/posix.ts | 8 ++++++++ process/test/exec.test.ts | 24 ++++++++++++++++++++++++ process/test/fixtures/read-one-line.js | 9 +++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 process/test/fixtures/read-one-line.js diff --git a/process/package.json b/process/package.json index ad1a9850..76c87918 100644 --- a/process/package.json +++ b/process/package.json @@ -1,7 +1,7 @@ { "name": "@effectionx/process", "description": "Spawn and manage child processes with structured concurrency", - "version": "0.7.3", + "version": "0.7.4", "keywords": [ "effection", "effectionx", diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index 575da480..875ff281 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -81,6 +81,14 @@ export const createPosixProcess: CreateOSProcess = (command, options) => { }, }; + childProcess.stdin.on("error", (err: Error & { code?: string }) => { + if (err.code === "EPIPE") { + return; // benign: child exited before write completed + } + // Route non-EPIPE errors through the package's normal error path + processResult.resolve(Err(err)); + }); + yield* spawn(function* trapError() { let [error] = yield* once<[Error]>(childProcess, "error"); processResult.resolve(Err(error)); diff --git a/process/test/exec.test.ts b/process/test/exec.test.ts index 766cf87d..b5a7d907 100644 --- a/process/test/exec.test.ts +++ b/process/test/exec.test.ts @@ -565,3 +565,27 @@ describe("handles env vars", () => { // Close the main "handles env vars" describe block }); + +describe("stdin EPIPE handling", () => { + it("does not crash when writing to stdin after child exits", function* () { + let proc = yield* exec("node './fixtures/read-one-line.js'", { + cwd: import.meta.dirname, + }); + + // First write succeeds — child reads this line and exits + proc.stdin.send("hello\n"); + + // Wait for child to exit cleanly + let status = yield* proc.join(); + expect(status.code).toEqual(0); + + // Explicitly write to stdin after child has exited. + // Without the EPIPE handler, this would surface as an uncaught exception. + proc.stdin.send("this should not crash\n"); + + // If we reach here, the EPIPE was handled gracefully. + // The test completing is the assertion — an uncaught EPIPE would + // have crashed the test runner before this point. + expect(true).toBe(true); + }); +}); diff --git a/process/test/fixtures/read-one-line.js b/process/test/fixtures/read-one-line.js new file mode 100644 index 00000000..8509d1b4 --- /dev/null +++ b/process/test/fixtures/read-one-line.js @@ -0,0 +1,9 @@ +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + if (buffer.includes("\n")) { + process.stdout.write("got line\n"); + process.exit(0); + } +}); From 4468d7d3cdcf8523634ef9f8c1e764555e1d4d16 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 22 Mar 2026 11:37:38 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20Align=20win32=20stdin=20erro?= =?UTF-8?q?r=20handler=20with=20POSIX=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route non-EPIPE stdin errors through processResult instead of throwing from the event callback, matching the pattern applied in posix.ts. Co-Authored-By: Claude Opus 4.6 --- process/src/exec/win32.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index 95b8ce9b..c8786bc8 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -127,13 +127,12 @@ export const createWin32Process: CreateOSProcess = (command, options) => { return status; } - // Suppress EPIPE errors on stdin - these occur on Windows when the child - // process exits before we finish writing to it. This is expected during - // cleanup when we're killing the process. childProcess.stdin.on("error", (err: Error & { code?: string }) => { - if (err.code !== "EPIPE") { - throw err; + if (err.code === "EPIPE") { + return; // benign: child exited before write completed } + // Route non-EPIPE errors through the package's normal error path + processResult.resolve(Err(err)); }); try { From fbbcee516b4589cd1bc5d277793d811d984acc59 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 22 Mar 2026 13:56:44 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20suppressStdi?= =?UTF-8?q?nEPIPE=20helper=20using=20action()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-locate bind/unbind for the stdin error handler using Effection's action() primitive. The cleanup function runs automatically when the spawned task is halted during scope teardown. Co-Authored-By: Claude Opus 4.6 --- process/src/exec/posix.ts | 9 ++------- process/src/exec/stdin.ts | 19 +++++++++++++++++++ process/src/exec/win32.ts | 9 ++------- 3 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 process/src/exec/stdin.ts diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index 875ff281..080587a3 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -14,6 +14,7 @@ import { } from "effection"; import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts"; import { ExecError } from "./error.ts"; +import { suppressStdinEPIPE } from "./stdin.ts"; type ProcessResultValue = [number?, string?]; @@ -81,13 +82,7 @@ export const createPosixProcess: CreateOSProcess = (command, options) => { }, }; - childProcess.stdin.on("error", (err: Error & { code?: string }) => { - if (err.code === "EPIPE") { - return; // benign: child exited before write completed - } - // Route non-EPIPE errors through the package's normal error path - processResult.resolve(Err(err)); - }); + yield* spawn(() => suppressStdinEPIPE(childProcess.stdin, processResult)); yield* spawn(function* trapError() { let [error] = yield* once<[Error]>(childProcess, "error"); diff --git a/process/src/exec/stdin.ts b/process/src/exec/stdin.ts new file mode 100644 index 00000000..e04bc885 --- /dev/null +++ b/process/src/exec/stdin.ts @@ -0,0 +1,19 @@ +import type { Writable } from "node:stream"; +import type { Operation, Result, WithResolvers } from "effection"; +import { Err, action } from "effection"; + +type ProcessResultValue = [number?, string?]; + +export function suppressStdinEPIPE( + stdin: Writable, + processResult: WithResolvers>, +): Operation { + return action((_resolve, _reject) => { + const handler = (err: Error & { code?: string }) => { + if (err.code === "EPIPE") return; + processResult.resolve(Err(err)); + }; + stdin.on("error", handler); + return () => stdin.off("error", handler); + }); +} diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index c8786bc8..43d82905 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -16,6 +16,7 @@ import { } from "effection"; import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts"; import { ExecError } from "./error.ts"; +import { suppressStdinEPIPE } from "./stdin.ts"; type ProcessResultValue = [number?, string?]; @@ -127,13 +128,7 @@ export const createWin32Process: CreateOSProcess = (command, options) => { return status; } - childProcess.stdin.on("error", (err: Error & { code?: string }) => { - if (err.code === "EPIPE") { - return; // benign: child exited before write completed - } - // Route non-EPIPE errors through the package's normal error path - processResult.resolve(Err(err)); - }); + yield* spawn(() => suppressStdinEPIPE(childProcess.stdin, processResult)); try { yield* provide({ From 7d0fa3cd9f5fc648a0a1c2adfef1e1ad67df4e25 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 22 Mar 2026 14:04:04 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20Fix=20stdin=20error=20handle?= =?UTF-8?q?r=20teardown=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action() approach removed the EPIPE handler too early — before the finally block writes to stdin during Windows graceful shutdown. Use named handler with explicit .off() at the end of finally instead, ensuring the handler survives through all cleanup writes. Co-Authored-By: Claude Opus 4.6 --- process/src/exec/posix.ts | 8 ++++++-- process/src/exec/stdin.ts | 19 ------------------- process/src/exec/win32.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 23 deletions(-) delete mode 100644 process/src/exec/stdin.ts diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index 080587a3..bcf503f3 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -14,7 +14,6 @@ import { } from "effection"; import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts"; import { ExecError } from "./error.ts"; -import { suppressStdinEPIPE } from "./stdin.ts"; type ProcessResultValue = [number?, string?]; @@ -82,7 +81,11 @@ export const createPosixProcess: CreateOSProcess = (command, options) => { }, }; - yield* spawn(() => suppressStdinEPIPE(childProcess.stdin, processResult)); + const stdinErrorHandler = (err: Error & { code?: string }) => { + if (err.code === "EPIPE") return; + processResult.resolve(Err(err)); + }; + childProcess.stdin.on("error", stdinErrorHandler); yield* spawn(function* trapError() { let [error] = yield* once<[Error]>(childProcess, "error"); @@ -131,6 +134,7 @@ export const createPosixProcess: CreateOSProcess = (command, options) => { } catch (_e) { // do nothing, process is probably already dead } + childProcess.stdin.off("error", stdinErrorHandler); } }); }; diff --git a/process/src/exec/stdin.ts b/process/src/exec/stdin.ts deleted file mode 100644 index e04bc885..00000000 --- a/process/src/exec/stdin.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Writable } from "node:stream"; -import type { Operation, Result, WithResolvers } from "effection"; -import { Err, action } from "effection"; - -type ProcessResultValue = [number?, string?]; - -export function suppressStdinEPIPE( - stdin: Writable, - processResult: WithResolvers>, -): Operation { - return action((_resolve, _reject) => { - const handler = (err: Error & { code?: string }) => { - if (err.code === "EPIPE") return; - processResult.resolve(Err(err)); - }; - stdin.on("error", handler); - return () => stdin.off("error", handler); - }); -} diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index 43d82905..88da8e92 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -16,7 +16,6 @@ import { } from "effection"; import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts"; import { ExecError } from "./error.ts"; -import { suppressStdinEPIPE } from "./stdin.ts"; type ProcessResultValue = [number?, string?]; @@ -128,7 +127,11 @@ export const createWin32Process: CreateOSProcess = (command, options) => { return status; } - yield* spawn(() => suppressStdinEPIPE(childProcess.stdin, processResult)); + const stdinErrorHandler = (err: Error & { code?: string }) => { + if (err.code === "EPIPE") return; + processResult.resolve(Err(err)); + }; + childProcess.stdin.on("error", stdinErrorHandler); try { yield* provide({ @@ -204,6 +207,7 @@ export const createWin32Process: CreateOSProcess = (command, options) => { } catch (_e) { // do nothing, process is probably already dead } + childProcess.stdin.off("error", stdinErrorHandler); } }); }; From 4c05cca3117764493bafbeaf841cc92f4b4be0db Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 23 Mar 2026 13:04:39 -0400 Subject: [PATCH 5/5] Add console.warn for stdin EPIPE to help diagnose write-after-exit Logs a warning when writing to stdin of an already-exited child process, helping developers spot logic bugs where writes aren't gated on join(). Co-Authored-By: Claude Opus 4.6 --- process/src/exec/posix.ts | 7 ++++++- process/src/exec/win32.ts | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index bcf503f3..cc5b63f7 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -82,7 +82,12 @@ export const createPosixProcess: CreateOSProcess = (command, options) => { }; const stdinErrorHandler = (err: Error & { code?: string }) => { - if (err.code === "EPIPE") return; + if (err.code === "EPIPE") { + console.warn( + `stdin EPIPE: child process (pid: ${childProcess.pid}) already exited. Writes to stdin are being discarded.`, + ); + return; + } processResult.resolve(Err(err)); }; childProcess.stdin.on("error", stdinErrorHandler); diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index 88da8e92..3c5a4e68 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -128,7 +128,12 @@ export const createWin32Process: CreateOSProcess = (command, options) => { } const stdinErrorHandler = (err: Error & { code?: string }) => { - if (err.code === "EPIPE") return; + if (err.code === "EPIPE") { + console.warn( + `stdin EPIPE: child process (pid: ${childProcess.pid}) already exited. Writes to stdin are being discarded.`, + ); + return; + } processResult.resolve(Err(err)); }; childProcess.stdin.on("error", stdinErrorHandler);