Skip to content

streams: use Web IDL "a promise resolved with" semantics for callback results - #32620

Closed
alii wants to merge 8 commits into
mainfrom
ali/streams-webidl-promise-resolved-with
Closed

streams: use Web IDL "a promise resolved with" semantics for callback results#32620
alii wants to merge 8 commits into
mainfrom
ali/streams-webidl-promise-resolved-with

Conversation

@alii

@alii alii commented Jun 23, 2026

Copy link
Copy Markdown
Member

Web IDL "a promise resolved with x" is NewPromiseCapability + Resolve(x) — always a fresh promise. Promise.resolve(x) returns x unchanged when x is already a Promise and skips the assimilation hop, which WPT's transform-streams microtask-ordering tests observe.

Changes

why
shieldingPromiseResolve$newPromise() + $resolvePromise() Spec-exact Web IDL. The wrapper for every async stream callback (start/pull/cancel/write/close/abort/transform/flush). Matches Node and the whatwg/streams ref-impl.
writableStreamDefaultControllerStart$shieldingPromiseResolve(startAlgorithm()) SetUpWritableStreamDefaultController step 17. TransformStream's startAlgorithm returns its startPromise capability; Promise.$resolve would short-circuit.
setUpWritableStreamDefaultControllerFromUnderlyingSink → raw startMethod.$call(...) UnderlyingSinkStartCallback IDL return type is any — no promise conversion at that layer; the single wrap is step 17. Prevents double-wrap.
readDirectStream closePromiseCapability removal Dead — nothing assigned it.
readablestreamtoarraybuffer.test.ts → sync start() The test pins that Bun.readableStreamToArray returns an InternalPromise. Its async start() was incidental setup that had counter==0 only because of the old short-circuit; per spec the wrap does call public .then for thenables (Node matches).
.then-observability test (new, subprocess) Asserts the spec behavior: exactly 2 .then calls for two async start()s. toBe(2) catches double-wrap regression. Subprocess-isolated because patching Promise.prototype.then permanently invalidates JSC's promiseThenWatchpointSet for the process.
@@species test → subprocess + .todo Upstream JSC bug (oven-sh/WebKit#256): promiseResolveThenableJobFastSlow bare-returns on SpeciesConstructor throw. Subprocess so a pristine watchpoint routes to the FastSlow path under test. Un-todo once WEBKIT_VERSION bumps.
[[started]] timing tests (4) Pin the exact hop count for start() → undefined / → Promise / no start() / TransformStream.

Perf (release, macOS arm64, 100k no-op transform writes)

sync async
before (Promise.$resolve) 0.47 µs 0.46
after 0.50 (+6%) 0.50 (+9%)
Node 0.87

The earlier $isPromise+$enqueueJob iteration (a JS-level workaround for the JSC bug) measured 0.56 async (+22%); the spec-exact intrinsic is both simpler and faster.

Unblocks

#32595 (transformer.cancel) → 11/11 WPT cancel.any.js. #32601 (transform-streams WPT) → 132/133. The one remaining failure is an unrelated pre-existing hang.

… results

Web IDL "a promise resolved with x" is `new Promise(r => r(x))` — always a
fresh promise, with the thenable-assimilation hop observable in the spec's
microtask ordering. `Promise.resolve(x)` returns x unchanged when x is
already a native Promise and skips that hop; the whatwg/streams ref-impl's
`promiseResolvedWith` explicitly avoids it for this reason.

`shieldingPromiseResolve` (the wrapper every `$promiseInvokeOrNoop*` call
goes through for start/pull/cancel/write/close/abort/transform/flush across
all three controller types) used `Promise.$resolve(result)`, and
`writableStreamDefaultControllerStart` wrapped its `startAlgorithm()` result
the same way. When the result is already a Promise — TransformStream's
`startAlgorithm` returns the constructor's startPromise capability — the
short-circuit shifts `[[started]]` one microtask early relative to the spec,
which is observable in the WPT transform-streams tests that depend on a
controller-abort/cancel reaction observing the writable mid-"erroring"
rather than already "errored".

Both sites now go through `$newPromise()` + `$resolvePromise()`, matching
the ref-impl. No callers depend on the identity short-circuit (every
consumer only `.$then()`s the result).

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Closed: superseded by #33193, which rewrites the streams builtins in C++ and removes StreamInternals.ts, so the JS-side promise-wrapping this PR corrected no longer exists. The upstream JSC @@species fix surfaced along the way lives on independently at oven-sh/WebKit#256.

@coderabbitai

coderabbitai Bot commented Jun 23, 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

shieldingPromiseResolve now creates fresh promises and resolves them through $resolvePromise(). Writable stream startup now uses that path directly, and tests were added for start timing, microtask ordering, and then-hook behavior.

Changes

Fresh-promise assimilation in Streams builtins

Layer / File(s) Summary
Fresh-promise assimilation core implementation
src/js/builtins/StreamInternals.ts
Documentation is expanded to describe observable promise-with-x assimilation and microtask ordering differences. shieldingPromiseResolve now allocates a fresh promise with $newPromise() and resolves it through $resolvePromise().
WritableStream start promise handling
src/js/builtins/WritableStreamInternals.ts
writableStreamDefaultControllerStart uses $shieldingPromiseResolve(startAlgorithm.$call()) before chaining. setUpWritableStreamDefaultControllerFromUnderlyingSink now calls startMethod.$call(underlyingSink, controller) directly and leaves promise wrapping to controller start handling.
Start timing and microtask ordering tests
test/js/web/streams/streams.test.js
New tests cover WritableStream and TransformStream start timing across fulfilled, undefined, missing, null-prototype, and throwing-@@species promise cases. The suite also records microtask ordering with an observer helper and adds a subprocess check for async start() behavior.
ReadableStream thenable contract test
test/js/bun/util/readablestreamtoarraybuffer.test.ts
Comments and setup now pin synchronous ReadableStream start behavior by using a non-async start(controller) callback.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adopting Web IDL "a promise resolved with" semantics for stream callback results.
Description check ✅ Passed The description covers the PR purpose, implementation changes, and verification notes, though it doesn't follow the template headings exactly.
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.

Comment thread src/js/builtins/WritableStreamInternals.ts Outdated
534cc2a switched shieldingPromiseResolve to $newPromise() +
$resolvePromise(p, result) to get the Web IDL 'a promise resolved
with' two-hop microtask ordering the WPT transform-streams/cancel
tests depend on. But $resolvePromise does thenable assimilation via
Get(result, 'then') and invokes it when it differs from the builtin,
so a monkey-patched Promise.prototype.then is reached for every
async underlying-source/sink/transformer callback result.
readablestreamtoarraybuffer.test.ts pins exactly that.

For native-promise results, replicate PromiseResolveThenableJob's
timing manually: $enqueueJob a job that chains through the intrinsic
.$then. Same two-hop ordering (WPT cancel.any.js stays 11/11),
Promise.prototype.then is not touched.
writableStreamDefaultControllerStart now routes through the same
helper instead of new Promise(r => r(startResult)), which had the
same Get(x, 'then') exposure.
@alii

alii commented Jun 23, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Closed: superseded by #33193, which rewrites the streams builtins in C++ and removes StreamInternals.ts, so the JS-side promise-wrapping this PR corrected no longer exists. The upstream JSC @@species fix surfaced along the way lives on independently at oven-sh/WebKit#256.

Comment thread src/js/builtins/WritableStreamInternals.ts
UnderlyingSinkStartCallback's IDL return type is `any`, so Web IDL
"invoke" performs no promise conversion at that layer. The single
"a promise resolved with startResult" wrap happens in
SetUpWritableStreamDefaultController step 17 (via
$shieldingPromiseResolve in writableStreamDefaultControllerStart).

Going through $promiseInvokeOrNoopMethodNoCatch here applied an extra
$shieldingPromiseResolve wrap, which with the new always-fresh-promise
semantics delayed [[started]] by ~2 microtask ticks for
new WritableStream({start}). Matches ReadableStreamDefaultControllerStart
and the whatwg/streams reference implementation.

Adds microtask-ordering tests that match Node exactly for both the
promise-returning and sync-returning start() cases.
Comment thread src/js/builtins/StreamInternals.ts Outdated
robobun and others added 2 commits June 23, 2026 13:07
r.$then is a prototype-chain lookup of @then, so a start()-returned
native promise with a null prototype would throw TypeError inside the
$enqueueJob callback and leave the wrapper pending forever. And even
with @then found, intrinsic Promise.prototype.then runs
SpeciesConstructor(this), so a throwing @@species on the result threw
inside the job the same way.

Call $Promise.prototype.$then.$call(r, ...) (independent of r's
prototype) wrapped in try/catch that rejects the wrapper on abrupt
completion, matching PromiseResolveThenableJob step 3. A throwing
@@species now errors the stream instead of leaving [[started]] pending
plus an uncaught exception; null-prototype results flip [[started]]
and writes proceed.

Tests cover both tamper vectors; the @@species case now matches Node.
Comment thread src/js/builtins/WritableStreamInternals.ts

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This adds an extra microtask. Can we avoid that?

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

The hop itself is the point of the PR: Web IDL "a promise resolved with x" is new Promise(r => r(x)), which for a thenable x enqueues a PromiseResolveThenableJob before the wrapper settles. Promise.resolve(x) short-circuits that when x is already a native Promise, which is what main does and why the transform-streams WPT ordering tests in stacked #32601 fail there. Node takes the hop; the whatwg/streams ref-impl takes the hop.

It only fires when the callback returns a native Promise. For a non-thenable result (start() {}, write() returning undefined, etc.) $resolvePromise(p, x) fulfills p synchronously and the tick count is identical to main.

What can go: the $enqueueJob + three closures. The original commit on this branch (534cc2a) was just

export function shieldingPromiseResolve(result) {
  const promise = $newPromise();
  $resolvePromise(promise, result);
  return promise;
}

which produces the same tick timing (JSC's JSPromise::resolvePromise enqueues PromiseResolveThenableJobFast natively for a native-promise input) with no JS-level job or closures. 72b45a2 swapped it for the explicit $enqueueJob only because $resolvePromise falls back to Get(result, "then") once Promise.prototype.then has been replaced, and test/js/bun/util/readablestreamtoarraybuffer.test.ts pins counter === 0 for exactly that. With the simple form the test sees counter === 1, which is what Node and the spec give.

If that test's expectation can be relaxed to match spec, the 3-line $resolvePromise form is the one to keep: same ordering, native fast path, no closure allocation per async callback result. If not reaching a user-replaced Promise.prototype.then from stream internals is the priority, the hop has to be hand-rolled in JS and the current shape is about as tight as that gets. Happy to go either way; which do you prefer?

…point tests

shieldingPromiseResolve is now $newPromise() + $resolvePromise() — exactly
Web IDL "a promise resolved with x" (NewPromiseCapability + Resolve), no
$isPromise branch. The branch was a JS-level workaround for an upstream
JSC bug (PromiseResolveThenableJobFastSlow bare-returns on a
SpeciesConstructor throw, oven-sh/WebKit#256) plus a non-spec
tamper-proof preservation; the JSC fix belongs in WebKit, and per spec
the assimilation does observe a patched Promise.prototype.then (Node
matches).

readablestreamtoarraybuffer.test.ts: sync start(). The test pins that
Bun.readableStreamToArray returns an InternalPromise; the async start()
was incidental setup that only had counter==0 under the old
Promise.$resolve short-circuit.

streams.test.js: subprocess-isolate the .then-observability test
(patching Promise.prototype.then permanently invalidates JSC's
promiseThenWatchpointSet for the process) and the @@species test (so a
pristine watchpoint routes to the FastSlow path under test). @@species
test is .todo until oven-sh/WebKit#256 lands and WEBKIT_VERSION bumps.
.then-observability now asserts exactly 2 (catches double-wrap
regression). Null-proto comment corrected to spec semantics.

WritableStreamInternals.ts: drop the stale ".$then so a monkey-patched
.then is not reached" comment.

Benched (release, macOS arm64, 100k no-op transform writes):
sync 0.47→0.50 µs/write (+6%), async 0.46→0.50 (+9%). The previous
$isPromise+$enqueueJob shape measured 0.56 async (+22%); the spec-exact
intrinsic is both simpler and faster.

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu

@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: 3

🤖 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 `@test/js/web/streams/streams.test.js`:
- Line 348: The subprocess assertions in the streams tests are omitting stderr
from the expected payload, which makes failures harder to diagnose. Update the
relevant expect(...) calls in the streams test cases to include stderr alongside
stdout and exitCode, using a loose matcher for stderr instead of asserting it is
exactly empty. Use the existing subprocess assertion objects in the stream test
cases so both the speciesError case and the other affected assertion keep stderr
in the combined comparison.
- Around line 1443-1446: The test is using Bun.sleep(0) as a timer yield, but it
should wait for the stream startup condition instead. In streams.test.js,
replace the Bun.sleep(0) pause after creating the ReadableStream and
WritableStream with an await on a stream-visible completion signal tied to the
startup assimilation behavior, so the test advances only when the stream
condition is actually reached. Keep the Promise.prototype.then restoration in
place after the wait.
- Around line 328-349: The current test is marked as todo but still contains
executable subprocess assertions, so the throwing-@@species startup path is not
actually being validated. In streams.test.js, either convert the WritableStream
startup case in the `it.todo(...)` block to an active `it(...)` if the behavior
should now pass, or keep it as a true TODO by removing the subprocess body; use
the `WritableStream`/`start()` scenario and the `Bun.spawn` assertion as the
unique anchor when updating it.
🪄 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: 9344edbd-c448-4fbe-8b70-005652dbd0c8

📥 Commits

Reviewing files that changed from the base of the PR and between 9467b39 and 8eb1567.

📒 Files selected for processing (4)
  • src/js/builtins/StreamInternals.ts
  • src/js/builtins/WritableStreamInternals.ts
  • test/js/bun/util/readablestreamtoarraybuffer.test.ts
  • test/js/web/streams/streams.test.js

Comment thread test/js/web/streams/streams.test.js
Comment thread test/js/web/streams/streams.test.js Outdated
Comment thread test/js/web/streams/streams.test.js
…sks instead of Bun.sleep(0)

stderr in the asserted object surfaces the subprocess's diagnostic
output in the failure diff without pinning it to empty (ASAN/debug
builds emit benign warnings).

The .then-observability fixture now drains microtasks via
`for (let i = 0; i < 20; i++) await 1` (await on a non-thenable
doesn't reach the patched .then) instead of Bun.sleep(0). The
PromiseResolveThenableJob is a microtask, so no task-level yield is
needed. Matches the flush pattern in the adjacent null-proto test.

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

All prior review threads are resolved and no new issues found, but since this rewrites the promise-wrapping primitive used by every stream callback (with a documented +6-9%/write cost and a spec-mandated observable behavior change around .then), it's worth an explicit human sign-off on the final $newPromise()+$resolvePromise() shape.

Extended reasoning...

Overview

This PR changes shieldingPromiseResolve in src/js/builtins/StreamInternals.ts — the helper that wraps the result of every WHATWG stream callback (start/pull/cancel/write/close/abort/transform/flush) — from Promise.$resolve(x) (identity on native promises) to $newPromise() + $resolvePromise(x) (always-fresh, spec-exact Web IDL "a promise resolved with x"). It also restructures writableStreamDefaultControllerStart / setUpWritableStreamDefaultControllerFromUnderlyingSink so the spec's single wrap happens at exactly one layer, and adds ~150 lines of microtask-ordering / tamper-vector tests.

Security risks

None. No auth, crypto, permissions, or untrusted-input parsing is touched. The change is internal promise machinery; the only user-observable surface is microtask ordering and whether a user-patched Promise.prototype.then is reached during assimilation (now yes, per spec/Node).

Level of scrutiny

High. shieldingPromiseResolve sits on the hot path for every async stream operation in Bun. The PR documents a +6-9% per-write cost vs main, intentionally changes observable behavior (the new subprocess test pins exactly 2 .then calls where main saw 0), and modifies an existing test's setup (readablestreamtoarraybuffer.test.ts switched to sync start()) to keep its assertion passing. The implementation went through three distinct shapes during review ($resolvePromise$enqueueJob+closures → back to $resolvePromise), with the final shape chosen after a maintainer was explicitly asked "which do you prefer?". One edge case (@@species throw) is .todo'd pending an upstream JSC fix.

Other factors

  • All four of my prior inline comments (double-wrap on the underlying-sink path × 3 rounds; $enqueueJob tamper-proofing) were addressed and are resolved. The one remaining nit (TransformerStartCallback timing) was explicitly and reasonably deferred to stacked work.
  • All CodeRabbit threads are resolved; the bug hunter found nothing this run.
  • No CODEOWNERS cover the touched paths.
  • The robobun summary references "per Jarred's review" for the final shape and a maintainer issued adopt, so humans have clearly been engaged — but there is no formal approval on the timeline. Given the hot-path scope, perf trade-off, and intentional behavior change, an explicit human approval is the right gate rather than a bot shadow-approval.

@alii

alii commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

This adds an extra microtask. Can we avoid that?

Honest answer: no — the microtask is Web IDL "a promise resolved with x" (NewPromiseCapability + Resolve(x)). When x is already a Promise, Resolve queues a PromiseResolveThenableJob; that's the hop. Promise.resolve(x) skips it by returning x unchanged, which is why it's faster but spec-divergent. The whatwg/streams ref-impl explicitly comments this; Node uses new Promise(r => r(x)) here.

So the choice is:

WPT transform-streams no-op-transform µbench (release, arm64)
keep Promise.$resolve short-circuit 130/133 (2 documented test.todo for the ordering races) 0.47 µs/write
this PR 132/133 0.50 µs/write (+6% sync, +9% async; Node is 0.87)

The 2 tests this fixes are edge-case microtask-ordering races (controller.error() inside transformer.cancel(); abort() before [[started]] racing cancel()) — real code is unlikely to hit them. If the +6-9% on no-op transforms isn't worth 2 WPT passes, dropping this PR and keeping the test.todos in #32601 is a defensible call. The rest of the stack (#32595/#32601/#31728) doesn't depend on it — they just go from 11/11 → 10/11 and 132 → 130.

(Side finding while investigating: this surfaced an unrelated upstream JSC bug where PromiseResolveThenableJobFastSlow bare-returns on a SpeciesConstructor throw instead of rejecting — oven-sh/WebKit#256. That fix is independent of whether this PR lands.)

@alii

alii commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Superseded by #33193, which rewrites the streams builtins in C++ and deletes StreamInternals.ts. The JS-side promise-wrapping semantics this PR was correcting don't exist in the C++ implementation (it schedules its reactions natively), and its vendored WPT transform-streams suite passes.

@alii alii closed this Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants