Repository navigation
process.stdout: an empty write() calls back after the queued chunks
#45319
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
|
|
@@ -1498,6 +1498,13 @@ impl crate::webcore::sink::JsSinkType for FileSink { | |
| // the C++ `JSFileSink` wrapper `js_construct` is about to create. | ||
| this.write(Self::construct()); | ||
| } | ||
| /// Behind the chunks the fd refused, like any other chunk written now. | ||
| fn write_empty(&mut self) -> streams::Writable { | ||
| if self.done.get() || !self.writer.get().is_backed_up() { | ||
|
Comment on lines
+1502
to
+1503
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. 🟣 pre-existing, not blocking: pre-existing: a small Why this was flaggedTrigger: process.stdout on a non-TTY pipe or socket; a write is refused partway leaving a tail smaller than CHUNK_SIZE (4096, PipeWriter.rs:746) in Verification: pre-existing — the base already fails the same way by the same route for a non-empty small write, and the PR's new |
||
| return streams::Writable::Temporary(0); | ||
| } | ||
| self.to_result(WriteResult::Pending(0), 0) | ||
| } | ||
|
dylan-conway marked this conversation as resolved.
Comment on lines
+1502
to
+1507
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. 🔴 A writer whose pipe broke can now see the broken-pipe error replaced by a successful byte count, so Why this was flaggedEntry point: Verification: Triggered when a non-empty write hits a synchronous EPIPE while an earlier write's promise is still pending and an empty chunk is written before the deferred settle runs.
Comment on lines
+1502
to
+1507
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. 🔴 Callers who write an empty chunk to a FileSink after a backpressured write failed asynchronously (reader closed, EPIPE) now get a promise that never settles, and the sink can leak. The Why this was flaggedEntry point: a user holds a FileSink with a write parked on a full socket or pipe, the reader closes, and later the user calls sink.write("") or sink.write(new Uint8Array(0)). The poll drains at PipeWriter.rs:180-203, try_write fails with EPIPE, on_error at PipeWriter.rs:822-830 sets is_done but leaves backed_up (set true at PipeWriter.rs:776 by the earlier Pending) untouched, then FileSink::on_error (FileSink.rs:463-487) rejects the parked promise and close() runs on_close, which releases the keep-alive ref at FileSink.rs:554. The empty write then reaches write_empty at FileSink.rs:1502-1507: done is false and is_backed_up() is still true, so to_result(WriteResult::Pending(0), 0) runs: ref() is taken at FileSink.rs:1604-1607 with no on_close left to release it, and WritablePending::promise (streams.rs:399-418) allocates a new promise. No on_write/on_error can arrive on a closed writer, so that promise never settles. On the base branch the empty write returned the literal 0 (old Sink.rs:551/570). The native leak test at test/js/bun/util/filesink.test.ts:507 does not cover this because it never writes again after the rejection. Verification: The async error path src/io/PipeWriter.rs:822-830 sets |
||
| fn end_from_js(&mut self, cx: &bun_jsc::JsThread<'_>) -> sys::Result<JSValue> { | ||
| Self::end_from_js(self, cx) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -371,6 +371,10 @@ pub(crate) trait JsSinkType: Sized + JsSinkAbi { | |
| fn write_bytes(&mut self, data: &streams::Result) -> streams::result::Writable; | ||
| fn write_utf16(&mut self, data: &streams::Result) -> streams::result::Writable; | ||
| fn write_latin1(&mut self, data: &streams::Result) -> streams::result::Writable; | ||
| /// `write()` of an empty chunk, which never reaches the `write_*` methods. | ||
| fn write_empty(&mut self) -> streams::result::Writable { | ||
| streams::result::Writable::Temporary(0) | ||
|
Comment on lines
+374
to
+376
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. 🟣 pre-existing, not blocking: A direct-stream pull that awaits Why this was flaggedA user Bun.serve Verification: Trigger: a
Member
Author
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. Left as it is on purpose. On |
||
| } | ||
| fn end(&mut self, err: Option<SysError>) -> sys::Result<()>; | ||
| fn end_from_js(&mut self, cx: &bun_jsc::JsThread<'_>) -> sys::Result<JSValue>; | ||
| fn flush(&mut self) -> sys::Result<()>; | ||
|
|
@@ -544,7 +548,7 @@ impl<T: JsSinkType> JSSink<T> { | |
| if let Some(buffer) = arg.as_array_buffer(global) { | ||
| let slice = buffer.slice(); | ||
| if slice.is_empty() { | ||
| return Ok(JSValue::js_number(0.0)); | ||
| return Ok(this.sink.write_empty().to_js(&cx)); | ||
| } | ||
| // Borrowed view over GC-kept buffer for the duration of the call. | ||
| let data = bun_ptr::RawSlice::new(slice); | ||
|
|
@@ -563,7 +567,7 @@ impl<T: JsSinkType> JSSink<T> { | |
|
|
||
| let view = arg.to_js_string_view(global)?; | ||
| if view.is_empty() { | ||
| return Ok(JSValue::js_number(0.0)); | ||
| return Ok(this.sink.write_empty().to_js(&cx)); | ||
| } | ||
|
|
||
| if view.is_utf16() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -273,6 +273,40 @@ it.skipIf(!isPosix)("a backpressured string write() resolves to its encoded byte | |
| expect(received).toBe(size); | ||
| }); | ||
|
|
||
| // An empty chunk is behind the chunks queued before it like any other; a number | ||
| // would tell the caller that nothing is waiting. | ||
| it.skipIf(!isPosix).each([ | ||
| ["string", ""], | ||
| ["Uint8Array", new Uint8Array(0)], | ||
| ])("an empty %s write() behind a backpressured write() returns that write's promise", async (_, empty) => { | ||
| const [readFd, writeFd] = createSocketPair(); | ||
| const sink = Bun.file(writeFd).writer(); | ||
| const size = 4 * 1024 * 1024; | ||
|
|
||
| expect(sink.write(empty)).toBe(0); | ||
| const first = sink.write(Buffer.alloc(size, 0x61)); | ||
| const second = sink.write(empty); | ||
|
|
||
| let received = 0; | ||
| const reader = (async () => { | ||
| for await (const part of Bun.file(readFd).stream()) received += part.byteLength; | ||
| })(); | ||
|
|
||
| try { | ||
| expect(first).toBeInstanceOf(Promise); | ||
| expect(second).toBe(first); | ||
| expect(await second).toBe(size); | ||
| expect(sink.write(empty)).toBe(0); | ||
|
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. 🟡 nit (optional): the Why this was flaggedFileSink.rs:1502-1507 returns Temporary(0) when Verification: |
||
| } finally { | ||
| await Promise.resolve(sink.end()).catch(() => {}); | ||
| fs.closeSync(writeFd); | ||
| await reader; | ||
| fs.closeSync(readFd); | ||
| } | ||
|
|
||
| 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 | ||
|
|
@@ -1234,6 +1268,39 @@ describe("a stream piped into a FileSink on a pipe is pumped to its end", () => | |
| } | ||
| }); | ||
|
|
||
| // The flush at the end of the tick takes the tail of the short write and resumes the pump from | ||
| // inside the flush, with the parked write's promise not settled yet. The empty chunk is what | ||
| // the pump writes then, and nothing would resume a pump that parked on it. | ||
| it.skipIf(!isPosix).each([ | ||
| ["string", ""], | ||
| ["ArrayBuffer", new ArrayBuffer(0)], | ||
| ])("an empty %s chunk behind a short write does not park the pump again", async (_, empty) => { | ||
| using dir = tempDir("filesink-piped-empty", {}); | ||
| const path = join(String(dir), "piped.fifo"); | ||
| mkfifo(path, 0o666); | ||
| const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); | ||
| try { | ||
| // A little more than a pipe holds, so the tail fits once the pipe is empty. | ||
| const short = Buffer.alloc(64 * 1024 + 1000, 97); | ||
| const source = new ReadableStream({ | ||
| start(c) { | ||
| c.enqueue(short); | ||
| c.enqueue(empty); | ||
| c.enqueue("end"); | ||
| c.close(); | ||
| }, | ||
| }); | ||
| const written = Bun.write(Bun.file(path), new Response(source)); | ||
| const head = fs.readSync(fd, Buffer.alloc(short.length)); | ||
| expect(head).toBeLessThan(short.length); | ||
| const [tail, total] = await Promise.all([drain(fd), written]); | ||
| expect(head + tail.length).toBe(short.length + 3); | ||
| expect(total).toBe(short.length + 3); | ||
| } finally { | ||
| fs.closeSync(fd); | ||
| } | ||
| }); | ||
|
|
||
| // With a listener the stream arrives on unfixed code too: the loop goes back to work each | ||
| // time the listener has run. It comes to the edge of its exit after each drain, though, | ||
| // and the listener hears of each one. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.