Skip to content
Open
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
16 changes: 11 additions & 5 deletions src/js/internal/fs/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type FSStream = Omit<import("node:fs").ReadStream & import("node:fs").WriteStrea
* sink = FileSink
*/
[kWriteStreamFastPath]?: undefined | true | FileSink;
[kDrainedWrite]?: Promise<unknown>;
};
type FD = number;

Expand All @@ -43,6 +44,7 @@ const kIoDone = Symbol("kIoDone");
// Bun supports a fast path for `createWriteStream("path.txt")` where instead of
// using `node:fs`, `Bun.file(...).writer()` is used instead.
const kWriteStreamFastPath = Symbol("kWriteStreamFastPath");
const kDrainedWrite = Symbol("kDrainedWrite");
const kFs = Symbol("kFs");

const {
Expand Down Expand Up @@ -451,6 +453,7 @@ function WriteStream(this: FSStream, path: string | null | undefined, options?:
this.pos = undefined;
this.bytesWritten = 0;
this[kIsPerformingIO] = false;
this[kDrainedWrite] = undefined;

if (start !== undefined) {
validateInteger(start, "start", 0);
Expand Down Expand Up @@ -560,7 +563,10 @@ function writevAll(chunks, size, pos, cb, retries = 0) {
}

// Like Writable's afterWrite: no 'drain' once the stream is ending or destroyed.
function emitDrain(stream: FSStream) {
// Chunks written while the sink is backed up share `write`, its promise: one 'drain' for all of them.
function emitDrain(stream: FSStream, write: Promise<unknown>) {
if (stream[kDrainedWrite] === write) return;
stream[kDrainedWrite] = write;
Comment thread
dylan-conway marked this conversation as resolved.
const state = stream._writableState;
if (state === undefined || !(state.ending || state.destroyed)) {
stream.emit("drain");
Expand All @@ -575,7 +581,7 @@ function _write(data, encoding, cb) {
if ($isPromise(maybePromise)) {
maybePromise
.then(() => {
emitDrain(this);
emitDrain(this, maybePromise);
cb(null);
})
.catch(cb);
Expand Down Expand Up @@ -621,7 +627,7 @@ function underscoreWriteFast(this: FSStream, data: any, encoding: any, cb: any)
maybePromise.then(
() => {
if (cb) cb(null);
emitDrain(this);
emitDrain(this, maybePromise);
},
err => {
if (cb) cb(err);
Expand Down Expand Up @@ -688,7 +694,7 @@ function writeFast(this: FSStream, data: any, encoding: any, cb: any) {
// mistaken for a write failure.
maybePromise.then(
() => {
emitDrain(this);
emitDrain(this, maybePromise);
cb(null);
},
err => {
Expand Down Expand Up @@ -731,7 +737,7 @@ writeStreamPrototype._writev = function (data, cb) {
if ($isPromise(maybePromise)) {
maybePromise
.then(() => {
emitDrain(this);
emitDrain(this, maybePromise);
cb(null);
})
.catch(cb);
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/webcore/FileSink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing: a small process.stdout.write(chunk, cb) behind a short write whose refused tail is under 4 KiB still returns true and runs cb at once, and the earlier write's promise resolves while that tail sits unwritten; exiting from cb loses it. PipeWriter.rs:989-1003 buffers the chunk and reports Drained, which clears backed_up (PipeWriter.rs:776) and makes FileSink.rs:405-428 settle the pending promise. The is_backed_up() guard at FileSink.rs:1503 is then false for a following write("") as well. Fix: while refused bytes are queued, a buffered write must not report Drained or settle the slot; report Pending (or gate the settle at FileSink.rs:405 on !has_pending_data) so write() returns the promise for "" and non-empty chunks alike.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: 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 outgoing, e.g. write(Buffer.alloc(65536 + 1000)) to an empty 64 KiB pipe. The next write("y", cb) enters PipeWriter.rs:989: should_buffer(1) is true (1000 + 1 < 4096), so it appends and calls parent_on_write(1, WriteStatus::Drained) at PipeWriter.rs:998, which sets backed_up=false at PipeWriter.rs:776 and enters FileSink::on_write. There status is Drained so the early return at FileSink.rs:401 is skipped; was_pending is true, so FileSink.rs:423-428 settles the first write's promise while 1001 bytes are still only in outgoing. write() then returns Wrote(1) -> 1, writeFast at streams.ts:709 calls cb synchronously and returns true. process.exit(0) inside cb exits before the tail is flushed. On the base branch the same happens; after one such buffered write the new guard at FileSink.rs:1503 sees is_backed_up()==false and write("", cb) again calls back at once with the tail queued. The PR's 1 MiB test leaves a tail far above 4 KiB, so it never reaches this path.

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 is_backed_up() guard inherits the hole. PipeWriter.rs:989-1003 appends to outgoing, calls parent_on_write(1, Drained) — which sets backed_up = false at line 776 — and returns Wrote(1). None of PipeWriter.rs:776/815/989-1003 or FileSink.rs:401-428 are touched by the diff.

return streams::Writable::Temporary(0);
}
self.to_result(WriteResult::Pending(0), 0)
}
Comment thread
dylan-conway marked this conversation as resolved.
Comment on lines +1502 to +1507

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 process.stdout.write callbacks get null instead of EPIPE. When a non-empty write hits a synchronous EPIPE behind a backpressured write, to_result at FileSink.rs:1595-1599 parks Err in the pending slot and leaves done false and the writer backed up. An empty write in the same tick then runs write_empty at FileSink.rs:1506, whose to_result Pending arm at FileSink.rs:1620-1623 overwrites that Err with Owned(consumed). Fix: when the pending slot is already Pending, return Writable::Pending(self.pending.as_ptr()) without touching p.result (or skip the overwrite when the result is Err), so the queued error still rejects the shared promise as it does on the base branch.

Why this was flagged

Entry point: sink.write(big) returns pending promise P while the pipe is full; the reader closes; sink.write("y") appends to outgoing and try_write returns EPIPE (PipeWriter.rs:1012, PipeWriter.rs:138). FileSink.rs:1595-1599 stores Writable::Err(err) in the slot and hands back P; done is not set and backed_up is still true. Still in the same tick, sink.write("") reaches write_empty at FileSink.rs:1502-1507: done is false and is_backed_up() is true, so it calls to_result(Pending(0), 0), and the Pending arm at FileSink.rs:1620-1623 sets p.result = Owned(p.consumed), discarding the error. run_pending then resolves P with the byte count, and the later on_error at FileSink.rs:473 does nothing because the slot is already Used. The caller awaiting P, and every stdout callback behind it, sees success for bytes that were never delivered. On the base branch the empty write returned 0 (old Sink.rs:551/570) without touching the slot, so P rejected with EPIPE.

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. write_empty reaches the Pending arm (FileSink.rs:1620-1623), which does p.result = streams::Writable::Owned(p.consumed), overwriting the parked Err. On the base branch the empty write returned the literal 0 (old Sink.rs:551/570).

Comment on lines +1502 to +1507

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 is_backed_up() check at FileSink.rs:1503 reads a flag that _on_error (PipeWriter.rs:822) never clears, so after the writer has closed, to_result(Pending(0)) creates a fresh promise no callback will ever run. It also re-takes the keep-alive ref at FileSink.rs:1604-1607 when on_close already released it. Fix: park an empty write only while bytes are still outstanding on a live writer (e.g. also require has_pending_data() and not is_done, or clear backed_up in _on_error), returning 0 otherwise as the base does.

Why this was flagged

Entry 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 is_done = true but never touches backed_up, set true at src/io/PipeWriter.rs:776. FileSink::on_error (FileSink.rs:463-487) never sets self.done. write_empty (FileSink.rs:1502-1507) so calls to_result(WriteResult::Pending(0), 0), taking self.ref_() again and creating a fresh promise nothing settles. On the base branch Sink.rs:551/570 returned the literal 0.

fn end_from_js(&mut self, cx: &bun_jsc::JsThread<'_>) -> sys::Result<JSValue> {
Self::end_from_js(self, cx)
}
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/webcore/Sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: A direct-stream pull that awaits controller.write("") on a backed-up HTTP response or S3/fetch upload still resolves at once, while the same call on a FileSink now parks until drain. The trait default at Sink.rs:374-376 answers Temporary(0) without asking the sink, and only FileSink.rs:1502 overrides it. HTTPServerWritable (streams.rs:1386-1397) and NetworkSink (streams.rs:2448-2457) both hold a pending promise for JS controllers exactly like FileSink. Fix: make an empty write consult the pending slot in every sink that can be backed up (HTTPServerWritable, NetworkSink), or implement it once in the shared write() from the sink's pending state.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user Bun.serve type: "direct" ReadableStream, or a direct stream feeding an S3/fetch upload, calls await controller.write("") while the socket is backed up (streams.rs:1387 has_backpressure). The generic host write() at Sink.rs:548-551 and Sink.rs:568-571 calls write_empty(), which for those sinks is the trait default at Sink.rs:374-376 returning Temporary(0). The caller resolves immediately and treats the sink as flushed, even though those sinks park JS controllers on self.pending (streams.rs:1395-1397, streams.rs:2455-2457) exactly as FileSink does. On the base branch all sinks answered 0; after this PR FileSink parks and the others do not, so the same code behaves differently per sink. The dismissal relied on the PR description scoping the change; REVIEW.md requires fixing the sibling sites (every JsSinkType with a pending promise) in the same PR. Remedy: override write_empty in HTTPServerWritable and NetworkSink to return Writable::Pending(&mut self.pending) when pending.state == Pending, or hoist that check into JSSink<T>::write.

Verification: Trigger: a type: "direct" stream calls controller.write("") while the destination is backed up. The write_empty default at Sink.rs:375-377 is Temporary(0) without consulting the sink. Only FileSink.rs:1502-1507 overrides it; HTTPServerWritable (streams.rs:1386-1398) and NetworkSink (streams.rs:2448-2458) park non-empty writes the same way, so there an empty chunk returns 0 during backpressure. Pre-existing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as it is on purpose. On FileSink the 0 was a visible defect because node:fs streams read a number as "flushed" and run the write callback. These two sinks have no such consumer: an empty chunk adds no bytes to hold back and end() still flushes, so nothing is lost. Returning the promise whenever the slot is Pending is also the shape that hung the pump on FileSink (other thread), and writable_result(0) / backpressure_result(0) would overwrite the value the outstanding write's promise resolves to. The exclusion is stated in the PR description.

}
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<()>;
Expand Down Expand Up @@ -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);
Expand All @@ -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() {
Expand Down
67 changes: 67 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,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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): the done clause of write_empty (FileSink.rs:1503) has no test; deleting it leaves every new test green. The socketpair test asserts 0 only while idle, never after end() returned a pending promise, which is the one state where done decides the answer. Fix: after a backpressured write, call sink.end() (it returns the pending promise) and assert sink.write(empty) is 0 before awaiting it, so the ended-sink contract the PR states is covered.

Why this was flagged

FileSink.rs:1502-1507 returns Temporary(0) when self.done.get() is true, otherwise falls through to is_backed_up(). done becomes true while is_backed_up() is still true only in the end paths (end_from_js Pending arm at FileSink.rs:1417-1424, end() at FileSink.rs:1319-1326, on_auto_flush Err arm at FileSink.rs:964). The new socketpair test (test/js/bun/util/filesink.test.ts:276-308) checks sink.write(empty) is 0 at line 286 (idle) and line 299 (after the backpressured promise resolved), and only then calls sink.end() in finally. The FIFO pump test (lines 1274-1302) never ends the sink while backed up either. With the self.done.get() || clause removed, both tests still pass, so the ended-sink behaviour the description promises is unverified; REVIEW.md asks that each load-bearing clause of a fix break at least one test.

Verification: src/runtime/webcore/FileSink.rs:1502-1507 returns Temporary(0) when self.done.get() || !self.writer.get().is_backed_up(). The done clause only decides the answer when done is true while the writer is still backed up. No new test reaches it: sink.end() is only called in finally (line 303) with no subsequent write(empty). So deleting self.done.get() || leaves every test green.

} 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
Expand Down Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions test/js/node/process/process-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,44 @@ describe.concurrent.skipIf(isWindows)(
await expectDrainOverSocket("", 1 << 20);
});

// A write's callback says the chunks before it are out, so exiting from it loses nothing. As in Node, the chunks queued
// while the pipe is full get one 'drain' between them.
test.each([
[`""`, 0],
[`Buffer.alloc(0)`, 0],
[`"B"`, 1],
])(
"process.stdout.write(%s, cb) behind a write to a full pipe calls back once both are out",
async (chunk, length) => {
await using proc = spawn({
cmd: [
bunExe(),
"-e",
`const order = [];
const report = v => require("fs").writeSync(2, JSON.stringify(v) + "\\n");
process.stdout.on("drain", () => order.push("drain"));
const first = process.stdout.write(Buffer.alloc(1 << 20, "A"), () => order.push("first"));
const second = process.stdout.write(${chunk}, () => {
order.push("second");
report({ order });
process.exit(0);
});
report({ first, second });`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
lazy: true,
});
const events = jsonLines(proc.stderr);
expect(await events.next()).toEqual({ first: false, second: false });
const [stdout, order, exitCode] = await Promise.all([proc.stdout.bytes(), events.next(), proc.exited]);
expect(stdout.byteLength).toBe((1 << 20) + length);
expect(order).toEqual({ order: ["drain", "first", "second"] });
expect(exitCode).toBe(0);
},
);

// Bun.spawn's "pipe" is a socketpair; these go through sh so fd 1 is a real pipe(2).
test("console.log and process.stdout.write are not truncated over a real pipe", async () => {
await using proc = spawn({
Expand Down
Loading