Skip to content

Response(asyncIterable): close the iterator with return(), never throw into it - #44862

Merged
Jarred-Sumner merged 2 commits into
mainfrom
robobun/07d895a6/async-iterable-body-return-only
Oct 9, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
robobun/07d895a6/async-iterable-body-return-only

Conversation

@robobun

@robobun robobun commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Every cancel calls return(), the rule of Call cancel on ReadableStream when Bun.serve() response is aborted #13687 and docs/guides/http/sse.mdx:19.
  • The error tail follows for await: a failed write calls return(), a failed iterator gets no call. A write that fails at once takes this path too.
  • Correct because a body cannot catch return(). node and ReadableStream.from() never call throw().
  • Verified: 120 new cells in 5 test files, 115 fail on main. Self-reviewed: 14 concerns raised, 13 addressed.

Background

  • The body is a direct ReadableStream pumped from iterator.next(). The cancel hook runs when the consumer leaves, the error tail when the pump fails.
  • Considered return() only where throw() is missing: a generator that catches stays open. A reason-less close at streams.rs:2015 misses FileSink::on_close.

Downsides

Notes

Who sends a reason (all end in the same cancel hook)

Producer Since Reason
HTTPServerWritable::abort (streams.rs:2015): client hang-up, idle timeout, server.stop(true) #41894 connection-closed AbortError
FileSink::on_close: a failed write on Bun.spawn stdin or Bun.write to a pipe or file #41456 the write error
HTMLRewriter input cancel #42130 the handler error or an AbortError
reader.cancel(reason), pipeTo error or abort, tee()/clone(), Readable.fromWeb().destroy(err), fetch() abort #41757 the caller's reason

The producers are not changed. Direct and default streams still get the reason in cancel(reason).

Rulings this follows

What it reverses

Results (debug ASAN build unless a build is named)

Check main bd599f5 this PR 1.4.2
The 8 ledger scripts at exit 0 0 of 8 8 of 8 7 of 8
Twelve ways a JS consumer leaves, iterator not returned 7 of 12 0 of 12 12 of 12
A body that ends its own controller, then yields: shapes left open (of 4) 2 0 0
Bun.write to /dev/full, 64 KiB chunks pulls for ever rejects ENOSPC after 1 pull, body closed pulls for ever

node v26.3.0 returns the iterator in 12 of 12 ways and never calls throw().

New tests on release builds of main and of this PR:

File main this PR
test/js/web/fetch/body-async-iterator.test.ts 10 pass, 40 fail 50 pass
test/js/bun/http/async-iterator-stream.test.ts 97 pass, 42 fail 139 pass
test/js/workerd/html-rewriter.test.js (new rows) 10 fail 10 pass
test/js/bun/spawn/spawn-stdin-readable-stream.test.ts (new rows) 10 fail 10 pass
test/js/bun/io/bun-write.test.js (new rows) 14 fail 14 pass
test/js/bun/http/serve-direct-readable-stream.test.ts (rewritten pin) 1 fail 1 pass

The 5 new cells that pass on main are the reader.cancel() controls with no reason. Each cell runs five body shapes from test/js/web/streams/async-iterable-body-shapes.ts: an iterator with next() and return(), one with throw() too, a generator with try/catch/finally, a generator whose loop catches, and a node Readable with autoDestroy: false.

The two commits

  • Commit 1 changes only the cancel hook. Built alone, the 8 ledger scripts exit 0. It leaves the end-then-yield route open for 2 of 4 shapes, and /dev/full with 64 KiB chunks still pulls for ever.
  • Commit 2 changes the error tail and adds the rejected-write branch. Both routes above need it.

Still open, for a maintainer

  • A generator acts on return() at a yield. One that waits in an await is returned when that await settles (the same on 1.4.2). The docs paragraph says so and shows req.signal.
  • A generator that writes through the yielded controller in a loop and never yields again is not stopped. 1.4.2 stopped it because write() threw once the client left. Since Bun.serve direct streams: flush on controller.close(), end the response once, call cancel() only on a client abort #41894 write() returns 0. The question is which of the two a write through the yielded controller must do after the consumer left.
  • A return() that throws synchronously while Bun.serve aborts the body is an uncaught exception, as on 1.4.2.
  • Open PR streams: throw instead of aborting when a direct stream's text sink cannot take a string chunk #44181 adds three tests that expect a refused chunk's error in a generator's catch (thrown: outOfMemory). They need a finally flag so that neither PR turns main red.
  • Not run: Windows and macOS. The stdin rows are skipped on Windows because fs.closeSync(0) does nothing there. HTTPS, HTTP/2 and HTTP/3 share the abort path by reading, and only HTTP/1.1 was run.

Measurements (release builds of main bd599f5 and of this PR)

  • Release text (size): 88,771,341 to 88,770,061 B (-1,280 B). Reaction handlers per global: 98 to 98.
  • Cells per chunk (heapStats slope, GC off, 2,000 to 6,000 chunks, 3 runs): 3.000 to 3.000 on Bun.serve and .bytes().
  • Per client abort of a generator body (N=200): Promise 10 to 9, reactions 6 to 5.
  • body.cancel(err): an iterator with throw() Promise 4 to 3. An iterator with return() only Promise 2 to 3, because return() is now called.
  • Error tail per failing body under .text() (N=1000): a generator that throws 13 to 9 cells and 4 to 3 microtask turns. A refused chunk with a generator 14 to 15 cells and 6 to 7 turns. A refused chunk with {next, return} 9 to 13 cells, because return() is now called.
  • End of a body whose return() gives a plain object and not a promise: 10 to 12 cells. The result is now awaited, so that a thenable or a lazy Bun.sql promise runs.
  • Static (llvm-objdump): the four iteration-protocol exception checks in the pump go from 3 instructions to 2. A write() that returns a promise gets one more status compare.
  • Not measured: dynamic instruction counts. perf hardware counters fail in the build VM and valgrind could not be installed.

Suites run on the debug ASAN build: the six files above, streams.test.js, body.test.ts, body-stream.test.ts, fetch-args.test.ts, serve-stream-body-error.test.ts, and the two main files with BUN_JSC_validateExceptionChecks=1. In a CPU-capped container the subprocess tests of serve-direct-readable-stream, spawn-stdin-readable-stream, bun-write, streams and serve-stream-body-error time out at the default concurrency. All pass with --max-concurrency 4 --timeout 60000. Two older chunk-count tests in async-iterator-stream.test.ts (async generator function, [Symbol.asyncIterator]) fail in 2 of 6 debug runs with the new tests filtered out.

Self-review, the one concern not taken: deleting the lastResort overload of atStreamsBoundary. The error tail still uses it, because what return() throws still replaces the error.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/workerd/html-rewriter.test.js, test/js/bun/spawn/spawn-stdin-readable-stream.test.ts, test/js/bun/io/bun-write.test.js, test/js/bun/http/serve-direct-readable-stream.test.ts

…ncel

The cancel hook of an async-iterable body chose by the reason. A truthy
reason went to iterator.throw?.(reason), a falsy one to iterator.return?.().
Until #41456, #41757 and #41894 no consumer sent a reason, so every cancel
took the return() arm. Now a client that disconnects, the idle timeout of
Bun.serve, a failed write to a pipe and cancel(reason) all send one.

- An iterator with next() and return() and no throw() got no call at all.
  Its cleanup never ran.
- A generator that catches what is thrown into it went on to a later
  `yield` and stayed there. Its `finally` never ran.

The hook now closes the iterator with return() and no argument, whatever
the reason is, the way `for await` leaves an iterator. node's
Response(asyncIterable) and ReadableStream.from() do the same. A
generator's `catch` no longer sees the reason of a cancel.

closeIterator() also awaits what return() gives back the way `await`
does. A thenable or a Promise subclass that starts its work in then(),
like a Bun.sql query, now runs, also at the end of a body.
…nsumer fails

The error tail of the pump called iterator.throw?.(error) for every
failure, after it emptied the iterator slot. It had the same two holes as
the cancel hook: an iterator with no throw() got no call, and a generator
that catches stayed suspended at a later `yield`. A body that ends its own
controller and yields again lands here, and so does a write to a pipe that
fails while it is pending.

The tail now follows `for await`:
- The consumer failed (a refused chunk, a rejected write() or end()): the
  iterator is closed with return(). The body still rejects with the
  consumer's error. What return() itself throws still replaces it.
- The iterator failed (next() threw or rejected, a malformed result):
  nothing more is called on it.

A write() that fails at once came back as a rejected promise, and the pump
dropped it and went on pulling for ever (Bun.write to /dev/full, a large
write to a closed pipe). It now takes the error tail like a write that
fails later.

No code path calls iterator.throw() now. A generator's `catch` no longer
sees the error of a failed write, and an iterator with throw() and no
return() gets no call.
@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced with a Bun.serve handler that returns new Response(iterator). The iterator has next() and return(). Five clients read one event each and hang up.

  • 1.4.2: return() calls: 5; listeners left on the emitter: 0
  • canary 367d939 and main bd599f5 (release build and debug ASAN build): return() calls: 0; listeners left on the emitter: 5
  • This PR: return() calls: 5; listeners left on the emitter: 0

PR: #44862

@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 4a774b01-305c-4b9b-8e45-c9bf01fa65db

📥 Commits

Reviewing files that changed from the base of the PR and between e655c58 and 7874524.


📒 Files selected for processing (13)
  • docs/runtime/streams.mdx
  • src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp
  • src/jsc/bindings/webcore/streams/JSAsyncIteratorSourceOperation.h
  • src/jsc/bindings/webcore/streams/JSStreamsRuntime.h
  • src/jsc/bindings/webcore/streams/WebStreamsInternals.h
  • src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
  • test/js/bun/http/async-iterator-stream.test.ts
  • test/js/bun/http/serve-direct-readable-stream.test.ts
  • test/js/bun/io/bun-write.test.js
  • test/js/bun/spawn/spawn-stdin-readable-stream.test.ts
  • test/js/web/fetch/body-async-iterator.test.ts
  • test/js/web/streams/async-iterable-body-shapes.ts
  • test/js/workerd/html-rewriter.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.



Walkthrough

Async-iterable stream cleanup now calls return() when consumers finish or cancel. Iterator operation failures use a separate abandonment path. New tests cover cleanup across web streams, HTTP responses, file writes, child processes, and HTML rewriting.

Changes

Async-Iterable Stream Cleanup

Layer / File(s) Summary
Close iterators on consumer exit
src/jsc/bindings/webcore/streams/JSAsyncIteratorSourceOperation.h, src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp, src/jsc/bindings/webcore/streams/WebStreamsInternals.h, src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
Normal completion and cancellation now close the iterator through return(). The new promiseResolvedWithFast helper handles non-object values, vanilla promises, and other objects.
Separate iterator failures from cleanup
src/jsc/bindings/webcore/streams/JSStreamsRuntime.h, src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp
Iterator operation failures abandon the iterator before error handling. Rejected next() promises use a dedicated handler, and immediately rejected writes enter the stream error path.
Validate web stream cleanup
test/js/web/streams/async-iterable-body-shapes.ts, test/js/web/fetch/body-async-iterator.test.ts, test/js/workerd/html-rewriter.test.js
Shared fixtures record iterator cleanup. Tests cover reader cancellation, pipe failures, transformed streams, tee branches, Request bodies, and HTML rewriter cancellation or handler errors.
Validate runtime consumer exits
test/js/bun/http/async-iterator-stream.test.ts, test/js/bun/http/serve-direct-readable-stream.test.ts, test/js/bun/io/bun-write.test.js, test/js/bun/spawn/spawn-stdin-readable-stream.test.ts, docs/runtime/streams.mdx
Tests cover HTTP disconnects, refused writes, and closed child-process pipes. The documentation describes return() behavior and shows a Bun.serve generator passing req.signal to an awaited operation.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 78745

Async-iterable response bodies are now closed with return() instead of having errors thrown into them. This matches for-await semantics and comes with broad test coverage. No concrete defect was identified, so the change appears ready to merge. Behavior on non-Linux platforms and on HTTP/2 and HTTP/3 was not exercised.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely summarizes the main change: async iterable response bodies now close iterators with return() instead of throw().
Description check Passed The description explains the problem, fix, behavioral details, verification results, limitations, performance measurements, and remaining concerns. It does not use the exact template headings, but it …


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the C++ side of the change: the reaction-handler macro in JSStreamsRuntime.h matches the JSC_DEFINE_HOST_FUNCTION set (NextRejected added, CancelRejected removed, no dangling references); closeIterator clears the Iterator field before invoking return(), so a cancel re-entered from inside return() or from a pending next() rejection finds nothing to call twice; and every throwing site in the pump (next getter/call, non-object result, done/value getters, sync-rejected next()) now abandons the iterator before the error tail, so return() is never invoked on an iterator that already failed. Since this reverses the cancel(reason)->throw() semantics of several unreleased PRs, a maintainer should still weigh the behavioral decision.

Extended reasoning...

The change rewrites the teardown of Bun's C++ async-iterable ReadableStream source (BunAsyncIterableSource.cpp) so cancel and the consumer-error tail always call iterator.return() instead of throw(), adds a promiseResolvedWithFast helper in WebStreamsMisc.cpp, removes one reaction handler and adds another, and ships a large test matrix plus a docs paragraph. It touches no auth, crypto, or input-parsing surface. The two inline findings are test-hygiene nits, but the change is a deliberate semantic reversal of recent unreleased behavior in a GC-sensitive streams path, so a human should confirm the policy and the exception-path handling rather than this review approving on its own.

Comment on lines +448 to +449
socket.on("error", seen.reject);
socket.on("close", () => seen.resolve());

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 (optional): when the server ends or drops the response before "chunk" arrives, the new Bun.serve cases hang to the test timeout instead of failing with a reason. connectAndRead at test/js/bun/http/async-iterator-stream.test.ts:449 resolves seen on the socket's close event, so an early close returns a dead socket and the caller then waits forever on aborted.promise (:486) or on settled(shape). Fix: make every failure signal reject the awaited promise, so close before until was seen rejects with what was received (e.g. socket.on("close", () => seen.reject(new Error("closed before " + until + ": " + raw)))), keeping the resolve only on the data path at :446.

Why this was flagged

connectAndRead at test/js/bun/http/async-iterator-stream.test.ts:439-452 is the raw client for the 15 "when the client hangs up" cases (:474) and 5 "when server.stop(true)" cases (:491) plus the "after the client hung up" cases (:722 area). It resolves seen from the close handler at :449. If the server finishes or aborts the response before the body's first "chunk" is seen (a body that errors, a connection reset, a regression that ends the stream early), seen resolves anyway and the function returns a closed socket. The test then calls socket.destroy() on a dead socket and awaits aborted.promise at :486, which never resolves because req.signal only aborts on a client-side hang-up, so the case dies on the harness timeout with no message about what the server sent. The repository review rules under "Await conditions, wire failures to reject" ask that close/error events reject the awaited promise so the failure is diagnosable; only the error event does so here (:448).

Verification: The server ends or drops the response before the first "chunk" reaches the raw client. connectAndRead at test/js/bun/http/async-iterator-stream.test.ts:449 does socket.on("close", () => seen.resolve()), so an early close resolves seen like success. Callers then await aborted.promise (:486), resolved only from req.signal's abort, so the test sits until the harness timeout.

Comment on lines +488 to +497
});
});

test.concurrent("when server.stop(true) closes the connection", async () => {
const shape = make();
const server = Bun.serve({ port: 0, fetch: () => new Response(shape.body) });
const socket = await connectAndRead(server, "chunk");
try {
await server.stop(true);
expect(await settled(shape)).toEqual(shape.expected);

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 (optional): a server leaks into later tests when this case fails before its try block. The server at test/js/bun/http/async-iterator-stream.test.ts:493 is a plain const, and connectAndRead at :491 runs before the try/finally that stops it, so a rejected connect leaves the listener open in a concurrent describe. Fix: declare the server with using (or move the connect inside the try) so it is stopped on every exit, as the sibling tests at :474 do.

Why this was flagged

The "when server.stop(true) closes the connection" test at test/js/bun/http/async-iterator-stream.test.ts:488 creates const server = Bun.serve(...) at :493 without using. await connectAndRead(server, "chunk") at :491 runs before the try { await server.stop(true) ... } finally { socket.destroy() } block at :492-497. If the connect or the read rejects (socket error wired to seen.reject at :452), the test fails with the server still listening, and nothing stops it. The surrounding describe.each is concurrent, so the leaked listener persists into later tests in the same worker; the base branch has no such test. test/CLAUDE.md and the review guidance ask for using or try/finally registered before assertions.

Verification: nit. Triggering condition: connectAndRead rejects or server.stop(true) throws. The server at test/js/bun/http/async-iterator-stream.test.ts:493 has no using, and the connect at :494 runs before the try/finally at :495-500, whose finally only destroys the socket, so a rejected connect leaves the listener open for the rest of the concurrent file.

@Jarred-Sumner
Jarred-Sumner merged commit 9e31db2 into main Oct 9, 2026
7 of 8 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/07d895a6/async-iterable-body-return-only branch October 9, 2026 23:52
@Jarred-Sumner Jarred-Sumner mentioned this pull request Oct 10, 2026
27 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants