Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
8612285e3d fix: preserve stack traces for errors after structuredClone
After calling structuredClone() on an Error object, the error would lose
its internal JSC stack trace. When console.error formatted the cloned error,
it fell back to parsing the .stack string property. However, the V8-style
stack trace parser would stop early when encountering frames without
function names (e.g., top-level code execution frames).

These anonymous frames are formatted as "at /path/to/file:line:column"
without parentheses, which the parser previously treated as invalid and
stopped parsing.

This fix updates the V8StackTraceIterator to properly handle frames
without function names by parsing them as anonymous frames with just
the source location information.
2025-11-11 05:01:14 +00:00
2 changed files with 162 additions and 3 deletions

View File

@@ -303,9 +303,70 @@ public:
return true;
}
// For any other frame without parentheses, terminate parsing as before
offset = stack.length();
return false;
// Frames without function names (e.g., top-level code) don't have parentheses
// Format: "/path/to/file.ts:line:column" or "/path/to/file.ts:line"
// Parse these directly as anonymous frames
auto marker1 = 0u;
auto marker2 = line.find(':', marker1);
if (marker2 == WTF::notFound) {
// No colons found, treat entire line as source URL
frame.sourceURL = line;
frame.functionName = StringView();
return true;
}
auto marker3 = line.find(':', marker2 + 1);
if (marker3 == WTF::notFound) {
marker3 = line.length();
auto segment1 = StringView_slice(line, marker1, marker2);
auto segment2 = StringView_slice(line, marker2 + 1, marker3);
if (auto int1 = WTF::parseIntegerAllowingTrailingJunk<unsigned int>(segment2)) {
frame.sourceURL = segment1;
frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int1.value());
} else {
frame.sourceURL = StringView_slice(line, marker1, marker3);
}
frame.functionName = StringView();
return true;
}
// Find the last two colons to extract line:column
while (true) {
auto newcolon = line.find(':', marker3 + 1);
if (newcolon == WTF::notFound)
break;
marker2 = marker3;
marker3 = newcolon;
}
auto marker4 = line.length();
auto segment1 = StringView_slice(line, marker1, marker2);
auto segment2 = StringView_slice(line, marker2 + 1, marker3);
auto segment3 = StringView_slice(line, marker3 + 1, marker4);
if (auto int1 = WTF::parseIntegerAllowingTrailingJunk<unsigned int>(segment2)) {
if (auto int2 = WTF::parseIntegerAllowingTrailingJunk<unsigned int>(segment3)) {
frame.sourceURL = segment1;
frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int1.value());
frame.columnNumber = WTF::OrdinalNumber::fromOneBasedInt(int2.value());
} else {
frame.sourceURL = segment1;
frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int1.value());
}
} else {
if (auto int2 = WTF::parseIntegerAllowingTrailingJunk<unsigned int>(segment3)) {
frame.sourceURL = StringView_slice(line, marker1, marker3);
frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int2.value());
} else {
frame.sourceURL = StringView_slice(line, marker1, marker4);
}
}
frame.functionName = StringView();
return true;
}
auto lineInner = StringView_slice(line, openingParentheses + 1, closingParentheses);

View File

@@ -0,0 +1,98 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
test("structuredClone() should not lose Error stack trace", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
function okay() {
const error = new Error("OKAY");
console.error(error);
}
function broken() {
const error = new Error("BROKEN");
structuredClone(error);
console.error(error);
}
function main() {
okay();
broken();
}
main();
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
// Both errors should have full stack traces
// The "okay" error should have the full stack
expect(stderr).toContain("at okay");
expect(stderr).toContain("at main");
// The "broken" error should ALSO have the full stack after structuredClone
const lines = stderr.split("\n");
const brokenErrorIndex = lines.findIndex(line => line.includes("BROKEN"));
expect(brokenErrorIndex).toBeGreaterThan(-1);
// Find the stack trace lines after BROKEN
const stackLinesAfterBroken = lines.slice(brokenErrorIndex);
const stackTraceStr = stackLinesAfterBroken.join("\n");
// Should have "at broken" in the stack
expect(stackTraceStr).toContain("at broken");
// Should also have "at main" in the stack (not just the first line)
expect(stackTraceStr).toContain("at main");
// CRITICAL: Should also have the top-level frame (the one that calls main())
// This is the frame that was being lost after structuredClone
// It appears as "at /path/to/file:line" without a function name
// Count the number of "at " occurrences in the BROKEN error stack trace
const brokenStackMatches = stackTraceStr.match(/\s+at\s+/g);
const okayErrorIndex = lines.findIndex(line => line.includes("OKAY"));
const okayStackLines = lines.slice(okayErrorIndex);
const okayStackTraceStr = okayStackLines.slice(0, brokenErrorIndex - okayErrorIndex).join("\n");
const okayStackMatches = okayStackTraceStr.match(/\s+at\s+/g);
// Both errors should have the same number of stack frames (or at least 3)
// Before the fix, BROKEN would only show 2 frames instead of 3+
expect(brokenStackMatches?.length).toBeGreaterThanOrEqual(3);
expect(okayStackMatches?.length).toBeGreaterThanOrEqual(3);
expect(exitCode).toBe(0);
});
test("error.stack should remain intact after structuredClone", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
function broken() {
const error = new Error("BROKEN");
structuredClone(error);
console.log(error.stack);
}
broken();
`,
],
env: bunEnv,
stdout: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
// The stack should contain both "at broken" and be properly formatted
expect(stdout).toContain("Error: BROKEN");
expect(stdout).toContain("at broken");
expect(exitCode).toBe(0);
});