-
Notifications
You must be signed in to change notification settings - Fork 5k
FileSink: deliver deferred-flush EPIPE to the pending write() promise #35351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
Comment on lines
+293
to
+309
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Both assertions in this test — Extended reasoning...What the finding isThe new test claims to prove that Step-by-step trace on 773be9d
So both assertions the test title/comment claim to check pass on pre-fix; the test only fails there via a defensive cleanup line wrapped in Why the test's premise doesn't hold hereThe comment above the test says throwing from REVIEW.md rules violated
How to fixMake the assertion target the invariant the Rust change actually restores — that the pending write() promise settles (rejected with EPIPE) instead of being orphaned: const writePromise = 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");
// Load-bearing: on 773be9df this promise never settles → test times out here,
// which IS the invariant under test (not a cleanup accident).
await expect(writePromise).rejects.toMatchObject({ code: "EPIPE" });Alternatively (or additionally), assert that a second Severitynit — the test does still fail on pre-fix (via timeout), so |
||
| } 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The sibling
FileSink::end()(reached viasink.close()→${name}__doClose→js_close, and via${controller}__closeon the piped-stream path) has the identicalWriteResult::Errarm and is left unfixed: it setsdone=true, callswriter.end(), and returns — never latchingpending.resultor schedulingrun_pending_later(). A backpressuredwrite()'s promise is therefore still orphaned forever whenclose()(rather thanend()) hits synchronous EPIPE. REVIEW.md requires covering the sync/async twin in the same PR; the samepending.result = Writable::Err(e)+run_pending_later()latch (as already applied inon_auto_flush's Err arm) belongs inend()'s Err arm too.Extended reasoning...
What the bug is
This PR fixes
end_from_js()'sWriteResult::Errarm so that whenflush()returns EPIPE synchronously and a backpressuredwrite()has leftpending.state == Pending, the error is latched intoself.pendingand delivered to the outstanding promise. The twin methodFileSink::end()has the byte-identical Err-arm shape and is left untouched:It sets
done=true, tears down the writer, and returns the error to the caller — but never touchesself.pendingor schedulesrun_pending.The code path that triggers it
FileSink::end()is JS-reachable through two routes ingenerate-jssink.ts:sink.close()on the prototype: line 1178 bindsclose→${name}__doClose(line 496), which callssink->detach()then${name}__close(line 515) →Sink.rs::js_close(line 638) →this.end(None)(line 655) →JsSinkType::end→FileSink::end().controller.close()on the ReadableStream controller:${controller}__close(line 378) reaches the same${name}__closeat line 403. This is the pathassign_to_streamuses.FileSink does not override
get_pending_error(default returnsNone, Sink.rs:343), sojs_closeproceeds straight toend().Why nothing else settles the promise
After
end()'s Err arm runs:writer.end()'s teardown reacheson_close, which only firessignal.close(None)andclear_keep_alive_ref(this)— it never touchesself.pending.on_erroris not invoked:flush()returned the error synchronously as aWriteResult::Err, not via the callback path.on_auto_flushshort-circuits on its first guard (if (*this).done.get() … return false) oncedone==true, without reaching the Err-arm latch that FileSink: reject the pending write() when the deferred auto-flush hits EPIPE #35278 added.run_pending_later()is never scheduled, sorun_pendingnever fires.The
WritablePendingslot is therefore left instate == Pendingforever.Step-by-step proof
const p = sink.write(Buffer.alloc(4 * 1024 * 1024))on a socket-pair fd →write_bytes→to_resultreturnsWritable::Pending(self.pending.as_ptr());Writable::to_jscallsWritablePending::promise()which setspending.state = Pendingand hands JS a Promise.sink.close()→${name}__doClose→js_close→FileSink::end(None)→writer.flush()returnsWriteResult::Err(EPIPE)synchronously.done.set(true);writer.end()(→on_close: signal + keep-alive ref only);return Err(EPIPE).js_closethrows the EPIPE to theclose()caller — butpfrom step 1 is never settled.await phangs forever, and the FileSink (plus its 4 MB buffer) is pinned by the promise's strong ref for the life of the process.This is the exact scenario the PR's new test exercises, with
sink.close()substituted forsink.end().Impact and fix
REVIEW.md ("Error handling → Every error/abort/timeout path actively completes the operation. Settle every pending promise slot — an unsettled promise pins objects and hangs callers forever") and "Correctness → Fix the whole class in the same PR — sync/async twins … same-class sites are ONE concern, not scope creep" both apply directly.
end()andend_from_js()are the canonical twins here; leaving one of the two unfixed re-exposes the same hang/leak on a public prototype method and on the piped-ReadableStream close path.The fix mirrors what
on_auto_flush's Err arm already does (since #35278) —end()returnssys::Result<()>so it can't hand back the promise, but it can settle it:(or hoist the shared latch+schedule into a helper both Err arms call). A sibling test using
sink.close()instead ofsink.end()would cover it.