diff --git a/src/bun.js/ConsoleObject.zig b/src/bun.js/ConsoleObject.zig index a52cf2dfb6..e8a3e1c926 100644 --- a/src/bun.js/ConsoleObject.zig +++ b/src/bun.js/ConsoleObject.zig @@ -2278,6 +2278,13 @@ pub const Formatter = struct { } }, .Error => { + // Temporarily remove from the visited map to allow printErrorlikeObject to process it + // The circular reference check is already done in printAs, so we know it's safe + const was_in_map = if (this.map_node != null) this.map.remove(value) else false; + defer if (was_in_map) { + _ = this.map.put(value, {}) catch {}; + }; + VirtualMachine.get().printErrorlikeObject( value, null, diff --git a/src/bun.js/VirtualMachine.zig b/src/bun.js/VirtualMachine.zig index 47f374dbaa..062155094f 100644 --- a/src/bun.js/VirtualMachine.zig +++ b/src/bun.js/VirtualMachine.zig @@ -3236,8 +3236,23 @@ fn printErrorInstance( } for (errors_to_append.items) |err| { + // Check for circular references to prevent infinite recursion in cause chains + if (formatter.map_node == null) { + formatter.map_node = ConsoleObject.Formatter.Visited.Pool.get(default_allocator); + formatter.map_node.?.data.clearRetainingCapacity(); + formatter.map = formatter.map_node.?.data; + } + + const entry = formatter.map.getOrPut(err) catch unreachable; + if (entry.found_existing) { + try writer.writeAll("\n"); + try writer.writeAll(comptime Output.prettyFmt("[Circular]", allow_ansi_color)); + continue; + } + try writer.writeAll("\n"); try this.printErrorInstance(.js, err, exception_list, formatter, Writer, writer, allow_ansi_color, allow_side_effects); + _ = formatter.map.remove(err); } } diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 7f5265754c..93efe7c2b5 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -4888,6 +4888,10 @@ static void fromErrorInstance(ZigException& except, JSC::JSGlobalObject* global, if (!scope.clearExceptionExceptTermination()) [[unlikely]] return; if (stackValue) { + // Prevent infinite recursion if stack property is the error object itself + if (stackValue == val) { + return; + } if (stackValue.isString()) { WTF::String stack = stackValue.toWTFString(global); if (!scope.clearExceptionExceptTermination()) [[unlikely]] { diff --git a/test/regression/issue/circular-error-stack-edge-cases.test.ts b/test/regression/issue/circular-error-stack-edge-cases.test.ts new file mode 100644 index 0000000000..cfc205df67 --- /dev/null +++ b/test/regression/issue/circular-error-stack-edge-cases.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("error.stack getter that throws should not crash", async () => { + using dir = tempDir("throwing-stack-getter", { + "index.js": ` + const error = new Error("Test error"); + Object.defineProperty(error, "stack", { + get() { + throw new Error("Stack getter throws!"); + } + }); + console.log(error); + console.log("after error print"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("after error print"); + expect(stdout).not.toContain("Stack getter throws"); + expect(stderr).not.toContain("Stack getter throws"); +}); + +test("error.stack getter returning circular reference", async () => { + using dir = tempDir("circular-stack-getter", { + "index.js": ` + const error = new Error("Test error"); + Object.defineProperty(error, "stack", { + get() { + return error; // Return the error itself + } + }); + console.log(error); + console.log("after error print"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("after error print"); + expect(stdout).not.toContain("Maximum call stack"); + expect(stderr).not.toContain("Maximum call stack"); +}); + +test("error with multiple throwing getters", async () => { + using dir = tempDir("multiple-throwing-getters", { + "index.js": ` + const error = new Error("Test error"); + Object.defineProperty(error, "stack", { + get() { + throw new Error("Stack throws!"); + } + }); + Object.defineProperty(error, "cause", { + get() { + throw new Error("Cause throws!"); + } + }); + error.normalProp = "works"; + console.log(error); + console.log("after error print"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("after error print"); + expect(stdout).toContain("normalProp"); + expect(stdout).not.toContain("Stack throws"); + expect(stdout).not.toContain("Cause throws"); +}); diff --git a/test/regression/issue/circular-error-stack.test.ts b/test/regression/issue/circular-error-stack.test.ts new file mode 100644 index 0000000000..25cc3c14c9 --- /dev/null +++ b/test/regression/issue/circular-error-stack.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("error with circular stack reference should not cause infinite recursion", async () => { + using dir = tempDir("circular-error-stack", { + "index.js": ` + const error = new Error("Test error"); + error.stack = error; + console.log(error); + console.log("after error print"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("after error print"); + expect(stdout).not.toContain("Maximum call stack"); + expect(stderr).not.toContain("Maximum call stack"); +}); + +test("error with nested circular references should not cause infinite recursion", async () => { + using dir = tempDir("nested-circular-error", { + "index.js": ` + const error1 = new Error("Error 1"); + const error2 = new Error("Error 2"); + error1.stack = error2; + error2.stack = error1; + console.log(error1); + console.log("after error print"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("after error print"); + expect(stdout).not.toContain("Maximum call stack"); + expect(stderr).not.toContain("Maximum call stack"); +}); + +test("error with circular reference in cause chain", async () => { + using dir = tempDir("circular-error-cause", { + "index.js": ` + const error1 = new Error("Error 1"); + const error2 = new Error("Error 2"); + error1.cause = error2; + error2.cause = error1; + console.log(error1); + console.log("after error print"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("after error print"); + expect(stdout).not.toContain("Maximum call stack"); + expect(stderr).not.toContain("Maximum call stack"); +});