diff --git a/src/jsc/bindings/ErrorStackTrace.cpp b/src/jsc/bindings/ErrorStackTrace.cpp index 96da90877070..931cfda0692f 100644 --- a/src/jsc/bindings/ErrorStackTrace.cpp +++ b/src/jsc/bindings/ErrorStackTrace.cpp @@ -113,6 +113,23 @@ 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(); + } + if (dynamicDowncast(callerObject)) + return false; + 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 +171,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 +191,11 @@ void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, J } } - if (removeCount > 0) - stackTrace.removeAt(0, removeCount); + 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..27916786814e 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1027,3 +1027,80 @@ 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 +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({}); + return [result]; + } + noInline(initPlugin); + + const [err] = initPlugin(); + expect(err.stack).toContain("at innerParse"); + expect(err.stack).toContain("at initPlugin"); +}); + +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", () => { + 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", () => { + 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"); +}); + +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"); +});