Repository navigation
streams: follow a promise returned by direct pull() or async-iterable next() through its own then() - #46295
streams: follow a promise returned by direct pull() or async-iterable next() through its own then()#46295robobun wants to merge 5 commits into
Conversation
|
Status: ready for review. The diff is green.
How it was reproduced // A pending Promise subclass that starts its work in then(): the shape of Bun.$ and of a Bun.sql query.
class Lazy extends Promise {
#start;
static get [Symbol.species]() { return Promise; }
constructor(work) { let resolve; super(r => { resolve = r; }); this.#start = () => resolve(work()); }
then(a, b) { const start = this.#start; this.#start = undefined; start?.(); return super.then(a, b); }
}
const stream = new ReadableStream({ type: "direct", pull: c => new Lazy(() => { c.write("ok"); c.close(); }) });
console.log(await new Response(stream).text());
PR: #46295 |
|
Updated 7:59 AM PT - Oct 26th, 2026
✅ @robobun, your commit 0dfb3c8a834996d1a75a8577de222efc130b1cff passed in 🧪 To try this PR locally: bunx bun-pr 46295That installs a local version of the PR into your bun-46295 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Async iterable promise normalization src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp, src/jsc/bindings/webcore/streams/WebStreamsInternals.h, src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp, test/js/web/fetch/body-async-iterator.test.ts |
Async iterable results use promise reaction helpers. Tests cover custom then() behavior, non-callable then properties, and cancellation during then lookup. |
Direct stream source callbacks src/jsc/bindings/webcore/streams/JSDirectStreamSource.h, src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp, src/jsc/bindings/webcore/streams/BunStreamSource.cpp |
JSDirectStreamSource now provides pull and cancellation methods. Direct stream callers use these methods and retain exception and promise reaction handling. |
Direct stream consumers and tests src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp, test/js/web/streams/direct-stream-contract.ts, test/js/web/streams/direct-stream-returned-promise.test.ts, test/js/web/streams/lazy-promise.ts |
Consumers treat only promise pull results as asynchronous. Tests cover pull results, completion and rejection behavior, consumers, and cancellation cleanup. |
Suggested reviewers: dylan-conway
Priority:
Merge Risk: 
Minimal · up to b4f9d
-
No actionable merge-blocking risk was identified in the reviewed changes.
Pre-merge checks: 
4
-
Full details: Title check
-
Explanation
The title names the main change: direct-stream pull() and async-iterable next() results are now followed through their own then() method. It matches the changeset, which applies promiseResolvedWithFast to both callbacks and adds the follower helper. It is specific and reads clearly in history. The title is long (99 characters), which is the only weakness.
Full details: Description check
-
Explanation
The description clearly explains the problem, implementation, scope, tradeoffs, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
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 that promiseForReactionsSlow in WebStreamsMisc.cpp cannot dereference a null follower (promiseResolvedWithFast returns nullptr only for non-objects, and the input is always a JSPromise cell), and that the three refactored pull() call sites keep the same this-value and the same TypeError text for a missing pull as before.
Extended reasoning...
The change touches the C++ web-streams layer (direct source pull/cancel, async-iterable next) to route a returned promise with a non-built-in then() through a follower promise, plus new tests; it touches no auth, crypto or injection surface. Inline findings were reported, so the note above only records what else was examined and ruled out.
…ull(), sink-side cancel() and async-iterable next() A direct stream's pull(), the cancel() that runs when a sink closes under the source, and the next() of an async-iterable body hung an internal reaction on the promise cell that the callback returned. A promise whose then() is not the built-in one (a subclass, an own then, any promise once Promise.prototype.then is replaced) never got a then() call. Bun.$ and a Bun.sql query start their work in then(), so the work never started, and a body that waited for it never ended. The reader and buffer-consumer paths of pull() and next() called then() in 1.3.14. These places now apply the rule that the streams module has for its spec callbacks (promiseResolvedWithFast): such a promise is waited for through a follower that calls then() once from a job. A plain promise of the realm is used as it is. A promise whose then is not callable is read by its state. JSDirectStreamSource is now the only caller of a source's functions. pull() and cancelFromSink() hand back a promise that an internal reaction can hang on, and the raw accessors are private.
af14fef to
709232e
Compare
The two tests that spawn a child read only its stdout, so an error that the child printed did not reach the failure message. They now pipe stderr and assert that it is empty. The raw-socket fixture also rejects when the response ends before its first chunk, as the in-process helper does.
- promiseForReactionsSlow declares a throw scope and returns before it reads the follower when an exception is pending. - Each added comment is one line. - The spawned tests pipe stderr and assert it with stdout and the exit code. The subprocess fixture rejects when the socket closes before the first chunk. - The Promise-count test runs its child with the collector off, so a count is a number of allocations. It fails when a collection still ran.
…t-in function promiseResolvedWithFast asked JSC's isThenFastAndNonObservable() whether a promise is plain. That function reads a watchpoint on Promise.prototype.then, and the watchpoint also ends when the prototype is frozen or sealed, when `then` is made read-only, and when `then` is replaced and put back. In those realms every promise got a follower, although then() was still the built-in function. The helper now compares the function itself when the watchpoint has ended, so a direct pull(), an async-iterable next() and the spec stream callbacks take a plain promise as it is there too. The spawned tests run with test.concurrent.
There was a problem hiding this comment.
I re-reviewed the latest push (0dfb3c8) and found no bugs; because this changes when native stream reactions call a user-visible then() and adds a hand-rolled fallback to JSC's watchpoint, a maintainer's look at the semantics is still worthwhile.
What was reviewed on this push:
hasUnpatchedTheninsrc/jsc/bindings/webcore/streams/WebStreamsMisc.cpp: the fallback is non-throwing (getDirectOffset/getDirectonly), checks the promise structure before the prototype slot, rejects accessor/custom attributes, and compares againstpromiseProtoThenFunction(); cross-realm promises take the slow path and come back as themselves. One design point for a human: when the watchpoint is gone butthenis intact, Bun now attaches reactions directly where JSC's ownawaitwould still callthen()— unobservable in practice, but it is a deliberate divergence from the engine's rule.promiseForReactionsSlownow runs under a throw scope and marks the original cell handled unconditionally when a follower takes the reactions; the threepull()/next()callers andcancelFromSinkall check for an exception before using the result.pullFunction()/cancelFunction()/thisValue()going private: no remaining external callers insrc/jsc/bindings/webcore/streams; thecallDirectPullnull-source path keeps the same "underlyingSource.pull is not a function" message.driveAsyncIterator: athengetter that throws or cancels mid-next()is handled by the exception check afterpromiseForReactionsand the existingm_cancelledchecks inasyncIterHandleNextResultand the fulfilled handler.
Extended reasoning...
The PR touches seven files in the WebStreams C++ bindings (direct stream pull/cancel, the async-iterable body source, and the shared promise helpers) plus about 900 lines of new tests, changing how a promise returned by user callbacks is followed through its own then(). It touches no auth, crypto, or injection surface, but it does decide when native code invokes a user-overridable then() and adds a fallback that compares the Promise.prototype.then slot directly when JSC's watchpoint has fired. The bug hunt ran dry with no findings, the prior inline threads on throw scope and markAsHandled are addressed in code, and the latest commit's fallback and test.concurrent changes read correctly. The change is not approvable automatically because the microtask-ordering and then()-call semantics are a design decision across several consumers (reader, Response.text, Bun.serve, Bun.write) that a maintainer should weigh, and the Promise-count heapStats tests depend on allocation thresholds.
Problem
pull()and an async-iterable body'snext()never callthen()on a returned promise. A returnedBun.$orBun.sqlquery never starts. A body that waits for it never ends.JSDirectStreamController.cpp:731,BunAsyncIterableSource.cpp:346) reads the promise's state.asyncplusawaitworks.Fix
promiseResolvedWithFast, the rule of spec stream callbacks. A promise without the built-inthen()(a subclass, an own or replacedthen) gets a follower, a new promise resolved with it. A job callsthen()once.thenfunction when JSC's watchpoint on it has ended, as afterObject.freeze(Promise.prototype). Such a realm costs no follower.JSDirectStreamSourcecalls a direct source's functions. New, not restored: native-sinkpull()(Bun.serve,Bun.write) and sink-sidecancel().direct-stream-returned-promise.test.ts(26 of 33 fail on main),body-async-iterator.test.ts(12 of 15). Self-reviewed: 32 concerns raised, 29 addressed.Background
type: "direct") writes to its consumer. A whole-body consumer ends the body whenpull()'s promise resolves.then()called in place (sql: consolidate the open Bun.sql, bun:sqlite and node:sqlite pull requests #45304). It fails when the state never carries the result (p-lazy).Downsides
then()that never calls back hangs anext()body. One that throws fails it. Node and 1.3.14 agree.pull()that writes from events and returns a lazy promise ends the body when it resolves. A reader calls it again.pull()without one, 0 cells, +1,664 B text.Notes
Scope
The fuzz report names six places that wait on a returned promise by its state. The
Bun.servehandlers are in #46075. This PR takes the two stream places. It is based on main and shares no code with the other PRs of the family (#45901, #45843, #41008). The two HTMLRewriter places are not here: see "Not changed".Repro
Binaries: official 1.3.14, a release build of main, a release build of this branch. The count of
then()calls is in brackets.pull(), read by a reader,Response.text()orBun.readableStreamToBytespull(), read by aBun.servebody,Bun.spawnstdin orBun.writecancel()after the client left aBun.servebodynext()ofnew Response(asyncIterable)Node 26.3.0 answers the last row with 1 call for each result. The first and the last row are the regression from #33193 (92459cd).
The rule, in
then()callsOne process for each cell. The returned value is:
then()thenpropertyPromise.prototype.thenis replacedawait, Node and Bunpull(), Node and Bun mainnext(), Node 26.3.0next(), Bun 1.3.14next(), mainnext(), this PRpull(), Bun 1.3.14pull(), mainpull(), this PRnext()now counts as Node does. A directpull()counts as 1.3.14 did.await.awaitdoes not call an ownthenof a plain promise (columns c and d).pull(): no release follows a plain thenable there. This PR does not change that.pull()returns a promise that Bun made, andreactionPromiseSlowexempts it. A replacedPromise.prototype.thensees only the promises thatnext()returned. A test covers it.What changes for a value, from main to this PR
then(): 0 calls ofthen()become 1, from a job. A subclass that overrides nothing counts too.thenis not callable (promise.then = undefined): Bun waits on the state, as on main.thengetter that throws, or athen()that throws: the body fails with that error. Main does not readthen.then()that never calls its callbacks:next()gets no answer, as in Node and 1.3.14. A directpull()whose work closes the controller still answers.p-lazy(fulfilled when it is made, works inthen()): main ends the body at once. The work never runs. This PR runs the work and waits for it.pull()on main report one whenthen()does not subscribe to the promise.then().cancel()(a client abort, a write error):then()is now called once, asreader.cancel()already does on main. Nothing waits for the result, as before.thenis still the built-in function: see "A realm where JSC's watchpoint has ended".then()runs in the async context of thepull()ornext()call that returned the promise (checked withAsyncLocalStorage). streams: run every callback of a direct source in the stream's AsyncLocalStorage context #41561 wants to change that context for two of the callers. A test here records that this PR does not move it.A realm where JSC's watchpoint has ended
JSC keeps a watchpoint on
Promise.prototype.then.JSPromise::isThenFastAndNonObservable()reads it, and on mainpromiseResolvedWithFastasks only that function whether a promise is plain. These end the watchpoint for the life of the realm, althoughthenis still the built-in function:Object.freeze(Promise.prototype)orObject.seal(Promise.prototype)thenthat is made read-onlythenthat is replaced and then put backOn main every promise then gets a follower at the spec callbacks, for example at a default stream's
pull(). An earlier revision of this PR added the same cost at a directpull()and atnext(). The fuzz report measured that revision: 513 ms on main and 1,000 ms on the PR for 1,000,000 one-byte chunks in a frozen realm.promiseResolvedWithFastnow makes its own test when the watchpoint has ended (hasUnpatchedThen). The promise must have the plain promise structure of its realm, and the ownthenof that realm'sPromise.prototypemust be a data property that holds the built-in function. Such a promise takes internal reactions as it is. A replacedthen, an accessor, an ownthenand a subclass still get a follower.Cells for each read (
Promise,Function,Object), fromheapStats()slopes with the collector off. Release builds of main (449877f) and of this branch (0dfb3c8).Promise.prototypeasync pull()async pull()thenread-only, or replaced and put back, mainUser-space instructions for each iteration in a frozen realm. The method is under "Measurements".
arrayBuffer()next()is already settledpull()returns a pending promiseasync pull()already settledpull()async pull()thenproperty, once for each returned promise. No cell is allocated, andread()is answered in the same microtask turn as on main.arrayBuffer(): 317 (355) ms on main, 321 (337) ms on this PR. Frozen realm, default stream with anasync pull(): 712 (863) ms on main, 387 (506) ms on this PR.JSPromise::isThenFastAndNonObservable(). That is a change in WebKit and is not part of this PR.A
pull()that writes from eventsResponse.text(),readableStreamToBytes,Bun.serveorBun.writehello worldhello world, 1pull()callhellopull()runs again for each later readasync pull()withawait sql, main and this PRhellopull()runs again for each later readpackages/bun-types/bun.d.ts:581). The stream "ends when it resolves". A reader "callspull()again for a later read, once the previous call has settled". On main the promise of the query never settles, because nothing starts it.Bun.serveandBun.writethe latec.write()throwsThis HTTPResponseSink has already been closed(orFileSink). Theasyncspelling does the same on main.pull()that writes on every call and never closes by itself is an endless stream for a reader. With a SQLiteBun.sqlquery that loop did not return to the event loop in my probe: the 20 ms timer never fired. Theasyncspelling does the same on main.Real values
p-lazy5.0.0,postgres3.4.9, Postgres on 127.0.0.1. Each query row is apull()that writesdone, returns the value and does not close, read byResponse.text().p-lazyfrom apull()that writesa, waits, writesb, closesababp-lazyfromnext()Async iterator result is not an object)ok, as in Nodepostgresquery frompull()doneBun.sqlquery (Postgres) frompull()Bun.$command frompull()In TypeScript only the
Bun.sqlquery compiles as the result ofpull()(void | PromiseLike<void>). ABun.$command needs a cast.Not changed
element(),comments(),text(), documentend()). No release follows a promise there, and workerd 1.20261019.1 reads the state too (0then()calls, no answer). To follow there is a new behaviour and a difference from workerd. A maintainer has to decide that first.pull().close(reason)hook. The hook is not in the docs or inDirectUnderlyingSource, and main discards its result at each of its three call sites. It is a few lines inJSDirectStreamSource::close()if it must start a returned promise too.WritableStreamandTransformStream. They already follow.Precedents
expect(p).resolvescallsthen()once whilepis pending (call_then_if_pending,src/runtime/test_runner/expect.rs:475). It then reads the state ofp.promise_to_awaitfollows a pending promise through promise resolution and reads a settled promise by its state.Questions for a maintainer
pull(). The precedents above read a settled promise (column b) by its state. With that rule here,p-lazyfrompull()stays broken andnext()differs from Node. One status test inpromiseMayNeedFollowerswitches the rule.JSDirectStreamSourcebecomes the only caller of a source's functions. The alternative is the same helper at the four call sites, with public accessors. That diff is smaller, and a fifth call site can skip the helper.html_rewriter.rs, tests, docs). It needs a rebase on Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021. I can open it if the difference from workerd is wanted.thenfunction. It is inpromiseResolvedWithFast, so default streams change in a realm with a frozenPromise.prototypetoo. There the spec's "a promise resolved with p" calls the built-inthen()from a job, and Bun now attaches its reaction topat once. The difference is one microtask turn and the read ofconstructor. The test can move topromiseForReactionsSlowif default streams must keep main's timing in such a realm.Measurements
Release builds of main (449877f) and of this branch (0dfb3c8), linux-x64, one build directory.
size: text 107,557,538 to 107,559,202 B (+1,664).dataandbssdo not change. The stripped file is 107,730,656 B on both.cancelFromSink664 B,hasUnpatchedThen337 B,reactionPromiseSlow247 B,promiseForReactionsSlow211 B. Grown:callDirectPull+334,consumeDirectStreamToArrayBuffer+110,driveAsyncIterator+94.User-space instructions for each iteration in an untouched realm (
perf_event_open, slope from N to 2N with N = 100,000, median of 5 runs, concurrent JIT and GC off). Main measured twice gives the noise floor. Every fixture returns plain promises only.arrayBuffer()next()is already settledpull()returns a pending promiseasync pull()already settledpull()async pull()promiseForReactionsSlow0,promiseResolvedWith0 andhasUnpatchedThenat most 1 for the whole run. At the default stream: one call of the plain-promise test for eachpull(), on main and on this branch.hasUnpatchedThenonce for each returned promise andpromiseResolvedWith0 on this branch. On main the default stream callspromiseResolvedWithonce for eachpull().heapStats()slopes with the collector off: plain promises give the same counts on both binaries. Each followed value costs +2Promise, +4Function, +1Object, +1FullPromiseReaction.Promise.prototype.thenreplaced by another function, each pending result costs 1 call of the replacement, +2Promiseand +2Function. A default streampull()on main costs the same.Tests
test/js/web/streams/direct-stream-returned-promise.test.ts(new, 33 tests). 26 fail on main. The other 7 guard code that the diff adds or moves:thenthat is not callable (3 tests)pull()pull()cancel()reportsthen()does not subscribe totest/js/web/fetch/body-async-iterator.test.ts(+15 tests). 12 fail on main.test/js/web/streams/direct-stream-contract.ts(+6 shapes, run bystreams.test.jsandserve-direct-readable-stream.test.ts): 92 new cells, 59 fail on main. The 441 old cells give the same result on both builds.then() was not called ...). None is a timeout.streams.test.js(801 pass),serve-direct-readable-stream.test.ts(193 pass),async-iterator-stream.test.ts(139 pass),wpt-streams.test.ts(1175 pass),readablestreamtoarraybuffer.test.ts(2 pass), and the other files oftest/js/web/streams/. One test ofstreams-leak.test.tstimes out on the debug build, on main too. The two test files above also pass withBUN_JSC_validateExceptionChecks=1.Promisecells in a child withBUN_JSC_useGC=0, one for each realm: untouched, frozen, andthenreplaced and put back. A count is then a number of allocations. On main they fail: the lazy promise in each child is not followed, and the default stream takes 4.0 promises for each read in the last two realms. Against a build that makes a follower for every promise, the untouched realm gives 3.00 and 2.00 promises for each read. This branch gives 2.00 and 1.00.test.concurrent.Self-review: the 3 concerns not taken
bun_jscface for the helper. No caller outside the streams module remains, because the HTMLRewriter change is not in this PR.Bun.sqlrow on SQLite, which needs no service. I ran the Postgres rows by hand (table above).[human-review] gate passed · iteration 0 · 11 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file