From a4b4fc38c0cfcf62a2b085d5779c85628a39fd1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:35:39 +0000 Subject: [PATCH 1/5] error: recover async stack frames dropped under AsyncLocalStorage When an AsyncLocalStorage store is active at an await point, JSPromise::resolveWithInternalMicrotaskForAsyncAwait wraps the awaiting generator in an InternalFieldTuple(generator, asyncContext) and stores that as the reaction context. Interpreter::getAsyncStackTrace reads the reaction context and casts it with dynamicDowncast, which fails on the tuple, so every 'at async ' frame is dropped from error.stack. Install a VM::onAppendStackTrace hook that replicates getParentGenerator with tuple unwrapping and appends the frames JSC's walk misses. The hook runs the unwrapped and non-unwrapped walks in lockstep so frames JSC does find (when ALS was inactive at some hop) are not duplicated. Fixes #24003. --- src/jsc/bindings/AsyncStackTrace.cpp | 188 ++++++++++++++++++++------- src/jsc/bindings/AsyncStackTrace.h | 8 ++ src/jsc/bindings/ZigGlobalObject.cpp | 2 + test/regression/issue/24003.test.ts | 129 ++++++++++++++++++ 4 files changed, 279 insertions(+), 48 deletions(-) create mode 100644 test/regression/issue/24003.test.ts diff --git a/src/jsc/bindings/AsyncStackTrace.cpp b/src/jsc/bindings/AsyncStackTrace.cpp index 6956902cbaff..e6d2d17a7171 100644 --- a/src/jsc/bindings/AsyncStackTrace.cpp +++ b/src/jsc/bindings/AsyncStackTrace.cpp @@ -16,9 +16,72 @@ #include #include #include +#include using namespace JSC; +// dynamicDowncast(JSValue)'s isCell() check is true for the empty value on +// JSVALUE64, so it would read through a null cell. asyncStackTraceContext() +// and reaction getters return the empty value in several paths, so every +// JSValue downcast in this file goes through this helper. +template +static inline T* cellAs(JSValue v) +{ + return (v && v.isCell()) ? dynamicDowncast(v.asCell()) : nullptr; +} + +template +static inline bool dynamicCastValue(JSValue v, T** out) +{ + *out = cellAs(v); + return *out != nullptr; +} + +static BytecodeIndex computeGeneratorBytecodeIndex(CodeBlock* codeBlock, JSAsyncFunctionGenerator* generator) +{ + BytecodeIndex bytecodeIndex(0); + JSValue stateValue = generator->internalField(JSAsyncFunctionGenerator::Field::State).get(); + if (stateValue.isInt32()) { + int32_t state = stateValue.asInt32(); + size_t numberOfJumpTables = codeBlock->numberOfUnlinkedSwitchJumpTables(); + if (state > 0 && numberOfJumpTables > 0) { + size_t lastTableIndex = numberOfJumpTables - 1; + const UnlinkedSimpleJumpTable& jumpTable = codeBlock->unlinkedSwitchJumpTable(lastTableIndex); + int32_t offset = jumpTable.offsetForValue(state); + if (offset) + bytecodeIndex = BytecodeIndex(offset); + } + } + return bytecodeIndex; +} + +static bool appendGeneratorFrame(VM& vm, JSCell* owner, JSAsyncFunctionGenerator* generator, WTF::Vector& results) +{ + auto* asyncFunction = cellAs(generator->next()); + if (!asyncFunction || asyncFunction->isHostOrPrivateBuiltinFunction()) + return false; + FunctionExecutable* executable = asyncFunction->jsExecutable(); + if (!executable) + return false; + if (CodeBlock* codeBlock = executable->codeBlockForCall()) { + BytecodeIndex bytecodeIndex = computeGeneratorBytecodeIndex(codeBlock, generator); + results.append(StackFrame(vm, owner, asyncFunction, codeBlock, bytecodeIndex, /* isAsyncFrame */ true)); + } else { + results.append(StackFrame(vm, owner, asyncFunction, /* isAsyncFrame */ true)); + } + return true; +} + +// With AsyncLocalStorage active, JSPromise::resolveWithInternalMicrotaskForAsyncAwait +// wraps the await context in InternalFieldTuple(context, asyncContext). Unwrap +// to field 0 so the generator/combinator probes see the real cell. +static inline JSValue unwrapAsyncContextTuple(JSValue v) +{ + if (auto* tuple = cellAs(v)) + return tuple->getInternalField(0); + return v; +} + // Walk a promise's reaction chain to find the async generators awaiting it, // and collect them as async StackFrames. Used when an error is created from // native code at the top of the event loop (e.g. run_from_js_thread in node_fs.rs) @@ -35,20 +98,8 @@ static void collectAsyncStackFramesFromPromise(JSC::VM& vm, JSC::JSCell* owner, JSC::AssertNoGC assertNoGC; - auto dynamicCastValue = [](JSC::JSValue v, T** out) -> bool { - if (!v || !v.isCell()) - return false; - *out = dynamicDowncast(v.asCell()); - return *out != nullptr; - }; - auto unwrapGeneratorFromContext = [&](JSC::JSValue context) -> JSC::JSAsyncFunctionGenerator* { - JSC::InternalFieldTuple* tuple = nullptr; - if (dynamicCastValue(context, &tuple)) - context = tuple->getInternalField(0); - JSC::JSAsyncFunctionGenerator* generator = nullptr; - dynamicCastValue(context, &generator); - return generator; + return cellAs(unwrapAsyncContextTuple(context)); }; // Walk reaction->context → generator. If context is not a generator (e.g. @@ -104,43 +155,9 @@ static void collectAsyncStackFramesFromPromise(JSC::VM& vm, JSC::JSCell* owner, return nullptr; }; - auto computeBytecodeIndex = [&](JSC::CodeBlock* codeBlock, JSC::JSAsyncFunctionGenerator* generator) -> JSC::BytecodeIndex { - JSC::BytecodeIndex bytecodeIndex(0); - JSC::JSValue stateValue = generator->internalField(JSC::JSAsyncFunctionGenerator::Field::State).get(); - if (stateValue.isInt32()) { - int32_t state = stateValue.asInt32(); - size_t numberOfJumpTables = codeBlock->numberOfUnlinkedSwitchJumpTables(); - if (state > 0 && numberOfJumpTables > 0) { - size_t lastTableIndex = numberOfJumpTables - 1; - const JSC::UnlinkedSimpleJumpTable& jumpTable = codeBlock->unlinkedSwitchJumpTable(lastTableIndex); - int32_t offset = jumpTable.offsetForValue(state); - if (offset) - bytecodeIndex = JSC::BytecodeIndex(offset); - } - } - return bytecodeIndex; - }; - - auto appendFrame = [&](JSC::JSAsyncFunctionGenerator* generator) { - JSC::JSFunction* asyncFunction = nullptr; - if (!dynamicCastValue(generator->next(), &asyncFunction)) - return; - if (asyncFunction->isHostOrPrivateBuiltinFunction()) - return; - JSC::FunctionExecutable* executable = asyncFunction->jsExecutable(); - if (!executable) - return; - if (JSC::CodeBlock* codeBlock = executable->codeBlockForCall()) { - JSC::BytecodeIndex bytecodeIndex = computeBytecodeIndex(codeBlock, generator); - results.append(JSC::StackFrame(vm, owner, asyncFunction, codeBlock, bytecodeIndex, /* isAsyncFrame */ true)); - } else { - results.append(JSC::StackFrame(vm, owner, asyncFunction, /* isAsyncFrame */ true)); - } - }; - JSC::JSAsyncFunctionGenerator* gen = getAwaitingGenerator(promise); while (gen && results.size() < maxStackSize) { - appendFrame(gen); + appendGeneratorFrame(vm, owner, gen, results); JSC::JSPromise* returnPromise = nullptr; if (!dynamicCastValue(gen->context(), &returnPromise)) break; @@ -176,3 +193,78 @@ extern "C" void Bun__attachAsyncStackFromPromise(JSC::JSGlobalObject* globalObje instance->setStackFrames(vm, WTF::move(frames)); } + +// Installed as VM::onAppendStackTrace. Interpreter::getAsyncStackTrace walks +// the await chain via JSPromise::asyncStackTraceContext(), but under +// AsyncLocalStorage that context is an InternalFieldTuple(generator, asyncContext) +// (see JSPromise::resolveWithInternalMicrotaskForAsyncAwait) and the walk's +// dynamicDowncast fails, dropping every `at async` +// frame. getStackTrace calls this hook before inserting its own async frames, +// so we replicate getParentGenerator here with tuple unwrapping and append the +// frames JSC's walk misses. To avoid duplicating frames JSC does find (when +// ALS was inactive at some hop), we run the unwrapped and non-unwrapped walks +// in lockstep and only start appending once the non-unwrapped walk stops. +void Bun::appendAsyncLocalStorageStackFrames(VM& vm, JSCell* owner, Vector& results, size_t maxToAppend) +{ + if (!maxToAppend || !Options::useAsyncStackTrace()) + return; + + AssertNoGC assertNoGC; + + JSAsyncFunctionGenerator* origin = nullptr; + for (EntryFrame* entryFrame = vm.topEntryFrame; entryFrame;) { + VMEntryRecord* record = vmEntryRecord(entryFrame); + if (auto* generator = dynamicDowncast(record->m_context)) { + origin = generator; + break; + } + entryFrame = record->prevTopEntryFrame(); + } + if (!origin) + return; + + auto promiseContext = [](JSPromise* p, bool unwrap) -> JSValue { + if (!p) + return { }; + JSValue context = p->asyncStackTraceContext(); + return unwrap ? unwrapAsyncContextTuple(context) : context; + }; + + // Mirrors Interpreter::getAsyncStackTrace's getParentGenerator for the + // direct-await and Promise.race shapes. Promise.all/allSettled/any store a + // JSPromiseCombinatorsGlobalContext (a private header the prebuilt WebKit + // doesn't forward), so under ALS the chain still ends at a combinator hop + // the same way JSC's own walk does; that narrower case wants the unwrap in + // getAsyncStackTrace itself. + auto getParentGenerator = [&](JSAsyncFunctionGenerator* gen, bool unwrap) -> JSAsyncFunctionGenerator* { + auto* returnPromise = cellAs(gen->internalField(JSAsyncFunctionGenerator::Field::Context).get()); + JSValue context = promiseContext(returnPromise, unwrap); + if (!context) + return nullptr; + if (auto* generator = cellAs(context)) + return generator; + if (auto* promise = cellAs(context)) + return cellAs(promiseContext(promise, unwrap)); + return nullptr; + }; + + size_t appended = 0; + bool jscStopped = false; + JSAsyncFunctionGenerator* current = origin; + for (unsigned hops = 0; hops < 256 && appended < maxToAppend; hops++) { + JSAsyncFunctionGenerator* next = getParentGenerator(current, /* unwrap */ true); + if (!next) + break; + if (!jscStopped) { + if (getParentGenerator(current, /* unwrap */ false)) + current = next; + else + jscStopped = true; + } + if (jscStopped) { + if (appendGeneratorFrame(vm, owner, next, results)) + appended++; + current = next; + } + } +} diff --git a/src/jsc/bindings/AsyncStackTrace.h b/src/jsc/bindings/AsyncStackTrace.h index f879f4879b26..bcb7d75303d3 100644 --- a/src/jsc/bindings/AsyncStackTrace.h +++ b/src/jsc/bindings/AsyncStackTrace.h @@ -11,6 +11,10 @@ // ErrorInstance with no stack of its own; no-op otherwise. Never throws. extern "C" void Bun__attachAsyncStackFromPromise(JSC::JSGlobalObject*, JSC::EncodedJSValue errorValue, JSC::JSPromise*); +namespace JSC { +class StackFrame; +} + namespace Bun { // C++ convenience wrapper over Bun__attachAsyncStackFromPromise. @@ -19,4 +23,8 @@ inline void attachAsyncStackFromPromise(JSC::JSGlobalObject* globalObject, JSC:: Bun__attachAsyncStackFromPromise(globalObject, JSC::JSValue::encode(error), promise); } +// VM::onAppendStackTrace hook: appends the `at async` frames JSC's own walk drops when +// AsyncLocalStorage wraps await contexts in InternalFieldTuple. See AsyncStackTrace.cpp. +void appendAsyncLocalStorageStackFrames(JSC::VM&, JSC::JSCell* owner, WTF::Vector&, size_t maxToAppend); + } // namespace Bun diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index a45cf0016dfe..dbe2817849e7 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -61,6 +61,7 @@ #include "JavaScriptCore/VM.h" #include "AddEventListenerOptions.h" #include "AsyncContextFrame.h" +#include "AsyncStackTrace.h" #include "BunClientData.h" #include "BunIDLConvert.h" #include "BunObject.h" @@ -554,6 +555,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, vm.setOnComputeErrorInfo(computeErrorInfoWrapperToString); vm.setOnComputeErrorInfoJSValue(computeErrorInfoWrapperToJSValue); vm.setComputeLineColumnWithSourcemap(computeLineColumnWithSourcemap); + vm.setOnAppendStackTrace(Bun::appendAsyncLocalStorageStackFrames); vm.setOnEachMicrotaskTick([](JSC::VM& vm) -> void { // if you process.nextTick on a microtask we need this auto* globalObject = defaultGlobalObject(); diff --git a/test/regression/issue/24003.test.ts b/test/regression/issue/24003.test.ts new file mode 100644 index 000000000000..364c8e2db143 --- /dev/null +++ b/test/regression/issue/24003.test.ts @@ -0,0 +1,129 @@ +// https://github.com/oven-sh/bun/issues/24003 +// Async stack traces were missing their `at async ` frames when an +// AsyncLocalStorage store was active at the point of `await`. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +async function run(source: string): Promise { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return stdout; +} + +function asyncFrames(stack: string): string[] { + return stack + .split("\n") + .filter(l => l.includes("at async ")) + .map(l => l.trim().replace(/ \(.*\)$/, "")); +} + +describe.concurrent("issue #24003", () => { + test("async stack frames under AsyncLocalStorage.run()", async () => { + const stdout = await run(` + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + async function fn3() { await 0; throw new Error("boom"); } + async function fn2() { await fn3(); } + async function fn1() { await fn2(); } + async function main() { + try { await als.run({}, () => fn1()); } + catch (e) { console.log(e.stack); } + } + main(); + `); + expect(stdout).toContain("at fn3"); + expect(asyncFrames(stdout)).toEqual(["at async fn2", "at async fn1", "at async main"]); + }); + + test("async stack frames under AsyncLocalStorage.enterWith()", async () => { + const stdout = await run(` + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + als.enterWith({ x: 1 }); + async function inner() { await 0; console.log(new Error("boom").stack); } + async function outer() { await inner(); } + async function main() { await outer(); } + main(); + `); + expect(stdout).toContain("at inner"); + expect(asyncFrames(stdout)).toEqual(["at async outer", "at async main"]); + }); + + test("async stack frames through Promise.race under AsyncLocalStorage", async () => { + const stdout = await run(` + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + async function leaf() { await 0; throw new Error("boom"); } + async function viaRace() { await Promise.race([leaf()]); } + async function caller() { await viaRace(); } + async function main() { + try { await als.run({}, () => caller()); } + catch (e) { console.log(e.stack); } + } + main(); + `); + expect(stdout).toContain("at leaf"); + expect(asyncFrames(stdout)).toEqual(["at async viaRace", "at async caller", "at async main"]); + }); + + test("async stack frames without AsyncLocalStorage are not duplicated", async () => { + const stdout = await run(` + async function fn3() { await 0; throw new Error("boom"); } + async function fn2() { await fn3(); } + async function fn1() { await fn2(); } + async function main() { + try { await fn1(); } + catch (e) { console.log(e.stack); } + } + main(); + `); + expect(stdout).toContain("at fn3"); + expect(asyncFrames(stdout)).toEqual(["at async fn2", "at async fn1", "at async main"]); + }); + + test("async stack frames through Promise.race without AsyncLocalStorage are not duplicated", async () => { + const stdout = await run(` + async function leaf() { await 0; throw new Error("boom"); } + async function viaRace() { await Promise.race([leaf()]); } + async function caller() { await viaRace(); } + async function main() { + try { await caller(); } + catch (e) { console.log(e.stack); } + } + main(); + `); + expect(stdout).toContain("at leaf"); + expect(asyncFrames(stdout)).toEqual(["at async viaRace", "at async caller", "at async main"]); + }); + + test("async stack frames when AsyncLocalStorage is entered mid-chain", async () => { + // fn2 awaits fn3 before ALS is entered, so JSC's own walk finds fn2; fn1 + // awaits fn2 after enterWith, so JSC stops there and the hook must supply + // fn1/main without duplicating fn2. + const stdout = await run(` + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + async function fn3() { await 0; throw new Error("boom"); } + async function fn2() { await fn3(); } + async function fn1() { + const p = fn2(); + als.enterWith({}); + await p; + } + async function main() { + try { await fn1(); } + catch (e) { console.log(e.stack); } + } + main(); + `); + expect(stdout).toContain("at fn3"); + expect(asyncFrames(stdout)).toEqual(["at async fn2", "at async fn1", "at async main"]); + }); +}); From 0c8a853d4bd49980d9b3f74a44281ea4ca325a7d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:38:07 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- src/jsc/bindings/AsyncStackTrace.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/bindings/AsyncStackTrace.cpp b/src/jsc/bindings/AsyncStackTrace.cpp index e6d2d17a7171..6753058350d4 100644 --- a/src/jsc/bindings/AsyncStackTrace.cpp +++ b/src/jsc/bindings/AsyncStackTrace.cpp @@ -225,7 +225,7 @@ void Bun::appendAsyncLocalStorageStackFrames(VM& vm, JSCell* owner, Vector JSValue { if (!p) - return { }; + return {}; JSValue context = p->asyncStackTraceContext(); return unwrap ? unwrapAsyncContextTuple(context) : context; }; From 052714f1ae0e234e02322e166afa00526bf2fd59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:36:12 +0000 Subject: [PATCH 3/5] Install the hook lazily from jsSetAsyncHooksEnabled and trim comments The hook is now registered the first time AsyncLocalStorage turns tracking on, so code that never touches ALS pays nothing at error-capture time. Also point at oven-sh/WebKit#347, which unwraps the tuple inside getAsyncStackTrace and will make this hook unnecessary once WEBKIT_VERSION moves past it. --- src/jsc/bindings/AsyncStackTrace.cpp | 37 +++++++++++----------------- src/jsc/bindings/NodeAsyncHooks.cpp | 7 +++++- src/jsc/bindings/ZigGlobalObject.cpp | 2 -- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/jsc/bindings/AsyncStackTrace.cpp b/src/jsc/bindings/AsyncStackTrace.cpp index 6753058350d4..dec54b1b042b 100644 --- a/src/jsc/bindings/AsyncStackTrace.cpp +++ b/src/jsc/bindings/AsyncStackTrace.cpp @@ -20,10 +20,8 @@ using namespace JSC; -// dynamicDowncast(JSValue)'s isCell() check is true for the empty value on -// JSVALUE64, so it would read through a null cell. asyncStackTraceContext() -// and reaction getters return the empty value in several paths, so every -// JSValue downcast in this file goes through this helper. +// dynamicDowncast(JSValue)'s isCell() is true for the empty value on JSVALUE64 +// (null cell deref); asyncStackTraceContext() and reaction getters return empty. template static inline T* cellAs(JSValue v) { @@ -72,9 +70,8 @@ static bool appendGeneratorFrame(VM& vm, JSCell* owner, JSAsyncFunctionGenerator return true; } -// With AsyncLocalStorage active, JSPromise::resolveWithInternalMicrotaskForAsyncAwait -// wraps the await context in InternalFieldTuple(context, asyncContext). Unwrap -// to field 0 so the generator/combinator probes see the real cell. +// resolveWithInternalMicrotaskForAsyncAwait wraps await contexts as +// InternalFieldTuple(context, asyncContext) when ALS is active; field 0 is the real cell. static inline JSValue unwrapAsyncContextTuple(JSValue v) { if (auto* tuple = cellAs(v)) @@ -194,16 +191,13 @@ extern "C" void Bun__attachAsyncStackFromPromise(JSC::JSGlobalObject* globalObje instance->setStackFrames(vm, WTF::move(frames)); } -// Installed as VM::onAppendStackTrace. Interpreter::getAsyncStackTrace walks -// the await chain via JSPromise::asyncStackTraceContext(), but under -// AsyncLocalStorage that context is an InternalFieldTuple(generator, asyncContext) -// (see JSPromise::resolveWithInternalMicrotaskForAsyncAwait) and the walk's -// dynamicDowncast fails, dropping every `at async` -// frame. getStackTrace calls this hook before inserting its own async frames, -// so we replicate getParentGenerator here with tuple unwrapping and append the -// frames JSC's walk misses. To avoid duplicating frames JSC does find (when -// ALS was inactive at some hop), we run the unwrapped and non-unwrapped walks -// in lockstep and only start appending once the non-unwrapped walk stops. +// VM::onAppendStackTrace hook, installed lazily by jsSetAsyncHooksEnabled so the +// no-ALS path is untouched. Interpreter::getAsyncStackTrace's getParentGenerator +// casts asyncStackTraceContext() to JSAsyncFunctionGenerator and fails on the +// InternalFieldTuple ALS puts there, dropping every `at async` frame. Run the same +// walk with and without the unwrap in lockstep and append only the frames the +// un-unwrapped walk misses, so hops where ALS was inactive aren't doubled. See +// oven-sh/WebKit#347 for the in-tree unwrap that will obsolete this hook. void Bun::appendAsyncLocalStorageStackFrames(VM& vm, JSCell* owner, Vector& results, size_t maxToAppend) { if (!maxToAppend || !Options::useAsyncStackTrace()) @@ -230,12 +224,9 @@ void Bun::appendAsyncLocalStorageStackFrames(VM& vm, JSCell* owner, Vector JSAsyncFunctionGenerator* { auto* returnPromise = cellAs(gen->internalField(JSAsyncFunctionGenerator::Field::Context).get()); JSValue context = promiseContext(returnPromise, unwrap); diff --git a/src/jsc/bindings/NodeAsyncHooks.cpp b/src/jsc/bindings/NodeAsyncHooks.cpp index 58beb067de3d..d66f43431851 100644 --- a/src/jsc/bindings/NodeAsyncHooks.cpp +++ b/src/jsc/bindings/NodeAsyncHooks.cpp @@ -4,6 +4,7 @@ #include "JavaScriptCore/ObjectConstructor.h" #include "JavaScriptCore/ArrayConstructor.h" +#include "AsyncStackTrace.h" #include "ZigGlobalObject.h" namespace Bun { @@ -27,7 +28,11 @@ JSC_DEFINE_HOST_FUNCTION(jsCleanupLater, (JSC::JSGlobalObject * globalObject, JS JSC_DEFINE_HOST_FUNCTION(jsSetAsyncHooksEnabled, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { ASSERT(callFrame->argumentCount() == 1); - globalObject->setAsyncContextTrackingEnabled(callFrame->uncheckedArgument(0).toBoolean(globalObject)); + bool enabled = callFrame->uncheckedArgument(0).toBoolean(globalObject); + globalObject->setAsyncContextTrackingEnabled(enabled); + auto& vm = JSC::getVM(globalObject); + if (enabled && !vm.onAppendStackTrace()) + vm.setOnAppendStackTrace(Bun::appendAsyncLocalStorageStackFrames); return JSC::JSValue::encode(JSC::jsUndefined()); } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index dbe2817849e7..a45cf0016dfe 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -61,7 +61,6 @@ #include "JavaScriptCore/VM.h" #include "AddEventListenerOptions.h" #include "AsyncContextFrame.h" -#include "AsyncStackTrace.h" #include "BunClientData.h" #include "BunIDLConvert.h" #include "BunObject.h" @@ -555,7 +554,6 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, vm.setOnComputeErrorInfo(computeErrorInfoWrapperToString); vm.setOnComputeErrorInfoJSValue(computeErrorInfoWrapperToJSValue); vm.setComputeLineColumnWithSourcemap(computeLineColumnWithSourcemap); - vm.setOnAppendStackTrace(Bun::appendAsyncLocalStorageStackFrames); vm.setOnEachMicrotaskTick([](JSC::VM& vm) -> void { // if you process.nextTick on a microtask we need this auto* globalObject = defaultGlobalObject(); From 9f710c780102eeace3a734788e72a715196005a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:07:16 +0000 Subject: [PATCH 4/5] test: install the hook in the no-store baseline subprocesses Constructing an AsyncLocalStorage without entering a store installs the onAppendStackTrace hook (via jsSetAsyncHooksEnabled) while leaving every await context unwrapped, so the lockstep walk runs and must append nothing. --- test/regression/issue/24003.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/regression/issue/24003.test.ts b/test/regression/issue/24003.test.ts index 364c8e2db143..4cfd6fc69ab1 100644 --- a/test/regression/issue/24003.test.ts +++ b/test/regression/issue/24003.test.ts @@ -73,8 +73,11 @@ describe.concurrent("issue #24003", () => { expect(asyncFrames(stdout)).toEqual(["at async viaRace", "at async caller", "at async main"]); }); - test("async stack frames without AsyncLocalStorage are not duplicated", async () => { + // The hook is installed lazily from the AsyncLocalStorage constructor; construct one + // without entering a store so the lockstep walk runs but JSC succeeds at every hop. + test("async stack frames are not duplicated when no AsyncLocalStorage store is active", async () => { const stdout = await run(` + new (require("node:async_hooks").AsyncLocalStorage)(); async function fn3() { await 0; throw new Error("boom"); } async function fn2() { await fn3(); } async function fn1() { await fn2(); } @@ -88,8 +91,9 @@ describe.concurrent("issue #24003", () => { expect(asyncFrames(stdout)).toEqual(["at async fn2", "at async fn1", "at async main"]); }); - test("async stack frames through Promise.race without AsyncLocalStorage are not duplicated", async () => { + test("async stack frames through Promise.race are not duplicated when no AsyncLocalStorage store is active", async () => { const stdout = await run(` + new (require("node:async_hooks").AsyncLocalStorage)(); async function leaf() { await 0; throw new Error("boom"); } async function viaRace() { await Promise.race([leaf()]); } async function caller() { await viaRace(); } From ca5c60b1ae4271effe6bc49e544c806a3e328c87 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:09:20 +0000 Subject: [PATCH 5/5] ci: retrigger