Skip to content

FileSink: settle the pending write()'s promise in every synchronous close()/end() flush arm - #35365

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/f1049b3d/filesink-close-orphaned-pending
Jul 24, 2026
Merged

FileSink: settle the pending write()'s promise in every synchronous close()/end() flush arm#35365
Jarred-Sumner merged 4 commits into
mainfrom
farm/f1049b3d/filesink-close-orphaned-pending

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Closes out the bug class #35278 and #35344 started: FileSink::end() (the js_close path behind sink.close()) and FileSink::end_from_js()'s remaining Done/Wrote arms both orphan a backpressured write()'s promise.

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(4 * 1024 * 1024, 0x61)); // backpressures
fs.closeSync(readFd);                    // reader gone before the first await
try { sink.close(); } catch {}           // throws EPIPE synchronously on main
await writePromise;                      // never settles on main

The same hang happens on the success path: write a backpressuring chunk, drain the reader synchronously with fs.readSync, then sink.end() (or sink.close()). flush() pushes the remaining buffer through in one shot and returns Done/Wrote, the arm calls writer.end() and returns, and the write's promise is left pending forever.

Cause

All three synchronous arms (Err/Done/Wrote) of FileSink::end(), and the Done/Wrote arms of FileSink::end_from_js(), tear the writer down via writer.end() and return without touching self.pending or scheduling run_pending. writer.end() re-enters on_close synchronously, which fires signal.close(None) and releases the keep-alive ref but never touches the pending slot; IOWriter::flush() doesn't route through parent_on_write for its drain; on_auto_flush short-circuits on done==true or !has_pending_data(). Nothing ever schedules run_pending, so the backpressured write()'s promise stays pending forever. On end()'s Err arm js_close additionally threw the EPIPE at the close() caller.

#35344 fixed end_from_js()'s Err arm; #35278 fixed on_auto_flush. Both left end() entirely and end_from_js()'s Done/Wrote arms unchanged.

Fix

In both end() and end_from_js(), when a backpressured write's promise is outstanding:

  • Err arm (both): latch the error into the pending slot, schedule run_pending_later(), and hand the caller that promise (for end_from_js) / return Ok(()) so js_close doesn't also throw (for end()). FileSink: deliver a failed end() to the pending write's promise instead of double-reporting #35344 already did this for end_from_js(); end() now matches.
  • Done/Wrote arms (both): pending.result already holds Owned(consumed) from to_result; schedule run_pending_later() to deliver it. end_from_js() additionally returns the promise (like its Err/Pending arms) instead of a bare byte count.
  • Pending arm (both): unchanged; the async drain fires on_write, which already settles the slot.

end() returns sys::Result<()> so it can't hand the promise back the way end_from_js does, but routing the outcome to the promise the caller is already meant to be awaiting keeps the one-delivery invariant #35344 established. The other caller of FileSink::end() (subprocess::Writable::close) discards its result, so the Ok(()) doesn't change it, and its pending stdin write now settles where it previously hung. When nothing is pending, end()'s Err-arm throw is unchanged.

Verification

$ git checkout main -- src/ && bun bd test test/js/bun/util/filesink.test.ts \
    -t 'close.. after a backpressured|reader drained returns'
(fail) close() after a backpressured write() with the reader gone ...
  Expected: "EPIPE"
  Received: "close-threw"
(fail) end() after a backpressured write() with the reader drained ...
  Expected: Promise { <pending> }
  Received: 87936

$ git checkout HEAD -- src/ && bun bd test test/js/bun/util/filesink.test.ts
 50 pass
 0 fail

spawn.test.ts -t "EPIPE|stdin", spawn-streaming-stdin.test.ts, spawn-stdin-readable-stream.test.ts, shell/epipe.test.ts, and rust:check-all are green.

Test notes

  • The sink.close() EPIPE test runs in a subprocess with detect_leaks=0 in its env: sink.close() on a Blob-created FileSink leaks the native FileSink on main (${name}__doClose nulls m_sinkPtr before ${name}__close, so ~JSFileSink skips ${name}__finalize and the wrapper's +1 ref is never released). That leak is pre-existing and tracked separately; no test on main exercises sink.close() on a Blob writer.
  • The drained-Done/Wrote test is Linux-only: reaching that arm with one flush() needs the AF_UNIX send buffer to hold the whole remainder after one read cycle (Linux default ~200KB; macOS ~8KB, where flush() returns Pending and the promise was already settled via on_write, so there is nothing to regress).

Flagged by a review comment on closed #35351 (duplicate of merged #35344).


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/filesink.test.ts

…ails

FileSink::end() (the js_close path behind sink.close() on the FileSink
prototype and the piped-ReadableStream controller's close()) had the
same WriteResult::Err arm that #35344 fixed in end_from_js(): it set
done=true, tore down the writer, and returned Err without touching the
pending slot. A backpressured write()'s promise was left pending forever
while close() threw EPIPE at its caller.

Mirror end_from_js(): when a backpressured write()'s promise is
outstanding, latch the error into it, schedule run_pending, and return
Ok so js_close doesn't also throw. When nothing is pending the Err
return (and close()'s throw) is unchanged.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b2184e4e-9346-4163-81b3-dc7c5629dad5

📥 Commits

Reviewing files that changed from the base of the PR and between 2bc1598 and ca30716.

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

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:10 AM PT - Jul 24th, 2026

@robobun, your commit ca307169dd3052120b139ceff03ad39d327356e5 passed in Build #79397! 🎉


🧪   To try this PR locally:

bunx bun-pr 35365

That installs a local version of the PR into your bun-35365 executable, so you can run:

bun-35365 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. await writer.end() crashes with ENOSPC instead of throwing a catchable error #24032 - await writer.end() crashes with ENOSPC instead of rejecting the promise; same FileSink::end() error path this PR fixes
  2. The app crashes when writing chunks because there isn't enough disk space #25706 - App crashes when writing chunks with insufficient disk space (ENOSPC during flush); same pattern of unsettled pending write promise in end()

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

Fixes #24032
Fixes #25706

🤖 Generated with Claude Code

@claude claude Bot left a comment

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.

I didn't find any bugs. The change mirrors the already-merged end_from_js / on_auto_flush Err arms exactly, and I verified both callers of FileSink::end() (js_close and subprocess::Writable::close) are fine with the Ok(()) return. Deferring only because this is native FileSink teardown code and changes close()'s observable contract (throw → route to pending promise), which is worth a human confirming.

Checked: latch-before-writer.end() ordering matches the sibling paths; self stays live through run_pending_later() (keep-alive ref taken in to_result, wrapper's +1 still held during js_close); the non-pending path is unchanged so close() still throws when no write is outstanding; test fails on unfixed build with the thrown EPIPE and mirrors the sibling end() test.

Extended reasoning...

Overview

Adds a pending-state guard to FileSink::end()'s WriteResult::Err arm (src/runtime/webcore/FileSink.rs:1055): when a backpressured write()'s promise is outstanding, latch the error into self.pending, call writer.end(), schedule run_pending_later(), and return Ok(()) so js_close doesn't also throw. When nothing is pending, behavior is unchanged. Adds a POSIX-only regression test in filesink.test.ts that is a direct sibling of the existing end()-after-backpressure-with-reader-gone test.

Security risks

None. No untrusted input parsing, no new allocation, no new refcount ops. The only new state mutation is writing streams::Writable::Err(e) into an existing JsCell slot.

Level of scrutiny

Medium-high. FileSink teardown is refcount- and re-entrancy-sensitive (the file is dense with SAFETY/provenance commentary), and the change alters the user-visible behavior of sink.close() when a write is pending — it now returns undefined and rejects the outstanding write promise asynchronously instead of throwing EPIPE synchronously. The change itself is small and follows two already-merged sibling fixes (#35344 end_from_js, #35278 on_auto_flush) line-for-line, so the pattern is proven, but a maintainer should confirm the API-contract shift is desired.

Other factors

  • Verified callers: js_close (Sink.rs:655) maps Ok(())JSValue::UNDEFINED; subprocess::Writable::close (Writable.rs:534) discards the result with let _ =, so Ok(()) doesn't change it — and it now gets its pending promise settled where previously it was orphaned.
  • Lifetime: when pending.state == Pending, to_result already set must_be_kept_alive_until_eof and took a +1. writer.end()on_closeclear_keep_alive_ref releases that +1, but the caller (js_close via the C++ wrapper's m_sinkPtr, or the subprocess Writable::Pipe slot) still holds its own ref, so self is live for the subsequent self.run_pending_later(). This is the identical sequence already shipped in end_from_js's Err arm.
  • Test mirrors the sibling end() test precisely (socket pair, 4 MB backpressure, close reader, assert close() returns undefined, await write promise → EPIPE), with the same cleanup pattern. PR description shows it fails on the unfixed build with the thrown EPIPE.

…to a subprocess

The Err arm wasn't the whole bug: end()'s Done and Wrote arms (reached when
close()'s flush drains the buffer synchronously because the reader pulled
between write() and close()) call writer.end() without scheduling run_pending
either, so the write()'s promise hangs the same way. writer.end() only
re-enters on_close, which never touches the pending slot; flush() doesn't
route through on_write. Schedule run_pending_later() in those arms too
(pending.result already holds Owned(consumed) from to_result).

The test now runs in a subprocess with detect_leaks=0 in its env:
sink.close() on a Blob-created FileSink strands the JS wrapper's +1 ref on
main (doClose nulls m_sinkPtr so ~JSFileSink skips finalize), which the
x64-asan lane flags. That leak is pre-existing and tracked separately.
@robobun robobun changed the title FileSink: settle the pending write()'s promise when close()'s flush fails FileSink: settle the pending write()'s promise when close()'s flush drains or fails Jul 24, 2026
Comment thread test/js/bun/util/filesink.test.ts Outdated
Comment thread src/runtime/webcore/FileSink.rs
…rm-fragile drained close() sub-test

end_from_js()'s Done/Wrote arms had the identical orphan (#35344 only fixed
its Err arm). When a backpressured write()'s promise is outstanding they now
hand that promise back (like the Err/Pending arms already do) and schedule
run_pending to resolve it, instead of returning a bare byte count while the
promise hangs.

The close() subprocess test's drained-arm check depended on Linux's ~200KB
AF_UNIX send buffer letting close()'s flush drain the remainder in one go; on
macOS (~8KB) flush() returns Pending and the test would have timed out. Drop
it and cover the Done/Wrote path via an in-process sink.end() test instead,
which is Linux-only for the same reason (on macOS the Pending arm was already
correct) and doesn't hit the pre-existing close() leak.
@robobun robobun changed the title FileSink: settle the pending write()'s promise when close()'s flush drains or fails FileSink: settle the pending write()'s promise in every synchronous close()/end() flush arm Jul 24, 2026

@claude claude Bot left a comment

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.

Beyond the inline nit: 159dbd5 addressed both of my earlier comments — end_from_js()'s Done/Wrote arms now hand back the pending write's promise and schedule run_pending_later(), and the platform-fragile drained close() sub-test was replaced with a Linux-gated in-process sink.end() test. I also checked the detect_leaks=0 override in the new subprocess test — it scopes to the pre-existing sink.close() wrapper-detach leak documented in the test comment, not to anything this fix introduces.

Extended reasoning...

Re-reviewed after 159dbd5. The two substantive issues from the prior run are resolved: end_from_js()'s Done/Wrote arms now mirror the Err/Pending arms (return the outstanding promise, schedule run_pending_later()), and the drained-arm coverage moved to an in-process Linux-only sink.end() test where the AF_UNIX buffer size makes the Done/Wrote path deterministic. In end(), has_pending is read before flush() and in end_from_js() after — checked that IOWriter::flush() never re-enters on_write/run_pending, so the ordering difference doesn't matter. Also confirmed writer.end() in the Done/Wrote arms only re-enters on_close (which never touches self.pending), so grabbing the promise after writer.end() there is safe, unlike the Err arm's before-ordering. The remaining finding is a dead-flag nit; leaving the merge decision to a maintainer given this is FileSink lifecycle/refcount code.

Comment thread test/js/bun/util/filesink.test.ts Outdated

@claude claude Bot left a comment

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.

All three prior findings (macOS drained-arm flake, unfixed end_from_js Done/Wrote arms, dead readFdOpen flag) are addressed and the bug hunter found nothing new this round. Deferring to a human because this is native lifecycle code with a user-visible behavior change to sink.close() (no longer throws when a backpressured write is pending — the error routes to the write's promise instead), and the new subprocess test sets detect_leaks=0 to work around a pre-existing doClose leak that a maintainer should be aware of.

What was reviewed:

  • end()'s new Err/Done/Wrote arms and end_from_js()'s new Done/Wrote arms — ordering of writer.end() vs run_pending_later() matches the merged #35344 Err-arm shape; on_close never touches self.pending, so grabbing the promise after writer.end() is safe.
  • Both callers of FileSink::end() (js_close at Sink.rs:655, subprocess::Writable::close at Writable.rs:534) — the Ok(()) return when pending is harmless for both.
  • has_pending hoisting before/after flush()flush() is pure I/O per the SAFETY comment and doesn't touch pending.state.
Extended reasoning...

Overview

Completes the FileSink pending-promise bug class started by #35278 and #35344. Touches src/runtime/webcore/FileSink.rs (two functions: end() and end_from_js()) and adds two tests to test/js/bun/util/filesink.test.ts. In end(), all three synchronous flush arms (Err/Done/Wrote) now schedule run_pending_later() when a backpressured write's promise is outstanding, and the Err arm returns Ok(()) instead of propagating the error (which js_close would have thrown, orphaning the promise). In end_from_js(), the Done/Wrote arms now return the outstanding promise (like the Err/Pending arms already did) and schedule run_pending_later().

Security risks

None. No untrusted input parsing, no auth/crypto, no allocation sizing from external data. The unsafe blocks are the same JsCell get_mut() promise-grab pattern already merged in #35344's Err arm, with matching SAFETY comments.

Level of scrutiny

High — native Rust with unsafe, JsCell interior mutability, intrusive refcounting, and re-entrant writer.end()on_closeclear_keep_alive_ref teardown. I traced the ordering: writer.end() re-enters on_close, which fires signal.close(None) and releases the keep-alive ref but never touches self.pending, so the post-writer.end() promise grab and run_pending_later() are safe. run_pending_later() is idempotent (gated on has). The wrapper's +1 keeps the FileSink alive across the call (same as pre-existing end() Done arm). Both callers of FileSink::end() were checked: js_close returns undefined on Ok(()); subprocess::Writable::close discards the result.

Other factors

This PR went through three prior review rounds where I flagged (1) a macOS-specific test flake in the drained-arm sub-test, (2) the end_from_js Done/Wrote arms being left unfixed, and (3) a dead readFdOpen flag — all addressed in 159dbd5 and ca30716. Two things a maintainer should sign off on: the sink.close() behavior change (silently returning undefined instead of throwing EPIPE when a write is pending — arguably correct since the error now reaches the promise, but it's observable), and the ASAN_OPTIONS: detect_leaks=0 override in the new subprocess test, which papers over a pre-existing doClose/m_sinkPtr leak the PR description says is tracked separately. The Done/Wrote test is Linux-only, which is justified (macOS AF_UNIX buffers are ~8KB so flush() returns Pending there and the arm is unreachable), but leaves that arm without CI coverage on macOS.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Build 79397: 194/196 lanes pass. filesink.test.ts is green everywhere it ran including debian-13-x64-asan (the lane the first revision tripped) and darwin-26-aarch64. The remaining red is darwin-14-aarch64-test-bun: Expired (agent never picked the job up) plus two jobs stuck in scheduled for 2.5h; the five annotated test failures are all retry-passes in unrelated files (webview-chrome, fastutf8stream-reopen, in-process-cron, test-repl-close, 20144). Ready for a maintainer look.

@Jarred-Sumner
Jarred-Sumner merged commit 028f7a3 into main Jul 24, 2026
53 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f1049b3d/filesink-close-orphaned-pending branch July 24, 2026 10:55
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
…lose()/end() flush arm (#35365)

Closes out the bug class #35278 and #35344 started: `FileSink::end()`
(the `js_close` path behind `sink.close()`) and
`FileSink::end_from_js()`'s remaining `Done`/`Wrote` arms both orphan a
backpressured `write()`'s promise.

## Repro

```js
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(4 * 1024 * 1024, 0x61)); // backpressures
fs.closeSync(readFd);                    // reader gone before the first await
try { sink.close(); } catch {}           // throws EPIPE synchronously on main
await writePromise;                      // never settles on main
```

The same hang happens on the success path: write a backpressuring chunk,
drain the reader synchronously with `fs.readSync`, then `sink.end()` (or
`sink.close()`). `flush()` pushes the remaining buffer through in one
shot and returns `Done`/`Wrote`, the arm calls `writer.end()` and
returns, and the write's promise is left pending forever.

## Cause

All three synchronous arms (`Err`/`Done`/`Wrote`) of `FileSink::end()`,
and the `Done`/`Wrote` arms of `FileSink::end_from_js()`, tear the
writer down via `writer.end()` and return without touching
`self.pending` or scheduling `run_pending`. `writer.end()` re-enters
`on_close` synchronously, which fires `signal.close(None)` and releases
the keep-alive ref but never touches the pending slot;
`IOWriter::flush()` doesn't route through `parent_on_write` for its
drain; `on_auto_flush` short-circuits on `done==true` or
`!has_pending_data()`. Nothing ever schedules `run_pending`, so the
backpressured `write()`'s promise stays pending forever. On `end()`'s
Err arm `js_close` additionally threw the EPIPE at the `close()` caller.

#35344 fixed `end_from_js()`'s Err arm; #35278 fixed `on_auto_flush`.
Both left `end()` entirely and `end_from_js()`'s Done/Wrote arms
unchanged.

## Fix

In both `end()` and `end_from_js()`, when a backpressured write's
promise is outstanding:
- **Err arm** (both): latch the error into the pending slot, schedule
`run_pending_later()`, and hand the caller that promise (for
`end_from_js`) / return `Ok(())` so `js_close` doesn't also throw (for
`end()`). #35344 already did this for `end_from_js()`; `end()` now
matches.
- **Done/Wrote arms** (both): `pending.result` already holds
`Owned(consumed)` from `to_result`; schedule `run_pending_later()` to
deliver it. `end_from_js()` additionally returns the promise (like its
Err/Pending arms) instead of a bare byte count.
- **Pending arm** (both): unchanged; the async drain fires `on_write`,
which already settles the slot.

`end()` returns `sys::Result<()>` so it can't hand the promise back the
way `end_from_js` does, but routing the outcome to the promise the
caller is already meant to be awaiting keeps the one-delivery invariant
#35344 established. The other caller of `FileSink::end()`
(`subprocess::Writable::close`) discards its result, so the `Ok(())`
doesn't change it, and its pending stdin write now settles where it
previously hung. When nothing is pending, `end()`'s Err-arm throw is
unchanged.

## Verification

```
$ git checkout main -- src/ && bun bd test test/js/bun/util/filesink.test.ts \
    -t 'close.. after a backpressured|reader drained returns'
(fail) close() after a backpressured write() with the reader gone ...
  Expected: "EPIPE"
  Received: "close-threw"
(fail) end() after a backpressured write() with the reader drained ...
  Expected: Promise { <pending> }
  Received: 87936

$ git checkout HEAD -- src/ && bun bd test test/js/bun/util/filesink.test.ts
 50 pass
 0 fail
```

`spawn.test.ts -t "EPIPE|stdin"`, `spawn-streaming-stdin.test.ts`,
`spawn-stdin-readable-stream.test.ts`, `shell/epipe.test.ts`, and
`rust:check-all` are green.

## Test notes

- The `sink.close()` EPIPE test runs in a subprocess with
`detect_leaks=0` in its env: `sink.close()` on a Blob-created FileSink
leaks the native FileSink on main (`${name}__doClose` nulls `m_sinkPtr`
before `${name}__close`, so `~JSFileSink` skips `${name}__finalize` and
the wrapper's +1 ref is never released). That leak is pre-existing and
tracked separately; no test on main exercises `sink.close()` on a Blob
writer.
- The drained-`Done`/`Wrote` test is Linux-only: reaching that arm with
one `flush()` needs the AF_UNIX send buffer to hold the whole remainder
after one read cycle (Linux default ~200KB; macOS ~8KB, where `flush()`
returns `Pending` and the promise was already settled via `on_write`, so
there is nothing to regress).

Flagged by a review comment on closed #35351 (duplicate of merged
#35344).

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/util/filesink.test.ts

<!-- robobun:evidence:end -->
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.

3 participants