From 92e8eef38a4e5af976c583334fc541c02666b2ac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:45:26 +0000 Subject: [PATCH 1/4] stdio: reset writable state after end() on file-backed process.stdout/stderr b280ca3fbc93 made the fast-path write() consult writable state so a piped process.stdout rejects writes after end(). File-backed stdio went through the same check: its writable state also latched ending/ended because the stream is created with autoClose:false, which WriteStream maps to autoDestroy:false, so the finish -> destroy -> _undestroy cycle that Node's SyncWriteStream relies on to reset state never ran. A later process.stdout.write() then failed with ERR_STREAM_WRITE_AFTER_END and the bytes were dropped from the output file, which is the pipeline(src, process.stdout) followed by more logging shape. Enable autoDestroy on file-backed stdio so end() runs through the existing stdio _destroy override (cb + _undestroy), matching Node's SyncWriteStream: writableEnded returns to false and later writes succeed. Pipe/socket stdio keeps autoDestroy:false so the write-after-end rejection from b280ca3fbc93 is preserved there. --- src/js/builtins/ProcessObjectInternals.ts | 5 ++ ...ss-stdout-write-after-end-file-fixture.mjs | 41 +++++++++++++++ .../process-stdout-write-after-end.test.ts | 50 ++++++++++++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 test/js/node/process/process-stdout-write-after-end-file-fixture.mjs diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 5206f8d7f10f..32f90f3f18a1 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -65,6 +65,11 @@ export function getStdioWriteStream( // stdout/stderr don't produce readable data, so yield nothing })(); }; + } else { + // File-backed stdio: Node's SyncWriteStream runs end() -> finish -> + // destroy -> the _destroy override below -> _undestroy(), which resets + // writable state so later writes succeed. autoClose:false disabled that. + stream._writableState.autoDestroy = true; } } diff --git a/test/js/node/process/process-stdout-write-after-end-file-fixture.mjs b/test/js/node/process/process-stdout-write-after-end-file-fixture.mjs new file mode 100644 index 000000000000..9e71434dc0be --- /dev/null +++ b/test/js/node/process/process-stdout-write-after-end-file-fixture.mjs @@ -0,0 +1,41 @@ +const code = e => (e == null ? null : String(e.code || e.name || e)); + +// Which stream to exercise. The JSON report goes to the other stream. +const which = process.argv[2] === "stderr" ? "stderr" : "stdout"; +const so = process[which]; +const reportStream = which === "stderr" ? process.stdout : process.stderr; + +const ev = []; +so.on("error", e => ev.push("err:" + code(e))); + +const finished = new Promise(resolve => so.once("finish", resolve)); +so.write("A"); +so.end("B"); + +// Let end() run to completion before the post-end write. This is the +// pipeline(src, process.stdout) shape: pipeline resolves after the destination +// finishes, then the program keeps logging. 'finish' always fires; one +// setImmediate then lets any finish -> destroy -> _undestroy nextTicks drain. +await finished; +await new Promise(resolve => setImmediate(resolve)); + +const { promise, resolve } = Promise.withResolvers(); +let cbErr = "no-callback"; +const ret = so.write("C", e => { + cbErr = code(e); + resolve(); +}); +// console goes to the same fd; its bytes must land alongside the write(). +console[which === "stderr" ? "error" : "log"]("D"); +await promise; +await new Promise(r => process.nextTick(r)); + +reportStream.write( + JSON.stringify({ + writableEnded: so.writableEnded, + writable: so.writable, + ret, + cbErr, + ev, + }) + "\n", +); diff --git a/test/js/node/process/process-stdout-write-after-end.test.ts b/test/js/node/process/process-stdout-write-after-end.test.ts index 178b0ff47782..9c84ca668c14 100644 --- a/test/js/node/process/process-stdout-write-after-end.test.ts +++ b/test/js/node/process/process-stdout-write-after-end.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; +import fs from "node:fs"; import path from "path"; test.each(["stdout", "stderr"] as const)( @@ -42,3 +43,50 @@ test.each(["stdout", "stderr"] as const)( expect(exitCode).toBe(0); }, ); + +test.each(["stdout", "stderr"] as const)( + "process.%s - write after end() succeeds and is delivered (file)", + async which => { + using dir = tempDir("stdio-write-after-end-file", {}); + const outPath = path.join(String(dir), "out.txt"); + const fd = fs.openSync(outPath, "w"); + try { + // Redirect the fixture's target stream to a regular file; the report + // stream stays piped so we can read the JSON facts. + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "process-stdout-write-after-end-file-fixture.mjs"), which], + stdout: which === "stdout" ? fd : "pipe", + stderr: which === "stderr" ? fd : "pipe", + stdin: "ignore", + env: bunEnv, + }); + + const reportStream = which === "stderr" ? proc.stdout : proc.stderr; + const [reportText, exitCode] = await Promise.all([reportStream.text(), proc.exited]); + const lines = reportText.trim().split("\n"); + const report = JSON.parse(lines[lines.length - 1]); + + // Node's file-backed stdio is never-closing: end() runs the finish -> + // destroy -> _undestroy cycle, which resets writable state, so a later + // write() succeeds with no error and writableEnded is false again. + expect(report).toEqual({ + writableEnded: false, + writable: true, + ret: true, + cbErr: null, + ev: [], + }); + + const fileContents = fs.readFileSync(outPath, "latin1"); + // stderr may carry benign ASAN/debug noise on fd 2; stdout is clean. + if (which === "stdout") { + expect(fileContents).toBe("ABCD\n"); + } else { + expect(fileContents).toContain("ABCD\n"); + } + expect(exitCode).toBe(0); + } finally { + fs.closeSync(fd); + } + }, +); From 7664448e901129af4d32eace652f0bc9379eb107 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:54:17 +0000 Subject: [PATCH 2/4] test: run stdio write-after-end cases concurrently --- src/js/builtins/ProcessObjectInternals.ts | 5 ----- test/js/node/process/process-stdout-write-after-end.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 32f90f3f18a1..5206f8d7f10f 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -65,11 +65,6 @@ export function getStdioWriteStream( // stdout/stderr don't produce readable data, so yield nothing })(); }; - } else { - // File-backed stdio: Node's SyncWriteStream runs end() -> finish -> - // destroy -> the _destroy override below -> _undestroy(), which resets - // writable state so later writes succeed. autoClose:false disabled that. - stream._writableState.autoDestroy = true; } } diff --git a/test/js/node/process/process-stdout-write-after-end.test.ts b/test/js/node/process/process-stdout-write-after-end.test.ts index 9c84ca668c14..32ceb8f1cc7c 100644 --- a/test/js/node/process/process-stdout-write-after-end.test.ts +++ b/test/js/node/process/process-stdout-write-after-end.test.ts @@ -3,7 +3,7 @@ import { bunEnv, bunExe, tempDir } from "harness"; import fs from "node:fs"; import path from "path"; -test.each(["stdout", "stderr"] as const)( +test.concurrent.each(["stdout", "stderr"] as const)( "process.%s - write after end() errors and is not delivered (piped)", async which => { await using proc = Bun.spawn({ @@ -44,7 +44,7 @@ test.each(["stdout", "stderr"] as const)( }, ); -test.each(["stdout", "stderr"] as const)( +test.concurrent.each(["stdout", "stderr"] as const)( "process.%s - write after end() succeeds and is delivered (file)", async which => { using dir = tempDir("stdio-write-after-end-file", {}); From 210409107107fe70971df1955b475ae7be992b1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:57:43 +0000 Subject: [PATCH 3/4] Restore autoDestroy assignment for file-backed stdio The previous commit inadvertently dropped the src/js/builtins/ProcessObjectInternals.ts hunk while applying the test.concurrent.each change. --- src/js/builtins/ProcessObjectInternals.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 5206f8d7f10f..32f90f3f18a1 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -65,6 +65,11 @@ export function getStdioWriteStream( // stdout/stderr don't produce readable data, so yield nothing })(); }; + } else { + // File-backed stdio: Node's SyncWriteStream runs end() -> finish -> + // destroy -> the _destroy override below -> _undestroy(), which resets + // writable state so later writes succeed. autoClose:false disabled that. + stream._writableState.autoDestroy = true; } } From adeb9c1a9af1c6e829b5ee79e41eaa2e2a100aa1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:37:45 +0000 Subject: [PATCH 4/4] ci: retrigger