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..73ba5ad3ab7f 100644 --- a/test/js/bun/util/filesink.test.ts +++ b/test/js/bun/util/filesink.test.ts @@ -273,6 +273,53 @@ it.skipIf(!isPosix)("a backpressured string write() resolves to its encoded byte expect(received).toBe(size); }); +// end() called after a backpressured write() whose promise the caller +// discarded, with the reader already gone: end_from_js's flush() sees EPIPE +// synchronously. Throwing it would report the failure to end()'s caller and +// then let the auto-flush/error path reject the orphaned write() promise as an +// unhandledRejection; instead the error is delivered to that pending promise +// and end() returns the same promise, so the failure is reported exactly once. +it.skipIf(!isPosix)( + "end() after a discarded backpressured write() delivers EPIPE once, with no unhandled rejection", + async () => { + const [readFd, writeFd] = createSocketPair(); + let readFdOpen = true; + const sink = Bun.file(writeFd).writer(); + let unhandled: any = null; + function onUnhandled(e: unknown) { + unhandled = e; + } + process.on("unhandledRejection", onUnhandled); + try { + // Discarded on purpose: the write's promise must not surface on its own. + sink.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); + fs.closeSync(readFd); + readFdOpen = false; + + let caught: any; + try { + await sink.end(); + } catch (e) { + caught = e; + } + expect(caught?.code).toBe("EPIPE"); + + // Bounded window for a stray second rejection to surface. + for (let i = 0; i < 10; i++) await Bun.sleep(1); + expect(unhandled).toBeNull(); + } finally { + process.off("unhandledRejection", onUnhandled); + try { + await sink.end(); + } catch {} + 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