Skip to content

[JSC] Create the errors of Promise.any and of promise self-resolution with no own cause - #895

Open
robobun wants to merge 1 commit into
mainfrom
robobun/07410bab/promise-errors-no-own-cause
Open

robobun wants to merge 1 commit into
mainfrom
robobun/07410bab/promise-errors-no-own-cause

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #340.

Problem

  • The AggregateError of Promise.any and the TypeError of a promise resolved with itself have an own cause: undefined. Object.hasOwn(error, "cause") is true, and util.inspect prints [cause]: undefined. Node and Bun 1.3.5 have no own cause.
  • Six call sites pass jsUndefined() as the cause: JSMicrotask.cpp:528, JSPromiseConstructor.cpp:1227, :1337, :1374, :1409, JSPromise.cpp:750. ErrorInstance::finishCreation defines cause for every non-empty value.

Fix

  • The six call sites pass the empty JSValue. Nothing else changes.
  • Correct because ECMA-262 defines only errors on this AggregateError. The other 17 native callers pass the empty value.
  • Verified: new JSTests/stress/promise-errors-have-no-own-cause.js exits 3 before, 0 after. Bun built against this branch passes 8 new rows that fail on Bun 1.4.3.

Background

  • ErrorInstance::create and createAggregateError take the cause by position. The empty JSValue means no cause. undefined is a real cause: new Error(m, { cause: undefined }) must define one.
  • Promise.any creates its error in five functions: a microtask job, the synchronous path, a reject element function, and two subclass variants.
  • Considered a Promise.any-only creator with V8's message (Promise.any: give the AggregateError a stack, message, and no spurious cause #340): ECMA-262 sets no message. Considered a signature that rejects an undefined cause: it rewrites upstream's callers, so it belongs upstream.

Downsides

Notes

Before and after. Promise.any([Promise.reject(new Error("r1")), Promise.reject("r2")]), read in a catch:

own keys Object.hasOwn(e, "cause") util.inspect(e) has [cause]: undefined e.cause = 1 is enumerable
node v26.3.0 stack, message, errors false no yes
Bun 1.3.5 errors, line, column, stack false no yes
Bun 1.3.6 and Bun 1.4.3 cause, errors true yes no
Bun debug build against this branch errors false no yes

Measurements.

  • Own cause differs from node v26.3.0 in 0 of 7 producer rows (was 7 of 7 on Bun 1.4.3-canary.1+367d939d9). Constructor controls changed: 0 of 2.
  • JSTests/stress/promise-errors-have-no-own-cause.js: exit 3 on the base jsc (autobuild-680ef47c, main), exit 0 on the jsc of this pull request (autobuild-preview-pr-895-f4adc68d), both linux-amd64-lto. Sites with an own cause: 6 of 6 -> 0 of 6. The debug builds give the same two exit codes (prebuilt debug jsc of 5e3554f, and a local debug build of this branch).
  • 7 of 7 test rows reach exactly their named creation line, and none of the other five (gdb breakpoint hit counts, jsc debug 5e3554f).
  • Per retained Promise.any rejection: Array +1.00, Error +1.00 (base +1.00 / +1.00). Own properties 2 -> 1.
  • Fork diff: 3 source files, +6/-6, 0 headers, 0 #if. 1 new JSTests file (80 lines). 0 upstream tests edited.
  • Bun rows: 16 of 16 cause values (8 rows) are wrong with Bun 1.4.3-canary.1+367d939d9 and 16 of 16 are right on the build with this branch. The control row passes on both.
  • Instructions per fully rejected Promise.any: 4747.8 -> 4594.2 (delta -153.5). Per fulfilled Promise.any: delta +0.8, which is inside the spread. Method: count of instructions:u, slope from N = 100000 to N = 200000 calls, median of 5 runs, jsc --useConcurrentJIT=0, linux-amd64-lto, spread at most 4.9 per call. perf is not installed on the machine that ran this. A 20-line perf_event_open wrapper made the count.
  • jsc text: x64 48,084,159 -> 48,084,127 bytes (delta -32), arm64 delta 0 (llvm-size, lto lanes, main 680ef47 against this pull request).
  • JavaScriptCore tests: test262 built-ins/Promise and built-ins/AggregateError (757 files, 1330 runs) have 0 failures on the base jsc, 0 on this pull request, 0 new. A small script ran them. It follows Tools/Scripts/test262/runner.py: the same scenarios and flags, and a run passes when jsc exits 0. The full suite is the CI test job: it passes on the three tested lanes (linux-amd64-lto, linux-amd64-lto-hardened, linux-arm64-lto).

Cost. Nothing is added. Each edited call is behind an existing guard: if (!count) in the Promise.any functions, if (resolution == this) [[unlikely]] in resolvePromise. One putDirect per error goes away.

Row to site. Each row of the test is named after the function that creates the error.

row creation site
Promise.any([Promise.reject(1), Promise.reject(2)]) promiseAnyResolveJob, JSMicrotask.cpp:528
Promise.any([]) promiseConstructorFuncAny, JSPromiseConstructor.cpp:1337
Promise.any([p]), p a rejected promise with an own then promiseAnyRejectFunction, JSPromiseConstructor.cpp:1374
DerivedPromise.any([]) promiseAnySlow, JSPromiseConstructor.cpp:1227
DerivedPromise.any([DerivedPromise.reject(1)]) promiseAnySlowRejectFunction, JSPromiseConstructor.cpp:1409
a resolving function called with its own promise JSPromise::resolvePromise, JSPromise.cpp:750
const p = Promise.resolve().then(() => p) JSPromise::resolvePromise, JSPromise.cpp:750

Why the fix is at the call sites. finishCreation cannot drop an undefined cause: the constructor path gives it the result of getIfPropertyExists(options, "cause"), and new Error(m, { cause: undefined }) must own cause. The test keeps that as a control. No code of Bun runs between the creation of these errors and their delivery to a handler.

Not changed, and why.

Relation to #340. This takes the empty JSValue as the cause at the five Promise.any sites from #340. It does not take the creator function, the message, or the change to ErrorInstance::materializeErrorInfoIfNeeded (the same change as #829, which is closed). #340 did not change JSPromise.cpp:750.

Upstream. WebKit main has the same six lines today (JSMicrotask.cpp:500, JSPromiseConstructor.cpp:1210, :1320, :1357, :1392, JSPromise.cpp:558). The patch and the test have no USE(BUN_JSC_ADDITIONS) and nothing specific to Bun, so upstream can take both as they are.

Rows for Bun. This is the test for the WebKit bump in oven-sh/bun that first includes this change (test/js/bun/jsc/webkit-upgrade-<sha>.test.ts). Run on a debug build of Bun main 5e182948be against the preview build of this pull request (bun bd --webkit-version=autobuild-preview-pr-895-f4adc68d test <file>): 9 pass. Run on Bun 1.4.3-canary.1+367d939d9: 8 fail, 1 pass (the control).

import { describe, expect, test } from "bun:test";
import vm from "node:vm";

describe("an error that JavaScriptCore creates without a cause has no own `cause`", () => {
  class DerivedPromise<T> extends Promise<T> {}

  const rejectionOf = (promise: PromiseLike<unknown>) =>
    promise.then(
      () => expect.unreachable(),
      (error: unknown) => error as Error,
    );

  // A promise with an own `then` gets a reject element function instead of the internal job.
  function rejectedPromiseWithOwnThen() {
    const promise = Promise.reject(1);
    promise.then = Promise.prototype.then;
    return promise;
  }

  // Each row is named after the function that creates the AggregateError.
  test.each([
    ["promiseAnyResolveJob", () => Promise.any([Promise.reject(1), Promise.reject(2)]), [1, 2]],
    ["promiseConstructorFuncAny", () => Promise.any([]), []],
    ["promiseAnyRejectFunction", () => Promise.any([rejectedPromiseWithOwnThen()]), [1]],
    ["promiseAnySlow", () => DerivedPromise.any([]), []],
    ["promiseAnySlowRejectFunction", () => DerivedPromise.any([DerivedPromise.reject(1)]), [1]],
  ])("Promise.any: %s", async (_site, run, errors) => {
    const error = (await rejectionOf(run())) as AggregateError;
    expect({
      prototype: Object.getPrototypeOf(error),
      ownCause: Object.hasOwn(error, "cause"),
      hasCause: "cause" in error,
      errors: error.errors,
    }).toEqual({ prototype: AggregateError.prototype, ownCause: false, hasCause: false, errors });
  });

  test.each([
    [
      "a resolving function",
      () => {
        const { promise, resolve } = Promise.withResolvers();
        resolve(promise);
        return promise;
      },
    ],
    [
      "a reaction job",
      () => {
        const promise: Promise<unknown> = Promise.resolve().then(() => promise);
        return promise;
      },
    ],
  ])("a promise resolved with itself from %s", async (_shape, run) => {
    const error = await rejectionOf(run());
    expect({
      prototype: Object.getPrototypeOf(error),
      ownCause: Object.hasOwn(error, "cause"),
      hasCause: "cause" in error,
    }).toEqual({ prototype: TypeError.prototype, ownCause: false, hasCause: false });
  });

  test("Promise.any in a node:vm context", async () => {
    const context = vm.createContext({});
    const error = await rejectionOf(vm.runInContext("Promise.any([Promise.reject(1)])", context));
    expect({
      prototype: Object.getPrototypeOf(error),
      ownCause: Object.hasOwn(error, "cause"),
      hasCause: "cause" in error,
    }).toEqual({
      prototype: vm.runInContext("AggregateError.prototype", context),
      ownCause: false,
      hasCause: false,
    });
  });

  test("a cause that the options give is an own property, also when it is undefined", () => {
    expect([
      Object.getOwnPropertyDescriptor(new AggregateError([1], "message", { cause: undefined }), "cause"),
      Object.getOwnPropertyDescriptor(new Error("message", { cause: undefined }), "cause"),
      Object.getOwnPropertyDescriptor(new AggregateError([1]), "cause"),
    ]).toEqual([
      { value: undefined, writable: true, enumerable: false, configurable: true },
      { value: undefined, writable: true, enumerable: false, configurable: true },
      undefined,
    ]);
  });
});

Suites run on that debug build of Bun main against the preview build: the rows above (9 pass), test/js/bun/jsc/webkit-upgrade-f59345e04a.test.ts (5 pass, it has a self-resolution row), test/js/bun/util/inspect-error.test.js (45 pass), test/js/node/v8/capture-stack-trace.test.js (71 pass), test/js/node/promise/reject-tostring.test.ts (1 pass) and the error block of test/js/bun/module-graph/module-graph.test.ts (9 pass). A local debug build of this branch (bun run build:local) gives the same results, with one difference: the self-resolution row of the upgrade test takes 5.3 s on the unoptimized local JavaScriptCore, which is above the default 5 s timeout. It passes there with a longer timeout.

… with no own `cause`

The five places that create the AggregateError of Promise.any, and
JSPromise::resolvePromise for a promise that is resolved with itself,
pass jsUndefined() as the cause. ErrorInstance::finishCreation defines
`cause` for every value that is not empty, so each of these errors has
an own `cause: undefined`.

ECMA-262 defines only `errors` on the AggregateError of Promise.any,
and no `cause` on the TypeError of a promise resolved with itself.

Pass the empty JSValue. That is the value every other native caller of
ErrorInstance::create passes when there is no cause.
@github-actions

Copy link
Copy Markdown

Preview build of f4adc68: autobuild-preview-pr-895-f4adc68d

@robobun
robobun marked this pull request as ready for review October 24, 2026 11:06

@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 reviewed this PR and didn't find any bugs. Because it is an observable behavior change on a spec-facing surface (Promise.any and self-resolution errors) in paths that CODEOWNERS routes to the JSC reviewers, a human look is still worthwhile.

What was reviewed:

  • Traced the empty JSValue through createAggregateError -> ErrorInstance::create -> finishCreation: the only read of cause is the isEmpty() guard, so the sentinel is safe on both finishCreation overloads.
  • Searched the three source files and the rest of runtime/ for any other spec-internal error still passing jsUndefined() as cause — none remain; the other native callers already pass the empty JSValue.
  • Checked the new stress test against JSTests/README.md conventions: it is pure JS in JSTests/stress, fails only by throwing, prints nothing, and the explicit { cause: undefined } control keeps the constructor path's own-property behavior pinned.
Extended reasoning...

The change replaces jsUndefined() with the empty JSValue() as the cause argument at six error-creation sites in JSMicrotask.cpp, JSPromise.cpp and JSPromiseConstructor.cpp (the five Promise.any AggregateError paths and the self-resolution TypeError), and adds one new stress test; no headers, no #if, no security-sensitive surface. The sentinel is consumed only by the !cause.isEmpty() guard in ErrorInstance::finishCreation, and the diff matches the pattern of the existing native callers in SuppressedError.cpp and NullSetterFunction.cpp. The bug hunt ran dry with no findings and the five ruled-out candidates all concerned downstream Bun consumers or test labeling rather than the engine change. Deferred rather than approved because /Source/JavaScriptCore and /JSTests are covered by the repository's CODEOWNERS file and the change alters observable JS semantics ("cause" in error, enumerability of a later error.cause = x), which a maintainer should sign off on.

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 0b519f82-dd61-43aa-9f2a-b2b08db48160
Commits

Reviewing files that changed from the base of the PR and between 680ef47 and f4adc68.

Files selected for processing: 4
  • JSTests/stress/promise-errors-have-no-own-cause.js
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp
  • Source/JavaScriptCore/runtime/JSPromise.cpp
  • Source/JavaScriptCore/runtime/JSPromiseConstructor.cpp

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

Walkthrough

Promise error construction now passes an empty JSValue instead of jsUndefined() in Promise.any rejection paths and promise self-resolution failures. A new stress test checks these errors for cause properties and verifies behavior when cause: undefined is supplied.

Changes

Promise error cause handling

Layer / File(s) Summary
Promise error construction
Source/JavaScriptCore/runtime/JSPromiseConstructor.cpp, Source/JavaScriptCore/runtime/JSMicrotask.cpp, Source/JavaScriptCore/runtime/JSPromise.cpp
Promise.any AggregateError creation paths and self-resolution TypeError creation now pass an empty JSValue instead of jsUndefined().
Stress test coverage
JSTests/stress/promise-errors-have-no-own-cause.js
The new test checks Promise.any rejection errors, self-resolution errors, AggregateError.errors descriptors and lengths, and whether explicit cause: undefined creates an own cause property.

Suggested reviewers: constellation

Priority: Low

Merge Risk: Minimal · up to f4adc

No concrete current-head regression or unresolved merge-blocking risk was identified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: preventing own cause properties on Promise.any and promise self-resolution errors.
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.
Description check Passed The description is detailed and relevant. It explains the problem, fix, affected call sites, behavior change, testing, performance impact, and upstream relationship. It does not include the template's…
Full details: Description check

Explanation

The description is detailed and relevant. It explains the problem, fix, affected call sites, behavior change, testing, performance impact, and upstream relationship. It does not include the template's explicit Bugzilla URL, Reviewed by NOBODY line, or a standalone bug-title heading, but the required technical context is substantially complete.

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

❤️ Share

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

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