diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 29152e29c5cf..7d9be2c178d3 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3923,29 +3923,15 @@ pub mod formatter { writer_: &mut dyn bun_io::Write, value: JSValue, ) -> JsResult<()> { - // Temporarily remove from the visited map to allow - // printErrorlikeObject to process it. The circular reference - // check is already done in print_as, so we know it's safe. - let was_in_map = if self.map_node.is_some() { - self.map.remove(&value).is_some() - } else { - false - }; - let map_restore_ptr: *mut visited::Map = &raw mut self.map; - scopeguard::defer! { - // SAFETY: `self.map` outlives this guard; no other borrow is - // live at the drop point. - unsafe { - if was_in_map { - let _ = (*map_restore_ptr).insert(value, ()); - } - } - } - + // The value stays in the visited map so re-entrant property + // formatting hits the `[Circular]` guard. let mut adapter = DynWriteAdapter::new(&mut *writer_); // SAFETY: per-thread VM. let vm = VirtualMachine::get().as_mut(); vm.print_errorlike_object(value, None, None, self, adapter.interface(), C, false); + if self.global_this.has_exception() { + return Err(jsc::JsError::Thrown); + } Ok(()) } diff --git a/test/regression/issue/circular-error-stack.test.ts b/test/regression/issue/circular-error-stack.test.ts index 25cc3c14c967..aea1616e4f92 100644 --- a/test/regression/issue/circular-error-stack.test.ts +++ b/test/regression/issue/circular-error-stack.test.ts @@ -82,3 +82,64 @@ test("error with circular reference in cause chain", async () => { expect(stdout).not.toContain("Maximum call stack"); expect(stderr).not.toContain("Maximum call stack"); }); + +test.concurrent("uncaught error that is its own cause and its own errors entry", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `const e = new Error('cyc'); e.cause = e; e.errors = [e]; throw e;`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("error: cyc"); + expect(stderr).toContain("[Circular]"); + expect(exitCode).toBe(1); +}); + +test.concurrent("console.log of error that is its own cause and its own errors entry", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const e = new Error('cyc'); e.cause = e; e.errors = [e]; console.log(e); console.log('after error print');`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("[Circular]"); + expect(stdout).toContain("after error print"); + expect(exitCode).toBe(0); +}); + +test.concurrent("worker uncaught cyclic error reaches the parent error event intact", async () => { + using dir = tempDir("worker-cyclic-error", { + "index.js": ` + const { Worker } = require("node:worker_threads"); + const src = "const e = new Error('cyc'); e.cause = e; e.errors = [e]; throw e"; + const w = new Worker(src, { eval: true }); + w.on("error", e => console.log("error-event", e.name, JSON.stringify(e.message))); + w.on("exit", c => console.log("worker-exit", c)); + `, + }); + + 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(stdout).toContain('error-event Error "cyc"'); + expect(stdout).toContain("worker-exit 1"); + expect(stderr).not.toContain("ASSERTION FAILED"); + expect(exitCode).toBe(0); +});