From c00b0f02209a4cd4ca936d754cb0c197ef5a21bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:21:29 +0000 Subject: [PATCH 1/3] FileSink: keep the event loop alive until end() has drained a pipe A chunk larger than the pipe buffer leaves its tail in the sink's buffer. end() after that takes a short flush and sets done. The deferred auto-flush task then released the writable poll's ref on the loop because done was set, and the process exited with the tail unwritten. Release the ref only when the buffer is empty. When done is set with bytes still buffered, the writable poll drains them and holds the loop until then. --- src/runtime/webcore/FileSink.rs | 9 ++- test/js/bun/util/filesink.test.ts | 115 ++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 48ac8dd9adf7..9cd5c0901533 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -852,11 +852,18 @@ impl FileSink { pub(crate) unsafe fn on_auto_flush(this: *mut FileSink) -> bool { // SAFETY: caller contract — `this` is live with write+dealloc provenance. unsafe { - if (*this).done.get() || !(*this).writer.get().has_pending_data() { + if !(*this).writer.get().has_pending_data() { (*this).update_ref(false); (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } + // `end()` took a short flush and left the tail in the buffer. The + // writable poll drains it, and its ref on the loop must stay until + // then, or the process exits with the tail unwritten. + if (*this).done.get() { + (*this).auto_flusher.with_mut(|a| a.registered.set(false)); + return false; + } let _guard = RefPtr::init_ref(this); diff --git a/test/js/bun/util/filesink.test.ts b/test/js/bun/util/filesink.test.ts index 3da3517f4bb2..1858e68157e0 100644 --- a/test/js/bun/util/filesink.test.ts +++ b/test/js/bun/util/filesink.test.ts @@ -938,6 +938,121 @@ describe("FileSink flush() from a 'beforeExit' listener", () => { }); }); +// A chunk larger than the pipe buffer leaves its tail in the sink's buffer +// and write() returns a promise. end() after that takes a short flush and +// hands back the same promise. The writable poll drains the tail over the +// next loop ticks, so the process must stay alive until it is empty, with or +// without an await on that promise. It used to exit on the next deferred +// tick with the tail unwritten. +describe("FileSink on a pipe stays alive until end() has drained the buffer", () => { + const size = 4 * 1024 * 1024; + + // The parent reads stdout only after the child reports on stderr that + // end() returned, so the child's first write has already filled the pipe. + async function run(body: string) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let settled = "pending"; + process.on("exit", code => console.error(JSON.stringify({ settled, code }))); + ${body} + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const decoder = new TextDecoder(); + const reader = proc.stderr.getReader(); + let stderr = ""; + while (!stderr.startsWith("ended\n")) { + const { value, done } = await reader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + const rest = (async () => { + while (true) { + const { value, done } = await reader.read(); + if (done) return; + stderr += decoder.decode(value, { stream: true }); + } + })(); + const [stdout, , exitCode] = await Promise.all([proc.stdout.bytes(), rest, proc.exited]); + return { stdoutLength: stdout.length, stderr, exitCode }; + } + + it.concurrent("end() without await", async () => { + expect( + await run(` + const w = Bun.stdout.writer(); + w.write(Buffer.alloc(${size}, 46)); + w.end().then(() => { settled = "resolved"; }, e => { settled = "rejected: " + e.code; }); + console.error("ended"); + `), + ).toEqual({ + stdoutLength: size, + stderr: "ended\n" + JSON.stringify({ settled: "resolved", code: 0 }) + "\n", + exitCode: 0, + }); + }); + + it.concurrent("await end() inside an async function", async () => { + expect( + await run(` + async function main() { + const w = Bun.stdout.writer(); + w.write(Buffer.alloc(${size}, 46)); + const p = w.end(); + console.error("ended"); + await p; + settled = "resolved"; + } + main(); + `), + ).toEqual({ + stdoutLength: size, + stderr: "ended\n" + JSON.stringify({ settled: "resolved", code: 0 }) + "\n", + exitCode: 0, + }); + }); + + // The unref'd child does not hold the loop. The bytes still owed to its + // stdin must. The child starts to read only once end() has been called, so + // the first write has filled the pipe by then. It inherits stdout, so its + // count arrives on the parent's stdout after it has read everything. + it.concurrent("Bun.spawn stdin pipe with an unref'd child", async () => { + const flag = join(tmpdirSync(), "ended"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const child = Bun.spawn( + [ + process.execPath, + "-e", + 'while (!require("fs").existsSync(process.argv[1])) Bun.sleepSync(1); console.log((await Bun.stdin.bytes()).length);', + ${JSON.stringify(flag)}, + ], + { stdin: "pipe", stdout: "inherit", stderr: "inherit" }, + ); + child.stdin.write(Buffer.alloc(${size}, 65)); + child.stdin.end(); + child.unref(); + require("fs").writeFileSync(${JSON.stringify(flag)}, ""); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: `${size}\n`, stderr: "", exitCode: 0 }); + }); +}); + it("fs.promises.writeFile with iterables under GC pressure does not crash", async () => { const dir = tmpdirSync(); await using proc = Bun.spawn({ From 367709587e60268859b5222742b947f3c8021402 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:23:20 +0000 Subject: [PATCH 2/3] test: accept a non-promise end() result and bound the reader's wait for the flag On Windows uv_write takes the whole chunk, so end() returns a number and .then() threw. The stdin test's reader now gives up after a deadline, and the parent writes the flag from a finally block, so the reader cannot be orphaned by a parent that threw. --- test/js/bun/util/filesink.test.ts | 36 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/test/js/bun/util/filesink.test.ts b/test/js/bun/util/filesink.test.ts index 1858e68157e0..85a7327ac66c 100644 --- a/test/js/bun/util/filesink.test.ts +++ b/test/js/bun/util/filesink.test.ts @@ -983,12 +983,14 @@ describe("FileSink on a pipe stays alive until end() has drained the buffer", () return { stdoutLength: stdout.length, stderr, exitCode }; } + // On Windows uv_write takes the whole chunk at once and end() can return a + // plain number, hence Promise.resolve(). it.concurrent("end() without await", async () => { expect( await run(` const w = Bun.stdout.writer(); w.write(Buffer.alloc(${size}, 46)); - w.end().then(() => { settled = "resolved"; }, e => { settled = "rejected: " + e.code; }); + Promise.resolve(w.end()).then(() => { settled = "resolved"; }, e => { settled = "rejected: " + e.code; }); console.error("ended"); `), ).toEqual({ @@ -1024,24 +1026,36 @@ describe("FileSink on a pipe stays alive until end() has drained the buffer", () // count arrives on the parent's stdout after it has read everything. it.concurrent("Bun.spawn stdin pipe with an unref'd child", async () => { const flag = join(tmpdirSync(), "ended"); + // Polls for the flag with a deadline so that it cannot outlive a parent + // that died before writing it. + const reader = ` + const fs = require("fs"); + const deadline = Date.now() + 60_000; + while (!fs.existsSync(process.argv[1])) { + if (Date.now() > deadline) { + console.error("gave up waiting for " + process.argv[1]); + process.exit(3); + } + Bun.sleepSync(1); + } + console.log((await Bun.stdin.bytes()).length); + `; await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const child = Bun.spawn( - [ - process.execPath, - "-e", - 'while (!require("fs").existsSync(process.argv[1])) Bun.sleepSync(1); console.log((await Bun.stdin.bytes()).length);', - ${JSON.stringify(flag)}, - ], + [process.execPath, "-e", ${JSON.stringify(reader)}, ${JSON.stringify(flag)}], { stdin: "pipe", stdout: "inherit", stderr: "inherit" }, ); - child.stdin.write(Buffer.alloc(${size}, 65)); - child.stdin.end(); - child.unref(); - require("fs").writeFileSync(${JSON.stringify(flag)}, ""); + try { + child.stdin.write(Buffer.alloc(${size}, 65)); + child.stdin.end(); + child.unref(); + } finally { + require("fs").writeFileSync(${JSON.stringify(flag)}, ""); + } `, ], env: bunEnv, From ce4578fc892d19d8761f8ac5ae06ca0f22c3efda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:26:19 +0000 Subject: [PATCH 3/3] FileSink: shorten the on_auto_flush comment --- src/runtime/webcore/FileSink.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 9cd5c0901533..7a0936ee5f57 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -857,9 +857,7 @@ impl FileSink { (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } - // `end()` took a short flush and left the tail in the buffer. The - // writable poll drains it, and its ref on the loop must stay until - // then, or the process exits with the tail unwritten. + // After `end()` the writable poll drains the tail and drops the ref. if (*this).done.get() { (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false;