Repository: denoland/deno
Pull request: fix(ext/node): fix worker exit code propagation in `worker_threads`

## Summary
- Properly propagate `process.exitCode` from Node `worker_threads` to the parent, matching Node.js behavior. Previously exit codes were hardcoded to 0 (natural exit) or 1 (error exit), ignoring `process.exitCode` set by the worker.
- Add `WorkerControlEvent::TerminalError` exit code field and dispatch `process.emit("exit")` on worker termination so exit handlers can modify the final code.
- Implement `process._fatalException` for uncaught exception handling, including exit code 6 when monkey-patched to not be a function (`kInvalidFatalExceptionMonkeyPatching`).

Fixes `test-worker-exit-code.js` — all 11 test cases now pass:
- `process.exitCode` assignment (case 0)
- `process.exit(code)` (cases 1-2)
- Uncaught exception with/without handlers (cases 3-9)
- `_fatalException = undefined` → exit code 6 (case 10)

## Test plan
- [x] `cargo test --test node_compat parallel -- --filter test-worker-exit-code.js` — all 11 cases pass
- [x] `test-worker-event.js`, `test-worker-process-argv.js` — still pass (no regression)
- [x] Verified `test-process-uncaught-exception-monitor.js` failure is pre-existing (fails on baseline too)

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

Changed files:
filename: ext/node/polyfills/process.ts
patch: @@ -696,6 +696,17 @@ process.reallyExit = (code: number) => {
 
 process._exiting = _exiting;
 
+// deno-lint-ignore no-explicit-any
+process._fatalException = function (err: any, fromPromise?: boolean) {
+  const origin = fromPromise ? "unhandledRejection" : "uncaughtException";
+  process.emit("uncaughtExceptionMonitor", err, origin);
+  if (process.listenerCount("uncaughtException") > 0) {
+    process.emit("uncaughtException", err, origin);
+    return true;
+  }
+  return false;
+};
+
 /** https://nodejs.org/api/process.html#processexitcode_1 */
 Object.defineProperty(process, "exitCode", {
   get() {
@@ -1001,11 +1012,15 @@ process.on("removeListener", (event: string) => {
 });
 
 function processOnError(event: ErrorEvent) {
-  if (process.listenerCount("uncaughtException") > 0) {
-    event.preventDefault();
+  if (typeof process._fatalException === "function") {
+    if (process._fatalException(event.error)) {
+      event.preventDefault();
+    }
+  } else {
+    // Exit code 6: _fatalException is not a function
+    // (kInvalidFatalExceptionMonkeyPatching in Node.js)
+    process.exitCode = 6;
   }
-
-  uncaughtExceptionHandler(event.error, "uncaughtException");
 }
 
 function dispatchProcessBeforeExitEvent() {

filename: ext/node/polyfills/worker_threads.ts
patch: @@ -346,7 +346,7 @@ class NodeWorker extends EventEmitter {
           }
           if (!this.#exited) {
             this.#exited = true;
-            this.emit("exit", 1);
+            this.emit("exit", data.exitCode ?? 1);
           }
           return;
         }

filename: runtime/ops/worker_host.rs
patch: @@ -351,7 +351,8 @@ async fn op_host_recv_ctrl(
     Ok(Some(event)) => {
       // Terminal error or close means that worker should be removed from worker table.
       match &event {
-        WorkerControlEvent::TerminalError(_) | WorkerControlEvent::Close(_) => {
+        WorkerControlEvent::TerminalError(..)
+        | WorkerControlEvent::Close(_) => {
           close_channel(state, id, WorkerChannel::Ctrl);
         }
       }

filename: runtime/web_worker.rs
patch: @@ -133,7 +133,7 @@ impl<'s> WorkerThreadType {
 /// worker.
 #[allow(clippy::large_enum_variant)]
 pub enum WorkerControlEvent {
-  TerminalError(CoreError),
+  TerminalError(CoreError, i32),
   Close(i32),
 }
 
@@ -145,12 +145,12 @@ impl Serialize for WorkerControlEvent {
     S: Serializer,
   {
     let type_id = match &self {
-      WorkerControlEvent::TerminalError(_) => 1_i32,
+      WorkerControlEvent::TerminalError(..) => 1_i32,
       WorkerControlEvent::Close(_) => 3_i32,
     };
 
     match self {
-      WorkerControlEvent::TerminalError(error) => {
+      WorkerControlEvent::TerminalError(error, exit_code) => {
         let value = match error.as_kind() {
           CoreErrorKind::Js(js_error) => {
             let frame = js_error.frames.iter().find(|f| match &f.file_name {
@@ -164,10 +164,12 @@ impl Serialize for WorkerControlEvent {
               "fileName": frame.map(|f| f.file_name.as_ref()),
               "lineNumber": frame.map(|f| f.line_number.as_ref()),
               "columnNumber": frame.map(|f| f.column_number.as_ref()),
+              "exitCode": exit_code,
             })
           }
           _ => json!({
             "message": error.to_string(),
+            "exitCode": exit_code,
           }),
         };
 
@@ -1095,13 +1097,13 @@ pub async fn run_web_worker(
   let mut maybe_coverage_collector = worker.maybe_setup_coverage_collector();
 
   let name = worker.name.to_string();
-  let internal_handle = worker.internal_handle.clone();
+  let mut internal_handle = worker.internal_handle.clone();
 
   // If the bootstrap failed, report it as a terminal error.
   if let Some(error) = worker.bootstrap_error.take() {
     print_worker_error(&error, &name, format_js_error_fn.as_deref());
     internal_handle
-      .post_event(WorkerControlEvent::TerminalError(error))
+      .post_event(WorkerControlEvent::TerminalError(error, 1))
       .expect("Failed to post message to host");
     return Ok(());
   }
@@ -1149,6 +1151,41 @@ pub async fn run_web_worker(
 
   if let Err(e) = result {
     print_worker_error(&e, &name, format_js_error_fn.as_deref());
+
+    // For Node workers, dispatch process 'exit' event before sending
+    // TerminalError so that exit handlers can run and modify the exit code.
+    // If _fatalException was monkey-patched to not be a function, exit
+    // with code 6 (kInvalidFatalExceptionMonkeyPatching) matching Node.js.
+    let exit_code = if internal_handle.worker_type == WorkerThreadType::Node {
+      let _ = worker.execute_script(
+        located_script_name!(),
+        r#"
+        if (typeof globalThis.process !== 'undefined') {
+          if (typeof globalThis.process._fatalException !== 'function') {
+            globalThis.process.exitCode = 6;
+          } else {
+            globalThis.process.exitCode = 1;
+            if (!globalThis.process._exiting) {
+              globalThis.process._exiting = true;
+              globalThis.process.emit("exit", globalThis.process.exitCode);
+            }
+          }
+        }
+        "#
+        .to_string()
+        .into(),
+      );
+      worker
+        .js_runtime
+        .op_state()
+        .borrow()
+        .try_borrow::<deno_os::ExitCode>()
+        .map(|e| e.get())
+        .unwrap_or(1)
+    } else {
+      1
+    };
+
     // For Node workers, convert "Module not found" to Node-compatible
     // "Cannot find module" format so it matches Node.js error behavior.
     let e = if internal_handle.worker_type == WorkerThreadType::Node {
@@ -1170,14 +1207,52 @@ pub async fn run_web_worker(
     } else {
       e
     };
+
+    // Exit code 6 means _fatalException was not a function. In Node.js
+    // this causes a clean exit without emitting 'error' on the parent Worker.
+    if exit_code == 6 {
+      let _ = internal_handle.post_event(WorkerControlEvent::Close(exit_code));
+      internal_handle.terminate();
+      return Ok(());
+    }
+
     internal_handle
-      .post_event(WorkerControlEvent::TerminalError(e))
+      .post_event(WorkerControlEvent::TerminalError(e, exit_code))
       .expect("Failed to post message to host");
 
     // Failure to execute script is a terminal error, bye, bye.
     return Ok(());
   }
 
+  // For Node workers that exit naturally (event loop idle), dispatch the
+  // process 'exit' event and send the final exit code to the parent.
+  // This matches Node.js behavior where SpinEventLoopInternal calls
+  // EmitProcessExitInternal before the worker thread ends.
+  if worker.worker_type == WorkerThreadType::Node
+    && !internal_handle.is_terminated()
+  {
+    let _ = worker.execute_script(
+      located_script_name!(),
+      r#"
+      if (typeof globalThis.process !== 'undefined' && !globalThis.process._exiting) {
+        globalThis.process._exiting = true;
+        globalThis.process.emit("exit", globalThis.process.exitCode ?? 0);
+      }
+      "#
+      .to_string()
+      .into(),
+    );
+    let exit_code = worker
+      .js_runtime
+      .op_state()
+      .borrow()
+      .try_borrow::<deno_os::ExitCode>()
+      .map(|e| e.get())
+      .unwrap_or(0);
+    let _ = internal_handle.post_event(WorkerControlEvent::Close(exit_code));
+    internal_handle.terminate();
+  }
+
   debug!("Worker thread shuts down {}", &name);
-  result
+  Ok(())
 }

filename: tests/node_compat/config.jsonc
patch: @@ -1562,6 +1562,7 @@
     "parallel/test-worker-esm-missing-main.js": {},
     "parallel/test-worker-esmodule.js": {},
     "parallel/test-worker-event.js": {},
+    "parallel/test-worker-exit-code.js": {},
     "parallel/test-worker-execargv-invalid.js": {},
     "parallel/test-worker-fs-stat-watcher.js": {},
     "parallel/test-worker-invalid-workerdata.js": {},