Skip to content

Bun.serve: flush buffered bytes when a direct stream's sync pull() calls controller.close() - #34334

Closed
robobun wants to merge 3 commits into
mainfrom
claude/farm/d2e1e944/direct-stream-sync-close
Closed

robobun wants to merge 3 commits into
mainfrom
claude/farm/d2e1e944/direct-stream-sync-close

Conversation

@robobun

@robobun robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

const s = Bun.serve({
  port: 0,
  fetch() {
    return new Response(new ReadableStream({
      type: "direct",
      pull(c) {
        c.write("hello world");
        c.close();
      },
    }));
  },
});
const r = await fetch(s.url);
console.log(r.status, JSON.stringify(await r.text()));
s.stop(true);
// before: 200 ""
// after:  200 "hello world"

Since #32140, a type: "direct" ReadableStream whose synchronous pull() returns without closing keeps the response open until controller.end() (react-dom/server.bun's renderToReadableStream depends on this to stream Suspense boundaries after pull() returns). That made the example at docs/runtime/streams.mdx:62-68, which writes and returns without calling close(), pin the connection until idleTimeout and surface ECONNRESET to the client. Adding the controller.close() the standard ReadableStream controller API expects then hit the bug above and still delivered zero bytes for writes below the auto-flush threshold, so there was no working shape for the documented synchronous case.

Cause

controller.close() on the sink controller dispatches via the generated ${controller}__close, which always called ${name}__close -> JSSink::js_close -> HTTPServerWritable::end(None). With buffered bytes, that path set requested_end = true and returned without flushing; the readable_len > 0 branch was a no-op. Back in readDirectStream the stream is already Closed, so it returns jsUndefined(), and do_render_stream's no-promise fallback runs mark_done() immediately before finalize(), so finalize's flush_no_wait() branch is skipped, destroy_sink frees the buffer with the bytes still in it, and render_missing() ends the response empty.

controller.end() took the sibling path (${name}__endWithSink -> end_from_js), which flushes via send_readable(0) and parks a pending_flush on backpressure, so it always delivered the full body.

Fix

The generated ${controller}__close now routes on its argument: close() with no argument is normal completion and calls ${name}__endWithSink (flush buffered bytes, terminate, park a pending_flush on backpressure for do_render_stream to wait on), matching controller.end(). close(error) is the readStreamIntoSink abrupt path (rsisSinkClose(op, error) invokes .close(error) when a default ReadableStream body errors) and keeps the existing ${name}__close call so the sink is torn down without a clean terminator and handle_reject_stream force-closes the connection per RFC 9112 section 7.

The docs example is updated to call controller.close() and notes that returning without closing leaves the destination open for captured-controller writes (the React SSR pattern). Reverting to the 1.3.x auto-close on synchronous return would re-break #32137, so the #32140 contract is kept.

The no-promise fallback in RequestContext.rs is left in place: it is still the residual catch-all when readDirectStream returns jsUndefined() and is not dead after #32140.

Tests

New cases in test/js/bun/http/serve-direct-readable-stream.test.ts:

  • sync pull() that writes N bytes and calls close() for N in {1, 11, 2048, 200000} over HTTP and HTTPS. The 1- and 11-byte cases deliver "" on the unfixed build; 2048 and 200000 happened to work because write() auto-flushed past highWaterMark, and stay as sibling coverage.
  • sync pull() that calls close() with nothing written ends the response.

The existing React-pattern tests (sync pull() that ends later ..., cancel() fires ..., the h3 backpressure cases, and the AsyncLocalStorage leak test) all still pass. serve-stream-body-error.test.ts covers the close(error) abort path (mid-stream error on a default ReadableStream body must not send a clean chunked terminator), and bake/dev/react-response.test.ts and direct-readable-stream.test.tsx exercise react-dom's server.bun build end to end.


[review] gate passed · iteration 0 · 6 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/serve-direct-readable-stream.test.ts test/js/bun/http/serve-stream-body-error.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (699fcebc0)

test/js/bun/http/serve-direct-readable-stream.test.ts:
(pass) HTTPResponseSink displays correct message [86.89ms]
(pass) controller.end() after pull() resolved does not use the sink after free [894.04ms]
(pass) client disconnect after controller.end() with a parked pull() does not use the socket after free [2428.51ms]
(pass) client disconnect after controller.end() with a parked rejecting pull() does not use the socket after free [2389.59ms]
(pass) h3: controller.end() from a parked pull() disarms onAborted before the context is released [1237.07ms]
(pass) sync pull() throw after status is written does not re-render error() > body bytes already flushed:
... (truncated)

release without fix: 1 failed, 5 skipped
bun test v1.4.0-canary.1 (3cbdbea7c)

test/js/bun/http/serve-direct-readable-stream.test.ts:
(pass) HTTPResponseSink displays correct message [13.18ms]
(skip) controller.end() after pull() resolved does not use the sink after free
(skip) client disconnect after controller.end() with a parked pull() does not use the socket after free
(skip) client disconnect after controller.end() with a parked rejecting pull() does not use the socket after free
(skip) h3: controller.end() from a parked pull() disarms onAborted before the context is released
(pass) sync pull() throw after status is written does not re-render error() > body bytes already flushed: connection is force-closed [44.04ms]
(pass) sync pull() throw after status is written does not re-render error() > no body bytes flushed: stream is ended without splicing error() headers [44.54ms]
(pass) sync pull() that ends later streams the whole body [2.18ms]
(pass) sync pull() that writes 1 bytes and calls close() > delivers the full body [1.45ms]
(pass) sync pull() that writes 1 bytes and calls close() > delivers the full body over TLS [16.34ms]
(pass) sync pull() that writes 11 bytes and calls close() > delivers the fu
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/serve-direct-readable-stream.test.ts test/js/bun/http/serve-stream-body-error.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (699fcebc0)

test/js/bun/http/serve-direct-readable-stream.test.ts:
(pass) HTTPResponseSink displays correct message [82.95ms]
(pass) controller.end() after pull() resolved does not use the sink after free [855.60ms]
(pass) client disconnect after controller.end() with a parked pull() does not use the socket after free [2368.19ms]
(pass) client disconnect after controller.end() with a parked rejecting pull() does not use the socket after free [2352.34ms]
(pass) h3: controller.end() from a parked pull() disarms onAborted before the context is released [1122.41ms]
(pass) sync pull() throw after status is written does not re-render error() > body bytes already flushed:
... (truncated)

release with fix: 5 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 1079ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/91] gen ErrorCode+*.h
[2/50] gen cpp.rs (cppbind)
[3/50] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[4/50] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Fou
... (truncated)
diff hotspot
docs/runtime/streams.mdx                           |  3 +
 src/codegen/generate-jssink.ts                     | 12 +++-
 src/runtime/webcore/streams.rs                     |  3 +-
 .../bun/http/serve-direct-readable-stream.test.ts  | 76 ++++++++++++++++++++++
 .../js/bun/http/serve-stream-body-error-fixture.ts | 14 ++++
 test/js/bun/http/serve-stream-body-error.test.ts   | 19 ++++++
 6 files changed, 125 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                   reads  edits  tests
docs/runtime/streams.mdx                                   1      1      0
src/codegen/generate-jssink.ts                             2      3      0
src/runtime/webcore/streams.rs                             5      3      0
test/js/bun/http/serve-direct-readable-stream.test.ts      1      1      0
test/js/bun/http/serve-stream-body-error-fixture.ts        1      1      0
test/js/bun/http/serve-stream-body-error.test.ts           1      1      0

…lls controller.close()

A type: "direct" ReadableStream whose sync pull() writes and calls
controller.close() delivered a 200 with an empty body: close() reached
HTTPServerWritable via js_close -> end(None), which set requested_end
and returned without touching the buffer when readable_len > 0. The
no-promise fallback in do_render_stream then mark_done()'d before
finalize(), so the buffered bytes were freed unsent and render_missing
ended the response empty.

Since #32140 a sync pull() that returns without closing keeps the
response open waiting for end() (react-dom/server.bun depends on this),
so the docs/runtime/streams.mdx example

    pull(controller) {
      controller.write("hello");
      controller.write("world");
    }

now pins the connection until idleTimeout and the client sees
ECONNRESET. Adding the close() the standard controller API expects then
hit the bug above and still delivered zero bytes.

end(None) now delegates to end_from_js: the buffer is flushed via
send_readable (parking a pending_flush on backpressure, which
do_render_stream already waits for) and the response terminates,
matching controller.end(). The err=Some branch keeps the abort-path
semantics (no partial flush). The docs example is updated to call
controller.close() and notes that returning without closing leaves the
destination open for captured-controller writes.

The no-promise fallback in RequestContext.rs is not deleted: it remains
the residual catch-all for a sync pull() whose close()/end() neither
responded nor parked a flush, and for a direct stream with no pull.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8f1236d3-a9b5-4d4d-b31d-bc0d6799edd8

📥 Commits

Reviewing files that changed from the base of the PR and between 4bbe075 and 699fceb.

📒 Files selected for processing (6)
  • docs/runtime/streams.mdx
  • src/codegen/generate-jssink.ts
  • src/runtime/webcore/streams.rs
  • test/js/bun/http/serve-direct-readable-stream.test.ts
  • test/js/bun/http/serve-stream-body-error-fixture.ts
  • test/js/bun/http/serve-stream-body-error.test.ts

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

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Verified locally:

sync write+close         200 len 11 "hello world"   (was: 200 len 0 "")
sync write+end           200 len 11 "hello world"
sync write+flush+close   200 len 11 "hello world"
async write+close        200 len 11 "hello world"
async write+end          200 len 11 "hello world"

699fceb: routing at the controller's close() is gated on argumentCount() == 0 (normal close from a direct stream) vs >= 1 (rsisSinkClose / NativeSink cancel always pass one, even when the reason is undefined), so a mid-stream controller.error() with no argument still force-closes without a clean chunked terminator.

CI 73832: 284/286 jobs passed. The two red lanes are test/js/node/test/parallel/test-net-connect-memleak.js on alpine x64/x64-baseline, which is pre-existing on main (GC-timing assertion, unrelated to this diff) and already being handled separately. Everything this diff touches (serve-direct-readable-stream, serve-stream-body-error, bake/dev/react-response, direct-readable-stream, body-stream) is green. Ready for review.

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:09 AM PT - Jul 16th, 2026

❌ @robobun, your commit 699fceb has 1 failures in Build #73832 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34334

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

bun-34334 --bun

Comment thread src/runtime/webcore/streams.rs Outdated
…-flush abort path

The previous commit made HTTPServerWritable::end(None) delegate to
end_from_js, but js_close reaches end(None) for BOTH a user's
controller.close() (normal completion) and readStreamIntoSink's abrupt
path (rsisSinkClose(op, error) -> sink.close(error) -> the 0-arg
controller close slot). That turned a mid-stream error on a default
ReadableStream body into a cleanly-terminated 200, breaking
serve-stream-body-error.test.ts and bake's react-response.test.ts.

Route at the controller's close() instead: no argument -> endWithSink
(flush + terminate, matching end()); an error argument -> the existing
__close (abort, no flush). HTTPServerWritable::end stays the abort-only
path. rsisSinkClose passes the error, a direct stream's
controller.close() passes nothing, so both callers get the behaviour
they need.
Comment thread src/codegen/generate-jssink.ts Outdated
controller.error() (or throw undefined, or a rejection with undefined)
reaches rsisSinkClose with reason undefined, which it still forwards as
exactly one argument. The isUndefinedOrNull() check treated that as a
normal close and cleanly terminated the response. Gate on
argumentCount() == 0 instead: rsisSinkClose and the NativeSink cancel
in ReadableStreamOperations always append one argument, a direct
stream's controller.close() passes none.

Adds a mid-stream-nullish-error variant to the serve-stream-body-error
fixture asserting no clean chunked terminator.

@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.

The two earlier concerns are addressed — 699fceb gates on callFrame->argumentCount() == 0, and I verified both internal callers (rsisSinkClose at BunStreamSource.cpp:968-971 and the NativeSink cancel at ReadableStreamOperations.cpp:420-423) unconditionally append exactly one argument, so the abort path is preserved even for nullish reasons. Deferring for a human look because the routing change fans out to all six generated sink controllers (ArrayBufferSink/FileSink/NetworkSink included) and shifts controller.close() from end(None) to end_from_js for each — worth a maintainer confirming that's the intended contract across the non-HTTP sinks.

Checked: the argumentCount() == 0 gate against every C++ call site of the controller's close; ${controller}__close still discards endWithSink's return value so close()'s JS return stays undefined; the new mid-stream-nullish-error fixture pins the RFC 9112 §7 force-close for controller.error()-with-no-arg.

Extended reasoning...

Overview

The PR fixes a regression where a direct ReadableStream whose synchronous pull() calls controller.write(...) then controller.close() delivers an empty body over Bun.serve. The fix lives in src/codegen/generate-jssink.ts: the generated ${controller}__close host fn now branches on callFrame->argumentCount(), routing zero-arg close() to ${name}__endWithSink (flush buffered bytes + clean terminator) and any-arg close(reason) to the existing ${name}__close abort path. src/runtime/webcore/streams.rs only gains a doc comment; docs/runtime/streams.mdx is updated to teach controller.close(); three test files add coverage for both the flush fix and the nullish-error abort case.

Review history on this PR

I flagged two issues on earlier revisions, both fixed:

  1. The initial end(None) → end_from_js delegation applied to the readStreamIntoSink error path too, defeating RFC 9112 §7 force-close. Fixed in 3cbdbea by moving the routing to the generated controller close().
  2. The isUndefinedOrNull() gate in 3cbdbea couldn't distinguish close() from close(undefined), so a stream erroring with a nullish reason still hit the flush path. Fixed in 699fceb by gating on argumentCount() == 0, with a new mid-stream-nullish-error regression test.

For 699fceb I verified the invariant the gate depends on: rsisSinkClose (BunStreamSource.cpp:968-971) and the ControllerKind::NativeSink cancel arm (ReadableStreamOperations.cpp:420-423) both do args.append(...) unconditionally before invoking .close, so argumentCount() is always ≥1 on every internal abort/cancel route regardless of the reason value. A grep for other .close invocations on the sink controller found none. The JSDirectStreamController.cpp:392 .close(error) call targets the sink prototype (${name}__doClose), not the controller, so it's unaffected.

Security risks

The RFC 9112 §7 behavior (force-close without a chunked terminator on mid-stream failure) is correctness-adjacent to security: a client that receives a cleanly-terminated truncated body can't detect the truncation. Both prior review rounds were about preserving exactly this. The final gate does, and the new test asserts it for the nullish-reason edge.

Level of scrutiny

High. This is generated code that fans out to all six sink types (ArrayBufferSink, FileSink, HTTP{,S,3}ResponseSink, NetworkSink) on the hot HTTP-response-streaming path, and it took three revisions to get the abort-path preservation right. Not a mechanical change.

Other factors / why defer

  • The new tests cover the HTTP/HTTPS sinks and the readStreamIntoSink abort path well. They do not exercise controller.close() on FileSink/NetworkSink/ArrayBufferSink controllers, which now also route through end_from_js instead of end(None). For NetworkSink that means end_from_js may now arm end_promise where end(None) didn't; the return value is discarded so JS-visible behavior is likely unchanged, but a maintainer should confirm the contract.
  • User code that spells controller.close(undefined) still hits the no-flush path (argumentCount 1). That's not a regression — it was already broken — and matches neither the docs example nor the standard controller signature, so it seems acceptable to leave.
  • Two rounds of substantive fixes on a ~12-line generated-code change is enough churn that human sign-off is warranted before merge.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

On the fan-out to non-HTTP sinks: the generated controller is only ever constructed via *__assignToStream, and grepping for call sites shows:

  • ArrayBufferSink__assignToStream: no callers (header declaration + generated definition only). JSReadableArrayBufferSinkController is dead code.
  • NetworkSink__assignToStream: no callers. Same.
  • FileSink__assignToStream: called from Blob.rs (Bun.write(file, stream)) and subprocess/Writable.rs (stdin from a ReadableStream). FileSink::end_from_js and FileSink::end both writer.flush() then writer.end(); the only difference is the return value (byte count or a pending promise), which ${controller}__close discards. Observable behavior is unchanged.
  • HTTP/HTTPS/H3 sinks: the target of this PR, covered by the new tests.

So the only live non-HTTP controller is FileSink, and its close() semantics are unchanged.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

This PR now conflicts with main (the surrounding HTTPServerWritable code changed in #36006 and #36943).

The same bug came up again, and there is an alternative fix on the branch farm/1e7b0445/http-sink-close-flushes-tail (735f975). Instead of routing the controller's close() to endWithSink in the generated C++ based on the argument count, it makes HTTPServerWritable::end() send the buffered tail itself the way end_from_js() does (try_end / end, or park pending_flush on backpressure), and removes the now unused requested_end branch of on_auto_flush(). That branch also covers the HTTP/3 variant, where the buffered tail was lost even from an async pull() because the deferred try_end hit backpressure with nothing parked for the request to wait on.

Only one of the two should land; leaving the choice to a maintainer rather than opening a second PR.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #37696, which fixes the same controller.close() tail loss against current main (this branch has conflicted since #36006 / #36943). The coverage this PR had that #37696 lacked (the TLS runs and the close() with nothing written case) has been added to #37696.

@robobun robobun closed this Aug 13, 2026
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.

1 participant