Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions src/jsc/bindings/ErrorStackTrace.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ JSCStackTrace JSCStackTrace::fromExisting(JSC::VM& vm, const WTF::Vector<JSC::St
return JSCStackTrace(newFrames);
}

static bool callerCouldBeTailCallElided(JSC::JSObject* callerObject)
{
if (dynamicDowncast<JSC::JSBoundFunction>(callerObject))
return true;
if (auto* function = dynamicDowncast<JSC::JSFunction>(callerObject)) {
if (function->isHostFunction())
return false;
JSC::FunctionExecutable* executable = function->jsExecutable();
if (!executable->isInStrictContext())
return false;
return executable->isGeneratedForCall();
}
if (dynamicDowncast<JSC::InternalFunction>(callerObject))
return false;
return true;
}

void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, JSC::JSCell* owner, JSC::JSValue caller, WTF::Vector<JSC::StackFrame>& stackTrace, size_t stackTraceLimit)
{
UNUSED_PARAM(callFrame);
Expand Down Expand Up @@ -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<size_t> removeCount;
for (size_t i = 0; i < stackTrace.size(); i++) {
const auto& frame = stackTrace.at(i);
if (frame.isAsyncFrame())
Expand All @@ -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);
Expand Down
77 changes: 77 additions & 0 deletions test/js/node/v8/capture-stack-trace.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading