Wait for end() when a direct stream's pull() returns synchronously in Bun.serve#32140
Conversation
|
Updated 4:01 PM PT - Jun 23rd, 2026
✅ @Jarred-Sumner, your commit cda06ffa25c6b3f14f61d48b9433e26a31317b67 passed in 🧪 To try this PR locally: bunx bun-pr 32140That installs a local version of the PR into your bun-32140 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughDirect ReadableStream pull() now signals closure via a returned closePromiseCapability so native sinks await controller.end(). RequestContext defers sink teardown while pending flushes resolve, and generated sink detach() invokes onClose via AsyncContextFrame. Tests validate direct-stream sync pull, cancellation, h3 backpressure, and no leak under AsyncLocalStorage. ChangesDirect ReadableStream synchronous pull-to-close coordination
Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/js/web/fetch/body-stream.test.ts (1)
57-79:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReplace timer-based stream completion with condition-driven completion.
Line 66 uses
setTimeout(..., 100)to end the direct stream. This keeps the test timing-sensitive and violates the test rule for non-flaky async coordination.Suggested change
pull(controller) { controller.write("hey"); - setTimeout(() => { - controller.end(); - }, 100); + queueMicrotask(() => { + controller.end(); + }); },As per coding guidelines, tests must not use
setTimeout; they should await/trigger concrete completion conditions instead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/js/web/fetch/body-stream.test.ts` around lines 57 - 79, Replace the timer-based stream completion inside the test "Should not crash when not returning a promise when stream is in progress": instead of calling setTimeout(() => controller.end(), 100) in the ReadableStream pull, use a concrete signal (e.g., a Promise or resolve function captured in the surrounding scope, or an event you can await) that you explicitly resolve when ready; update the fetch() test setup (the ReadableStream pull and the controller.end call) so the test triggers controller.end via that resolved signal before asserting on fetch(url).then(res => res.text()), ensuring deterministic completion without setTimeout.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@test/js/web/fetch/body-stream.test.ts`:
- Around line 57-79: Replace the timer-based stream completion inside the test
"Should not crash when not returning a promise when stream is in progress":
instead of calling setTimeout(() => controller.end(), 100) in the ReadableStream
pull, use a concrete signal (e.g., a Promise or resolve function captured in the
surrounding scope, or an event you can await) that you explicitly resolve when
ready; update the fetch() test setup (the ReadableStream pull and the
controller.end call) so the test triggers controller.end via that resolved
signal before asserting on fetch(url).then(res => res.text()), ensuring
deterministic completion without setTimeout.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 496e5055-efec-4d8c-a57d-194044c0778e
📒 Files selected for processing (3)
src/js/builtins/ReadableStreamInternals.tstest/js/bun/http/serve-direct-readable-stream.test.tstest/js/web/fetch/body-stream.test.ts
|
Checked all three against this branch before adding any closing keywords; none of them are fixed by this PR, so I'm not adding the block:
|
|
Status: the branch is complete and verified. The three test files in this PR pass with the fix (9094 tests across serve-direct-readable-stream, serve-response-stream-sink-leak, and body-stream) and fail without it (9 failures, matching the expected fail-before behavior). The automated pre-merge check for this branch has been failing to build for reasons unrelated to the diff: its build workspace keeps picking up stray files from other work (a src/runtime/bake/source_provider_exports.rs that duplicates HOST_EXPORT symbols from src/jsc/virtual_machine_exports.rs). None of those files are part of this PR. Buildkite CI builds the same revision cleanly. |
There was a problem hiding this comment.
Thanks for addressing both issues — the AsyncContextFrame fix and the leak fixture rework look right. This still touches request-context refcounting, sink lifecycle, and the h3 backpressure path across three layers (JS builtins, codegen, RequestContext.rs), so I'd like a human to sign off; also note Build #61975 is showing failures.
Extended reasoning...
Overview
This PR changes how Bun.serve handles a type: "direct" ReadableStream whose pull() returns synchronously without ending the sink — the shape react-dom/server.bun's renderToReadableStream uses for Suspense streaming. Three coordinated changes:
ReadableStreamInternals.ts:readDirectStreamnow returns a close-promise capability when syncpull()leaves the stream readable, so native consumers wait forend()instead of finalizing immediately.RequestContext.rs(handle_resolve_stream): ifendFromJS()parked apending_flush(h3 backpressure right after HEADERS), defer sink teardown —ref_(), wrap the flush in aNativePromiseContext, attachON_RESOLVE_STREAM/ON_REJECT_STREAM, and re-enter when it settles.generate-jssink.ts(detach()): invokem_onCloseviaAsyncContextFrame::callso the close callback fires when the stream was constructed underAsyncLocalStorage.run()(previouslygetCallDatareported the wrapped frame as not callable and silently skipped it, leaking the request).
Tests cover progressive streaming, empty-shell-then-write, client-disconnect cancel, h3 backpressure (async pull ending sync, sync pull ending from a microtask), an ALS heapStats leak regression, and the existing 10k-request sink-leak fixture retargeted at the new path. body-stream.test.ts was tightened from toBeOneOf(["hey", ""]) to toBe("hey").
Prior review
In the previous run I flagged two issues, both now resolved (commit 4342087):
- ALS-wrapped streams leaked because
detach()couldn't call anAsyncContextFrame— fixed by routing throughAsyncContextFrame::call, with a heapStats regression test. serve-response-stream-sink-leak-fixture.tsrelied on the old truncating fallback and would hang — fixture now ends each stream from the client side and still asserts flat memory.
The bug-hunting system found nothing new this round.
Security risks
None apparent. No auth, crypto, parsing of untrusted input, or permission surfaces are touched. The change is internal stream-completion plumbing. The main risk class is correctness/availability: a mis-balanced ref_() or a never-settling close promise would leak/hang requests, and a premature teardown on the new pending_flush re-entry path could truncate h3 responses. Those are reliability concerns, not security exposures.
Level of scrutiny
High. This is core HTTP server request lifecycle:
- Adds a
req.ref_()inhandle_resolve_streamthat must be balanced by the re-entered resolve/reject handler in every code path (including abort racing the parked flush). - Changes when the heap
ResponseStreamJSSinkis destroyed. - Alters generated C++ that runs for every sink controller (all six sink classes).
- Changes the contract of
readDirectStream's return value, which other native consumers (e.g.FileSink) also depend on.
The interactions between abort, h3 backpressure, ALS context wrapping, and the new close-promise are subtle enough that I'm not comfortable approving without a human pass.
Other factors
- CI: robobun reports failures in Build #61975 on the latest commit (bdc63ca, the autofix-applied version of 4342087). Worth checking whether those are related before merge.
- Test coverage is good and targeted; each of the three fixes has a test that fails without it.
- The
body-stream.test.tschange is inside adescribe.eachover http/1.1 and http/3 with a sharedpulled/controllercapture; the test only fetches once per row so the module-scope capture is fine, but it's the kind of thing a reviewer should glance at. - No CODEOWNERS file in the repo, so no ownership gate.
|
Re the CI note: Build #61975 was canceled mid-run when it was superseded by the current head (75d6a8c), so its red jobs are cancellations, not failures. Its only test annotation was serve-body-leak.test.ts on debian 13 x64-asan, which passed on retry and is buildkite-classified as flaky; that test exercises request-body upload buffering, not the response-stream path this PR touches. Build #61981 on the current head is green so far: 70 checks passed, 0 failed, with the two darwin test lanes still finishing. |
There was a problem hiding this comment.
Both earlier findings are addressed and I didn't spot anything new, but this changes request-lifecycle semantics across the JS builtins, the Rust request context refcounting/teardown, and the generated C++ sink — worth a human pass before merge (and Build #61975 still shows failures).
Extended reasoning...
Overview
This PR changes how Bun.serve handles a type: "direct" ReadableStream whose pull() returns synchronously without ending the sink. It spans three layers: readDirectStream in ReadableStreamInternals.ts now returns a close-promise capability instead of undefined; handle_resolve_stream in RequestContext.rs defers sink teardown while a pending_flush is parked (with a new ref_() and re-entry through ON_RESOLVE_STREAM/ON_REJECT_STREAM); and the codegen'd controller detach() now invokes m_onClose via AsyncContextFrame::call so an ALS-wrapped callback actually fires. Tests cover the React-shaped scenario, client-disconnect cancel, h3 backpressure, an ALS leak regression, and the existing 10k-request sink-leak fixture was retargeted to the new path.
Security risks
None apparent — no auth, parsing, or untrusted-input handling is touched. The risk profile here is correctness/leaks (unbalanced ref_(), never-settling promises, sinks not destroyed) rather than security.
Level of scrutiny
High. This is a behavior change on the hot HTTP response path, touching request-context refcounting and sink lifetime across JS builtins, Rust, and generated C++. Two real bugs (the ALS detach() skip and the broken sink-leak fixture) were already found and fixed during review, which is a good sign for test coverage but also confirms the change is subtle. The new pending_flush re-entry in handle_resolve_stream adds another ref/promise edge to the teardown graph and deserves eyes from someone who owns that code.
Other factors
My two prior inline findings are resolved with regression tests in 4342087. The bug-hunting pass on the current revision found nothing. However, robobun reports failures in Build #61975 for the latest commit; the author attributes them to an unrelated stale-workspace issue, but that should be confirmed green before merge. The semantic shift (sync pull() without end() now holds the response open instead of truncating) is intentional and well-argued, but it's exactly the kind of contract change a maintainer should sign off on.
|
Build 61981 finished with 4 annotations, none related to this diff:
The three test files this PR adds/changes passed on every lane. The diff is ready for review. |
75d6a8c to
3d18f13
Compare
|
Rebased onto current main and squashed to a single commit (3d18f13). The conflicts were non-trivial because two PRs merged since this branch was opened touch the same code:
Verification on the rebased base: all 8 tests in serve-direct-readable-stream.test.ts pass with the fix (including #32597's UAF test); 5 of them fail without the src changes. serve-response-stream-sink-leak + body-stream (9087 tests) pass. #32553's own backpressure tests in serve.test.ts pass. |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/codegen/generate-jssink.ts`:
- Around line 716-728: In the block where readableStream and onClose are checked
before calling AsyncContextFrame::call, clear the m_onClose member variable
before invoking the callback. After obtaining the onClose value via
m_onClose.get(), reset m_onClose to null (or use whatever method clears this
member) before calling AsyncContextFrame::call with the onClose parameter. This
prevents re-entrancy issues where the callback can re-enter detach() and cause
duplicate close/cancel callbacks, following the one-shot callback pattern of
copying the value, clearing the stored state, then invoking it.
- Around line 470-476: Protect the pending exception value before scope clearing
and detach() to prevent dangling pointer issues. In the exception handling block
where JSC::Exception* pending is captured, create a JSC::Strong<JSC::Unknown> to
protect pending->value() before calling scope.tryClearException(). After
controller->detach() completes, rethrow the exception using the protected Strong
reference instead of the raw pointer. Apply this same fix to the identical
exception handling pattern in the __end function (around lines 517-523).
In `@src/js/builtins/ReadableStreamInternals.ts`:
- Around line 951-952: The backpressure check at lines 951-952 and similar
locations coerces the result of sink.write() to a number using the comparison
operator, which can trigger user-overridable coercion hooks when the result is a
Promise. Store the return value of sink.write(values[i]) and controller.write()
calls in a variable, then check if it is a number before performing the
less-than comparison with 0, avoiding implicit coercion. Apply this fix to all
affected backpressure checks at the mentioned line ranges: 951-952, 973-974, and
1912-1915.
- Around line 1043-1048: The property lookup for underlyingSource.close is
occurring outside the try-catch block, but since close can be a user-defined
getter that may throw, this allows errors to bypass the exception handler and
skip necessary cleanup operations. Move the underlyingSource.close property
lookup inside the try block so that any getter execution is caught and handled
properly. Apply this same fix to both occurrences in the file (the initial
location and the secondary location around lines 1202-1207).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 07e93ea9-7c9e-4550-8440-55004e7b4720
📒 Files selected for processing (2)
src/codegen/generate-jssink.tssrc/js/builtins/ReadableStreamInternals.ts
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/codegen/generate-jssink.ts`:
- Around line 716-728: In the block where readableStream and onClose are checked
before calling AsyncContextFrame::call, clear the m_onClose member variable
before invoking the callback. After obtaining the onClose value via
m_onClose.get(), reset m_onClose to null (or use whatever method clears this
member) before calling AsyncContextFrame::call with the onClose parameter. This
prevents re-entrancy issues where the callback can re-enter detach() and cause
duplicate close/cancel callbacks, following the one-shot callback pattern of
copying the value, clearing the stored state, then invoking it.
- Around line 470-476: Protect the pending exception value before scope clearing
and detach() to prevent dangling pointer issues. In the exception handling block
where JSC::Exception* pending is captured, create a JSC::Strong<JSC::Unknown> to
protect pending->value() before calling scope.tryClearException(). After
controller->detach() completes, rethrow the exception using the protected Strong
reference instead of the raw pointer. Apply this same fix to the identical
exception handling pattern in the __end function (around lines 517-523).
In `@src/js/builtins/ReadableStreamInternals.ts`:
- Around line 951-952: The backpressure check at lines 951-952 and similar
locations coerces the result of sink.write() to a number using the comparison
operator, which can trigger user-overridable coercion hooks when the result is a
Promise. Store the return value of sink.write(values[i]) and controller.write()
calls in a variable, then check if it is a number before performing the
less-than comparison with 0, avoiding implicit coercion. Apply this fix to all
affected backpressure checks at the mentioned line ranges: 951-952, 973-974, and
1912-1915.
- Around line 1043-1048: The property lookup for underlyingSource.close is
occurring outside the try-catch block, but since close can be a user-defined
getter that may throw, this allows errors to bypass the exception handler and
skip necessary cleanup operations. Move the underlyingSource.close property
lookup inside the try block so that any getter execution is caught and handled
properly. Apply this same fix to both occurrences in the file (the initial
location and the secondary location around lines 1202-1207).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 07e93ea9-7c9e-4550-8440-55004e7b4720
📒 Files selected for processing (2)
src/codegen/generate-jssink.tssrc/js/builtins/ReadableStreamInternals.ts
🛑 Comments failed to post (4)
src/codegen/generate-jssink.ts (2)
470-476: 🩺 Stability & Availability | 🔴 Critical
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash # Description: Inspect existing JSC exception-preservation patterns before patching this generated path. # Expectation: find a local pattern that roots/protects exceptions or JSValues before clearing a ThrowScope # and running code that can execute JS. rg -n -C4 'scope\.exception\(\)|tryClearException\(\)|Strong<.*Exception|throwException\(.*pending|throwException\(.*exception' srcRepository: oven-sh/bun
Length of output: 50368
🏁 Script executed:
#!/bin/bash # Get the file size first to check if we can read it wc -l src/codegen/generate-jssink.ts # Read the relevant sections to understand the code structure echo "=== Lines 460-480 ===" sed -n '460,480p' src/codegen/generate-jssink.ts echo "=== Lines 510-530 ===" sed -n '510,530p' src/codegen/generate-jssink.tsRepository: oven-sh/bun
Length of output: 1776
🏁 Script executed:
#!/bin/bash # Search for how Strong<> is used with Exception in the JSC bindings rg -n 'Strong.*Exception|protect.*exception|exception.*protect' src/jsc --max-count 20 # Also look for how exceptions are preserved across JS-executing calls echo "=== Looking for exception preservation patterns ===" rg -n -B3 -A3 'exception.*Strong|Strong.*exception' src/jsc/bindingsRepository: oven-sh/bun
Length of output: 2001
🏁 Script executed:
#!/bin/bash # Search for how Strong<> is created and used in generated bindings rg -n 'Strong<.*>\(' src/jsc/bindings --max-count 10 # Look for JSC::Strong declaration and usage patterns rg -n 'JSC::Strong' src/jsc --max-count 5 # Search for exception rooting in similar detach/cleanup patterns rg -n -B5 -A5 'controller.*detach|detach.*exception' src/runtimeRepository: oven-sh/bun
Length of output: 17097
🏁 Script executed:
#!/bin/bash # Look at how exceptions are held and rethrown in similar patterns rg -n -B2 -A8 'auto.*exception.*scope\.exception|Exception.*scope\.exception' src/jsc/bindings | head -100 # Check if Strong<Exception> is used anywhere rg -n 'Strong.*Exception\*|Strong<.*Exception' src --max-count 20Repository: oven-sh/bun
Length of output: 8822
🏁 Script executed:
#!/bin/bash # Check if Exception::value() needs rooting or if Strong can protect the exception cell directly rg -n -B2 -A2 'exception.*value\(\)|Exception.*value()' src/jsc --max-count 20 # Look at what type Exception is and how it's stored rg -n 'class Exception|struct Exception' src/jsc --max-count 5 # See how Strong is used with exception values specifically rg -n -B3 -A3 'Strong.*exception\->value\(\)|Strong.*exception->value' src/jsc/bindingsRepository: oven-sh/bun
Length of output: 50367
🏁 Script executed:
#!/bin/bash # Check if this is a generated file head -20 src/codegen/generate-jssink.ts | grep -i 'generated\|autogen\|generated file' # Look at the actual code pattern used for exception rooting in similar contexts rg -n -B5 -A5 'Strong.*vm.*exception|Strong.*exception.*value\(\)' src/jsc/bindings | head -50Repository: oven-sh/bun
Length of output: 149
🏁 Script executed:
#!/bin/bash # Look for how to handle exceptions that need to survive JS-executing calls # Check if there are patterns of protecting exception values specifically rg -n -B3 -A8 'exception.*value\(\).*Strong|Strong.*exception.*value' src/jsc/bindings | head -60 # Also check what protect/ensure_still_alive does for exceptions rg -n 'ensure_still_alive.*exception|protection.*exception' srcRepository: oven-sh/bun
Length of output: 149
🏁 Script executed:
#!/bin/bash # Look at what the generated code looks like and understand the full context sed -n '450,490p' src/codegen/generate-jssink.ts # Check what controller->detach does and if it can run JS rg -n 'void.*detach\(' src/runtime/webcore --max-count 5Repository: oven-sh/bun
Length of output: 1855
Protect the pending exception value before
detach()runs user JS.The exception is extracted as a raw
JSC::Exception*, thenscope.tryClearException()removes the VM's exception root. At that point,controller->detach()can invokem_onClose(user code) and trigger GC, leaving the raw pointer dangling.Create a
JSC::Strong<JSC::Unknown>to hold the exception value before clearing the scope, then rethrow the protected value afterdetach()completes:if (JSC::Exception* pending = scope.exception()) [[unlikely]] { JSC::Strong<JSC::Unknown> protectedValue(vm, pending->value()); if (!scope.tryClearException()) { return {}; } controller->detach(); (void)scope.tryClearException(); scope.throwException(lexicalGlobalObject, protectedValue.get()); return {}; }Same fix applies to the identical pattern at lines 517–523 in the
__endfunction.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/codegen/generate-jssink.ts` around lines 470 - 476, Protect the pending exception value before scope clearing and detach() to prevent dangling pointer issues. In the exception handling block where JSC::Exception* pending is captured, create a JSC::Strong<JSC::Unknown> to protect pending->value() before calling scope.tryClearException(). After controller->detach() completes, rethrow the exception using the protected Strong reference instead of the raw pointer. Apply this same fix to the identical exception handling pattern in the __end function (around lines 517-523).Source: Coding guidelines
716-728: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Clear
m_onClosebefore invoking it.
AsyncContextFrame::callruns user JS, and the callback can re-enterdetach()whilem_onCloseis still set, causing duplicate close/cancel callbacks. Copy the callback, clear the stored one-shot state, then call it.🔁 Proposed fix
- auto readableStream = m_weakReadableStream.get(); - auto onClose = m_onClose.get(); + auto readableStream = m_weakReadableStream.get(); + JSC::JSValue onClose = m_onClose.get(); + m_onClose.clear(); + m_weakReadableStream.clear(); if (readableStream && onClose) { JSC::JSGlobalObject *globalObject = this->globalObject(); JSC::MarkedArgumentBuffer arguments; arguments.append(readableStream); @@ // unwraps the frame and also handles plain callables. AsyncContextFrame::call(globalObject, onClose, JSC::jsUndefined(), arguments); } - - m_onClose.clear(); - m_weakReadableStream.clear();As per coding guidelines, stored one-shot callbacks should be copied and nulled before invoking user JS.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/codegen/generate-jssink.ts` around lines 716 - 728, In the block where readableStream and onClose are checked before calling AsyncContextFrame::call, clear the m_onClose member variable before invoking the callback. After obtaining the onClose value via m_onClose.get(), reset m_onClose to null (or use whatever method clears this member) before calling AsyncContextFrame::call with the onClose parameter. This prevents re-entrancy issues where the callback can re-enter detach() and cause duplicate close/cancel callbacks, following the one-shot callback pattern of copying the value, clearing the stored state, then invoking it.Source: Coding guidelines
src/js/builtins/ReadableStreamInternals.ts (2)
951-952: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Avoid coercing Promise write results in the backpressure check.
The comments say FileSink may return a Promise;
sink.write(...) < 0/controller.write(...) < 0coerces non-number results, which can run user-overridable coercion hooks and turn the intended “Promise falls through” path into userland execution. Cache the result and compare only numbers.🛡️ Proposed fix
- if (sink.write(values[i]) < 0) { + const written = sink.write(values[i]); + if (typeof written === "number" && written < 0) { await sink.flush(true); // The sink's close path resolves the same promise; stop writing into a // dead sink. if (state.didClose) break; @@ - if (sink.write(value) < 0) { + const written = sink.write(value); + if (typeof written === "number" && written < 0) { await sink.flush(true); if (state.didClose) return sink.end(); } @@ - if (controller.write(value) < 0) { + const written = controller.write(value); + if (typeof written === "number" && written < 0) { clearImmediate(immediateTask); immediateTask = undefined; await controller.flush(true);As per coding guidelines, builtin JS modules must avoid user-overridable machinery on hostile/user-facing paths.
Also applies to: 973-974, 1912-1915
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/js/builtins/ReadableStreamInternals.ts` around lines 951 - 952, The backpressure check at lines 951-952 and similar locations coerces the result of sink.write() to a number using the comparison operator, which can trigger user-overridable coercion hooks when the result is a Promise. Store the return value of sink.write(values[i]) and controller.write() calls in a variable, then check if it is a number before performing the less-than comparison with 0, avoiding implicit coercion. Apply this fix to all affected backpressure checks at the mentioned line ranges: 951-952, 973-974, and 1912-1915.Source: Coding guidelines
1043-1048: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Wrap the
underlyingSource.closelookup too.
underlyingSource.closecan be a user-defined getter; reading it before thetrylets a thrown getter skip pending-read rejection,sink.end(), and stream cleanup. Put the property lookup and call in the same guarded block.🛡️ Proposed fix
- const underlyingSource = this.$underlyingSource; - const underlyingClose = underlyingSource.close; - if (typeof underlyingClose === "function") { - try { + try { + const underlyingSource = this.$underlyingSource; + const underlyingClose = underlyingSource?.close; + if ($isCallable(underlyingClose)) { underlyingClose.$call(underlyingSource, e); - } catch {} - } + } + } catch {} @@ - const underlyingSource = this.$underlyingSource; - const underlyingClose = underlyingSource.close; - if (typeof underlyingClose === "function") { - try { + try { + const underlyingSource = this.$underlyingSource; + const underlyingClose = underlyingSource?.close; + if ($isCallable(underlyingClose)) { underlyingClose.$call(underlyingSource, reason); - } catch {} - } + } + } catch {}As per coding guidelines, anything that can run user JS, including getters, must be handled before relying on subsequent cleanup.
Also applies to: 1202-1207
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/js/builtins/ReadableStreamInternals.ts` around lines 1043 - 1048, The property lookup for underlyingSource.close is occurring outside the try-catch block, but since close can be a user-defined getter that may throw, this allows errors to bypass the exception handler and skip necessary cleanup operations. Move the underlyingSource.close property lookup inside the try block so that any getter execution is caught and handled properly. Apply this same fix to both occurrences in the file (the initial location and the secondary location around lines 1202-1207).Source: Coding guidelines
3d18f13 to
eff4f3a
Compare
|
Thanks for the review. The inline threads failed to post, so replying here. This review diffed against the pre-rebase tip (bdc63ca), so it includes code from three PRs that merged into main since, not just this PR's changes. Sorted by detach() The other three are not part of this PR's diff (my hunks touch only
They showed up because of the diff base, not because this PR introduced them. Leaving those to their own code paths rather than pulling unrelated merged code into this bugfix; they can be addressed separately if the maintainers agree they're worth fixing. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/codegen/generate-jssink.ts`:
- Around line 724-734: The AsyncContextFrame::call invocation in the block
handling readableStream and onClose lacks a throw scope for exception checking
after calling user code. Add a DECLARE_THROW_SCOPE statement before the
AsyncContextFrame::call invocation to properly track exceptions, then ensure
exception checking is performed after the call completes. Mirror the scoped
exception handling pattern used in __onClose() and __onReady() methods by using
RELEASE_AND_RETURN to validate that no exceptions were thrown, ensuring thrown
close errors are properly detected and handled.
In `@src/runtime/server/RequestContext.rs`:
- Around line 2671-2699: The code takes a request reference with req.ref_() and
sets up a promise for pending_flush, but only conditionally re-arms the
on_writable callback inside the if let Some(resp) block. If req.resp is None,
the reference is taken and the promise is established but the callback is never
armed, leaving the flush permanently pending and violating reference count
balance requirements. Add a guard condition to check that req.resp is Some
before calling req.ref_() and creating the NativePromiseContext for the
pending_flush promise, ensuring that if there's no response object to drain the
flush, the code falls through to the teardown path that properly clears/destroys
the sink instead.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: a938b4d3-1be9-42b7-bd9d-fa5c2b54f801
📒 Files selected for processing (7)
src/codegen/generate-jssink.tssrc/js/builtins/ReadableStreamInternals.tssrc/runtime/server/RequestContext.rstest/js/bun/http/serve-direct-readable-stream.test.tstest/js/bun/http/serve-response-stream-sink-leak-fixture.tstest/js/bun/http/serve-response-stream-sink-leak.test.tstest/js/web/fetch/body-stream.test.ts
eff4f3a to
cd91d3f
Compare
cd91d3f to
2959725
Compare
2959725 to
58f04f8
Compare
|
CI note: build #64119 is red only on The Autobahn WebSocket fuzzing suite runs its server in a Docker container whose image is built for amd64; on the arm64 runner it can't exec, so the test's service-startup throws before any assertion. This PR does not touch WebSocket or the Docker test services, and the failure reproduces independently of the diff (it's deterministic on that runner, so a re-run hits the same mismatch). The other 285 jobs passed; earlier flaky lanes all recovered on retry. Diff is green. |
…usly A direct ReadableStream whose pull() returns a non-promise without ending the sink made Bun.serve cancel the stream and finalize the response after the shell flush, truncating anything written through the captured controller later. react-dom/server.bun's renderToReadableStream relies on exactly that shape to stream resolved Suspense boundaries. readDirectStream now returns a promise that settles when the sink closes when pull() returns synchronously while the stream is still readable, so native consumers (Bun.serve, FileSink) wait for end() instead of finalizing early. The async-pull termination contract is unchanged. Also fixes two teardown races this surfaced: - handle_resolve_stream destroyed the sink while endFromJS had a pending_flush parked under transport backpressure (common on QUIC right after the HEADERS frame), failing the request with HTTP3ContentLengthMismatch. Wait for the flush to settle and re-enter. - The controller's detach() skipped the close callback when it was wrapped in an AsyncContextFrame (stream constructed inside AsyncLocalStorage.run), leaking the request context. Route through AsyncContextFrame::call like the generated onClose already does. Fixes #32137
58f04f8 to
cda06ff
Compare
| resp.on_writable( | ||
| |this, off, resp| Self::on_writable_response_stream(this, off, resp), | ||
| std::ptr::from_mut::<Self>(req), | ||
| ); |
There was a problem hiding this comment.
🟡 Nit: the doc comment at on_writable_response_stream (RequestContext.rs:1513, "Registered once in do_render_stream (pending-promise branch)") and its SAFETY comment at :1524-1526 ("registered with uWS in do_render_stream") are now stale — this PR adds a second registration site here in handle_resolve_stream. The safety property still holds (this site is gated on a live resp and takes req.ref_() immediately after), so it is pure documentation drift; consider generalizing to e.g. "registered with uWS while the response (and the ref it holds on this context) is live". Same class of comment update as the Sink.rs detach() comment already fixed in 58f04f8.
Extended reasoning...
What drifted
on_writable_response_stream at src/runtime/server/RequestContext.rs:1518 carries two comments describing where its raw *mut Self user-data pointer is registered:
- The doc comment at :1513 — "Registered once in
do_render_stream(pending-promise branch) for the lifetime of the streaming response, so the sink itself never touches uWS callback registration". - The SAFETY comment at :1524-1526 justifying
unsafe { &mut *this }— "thisis the liveRequestContextuser-data pointer registered with uWS indo_render_stream; uWS invokes the callback while the response (and so this context) is alive."
This PR adds a second registration site at handle_resolve_stream (RequestContext.rs:2689-2692):
resp.on_writable(
|this, off, resp| Self::on_writable_response_stream(this, off, resp),
std::ptr::from_mut::<Self>(req),
);So both the "Registered once" claim and the "in do_render_stream" enumeration are now factually wrong — do_render_stream is no longer the sole registration site.
Why the safety property itself still holds
The new site is reached only when if let (Some(flush), Some(resp)) = (wrapper.sink.pending_flush, req.resp) binds — i.e. there is a live uWS response — and req.ref_() is taken on the very next line (RequestContext.rs:2693), with the matching deref happening in ON_RESOLVE_STREAM / ON_REJECT_STREAM after the flush settles. uWS only invokes on_writable while the response is alive, and the response is alive only while the RequestContext ref is held, so *mut Self is valid for the duration of every callback invocation from this site as well. There is no behavior bug here; only the comment's stated provenance drifted.
Step-by-step
- Before this PR, the only
resp.on_writable(... on_writable_response_stream ...)call lived indo_render_stream's Pending branch (introduced in Bun.serve: propagate socket backpressure to Response(ReadableStream) bodies #32553). The doc comment ("Registered once indo_render_stream") and SAFETY comment ("registered with uWS indo_render_stream") were accurate. - After the rebase onto Bun.serve: propagate socket backpressure to Response(ReadableStream) bodies #32553, this PR's
handle_resolve_streamdeferral path needed to arm the same drain callback (a fulfilledpull()reacheshandle_resolve_streamdirectly without passing through the Pending branch), so it adds a secondresp.on_writable(...)call at :2689-2692. The inline comment there ("do_render_stream only arms it on the Pending branch, but a fulfilled pull() reaches here directly, so arm it now") even acknowledges the second site. - A reader auditing the
unsafe { &mut *this }at :1527 and following the SAFETY comment todo_render_streamwill not find this second registration site, and the doc comment's "Registered once" is now false.
Why this is worth a nit
This is exactly the same class of documentation drift the PR author already fixed in commit 58f04f8 for the Sink.rs detach() wrapper comment (per inline-comment 3457096091): a SAFETY/why-this-wrapper comment whose stated mechanism became stale after this PR rewired the call site, while the conclusion remained correct. Applying the same bar here keeps the SAFETY comment trustworthy for the next reader auditing the raw-pointer deref.
Suggested fix
Generalize both comments so they no longer enumerate a single site, e.g.:
/// Registered on the response in `do_render_stream` (Pending branch) and
/// in `handle_resolve_stream` (pending-flush deferral) for the lifetime of
/// the streaming response, ...
and for the SAFETY block:
// SAFETY: `this` is the live `RequestContext` user-data pointer registered
// with uWS while a ref is held on this context (do_render_stream /
// handle_resolve_stream); uWS invokes the callback while the response (and
// so this context) is alive.Pure documentation; no behavior or test impact.
Fixes #32137
Repro
curlgetsSHELL(5 bytes, clean 200) instead ofSHELLRESOLVED. With react-dom 19'srenderToReadableStream(itsserver.bun.jsbuild uses exactly this shape), the response ends after the shell flush with the pending marker<!--$?-->and the fallback, missing</body></html>, and React logserror: The render was aborted by the server without a reason.because the server cancels the stream mid-render. Reproduces on 1.0.36 through current main.Cause
For a direct
ReadableStream,readDirectStreamcallsunderlyingSource.pull(sink)once and returnsundefinedwhen pull returns a non-promise.Bun.serve'sdo_render_stream(src/runtime/server/RequestContext.rs) then has no way to know the stream isn't finished: it hits the"is in progress, but did not return a Promise. Finalizing request context"branch, which cancels the stream (aborting React's render) and finalizes the sink, truncating everything the producer would have written through the captured controller.react-dom/server.bun.js depends on the captured-controller pattern:
pullwrites the shell and returns, then resolved Suspense boundaries are written later andend()is called when the render completes (react/react#25597, which Bun's SSR guide links).Fix
Three pieces, each with its own test:
readDirectStream(src/js/builtins/ReadableStreamInternals.ts): whenpull()returns a non-promise and the stream is still readable, return a promise that settles when the sink closes (end(), or abort on client disconnect). Native consumers already handle a returned promise through the same pending path asyncpull()uses, soBun.servekeeps the request context alive and lets the laterend()complete the response. Client disconnects still tear the stream down through the sink's abort path, which invokes the source'scancel()(so React aborts its render on disconnect as before). The async-pull contract (socket terminates when the promise resolves) and the default-stream path are unchanged.handle_resolve_stream(src/runtime/server/RequestContext.rs):endFromJS()can hit transport backpressure right after the HEADERS frame on QUIC and park apending_flushpromise whileonWritabledrains the remaining bytes. The resolve path destroyed the sink at that point, discarding the parked bytes and failing the request withHTTP3ContentLengthMismatch. This pre-exists the PR (anasync pull()that callsend()synchronously fails the same way over h3 on main) and is the same bug class the undefined-result path already guards (theeffective_result/pending_flushsubstitution). Now the resolve path waits for the parked flush to settle and re-enters.detach()(src/codegen/generate-jssink.ts): the controller's detach usedJSC::getCallDatato invoke the stored close callback, which reports anAsyncContextFrame(stream constructed insideAsyncLocalStorage.run(), a common per-request SSR pattern) as not callable, silently skipping the callback. With the new wait this leaked the request context, sink, andReadableStreamon every request. Route throughAsyncContextFrame::calllike the generatedonClosealready does.Tests
In
test/js/bun/http/serve-direct-readable-stream.test.ts(all fail on the unfixed build; the first two withThis HTTPResponseSink has already been closed. A "direct" ReadableStream terminates its underlying socket once 'async pull()' returns.):pull()that ends later streams the whole body (also asserts the shell arrives beforeend(), i.e. streaming stays progressive)pull()that writes nothing and ends later still respondscancel()fires when the client disconnects while waiting forend()pull()that ends synchronously, and syncpull()that ends from a microtask (fail withHTTP3ContentLengthMismatchwithout fix 2)heapStats()ReadableStream counts stay flat (leaks 20/20 without fix 3)Also updated:
body-stream.test.ts"Should not crash when not returning a promise when stream is in progress":toBeOneOf(["hey", ""])tightened totoBe("hey")and the timer replaced with condition-drivenend(), per reviewserve-response-stream-sink-leak-fixture.ts: the fixture relied on the old truncating fallback (pull never calledend(), which now means "keep the response open"); it ends each stream from the client side and still asserts flat memory over 10k requests (delta 0.0 bytes/request)Verified the issue's React repro end to end with [email protected]: the response is the complete 1158-byte document containing
<span>RESOLVED</span>and closing</body></html>, with no abort error. Existing suites (serve.test.ts,bun-server.test.ts,body-stream.test.tsincl. the h3 matrix,streams.test.js,direct-readable-stream.test.tsx, spawn stdin ReadableStream suites, the serve leak tests) show no new failures against the unfixed build.