Skip to content

Apply backpressure when piping a ReadableStream into a subprocess - #33399

Closed
robobun wants to merge 5 commits into
mainfrom
farm/e7d16322/spawn-stdin-stream-backpressure
Closed

Apply backpressure when piping a ReadableStream into a subprocess#33399
robobun wants to merge 5 commits into
mainfrom
farm/e7d16322/spawn-stdin-stream-backpressure

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a process-level DoS when a ReadableStream is piped into a subprocess'
stdin: the stream is drained with no backpressure, starving the event loop and
growing memory without bound.

Repro

const chunk = Buffer.alloc(65536, "a");
setTimeout(() => console.log("timer fired"), 250);

const proc = Bun.spawn({
  cmd: ["head", "-c", "10"],           // reads a little, exits early
  stdin: new ReadableStream({
    pull(c) { c.enqueue(chunk); },     // synchronous producer
  }),
});
await proc.exited;

On main this never prints timer fired and never resolves proc.exited: RSS
climbs past 4 GB in a few seconds while pull() is called ~500k times/s. A
child that reads nothing at all (sleep 10) buffers 3.1 GB in 3 s with zero
timer ticks. Node paces the same source through child.stdin and stays flat.

A synchronous pull() is the natural shape for a generator-backed source, and
nothing in the docs asks for an async one, so this is reachable from any child
that consumes slowly or exits early.

Cause

readStreamIntoSink only stops reading when sink.write() returns a negative
number. FileSink never returns one: it buffers whatever the destination will
not take and reports the bytes as written. Since the read is already fulfilled
from the stream's queue, the whole read/write cycle stays inside the microtask
queue and the event loop never runs.

Two more defects fall out of the same pump:

  • FileSink.write() surfaces a failed write (EPIPE, once the child closes its
    stdin) as an already-rejected Promise. rsisWriteChunk called
    markPromiseAsHandled on it and kept reading into a dead sink forever.
  • Both pumps cancelled the source via publicStreamCancelIgnoringResult, i.e.
    ReadableStream.prototype.cancel semantics. A pump always holds the stream's
    reader, so isReadableStreamLocked was always true and every call built a
    rejected TypeError that was immediately swallowed. The source's cancel()
    could never run.

Fix

  • FileSink reports Writable::Backpressure (the existing -(len + 1)
    sentinel the HTTP sink already uses) once the destination refuses bytes,
    and the pump's existing await sink.flush(true) path takes it from there.
    The signal comes from the OS, not a threshold: write(2) returns EAGAIN,
    which try_write surfaces as WriteResult::Pending and reports as
    WriteStatus::Pending. (has_pending_data() is a different thing — it is
    also true while sub-CHUNK_SIZE writes coalesce in a buffer the kernel has
    not been shown. On Windows uv_write is always async, so the equivalent is
    process_send finding one already in flight and leaving the bytes in
    outgoing.) The writer ends up holding at most the one chunk the destination
    refused. Gated on a ReadableStream actually being pumped into the sink: the
    sentinel is private to that pump, so Bun.file().writer() and
    proc.stdin.write() keep write()'s number-or-Promise contract.
  • A rejected write promise aborts the pump instead of being swallowed.
  • Both abort paths cancel through the ReadableStreamCancel abstract op, the
    way pipeTo does, so the lock check no longer eats the cancel.

Making the pump actually park on flush(true) then exposed a latent re-entrancy
bug in FileSink::on_write. The flush promise is resolved from inside on_write
by run_pending, which drains microtasks: the pump resumes right there, writes
the stream's last chunks and calls end(), all before on_write continues.
on_write then acted on the done && Drained snapshot it took before the
re-entry and called writer.end(), which on POSIX closes the fd immediately and
discards whatever had just been buffered, truncating the child's stdin (1 MiB
sent, 911168 bytes delivered). It now re-reads has_pending_data after
run_pending rather than trusting the snapshot.

With the fix the repro exits cleanly after 3 pulls, RSS stays flat, and
cancel() runs with the EPIPE reason.

Related

Partially addresses #20815 (stdin: otherProc.stdout): that shape does go
through this pump, but it still grows without bound, because the
subprocess-stdout source has its own missing backpressure. A proc.stdout
nobody reads buffers ~496 MB in about a second on this branch. Leaving that
issue open for the separate fix.

How did you verify your code works?

New and updated tests in test/js/bun/spawn/spawn-stdin-readable-stream.test.ts:

  • Two new tests for a synchronous pull(). One asserts timers keep firing while
    a child that never reads stdin is fed; one asserts the source is cancelled
    when the child exits early. Both bail out of pull() past a bound so they
    fail fast rather than exhausting memory. On main both fail with
    pull() was never bounded by backpressure; they pass on this branch.
  • A data-integrity test for the truncation above: chunks larger than the
    high-water mark park every other write, so an odd number of them reliably
    leaves one in the buffer as the stream closes. It passes on main (nothing
    parks there, so the re-entrancy window never opens) and caught the bug 12/12
    against the intermediate version of this PR that lacked the on_write guard.
  • A test pinning the invariant the negative sentinel relies on: proc.stdin is
    the stream itself, not a FileSink, when stdin is a ReadableStream (spawn
    caches it), so the sink that reports backpressure has no JS handle. Nothing
    enforced that before, and a change to the stdin getter would have leaked
    -(len + 1) into user code.
  • Un-todos ReadableStream cancellation when process exits early (todo since
    Enable ReadableStream as stdin for Bun.spawn #20582) and makes it await the cancel instead of sleeping 100 ms.
  • expectNoUnhandledRejectionWhenChildDies / expectParentExitsAfterChildDies
    killed the child on the 4th chunk; backpressure now stops the pull before
    that, so they kill on the 2nd (the first write is already in flight by then).
    Assertions unchanged.

Regression sweep, each suite compared against a build of the same tree with
src/ reverted, so this container's slow-test timeouts don't read as
regressions: test/js/web/streams/, test/js/bun/util/filesink.test.ts,
test/js/web/fetch/fetch.stream.test.ts, fetch-leak, body,
fetch-response-finalizer-sweep, test/js/bun/http/serve* and the rest of
test/js/bun/spawn/ show no new failures.

Backpressure costs nothing when the consumer keeps up: 64 MB through cat,
same build, measures 461 MB/s with it and 444 MB/s without — identical within
noise, because a fast reader drains the pipe, the write completes, and the pump
never parks.

bun run rust:check-all passes on all 10 targets (the Windows writer's
is_backed_up() is cfg-gated).

Rebase note

Rebased onto main after #33538 landed, which independently added the
buffered_len() accessor and gave to_result an accepted: u64 argument.
Resolved by keeping main's accepted-byte computation (the post-transcode buffer
delta, which is more accurate than the input length this PR originally used) and
routing it through write_result. is_backed_up() now sits alongside main's
buffered_len() rather than replacing it.

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 AM PT - Jul 7th, 2026

@robobun, your commit 2d34e88 has some failures in Build #69612 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33399

That installs a local version of the PR into your bun-33399 executable, so you can run:

bun-33399 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. New subprocess piping still consumes much CPU #20815 - Subprocess piping high CPU usage is caused by the lack of backpressure in FileSink, which makes the pipe loop busy-spin without yielding to the event loop
  2. cancel callback of "direct" readable stream doesn't get called #18315 - The cancel callback of a "direct" ReadableStream never being called is exactly the stream-cancel-never-reaching-the-source defect this PR fixes
  3. FileSink.write incoherencies #12194 - FileSink.write returning inconsistent values (promise vs number) is addressed by the new backpressure-aware write_result() path

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #20815
Fixes #18315
Fixes #12194

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds backed-up-state tracking to streaming writers, uses it to map FileSink writes to Writable::Backpressure, changes WebStreams cancellation and rejected-write handling, and expands stdin readable-stream tests.

Changes

Stream backpressure signaling and pump fixes

Layer / File(s) Summary
Backed-up state tracking
src/io/PipeWriter.rs
Adds backed-up flags and is_backed_up() accessors for POSIX and Windows streaming writers, with POSIX updating the flag on write callbacks and flushes.
FileSink backpressure mapping
src/runtime/webcore/FileSink.rs, src/runtime/webcore/streams.rs
Computes accepted byte counts for writes, maps non-terminal results to Writable::Backpressure when the writer is backed up, and tightens drained handling after pending work runs.
Stream cancellation and rejected writes
src/jsc/bindings/webcore/streams/BunStreamSource.cpp
Replaces the abrupt cancellation helper, always cancels through the internal path, and throws when rsisWriteChunk receives an already rejected wrote promise.
ReadableStream stdin pump tests
test/js/bun/spawn/spawn-stdin-readable-stream.test.ts
Replaces the cancellation todo with a real test, adjusts child-exit timing in existing helpers, adds a proc.stdin shape check, and adds synchronous pull() tests for backpressure, liveness, and cancellation.

Possibly related PRs

  • oven-sh/bun#32553: Shares the same Writable::Backpressure and readStreamIntoSink plumbing touched by the FileSink backpressure changes here.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: applying backpressure when piping a ReadableStream into a subprocess.
Description check ✅ Passed The description matches the required template and includes both the change summary and verification details.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

I checked all three against a build of this branch before adding any Fixes lines, and none of them close cleanly, so I'm not adding the block.

#18315 — no. The repro still fails on this branch:

let cancelled = false;
const s = new ReadableStream({ type: "direct", cancel() { cancelled = true; } });
await s.cancel();
console.log(cancelled); // still false

Different path. That issue is ReadableStream.prototype.cancel() on a direct stream with no pump in the picture; what this PR fixes is the pump's abort path using the public cancel() while it holds the stream's reader.

#12194 — no, and deliberately so. Still reproduces:

write() returned Promise => awaited 219264
write() returned Promise => awaited 365440

proc.stdin.write() keeps returning a Promise under backpressure. That's the point of gating write_result() on a ReadableStream actually being pumped into the sink: the -(len + 1) sentinel is private to readStreamIntoSink, and leaking it into the public FileSink.write() contract would silently break anyone who awaits that Promise for backpressure.

#20815 — partially, but not enough to close it. stdin: otherProc.stdout does go through the pump this PR fixes, but the shape in that issue still grows without bound, because the source side has its own missing backpressure. On this branch, a proc.stdout that nobody reads at all buffers ~496 MB in about a second:

const producer = Bun.spawn({ cmd: [...], stdout: "pipe" });
const rs = producer.stdout; // never read
// rssDelta=496MB after ~1s

So bounding the sink slows the growth but the reader keeps draining the pipe into memory regardless of desiredSize. That's a separate fix on the subprocess-stdout reader, and #20815 should stay open until it lands.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Fair catch on the description check — restructured it under the template's ### What does this PR do? / ### How did you verify your code works? headings. No content changed.

@claude claude Bot left a comment

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.

I didn't find any bugs, but this changes behavior in the shared streams pump (cancel semantics, rejected-write handling) that also backs HTTP/fetch/S3 sinks, and the Pending → Backpressure override in write_result has subtle Windows-side interactions — worth a human look before landing.

Extended reasoning...

Overview

Five files: a new buffered_size() accessor on both POSIX and Windows streaming writers (src/io/PipeWriter.rs); FileSink now reports Writable::Backpressure once >256 KiB is buffered and a ReadableStream is attached (src/runtime/webcore/FileSink.rs); the C++ pump now aborts on an already-rejected write promise and cancels the source via the ReadableStreamCancel abstract op instead of the public locked-check path (BunStreamSource.cpp); a doc-comment tweak on Writable::Backpressure (streams.rs); and test updates including un-todo'ing the long-standing cancellation test and two new sync-pull backpressure tests.

Security risks

None identified. The change reduces a DoS surface (unbounded buffering when a subprocess stops reading stdin). No new untrusted-input parsing, auth, or crypto paths.

Level of scrutiny

Moderate-to-high. The FileSink backpressure gate is scoped to the readable_stream.has() case (spawn stdin only), which limits blast radius nicely. But the two C++ pump changes are not scoped to FileSink: cancelStreamIgnoringResult and the rejected-promise abort in rsisWriteChunk apply to every sink routed through readStreamIntoSink / assignStreamIntoResumableSink (HTTP/HTTPS/H3 response, Network/S3). The old cancel path was a no-op by construction, so making it actually run cancel() is a real behavior change for those consumers too.

Other factors

  • Design choices a human should confirm: the 256 KiB HWM constant; gating backpressure on readable_stream.has() (so Bun.file().writer() and direct proc.stdin.write() still buffer unboundedly by design); and whether un-todo'ing the #20582 test is now safe on all platforms.
  • write_result subtlety: when to_result returns Pending (every write on Windows), it has already bumped pending.consumed, taken the keep-alive ref, and set pending.result — then write_result discards that and returns Backpressure instead. The follow-up flush(true)flush_from_js path re-enters to_result and creates the promise there, and the must_be_kept_alive_until_eof guard prevents a double-ref, so it looks correct, but the interaction is non-obvious and worth a maintainer's eye.
  • Existing-test edits: two helpers now kill the child on chunk 2 instead of 4 because backpressure stops pulls earlier — the assertions are unchanged, but a reviewer should confirm the tests still exercise the same failure mode they were written for.
  • The PR description reports broad regression testing (streams/, filesink, fetch.stream, serve*, spawn/) and rust:check-all on all targets, and CI is building.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

the Pending → Backpressure override in write_result has subtle Windows-side interactions — worth a human look before landing

Good instinct, and it paid off: there was a real bug next door, though not in write_result itself. Pushed a fix in ff9c335.

The flush promise that write_result's new Backpressure sentinel makes the pump await is resolved from inside FileSink::on_write, by run_pending — which drains microtasks. So the pump resumes inside on_write, writes the stream's last chunks and calls end(), and only then does on_write continue. It then acted on the done && Drained snapshot it had taken before the re-entry:

let has_pending_data = (*this).writer.get().has_pending_data();  // snapshot
...
FileSink::run_pending(this);                                     // re-enters JS, buffers more
if (*this).done.get() && status == WriteStatus::Drained {
    (*this).writer.with_mut(|w| w.end());                        // POSIX end() closes the fd now
}

POSIX PosixStreamingWriter::end() is is_done = true; self.close() — it drops whatever is still in outgoing. Result: the child's stdin was truncated. 1 MiB sent, 911168 bytes delivered. It only fires when the final resume lands with a chunk still buffered, so it reproduced about 1 run in 6 on a release-asan build and not at all on debug.

The fix re-reads has_pending_data after run_pending instead of trusting the snapshot. The writer still ends, one drain later, through the same path.

Latent before this PR: nothing ever awaited that promise, so the pump never resumed inside run_pending.

On your other points:

  • 256 KiB HWM / gating on readable_stream.has(): agreed these are the judgement calls. The gate is what keeps Bun.file().writer() and proc.stdin.write() on their existing number-or-Promise contract — the negative sentinel is private to readStreamIntoSink, and leaking it would silently break anyone awaiting that Promise for backpressure.
  • Un-todoing the Enable ReadableStream as stdin for Bun.spawn #20582 test: it already passed on released 1.4.0 (5/5), and I rewrote it to await the cancel rather than sleep 100 ms, so it's no longer timing-dependent.
  • The two edited helpers: they kill on chunk 2 instead of 4 because backpressure stops the pull before 4 arrives. The first write is already in flight at that point, so they still exercise "child dies mid-write"; assertions unchanged.

New regression test for the truncation: chunks larger than the HWM park every other write, so an odd number of them reliably leaves one buffered as the stream closes. 12/12 against the unguarded intermediate, 25/25 clean with the fix.

@claude claude Bot left a comment

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.

I didn't find any bugs, but this changes core stream-pump and FileSink write/drain semantics (including the re-entrant on_writerun_pending → re-read has_pending_data path and the pump's cancel/reject handling), so it's worth a human look before merging.

Extended reasoning...

Overview

This PR spans five files: a trivial buffered_size() accessor added to both the POSIX and Windows streaming writers in src/io/PipeWriter.rs; a new write_result() path in src/runtime/webcore/FileSink.rs that converts non-terminal write outcomes into Writable::Backpressure once >256 KiB is buffered (gated on readable_stream.has()), plus a subtle change in on_write to re-read has_pending_data after run_pending() drains microtasks; a doc-comment refinement in src/runtime/webcore/streams.rs; and in src/jsc/bindings/webcore/streams/BunStreamSource.cpp, replacing publicStreamCancelIgnoringResult (which checked isReadableStreamLocked and rejected) with cancelStreamIgnoringResult (which calls readableStreamCancel unconditionally) at both rsisAbrupt and resumableHandleAbrupt, and making rsisWriteChunk throw when sink.write() returns an already-rejected promise. Tests are added/updated in test/js/bun/spawn/spawn-stdin-readable-stream.test.ts, including un-todo'ing a cancellation test and adding three new tests for synchronous-pull backpressure, byte-count integrity behind backpressure, and source cancellation on early child exit.

Security risks

None identified. This is I/O flow-control and error-path handling; no auth, crypto, path handling, or untrusted-input parsing is touched. The fix itself mitigates a resource-exhaustion / event-loop-starvation vector (unbounded buffering when a subprocess consumes stdin slowly or exits early).

Level of scrutiny

High. While each individual hunk is small, the changes touch the intersection of the C++ readStreamIntoSink/resumableSink pumps, the Rust FileSink write-completion state machine, and re-entrant JS execution during run_pending. In particular: (1) the on_write change re-reads has_pending_data after resolving the flush promise, because the resumed pump can synchronously buffer more bytes and end() — closing the fd on a stale "drained" snapshot would truncate the child's stdin (covered by the new "receives every byte" test); (2) dropping the locked-stream check on the pump's abort path changes cancellation semantics for two pump implementations; (3) the Backpressure sentinel is deliberately scoped via readable_stream.has() so the public FileSink.write() number-or-Promise contract is unchanged, but that gating condition is worth a maintainer's confirmation. These are exactly the kind of lifecycle/re-entrancy edges where a subtle mistake causes hangs, truncation, or leaks on one platform.

Other factors

The PR description is exceptionally thorough — root cause, mechanism, gating rationale, and a manual regression sweep across streams/fetch/serve/spawn suites are all documented — and the author verified rust:check-all on all 10 targets for the cfg-gated Windows accessor. The bug-hunting system found nothing. Two existing test fixtures had their kill trigger moved from the 4th to the 2nd chunk to accommodate the new backpressure behavior; the assertions themselves are unchanged and the rationale is commented. Still, this is a behavioral change to a hot, cross-language, cross-platform I/O path and not a mechanical fix, so I'm deferring rather than approving.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green, the red lanes are unrelated

The 13 x64-asan lane caught a real bug in the first revision of this PR (stdin truncation), I fixed it in ff9c335, and that lane has been clean since. spawn-stdin-readable-stream.test.ts does not appear in the failures of either build run after the fix.

What is currently red on #68726, and why none of it is this PR:

lane failure why it isn't this diff
darwin 26 aarch64 buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. Refusing to continue with a partial download. Infrastructure. The lane never ran a test.
windows 11 aarch64 spawn-pipe-leak.test.ts, RSS delta 138% > 80% Marked flaky, passed on retry. Every spawn in that file uses stdin: "ignore", so no FileSink is ever constructed — the code this PR touches cannot run.
windows 11 aarch64 bun-install.test.ts, EBADF: bad file descriptor, fstat Zero ReadableStream usages in the file, and no FileSink pending-write path. The pump and FileSink changes here are unreachable from bun install.

Across the three builds on this branch the red lane has been different every time (hot.test.ts, update_interactive_install, v8-heap-snapshot SIGKILL, bun-install, a darwin artifact timeout) and never the same test twice. A real regression would reproduce on the same lane.

I also chased the one mechanism by which the on_write guard could plausibly move memory — leaking a FileSink by skipping end() — with a live-count probe over 12 backpressure rounds: delta = 0.

Verification on the diff itself:

  • The two synchronous-pull() tests fail on released 1.4.0 with pull() was never bounded by backpressure and pass here.
  • The data-integrity test catches the truncation 12/12 against the intermediate revision that lacked the on_write guard, and is 25/25 clean with it.
  • spawn-stdin-readable-stream.test.ts: 29 pass / 0 fail on both debug+asan and release-asan.
  • streams/, filesink, fetch.stream, fetch-leak, body, serve* and the rest of spawn/ compared against a build of the same tree with src/ reverted: no new failures.
  • cargo clippy clean; bun run rust:check-all green on all 10 targets.

I've used my one re-trigger already (02ea04d9), so I'm not going to push another empty commit. This needs a maintainer to either re-run the darwin lane or merge over it.

Comment thread src/runtime/webcore/FileSink.rs Outdated
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — I chased both down against a build of the branch. Neither is reachable today, but (2) pointed at a real gap: the gate's safety rests on an invariant nothing pinned. Fixed in 4bb28f2.

(2) the sentinel cannot reach proc.stdin.write()

proc.stdin is not a FileSink when stdin is a ReadableStream — js_bun_spawn_bindings.rs:1639 caches the stream itself into the stdin slot, so get_stdin (which would hand back Writable::to_js) never runs:

typeof proc.stdin = object ReadableStream
proc.stdin is not a writable sink

So the sink with readable_stream.has() == true has no JS handle at all, and every sink that does have one (proc.stdin for stdin: "pipe", Bun.file().writer()) has no stream attached. The void proc.stdin line in the leak test is a no-op for that reason; its comment is stale.

That said, you're right that nothing enforced this — a change to the stdin getter would silently start leaking -(len+1) into user code. 4bb28f2 adds a test asserting proc.stdin === stream and that it has no .write, and rewrites the write_result doc comment to state the actual reason rather than implying proc.stdin is a FileSink that merely happens to have no stream attached. That wording is what made the gate look imprecise, so thanks for the nudge.

(1) the Bun.write sibling site doesn't pump a user stream

pipe_readable_stream_to_blob has exactly one caller (Blob.rs:4914): Bun.write(fileBlob, s3Blob), S3→local file, where the stream is ReadableStream::from_blob_copy_ref — a native source, never a user pull().

A bare Bun.write(dest, readableStream) never reaches it. It stringifies:

await Bun.write(Bun.stdout, new ReadableStream({ start(c) { c.enqueue("hi\n"); c.close(); } }));
// writes "[object ReadableStream]" — 23 bytes

Same with a FIFO destination (pulls=1, no drain). So there is no synchronous-pull()-into-a-pollable-sink path there today.

Two things worth recording for later:

  • The S3→file destination is DataTag::File, so in practice it's a regular file: force_sync, writes complete synchronously, nothing buffers, and the buffered_size() > HWM condition can't trip. Bun.write(Bun.stdout, Bun.s3(...)) into a blocked pipe could buffer, but the source is a native async stream, so it can't starve the loop the way the reported bug does. Narrower, different bug.
  • If Bun.write: stream ReadableStream sources instead of stringifying them #31689 (Bun.write: stream ReadableStream sources instead of stringifying them) lands, that path will pump a user-controlled stream into a possibly-pollable sink, and it will need the same flag. Worth a note on that PR.

I kept the gate scoped rather than widening it, since the only site it would newly cover is one where the destination is force_sync anyway.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Update on the CI triage above, since the red lane moved again.

#68743 (4bb28f27): 283 jobs passed, 1 failed. The one failure is darwin 14 x64terminal.test.tscreates subprocess with terminal attached, which timed out after 90 s waiting for PTY output. The other two annotations (compile-windows-metadata, bun-install-registry) are both marked flaky and passed on retry.

That test cannot reach this diff:

  • src/runtime/api/bun/Terminal.rs — 0 references to FileSink
  • src/runtime/api/bun/subprocess/Writable.rs — 0 references to terminal, so the PTY path never constructs a Writable::Pipe
  • test/js/bun/terminal/terminal.test.ts — 0 references to ReadableStream

Everything this PR changes is behind FileSink (reached only via Writable::Pipe) or the readStreamIntoSink / assignStreamIntoResumableSink pumps (reached only via assignToStream). buffered_size() is a pure accessor.

Running tally of red lanes across the five builds on this branch:

build red lane
68682 hot.test.ts (flaky) + spawn-stdin-readable-stream.test.ts — a real bug, fixed in ff9c335
68708 v8-heap-snapshot SIGKILL/OOM
68726 darwin artifact-download timeout (infra) + bun-install EBADF
68743 terminal.test.ts PTY timeout

A different test every time, never the same one twice, and spawn-stdin-readable-stream.test.ts has been clean on every build since the fix. A real regression would reproduce on the same lane.

I've used my one re-trigger (02ea04d9) and won't push another empty commit. The diff is green; this needs a maintainer to re-run darwin 14 x64 or merge over it.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hardcoding a number to signal backpressure is incorrect. Backpressure should be based on something from the operating system - EAGAIN or similar.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

You're right, and the signal was already sitting there — I just didn't use it. Reworked in 93ac734.

try_write turns EAGAIN into WriteResult::Pending:

sys::Result::Err(err) => {
    if err.is_retry() {
        return WriteResult::Pending(offset);
    }

and that reaches the parent as WriteStatus::Pending, which PosixStreamingWriter emits only on the EAGAIN / short-write paths — a full write and the sub-CHUNK_SIZE coalesce buffer both report Drained. So the writer now records that one bit and FileSink gates on it:

if !self.readable_stream.with_mut(|s| s.has()) || !self.writer.get().is_backed_up() {
    return result;
}

STREAM_BACKPRESSURE_HIGH_WATER_MARK and buffered_size() are gone.

Worth noting has_pending_data() would have been the wrong predicate: it's also true while writes under CHUNK_SIZE coalesce in a buffer the kernel hasn't been shown yet, so a stream of small chunks would have reported backpressure on data the OS never refused, and lost the coalescing. On Windows uv_write is always async, so a write in flight says nothing either; the equivalent signal there is process_send finding one already outstanding and leaving the bytes in outgoing.

Two things fell out of it, both good:

The buffer is now bounded by one chunk instead of 256 KiB. The pump parks on the first refused write. A child that never reads stdin now takes 5 pulls and ~12 MB RSS, where the old threshold took 9.

It costs nothing when the consumer keeps up. Same build, 64 MB through cat:

backpressure on:  64MB in 139ms = 461 MB/s
backpressure off: 64MB in 144ms = 444 MB/s

Identical within noise — a fast reader drains the pipe, try_write completes, backed_up stays false, and the pump never parks. Parks happen exactly when the kernel says so.

One honest consequence. The on_write stale-snapshot fix earlier in this PR (ff9c335) had a test that caught it 12/12; under the EAGAIN rule that test can no longer reach the bug (0/20), because a chunk bigger than the pipe now parks before the pump can reach end(). I kept the guard — acting on a snapshot taken before a call that re-enters JS is wrong regardless, and it would reopen the moment anyone reintroduces a threshold — but I reworded the test so it only claims what it still proves: byte-exact delivery across repeated parks.

Also had to retune the two child-dies-mid-write fixtures. They killed on the second chunk; the pump now parks on the first refused write, so a second chunk is never produced. They kill on the first now, from a macrotask, which lands after the microtask that issued the write.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 93ac734 (the EAGAIN rework): #68795284 test jobs passed, 0 failed.

The single red check is darwin 14 aarch64 - test-bun, status Expired: the job was never picked up by an agent, so it never ran a test. Queue infrastructure, not this diff.

Three annotations, all marked flaky and all passed on retry:

lane failure why it isn't this diff
windows 2019 x64 test-http-client-request-options.js, ECONNREFUSED 127.0.0.1:64631 Two node http tests racing on the same port.
windows 2019 x64 test-http-dont-set-default-headers.js, same port, same errno Same race.
windows 2019 x64-baseline shell/exec.test.ts crash src/shell/ has zero FileSink references, and readable_stream is set in exactly one place (inside assign_to_stream), so write_result's gate short-circuits for every sink except Bun.spawn({ stdin: <ReadableStream> }). The shell cannot reach the changed code.

spawn-stdin-readable-stream.test.ts has been clean on every build since ff9c335.


To save a re-read, the state of this PR:

  • 93ac734 replaces the invented 256 KiB threshold with the kernel's own signal, per your review. write(2) returns EAGAINtry_write yields WriteResult::Pending → the writer reports WriteStatus::Pending, which it emits only on the refused-write paths. STREAM_BACKPRESSURE_HIGH_WATER_MARK and buffered_size() are deleted.
  • The writer now holds at most the one chunk the destination refused, down from 256 KiB.
  • Measured free when the consumer keeps up: 64 MB through cat, same build, 461 MB/s with backpressure vs 444 MB/s without.

I've used my one re-trigger (02ea04d9) and won't push another empty commit. Ready for a re-look whenever you have a minute.

robobun added 5 commits July 7, 2026 03:04
FileSink accepted every chunk the readStreamIntoSink pump handed it, buffering
whatever the destination would not take. With a synchronous pull() the pump's
read/write cycle never leaves the microtask queue, so the event loop stopped
servicing timers and IO while the buffer grew without bound.

FileSink now reports Writable::Backpressure once more than 256 KiB is sitting
unflushed in the writer, which the pump already handles by awaiting
flush(true). Only a sink being fed by a stream pump reports it: the negative
sentinel is private to that pump, so Bun.file().writer() and proc.stdin keep
write()'s number-or-Promise contract.

Two related defects in the pump:

- A rejected write promise (EPIPE once the child closes its stdin) was
  markAsHandled'd and the pump read on into a dead sink. It now aborts.
- Both pumps cancelled the source through ReadableStream.prototype.cancel while
  holding the stream's reader, so the lock check rejected every call and the
  source's cancel() never ran. Use the ReadableStreamCancel abstract op, as
  pipeTo does.
Backpressure parks the pump on `sink.flush(true)`. Resolving that promise happens
inside `FileSink::on_write` via `run_pending`, which drains microtasks: the pump
resumes there, writes the stream's last chunks and calls `end()`, all before
`on_write` continues. `on_write` then saw `done && Drained` from the snapshot it
took before the re-entry and called `writer.end()`, which on POSIX closes the fd
immediately, discarding the bytes that had just been buffered. The child's stdin
was truncated (1 MiB sent, 911168 bytes delivered).

Re-read `has_pending_data` after `run_pending` instead of trusting the snapshot.

Chunks larger than the high-water mark make every other write park, so a stream
of an odd number of them reliably lands a chunk in the buffer as the stream
closes; the new test pins that.
The negative `write()` return is private to `readStreamIntoSink`, which is safe
only because a sink fed by a ReadableStream has no JS handle: spawn caches
`proc.stdin` as the stream itself rather than exposing the FileSink. Nothing
pinned that, so a change to the `stdin` getter would silently leak the sentinel
into user code.

Add a test for it, and say so at `write_result` instead of implying `proc.stdin`
is a FileSink that happens to have no stream attached.
The 256 KiB high-water mark was invented, not measured. The kernel already says
when the destination is full: `write(2)` returns EAGAIN, which `try_write`
surfaces as `WriteResult::Pending` and reports to the parent as
`WriteStatus::Pending`. Track that and gate backpressure on it.

`has_pending_data()` is not the same signal: it's also true while writes below
CHUNK_SIZE coalesce in a buffer the kernel hasn't been shown yet. On Windows
`uv_write` is always async, so a write in flight means nothing; the equivalent
is `process_send` finding one already outstanding and leaving the bytes in
`outgoing`.

The writer now holds at most the one chunk the destination refused, rather than
256 KiB. Costs nothing when the consumer keeps up: 64 MB through `cat` measures
461 MB/s with backpressure and 444 MB/s without, i.e. the same. A fast reader
drains the pipe, the write completes, and the pump never parks.

Both child-dies-mid-write fixtures killed on the second chunk. The pump now
parks on the first refused write, so no second chunk is ever produced: kill on
the first, from a macrotask, which lands after the microtask that issued the
write.
@robobun
robobun force-pushed the farm/e7d16322/spawn-stdin-stream-backpressure branch from 93ac734 to 2d34e88 Compare July 7, 2026 03:22
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at 3f67971 (2d34e880).

The conflicts were with #33538, which independently added buffered_len() to both streaming writers and gave FileSink::to_result an accepted: u64 argument — the same surface this PR touches. Resolved by:

  • keeping main's buffered_len() (it's now used by bytes_accepted) and adding is_backed_up() alongside it rather than replacing it;
  • keeping main's accepted-byte computation in write/write_latin1/write_utf16 — it measures the post-transcode buffer delta, which is more accurate than the input length this PR originally used — and routing it through write_result(rc, accepted);
  • write_result now takes accepted: u64 and forwards it to to_result.

Post-rebase: 30 pass / 0 fail on spawn-stdin-readable-stream.test.ts, both original repros fixed (5 pulls / flat RSS; cancel() fires with EPIPE), fail-before still holds on released 1.4.0, clippy clean, rust:check-all green on all 10 targets.


CI on the rebased sha (#69612, final): 280 test jobs passed; 0 test failures. The 6 red lanes are all macOS infrastructure: 2 × darwin 26 aarch64 with buildkite-agent artifact download timed out after 120s, 4 × darwin 14 that expired waiting for an agent. None of them ran a test. Same infra pattern as builds 68726, 68743 and 68795 on this PR. spawn-stdin-readable-stream.test.ts is clean, as on every build since ff9c335. The one flaky annotation (bunshell.test.ts on Windows) passed on retry.

My one re-trigger is spent and re-rolling won't fix a macOS agent pool, so I'm not pushing another empty commit. Needs a maintainer to re-run the macOS lanes or merge over them.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this since #36087 (fetch/S3: replace ResumableSink with proper JSSinks) merged and covers the same ground. Thank you @robobun for the PR — if there's a piece of this that #36087 didn't pick up, please say so and we'll take another look.

(This comment was written by Claude, on behalf of the Bun team.)

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the heads-up — I checked against current main (f91d5c95c, after #36087). Most of it is covered; one piece isn't.

Covered by #36087

The headline bug is fixed. A synchronous pull() into a child that never reads now parks on EAGAIN, the event loop runs, and RSS stays flat — same mechanism (is_backed_up() from the streaming writer) and same numbers as this PR. All four of my tests pass against a build of main.

Not covered: child exits → EPIPE → pump never stops

The original report's exact shape — a synchronous pull() into ["head", "-c", "10"] — still reads the source forever and never calls cancel() on main:

const chunk = Buffer.alloc(64 * 1024, "x");
let pulls = 0;
const rs = new ReadableStream({
  pull(c) { if (++pulls > 500) { console.log("never bounded"); process.exit(1); } c.enqueue(chunk); },
  cancel: () => console.log("cancelled"),
});
await Bun.spawn({ cmd: ["head", "-c", "10"], stdin: rs, stdout: "ignore" }).exited;

On main: never bounded at 501 pulls. On this PR's branch: cancelled at 3 pulls.

It's specific to how the child closes its end. Verified across five shapes on main:

child result on main
head -c 10 reads forever, never cancels
true (exits, never reads) reads forever, never cancels
sh -c "head -c 10 >/dev/null" cancelled at 5 pulls
bun -e 'process.stdin.once("data", () => process.exit(0))' cancelled at 12 pulls
bun -e 'process.exit(0)' cancelled at 5 pulls

So orderly close works; a direct-exec child that dies holding the pipe (→ EPIPE on the next write) does not. Main's rsisWriteChunk now does

shouldSuspend = wrotePromise->status() == JSPromise::Status::Pending;

which means a Rejected write promise neither suspends nor aborts — the pump keeps reading into a dead sink. That's what this PR's BunStreamSource.cpp hunk handled: abort the pump (and cancel the source through ReadableStreamCancel, not the public cancel() whose lock check always rejects while the pump holds the reader).

My tests used a bun child, so they pass on main and aren't a guard for this. The head -c 10 case is the one from the original report.

Happy to open a small focused PR with just that piece: the rejected-write abort in rsisWriteChunk, the lock-check drop in publicStreamCancelIgnoringResult, and a test that spawns head -c N directly so it stays pinned. Say the word and I'll put it up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants