Skip to content

FileSink: resolve the pending write()'s promise when end()'s flush drains it - #35397

Closed
robobun wants to merge 1 commit into
mainfrom
farm/d6e487cc/filesink-end-drained-pending
Closed

FileSink: resolve the pending write()'s promise when end()'s flush drains it#35397
robobun wants to merge 1 commit into
mainfrom
farm/d6e487cc/filesink-end-drained-pending

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Sibling of #35344 (the Err arm) for the Done/Wrote arms of FileSink::end_from_js().

Repro

import { createSocketPair } from "bun:internal-for-testing";
import fs from "node:fs";

const [readFd, writeFd] = createSocketPair();
const sink = Bun.file(writeFd).writer();
const writePromise = sink.write(Buffer.alloc(300 * 1024, 0x61)); // backpressures

const buf = Buffer.alloc(64 * 1024);
while (true) { try { if (!fs.readSync(readFd, buf)) break } catch { break } } // sync drain

const r = sink.end();
console.log("end() returned:", typeof r); // number — strands writePromise
let settled = false;
writePromise.then(() => settled = true, () => settled = true);
await new Promise(r => setTimeout(r, 100));
console.log("write() promise settled:", settled); // false on main

Cause

FileSink::end_from_js()'s flush() call drains the buffered remainder synchronously and returns Done/Wrote. Those arms called writer.end() and returned js_number(written) without touching self.pending. IOWriter::flush() updates its buffer head but doesn't route through on_write, and writer.end() only fires on_close which never touches the pending slot either. Nothing ever schedules run_pending, so the backpressured write()'s promise is orphaned.

The deferred auto-flush path already handles this (its Done/Wrote arms call run_pending_later()); the bug is only reachable when end() runs before the first microtask checkpoint after the backpressured write, so end_from_js does the flush itself.

#35344 fixed the Err arm of end_from_js() with the same shape; this completes the Done/Wrote arms. #35365 covers the Err arm of FileSink::end() (sink.close()), whose Done/Wrote arms have the same gap and are left for that PR's scope.

Fix

When a write is pending, Done/Wrote now behave like the Pending/Err arms already do: grab the outstanding promise, tear the writer down, schedule run_pending_later(), and return that promise. pending.result already holds Owned(consumed) from to_result, so run_pending resolves it with the write's chunk size. The no-pending path is unchanged.

Verification

$ git stash push -- src/ && bun bd test test/js/bun/util/filesink.test.ts \
    -t 'whose remainder drains synchronously'
(fail) end() after a backpressured write() whose remainder drains synchronously...
  Expected: Promise { <pending> }
  Received: 16384

$ git stash pop && bun bd test test/js/bun/util/filesink.test.ts
 49 pass
 0 fail

Also green: spawn.test.ts -t EPIPE, spawn-streaming-stdin.test.ts, spawn-stdin-readable-stream.test.ts, shell/epipe.test.ts, rust:check-all (10/10).

…ains it

When a backpressured write() leaves its promise outstanding and the caller
then drains the read side and calls sink.end() before any await, end_from_js's
own flush() can push the buffered remainder out synchronously and land in the
Done/Wrote arm. Those arms called writer.end() and returned the drained byte
count without touching the pending slot; IOWriter::flush() doesn't route
through on_write, so nothing else ever settled the write()'s promise and it
hung forever.

Mirror the Pending/Err arms: when a write is pending, hand back that promise
and schedule run_pending to resolve it with the Owned(consumed) that to_result
already latched. The no-pending path is unchanged.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:47 PM PT - Jul 23rd, 2026

@robobun, your commit a684ff3 is building: #79405

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. FileSink.write incoherencies #12194 - Reports FileSink.write() returning incoherent values when backpressure causes it to return a promise on pipe-backed stdin, matching the orphaned-promise bug this PR fixes

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #12194

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dbdfda65-4f5f-4402-8a37-4c788af87650

📥 Commits

Reviewing files that changed from the base of the PR and between 50bb3bd and a684ff3.

📒 Files selected for processing (2)
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/util/filesink.test.ts

Walkthrough

FileSink now preserves an outstanding backpressured write promise when end() synchronously flushes the remainder, and adds POSIX socket coverage for promise identity, resolution, and subsequent numeric completion.

Changes

FileSink pending write handoff

Layer / File(s) Summary
Pending promise handoff
src/runtime/webcore/FileSink.rs
Successful Done and Wrote paths return the existing pending write promise after ending the writer and scheduling its resolution.
Backpressure regression coverage
test/js/bun/util/filesink.test.ts
A POSIX socket test verifies that synchronous remainder draining makes end() return and resolve the original backpressured write promise, followed by numeric completion.

Possibly related PRs

  • oven-sh/bun#34999: Updates related WritablePending promise preservation across FileSink lifecycle events.
  • oven-sh/bun#35344: Handles pending promise delivery for another end_from_js result path.
  • oven-sh/bun#35351: Covers pending promise resolution for end_from_js, including error handling.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main FileSink fix: returning the pending write promise when end() flushes it.
Description check ✅ Passed The description includes the issue, fix, and verification, though it uses custom headings instead of the template's exact sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. FileSink: settle the pending write()'s promise in every synchronous close()/end() flush arm #35365 - Superset: fixes the same Done/Wrote arms of end_from_js() that this PR targets, and additionally covers end() (the sink.close() path) and its Err arm. Both PRs resolve the same orphaned-promise bug in FileSink.rs when flush() drains remaining bytes synchronously.

🤖 Generated with Claude Code

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #35365, which was extended to cover end_from_js()'s Done/Wrote arms (commit at 05:11 UTC) while this one was in flight. #35365 is a superset: it fixes these two arms and the corresponding arms of FileSink::end() (sink.close()). Closing in favor of that PR.

@robobun robobun closed this Jul 24, 2026
Comment on lines +336 to +345
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;

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant