From 8ef813f571b9f5c77729358d87d0f31d1930ad45 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:42:10 +0000 Subject: [PATCH 1/6] error printer: keep the value in the visited set while rendering it A plain Error that is its own cause and also sits in its own errors array crashed the process when the uncaught-exception printer rendered it (silent SIGSEGV on the main thread; thrown inside a Worker, debug/ASAN builds aborted on assertNoExceptionExceptTermination and release builds delivered the rendered dump as the parent error event's message). The formatter's Tag::Error handler removed the value from the visited set before re-entering printErrorlikeObject, on the theory that print_as had already done the circular check. But the error printer re-enters the formatter for the error's own properties (cause rendered inline, the errors array), so alternating between the two paths never found the value in the set and recursed until the stack ran out. Keeping the value in the set makes those re-entries print [Circular] like every other cycle. Also restore the printer's error contract: print_error_instance_body returned Ok with a thrown exception still pending when side effects were disallowed, and print_errorlike_object can swallow exceptions outright. Propagate instead, so console.log and the worker error render don't leak a pending exception into the next ExceptionScope, and the worker's error serialization no longer fails (the parent now receives the real message). Verified: the repro exits 1 with [Circular] markers on the main thread, console.log continues afterwards, and a Worker delivers name/message intact with no debug assert. --- src/jsc/ConsoleObject.rs | 31 ++++----- src/jsc/VirtualMachine.rs | 10 ++- .../issue/circular-error-stack.test.ts | 69 +++++++++++++++++++ 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index a9270cf88662..07038613c8c7 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3913,29 +3913,22 @@ 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 must STAY in the visited map while the error printer + // runs: it re-enters this formatter for the error's properties + // (`cause` rendered inline, the `errors` array), and an error + // reachable from itself through those would otherwise recurse + // until the stack runs out (e.cause = e; e.errors = [e]). 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); + // `print_errorlike_object` returns unit and can leave a pending + // exception (e.g. the AggregateError branch swallows `for_each` + // failures); restore the JsResult contract for `?`-chaining + // callers. + if self.global_this.has_exception() { + return Err(jsc::JsError::Thrown); + } Ok(()) } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index fc224cc8f479..f32dd0b73e39 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6397,7 +6397,15 @@ impl VirtualMachine { if global_ref.has_exception() { global_ref.clear_exception(); } - } else if global_ref.has_exception() || formatter.failed { + } else if global_ref.has_exception() { + // Propagate instead of returning Ok with the exception + // still pending: `?`-chaining callers (console.log, + // the worker error render) treat Ok as "nothing + // pending", and a leaked exception trips + // assertNoExceptionExceptTermination on the next + // ExceptionScope in debug builds. + return Err(crate::CrateError::JSError); + } else if formatter.failed { return Ok(()); } diff --git a/test/regression/issue/circular-error-stack.test.ts b/test/regression/issue/circular-error-stack.test.ts index 25cc3c14c967..4cb2b0bd0356 100644 --- a/test/regression/issue/circular-error-stack.test.ts +++ b/test/regression/issue/circular-error-stack.test.ts @@ -82,3 +82,72 @@ test("error with circular reference in cause chain", async () => { expect(stdout).not.toContain("Maximum call stack"); expect(stderr).not.toContain("Maximum call stack"); }); + +// A plain Error that is its own `cause` AND sits in its own `errors` array +// used to alternate between the cause branch and the errors branch of the +// printer, bypassing both cycle guards and crashing the process (SIGSEGV). +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.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.exited]); + + expect(stdout).toContain("[Circular]"); + expect(stdout).toContain("after error print"); + expect(exitCode).toBe(0); +}); + +// Thrown inside a worker, the same cyclic error is rendered to build the +// 'error' event payload. The render used to overflow, leaving the RangeError +// pending across the dispatch (debug assert / aborted process), and the +// serialization failure made the parent receive the rendered dump as +// `message` instead of the real one. +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); +}); From 68597afa1ee660387c23d97d4a479caf8dc77f97 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:56:10 +0000 Subject: [PATCH 2/6] Keep the pending exception in place inside the printer body Returning Err from the no-side-effects bail routed genuine stack-overflow exceptions into print_error_from_maybe_private_data's clear_exception, so Bun.inspect of a deep (non-cyclic) Error chain with depth Infinity no longer threw the RangeError its callers pin (bun-inspect.test.ts). Leave the body returning Ok with the exception pending, as before; the formatter boundary (Formatter::print_error) still converts it to Err(Thrown) without clearing, which is what keeps the worker error render from reporting success with an exception pending. --- src/jsc/VirtualMachine.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index f32dd0b73e39..d88f13573b2e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6397,15 +6397,14 @@ impl VirtualMachine { if global_ref.has_exception() { global_ref.clear_exception(); } - } else if global_ref.has_exception() { - // Propagate instead of returning Ok with the exception - // still pending: `?`-chaining callers (console.log, - // the worker error render) treat Ok as "nothing - // pending", and a leaked exception trips - // assertNoExceptionExceptTermination on the next - // ExceptionScope in debug builds. - return Err(crate::CrateError::JSError); - } else if formatter.failed { + } else if global_ref.has_exception() || formatter.failed { + // Ok with the exception left pending, deliberately: + // Err here would be cleared by + // `print_error_from_maybe_private_data`, and callers + // depend on the exception surviving (Bun.inspect + // re-throws the stack-overflow RangeError; + // `Formatter::print_error` converts it to Err(Thrown) + // at the formatter boundary). return Ok(()); } From 55b8f606f2ebe90892c04024a6400d8147e0d8fc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:58:30 +0000 Subject: [PATCH 3/6] Tighten printer comments --- src/jsc/ConsoleObject.rs | 15 ++++++--------- src/jsc/VirtualMachine.rs | 10 +++------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 07038613c8c7..b699567f8997 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3913,19 +3913,16 @@ pub mod formatter { writer_: &mut dyn bun_io::Write, value: JSValue, ) -> JsResult<()> { - // The value must STAY in the visited map while the error printer - // runs: it re-enters this formatter for the error's properties - // (`cause` rendered inline, the `errors` array), and an error - // reachable from itself through those would otherwise recurse - // until the stack runs out (e.cause = e; e.errors = [e]). + // Deliberately left in the visited map: the printer re-enters + // this formatter for the error's `cause`/`errors` properties, and + // removing the value here let a self-referencing error recurse + // until stack overflow. 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); - // `print_errorlike_object` returns unit and can leave a pending - // exception (e.g. the AggregateError branch swallows `for_each` - // failures); restore the JsResult contract for `?`-chaining - // callers. + // `print_errorlike_object` returns unit; surface a pending + // exception as Err for `?`-chaining callers. if self.global_this.has_exception() { return Err(jsc::JsError::Thrown); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d88f13573b2e..d5f90d52c0f2 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6398,13 +6398,9 @@ impl VirtualMachine { global_ref.clear_exception(); } } else if global_ref.has_exception() || formatter.failed { - // Ok with the exception left pending, deliberately: - // Err here would be cleared by - // `print_error_from_maybe_private_data`, and callers - // depend on the exception surviving (Bun.inspect - // re-throws the stack-overflow RangeError; - // `Formatter::print_error` converts it to Err(Thrown) - // at the formatter boundary). + // Deliberate Ok with the exception left pending: Err + // would get cleared upstream, and Bun.inspect rethrows + // the pending overflow. return Ok(()); } From 4069cf3c4f6584f105f8a3ac61aba80ae72fcea5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:00:08 +0000 Subject: [PATCH 4/6] Reduce printer comments to the essentials --- src/jsc/ConsoleObject.rs | 8 ++------ src/jsc/VirtualMachine.rs | 3 --- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index b699567f8997..42cee1916033 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3913,16 +3913,12 @@ pub mod formatter { writer_: &mut dyn bun_io::Write, value: JSValue, ) -> JsResult<()> { - // Deliberately left in the visited map: the printer re-enters - // this formatter for the error's `cause`/`errors` properties, and - // removing the value here let a self-referencing error recurse - // until stack overflow. + // 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); - // `print_errorlike_object` returns unit; surface a pending - // exception as Err for `?`-chaining callers. if self.global_this.has_exception() { return Err(jsc::JsError::Thrown); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d5f90d52c0f2..fc224cc8f479 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6398,9 +6398,6 @@ impl VirtualMachine { global_ref.clear_exception(); } } else if global_ref.has_exception() || formatter.failed { - // Deliberate Ok with the exception left pending: Err - // would get cleared upstream, and Bun.inspect rethrows - // the pending overflow. return Ok(()); } From 3cdb45c9354bce010856930fe007d48f483e6350 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:03:33 +0000 Subject: [PATCH 5/6] Drain both subprocess pipes in the cyclic-error tests --- test/regression/issue/circular-error-stack.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/test/regression/issue/circular-error-stack.test.ts b/test/regression/issue/circular-error-stack.test.ts index 4cb2b0bd0356..aea1616e4f92 100644 --- a/test/regression/issue/circular-error-stack.test.ts +++ b/test/regression/issue/circular-error-stack.test.ts @@ -83,9 +83,6 @@ test("error with circular reference in cause chain", async () => { expect(stderr).not.toContain("Maximum call stack"); }); -// A plain Error that is its own `cause` AND sits in its own `errors` array -// used to alternate between the cause branch and the errors branch of the -// printer, bypassing both cycle guards and crashing the process (SIGSEGV). 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;`], @@ -94,7 +91,7 @@ test.concurrent("uncaught error that is its own cause and its own errors entry", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toContain("error: cyc"); expect(stderr).toContain("[Circular]"); @@ -113,18 +110,13 @@ test.concurrent("console.log of error that is its own cause and its own errors e stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + 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); }); -// Thrown inside a worker, the same cyclic error is rendered to build the -// 'error' event payload. The render used to overflow, leaving the RangeError -// pending across the dispatch (debug assert / aborted process), and the -// serialization failure made the parent receive the rendered dump as -// `message` instead of the real one. test.concurrent("worker uncaught cyclic error reaches the parent error event intact", async () => { using dir = tempDir("worker-cyclic-error", { "index.js": ` From 60f95cdcf1eba8e9cdb800a69cd5f58e4f2cf922 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:55:50 +0000 Subject: [PATCH 6/6] Drop the vacuous stderr assertion from the worker test --- test/regression/issue/circular-error-stack.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/regression/issue/circular-error-stack.test.ts b/test/regression/issue/circular-error-stack.test.ts index aea1616e4f92..eb853c9dc105 100644 --- a/test/regression/issue/circular-error-stack.test.ts +++ b/test/regression/issue/circular-error-stack.test.ts @@ -136,10 +136,9 @@ test.concurrent("worker uncaught cyclic error reaches the parent error event int stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [stdout, , 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); });