Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
9 changes: 5 additions & 4 deletions src/bun.js/bindings/FormatStackTraceForJS.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -609,12 +609,13 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionDefaultErrorPrepareStackTrace, (JSGlobalObjec
auto scope = DECLARE_THROW_SCOPE(vm);
auto* globalObject = defaultGlobalObject(lexicalGlobalObject);

auto errorObject = jsDynamicCast<JSC::ErrorInstance*>(callFrame->argument(0));
auto callSites = jsDynamicCast<JSC::JSArray*>(callFrame->argument(1));
if (!errorObject) {
throwTypeError(lexicalGlobalObject, scope, "First argument must be an Error object"_s);
JSC::JSValue errorArg = callFrame->argument(0);
if (!errorArg.isObject()) {
throwTypeError(lexicalGlobalObject, scope, "First argument must be an object"_s);
return {};
}
auto* errorObject = errorArg.getObject();
auto callSites = jsDynamicCast<JSC::JSArray*>(callFrame->argument(1));
if (!callSites) {
callSites = JSArray::create(vm, globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), 0);
}
Expand Down
49 changes: 49 additions & 0 deletions test/js/node/v8/capture-stack-trace.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -790,3 +790,52 @@ test("captureStackTrace with constructor function not in stack returns error str
expect(e.stack).toBe("TypeError: bad type");
}
});

// Regression test for https://github.com/oven-sh/bun/issues/27708
// Error.captureStackTrace must work with ES5-style Error subclasses
// even when Error.prepareStackTrace has been replaced (e.g. by @babel/core).
test("captureStackTrace works with ES5 Error subclass when prepareStackTrace is set", () => {
// ES5-style Error subclass (no super() call, just prototype chain setup)
// This is the pattern used by @xmldom/xmldom's ParseError
function ES5Error(message) {
this.message = message;
if (Error.captureStackTrace) Error.captureStackTrace(this, ES5Error);
}
ES5Error.prototype = Object.create(Error.prototype, {
constructor: { value: ES5Error },
name: { value: "ES5Error", enumerable: true },
});

// Works before prepareStackTrace is replaced
const e1 = new ES5Error("before");
expect(e1.message).toBe("before");
expect(typeof e1.stack).toBe("string");
expect(e1 instanceof Error).toBe(true);

// Replace prepareStackTrace with a wrapper (like @babel/core does)
const original = Error.prepareStackTrace;
Error.prepareStackTrace = function wrappedPrepareStackTrace(err, trace) {
// Babel's stackTraceRewriter chains to the original prepareStackTrace
if (original) return original(err, trace);
return `${err}\n at ${trace.join("\n at ")}`;
};

// Must still work after prepareStackTrace is replaced
const e2 = new ES5Error("after");
expect(e2.message).toBe("after");
expect(typeof e2.stack).toBe("string");
expect(e2 instanceof Error).toBe(true);
});

test("captureStackTrace works with plain object when prepareStackTrace is set", () => {
const original = Error.prepareStackTrace;
Error.prepareStackTrace = function wrappedPrepareStackTrace(err, trace) {
if (original) return original(err, trace);
return `${err}\n at ${trace.join("\n at ")}`;
};

// V8 allows any object as the target for captureStackTrace
const obj = { message: "plain object" };
Error.captureStackTrace(obj);
expect(typeof obj.stack).toBe("string");
});
180 changes: 180 additions & 0 deletions test/regression/issue/027708.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";

// Test for https://github.com/oven-sh/bun/issues/27708
//
// Root cause: Error.prepareStackTrace interacts incorrectly with ES5-style
// Error subclasses when called through Error.captureStackTrace.
//
// 1. @babel/core's loadPartialConfig/transformSync replaces Error.prepareStackTrace
// with a "stackTraceRewriter" that chains to the original prepareStackTrace.
// 2. @xmldom/xmldom uses ES5-style Error subclassing (Object.create(Error.prototype))
// for its ParseError class, without calling super()/Error.call(this).
// 3. When Error.captureStackTrace is called on this non-ErrorInstance object,
// bun's default prepareStackTrace (jsFunctionDefaultErrorPrepareStackTrace) was
// called through the chain and threw TypeError because it required a JSC ErrorInstance.
// 4. This TypeError prevents xmldom's ParseError from being properly thrown during
// malformed SVG parsing, causing the SAX parser to enter an error recovery path
// that leads to an infinite loop in the position() function's linePattern regex.

const HELPERS_TS = `
import { DOMParser } from "@xmldom/xmldom";

interface DomNode {
readonly tagName?: string;
readonly nodeType: number;
readonly attributes?: {
readonly length: number;
item(index: number): { readonly name: string; readonly value: string } | null;
};
readonly childNodes?: {
readonly length: number;
item(index: number): DomNode | null;
};
}

const FORBIDDEN_ELEMENTS = new Set(["script", "style", "foreignobject"]);
const EVENT_HANDLER_PATTERN = /^on[a-z]+$/i;

interface Violation { rule: string; message: string; }
interface ValidationResult { valid: boolean; violations: Violation[]; }

const walkNode = (node: DomNode, violations: Violation[]): void => {
const tagName = node.tagName?.toLowerCase() ?? "";
if (FORBIDDEN_ELEMENTS.has(tagName)) {
violations.push({ rule: "forbidden-element", message: "Forbidden: " + tagName });
}
if (node.attributes) {
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes.item(i);
if (attr && EVENT_HANDLER_PATTERN.test(attr.name)) {
violations.push({ rule: "event-handler", message: attr.name });
}
}
}
if (node.childNodes) {
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes.item(i);
if (child && child.nodeType === 1) walkNode(child, violations);
}
}
};

export const validateSvg = (svg: string): ValidationResult => {
const violations: Violation[] = [];
const sanitized = svg.replace(/<!DOCTYPE[^[>]*(?:\\\\[[^\\\\]]*\\\\])?\\\\s*>/gi, "").replace(/<!ENTITY[^>]*>/gi, "");
if (svg !== sanitized) violations.push({ rule: "xxe-prevention", message: "DOCTYPE/ENTITY" });
try {
// No onError handler — xmldom defaults to console.error output
const parser = new DOMParser();
const doc = parser.parseFromString(sanitized, "image/svg+xml");
if (doc.documentElement) walkNode(doc.documentElement, violations);
} catch {
violations.push({ rule: "parse-error", message: "Parse failed" });
}
return { valid: violations.length === 0, violations };
};
`;

// Generate a test file that exercises xmldom DOMParser with malformed input
// to produce stderr output via console.error
function makeParseTestFile(index: number): string {
return `
import { describe, expect, test } from "bun:test";
import { validateSvg } from "./helpers";

describe("SVG validation batch ${index}", () => {
test("accepts valid SVG", () => {
const r = validateSvg('<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"/></svg>');
expect(r.valid).toBe(true);
});
test("rejects script element", () => {
const r = validateSvg('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
expect(r.valid).toBe(false);
});
test("handles malformed SVG", () => {
const r = validateSvg("<not-valid-svg-${index}>");
expect(r).toHaveProperty("valid");
});
test("handles random garbage", () => {
for (let i = 0; i < 20; i++) {
const garbage = Array.from({ length: 50 }, () => String.fromCharCode(Math.floor(Math.random() * 128))).join("");
const r = validateSvg(garbage);
expect(typeof r.valid).toBe("boolean");
}
});
});
`;
}

// Generate the async svgr test file
const SVGR_TEST = `
import { describe, expect, test } from "bun:test";
import { transform } from "@svgr/core";

describe("SVGR transformation", () => {
test("transforms SVG to React component", async () => {
const result = await transform(
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"/></svg>',
{ plugins: ["@svgr/plugin-jsx"] },
{ componentName: "TestIcon" },
);
expect(result).toContain("TestIcon");
expect(result).toContain("export default");
});
test("handles SVG with viewBox", async () => {
const result = await transform(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>',
{ plugins: ["@svgr/plugin-jsx"] },
{ componentName: "CircleIcon" },
);
expect(result).toContain("CircleIcon");
});
});
`;

test("bun test should not hang with 7+ test files combining xmldom stderr and svgr async", async () => {
const files: Record<string, string> = {
"package.json": JSON.stringify({
name: "stderr-hang-regression",
private: true,
devDependencies: {
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0",
"@xmldom/xmldom": "^0.9.8",
},
Comment on lines +142 to +145
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n test/regression/issue/027708.test.ts | sed -n '135,155p'

Repository: oven-sh/bun

Length of output: 852


🏁 Script executed:

# Check the complete devDependencies section to understand the context
rg -A 20 "devDependencies:" test/regression/issue/027708.test.ts

Repository: oven-sh/bun

Length of output: 622


🏁 Script executed:

# Check if there are other test fixtures and how they specify dependencies
fd "\.test\.ts" test/regression/issue/ -x basename {} | head -5

Repository: oven-sh/bun

Length of output: 146


🏁 Script executed:

# Look for patterns in other test files with package.json generation
rg -l "devDependencies" test/regression/ --type ts

Repository: oven-sh/bun

Length of output: 260


🏁 Script executed:

# Check how other regression tests specify dependencies
rg -B 3 -A 5 "devDependencies" test/regression/issue/11806.test.ts test/regression/issue/07740.test.ts

Repository: oven-sh/bun

Length of output: 1158


🏁 Script executed:

# Look at the issue this regression test is for - what problem was it solving?
# Check if there are comments in the test explaining the purpose
rg -B 5 "27708\|hang\|xmldom\|svgr" test/regression/issue/027708.test.ts | head -50

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Check git history or comments about why caret ranges might be used
git log --oneline -n 20 -- test/regression/issue/027708.test.ts 2>/dev/null || echo "No git history available"

Repository: oven-sh/bun

Length of output: 133


🏁 Script executed:

# Check if there are any guidelines about version pinning in test fixtures
rg "version|pin|exact|caret" test/CLAUDE.md CLAUDE.md test/regression/README.md 2>/dev/null | head -20

Repository: oven-sh/bun

Length of output: 120


🏁 Script executed:

# Get more context about the test and the issue it addresses
rg -B 10 "bun test should not hang with 7\+" test/regression/issue/027708.test.ts

Repository: oven-sh/bun

Length of output: 448


🏁 Script executed:

# Check what the test is actually verifying - does it check for a hang/timeout?
rg -A 30 "bun test should not hang" test/regression/issue/027708.test.ts | head -50

Repository: oven-sh/bun

Length of output: 964


🏁 Script executed:

# Look for the GitHub issue number reference or comments about why these specific versions
rg "27708\|issue" test/regression/issue/027708.test.ts | head -20

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Check if other tests that use exact versions had a specific reason (like reproducing a bug)
rg -B 5 -A 5 "1\.0\.0" test/regression/issue/07740.test.ts

Repository: oven-sh/bun

Length of output: 465


🏁 Script executed:

# Check what the specific issue `#27708` was about - search for any related commits or PRs
git log --all --oneline --grep="27708\|hang.*svgr\|hang.*xmldom" 2>/dev/null | head -10 || echo "No git log available"

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Examine the complete test to see if it validates against the behavior at these specific versions
rg -A 80 "bun test should not hang with 7\+" test/regression/issue/027708.test.ts | tail -60

Repository: oven-sh/bun

Length of output: 1327


🏁 Script executed:

# Check if there are any version constraints or lock files in the test fixture approach
rg "bun\.lockb|yarn\.lock|package-lock\.json" test/regression/issue/027708.test.ts

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Check the purpose: does the test verify behavior at THESE specific versions, or general behavior?
rg -B 2 -A 10 "timeout\|hang\|expect" test/regression/issue/027708.test.ts | grep -A 5 "const result"

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Verify the complete test flow and check if there are assertions about specific package behavior
rg -A 120 "bun test should not hang with 7\+" test/regression/issue/027708.test.ts | tail -50

Repository: oven-sh/bun

Length of output: 1327


🏁 Script executed:

# Search for any documentation about regression test practices regarding dependency versions
find test -name "README*" -o -name "CLAUDE*" | xargs grep -l "regression\|version" 2>/dev/null | head -5

Repository: oven-sh/bun

Length of output: 221


🏁 Script executed:

# Check if the versions in the caret ranges have known breaking changes
web_search
npm registry `@svgr/core` versions after 8.1.0

Repository: oven-sh/bun

Length of output: 206


🌐 Web query:

@svgr/core 8.1.0 package versions npm

💡 Result:

  • Package: @svgr/core (npm)
  • Latest (as of March 3, 2026): 8.1.0 (npmjs.com)
  • Published date for 8.1.0: Aug 15, 2023 (security.snyk.io)
  • Recent versions (newest → older): 8.1.0, 8.0.0, 7.0.0, 6.5.1, 6.5.0, 6.4.0, 6.3.1, … (security.snyk.io)

To list all versions from npm locally:

npm view `@svgr/core` versions --json

Citations:


🌐 Web query:

@xmldom/xmldom 0.9.8 package breaking changes

💡 Result:

Are there breaking changes in 0.9.8 itself?

No. The 0.9.8 changelog entry lists fixes/performance/chore changes, but no “BREAKING CHANGE” notes for that release. [1]

Breaking changes you’re likely hitting when upgrading to 0.9.8 (from 0.8.x / earlier 0.9.x)

From the project changelog, the notable breaking changes in the 0.9.x line (that still apply when you land on 0.9.8) include:

  • DOMParser.parseFromString now requires mimeType as the 2nd argument. [2]
  • HTML parsing rules now require the passed mimeType to match 'text/html' (not just namespace-driven behavior). [3]
  • Type change: DOMParser.parseFromString return type corrected to Document | undefined (you may need to handle undefined in TS). [3]
  • DOMParser.options instance property removed; use the individual readonly option properties instead; also locator option is now just a boolean. [3]
  • Removed non-spec boolean toggle for “HTML rules” on:
    • XMLSerializer.serializeToString
    • Node.toString
    • Document.toString [4]
  • Attribute/NamedNodeMap behavior aligned with DOM spec:
    • iteration over attributes now in the “right” order
    • non-existing attributes return null instead of undefined
    • similar null behavior for Attr.namespaceURI and Attr.prefix [5]

If you tell me what version you’re upgrading from (e.g., 0.8.11 vs 0.9.7) and what breaks (error message / failing test), I can narrow it to the exact change.


🏁 Script executed:

# Check test/CLAUDE.md for any guidance on regression test practices
cat test/CLAUDE.md | grep -A 10 -B 5 "regression\|version"

Repository: oven-sh/bun

Length of output: 780


🏁 Script executed:

# Check if there are similar test patterns with dynamic vs pinned versions
rg "devDependencies.*\^|devDependencies.*[0-9]+" test/regression/issue/*.test.ts -A 3 | head -40

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Verify the exact claim: check if there are any newer versions of these packages currently
npm view `@svgr/core`@"^8.1.0" version 2>/dev/null || echo "Checking npm would resolve to:"

Repository: oven-sh/bun

Length of output: 60


🏁 Script executed:

# Check if other test fixtures in the repo use exact vs caret versions consistently
rg "devDependencies.*{" test/regression/issue/*.test.ts -A 10 | grep -E "^\s+\"[^\"]+\":\s+[\"\^]" | sort | uniq -c

Repository: oven-sh/bun

Length of output: 154


🏁 Script executed:

# Check version patterns in regression test fixtures
rg "devDependencies" test/regression/issue/*.test.ts -A 10 | grep -E '\"[^\"]+\":\s+' | head -30

Repository: oven-sh/bun

Length of output: 1348


🏁 Script executed:

# Check what the actual resolved versions would be if someone ran bun install today
python3 <<'PYEOF'
import re
from pathlib import Path

# Read the test file
content = Path("test/regression/issue/027708.test.ts").read_text()

# Find devDependencies block
match = re.search(r"devDependencies:\s*\{([^}]+)\}", content, re.DOTALL)
if match:
    deps = match.group(1)
    print("Current dependencies in test/regression/issue/027708.test.ts:")
    for line in deps.split('\n'):
        if line.strip():
            print(f"  {line.strip()}")
PYEOF

Repository: oven-sh/bun

Length of output: 204


🏁 Script executed:

# Check if there's a pattern in the codebase about regression test fixture versions
rg "@svgr|@xmldom" test/regression/ -B 2 -A 2

Repository: oven-sh/bun

Length of output: 2500


Use exact versions for the fixture dependencies to ensure regression test determinism.

Lines 142–145 use caret ranges in the generated package.json. While these currently resolve to the latest versions, pinning exact versions (remove the ^ prefix) makes the regression test's conditions explicit and protects against any future patch or minor releases that could affect test behavior. This aligns with best practices for regression test stability.

🔧 Proposed fix
       devDependencies: {
-        "@svgr/core": "^8.1.0",
-        "@svgr/plugin-jsx": "^8.1.0",
-        "@xmldom/xmldom": "^0.9.8",
+        "@svgr/core": "8.1.0",
+        "@svgr/plugin-jsx": "8.1.0",
+        "@xmldom/xmldom": "0.9.8",
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/027708.test.ts` around lines 142 - 145, The
package.json fixture currently uses caret ranges for dependencies ("@svgr/core",
"@svgr/plugin-jsx", "@xmldom/xmldom"); update those entries to pin exact
versions by removing the leading '^' so the values become "8.1.0" and "0.9.8"
respectively, ensuring the generated fixture uses deterministic, exact
dependency versions.

}),
"tests/helpers.ts": HELPERS_TS,
"tests/svgr.test.ts": SVGR_TEST,
};

// Create 7 parse test files to exceed the threshold
for (let i = 1; i <= 7; i++) {
files[`tests/parse-${String(i).padStart(2, "0")}.test.ts`] = makeParseTestFile(i);
}

const dir = tempDirWithFiles("issue-27708", files);

// Install dependencies
const install = Bun.spawnSync({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
expect(install.exitCode).toBe(0);

// Run bun test — before the fix, this would hang indefinitely.
// Non-hang is enforced by this test's 60s timeout.
await using proc = Bun.spawn({
cmd: [bunExe(), "test"],
cwd: dir,
env: bunEnv,
stderr: "ignore",
stdout: "ignore",
});

const exitCode = await proc.exited;
expect(exitCode).toBe(0);
}, 60_000); // 60s test timeout