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..cc5b63f7 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -81,6 +81,17 @@ export const createPosixProcess: CreateOSProcess = (command, options) => { }, }; + const stdinErrorHandler = (err: Error & { code?: string }) => { + 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); + yield* spawn(function* trapError() { let [error] = yield* once<[Error]>(childProcess, "error"); processResult.resolve(Err(error)); @@ -128,6 +139,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/win32.ts b/process/src/exec/win32.ts index 95b8ce9b..3c5a4e68 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -127,14 +127,16 @@ 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; + const stdinErrorHandler = (err: Error & { code?: string }) => { + 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); try { yield* provide({ @@ -210,6 +212,7 @@ export const createWin32Process: CreateOSProcess = (command, options) => { } catch (_e) { // do nothing, process is probably already dead } + childProcess.stdin.off("error", stdinErrorHandler); } }); }; 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); + } +});