Skip to content

Keep the error name and message in stack strings cached by the GC finalizer - #34408

Open
robobun wants to merge 3 commits into
mainfrom
farm/dd407043/error-stack-message-gc-finalizer
Open

Keep the error name and message in stack strings cached by the GC finalizer#34408
robobun wants to merge 3 commits into
mainfrom
farm/dd407043/error-stack-message-gc-finalizer

Conversation

@robobun

@robobun robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #34398

Repro

async function boom() { throw new Error("the message"); }
try {
  await boom();
} catch (e) {
  Bun.gc(true); // any GC between construction and first .stack access
  console.log(JSON.stringify({ message: e.message, stackHead: e.stack.split("\n")[0] }));
}

Before: {"message":"the message","stackHead":"Error"} (the message is dropped from the stack string). Expected, and what Node prints: "stackHead":"Error: the message". Sync-thrown errors were unaffected; async-thrown errors hit this reliably because nothing keeps the resumed async frames marked.

Cause

Two paths materialize the stack string:

  1. First .stack access: vm.onComputeErrorInfoJSValue() receives the ErrorInstance, reads name/message, and formats Error: the message\n at ....
  2. GC finalizer: when any captured frame is no longer marked, ErrorInstance::finalizeUnconditionally caches the stack string eagerly (so the frames can be released) via vm.onComputeErrorInfo(), which did NOT receive the instance. Bun's callback fell back to name = "Error", empty message, and that string was cached and later served verbatim by materializeErrorInfoIfNeeded.

Fix

  • Pass the error instance to the stack string callback WebKit#302 adds an optional VM::onComputeErrorInfoWithInstance callback (same shape as onComputeErrorInfo plus the JSObject*), preferred by ErrorInstance::computeErrorInfo when set. The existing callback is untouched, so the change is backward compatible.
  • Bun registers the new callback and derives name/message from the instance on the finalizer path. Inside the finalizer we must not allocate in the JS heap or run user code, so:
    • properties are read via Structure::getConcurrently + getDirect (a regular lookup can re-materialize a property table the GC just cleared, which allocates a GC cell during the end phase), mirroring sanitizedNameString's own-then-prototype lookup;
    • the instance is not forwarded to formatStackTrace, whose SyntaxError branch does a putDirect.
  • WEBKIT_VERSION points at the preview build of Pass the error instance to the stack string callback WebKit#302 (autobuild-preview-pr-302-4abb9e38). It should be bumped to the merged oven-sh/WebKit commit once that PR lands.

Verification

New tests in test/js/bun/util/error-gc-test.test.js (spawned subprocesses, so the GC/marking conditions match the issue): async-thrown Error, TypeError (name from the prototype), and a subclass with an own name property all keep Name: message as the stack head after Bun.gc(true); sync-thrown and primed-stack variants stay correct. All three bug cases fail on the unfixed build (bare Error" head) and pass with this change. Related suites (capture-stack-trace, circular-error-stack, inspect-error, error-prepare-stack-trace`) show no new failures.


[decide:webkit] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/error-stack-gc.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (e6da70150)

test/js/bun/util/error-stack-gc.test.js:
68 |     });
69 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
70 |     if (exitCode !== 0) {
71 |       throw new Error(`exited with ${exitCode}: ${stderr}`);
72 |     }
73 |     expect(JSON.parse(stdout)).toEqual(expected);
                                    ^
error: expect(received).toEqual(expected)

  {
    "message": "the message",
-   "stackHead": "Error: the message",
+   "stackHead": "Error",
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/util/error-stack-gc.test.js:73:32)
(fail) error.stack after GC keeps name and message > async-thrown Error, GC before firs
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (e6da70150)

test/js/bun/util/error-stack-gc.test.js:
(pass) error.stack after GC keeps name and message > async-thrown TypeError, name comes from the prototype [10.99ms]
(pass) error.stack after GC keeps name and message > async-thrown subclass with an own name property [10.87ms]
(pass) error.stack after GC keeps name and message > async-thrown Error, GC before first .stack access [11.93ms]
(pass) error.stack after GC keeps name and message > sync-thrown Error stays correct [10.80ms]
(pass) error.stack after GC keeps name and message > .stack primed before GC stays correct [10.54ms]

 5 pass
 0 fail
 5 expect() calls
Ran 5 tests across 1 file. [162.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/error-stack-gc.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (e6da70150)

test/js/bun/util/error-stack-gc.test.js:
(pass) error.stack after GC keeps name and message > async-thrown Error, GC before first .stack access [481.65ms]
(pass) error.stack after GC keeps name and message > async-thrown TypeError, name comes from the prototype [456.77ms]
(pass) error.stack after GC keeps name and message > sync-thrown Error stays correct [447.67ms]
(pass) error.stack after GC keeps name and message > .stack primed before GC stays correct [469.13ms]
(pass) error.stack after GC keeps name and message > async-thrown subclass with an own name property [533.89ms]

 5 pass
 0 fail
 5 expect() calls
Ran 5 tests across 1 file. [2.85s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 806ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/9] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[2/9] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[3/9] cxx obj/src/jsc/bindings/BunProcess.cpp.o
[4/9] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[5/9] gen cpp.rs (cppbind)
[5/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_base64 v
... (truncated)
diff hotspot
scripts/build/deps/webkit.ts               |  2 +-
 src/jsc/bindings/FormatStackTraceForJS.cpp | 60 ++++++++++++++++++------
 src/jsc/bindings/FormatStackTraceForJS.h   |  2 +-
 src/jsc/bindings/ZigGlobalObject.cpp       |  2 +-
 test/js/bun/util/error-stack-gc.test.js    | 75 ++++++++++++++++++++++++++++++
 5 files changed, 124 insertions(+), 17 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                        reads  edits  tests
scripts/build/deps/webkit.ts                    1      1      0
src/jsc/bindings/FormatStackTraceForJS.cpp      4      6      0
src/jsc/bindings/FormatStackTraceForJS.h        1      1      0
src/jsc/bindings/ZigGlobalObject.cpp            0      0      0
test/js/bun/util/error-stack-gc.test.js         0      4      0

…alizer

When a GC runs between an error being thrown and the first .stack
access, ErrorInstance::finalizeUnconditionally caches the stack string
eagerly so the captured frames can be released. That path went through
a callback that never received the ErrorInstance, so the cached string
began with a bare "Error" instead of "Name: message". Async-thrown
errors hit this reliably because nothing else keeps their resumed
frames marked.

JSC now exposes onComputeErrorInfoWithInstance (oven-sh/WebKit#302),
which also passes the instance. Register that callback instead and read
name/message there. Inside the finalizer this must not allocate in the
JS heap or run user code, so the properties are read with
Structure::getConcurrently (a plain get could re-materialize a property
table the GC just cleared) and the instance is not forwarded to
formatStackTrace's SyntaxError branch, which does a putDirect.

WEBKIT_VERSION points at the preview build of oven-sh/WebKit#302 and
should move to the merged commit once that lands.

Fixes #34398
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 9 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f3e30709-ee09-411f-8f6b-ce54be1560cd

📥 Commits

Reviewing files that changed from the base of the PR and between 17fe187 and e6da701.

📒 Files selected for processing (1)
  • test/js/bun/util/error-stack-gc.test.js

Walkthrough

Changes

The PR adds finalizer-safe lazy error.stack formatting with GC regression tests, passes error-instance context through JavaScriptCore callbacks, and updates the WebKit version from a commit hash to an autobuild preview tag.

Error stack GC formatting

Layer / File(s) Summary
Finalizer-safe error formatting
src/jsc/bindings/FormatStackTraceForJS.*, src/jsc/bindings/ZigGlobalObject.cpp
Direct property reads preserve error names and messages during finalizer stack formatting, with the error instance passed through the callback.
GC regression coverage
test/js/bun/util/error-stack-gc.test.js
Tests validate stack headers after forced GC across asynchronous, synchronous, prototype-derived, subclass, and pre-accessed stack cases.

WebKit build version

Layer / File(s) Summary
WebKit version tag
scripts/build/deps/webkit.ts
WEBKIT_VERSION now uses the autobuild-preview-pr-302-4abb9e38 tag for downstream prebuilt identity generation.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#33956 — Updates the same WEBKIT_VERSION constant with another preview tag.
  • oven-sh/bun#33961 — Makes a corresponding WEBKIT_VERSION preview-tag update.
  • oven-sh/bun#34138 — Updates the same WebKit version constant while its other changes are unrelated.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: GC finalizer stack strings now preserve the error name and message.
Description check ✅ Passed The description includes the fix, root cause, and verification steps, satisfying both required template sections.
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.

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

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:53 PM PT - Jul 16th, 2026

@robobun, your commit e6da701 has 1 failures in Build #74130 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34408

That installs a local version of the PR into your bun-34408 executable, so you can run:

bun-34408 --bun

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

Beyond the inline finding, I also checked whether the one-level prototype walk in getDirectPropertyInFinalizer misses multi-level Error subclasses — it matches sanitizedNameString's own-then-direct-prototype behavior, so no regression there. Also confirmed the old computeErrorInfoWrapperToString symbol has no remaining live callers (only a commented-out reference in BakeGlobalObject.cpp).

Extended reasoning...

This touches the GC end-phase finalizer path in JSC bindings and depends on an unmerged oven-sh/WebKit change (WEBKIT_VERSION is pinned to a preview-PR tag), so it needs human review regardless. The inline nit about tryGetValue() vs tryGetValueWithoutGC() covers the one substantive concern found; the note above records what else was examined so it isn't re-explored.

Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
The pre-existing stress tests in error-gc-test.test.js are too slow for
debug+ASAN runs with the default per-test timeout, so the new tests get
their own file.

In the finalizer branch, read name/message with tryGetValueWithoutGC:
plain tryGetValue resolves ropes through a path that calls
heap.reportExtraMemoryAllocated, which must not run during the GC end
phase. This matches the MustNotTriggerGC lookup in ErrorStackTrace.cpp.
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review: the finalizer branch now uses tryGetValueWithoutGC for both the name and message reads (17fe187), consistent with the MustNotTriggerGC lookup in ErrorStackTrace.cpp. Plain tryGetValue would resolve ropes through heap.reportExtraMemoryAllocated during the GC end phase.

Also moved the new tests into test/js/bun/util/error-stack-gc.test.js: the pre-existing stress tests in error-gc-test.test.js exceed the default per-test timeout under debug+ASAN, independent of this change.

I tried to add a reassigned-rope-message case, but every shape that assigns a property on the error before the throw keeps the captured frames marked, so the finalizer path never fires and the test passes even without the fix. Left it out rather than ship a test that cannot fail.

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

🤖 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/bun/util/error-stack-gc.test.js`:
- Around line 63-76: Replace the manual cases loop around the subprocess test
with a describe.each(cases) parameterized block, preserving each case’s label,
script, expected value, and concurrent test execution. Keep the existing
Bun.spawn, output parsing, exit-code validation, and expectation logic inside
the parameterized test body.
🪄 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: 33c03473-40e9-4c01-a650-c86340189f06

📥 Commits

Reviewing files that changed from the base of the PR and between 2f5e810 and 17fe187.

📒 Files selected for processing (5)
  • scripts/build/deps/webkit.ts
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • src/jsc/bindings/FormatStackTraceForJS.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/bun/util/error-stack-gc.test.js

Comment thread test/js/bun/util/error-stack-gc.test.js Outdated
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: everything related to this diff is green; the new tests pass on all lanes. The remaining red is test/js/web/timers/timer-heap-race.test.ts on the x64-asan lane, a pre-existing failure on main (also red in builds without this change) that is being fixed separately. The other red lanes passed on retry.

Merge order: oven-sh/WebKit#302 should land first, then WEBKIT_VERSION here moves from the preview tag to the merged commit.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

This came up again independently (worker_threads tests under ASAN, see below), so adding what that second look turned up rather than opening another PR for the same bug.

The trigger is broader than async throws. The finalizer path runs whenever any callee or code block captured in the trace is collected before the first .stack read, so a plain synchronous new inside a callback that becomes unreachable afterwards is enough:

const errors = [];
[1].forEach(x => { errors.push(new TypeError("bad " + x)); });
Bun.gc(true);
console.log(errors[0].stack.split("\n")[0]);                     // bun 1.4.0: "Error"    node: "TypeError: bad 1"
console.log(require("util").inspect(errors[0]).split("\n")[0]);  // "Error" as well (improveStack only rewrites headers that start with err.name)

Same result on 1.4.0 for errors created by vm.runInThisContext(...), new Function(...)() and indirect eval, and for an uncaught error thrown at the top level of an eval: true worker (the program code is collectable as soon as it has run), which is how it shows up intermittently in test/js/node/worker_threads/worker_threads.test.ts when the whole file runs under the debug build. Subclasses with an own name, a name reassigned after construction, and name = "" all degrade the same way; without the GC the header matches node in every one of these cases (MyError: msg, Renamed: msg, msg). The synchronous shape above does not depend on async frame lifetimes, so it may be a more robust fixture for the tests here than the async one.

On the WebKit side, oven-sh/WebKit#302 has a changes requested review: the instance must not be read from the finalizer. Unpinned property tables are dropped during marking, so by the time ErrorInstance::finalizeUnconditionally runs even an own property read can allocate a PropertyTable cell; working around that from the callback (as the current branch does with getConcurrently) is what the review is objecting to. A shape that avoids touching the instance there: leave the finalizer callback instance-free and have it cache only the frame lines, then add the Name: message header when materializeErrorInfoIfNeeded publishes the cached string, which runs in the mutator with the instance live, exactly where the onComputeErrorInfoJSValue path reads name and message today. That also preserves the lazy semantics node has (a name assigned before the first .stack read is reflected regardless of GC timing). The ErrorInstance::create overload that seeds m_stackString with an already complete stack (structured clone) would need to be told apart from a finalizer-produced string, for example with a bit that computeErrorInfo sets.

There is no bun-only fix: the string callback never sees the error object, and bunErrorData is never populated, so either way this stays blocked on a WebKit change.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Another sighting of this bug, in case it helps get it over the line: the debug-build flake in test/js/third_party/express/res.sendFile.test.ts ("should error missing path") is the same thing. Express throws TypeError('path argument is required to res.sendFile'), finalhandler renders err.stack into the 500 body, and when a GC happens in between the body reads Error<br> at sendFile (...), so supertest's /path.*required/ check fails. This PR fixes that run too.

I came at it independently and ended up with the same design (closed my duplicate of oven-sh/WebKit#302 as oven-sh/WebKit#445), so no competing PR. Two things from that attempt that may be worth folding in here:

  1. A test that fails on release builds as well. The gate output above shows the release build without the fix passing this PR's test. Creating each error inside a function made with new Function (so the callee of the frame it records is unreachable as soon as it returns), yielding once, then Bun.gc(true), takes the finalizer path deterministically: 11/11 cases come out as bare Error on bun 1.4.0 release and on a debug build of main, and asserting that the dead frame is still in the output pins down that the string really came from the finalizer.

    // JSC holds an error's captured frames weakly. When a GC runs before .stack is first read and one
    // of the frames has died, the stack is rendered to a string inside the GC instead of on first
    // access. The header line must come out the same either way.
    test("error.stack header keeps name and message when the frames are rendered during GC", async () => {
    class MyError extends Error {
    constructor(message: string) {
    super(message);
    this.name = "MyError";
    }
    }
    class ProtoNamed extends Error {}
    ProtoNamed.prototype.name = "ProtoNamed";
    // Runs `create` inside a function that nothing references once it has returned, so the deadFrame
    // frame recorded in each error is dead by the time the GC below runs.
    const inDeadFrame = <T>(create: () => T): T =>
    new Function("create", "return function deadFrame() { return create(); }")(create)();
    const errors: Record<string, Error> = {
    typeError: inDeadFrame(() => new TypeError("path argument is required to res.sendFile")),
    noMessage: inDeadFrame(() => new RangeError()),
    nameSetInConstructor: inDeadFrame(() => new MyError("custom subclass")),
    nameOnPrototype: inDeadFrame(() => new ProtoNamed("from the prototype")),
    nameReassigned: inDeadFrame(() => Object.assign(new Error("renamed"), { name: "Renamed" })),
    messageReassigned: inDeadFrame(() =>
    Object.assign(new Error("original"), { message: "changed before first read" }),
    ),
    emptyName: inDeadFrame(() => Object.assign(new Error("only the message"), { name: "" })),
    numberMessage: inDeadFrame(() => Object.assign(new Error("original"), { message: 404 })),
    bigintMessage: inDeadFrame(() => Object.assign(new Error("original"), { message: 10n })),
    objectName: inDeadFrame(() => Object.assign(new Error("object name"), { name: {} })),
    thrownAndCaught: inDeadFrame(() => {
    try {
    throw new SyntaxError("thrown and caught");
    } catch (e) {
    return e as Error;
    }
    }),
    thrownByTheEngine: inDeadFrame(() => {
    try {
    (null as any).property;
    } catch (e) {
    return e as Error;
    }
    }),
    captureStackTrace: inDeadFrame(() => {
    const e = new TypeError("captured");
    Error.captureStackTrace(e);
    return e;
    }),
    };
    // Not waiting for anything: yielding once resumes this function on a fresh stack, so nothing the
    // calls above left behind can keep a deadFrame alive through the collection.
    await Bun.sleep(0);
    Bun.gc(true);
    const headers: Record<string, string> = {};
    for (const [label, error] of Object.entries(errors)) {
    const [header, ...frames] = error.stack!.split("\n");
    headers[label] = header;
    // A dead frame in the trace is what makes the GC render it, so its presence proves this stack
    // took that path.
    expect(frames, label).toEqual(expect.arrayContaining([expect.stringMatching(/^ at deadFrame \(/)]));
    }
    expect(headers).toEqual({
    typeError: "TypeError: path argument is required to res.sendFile",
    noMessage: "RangeError",
    nameSetInConstructor: "MyError: custom subclass",
    nameOnPrototype: "ProtoNamed: from the prototype",
    nameReassigned: "Renamed: renamed",
    messageReassigned: "Error: changed before first read",
    emptyName: "only the message",
    numberMessage: "Error: 404",
    bigintMessage: "Error: 10",
    objectName: "Error: object name",
    thrownAndCaught: "SyntaxError: thrown and caught",
    thrownByTheEngine: `TypeError: ${errors.thrownByTheEngine.message}`,
    captureStackTrace: "TypeError: captured",
    });
    });

  2. Non-string name/message values. The first-access path renders numbers, booleans, null, undefined and bigints (e.message = 404 gives Error: 404); reading only strings leaves those as a bare Error on the finalizer path. JSValue::toWTFString never touches the heap for non-cells and JSBigInt::tryGetString covers bigints, and with those two cases added, 36 name/message shapes (accessors on the instance and prototype, dictionary-mode instances, null and Proxy prototypes, ropes, empty strings, subclasses, and so on) rendered identically on both paths when I routed the normal path through the finalizer code locally.

    // Everything down to computeErrorInfoToString runs inside ErrorInstance::finalizeUnconditionally
    // (the GC found a dead frame in an error whose .stack was never read and renders it now), so it
    // must not allocate on the JS heap. ErrorInstance::sanitizedNameString()/sanitizedMessageString()
    // can (Structure::get materializes property tables); read the properties like Zig::functionName
    // reads function names on this same path instead.
    static JSValue getDataPropertyWithoutGC(JSObject* object, const Identifier& propertyName)
    {
    unsigned attributes;
    PropertyOffset offset = object->structure()->getConcurrently(propertyName.impl(), attributes);
    if (offset == invalidOffset || (attributes & (PropertyAttribute::Accessor | PropertyAttribute::CustomAccessorOrValue)))
    return {};
    return object->getDirect(offset);
    }
    // Null for objects and symbols, which toWTFString() would have to run JS for (the sanitized
    // getters give up on objects too); every other primitive formats without touching the heap.
    static String toWTFStringWithoutGC(JSGlobalObject* globalObject, JSValue value)
    {
    if (value.isString())
    return asString(value)->tryGetValueWithoutGC();
    if (value.isNumber() || value.isBoolean() || value.isUndefinedOrNull())
    return value.toWTFString(globalObject);
    if (value.isHeapBigInt())
    return JSBigInt::tryGetString(globalObject->vm(), value.asHeapBigInt(), 10);
    return {};
    }
    // Same lookup as ErrorInstance::sanitizedNameString(): the instance, then its prototype (where
    // TypeError.prototype.name and friends live).
    static String errorNameWithoutGC(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* errorInstance)
    {
    JSValue nameValue = getDataPropertyWithoutGC(errorInstance, vm.propertyNames->name);
    if (!nameValue) {
    if (auto* prototype = errorInstance->getPrototypeDirect().getObject())
    nameValue = getDataPropertyWithoutGC(prototype, vm.propertyNames->name);
    }
    if (!nameValue || nameValue.isUndefined())
    return "Error"_s;
    String name = toWTFStringWithoutGC(globalObject, nameValue);
    if (name.isNull())
    return "Error"_s;
    return name;
    }
    // Same lookup as ErrorInstance::sanitizedMessageString(): own property only.
    static String errorMessageWithoutGC(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* errorInstance)
    {
    JSValue messageValue = getDataPropertyWithoutGC(errorInstance, vm.propertyNames->message);
    if (!messageValue)
    return {};
    return toWTFStringWithoutGC(globalObject, messageValue);
    }
    static String computeErrorInfoToString(JSC::VM& vm, Vector<StackFrame>& stackTrace, OrdinalNumber& line, OrdinalNumber& column, String& sourceURL, JSObject* errorInstance)
    {
    JSGlobalObject* globalObject = errorInstance->globalObject();
    String name = errorNameWithoutGC(vm, globalObject, errorInstance);
    String message = errorMessageWithoutGC(vm, globalObject, errorInstance);
    // Passing no lexical global object and no error instance is what keeps formatStackTrace on its
    // finalizer-safe path (no originalLine/originalColumn putDirect, MustNotTriggerGC names).
    return Bun::formatStackTrace(vm, defaultGlobalObject(), nullptr, name, message, line, column, sourceURL, stackTrace, nullptr);
    }

That branch pins a preview build of the now-closed WebKit PR, so it only builds against something carrying #302; it is there for cherry-picking, not as an alternative.

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.

Async-thrown Error loses its message from error.stack when GC runs before first .stack access

1 participant