Conversation
JSC holds an error's stack frames weakly. Once a frame's callee dies, the GC end phase renders the stack string, where no JS can run, and the user's Error.prepareStackTrace is skipped for that error. Set VM::setKeepsErrorStackFramesAlive (oven-sh/WebKit#510) from the Error.prepareStackTrace setter while a user formatter is installed. ErrorInstance::visitChildren then marks the frames, so a live error keeps them until the first .stack read, as V8 does. Take the cell lock in Error.appendStackTrace and in the lazy .stack getter when they move frames, since marking threads now read them.
|
Warning Review limit reached
On-demand reviews are free for the next 27 days. After that, they cost $0.25 per reviewed file. Or wait 21 minutes for your next included review. View limit detailsLimit details: You’ve used all 5 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Status: the fix is pushed and waits for CI and review. Reproduced with stock bun 1.4.0 and the debug build: Error.prepareStackTrace = (e, cs) => "custom:" + e.message;
async function boom() { throw new Error("m"); }
try { await boom(); } catch (e) { Bun.gc(true); console.log(JSON.stringify(e.stack)); }prints the default
CI on d569b96: the only red tests were |
|
Updated 8:42 AM PT - Aug 24th, 2026
✅ @robobun, your commit 64d960180af2e7a51f9db02625cb19b7396b6fb8 passed in 🧪 To try this PR locally: bunx bun-pr 40352That installs a local version of the PR into your bun-40352 --bun |
ShadowRealm and bun test --isolate create more Zig::GlobalObjects on one VM. Each has its own Error.prepareStackTrace. A bool written by the last setter call let one realm clear the flag for another, or a collected realm leave it set. Count the realms with a callable user formatter in JSVMClientData and keep the VM flag set while the count is non-zero. A global that dies with a formatter installed drops its count in the destructor.
|
#40354 (JSC side: oven-sh/WebKit#511) takes the same mechanism, The Bun-side cell lock changes in |
There was a problem hiding this comment.
Thanks — the per-VM realmsWithUserPrepareStackTrace refcount (with the ~GlobalObject decrement and the new ShadowRealm test) addresses the last-writer-wins concern I raised. I didn't find further issues in this revision, but given this touches concurrent GC marking (cell-lock discipline around ErrorInstance frames) and pins WEBKIT_VERSION to a preview build of an unmerged WebKit PR, a human should sign off before merge.
Extended reasoning...
Overview
The PR keeps ErrorInstance stack frames alive across GC while a user Error.prepareStackTrace is installed, so the formatter still runs after a GC between throw and first .stack read. It adds a per-VM refcount (JSVMClientData::realmsWithUserPrepareStackTrace) driven by Zig::GlobalObject::setHasUserPrepareStackTrace, wraps mutator-side frame-vector moves in cellLock() to match the new concurrent visitChildren reader in oven-sh/WebKit#510, and pins WEBKIT_VERSION to that PR's preview build. Three subprocess tests are added.
What changed since my last review
Commit 64d9601 replaced the unconditional VM-flag write with the refcount I suggested: each Zig::GlobalObject tracks its own m_hasUserPrepareStackTrace transition and adjusts the shared count, ~GlobalObject decrements on destruction, and a ShadowRealm test covers the cross-realm reset case. This resolves the concern.
Security risks
None identified — no untrusted input parsing, auth, or network surface.
Level of scrutiny
High. This is JSC GC/memory-safety territory (the repo's most-blocked review category): a VM-wide atomic read by concurrent marking threads, cell-lock discipline around a Vector<StackFrame> that visitChildren now walks, a writeBarrier after appendVector, and a vm()/clientData() access from ~GlobalObject (which runs during sweep). It also lands with WEBKIT_VERSION pointed at a preview autobuild of an unmerged WebKit PR — a maintainer should confirm that's acceptable to merge or should wait for #510.
Other factors
The comment-cop bot has three outstanding flags on the new multi-line comments; those are visible to the author and I'm not restating them. The refcount implementation looks balanced (early-return on no-transition prevents double-counting; destructor decrement covers --isolate teardown), but the interaction between GC-driven ~GlobalObject and JSVMClientData lifetime at VM shutdown is the kind of thing a maintainer familiar with Bun's teardown ordering should confirm.
|
On the teardown order question: The |
|
Closing in favor of #40354. Both PRs mark the frames from The three tests added here are now in #40354 ( |
Problem
Error.prepareStackTraceinstalled, an error whose frames hold a dead callee at GC time gets the default stack string. Every error thrown from an async function hits this once a GC runs before the first.stackread (the async body is a per-call closure). Node runs the formatter.ErrorInstance::reconcileWeakReferencesAtGCEndrenders the string in the GC end phase throughcomputeErrorInfoWrapperToString(src/jsc/bindings/FormatStackTraceForJS.cpp:574), where no JS can run, and drops the frames. The hook that honorsprepareStackTraceruns only while the frames exist.Fix
VM::setKeepsErrorStackFramesAlive(bool). While set,ErrorInstance::visitChildrenmarks the frames under the cell lock, so a live error keeps them until the first.stackread.Error.prepareStackTracesetter counts the realms on the VM with a callable user formatter (JSVMClientData::realmsWithUserPrepareStackTrace) and keeps the flag set while the count is non-zero. A realm leaves the count when the property is reset or its global object dies.prepareStackTraceexists for V8 compatibility. Programs without a formatter keep JSC's weak frames and today's memory behavior.test/js/node/v8/capture-stack-trace.test.js(three new tests, all fail on stock bun). Alsotest/js/bun/test/stack.test.ts,test/js/node/vm/vm.test.ts,test/js/node/util/util.test.js.Background
ErrorInstanceis JSC's class behind every Error object. It captures aVector<StackFrame>at creation and renderserror.stackon first access.StackFrameholds the callee function and its CodeBlock throughWriteBarriers that nothing marks, so an error nobody inspects does not pin functions.visitChildrenis the GC marking hook of a cell. Marking threads run it concurrently with JS, so the frames are read under the cell lock, asestimatedSizedoes.prepareStackTracesupport iscomputeErrorInfoWrapperToJSValue. It buildsCallSiteobjects from the frames and calls the user function.Notes
WEBKIT_VERSIONpoints at the preview build of ErrorInstance: mark the stack frames while the embedder keeps them alive WebKit#510 (autobuild-preview-pr-510-8f7ed779, the current pin c148a12d plus that change). Bump to the merged commit once WebSocket clientmessaageevent is broken #510 lands.Error.appendStackTraceand the lazy.stackgetter now move frames under the cell lock, since marking threads read them.Zig::GlobalObjects (ShadowRealm,bun test --isolate), each with its ownError.prepareStackTrace. A plain flag written by the last setter call let one realm clear it for another. The third test covers the ShadowRealm case. A collected--isolateglobal drops its count in~GlobalObject; that case is not asserted because whether a released callee is collected by a given GC is build dependent.util.getCallSites, theisInsideNodeModulescheck) install a formatter for one capture and restore the previous value. The count follows: one for that capture, zero after.new Functioncallee, andError.captureStackTraceinside anew Functioncallee, at the top level of a script. All three hit the GC end phase rendering on every run of stock bun 1.4.0 and of the debug ASAN build (30 and 8 runs). Shapes nested in a test function were build dependent, so the tests run the scripts in a child process.Bun.gc(true)differs between the release and debug builds, so the tests do not assert it. They assert that an unread error keeps its callee alive (aWeakRefsurvives the GC) and that the formatter result is the same with and without a GC.