Skip to content

Promise.any: give the AggregateError a stack, message, and no spurious cause - #340

Open
robobun wants to merge 1 commit into
mainfrom
farm/07410bab/promise-any-aggregate-error
Open

robobun wants to merge 1 commit into
mainfrom
farm/07410bab/promise-any-aggregate-error

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

The AggregateError rejected by Promise.any in Bun has:

  • err.stack === undefined (no own stack at all)
  • err.message === "" (V8/Node set "All promises were rejected")
  • a spurious own cause: undefined (Object.hasOwn(err, "cause") is true)

while a user-constructed new AggregateError(...) in the same process has a stack and no own cause. Result: the most common real-world AggregateError (a Promise.any fan-in where everything rejected) is effectively undebuggable.

try { await Promise.any([Promise.reject(new Error("r1")), Promise.reject("r2")]); }
catch (e) {
  console.log(typeof e.stack, JSON.stringify(e.message), Object.getOwnPropertyNames(e));
}
// node: string "All promises were rejected" ["stack","message","errors"]
// bun : undefined "" ["cause","errors"]

Cause

The five Promise.any rejection sites (promiseConstructorFuncAny, promiseAnySlow, promiseAnyRejectFunction, promiseAnySlowRejectFunction, promiseAnyResolveJob) call:

createAggregateError(vm, structure, errors, String(), jsUndefined());

ErrorInstance::finishCreation installs cause whenever the JSValue is non-empty, so jsUndefined() becomes an own cause: undefined. The null String() means no own message is set.

The async paths (promiseAnyResolveJob, the reject functions) run from a native internal microtask with no JS frames on the stack, so m_stackTrace is an empty vector. materializeErrorInfoIfNeeded's BUN_JSC_ADDITIONS branch is gated on !m_stackTrace->isEmpty(), so it never runs and .stack is never materialized.

Fix

  • Add createAggregateErrorForPromiseAnyRejection which passes an empty JSValue() for cause (so no own property is installed) and, under USE(BUN_JSC_ADDITIONS), sets the message to "All promises were rejected" to match V8. Route all five call sites through it.
  • In materializeErrorInfoIfNeeded, drop the !m_stackTrace->isEmpty() gate on the Bun branch so the embedder hook still runs for zero-frame errors and produces the "Name: message" header string. line/column/sourceURL are only set when there are frames to source them from.

With this change the repro above prints string "All promises were rejected" ["message","errors","stack"], matching Node.

The cause fix applies unconditionally (it is a spec-observable bug in upstream as well); the message string is Bun-only.

…s cause

The five Promise.any rejection sites in JSPromiseConstructor.cpp and
JSMicrotask.cpp called createAggregateError with message = String() and
cause = jsUndefined(). ErrorInstance::finishCreation treats a non-empty
JSValue cause as an explicit cause and installs it as an own property, so
every Promise.any rejection carried an own 'cause: undefined'. Route all
five call sites through createAggregateErrorForPromiseAnyRejection, which
passes an empty JSValue for cause and (under BUN_JSC_ADDITIONS) V8's
"All promises were rejected" message.

The async rejection paths run from a native internal microtask with no
JS frames on the stack, so the captured trace is empty and
materializeErrorInfoIfNeeded's BUN_JSC_ADDITIONS branch (gated on
!m_stackTrace->isEmpty()) never ran, leaving .stack undefined. Drop the
isEmpty() gate so the embedder hook still produces the "Name: message"
header for zero-frame errors; skip line/column/sourceURL when there are
no frames to source them from.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e559e68a-8221-4bb2-b9ad-1f33eaae635e

📥 Commits

Reviewing files that changed from the base of the PR and between b11589f and 6e83cef.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/runtime/AggregateError.h
  • Source/JavaScriptCore/runtime/ErrorInstance.cpp
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp
  • Source/JavaScriptCore/runtime/JSPromiseConstructor.cpp

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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs, but this changes user-observable error semantics in the core runtime and widens the onComputeErrorInfoJSValue embedder-callback contract to cover zero-frame stack traces, so it's worth a human look.

What was reviewed:

  • Verified JSValue() for cause correctly skips the own-property install in ErrorInstance::finishCreation (checks !cause.isEmpty()).
  • Confirmed all five Promise.any rejection sites are routed through the new helper; no other createAggregateError callers were affected.
  • Checked that the dropped !m_stackTrace->isEmpty() gate now sends empty vectors to Bun's callback for all zero-frame errors (not just Promise.any's) — the callback must tolerate that, which lives on the Bun side.
  • Confirmed line/column/sourceURL are correctly guarded by hasFrames so they aren't set to garbage/zero values on frameless errors.
Extended reasoning...

Overview

This PR touches four files in the JSC runtime to fix the AggregateError produced by Promise.any when all inputs reject:

  • AggregateError.h: adds an inline helper createAggregateErrorForPromiseAnyRejection that passes an empty JSValue() for cause (so no own cause property is installed) and, under USE(BUN_JSC_ADDITIONS), sets the message to "All promises were rejected".
  • JSPromiseConstructor.cpp and JSMicrotask.cpp: mechanically reroute the five existing createAggregateError(..., String(), jsUndefined()) call sites through the new helper.
  • ErrorInstance.cpp: in the USE(BUN_JSC_ADDITIONS) branch of materializeErrorInfoIfNeeded, drops the !m_stackTrace->isEmpty() gate so the Bun embedder hook runs even when the captured stack has zero frames, and wraps the line/column/sourceURL putDirects in a new hasFrames guard.

Security risks

None identified. This is error-metadata plumbing — no parsing, no untrusted input, no auth/crypto surface. The new code paths allocate a small string literal and pass an empty JSValue, both benign.

Level of scrutiny

Medium-high. The call-site rerouting and the cause/message change are straightforward and low-risk. However, the materializeErrorInfoIfNeeded change is a semantic change to core error materialization that applies to every ErrorInstance created with an empty stack trace under Bun, not just Promise.any's AggregateError. Previously such errors fell through to the non-Bun path (which is a no-op for empty traces, leaving m_errorInfoMaterialized false and .stack unmaterialized). Now they enter the Bun path, invoke vm.onComputeErrorInfoJSValue() with an empty vector, set m_errorInfoMaterialized = true, and install a .stack property. This relies on Bun's callback (which lives in the Bun repo, not here) handling an empty frame vector gracefully and producing a sensible header-only string. That's a cross-repo contract change that a human maintainer should confirm.

Other factors

  • The cause fix (empty JSValue() instead of jsUndefined()) is spec-correct: ECMA-262's PerformPromiseAny constructs the AggregateError with no options object, so no own cause should exist. This part applies unconditionally (not gated on Bun) and is a real upstream-observable bug fix.
  • The message string is Bun-gated, so upstream/non-Bun builds keep the spec-compliant empty message.
  • The hasFrames guard around line/column/sourceURL is correct — those out-params are only populated by the callback when there are frames to source them from, so writing them unconditionally would install line: 0 / column: 0 on frameless errors.
  • No new tests are included in this PR; verification presumably happens in the Bun repo's test suite.

Given the cross-repo callback-contract implication and the fact that this changes observable .stack behavior for a broader class of errors than the PR title suggests, I'm deferring to a human reviewer rather than auto-approving.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
6e83cef9 autobuild-preview-pr-340-6e83cef9 2026-07-25 03:28:39 UTC

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.

2 participants