Skip to content

Wait for end() when a direct stream's pull() returns synchronously in Bun.serve#32140

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/f96ac832/serve-direct-stream-wait-for-end
Jun 23, 2026
Merged

Wait for end() when a direct stream's pull() returns synchronously in Bun.serve#32140
Jarred-Sumner merged 2 commits into
mainfrom
farm/f96ac832/serve-direct-stream-wait-for-end

Conversation

@robobun

@robobun robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #32137

Repro

Bun.serve({
  port: 3000,
  fetch() {
    const stream = new ReadableStream({
      type: "direct",
      pull(controller) {
        controller.write("SHELL");
        controller.flush();
        setTimeout(() => {       // like React resolving a Suspense boundary
          controller.write("RESOLVED");
          controller.end();
        }, 50);
        // returns undefined synchronously
      },
    });
    return new Response(stream);
  },
});

curl gets SHELL (5 bytes, clean 200) instead of SHELLRESOLVED. With react-dom 19's renderToReadableStream (its server.bun.js build uses exactly this shape), the response ends after the shell flush with the pending marker <!--$?--> and the fallback, missing </body></html>, and React logs error: 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, readDirectStream calls underlyingSource.pull(sink) once and returns undefined when pull returns a non-promise. Bun.serve's do_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: pull writes the shell and returns, then resolved Suspense boundaries are written later and end() is called when the render completes (react/react#25597, which Bun's SSR guide links).

Fix

Three pieces, each with its own test:

  1. readDirectStream (src/js/builtins/ReadableStreamInternals.ts): when pull() 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 async pull() uses, so Bun.serve keeps the request context alive and lets the later end() complete the response. Client disconnects still tear the stream down through the sink's abort path, which invokes the source's cancel() (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.

  2. handle_resolve_stream (src/runtime/server/RequestContext.rs): endFromJS() can hit transport backpressure right after the HEADERS frame on QUIC and park a pending_flush promise while onWritable drains the remaining bytes. The resolve path destroyed the sink at that point, discarding the parked bytes and failing the request with HTTP3ContentLengthMismatch. This pre-exists the PR (an async pull() that calls end() synchronously fails the same way over h3 on main) and is the same bug class the undefined-result path already guards (the effective_result/pending_flush substitution). Now the resolve path waits for the parked flush to settle and re-enters.

  3. detach() (src/codegen/generate-jssink.ts): the controller's detach used JSC::getCallData to invoke the stored close callback, which reports an AsyncContextFrame (stream constructed inside AsyncLocalStorage.run(), a common per-request SSR pattern) as not callable, silently skipping the callback. With the new wait this leaked the request context, sink, and ReadableStream on every request. Route through AsyncContextFrame::call like the generated onClose already does.

Tests

In test/js/bun/http/serve-direct-readable-stream.test.ts (all fail on the unfixed build; the first two with This HTTPResponseSink has already been closed. A "direct" ReadableStream terminates its underlying socket once 'async pull()' returns.):

  • sync pull() that ends later streams the whole body (also asserts the shell arrives before end(), i.e. streaming stays progressive)
  • sync pull() that writes nothing and ends later still responds
  • cancel() fires when the client disconnects while waiting for end()
  • h3: async pull() that ends synchronously, and sync pull() that ends from a microtask (fail with HTTP3ContentLengthMismatch without fix 2)
  • AsyncLocalStorage variant asserting 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 to toBe("hey") and the timer replaced with condition-driven end(), per review
  • serve-response-stream-sink-leak-fixture.ts: the fixture relied on the old truncating fallback (pull never called end(), 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.ts incl. 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.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:01 PM PT - Jun 23rd, 2026

@Jarred-Sumner, your commit cda06ffa25c6b3f14f61d48b9433e26a31317b67 passed in Build #64283! 🎉


🧪   To try this PR locally:

bunx bun-pr 32140

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

bun-32140 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. ReadableStream reads to completion in server, does not stream. React example provided #7321 - React's renderToReadableStream doesn't stream progressively in Bun.serve; the sync-pull/captured-controller pattern causes the entire response to buffer until completion instead of streaming the Suspense fallback first
  2. cancel callback of "direct" readable stream doesn't get called #18315 - cancel callback of type: "direct" readable stream never fires; premature finalization from sync pull() bypasses the cancel callback path
  3. ReadableStream with new Response produces cancel reason: undefined #17175 - type: "direct" stream with sync pull() in Bun.serve spuriously fires cancel(reason: undefined) even when the stream completed successfully, because the premature finalization path cancels the stream

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

Fixes #7321
Fixes #18315
Fixes #17175

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

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

Changes

Direct ReadableStream synchronous pull-to-close coordination

Layer / File(s) Summary
Close promise capability tracking in readDirectStream
src/js/builtins/ReadableStreamInternals.ts
readDirectStream introduces closePromiseCapability variable. Internal close() function resolves and clears this capability when present. When pull() returns synchronously and stream remains readable, readDirectStream returns the close promise instead of undefined to signal native consumers to await closure.
RequestContext defers resolve when pending flush
src/runtime/server/RequestContext.rs
handle_resolve_stream checks if ResponseStreamJSSink has a pending_flush promise and, if not aborted, rehydrates it into JS promises via NativePromiseContext, schedules resolve/reject handlers, increments request refcount, and returns early without destroying sink until flush settles.
Generated sink detach invokes onClose via AsyncContextFrame
src/codegen/generate-jssink.ts
Generated controller detach() now copies and clears m_onClose before invocation, then invokes it using AsyncContextFrame::call(...) forwarding the readable stream and jsUndefined() argument buffer, enabling consistent handling of both AsyncContextFrame-wrapped and plain callables.
HTTP server direct stream tests
test/js/bun/http/serve-direct-readable-stream.test.ts
Adds tests for direct streams: synchronous pull() with buffered write followed by later controller.end(), synchronous pull() that defers all writes to after end() call, client abort during pending end, h3 transport backpressure cases, and AsyncLocalStorage regression test for ReadableStream leak across GC cycles. Imports updated for new test utilities.
Serve response stream sink-leak fixture and regression test
test/js/bun/http/serve-response-stream-sink-leak-fixture.ts, test/js/bun/http/serve-response-stream-sink-leak.test.ts
Fixture uses Promise.withResolvers() to coordinate pull() timing and captures the stream controller; once() helper starts fetch without awaiting, waits for pulled.promise, then calls controller.end(). Regression test verifies native ResponseStream.JSSink is destroyed after synchronous pull() that ends later, confirming no heap leak.
Fetch body-stream test synchronization
test/js/web/fetch/body-stream.test.ts
Server harness captures controller and uses pulled resolver to signal when stream is in-progress but not yet ended; client starts fetch() before synchronization point, then calls controller.end() and asserts response body is always "hey" (strict, no conditional).

Possibly related issues

Possibly related PRs

  • oven-sh/bun#32120: Both PRs change the JS sink/controller lifecycle used for streaming/backpressure—this PR adjusts sink teardown/detach behavior in src/codegen/generate-jssink.ts and defers RequestContext sink cleanup under pending_flush, while the related PR adds sink "controllerDetached" plumbing and fixes onWritable/abort userData type confusion in the same backpressure streaming-response machinery.
  • oven-sh/bun#32553: This PR's changes to readDirectStream and RequestContext::handle_resolve_stream to defer/coordinate sink closure based on a pending pending_flush backpressure signal are directly in the same backpressure-drain/teardown flow introduced in the related PR (notably RequestContext::on_writable_response_stream and the pending_flush handling).
  • oven-sh/bun#32597: Both PRs change the JS readable controller teardown path generated in src/codegen/generate-jssink.ts (reordering/guarding detach()/onClose and sink-pointer handling) to ensure end()/close() and direct-stream shutdown don't dereference freed sink state or misfire callbacks.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: waiting for end() when a direct stream's pull() returns synchronously in Bun.serve, which is the core fix for issue #32137.
Description check ✅ Passed The description comprehensively covers both required sections: clearly explains what the PR does (the three-part fix with detailed context) and how it was verified (tests, end-to-end React validation, existing suite checks).
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

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 win

Replace 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6e91d24 and ce9a429.

📒 Files selected for processing (3)
  • src/js/builtins/ReadableStreamInternals.ts
  • test/js/bun/http/serve-direct-readable-stream.test.ts
  • test/js/web/fetch/body-stream.test.ts

Comment thread src/js/builtins/ReadableStreamInternals.ts
Comment thread src/js/builtins/ReadableStreamInternals.ts
@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

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:

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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

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: readDirectStream now returns a close-promise capability when sync pull() leaves the stream readable, so native consumers wait for end() instead of finalizing immediately.
  • RequestContext.rs (handle_resolve_stream): if endFromJS() parked a pending_flush (h3 backpressure right after HEADERS), defer sink teardown — ref_(), wrap the flush in a NativePromiseContext, attach ON_RESOLVE_STREAM/ON_REJECT_STREAM, and re-enter when it settles.
  • generate-jssink.ts (detach()): invoke m_onClose via AsyncContextFrame::call so the close callback fires when the stream was constructed under AsyncLocalStorage.run() (previously getCallData reported 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):

  1. ALS-wrapped streams leaked because detach() couldn't call an AsyncContextFrame — fixed by routing through AsyncContextFrame::call, with a heapStats regression test.
  2. serve-response-stream-sink-leak-fixture.ts relied 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_() in handle_resolve_stream that must be balanced by the re-entered resolve/reject handler in every code path (including abort racing the parked flush).
  • Changes when the heap ResponseStreamJSSink is 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.ts change is inside a describe.each over http/1.1 and http/3 with a shared pulled/controller capture; 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.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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

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.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Build 61981 finished with 4 annotations, none related to this diff:

  • test/cli/init/init.test.ts (darwin 14 aarch64): bun init emitted an extra CLAUDE.md, failing 3 directory snapshots. bun init creates CLAUDE.md when a claude binary is found in PATH (src/runtime/cli/init_command.rs, is_claude_code_installed), so this is runner state: that mac runner appears to have the claude CLI installed, and these snapshots will fail for any PR landing on it. This PR does not touch the init command.
  • test/js/web/fetch/fetch-leak.test.ts "should not leak using readable stream" (darwin 14 aarch64): RSS-threshold miss by ~5.6 MB against a 5 MB headroom on a shared runner. The test serves a static buffer Response and reads it with a client-side fetch reader; it exercises neither the direct-stream path, the response sink teardown, nor the sink controller codegen changed here.
  • test/cli/run/cpu-prof.test.ts (windows 2019) and test/js/web/streams/streams-leak.test.ts (debian 13 aarch64): both passed on retry and are marked flaky by the CI itself.

The three test files this PR adds/changes passed on every lane. The diff is ready for review.

@robobun
robobun force-pushed the farm/f96ac832/serve-direct-stream-wait-for-end branch from 75d6a8c to 3d18f13 Compare June 23, 2026 03:26
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between bdc63ca and 3d18f13.

📒 Files selected for processing (2)
  • src/codegen/generate-jssink.ts
  • src/js/builtins/ReadableStreamInternals.ts

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between bdc63ca and 3d18f13.

📒 Files selected for processing (2)
  • src/codegen/generate-jssink.ts
  • src/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' src

Repository: 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.ts

Repository: 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/bindings

Repository: 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/runtime

Repository: 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 20

Repository: 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/bindings

Repository: 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 -50

Repository: 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' src

Repository: 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 5

Repository: 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*, then scope.tryClearException() removes the VM's exception root. At that point, controller->detach() can invoke m_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 after detach() 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 __end function.

🤖 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_onClose before invoking it.

AsyncContextFrame::call runs user JS, and the callback can re-enter detach() while m_onClose is 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(...) < 0 coerces 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.close lookup too.

underlyingSource.close can be a user-defined getter; reading it before the try lets 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

@robobun
robobun force-pushed the farm/f96ac832/serve-direct-stream-wait-for-end branch from 3d18f13 to eff4f3a Compare June 23, 2026 03:38
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

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 git blame:

detach() m_onClose one-shot (generate-jssink.ts ~716, this PR's code): valid, applied in eff4f3a. m_onClose/m_weakReadableStream are now copied into locals and cleared before AsyncContextFrame::call runs the callback, so a re-entrant detach() can't fire onClose twice. All 8 tests in serve-direct-readable-stream.test.ts still pass.

The other three are not part of this PR's diff (my hunks touch only readDirectStream and detach()):

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.

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 3d18f13 and eff4f3a.

📒 Files selected for processing (7)
  • src/codegen/generate-jssink.ts
  • src/js/builtins/ReadableStreamInternals.ts
  • src/runtime/server/RequestContext.rs
  • test/js/bun/http/serve-direct-readable-stream.test.ts
  • test/js/bun/http/serve-response-stream-sink-leak-fixture.ts
  • test/js/bun/http/serve-response-stream-sink-leak.test.ts
  • test/js/web/fetch/body-stream.test.ts

Comment thread src/codegen/generate-jssink.ts Outdated
Comment thread src/runtime/server/RequestContext.rs
@robobun
robobun force-pushed the farm/f96ac832/serve-direct-stream-wait-for-end branch from eff4f3a to cd91d3f Compare June 23, 2026 03:51
Comment thread test/js/bun/http/serve-direct-readable-stream.test.ts Outdated
@robobun
robobun force-pushed the farm/f96ac832/serve-direct-stream-wait-for-end branch from cd91d3f to 2959725 Compare June 23, 2026 04:01
Comment thread src/codegen/generate-jssink.ts Outdated
@robobun
robobun force-pushed the farm/f96ac832/serve-direct-stream-wait-for-end branch from 2959725 to 58f04f8 Compare June 23, 2026 04:57
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI note: build #64119 is red only on test/js/web/websocket/autobahn.test.ts (darwin 14 aarch64). The failure is a Docker image platform mismatch on that runner, not a code issue:

autobahn The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)
autobahn-1  | exec /opt/pypy/bin/wstest: exec format error
container bun-test-services-autobahn-1 exited (255)

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.

robobun and others added 2 commits June 23, 2026 14:21
…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
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/f96ac832/serve-direct-stream-wait-for-end branch from 58f04f8 to cda06ff Compare June 23, 2026 21:32
@Jarred-Sumner
Jarred-Sumner merged commit 5d2f66c into main Jun 23, 2026
60 of 69 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f96ac832/serve-direct-stream-wait-for-end branch June 23, 2026 21:58
Comment on lines +2689 to +2692
resp.on_writable(
|this, off, resp| Self::on_writable_response_stream(this, off, resp),
std::ptr::from_mut::<Self>(req),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Nit: 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 } — "this is the live RequestContext user-data pointer registered with uWS in do_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

  1. Before this PR, the only resp.on_writable(... on_writable_response_stream ...) call lived in do_render_stream's Pending branch (introduced in Bun.serve: propagate socket backpressure to Response(ReadableStream) bodies #32553). The doc comment ("Registered once in do_render_stream") and SAFETY comment ("registered with uWS in do_render_stream") were accurate.
  2. After the rebase onto Bun.serve: propagate socket backpressure to Response(ReadableStream) bodies #32553, this PR's handle_resolve_stream deferral path needed to arm the same drain callback (a fulfilled pull() reaches handle_resolve_stream directly without passing through the Pending branch), so it adds a second resp.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.
  3. A reader auditing the unsafe { &mut *this } at :1527 and following the SAFETY comment to do_render_stream will 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.

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.

Bun.serve sends incomplete response when body is a renderToReadableStream() with unresolved Suspense boundaries

2 participants