Repository: denoland/deno
Pull request: fix(ext/node): route uncaught exceptions through _fatalException

## Summary
- Route `uncaughtExceptionHandler` through `process._fatalException` so that `setUncaughtExceptionCaptureCallback` is respected for unhandled rejections
- Install the unhandled rejection callback in `synchronizeListeners` when a capture callback is active (not just when event listeners exist)
- Use proper Node.js error classes (`ERR_INVALID_ARG_TYPE`, `ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET`) in `setUncaughtExceptionCaptureCallback`
- Enable 4 newly passing node_compat tests

## Test plan
- [x] `./x test-compat test-process-exception-capture.js`
- [x] `./x test-compat test-process-exception-capture-should-abort-on-uncaught.js`
- [x] `./x test-compat test-process-exception-capture-should-abort-on-uncaught-setflagsfromstring.js`
- [x] `./x test-compat test-process-exception-capture-errors.js`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/process.ts
patch: @@ -40,6 +40,7 @@ import {
   ERR_INVALID_ARG_TYPE,
   ERR_INVALID_ARG_VALUE_RANGE,
   ERR_OUT_OF_RANGE,
+  ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET,
   ERR_UNKNOWN_SIGNAL,
   ERR_WORKER_UNSUPPORTED_OPERATION,
   errnoException,
@@ -520,8 +521,7 @@ function uncaughtExceptionHandler(err: any, origin: string) {
   // module are reported as 'unhandledRejection'. Deno does not have a true
   // CommonJS implementation, so all exceptions thrown from the top level are
   // reported as 'uncaughtException'.
-  process.emit("uncaughtExceptionMonitor", err, origin);
-  process.emit("uncaughtException", err, origin);
+  process._fatalException(err, origin === "unhandledRejection");
 }
 
 export let execPath: string = "";
@@ -860,12 +860,10 @@ process.setUncaughtExceptionCaptureCallback = function (fn: any) {
     return;
   }
   if (typeof fn !== "function") {
-    throw new TypeError("The argument must be a function or null");
+    throw new ERR_INVALID_ARG_TYPE("fn", ["function", "null"], fn);
   }
   if (_uncaughtExceptionCaptureFn !== null) {
-    throw new Error(
-      "A callback is already set. Use `process.setUncaughtExceptionCaptureCallback(null)` to unset it first.",
-    );
+    throw new ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET();
   }
   _uncaughtExceptionCaptureFn = fn;
   synchronizeListeners();
@@ -1256,7 +1254,9 @@ function synchronizeListeners() {
   // Install special "unhandledrejection" handler, that will be called
   // last.
   if (
-    unhandledRejectionListenerCount > 0 || uncaughtExceptionListenerCount > 0
+    unhandledRejectionListenerCount > 0 ||
+    uncaughtExceptionListenerCount > 0 ||
+    _uncaughtExceptionCaptureFn !== null
   ) {
     internals.nodeProcessUnhandledRejectionCallback = (event) => {
       if (process.listenerCount("unhandledRejection") === 0) {

filename: tests/node_compat/config.jsonc
patch: @@ -1691,6 +1691,10 @@
     "parallel/test-process-env-sideeffects.js": {},
     "parallel/test-process-env-tz.js": {},
     "parallel/test-process-env-windows-error-reset.js": {},
+    "parallel/test-process-exception-capture-errors.js": {},
+    "parallel/test-process-exception-capture-should-abort-on-uncaught-setflagsfromstring.js": {},
+    "parallel/test-process-exception-capture-should-abort-on-uncaught.js": {},
+    "parallel/test-process-exception-capture.js": {},
     "parallel/test-process-exit-code-validation.js": {},
     "parallel/test-process-exit-from-before-exit.js": {},
     "parallel/test-process-exit-handler.js": {},