Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/runtime/webcore/FileSink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
45 changes: 45 additions & 0 deletions test/js/bun/util/filesink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},
);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand Down
Loading