diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index c5da0eeac865..cbc45719711c 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -1134,6 +1134,31 @@ impl FileSink { } WriteResult::Err(err) => { self.done.set(true); + if self.pending.get().state == streams::PendingState::Pending { + // A backpressured write() left its promise outstanding. + // Throwing here would report the failure to the caller and + // then let the auto-flush/error path reject that promise a + // second time — with nobody holding it when the caller + // discarded write()'s return value, that second delivery + // surfaces as an unhandledRejection. Deliver the error to + // the pending promise instead and hand the caller the same + // promise (exactly like the Pending arm), so the failure is + // reported once, to whichever await is watching. The latch + // and promise grab happen before `writer.end()`: its + // teardown can re-enter `on_error`/`run_pending` + // synchronously, and the slot must already hold the error + // and this caller's promise when that runs. + self.pending + .with_mut(|p| p.result = streams::Writable::Err(err)); + // 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::Err(err) } diff --git a/test/js/bun/util/filesink.test.ts b/test/js/bun/util/filesink.test.ts index d4d98fbec2f3..72da12167a4f 100644 --- a/test/js/bun/util/filesink.test.ts +++ b/test/js/bun/util/filesink.test.ts @@ -273,6 +273,51 @@ it.skipIf(!isPosix)("a backpressured string write() resolves to its encoded byte expect(received).toBe(size); }); +// 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 +// path on_attached_process_exit rejected it a second time as an unhandled +// rejection). Instead end() latches the error into that pending promise and +// returns it, so write()'s promise and end()'s return are the same object and +// the failure is reported exactly once. +it.skipIf(!isPosix)( + "end() after a backpressured write() with the reader gone returns the write's promise, rejecting with EPIPE", + async () => { + const [readFd, writeFd] = createSocketPair(); + let readFdOpen = true; + const sink = Bun.file(writeFd).writer(); + try { + const writePromise = sink.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); + expect(writePromise).toBeInstanceOf(Promise); + + fs.closeSync(readFd); + readFdOpen = false; + + // end()'s flush() hits EPIPE synchronously. It must not throw and strand + // writePromise; it hands back the same promise with the error latched. + const endResult = sink.end(); + expect(endResult).toBe(writePromise); + + let caught: any; + try { + await endResult; + } catch (e) { + caught = e; + } + expect(caught?.code).toBe("EPIPE"); + + // The pending slot is now settled; a follow-up end() short-circuits to + // the written byte count, not another promise. + expect(typeof sink.end()).toBe("number"); + } finally { + try { + fs.closeSync(writeFd); + } catch {} + if (readFdOpen) fs.closeSync(readFd); + } + }, +); + // The deferred auto-flush microtask runs at the first microtask checkpoint // after write() backpressures. If its flush() hit EPIPE, it discarded the // error and then let `run_pending_later()` resolve the pending write() promise