Skip to content

FileSink: reject the pending write() when the deferred auto-flush hits EPIPE - #35278

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/73d1b22c/filesink-autoflush-epipe
Jul 23, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/73d1b22c/filesink-autoflush-epipe

Conversation

@robobun

@robobun robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator

When a FileSink.write() backpressures it registers a deferred auto-flush microtask. If that microtask's flush() drains to a write error (typically EPIPE because the reader closed while most of the chunk was still sitting in the sink's buffer), on_auto_flush matched WriteResult::Err(_), discarded the error, and called run_pending_later(). The pending promise still held the Owned(consumed) result to_result had seeded, so the awaited write() resolved as if every buffered byte had reached the reader, and a follow-up await end() returned 0.

Repro

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

const [readFd, writeFd] = createSocketPair();
const sink = Bun.file(writeFd).writer();
const p = sink.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); // backpressures
fs.closeSync(readFd); // reader gone before the first await
console.log(await p);          // before: 4194304   after: EPIPE thrown
console.log(await sink.end()); // before: 0

The user-facing shape (found while hardening the existing stdin.end() rejects with EPIPE test in spawn.test.ts) is a Bun.spawn child that reads a little and exits while the parent is doing await proc.stdin.write(16MB) then await proc.stdin.end(): on the unfixed build roughly half of the runs resolve both promises with no error even though the child only ever consumed a few KB.

Cause

PosixStreamingWriter::flush() returns WriteResult::Err without routing through _on_error (it resets outgoing and hands the error back), so nothing stores the error in pending.result. Every other FileSink caller of flush() (end, end_from_js, flush_from_js) propagates the Err itself; on_auto_flush was the one place that threw it away.

Fix

Split Err from Done in on_auto_flush: on Err, set pending.result = Writable::Err(err) (if the slot is still pending), set done = true and writer.end() so the sink reaches its terminal state (mirrors end_from_js's Err arm), then run_pending_later() and unregister the auto-flusher.

Verification

$ git stash push -- src/ && bun bd test test/js/bun/util/filesink.test.ts \
    -t "rejects with EPIPE when the reader closes before the deferred flush"
(fail) a backpressured write() rejects with EPIPE ...
  Expected: "EPIPE"
  Received: undefined

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

spawn.test.ts -t stdin, spawn-streaming-stdin.test.ts, shell/epipe.test.ts, and rust:check-all are green.


no test proof · iteration 1 · 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

When a backpressured write() registers the deferred auto-flush microtask
and that flush's drain hits a write error (typically EPIPE because the
reader closed while the chunk was still buffered), on_auto_flush matched
WriteResult::Err(_) and discarded the error before calling
run_pending_later(). The pending promise still held the Owned(consumed)
result to_result seeded it with, so the awaited write() resolved as if
the full chunk had been delivered, and a follow-up end() returned 0.

Latch the error into pending.result, move the sink to its terminal state
(done + writer.end()), and unregister the auto-flusher.

Deterministic repro uses a socketpair: write 4MB (backpressures), close
the read fd before the first await so the auto-flush drain sees EPIPE.
@coderabbitai

coderabbitai Bot commented Jul 23, 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 seconds

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: 11534d91-281a-44bc-96c7-ea377cc9846b

📥 Commits

Reviewing files that changed from the base of the PR and between 892b1da and d55e75c.

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. FileSink.write incoherencies #12194 - FileSink.write returns incoherent results (Promise instead of number, wrong byte counts) when writing to spawned process stdin — directly related to backpressured write promises resolving with incorrect results
  2. await writer.end() crashes with ENOSPC instead of throwing a catchable error #24032 - await writer.end() crashes with ENOSPC instead of throwing a catchable error — the on_auto_flush error-discarding bug applies to any WriteResult::Err, including ENOSPC
  3. The app crashes when writing chunks because there isn't enough disk space #25706 - App crashes when writing chunks with insufficient disk space — same root cause as await writer.end() crashes with ENOSPC instead of throwing a catchable error #24032, write errors from flush() not latched into pending promise result

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

Fixes #12194
Fixes #24032
Fixes #25706

🤖 Generated with Claude Code

Comment thread test/js/bun/util/filesink.test.ts Outdated
- assert sink.write("x") === true so the done/writer.end() half of the
  Err arm is observable (write() returns 1 if the sink stays writable)
- close readFd in the finally when the pre-close assertion throws
- use try/await for end() cleanup so a synchronous throw is caught

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

LGTM — the earlier readFd cleanup nit is addressed in ce16462, and no new issues found.

What was reviewed:

  • Traced PosixStreamingWriter::flush()'s Err path (PipeWriter.rs:982-986): confirmed it resets outgoing and returns without calling _on_error, so nothing else latches the error into pending.result.
  • Verified writer.end() after a flush error only reaches on_close (not on_write), so the newly-latched Writable::Err cannot be overwritten before run_pending_later() runs; the pre-existing _guard keeps this alive across on_close → clear_keep_alive_ref.
  • New Err arm mirrors end_from_js/end's Err arms (done=true, writer.end()) and the existing on_error handler (gate on PendingState::Pending, run_pending_later()).
  • Test: hermetic fd cleanup via readFdOpen flag; asserts specific EPIPE code and terminal-state behavior; POSIX-gated.
Extended reasoning...

Overview

Splits the WriteResult::Err(_) | WriteResult::Done(_) arm in FileSink::on_auto_flush so that a write error from the deferred auto-flush is latched into pending.result (rejecting the awaited write() promise) and the sink is moved to its terminal state, instead of being silently discarded and letting the pending promise resolve with the stale Owned(consumed) seeded by to_result. Adds a POSIX-gated regression test in filesink.test.ts using createSocketPair() to force backpressure then close the reader before the first await.

Security risks

None. This is error-path plumbing in a streaming file writer; no auth, crypto, parsing of untrusted input, or permission surface.

Level of scrutiny

Elevated — this is unsafe native code with intrusive refcounting, JsCell interior mutability, and re-entrant writer callbacks (on_close/on_write via the parent backref). I traced the new arm's control flow through PipeWriter.rs:

  • flush() on Err resets outgoing and returns without touching is_done or calling _on_error (lines 982-986), confirming the PR's root-cause claim.
  • writer.end() sets is_done = true and calls close(), which dispatches Parent::on_close — not on_write — so the freshly-latched Writable::Err(err) cannot be clobbered by on_write's OwnedAndDone(consumed) overwrite.
  • on_close → clear_keep_alive_ref may deref, but the _guard = FileSinkRef::new_ref(this) taken before the match holds an extra ref, so this survives to the trailing run_pending_later() / auto_flusher.registered.set(false).
  • The writer.with_mut(|w| w.end()) re-entry shape is identical to the existing end/end_from_js/on_attached_process_exit call sites; on_close does not borrow writer.

Other factors

  • The change is ~18 native lines and is a strict superset of the old Err behavior (still does update_ref(false) + run_pending_later()), plus the terminal-state transition copied from end_from_js's Err arm.
  • PR description shows the test failing on the unfixed build (Received: undefined for caught?.code) and passing after; spawn.test.ts -t stdin, spawn-streaming-stdin, shell/epipe, and rust:check-all reported green.
  • My prior review nit (potential readFd leak on early assertion failure) was addressed in ce16462 with a readFdOpen flag guarding the finally close — avoids double-closing a recycled fd number, which is better than the blind try { closeSync } catch {} I originally suggested.
  • The existing "does not leak native FileSink when a pending write fails (EPIPE)" test in the same file exercises the same scenario for native-leak coverage, so the keep-alive ref release path through writer.end() → on_close → clear_keep_alive_ref is already leak-tested.

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

No issues found, but this touches the FileSink refcount/re-entrancy state machine (unsafe native code), so worth a human look at the new writer.end() call inside the deferred-microtask context.

What was reviewed:

  • The new Err arm mirrors end_from_js's Err handling (done = true, latch error into pending.result, writer.end()) and is guarded by the existing FileSinkRef::new_ref so on_close re-entry can't free this mid-body.
  • Confirmed run_pending_later() (not run_pending()) is used, consistent with on_error's deferred-queue path.
  • The earlier readFd cleanup nit was addressed in ce16462 via the readFdOpen flag.
Extended reasoning...

Overview

Splits the WriteResult::Err(_) | WriteResult::Done(_) match arm in FileSink::on_auto_flush so that a flush error (EPIPE, ENOSPC, etc.) is latched into pending.result before run_pending_later() fires — previously the error was discarded and the pending write() promise resolved with the seeded Owned(consumed) value as if the full chunk had been delivered. Adds a POSIX-only regression test using createSocketPair that backpressures a 4 MB write, closes the reader before the first await, and asserts the write rejects with EPIPE and the sink reaches its terminal state.

Security risks

None. This is error-propagation plumbing on an already-owned fd; no new input parsing, no auth/crypto surface.

Level of scrutiny

High. FileSink.rs is ~1300 lines of unsafe Rust with intrusive refcounting, *mut Self backrefs, and extensive re-entrancy commentary. The new arm adds a writer.end() call inside the deferred-microtask context (is_inside_deferred_task_queue == true), which is a slightly different call site than the existing end/end_from_js Err arms it mirrors (those run from JS host-fn context). The _guard = FileSinkRef::new_ref(this) taken before flush() should keep this alive across any on_close re-entry that writer.end() triggers, and run_pending_later() correctly defers promise resolution rather than draining synchronously — but a maintainer familiar with the PipeWriter close/end sequencing should confirm nothing about calling writer.end() from inside the AutoFlusher tick violates an invariant.

Other factors

  • The change is small (~18 lines Rust) and follows the established pattern from end_from_js's WriteResult::Err arm exactly.
  • The regression test is well-constructed: it pins the terminal-state contract (sink.write("x") === true after the error, end() returns < size), and the PR description shows it fails on the unfixed build.
  • My earlier nit about readFd cleanup on assertion failure was addressed in ce16462 with a readFdOpen flag (avoids double-closing a recycled fd number).
  • The bug-hunting system found no issues this round.

Given the file's complexity and the memory-safety weight REVIEW.md places on this category, I'm deferring rather than approving.

@robobun

robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:40 AM PT - Jul 23rd, 2026

✅ @robobun, your commit d55e75c161c91650aca683f028353d1addee85a2 passed in Build #78713! 🎉


🧪   To try this PR locally:

bunx bun-pr 35278

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

bun-35278 --bun

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green. filesink.test.ts (the only changed test file) passes on every lane that ran it across builds 78685 and 78713.

Remaining red is unrelated to this change:

  • darwin-14-aarch64-test-bun expired on both builds (runner pool not picking up the job).
  • The handful of other test failures are all flaky-retry passes in unrelated subsystems (fs-promises FileHandle GC, cron, no-orphans, FormData, http3 client, issue 20144); none touch FileSink or the pipe-writer path.

Ready for review/merge.

@Jarred-Sumner
Jarred-Sumner merged commit 773be9d into main Jul 23, 2026
52 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/73d1b22c/filesink-autoflush-epipe branch July 23, 2026 21:57
cirospaciari added a commit that referenced this pull request Jul 24, 2026
…ad of double-reporting (#35344)

## What broke

Since #35278 landed, `test/js/bun/spawn/spawn.test.ts` fails
deterministically on the x64 Linux lanes (ubuntu 25.04 / debian 13 /
alpine 3.23) — including on main's own build
[78927](https://buildkite.com/bun/bun/builds/78927):

- `gcTick > spawn > stdin.end() rejects with EPIPE when the child exits
before consuming the write` fails with an **unhandled** `EPIPE: broken
pipe, write` (~13ms, 100% reproducible with the build-78927 release
binary; repro: `bun test test/js/bun/spawn/spawn.test.ts -t 'rejects
with EPIPE when the child exits'`)
- `with BUN_FEATURE_FLAG_FORCE_WAITER_THREAD` fails because its inner
full-file re-run exits 1 on the same unhandled rejection
(`expect(result.exitCode).toBe(0)` at spawn.test.ts:625)

The test's own assertion actually passes — `await proc.stdin.end()` does
reject with EPIPE and the test catches it. What fails the test is a
**second** delivery of the same error, as an unhandledRejection on the
`write()` promise the test deliberately discarded.

## Root cause

`src/runtime/webcore/FileSink.rs`, `end_from_js` (`WriteResult::Err`
arm, previously line ~1136). On release-build timing the child exits
**before** `end()` runs, so `end_from_js`'s own `flush()` sees the EPIPE
first and threw it synchronously — while the backpressured `write()`'s
promise was still sitting in the pending slot. #35278's auto-flush Err
arm (correctly) no longer swallows that error, so it then rejected the
orphaned promise with nobody holding it. Debug/slower builds don't hit
this because `end()` runs before the child exits, takes the `Pending`
arm, and shares the write promise — one promise, one rejection, handled
by the `await`.

Before #35278 the orphaned promise silently resolved as a full success —
the lie that PR removed. This completes it: the error goes to exactly
one place.

## Fix

In `end_from_js`'s `Err` arm, when a backpressured write's promise is
outstanding: latch the error into the pending slot and return **that
same promise** (exactly like the `Pending` arm already does), instead of
throwing synchronously. The latch and promise grab happen before
`writer.end()`, whose teardown can re-enter `on_error`/`run_pending`
synchronously. When no write is pending, the synchronous throw is
unchanged.

## Verification

- New regression test in `filesink.test.ts` (discarded backpressured
`write()` + reader closed + same-tick `end()`): fails on the unfixed
build (orphaned pending promise), passes with the fix — deterministic on
any build type, no release timing needed.
- The exact CI failure reproduces locally with the release binary
downloaded from main build 78927 (both cases, 100%), and mechanically
cannot recur: the write promise and the `end()` return value are now the
same object on this path.
- #35278's own regression test (`a backpressured write() rejects with
EPIPE when the reader closes before the deferred flush`) still passes —
its fix is preserved, not reverted.
- Green on the debug build: `filesink.test.ts` 48/0 (includes both
regression tests), `spawn.test.ts` EPIPE case 5/5,
`spawn-stdin-readable-stream` 28/0, `spawn-stdin-pipe-fd-leak` 2/0,
`spawn-streaming-stdin` 1/0, `shell/epipe` 2/0.
- A local release-build run of the full `spawn.test.ts` is in flight; CI
covers the release lanes either way.

Found while triaging CI on #34598, which inherited the failure through a
main merge — this class currently reds every branch that merges main.

<!-- 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 -->

---------

Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Jarred-Sumner pushed a commit that referenced this pull request Jul 24, 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 -->
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.

2 participants