Repository: denoland/deno
Pull request: fix(core): don't panic in to_v8_error when JS error builder callback fails

## Summary
- When the JS error builder callback in `to_v8_error` throws (e.g. due to stack overflow or `Object.prototype` / `Array.prototype` pollution), fall back to returning the plain message string instead of panicking
- Adds a unit test for the stack overflow case

Closes #28436
Closes #29824
Closes #27720

## Test plan
- [x] New unit test `test_to_v8_error_handles_stack_overflow_in_builder` — simulates the builder throwing `RangeError` and verifies fallback
- [x] Existing test `test_to_v8_error_handles_null_builder_exception` still passes
- [x] Manually verified #28436 repro script no longer panics
- [x] Manually verified #29824 repro script no longer panics (proper error message shown)
- [x] Manually verified #27720 repro script no longer panics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: libs/core/error.rs
patch: @@ -333,19 +333,14 @@ pub fn to_v8_error<'s, 'i>(
   match maybe_exception {
     Some(exception) => exception,
     None => {
-      let mut msg =
-        "Custom error class must have a builder registered".to_string();
+      // The JS error builder callback failed. This can happen when the
+      // stack is exhausted (e.g. Maximum call stack size exceeded) and
+      // the callback itself throws. Fall back to a plain V8 error
+      // instead of panicking.
       if tc_scope.has_caught() {
-        let e = tc_scope.exception().unwrap();
-        // If the builder threw a bare `null`/`undefined`, propagate the
-        // original message instead of panicking on an opaque "Uncaught null".
-        if e.is_null_or_undefined() {
-          return message.into();
-        }
-        let js_error = JsError::from_v8_exception(tc_scope, e);
-        msg = format!("{}: {}", msg, js_error.exception_message);
+        tc_scope.reset();
       }
-      panic!("{}", msg);
+      message.into()
     }
   }
 }
@@ -2426,4 +2421,38 @@ mod tests {
 
     assert_eq!(value.to_rust_string_lossy(scope), expected_message);
   }
+
+  #[cfg(not(miri))]
+  #[test]
+  fn test_to_v8_error_handles_stack_overflow_in_builder() {
+    let mut runtime = JsRuntime::new(Default::default());
+
+    deno_core::scope!(scope, runtime);
+
+    // Simulate the error builder throwing RangeError (as happens during
+    // stack overflow when the builder callback itself exceeds the call
+    // stack limit).
+    let throw_range_error_fn: v8::Local<v8::Function> = JsRuntime::eval(
+      scope,
+      "(function () { throw new RangeError('Maximum call stack size exceeded'); })",
+    )
+    .unwrap();
+
+    let exception_state = JsRealm::exception_state_from_scope(scope);
+    exception_state
+      .js_build_custom_error_cb
+      .borrow_mut()
+      .replace(v8::Global::new(scope, throw_range_error_fn));
+
+    let err = CoreErrorKind::TLA;
+    let expected_message = err.get_message();
+
+    // Should not panic, should fall back to the message string
+    let value = to_v8_error(scope, &err);
+    let value: v8::Local<v8::String> = value
+      .try_into()
+      .expect("should fall back to message string");
+
+    assert_eq!(value.to_rust_string_lossy(scope), expected_message);
+  }
 }