Skip to content

Preserve a pending exception across the GC stack-trace finalizer - #33584

Merged
Jarred-Sumner merged 14 commits into
mainfrom
farm/8079e315/vm-module-eval-error-gc
Sep 14, 2026
Merged

Jarred-Sumner merged 14 commits into
mainfrom
farm/8079e315/vm-module-eval-error-gc

Conversation

@robobun

@robobun robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A garbage collection can drop the VM's pending exception. A module whose evaluation throws then hits ASSERTION FAILED: exception in JSPromise::rejectWithCaughtException (assert builds) or segfaults at 0x8 in JSPromise::reject <- rejectWithCaughtException <- CyclicModuleRecord::evaluate <- dynamicImportLoadSettled (release, Sentry BUN-4R6E). Bun.resolve() hits panic: A JavaScript exception was thrown, but it was cleared before it could be read. in rejected_promise_with_caught_exception.
  • Cause: computeErrorInfoWrapperToString (src/jsc/bindings/FormatStackTraceForJS.cpp), the onComputeErrorInfo hook, runs in Heap::runEndPhase for live Errors whose frames died, and cleared whatever exception was pending. With concurrent GC it lands while the mutator is parked at an allocation with an exception pending.

Fix

  • Wrap the hook body in JSC's SuspendExceptionScope (as TypeProfilerLog::processLogEntries does). The mutator's exception and trap bit are set aside and restored, so tryClearException() only swallows what the computation itself raised.
  • Add DeferTerminationForAWhile. A terminate() thrown inside the window survives the clear, and the restore then overwrites it with the trap bit still set.
  • Verified: error-stack-finalizer-exception.test.ts is the deterministic one (slowPathAllocsBetweenGCs fixes where the end phase lands): all three N values abort every run unfixed, all pass fixed, in about 6s. dynamic-import-evaluation-error-gc.test.ts, resolve-error.test.ts and sourcetextmodule-link-gc.test.ts cover the concurrent-GC faces and fail 3/3 unfixed. The terminate test fails 3/3 on a suspend-only build. test-stream-writable-write-writev-finish.js passes under BUN_JSC_validateExceptionChecks=1.

Background

  • JSC keeps one pending exception per VM (VM::m_exception), mirrored by a trap bit. A callee that clears it drops the caller's exception.
  • CyclicModuleRecord::evaluate step 9 sees the module's error as the pending exception and step 9.d rejects by re-reading the slot. Bun.resolve() allocates its promise before it takes the exception.
  • With concurrent GC the end phase runs while the mutator is stopped at a safepoint, which is any allocation slow path. ErrorInstance keeps weak stack frames. When a frame's code dies, the end phase materializes the stack string through this hook.
Notes Deterministic door (no concurrent collector): `BUN_JSC_slowPathAllocsBetweenGCs=7 bun -e 'const e = eval("(() => Object.assign(new Error(\"c\"), { code: 1 }))")(); throw e'`. Reported as 5/5 `Segmentation fault at address 0x8` on release 1.4.2, canary and a release build of main, and 5/5 `ASSERTION FAILED: exception` on an ASan build. Measured here on a debug+ASAN build of main: N of 3, 4 and 7 abort every run; 1, 2, 5, 6 and >= 8 print the error normally; with the fix all of 3, 4, 7 print `error: c` and exit 1 (9/9). The eval'd arrow matters: it makes the Error's frames garbage before the throw propagates, so the end phase has a stack to materialize.

Reproduction (plain import(), no node:vm, no plugin), about 4 of 6 runs abort at 200 iterations and 6 of 6 at 300 on the unfixed assert build:

import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
for (let it = 0; it < 300; it++) {
  const d = join(import.meta.dir, "graphs", "g" + it);
  mkdirSync(d, { recursive: true });
  writeFileSync(join(d, "bad.mjs"), `export const x = ${it};\nthrow new Error("boom ${it}");\n`);
  writeFileSync(join(d, "tla.mjs"), `await new Promise(r => setTimeout(r, 0));\nexport const t = ${it};\n`);
  writeFileSync(join(d, "leaf.mjs"), `export const l = ${it};\n`);
  writeFileSync(join(d, "mid.mjs"), `import { l } from "./leaf.mjs";\nimport "./bad.mjs";\nexport const m = l + 1;\n`);
  writeFileSync(join(d, "a.mjs"), `import { m } from "./mid.mjs";\nimport { t } from "./tla.mjs";\nexport const a = m + t;\n`);
  writeFileSync(join(d, "b.mjs"), `import { t } from "./tla.mjs";\nimport "./bad.mjs";\nexport const b = t;\n`);
  writeFileSync(join(d, "c.mjs"), `import { m } from "./mid.mjs";\nexport const c = m;\n`);
  await import(join(d, "a.mjs")).catch(() => {});
  await import(join(d, "b.mjs")).catch(() => {});
  await import(join(d, "c.mjs")).catch(() => {});
  await import(`data:text/javascript,throw new Error('d${it}')`).catch(() => {});
}

import(b) reaches the already-errored bad.mjs, so InnerModuleEvaluation step 2 rethrows the stored error and the step 9 to 9.d window runs again. A data:-URL-only loop does not fire. A single iteration does not fire.

State at the assert (gdb, conditional break on exception == 0 at the assert line): the Exception* local captured at step 9 is non-null while vm.m_exception is null. The window between the two is straight-line C++ (attachErrorInfo, setStatus, setEvaluationError) with no JS and no microtask checkpoint, so the slot is cleared out of band. BUN_JSC_useConcurrentGC=false: 0/16 aborts (baseline 12/16 on the vm.SourceTextModule reproducer). collectContinuously also hides it (it moves the window).

The vm.SourceTextModule reproducer (the first one found) needs --smol to fire reliably and a file-backed fixture (a -e script has no source URL, so the stack materializer takes a different path).

Earlier iteration of this PR compared the pending exception before and after the computation and cleared only a new one. SuspendExceptionScope replaces that: it is the upstream pattern, it also restores m_lastException and the trap bit, and the computation runs against a clean slot.

The first reports of this assert named a Bun.plugin onResolve recipe. That recipe cannot reach the state (its filter never matched), and the plugin is not part of the cause.

The first push used SuspendExceptionScope alone. CI caught the termination interaction on the debian x64-asan lane (worker.test.ts, "terminate() while importing every builtin module"): ASSERTION FAILED: !!(*scope).exception() == vm.traps().needHandling(JSC::VMTraps::NeedExceptionHandling) in TopExceptionScope__exceptionIncludingTraps. A stress (the vm fixture inside a Worker terminated mid-run, 20 rounds) reproduced it 6/6 on that build and 0/6 with the deferral; it is now the third test in the vm file.

Bun.resolve() face (the helper from #41796): Bun.resolve() with no arguments from a fresh closure per iteration, rejection reasons kept in a 1024-entry ring, awaited one at a time, BUN_JSC_collectContinuously=1. Unfixed main 09bb546 (debug+ASAN): 9/9 panic after 2500 to 8500 iterations, trace take_exception <- JSPromise::reject <- rejected_promise_with_caught_exception <- bun_object::resolve. With the fix: 5/5 complete 80000 iterations. Batching 64 calls per tick never fires. Reported on a release build of main as well (panic within 0.2 s under collectContinuously). The same helper serves the early rejections of fetch(), Bun.write() and server.fetch().

The module-evaluation standalone above also segfaults shipped release builds on demand under BUN_JSC_collectContinuously=1 (reported: 1.4.2 4/5, canary 4/5, release main 5/5 at N=400, SEGV at 0x8).


no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/vm/sourcetextmodule-link-gc.test.ts, test/js/bun/resolve/resolve-error.test.ts, test/js/bun/resolve/dynamic-import-evaluation-error-gc.test.ts

@coderabbitai

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0e970bfd-e3e9-48ed-89b3-c68dedb61c9c

📥 Commits

Reviewing files that changed from the base of the PR and between 4edbfd4 and d970c98.

📒 Files selected for processing (2)
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • test/js/node/vm/sourcetextmodule-link-gc.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

Changes

Exception-safe stack trace finalization

Layer / File(s) Summary
Preserve VM exceptions during stack formatting
src/jsc/bindings/FormatStackTraceForJS.cpp
computeErrorInfoWrapperToString defers termination and suspends existing VM exceptions before handling exceptions raised during stack computation.
Exercise dynamic import evaluation errors
test/js/bun/resolve/dynamic-import-evaluation-error-gc.test.ts
The regression test repeats failed dynamic imports, top-level await, and data-URL evaluations during garbage-collection stack finalization, then verifies clean Bun process completion.
Exercise SourceTextModule evaluation errors
test/js/node/vm/sourcetextmodule-link-gc.test.ts
The regression test repeats SourceTextModule linking and evaluation failures, including terminated workers, then verifies clean process completion.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to d970c

This change preserves a pending module-evaluation exception during stack-trace finalization, preventing the reported crash path; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the main change: preserving a pending exception during GC stack-trace finalization.
Description check ✅ Passed The description explains the problem, root cause, fix, regression coverage, and verification results. It does not use the exact template headings, but it provides the required information in equivalen…

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

@github-actions github-actions Bot added the claude label Jul 7, 2026
@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:58 AM PT - Sep 14th, 2026

✅ @robobun, your commit afb2e9ce6c5e3e6683659524b34cb648bb4b086d passed in Build #115511! 🎉


🧪   To try this PR locally:

bunx bun-pr 33584

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

bun-33584 --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.

I didn't find any bugs, but this touches JSC exception-scope handling inside a GC finalizer (concurrent-GC safepoint interleave with a pending mutator exception), so it's worth a human familiar with JSC internals confirming this is the right layer for the fix vs. e.g. a suspend-exception pattern.

Extended reasoning...

Overview

The PR changes one function in src/jsc/bindings/FormatStackTraceForJS.cpp: computeErrorInfoWrapperToString, which is installed via vm.setOnComputeErrorInfo and invoked from ErrorInstance::finalizeUnconditionally during Heap::runEndPhase. Previously it unconditionally cleared any pending exception after computing the stack string; the fix captures scope.exception() before the computation and only clears if a different exception is now pending, preserving a mutator-owned exception that happened to be on the VM at the GC safepoint. A regression test is added to test/js/node/vm/sourcetextmodule-link-gc.test.ts that spawns a --smol fixture exercising vm.SourceTextModule evaluation errors under concurrent GC.

Security risks

None identified. This is an internal exception-handling adjustment in a GC finalizer path; no user-controlled input parsing, auth, crypto, or permission surface is touched.

Level of scrutiny

High. Although the diff is tiny (one captured pointer + one added comparison), it sits at the intersection of JSC's exception-scope machinery and concurrent-GC finalization — an area CLAUDE.md explicitly flags as the most-blocked review category. The root-cause analysis is thorough and the fix is strictly narrowing (it clears in fewer cases than before, never more), so it's unlikely to regress the original test-stream-writable-write-writev-finish.js case. But whether "snapshot the prior exception and compare" is the idiomatic JSC pattern here — versus, say, suspending the exception across the call, or addressing why the finalizer is entered at all with a pending VM exception — is a design call a JSC-familiar maintainer should make.

Other factors

  • No CODEOWNER covers this file.
  • The bug-hunting pass found no issues.
  • The test follows harness conventions (tempDir, bunEnv/bunExe, concurrent pipe drain, asserts {stdout, exitCode} object) and is skipped on Windows consistent with the sibling test in the same file.
  • In the finalizer path errorInstance is nullptr, so the RETURN_IF_EXCEPTION guards inside computeErrorInfoWithoutPrepareStackTrace are not reached on entry with a pre-existing exception; and JSC's throwException does not overwrite an already-pending exception, so the != priorException comparison should hold in the intended case. Still, given the subtlety, deferring to a human.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Good call on the suspend-exception pattern. JSC has SuspendExceptionScope (in JavaScriptCore/interpreter/FrameTracers.h, a friend of VM) for exactly this: it saves and nulls m_exception, m_lastException and the NeedExceptionHandling trap bit, then restores all three on destruction. TypeProfilerLog::processLogEntries uses it for the same reason ("we may be called when an exception was already thrown, so we don't want [callee] to clear that exception").

Switched to it in 6ff6ce9. It is strictly more correct than the compare-snapshot approach: the stack computation now runs against a clean exception slot (no early returns on a pre-existing exception), and both m_lastException and the trap bit round-trip as well.

Re-verified locally:

  • unfixed: sourcetextmodule-link-gc.test.ts fails 3/3 (child exits 134)
  • fixed: passes 3/3
  • test-stream-writable-write-writev-finish.js still passes under BUN_JSC_validateExceptionChecks=1

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

The fix looks well-reasoned and uses JSC's own SuspendExceptionScope primitive, but the correctness argument hinges on subtle concurrent-GC / finalizer / VM-exception-state interactions that deserve a look from someone who knows the JSC exception model well.

Extended reasoning...

Overview

This PR adds a single JSC::SuspendExceptionScope RAII guard to computeErrorInfoWrapperToString in src/jsc/bindings/FormatStackTraceForJS.cpp, plus a new regression test in test/js/node/vm/sourcetextmodule-link-gc.test.ts. The functional change is one line (plus an include and comment updates): suspend any pre-existing VM exception before computing an error's stack string in the GC finalizer, so the existing tryClearException() only ever swallows exceptions raised by the stack computation itself, not one belonging to the suspended mutator.

Security risks

None. This is a crash fix in error-info finalization; there is no attacker-controlled input surface, auth, or data-exposure angle.

Level of scrutiny

High. The diff is tiny, but it sits at the intersection of concurrent GC (Heap::runEndPhase → ErrorInstance::finalizeUnconditionally), JSC's VM-global exception slot, and CyclicModuleRecord::evaluate's reject-with-caught-exception path. The root-cause narrative is detailed and plausible, and SuspendExceptionScope is the upstream JSC primitive for exactly this (cited prior art: TypeProfilerLog::processLogEntries). Destruction order is correct (the suspend scope outlives the TopExceptionScope, so the mutator's exception is restored last). Still, whether it is truly safe for this finalizer to run against a nulled exception slot while the mutator is suspended mid-evaluation — and whether restoring m_lastException / the trap bit here has no other side effects — is the kind of JSC-internals question I'd want a human familiar with the GC/exception model to confirm rather than rubber-stamp.

Other factors

  • No CODEOWNERS coverage for these paths.
  • The bug-hunting system found no issues.
  • The regression test is thorough (real file fixture, --smol to force frequent collections, 4 sequential runs, well-commented rationale) and follows harness conventions.
  • The PR author already iterated once (snapshot-compare → SuspendExceptionScope) and re-verified both the crash repro and the exception-checker test that motivated the original tryClearException.

Given the subtlety of the code path, I'm deferring rather than approving.

computeErrorInfoWrapperToString (installed via vm.setOnComputeErrorInfo)
runs from ErrorInstance::finalizeUnconditionally during Heap::runEndPhase.
It cleared whatever exception was pending after computing a stack string.
With concurrent GC that finalizer lands at an arbitrary safepoint: if it
fires while the mutator is mid vm.SourceTextModule evaluation with a
module's evaluation error pending, it nulled that exception.
CyclicModuleRecord::evaluate step 9.d then reached rejectWithCaughtException
with no pending exception, aborting on assert builds (ASSERTION FAILED:
exception, JSPromise.cpp) and segfaulting at 0x8 on release.

Only swallow an exception the stack computation itself raised; leave a
pre-existing one untouched so it survives the finalizer.
JSC has a dedicated RAII helper for exactly this case (a callee that must
run transparently to a pre-existing pending exception). It saves and nulls
m_exception, m_lastException and the NeedExceptionHandling trap, then
restores all three on destruction. TypeProfilerLog::processLogEntries uses
the same pattern for the same reason.
A fresh on-disk ESM graph per iteration, one member throwing at top level
and one using top-level await. import(a) fails on the throwing member;
import(b) reaches that already-errored member, so Evaluate() rethrows the
stored error and runs the step 9 -> 9.d window again. This is the path in
the release crash reports (JSPromise::reject <- rejectWithCaughtException <-
CyclicModuleRecord::evaluate <- dynamicImportLoadSettled, SEGV at 0x8).
@robobun
robobun force-pushed the farm/8079e315/vm-module-eval-error-gc branch from 6ff6ce9 to 91b4605 Compare August 30, 2026 04:26
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
@robobun robobun changed the title node:vm: preserve a pending exception across the GC stack-trace finalizer Preserve a pending exception across the GC stack-trace finalizer (module evaluation crash) Aug 30, 2026
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/vm/sourcetextmodule-link-gc.test.ts Outdated
Comment thread test/js/bun/resolve/dynamic-import-evaluation-error-gc.test.ts
… the vm fixture

The vm fixture's unhandledRejection handler counted rejections that nothing
asserted. It was also hiding a separate, pre-existing bug: a throwing
top-level-await SourceTextModule shared by two importers reports one
unhandled rejection per graph even though every evaluate() is awaited and
caught. Tolerate exactly that rejection and exit nonzero on any other, so
the crash under test stays the only failure mode.

Both Windows skips now say why: the fixtures are long GC-churn loops that
run several times slower under Windows + ASAN, and the fixed path is not
platform-specific.

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

Both earlier nits are addressed — the Windows skips now carry a stated reason, and the unhandledRejection handler in the vm.SourceTextModule fixture now fails the process (exit 3) on any rejection other than the known pre-existing URIError('tla'), so a regression that turns the crash into a stray unhandled rejection can no longer pass. No further findings from this run; a human look is still worthwhile given this is a concurrent-GC/exception-handling change in the JSC bindings.

What was reviewed

  • SuspendExceptionScope placement: constructed before the TopExceptionScope and destructed after it, so the mutator's exception is restored after the local clear — ordering is correct.
  • Confirmed the inner tryClearException() now only sees exceptions raised by computeErrorInfoToString itself, matching the stated intent.
  • Re-read both test fixtures against the updated harness conventions (tempDir, bunEnv, concurrent pipe drain, stdout-before-exitCode assertion shape).
Extended reasoning...

Overview

The PR is a 3-line native fix in src/jsc/bindings/FormatStackTraceForJS.cpp: it wraps the onComputeErrorInfo hook body in JSC::SuspendExceptionScope so a mutator's pending exception is stashed and restored across the GC-end-phase stack-trace materialization, and only exceptions raised by the computation itself are cleared. Two stress tests exercise the plain import() and vm.SourceTextModule faces of the crash. Since the previous review, commit 4edbfd4 addressed both inline nits: skip-reason comments were added for the Windows gates, and the unhandledRejection handler in the SourceTextModule fixture was rewritten to whitelist only the known pre-existing URIError('tla') and hard-exit(3) on anything else.

Security risks

None. This is an internal JSC exception-bookkeeping fix with no user-facing input surface, auth, or data-handling change.

Level of scrutiny

Medium-high. The source change is tiny and follows an upstream JSC pattern (SuspendExceptionScope, as used in TypeProfilerLog::processLogEntries), and the destruction ordering relative to the inner TopExceptionScope is correct. But it sits in a concurrent-GC finalizer path where subtle exception-state and safepoint interactions live, so a maintainer familiar with the JSC bindings should still sign off rather than an automated approval.

Other factors

The new tests are heavy (120s per-test timeouts, 2× and 4× serial subprocess runs) — the PR justifies the iteration counts and serial execution as necessary to hit the race reliably on the unfixed build, and both are Windows-skipped with stated CI-runtime reasons. The rewritten unhandledRejection handler now fails closed on unexpected rejections, resolving the earlier concern that it could mask a regression. No outstanding third-party CHANGES_REQUESTED reviews are visible in the timeline.

SuspendExceptionScope restores the saved exception slot blindly on exit. If a
termination exception is thrown inside the window (a RETURN_IF_EXCEPTION in
the stack computation servicing a pending terminate()), tryClearException
refuses to clear it, and the restore then overwrites it with the saved value
(null when nothing was pending) while the NeedExceptionHandling trap bit
stays set. The next exception check asserts the exception/trap invariant:

  ASSERTION FAILED: !!(*scope).exception() == vm.traps().needHandling(NeedExceptionHandling)
  TopExceptionScope__exceptionIncludingTraps

Hold termination off for the duration of the window with
DeferTerminationForAWhile, which re-arms the NeedTermination trap on exit so
the mutator throws it at its next check. A termination already pending on
entry is suspended and rethrown by the same scope.

Regression test: run the GC-churn fixture in a Worker and terminate it
mid-run. Every run of the suspend-only build asserted.
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/vm/sourcetextmodule-link-gc.test.ts Outdated
Comment thread test/js/node/vm/sourcetextmodule-link-gc.test.ts Outdated
…ose before yielding

--smol on the parent does not reach a Worker's VM; pass smol: true so the
worker's heap is the one that collects often. Register the close listener
before the timer so a worker that dies early still dispatches close instead
of hanging the loop until the test timeout.
@robobun

robobun commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI status at d661d2b (build 108433): every lane is green except test/js/web/url/url.test.ts on darwin x64, which fails the same way on main (process.versions.icu gate vs the Unicode 16 IDNA table) and is already reported to main-break triage. The remaining entries in the build's failure list all passed on retry or when run alone. The three tests this PR adds passed on all lanes, and the earlier worker.test.ts regression on debian x64-asan (fixed in 311565f) did not reappear.

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

Code review found no issues

No high-confidence issues detected in this change.

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

Code review found no issues

No high-confidence issues detected in this change.

Bun.resolve() allocates its rejected promise while the exception the
resolve threw is still pending (JSPromise::rejected_promise_with_caught_exception).
That allocation is a GC safepoint. When the concurrent collector's end
phase ran there and materialized a live Error's stack, the hook cleared the
pending exception and the rejection then found nothing:

  panic: A JavaScript exception was thrown, but it was cleared before it could be read.
  take_exception <- JSPromise::reject <- rejected_promise_with_caught_exception <- bun_object::resolve

The unfixed debug build panics after 2500 to 8500 iterations under
collectContinuously; with the finalizer fix 80000 iterations complete.
@robobun robobun changed the title Preserve a pending exception across the GC stack-trace finalizer (module evaluation crash) Preserve a pending exception across the GC stack-trace finalizer Sep 13, 2026

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

One more face of this cause, measured on release builds. It is on main and not in a release yet.

Symptom. A failing Bun.resolve() aborts a release build of main:

panic: A JavaScript exception was thrown, but it was cleared before it could be read.

Path. #41796 (merged 2026-09-07) makes Bun.resolve() build its rejected promise with JSPromise::rejected_promise_with_caught_exception (src/jsc/JSPromise.rs:290). That helper allocates the promise with JSPromise::create while the resolver's exception is still pending. Then reject(Err(Thrown)) reads the exception with take_exception. The promise is the only allocation between the throw and the read. If a collection ends at that allocation, computeErrorInfoWrapperToString clears the pending exception, which is the cause this PR describes. take_exception then finds nothing and panics (src/jsc/JSGlobalObject.rs:1016). Before #41796 (and in 1.4.2) Bun.resolve() called take_error first and allocated the promise after it, so there was no window.

Script. The Error objects stay alive and the functions that made them die, so the end phase has stack strings to materialize:

const keep = [];
let rejected = 0;
for (let i = 0; i < 3000; i++) {
  const f = new Function("return new Error('e" + i + "')");
  keep.push(f());
  if (keep.length > 400) keep.shift();
  try {
    await Bun.resolve("./does-not-exist-" + i, import.meta.dir);
  } catch (e) {
    rejected++;
  }
}
console.log("rejected", rejected);

Runs. Linux x64, BUN_JSC_collectContinuously=1. The three commit builds come from bun run build:release on the same machine. 1.4.2 is the published binary.

build aborts
main 09bb54630 2 of 12
b99371011 (merge base of this branch) 3 of 9
this branch 3bf076a36 0 of 24
1.4.2 0 of 6

Without collectContinuously, main 09bb54630 gave 0 of 6, so this script needs the stress option. The Notes say collectContinuously hides the import() face. It exposes this one.

The same helper serves Bun.write() (Blob.rs:4253, Blob.rs:4459), the stream-to-file pipe (Blob.rs:1387) and server.fetch() (server_body.rs:2334, server_body.rs:2381). I tested Bun.resolve() only.

slowPathAllocsBetweenGCs collects every N slow-path allocations, so the end
phase lands in the same place every run. With the Error built inside an
eval'd arrow its frames are dead by the time the uncaught throw propagates,
which gives the end phase a stack to materialize in the window where the
entry module's evaluation promise is about to be rejected with the caught
exception.

Unlike the three concurrent-GC reproducers this needs no iteration count and
finishes in about 6s. On the unfixed build all three N values abort every
run; with the fix all print `error: c` and exit 1.

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner merged commit d27fef0 into main Sep 14, 2026
6 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/8079e315/vm-module-eval-error-gc branch September 14, 2026 20:27
Jarred-Sumner pushed a commit that referenced this pull request Sep 14, 2026
…ing length limit (#42314)

### Problem

- Reading `.stack` of an error with a very long message aborts the
process: `panic(main thread): abort() called`, exit code 134. Example:
`bun -e 'new Error("q".repeat(2**31-10)).stack'`. The default
`Error.prepareStackTrace`, which also runs before a user-defined one,
aborts the same way.
- Both formatters in `src/jsc/bindings/FormatStackTraceForJS.cpp`
(`formatStackTrace` at :154, `formatStackTraceToJSValue` at :42) use a
default-constructed `WTF::StringBuilder`. It calls `CRASH()` on the
append that passes `String::MaxLength`. The message and each frame's
source URL come from JS.

### Fix

- Both builders record the overflow (`OverflowPolicy::RecordOverflow`).
A trace that did not fit becomes the `name: message` header without the
frames, or the message alone if the header does not fit either.
- It degrades and does not throw, because `formatStackTrace` also runs
from `ErrorInstance::finalizeUnconditionally`, inside a GC, where
nothing can throw. `.stack` is then the same whether the GC or the
getter computes it.
- A trace under the limit is unchanged. A user `Error.prepareStackTrace`
still runs and still gets the call sites.
- Verified: `test/js/node/v8/capture-stack-trace.test.js`, one new test
with both paths in one child. Each path aborts alone on 1.4.3. Also
`stack.test.ts`, three stack-trace regression tests and
`test-error-prepare-stack-trace.js`.

### Background

- `String::MaxLength` is 2^31 - 1 code units. An error message can be
that long.
- `.stack` is computed lazily:
`ErrorInstance::materializeErrorInfoIfNeeded` calls Bun's hook on the
first read. If the GC finds dead frames first, `finalizeUnconditionally`
calls the string variant of the same hook.
- `RecordOverflow` makes the builder set a flag that `hasOverflowed()`
reports. Later appends do nothing.

<details><summary>Notes</summary>

This is split out of #42202, which is now only about error messages.

Other policies for a trace that does not fit, and why this PR does not
take them:
- Throw, like Node. Node v26.3.0 throws `RangeError: Invalid string
length` from the `.stack` read for the same input. In Bun the same text
is also produced inside a GC, where a throw is not possible, so the two
paths would differ. The getter runs under many operations on the error
object (`Object.keys`, a property write), which would all start to
throw.
- Keep the frames and cut the message. That keeps more information,
because the frames are small. It needs the header and the frames built
apart, which is a larger change to a function that #40354 and #33584
also edit.

The change is small on purpose: the builder policy and three return
sites. It does not touch the parts of the file that #40354 and #33584
change, so any merge order works.

Seen while testing, not caused by this change: `stack.test.ts > Async
functions frame should be included in stack trace` fails when it runs in
one process after the three regression files (two extra frames). It
fails the same way with `src/` at `origin/main`, and it passes alone.

Cost of the test: the length is what is under test, so the child needs a
string of about 2 GiB. Both paths run in one child, so the string is
allocated once. Each path copies the header once, so the child touches
about 6 GB of pages. It takes about 5 s in a debug ASAN build, so it
carries a 30 s ceiling. It skips below 10 GiB of total memory, the gate
`test/js/bun/transpiler/source-too-large.test.ts` uses.

</details>
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…n-sh#33584)

### Problem
- A garbage collection can drop the VM's pending exception. A module
whose evaluation throws then hits `ASSERTION FAILED: exception` in
`JSPromise::rejectWithCaughtException` (assert builds) or segfaults at
`0x8` in `JSPromise::reject <- rejectWithCaughtException <-
CyclicModuleRecord::evaluate <- dynamicImportLoadSettled` (release,
Sentry BUN-4R6E). `Bun.resolve()` hits `panic: A JavaScript exception
was thrown, but it was cleared before it could be read.` in
`rejected_promise_with_caught_exception`.
- Cause: `computeErrorInfoWrapperToString`
(`src/jsc/bindings/FormatStackTraceForJS.cpp`), the `onComputeErrorInfo`
hook, runs in `Heap::runEndPhase` for live Errors whose frames died, and
cleared whatever exception was pending. With concurrent GC it lands
while the mutator is parked at an allocation with an exception pending.

### Fix
- Wrap the hook body in JSC's `SuspendExceptionScope` (as
`TypeProfilerLog::processLogEntries` does). The mutator's exception and
trap bit are set aside and restored, so `tryClearException()` only
swallows what the computation itself raised.
- Add `DeferTerminationForAWhile`. A `terminate()` thrown inside the
window survives the clear, and the restore then overwrites it with the
trap bit still set.
- Verified: `error-stack-finalizer-exception.test.ts` is the
deterministic one (`slowPathAllocsBetweenGCs` fixes where the end phase
lands): all three N values abort every run unfixed, all pass fixed, in
about 6s. `dynamic-import-evaluation-error-gc.test.ts`,
`resolve-error.test.ts` and `sourcetextmodule-link-gc.test.ts` cover the
concurrent-GC faces and fail 3/3 unfixed. The terminate test fails 3/3
on a suspend-only build. `test-stream-writable-write-writev-finish.js`
passes under `BUN_JSC_validateExceptionChecks=1`.

### Background
- JSC keeps one pending exception per VM (`VM::m_exception`), mirrored
by a trap bit. A callee that clears it drops the caller's exception.
- `CyclicModuleRecord::evaluate` step 9 sees the module's error as the
pending exception and step 9.d rejects by re-reading the slot.
`Bun.resolve()` allocates its promise before it takes the exception.
- With concurrent GC the end phase runs while the mutator is stopped at
a safepoint, which is any allocation slow path. `ErrorInstance` keeps
weak stack frames. When a frame's code dies, the end phase materializes
the stack string through this hook.

<details><summary>Notes</summary>
Deterministic door (no concurrent collector):
`BUN_JSC_slowPathAllocsBetweenGCs=7 bun -e 'const e = eval("(() =>
Object.assign(new Error(\"c\"), { code: 1 }))")(); throw e'`. Reported
as 5/5 `Segmentation fault at address 0x8` on release 1.4.2, canary and
a release build of main, and 5/5 `ASSERTION FAILED: exception` on an
ASan build. Measured here on a debug+ASAN build of main: N of 3, 4 and 7
abort every run; 1, 2, 5, 6 and >= 8 print the error normally; with the
fix all of 3, 4, 7 print `error: c` and exit 1 (9/9). The eval'd arrow
matters: it makes the Error's frames garbage before the throw
propagates, so the end phase has a stack to materialize.



Reproduction (plain `import()`, no `node:vm`, no plugin), about 4 of 6
runs abort at 200 iterations and 6 of 6 at 300 on the unfixed assert
build:

```js
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
for (let it = 0; it < 300; it++) {
  const d = join(import.meta.dir, "graphs", "g" + it);
  mkdirSync(d, { recursive: true });
  writeFileSync(join(d, "bad.mjs"), `export const x = ${it};\nthrow new Error("boom ${it}");\n`);
  writeFileSync(join(d, "tla.mjs"), `await new Promise(r => setTimeout(r, 0));\nexport const t = ${it};\n`);
  writeFileSync(join(d, "leaf.mjs"), `export const l = ${it};\n`);
  writeFileSync(join(d, "mid.mjs"), `import { l } from "./leaf.mjs";\nimport "./bad.mjs";\nexport const m = l + 1;\n`);
  writeFileSync(join(d, "a.mjs"), `import { m } from "./mid.mjs";\nimport { t } from "./tla.mjs";\nexport const a = m + t;\n`);
  writeFileSync(join(d, "b.mjs"), `import { t } from "./tla.mjs";\nimport "./bad.mjs";\nexport const b = t;\n`);
  writeFileSync(join(d, "c.mjs"), `import { m } from "./mid.mjs";\nexport const c = m;\n`);
  await import(join(d, "a.mjs")).catch(() => {});
  await import(join(d, "b.mjs")).catch(() => {});
  await import(join(d, "c.mjs")).catch(() => {});
  await import(`data:text/javascript,throw new Error('d${it}')`).catch(() => {});
}
```

`import(b)` reaches the already-errored `bad.mjs`, so
`InnerModuleEvaluation` step 2 rethrows the stored error and the step 9
to 9.d window runs again. A data:-URL-only loop does not fire. A single
iteration does not fire.

State at the assert (gdb, conditional break on `exception == 0` at the
assert line): the `Exception*` local captured at step 9 is non-null
while `vm.m_exception` is null. The window between the two is
straight-line C++ (`attachErrorInfo`, `setStatus`, `setEvaluationError`)
with no JS and no microtask checkpoint, so the slot is cleared out of
band. `BUN_JSC_useConcurrentGC=false`: 0/16 aborts (baseline 12/16 on
the `vm.SourceTextModule` reproducer). `collectContinuously` also hides
it (it moves the window).

The `vm.SourceTextModule` reproducer (the first one found) needs
`--smol` to fire reliably and a file-backed fixture (a `-e` script has
no source URL, so the stack materializer takes a different path).

Earlier iteration of this PR compared the pending exception before and
after the computation and cleared only a new one.
`SuspendExceptionScope` replaces that: it is the upstream pattern, it
also restores `m_lastException` and the trap bit, and the computation
runs against a clean slot.

The first reports of this assert named a `Bun.plugin` `onResolve`
recipe. That recipe cannot reach the state (its filter never matched),
and the plugin is not part of the cause.

The first push used `SuspendExceptionScope` alone. CI caught the
termination interaction on the debian x64-asan lane (`worker.test.ts`,
"terminate() while importing every builtin module"): `ASSERTION FAILED:
!!(*scope).exception() ==
vm.traps().needHandling(JSC::VMTraps::NeedExceptionHandling)` in
`TopExceptionScope__exceptionIncludingTraps`. A stress (the vm fixture
inside a Worker terminated mid-run, 20 rounds) reproduced it 6/6 on that
build and 0/6 with the deferral; it is now the third test in the vm
file.

`Bun.resolve()` face (the helper from oven-sh#41796): `Bun.resolve()` with no
arguments from a fresh closure per iteration, rejection reasons kept in
a 1024-entry ring, awaited one at a time,
`BUN_JSC_collectContinuously=1`. Unfixed main 09bb546 (debug+ASAN):
9/9 panic after 2500 to 8500 iterations, trace `take_exception <-
JSPromise::reject <- rejected_promise_with_caught_exception <-
bun_object::resolve`. With the fix: 5/5 complete 80000 iterations.
Batching 64 calls per tick never fires. Reported on a release build of
main as well (panic within 0.2 s under collectContinuously). The same
helper serves the early rejections of `fetch()`, `Bun.write()` and
`server.fetch()`.

The module-evaluation standalone above also segfaults shipped release
builds on demand under `BUN_JSC_collectContinuously=1` (reported: 1.4.2
4/5, canary 4/5, release main 5/5 at N=400, SEGV at 0x8).

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 4 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/node/vm/sourcetextmodule-link-gc.test.ts,
test/js/bun/resolve/resolve-error.test.ts,
test/js/bun/resolve/dynamic-import-evaluation-error-gc.test.ts

<!-- robobun:evidence:end -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…ing length limit (oven-sh#42314)

### Problem

- Reading `.stack` of an error with a very long message aborts the
process: `panic(main thread): abort() called`, exit code 134. Example:
`bun -e 'new Error("q".repeat(2**31-10)).stack'`. The default
`Error.prepareStackTrace`, which also runs before a user-defined one,
aborts the same way.
- Both formatters in `src/jsc/bindings/FormatStackTraceForJS.cpp`
(`formatStackTrace` at :154, `formatStackTraceToJSValue` at :42) use a
default-constructed `WTF::StringBuilder`. It calls `CRASH()` on the
append that passes `String::MaxLength`. The message and each frame's
source URL come from JS.

### Fix

- Both builders record the overflow (`OverflowPolicy::RecordOverflow`).
A trace that did not fit becomes the `name: message` header without the
frames, or the message alone if the header does not fit either.
- It degrades and does not throw, because `formatStackTrace` also runs
from `ErrorInstance::finalizeUnconditionally`, inside a GC, where
nothing can throw. `.stack` is then the same whether the GC or the
getter computes it.
- A trace under the limit is unchanged. A user `Error.prepareStackTrace`
still runs and still gets the call sites.
- Verified: `test/js/node/v8/capture-stack-trace.test.js`, one new test
with both paths in one child. Each path aborts alone on 1.4.3. Also
`stack.test.ts`, three stack-trace regression tests and
`test-error-prepare-stack-trace.js`.

### Background

- `String::MaxLength` is 2^31 - 1 code units. An error message can be
that long.
- `.stack` is computed lazily:
`ErrorInstance::materializeErrorInfoIfNeeded` calls Bun's hook on the
first read. If the GC finds dead frames first, `finalizeUnconditionally`
calls the string variant of the same hook.
- `RecordOverflow` makes the builder set a flag that `hasOverflowed()`
reports. Later appends do nothing.

<details><summary>Notes</summary>

This is split out of oven-sh#42202, which is now only about error messages.

Other policies for a trace that does not fit, and why this PR does not
take them:
- Throw, like Node. Node v26.3.0 throws `RangeError: Invalid string
length` from the `.stack` read for the same input. In Bun the same text
is also produced inside a GC, where a throw is not possible, so the two
paths would differ. The getter runs under many operations on the error
object (`Object.keys`, a property write), which would all start to
throw.
- Keep the frames and cut the message. That keeps more information,
because the frames are small. It needs the header and the frames built
apart, which is a larger change to a function that oven-sh#40354 and oven-sh#33584
also edit.

The change is small on purpose: the builder policy and three return
sites. It does not touch the parts of the file that oven-sh#40354 and oven-sh#33584
change, so any merge order works.

Seen while testing, not caused by this change: `stack.test.ts > Async
functions frame should be included in stack trace` fails when it runs in
one process after the three regression files (two extra frames). It
fails the same way with `src/` at `origin/main`, and it passes alone.

Cost of the test: the length is what is under test, so the child needs a
string of about 2 GiB. Both paths run in one child, so the string is
allocated once. Each path copies the header once, so the child touches
about 6 GB of pages. It takes about 5 s in a debug ASAN build, so it
carries a 30 s ceiling. It skips below 10 GiB of total memory, the gate
`test/js/bun/transpiler/source-too-large.test.ts` uses.

</details>
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.

2 participants