Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
24b4559f86 fix(stacktrace): use Interpreter::getStackTrace for async continuation frames
Replace the manual StackVisitor walk in Error.captureStackTrace with
JSC's Interpreter::getStackTrace, which properly handles async
continuation frames (frames from functions suspended at await points
higher up the async call chain).

The previous implementation manually walked the synchronous call stack
using StackVisitor::visit(), which missed async continuation frames.
This caused issues with libraries like NX that use
Error.captureStackTrace with custom Error.prepareStackTrace to detect
recursion by inspecting CallSite objects in async call chains.

Closes #25695

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-15 00:14:38 +00:00
2 changed files with 160 additions and 100 deletions

View File

@@ -22,6 +22,7 @@
#include <wtf/IterationStatus.h>
#include <JavaScriptCore/CodeBlock.h>
#include <JavaScriptCore/FunctionCodeBlock.h>
#include <JavaScriptCore/Interpreter.h>
#include "ErrorStackFrame.h"
@@ -114,9 +115,9 @@ JSCStackTrace JSCStackTrace::fromExisting(JSC::VM& vm, const WTF::Vector<JSC::St
void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, JSC::JSCell* owner, JSC::JSValue caller, WTF::Vector<JSC::StackFrame>& stackTrace, size_t stackTraceLimit)
{
size_t framesCount = 0;
bool belowCaller = false;
// Compute the number of frames to skip by walking the stack to find the caller.
// We need this first pass because Interpreter::getStackTrace uses framesToSkip
// as a count of visible (non-private) frames to skip.
int32_t skipFrames = 0;
WTF::String callerName {};
@@ -129,29 +130,15 @@ void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, J
callerName = callerFunctionInternal->name();
}
size_t totalFrames = 0;
if (!callerName.isEmpty()) {
JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus {
if (isImplementationVisibilityPrivate(visitor)) {
return WTF::IterationStatus::Continue;
}
framesCount += 1;
skipFrames += 1;
// skip caller frame and all frames above it
if (!belowCaller) {
skipFrames += 1;
if (visitor->functionName() == callerName) {
belowCaller = true;
return WTF::IterationStatus::Continue;
}
}
totalFrames += 1;
if (totalFrames > stackTraceLimit) {
if (visitor->functionName() == callerName) {
return WTF::IterationStatus::Done;
}
@@ -163,95 +150,34 @@ void JSCStackTrace::getFramesForCaller(JSC::VM& vm, JSC::CallFrame* callFrame, J
return WTF::IterationStatus::Continue;
}
framesCount += 1;
// skip caller frame and all frames above it
if (!belowCaller) {
auto callee = visitor->callee();
skipFrames += 1;
if (callee.isCell() && callee.asCell() == caller) {
belowCaller = true;
return WTF::IterationStatus::Continue;
}
}
totalFrames += 1;
if (totalFrames > stackTraceLimit) {
skipFrames += 1;
auto callee = visitor->callee();
if (callee.isCell() && callee.asCell() == caller) {
return WTF::IterationStatus::Done;
}
return WTF::IterationStatus::Continue;
});
} else if (caller.isEmpty() || caller.isUndefined()) {
// Skip the first frame.
JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus {
if (isImplementationVisibilityPrivate(visitor)) {
return WTF::IterationStatus::Continue;
}
framesCount += 1;
if (!belowCaller) {
skipFrames += 1;
belowCaller = true;
}
totalFrames += 1;
if (totalFrames > stackTraceLimit) {
return WTF::IterationStatus::Done;
}
return WTF::IterationStatus::Continue;
});
// Skip the first frame (captureStackTrace itself).
skipFrames = 1;
}
size_t i = 0;
totalFrames = 0;
stackTrace.reserveInitialCapacity(framesCount);
JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus {
// Skip native frames
if (isImplementationVisibilityPrivate(visitor)) {
return WTF::IterationStatus::Continue;
// Use Interpreter::getStackTrace which handles async continuation frames
// (frames from functions suspended at await points higher up the async call chain).
// This is critical for compatibility with V8's behavior where Error.captureStackTrace
// includes suspended async frames in the CallSite array.
WTF::Vector<JSC::StackFrame> rawStackTrace;
vm.interpreter.getStackTrace(owner, rawStackTrace, skipFrames, stackTraceLimit);
// Filter out private/internal implementation frames to match the behavior
// of the previous StackVisitor-based approach.
stackTrace.reserveInitialCapacity(rawStackTrace.size());
for (auto& frame : rawStackTrace) {
if (!isImplementationVisibilityPrivate(frame)) {
stackTrace.append(frame);
}
// Skip frames if needed
if (skipFrames > 0) {
skipFrames--;
return WTF::IterationStatus::Continue;
}
totalFrames += 1;
if (totalFrames > stackTraceLimit) {
return WTF::IterationStatus::Done;
}
if (visitor->isNativeCalleeFrame()) {
auto* nativeCallee = visitor->callee().asNativeCallee();
switch (nativeCallee->category()) {
case NativeCallee::Category::Wasm: {
stackTrace.append(StackFrame(visitor->wasmFunctionIndexOrName()));
break;
}
case NativeCallee::Category::InlineCache: {
break;
}
}
#if USE(ALLOW_LINE_AND_COLUMN_NUMBER_IN_BUILTINS)
} else if (!!visitor->codeBlock())
#else
} else if (!!visitor->codeBlock() && !visitor->codeBlock()->unlinkedCodeBlock()->isBuiltinFunction())
#endif
stackTrace.append(StackFrame(vm, owner, visitor->callee().asCell(), visitor->codeBlock(), visitor->bytecodeIndex()));
else
stackTrace.append(StackFrame(vm, owner, visitor->callee().asCell()));
i++;
return (i == framesCount) ? WTF::IterationStatus::Done : WTF::IterationStatus::Continue;
});
}
}
JSCStackTrace JSCStackTrace::getStackTraceForThrownValue(JSC::VM& vm, JSC::JSValue thrownValue)

View File

@@ -0,0 +1,134 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// GitHub Issue #25695: Error.captureStackTrace with custom prepareStackTrace
// does not include async continuation frames (awaiter frames), causing NX's
// recursion detection to fail and leading to infinite recursion.
test("Error.captureStackTrace includes async continuation frames in CallSite array", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let callCount = 0;
function getCallSites() {
const prepareStackTraceBackup = Error.prepareStackTrace;
Error.prepareStackTrace = (_, stackTraces) => stackTraces;
const errorObject = {};
Error.captureStackTrace(errorObject);
const trace = errorObject.stack;
Error.prepareStackTrace = prepareStackTraceBackup;
trace.shift();
return trace;
}
function preventRecursion() {
const stackframes = getCallSites().slice(2);
const found = stackframes.some((f) => {
return f.getFunctionName() === 'outerAsync';
});
if (found) {
throw new Error('Loop detected');
}
}
async function outerAsync() {
callCount++;
if (callCount > 5) {
throw new Error('Safety limit');
}
preventRecursion();
await new Promise(resolve => setTimeout(resolve, 1));
const result = await middleAsync();
return result;
}
async function middleAsync() {
return await innerAsync();
}
async function innerAsync() {
return await outerAsync();
}
try {
await outerAsync();
console.log("BUG:" + callCount);
} catch (e) {
if (e.message === 'Loop detected') {
console.log("OK:" + callCount);
} else {
console.log("FAIL:" + e.message);
}
}
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should detect the recursion, not hit the safety limit
expect(stdout.trim()).toStartWith("OK:");
expect(exitCode).toBe(0);
});
test("Error.captureStackTrace async frames have correct function names", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
function getCallSites() {
const backup = Error.prepareStackTrace;
Error.prepareStackTrace = (_, sites) => sites;
const obj = {};
Error.captureStackTrace(obj);
const trace = obj.stack;
Error.prepareStackTrace = backup;
return trace;
}
let captured = null;
async function alphaAsync() {
await new Promise(resolve => setTimeout(resolve, 1));
const result = await betaAsync();
return result;
}
async function betaAsync() {
return await gammaAsync();
}
async function gammaAsync() {
// Capture the stack trace inside the innermost async function
captured = getCallSites().map(s => s.getFunctionName()).filter(Boolean);
return 42;
}
await alphaAsync();
// The captured stack should include gammaAsync's callers (betaAsync, alphaAsync)
// via async continuation frames
const hasGamma = captured.includes('gammaAsync');
const hasBeta = captured.includes('betaAsync');
const hasAlpha = captured.includes('alphaAsync');
console.log(JSON.stringify({ hasGamma, hasBeta, hasAlpha, frames: captured }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
// gammaAsync is the current frame (synchronous), should always be present
expect(result.hasGamma).toBe(true);
// betaAsync and alphaAsync are async continuation frames
expect(result.hasBeta).toBe(true);
expect(result.hasAlpha).toBe(true);
expect(exitCode).toBe(0);
});