Skip to content
15 changes: 13 additions & 2 deletions src/io/PipeReader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,11 @@ impl PosixBufferedReader {

// Exists for consistently with Windows.
pub fn has_pending_read(&self) -> bool {
matches!(&self.handle, PollOrFd::Poll(poll) if poll.is_registered())
// `is_watching()` (registered && !needs-rearm) rather than
// `is_registered()`: a one-shot poll that has fired but not been
// re-armed will not deliver another callback, so callers that skip
// `read()` on "pending" must not be told one is in flight.
Comment thread
robobun marked this conversation as resolved.
matches!(&self.handle, PollOrFd::Poll(poll) if poll.is_watching())
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}

pub fn watch(&mut self) {
Expand Down Expand Up @@ -1912,7 +1916,14 @@ impl WindowsBufferedReader {
// grows by `amount_result` every chunk and never resets, so a 1 GB
// `cat` holds 1 GB resident instead of ~64 KB. Clear it here, after
// the streaming consumer has finished with `slice`.
if should_continue && has_more != ReadState::Eof && self.vtable.is_streaming_enabled() {
// `should_continue` no longer gates the clear: FileReader may say
// stop at its highwater mark while uv keeps delivering, and leaving
// `_buffer` uncleared would double-buffer (here + FileReader.buffered).
// Parents that want the reader paused call `reader().pause()`
// themselves; stopping here could free a parent whose caller still
// holds `this` (FileResponseStream on abort).
Comment thread
robobun marked this conversation as resolved.
let _ = should_continue;
if has_more != ReadState::Eof && self.vtable.is_streaming_enabled() {
self._buffer.clear();
}

Expand Down
25 changes: 20 additions & 5 deletions src/js/internal/streams/native-readable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
pull: (view: any, closer: any) => any;
updateRef: (ref: boolean) => void;
cancel: (error: any) => void;
setFlowing?: (flowing: boolean) => void;
}

let debugId = 0;
Expand Down Expand Up @@ -122,10 +123,14 @@

function read(this: NativeReadable, maxToRead: number) {
$debug(`[${this.debugId}] read${this[kPendingRead] ? ", is already pending" : ""}`);
var ptr = this.$bunNativePtr;
// Readable called `_read`, so it wants data: make sure the native reader is
// not paused from a previous `push()===false` (readStart, like net.Socket).
// Runs even when a pull promise is outstanding so that promise can resolve.
Comment thread
robobun marked this conversation as resolved.
if (ptr) ptr.setFlowing?.(true);
if (this[kPendingRead]) {
return;
}
var ptr = this.$bunNativePtr;
if (!ptr) {
$debug(`[${this.debugId}] read, no ptr`);
this.push(null);
Expand All @@ -139,13 +144,13 @@
this[kHighWaterMark] = Math.min(this[kHighWaterMark], result);
}
if ($isTypedArrayView(result) && result.byteLength > 0) {
this.push(result);
pushAndCheck(this, result);
}
const drainResult = ptr.drain();
this[kConstructed] = true;
$debug(`[${this.debugId}] drain result: ${drainResult?.byteLength ?? "null"}`);
if ((drainResult?.byteLength ?? 0) > 0) {
this.push(drainResult);
pushAndCheck(this, drainResult);
}
}
const chunk = getRemainingChunk(this, maxToRead);
Expand Down Expand Up @@ -196,12 +201,22 @@
}
}

// `push()` returning false means the Readable's buffer is at/above hwm (or
// the consumer paused); stop the native reader so kernel backpressure reaches
// the writer (readStop, like net.Socket). The next `_read()` re-enables it.
Comment thread
robobun marked this conversation as resolved.
function pushAndCheck(stream: NativeReadable, chunk: any) {
if (!stream.push(chunk)) {
const ptr = stream.$bunNativePtr;
if (ptr) ptr.setFlowing?.(false);
}
}

Check warning on line 212 in src/js/internal/streams/native-readable.ts

View check run for this annotation

Claude / Claude Code Review

Stale NativeReadable.push type declaration: void return, but pushAndCheck depends on boolean

The local `NativeReadable` type at line 32 still declares `push: (chunk: any) => void;`, but the new `pushAndCheck` helper depends on `push()` returning a boolean (`if (!stream.push(chunk))`). No runtime effect — src/js builtins bypass tsc and `Readable.prototype.push` returns boolean at runtime — but per REVIEW.md ("update every consumer atomically") the local override should become `push: (chunk: any) => boolean;` in the same PR that introduces the dependency.
Comment thread
robobun marked this conversation as resolved.

function handleNumberResult(stream: NativeReadable, result: number, chunk: any, isClosed: boolean) {
if (result > 0) {
const slice = chunk.subarray(0, result);
chunk = slice.byteLength < chunk.byteLength ? chunk.subarray(result) : undefined;
if (slice.byteLength > 0) {
stream.push(slice);
pushAndCheck(stream, slice);
}
}

Expand All @@ -216,7 +231,7 @@

function handleArrayBufferViewResult(stream: NativeReadable, result: any, chunk: any, isClosed: boolean) {
if (result.byteLength > 0) {
stream.push(result);
pushAndCheck(stream, result);
}

if (isClosed) {
Expand Down
34 changes: 20 additions & 14 deletions src/runtime/webcore/FileReader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,8 +778,15 @@
unsafe { (*parent).increment_count() };
self.pending.with_mut(|p| p.run());
close_if_needed!();
// Re-entrant cancel closed the reader; tell the io caller to stop.
let ret = if self.done.get() { false } else { ret };
// Re-entrant cancel (sets `done`) or a nested on_pull that read to
// EOF (sets IS_DONE via on_reader_done but not `self.done`) closed
// the reader; tell the io caller to stop so it does not re-read the
// captured fd.
Comment thread
robobun marked this conversation as resolved.
let ret = if self.done.get() || self.reader().is_done() {
false
} else {
ret
};

Check failure on line 789 in src/runtime/webcore/FileReader.rs

View check run for this annotation

Claude / Claude Code Review

d204caa re-entrancy guard bypassed when outer read_with_fn has received_hup=true

The d204caa guard (`|| self.reader().is_done()`) makes `on_read_chunk` return `false` after a nested `on_pull → reader().read()` reaches EOF, but `read_with_fn`'s two mid-loop `on_read_chunk` callsites gate the early return on `&& !received_hup` (PipeReader.rs ~848, ~873) — so when the outer poll fired with HUP (child closed with data still buffered), the `false` is ignored, `head_start` resets to 0, and the inner loop calls `recv_non_block(fd, …)` on the local `fd` that the nested EOF path alre
Comment thread
robobun marked this conversation as resolved.
// SAFETY: see `parent()`; the pin keeps the count >= 1, so this
// never frees. `self` is not accessed after.
let _ = unsafe { Source::decrement_count(parent) };
Expand All @@ -794,14 +801,19 @@
}

// No JS read is waiting; stop at the highwater mark. onPull restarts.
//
// `started` gates the backstop: a `from_pipe()` reader for non-lazy
// `Bun.spawn` is already reading when it arrives here, and throttling
// before any consumer has attached deadlocks a child that alternates
// stdout/stderr writes while the caller only awaits one of them.
Comment thread
robobun marked this conversation as resolved.
// SAFETY: see `reader_buffer` decl.
let reader_buffer_len = unsafe { (*reader_buffer).len() };
let ret = self.flowing.get()
&& !matches!(
self.read_inside_on_pull.get(),
ReadDuringJSOnPullResult::Temporary(_)
)
&& self.buffered.get().len() + reader_buffer_len < self.highwater_mark;
let ret = !matches!(
self.read_inside_on_pull.get(),
ReadDuringJSOnPullResult::Temporary(_)
) && (!self.started.get()
|| (self.flowing.get()
&& self.buffered.get().len() + reader_buffer_len < self.highwater_mark));
close_if_needed!();
ret
}
Expand Down Expand Up @@ -950,12 +962,6 @@
self.pending_value.with_mut(|p| p.set(&global, array));
self.pending_view.set(buffer);

// `has_pending_read()` tracks the registration flag, not the kernel
// arm state: the highwater backstop leaves the one-shot poll disarmed.
if self.flowing.get() {
self.reader().watch();
}

bun_core::scoped_log!(FileReader, "onPull({}) = pending", buffer_len);

streams::Result::Pending(self.pending.as_ptr())
Expand Down
62 changes: 62 additions & 0 deletions test/js/node/child_process/child_process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,68 @@ describe.skipIf(!isPosix)("stdout pipe backpressure", () => {
});
});

// child.stdout.pause() must stop the native reader so the kernel pipe fills
// and the child blocks on write. Previously, once the stream had flowed even
// once the native FileReader kept the poll armed (or uv_read_start active on
// Windows) regardless of JS state, so the child wrote its entire output into
// the parent's heap and 'data' kept firing after #handleOnExit resumed it.
it("child.stdout.pause() after flowing stops native reads and blocks the child", async () => {
// 20 MB: well above any kernel socket buffer, small enough to drain fast
// once resumed on ASAN.
const SIZE = 20 * 1024 * 1024;
const writer = `const c=Buffer.alloc(1<<20,97);let w=0;(function f(){while(w<${SIZE}){const n=Math.min(c.length,${SIZE}-w);w+=n;if(!process.stdout.write(c.subarray(0,n))){process.stdout.once('drain',f);return}}})()`;
const c = spawn(bunExe(), ["-e", writer], {
stdio: ["ignore", "pipe", "ignore"],
env: bunEnv,
});
try {
let events = 0;
let bytes = 0;
let eventsAfterPause = 0;
const { promise: firstData, resolve: gotFirst, reject: failFirst } = Promise.withResolvers<void>();
c.on("error", failFirst);
c.on("close", () => failFirst(new Error("child closed before first 'data'")));
c.stdout!.on("data", (d: Buffer) => {
events++;
bytes += d.length;
if (events === 1) {
c.stdout!.pause();
gotFirst();
} else {
eventsAfterPause++;
}
});
await firstData;

expect(c.stdout!.isPaused()).toBe(true);

// Give the eager-read path every chance to over-buffer: without the fix
// the native reader has already drained most of SIZE and the child is
// racing to exit. Deadline-polled; breaks early if the bug is present.
const deadline = Date.now() + 1000;
while (c.exitCode === null && Date.now() < deadline) {
await new Promise(r => setImmediate(r));
}

// Core assertion: pause() applied backpressure, so the child cannot have
// finished writing SIZE bytes and is blocked on a full pipe.
expect(c.exitCode).toBeNull();
expect(eventsAfterPause).toBe(0);
expect(c.stdout!.isPaused()).toBe(true);
// At most a few pipe-buffer-sized chunks were delivered before pause took
// effect, never the whole output.
expect(bytes).toBeLessThan(SIZE);

// Resuming delivers every byte and the child exits cleanly.
c.stdout!.resume();
await once(c, "close");
expect(bytes).toBe(SIZE);
expect(c.exitCode).toBe(0);
} finally {
c.kill();
}
});

// When spawn fails (ENOENT, bad cwd, etc.) the ChildProcess emits 'error' and
// 'close' but never 'exit'. The abort listener on options.signal was only
// removed on 'exit', so every failed spawn against a shared AbortSignal leaked
Expand Down
Loading