Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/runtime/webcore/FileSink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,25 @@ impl FileSink {

match flush_result {
WriteResult::Done(written) => {
if self.pending.get().state == streams::PendingState::Pending {
// A backpressured `write()` left its promise outstanding and
// `flush()` drained the remainder just now. `IOWriter::flush`
// doesn't route through `on_write`, so nothing else will
// settle that promise: hand it back (like the Pending/Err
// arms) and schedule `run_pending`. `pending.result` already
// holds `Owned(consumed)` from `to_result`. Grab the promise
// before `writer.end()` so `on_close` re-entry runs with the
// slot already pinned.
// SAFETY: JsCell — `WritablePending::promise` allocates a
// JSPromise (may GC) but does not invoke any FileSink
// host-fn synchronously.
let promise_result = unsafe { self.pending.get_mut() }.promise(global_this);
self.update_ref(false);
self.writer.with_mut(|w| w.end());
self.run_pending_later();
// SAFETY: `WritablePending::promise()` never returns null.
return sys::Result::Ok(unsafe { (*promise_result).to_js() });
}
self.update_ref(false);
self.writer.with_mut(|w| w.end());
sys::Result::Ok(JSValue::js_number(written as f64))
Expand Down Expand Up @@ -1187,6 +1206,21 @@ impl FileSink {
sys::Result::Ok(unsafe { (*promise_result).to_js() })
}
WriteResult::Wrote(written) => {
if self.pending.get().state == streams::PendingState::Pending {
// Same as the Done arm above: `flush()` drained the
// backpressured write's remainder without routing through
// `on_write`, so hand back the outstanding promise and
// schedule `run_pending` to resolve it with the
// `Owned(consumed)` `to_result` already latched.
// SAFETY: JsCell — `WritablePending::promise` allocates a
// JSPromise (may GC) but does not invoke any FileSink
// host-fn synchronously.
let promise_result = unsafe { self.pending.get_mut() }.promise(global_this);
self.writer.with_mut(|w| w.end());
self.run_pending_later();
// SAFETY: `WritablePending::promise()` never returns null.
return sys::Result::Ok(unsafe { (*promise_result).to_js() });
}
self.writer.with_mut(|w| w.end());
sys::Result::Ok(JSValue::js_number(written as f64))
}
Expand Down
88 changes: 88 additions & 0 deletions test/js/bun/util/filesink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,94 @@
expect(received).toBe(size);
});

// end() called after a backpressured write() once the reader has drained the
// socket buffer: end_from_js's own flush() writes the remaining buffered bytes
// synchronously and lands in the Done/Wrote arm. Those arms used to return the
// drained byte count without touching the pending slot, so the write()'s
// promise was orphaned and never settled. end() now hands back that same
// promise (like the Pending/Err arms) and schedules run_pending to resolve it
// with the write's chunk size.
it.skipIf(!isPosix)(
"end() after a backpressured write() whose remainder drains synchronously returns and resolves the write's promise",
async () => {
const [readFd, writeFd] = createSocketPair();
const sink = Bun.file(writeFd).writer();
try {
// Write 16 KiB chunks until one backpressures. 16 KiB is at least the
// sink's CHUNK_SIZE on every target so each write reaches the fd
// immediately, and when backpressure hits the sink's buffered remainder
// is at most one chunk — small enough that end()'s flush() can push it
// out in one go after the read side has been drained.
const chunkSize = 16 * 1024;
const chunk = Buffer.alloc(chunkSize, 0x61);
let total = 0;
let writePromise!: Promise<number>;
for (let i = 0; i < 4096; i++) {
const r = sink.write(chunk);
total += chunkSize;
if (r instanceof Promise) {
writePromise = r;
break;
}
}
expect(writePromise).toBeInstanceOf(Promise);

// Drain the read side synchronously so the socket buffer has room before
// end()'s flush() runs. No await in between: the deferred auto-flush
// (which already handles this correctly) must not fire first.
const buf = Buffer.alloc(64 * 1024);
let drained = 0;
const drainSync = () => {
for (;;) {
try {
const n = fs.readSync(readFd, buf);
if (!n) break;
drained += n;
} catch {
break;
}
}
};
drainSync();
expect(drained).toBeGreaterThan(0);

// flush() writes the buffered remainder. When it completes (Done/Wrote),
// end() hands back the write()'s promise instead of a bare number that
// strands it; when the remainder still doesn't fit (Pending) it already
// did. Either way end()'s result and write()'s promise are the same
// object and it resolves to the backpressured chunk's size.
const endResult = sink.end();
expect(endResult).toBe(writePromise);

// Keep the reader draining so any still-pending tail finishes.
const reader = (async () => {
while (drained < total) {
drainSync();
if (drained >= total) break;
await new Promise<void>(r => setImmediate(r));
}
})();

expect(await writePromise).toBe(chunkSize);
await reader;

Check warning on line 345 in test/js/bun/util/filesink.test.ts

View check run for this annotation

Claude / Claude Code Review

Reader IIFE spins forever if the assertion at line 344 fails

The `reader` IIFE is only awaited inside the try body (line 345), not in `finally` — if `expect(await writePromise).toBe(chunkSize)` at line 344 throws, `finally` closes `readFd`, and on the next tick `drainSync()` hits EBADF → caught → `break` inner loop with `drained` unchanged → outer `while (drained < total)` re-awaits `setImmediate` forever, leaking a spinning loop into later tests. The sibling tests in this file put `await reader` in `finally` for exactly this reason; add a `done` flag the
Comment on lines +336 to +345

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The reader IIFE is only awaited inside the try body (line 345), not in finally — if expect(await writePromise).toBe(chunkSize) at line 344 throws, finally closes readFd, and on the next tick drainSync() hits EBADF → caught → break inner loop with drained unchanged → outer while (drained < total) re-awaits setImmediate forever, leaking a spinning loop into later tests. The sibling tests in this file put await reader in finally for exactly this reason; add a done flag the loop checks, set it in finally before closing the fds, and await reader there.

Extended reasoning...

What the bug is

The new test creates a background reader IIFE at lines 336-342 that loops while (drained < total), calling drainSync() and then await new Promise(r => setImmediate(r)) each iteration. The only place this reader is awaited is line 345, inside the try body — the finally block closes writeFd and readFd but never signals or awaits reader.

The specific code path that triggers it

If line 344 — expect(await writePromise).toBe(chunkSize) — throws (either because writePromise rejects, or because it resolves to something other than chunkSize, i.e. exactly the regression this test guards against), control jumps straight to finally without ever reaching await reader. The finally block then does fs.closeSync(readFd).

Step-by-step proof

  1. Line 344 throws → jump to finally.
  2. finally runs fs.closeSync(readFd). The reader IIFE is still suspended on setImmediate.
  3. Next tick: reader resumes, calls drainSync()fs.readSync(readFd, buf) on the closed fd → throws EBADF.
  4. The catch { break } breaks only the inner for(;;) loop. drained was not incremented.
  5. Back at the outer loop: drained < total is still true (nothing changed), so it does not exit.
  6. await new Promise(r => setImmediate(r)) → step 3 repeats forever.

The setImmediate handle keeps the event loop referenced each tick, so this leaks a hot-spinning loop into every subsequent test in the file. Worse, closed fd numbers are recycled — a later test that opens a socket may get the same fd number, and this loop will silently readSync from that unrelated fd.

Why existing code doesn't prevent it

drainSync()'s catch handler was written for the happy-path EAGAIN case (nonblocking socket has no more data right now), where breaking the inner loop and re-polling via setImmediate is correct. It doesn't distinguish EBADF, and nothing else terminates the outer while. The finally block has no reference to reader at all.

Impact

This only bites when the test is already failing — the endResult === writePromise assertion at line 333 (the primary regression guard) fires before the reader is created, so the leak window is exactly line 344. But line 344 is the test's core resolution-value assertion; a future regression in pending.consumed accounting would make it fail and then poison the rest of the suite on persistent CI runners. This is precisely what REVIEW.md's rule targets: "Release every resource via using/await using or try/finally registered BEFORE the assertions (cleanup after expectations leaks on the first failure and poisons later tests on persistent CI runners)".

How to fix

Match the sibling tests in this file (e.g. "a backpressured write() resolves to the chunk's byte count"), which put await reader inside finally. Since this reader has no natural EOF, add a termination flag:

let readerDone = false;
const reader = (async () => {
  while (!readerDone && drained < total) {
    drainSync();
    if (drained >= total) break;
    await new Promise<void>(r => setImmediate(r));
  }
})();

and in finally, before closing the fds:

readerDone = true;
await reader;

(or equivalently, bound the loop with an iteration cap).

expect(drained).toBe(total);

// Once settled a follow-up end() short-circuits to a number.
expect(typeof sink.end()).toBe("number");
} finally {
try {
await Promise.resolve(sink.end()).catch(() => {});
} catch {}
try {
fs.closeSync(writeFd);
} catch {}
try {
fs.closeSync(readFd);
} catch {}
}
},
);

// end() called after a backpressured write() with the reader already gone:
// end_from_js's own flush() sees EPIPE synchronously. Throwing it would leave
// the write()'s outstanding promise orphaned (never settled here; in the spawn
Expand Down
Loading