From d04cea1bc1ecde205a6b444b771b37cef4de504c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:08:51 +0000 Subject: [PATCH 1/4] Error.captureStackTrace: don't clear the trace when the callee frame was elided by a tail call JSC implements ES2015 proper tail calls, so a strict-mode function whose body ends in a tail call has its frame replaced by its callee's. When such a function is passed as the second argument to Error.captureStackTrace, it cannot be found on the stack, and the previous behavior (remove every frame, matching V8's skip-until-seen semantics) destroyed the entire trace. zod v4's .parse() does exactly this on every failed parse, leaving ZodErrors with zero stack frames. Keep the collected frames when the callee's frame could have been tail-elided. Still clear the trace, matching Node, when the callee provably never had an elidable call frame: host functions, native constructors, sloppy-mode functions, and functions that never generated code for a regular call. Bound functions keep the trace too, matching Node, where V8 never matches bound functions against stack frames. --- src/jsc/bindings/ErrorStackTrace.cpp | 61 ++++++++++++++--- test/js/node/v8/capture-stack-trace.test.js | 73 +++++++++++++++++++++ 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/jsc/bindings/ErrorStackTrace.cpp b/src/jsc/bindings/ErrorStackTrace.cpp index 96da90877070..37135c7c8263 100644 --- a/src/jsc/bindings/ErrorStackTrace.cpp +++ b/src/jsc/bindings/ErrorStackTrace.cpp @@ -113,6 +113,37 @@ JSCStackTrace JSCStackTrace::fromExisting(JSC::VM& vm, const WTF::Vector(callerObject)) + return true; + if (auto* function = dynamicDowncast(callerObject)) { + if (function->isHostFunction()) + return false; + JSC::FunctionExecutable* executable = function->jsExecutable(); + if (!executable->isInStrictContext()) + return false; + return executable->isGeneratedForCall(); + } + // Native constructors (Function, Array, ...) execute no JS code, so their + // frames are never tail-elided. + if (dynamicDowncast(callerObject)) + return false; + // Remaining callables (e.g. callable proxies) can forward tail calls, so + // their absence from the stack proves nothing. + return true; +} + void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, JSC::JSCell* owner, JSC::JSValue caller, WTF::Vector& stackTrace, size_t stackTraceLimit) { UNUSED_PARAM(callFrame); @@ -154,13 +185,12 @@ void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, J auto* globalObject = callerObject->globalObject(); WTF::String callerName = Zig::functionName(vm, globalObject, callerObject); - // Match V8: remove all frames up to and including the caller. If the caller - // is not found anywhere in the sync portion of the stack, remove everything. - // We match by cell identity first, then by name — name matching is needed - // because a resumed async function's frame callee is the generator's `next` - // function (a different cell) but Zig::functionName still reports the - // original async function's name. - size_t removeCount = stackTrace.size(); + // Match V8: remove all frames up to and including the caller. We match by + // cell identity first, then by name: name matching is needed because a + // resumed async function's frame callee is the generator's `next` function + // (a different cell) but Zig::functionName still reports the original + // async function's name. + std::optional removeCount; for (size_t i = 0; i < stackTrace.size(); i++) { const auto& frame = stackTrace.at(i); if (frame.isAsyncFrame()) @@ -175,8 +205,21 @@ void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, J } } - if (removeCount > 0) - stackTrace.removeAt(0, removeCount); + // V8 removes every frame when the caller is not found: without tail calls, + // "not on the stack" can only mean the function was never called, so every + // collected frame is machinery above it. JSC implements ES2015 proper tail + // calls, so a strict-mode function whose body ends in a tail call has its + // frame replaced by its callee's and is unfindable here even though it is + // logically on the stack. zod v4's .parse() passes exactly such a function + // (`inst.parse = (data, params) => parse.parse(inst, data, params, ...)`), + // and wiping gave every ZodError an empty stack (issue #13904). Only wipe + // when the caller's frame provably could not have been tail-elided; keep + // the whole trace otherwise, since extra frames beat an empty stack. + if (!removeCount && !callerCouldBeTailCallElided(callerObject)) + removeCount = stackTrace.size(); + + if (removeCount) + stackTrace.removeAt(0, *removeCount); if (stackTrace.size() > stackTraceLimit) stackTrace.shrink(stackTraceLimit); diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index a834a4b0a0ad..3f8091333103 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1027,3 +1027,76 @@ test("lazy error-info materialization does not store an empty stack value when t }); expect(exitCode).toBe(0); }); + +// https://github.com/oven-sh/bun/issues/13904 +// JSC implements ES2015 proper tail calls: `inst.parse = data => inner(data)` is +// an implicit-return tail call in strict mode, so the `inst.parse` frame is +// replaced by inner's frame and the function passed to captureStackTrace is not +// findable on the stack. zod v4's .parse() passes exactly such a function. The +// frames that do exist must survive instead of the whole trace being cleared. +test("captureStackTrace keeps frames when the caller frame was elided by a tail call", () => { + const inst = {}; + function innerParse(data, callee) { + const err = new Error("invalid input"); + Error.captureStackTrace(err, callee); + return err; + } + noInline(innerParse); + inst.parse = data => innerParse(data, inst.parse); + + function initPlugin() { + const result = inst.parse({}); + // Not a tail call, so initPlugin's frame stays on the stack. + return [result]; + } + noInline(initPlugin); + + const [err] = initPlugin(); + expect(err.stack).toContain("at innerParse"); + expect(err.stack).toContain("at initPlugin"); +}); + +// The tail-call exception must not weaken V8 parity where the caller's frame +// provably could not have been elided: host functions and sloppy-mode +// functions never make tail calls, so "not on the stack" means "not called" +// and the trace is cleared like in Node.js. +test("captureStackTrace still clears frames for a host function not in the stack", () => { + Math.max(1, 2); + const e = new Error("test"); + Error.captureStackTrace(e, Math.max); + expect(e.stack).toBe("Error: test"); +}); + +test("captureStackTrace still clears frames for a sloppy-mode function not in the stack", () => { + // Indirect eval runs in the global scope in sloppy mode. + const sloppyFn = (0, eval)("(function sloppyNotOnStack() { return 1; })"); + sloppyFn(); + const e = new Error("test"); + Error.captureStackTrace(e, sloppyFn); + expect(e.stack).toBe("Error: test"); +}); + +test("captureStackTrace keeps frames for a bound function not in the stack", () => { + // Node keeps the full trace when the second argument is a bound function + // that is not on the stack: V8 never matches bound functions against stack + // frames, so no filtering happens. + function target() { + return 1; + } + const bound = target.bind(null); + function makeErr() { + const e = new Error("test"); + Error.captureStackTrace(e, bound); + return [e]; + } + noInline(makeErr); + function invoker() { + const r = makeErr(); + return [r]; + } + noInline(invoker); + + const [[e]] = invoker(); + expect(e.stack).toContain("at makeErr"); + expect(e.stack).toContain("at invoker"); +}); From efe9d8c8ad0f9f65e7dcef204ca9fba7e39b9297 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:23:22 +0000 Subject: [PATCH 2/4] ci: retrigger From 98485736bd3ebbd5c729ce14397639c37e2ce800 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:42:53 +0000 Subject: [PATCH 3/4] Remove comments --- src/jsc/bindings/ErrorStackTrace.cpp | 26 +-------------------- test/js/node/v8/capture-stack-trace.test.js | 14 ----------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/jsc/bindings/ErrorStackTrace.cpp b/src/jsc/bindings/ErrorStackTrace.cpp index 37135c7c8263..931cfda0692f 100644 --- a/src/jsc/bindings/ErrorStackTrace.cpp +++ b/src/jsc/bindings/ErrorStackTrace.cpp @@ -113,18 +113,8 @@ JSCStackTrace JSCStackTrace::fromExisting(JSC::VM& vm, const WTF::Vector(callerObject)) return true; if (auto* function = dynamicDowncast(callerObject)) { @@ -135,12 +125,8 @@ static bool callerCouldBeTailCallElided(JSC::JSObject* callerObject) return false; return executable->isGeneratedForCall(); } - // Native constructors (Function, Array, ...) execute no JS code, so their - // frames are never tail-elided. if (dynamicDowncast(callerObject)) return false; - // Remaining callables (e.g. callable proxies) can forward tail calls, so - // their absence from the stack proves nothing. return true; } @@ -186,7 +172,7 @@ void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, J WTF::String callerName = Zig::functionName(vm, globalObject, callerObject); // Match V8: remove all frames up to and including the caller. We match by - // cell identity first, then by name: name matching is needed because a + // cell identity first, then by name — name matching is needed because a // resumed async function's frame callee is the generator's `next` function // (a different cell) but Zig::functionName still reports the original // async function's name. @@ -205,16 +191,6 @@ void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, J } } - // V8 removes every frame when the caller is not found: without tail calls, - // "not on the stack" can only mean the function was never called, so every - // collected frame is machinery above it. JSC implements ES2015 proper tail - // calls, so a strict-mode function whose body ends in a tail call has its - // frame replaced by its callee's and is unfindable here even though it is - // logically on the stack. zod v4's .parse() passes exactly such a function - // (`inst.parse = (data, params) => parse.parse(inst, data, params, ...)`), - // and wiping gave every ZodError an empty stack (issue #13904). Only wipe - // when the caller's frame provably could not have been tail-elided; keep - // the whole trace otherwise, since extra frames beat an empty stack. if (!removeCount && !callerCouldBeTailCallElided(callerObject)) removeCount = stackTrace.size(); diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 3f8091333103..b10d01c0d6e0 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1029,11 +1029,6 @@ test("lazy error-info materialization does not store an empty stack value when t }); // https://github.com/oven-sh/bun/issues/13904 -// JSC implements ES2015 proper tail calls: `inst.parse = data => inner(data)` is -// an implicit-return tail call in strict mode, so the `inst.parse` frame is -// replaced by inner's frame and the function passed to captureStackTrace is not -// findable on the stack. zod v4's .parse() passes exactly such a function. The -// frames that do exist must survive instead of the whole trace being cleared. test("captureStackTrace keeps frames when the caller frame was elided by a tail call", () => { const inst = {}; function innerParse(data, callee) { @@ -1046,7 +1041,6 @@ test("captureStackTrace keeps frames when the caller frame was elided by a tail function initPlugin() { const result = inst.parse({}); - // Not a tail call, so initPlugin's frame stays on the stack. return [result]; } noInline(initPlugin); @@ -1056,10 +1050,6 @@ test("captureStackTrace keeps frames when the caller frame was elided by a tail expect(err.stack).toContain("at initPlugin"); }); -// The tail-call exception must not weaken V8 parity where the caller's frame -// provably could not have been elided: host functions and sloppy-mode -// functions never make tail calls, so "not on the stack" means "not called" -// and the trace is cleared like in Node.js. test("captureStackTrace still clears frames for a host function not in the stack", () => { Math.max(1, 2); const e = new Error("test"); @@ -1068,7 +1058,6 @@ test("captureStackTrace still clears frames for a host function not in the stack }); test("captureStackTrace still clears frames for a sloppy-mode function not in the stack", () => { - // Indirect eval runs in the global scope in sloppy mode. const sloppyFn = (0, eval)("(function sloppyNotOnStack() { return 1; })"); sloppyFn(); const e = new Error("test"); @@ -1077,9 +1066,6 @@ test("captureStackTrace still clears frames for a sloppy-mode function not in th }); test("captureStackTrace keeps frames for a bound function not in the stack", () => { - // Node keeps the full trace when the second argument is a bound function - // that is not on the stack: V8 never matches bound functions against stack - // frames, so no filtering happens. function target() { return 1; } From ee01e06106171fe051d62db4126144085966514f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:06:30 +0000 Subject: [PATCH 4/4] Test captureStackTrace keeps frames for a non-callable object argument --- test/js/node/v8/capture-stack-trace.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index b10d01c0d6e0..27916786814e 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1086,3 +1086,21 @@ test("captureStackTrace keeps frames for a bound function not in the stack", () expect(e.stack).toContain("at makeErr"); expect(e.stack).toContain("at invoker"); }); + +test("captureStackTrace keeps frames when the second argument is a non-callable object", () => { + function makeErr(arg) { + const e = new Error("test"); + Error.captureStackTrace(e, arg); + return [e]; + } + noInline(makeErr); + function outer() { + const r = makeErr({}); + return [r]; + } + noInline(outer); + + const [[e]] = outer(); + expect(e.stack).toContain("at makeErr"); + expect(e.stack).toContain("at outer"); +});