Repository: denoland/deno
Pull request: fix(ext/node): process.exit() in worker immediately halts execution

## Summary

In Node.js, `process.exit()` inside a worker immediately stops all JS execution at the C++ level. In Deno, it called `workerClose()` which triggered V8's `terminate_execution()`, but JS continued running because V8 only checks the termination flag at function entries and loop back-edges — not during function returns.

Adds an infinite loop after `process.reallyExit()` that gives V8 a loop back-edge to detect the pending termination and throw an uncatchable `TerminationException`. On the main thread the loop is unreachable since `reallyExit()` calls `std::process::exit()`.

Enables 2 new node_compat tests:
- `test-worker-voluntarily-exit-followed-by-addition.js`
- `test-worker-voluntarily-exit-followed-by-throw.js`

## Test plan

- [x] `cargo test --test node_compat parallel -- --filter "test-worker-voluntarily-exit"` — both pass
- [x] `cargo test --test node_compat parallel -- --filter "test-worker-terminate"` — no regressions
- [x] `cargo test --test node_compat parallel -- --filter "test-worker-exit"` — no regressions

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

Changed files:
filename: ext/node/polyfills/process.ts
patch: @@ -128,6 +128,21 @@ export const exit = (code?: number | string) => {
   // Any valid thing `process.exitCode` set is already held in Deno.exitCode.
   // At this point, we don't have to pass around Node's raw/string exit value.
   process.reallyExit(ProcessExitCode);
+
+  // In a worker, reallyExit() returns because Deno.exit() calls workerClose()
+  // instead of std::process::exit(). workerClose() already called V8's
+  // terminate_execution(). Unlike Node.js where reallyExit is a C++ binding
+  // and a single nop() call suffices to trigger the stack guard check, in Deno
+  // reallyExit goes through JS frames (Deno.exit -> exitHandler -> workerClose
+  // -> op), so we need a loop back-edge for V8 to reliably detect the pending
+  // termination and throw an uncatchable TerminationException.
+  // On the main thread reallyExit() normally never returns, but users can
+  // override it (test-process-really-exit.js), so only spin in workers.
+  // ref: https://github.com/nodejs/node/blob/9cc7fcc26d/lib/internal/process/per_thread.js#L243-L251
+  if (internals.__isWorkerThread) {
+    // deno-lint-ignore no-empty
+    for (;;) {}
+  }
 };
 
 /** https://nodejs.org/api/process.html#processumaskmask */

filename: ext/node/polyfills/worker_threads.ts
patch: @@ -732,6 +732,7 @@ internals.__initWorkerThreads = (
   moduleSpecifier,
 ) => {
   isMainThread = runningOnMainThread;
+  internals.__isWorkerThread = !runningOnMainThread;
 
   defaultExport.isMainThread = isMainThread;
   // fake resourceLimits

filename: tests/node_compat/config.jsonc
patch: @@ -1597,6 +1597,8 @@
     "parallel/test-worker-terminate-unrefed.js": {},
     "parallel/test-worker-unref-from-message-during-exit.js": {},
     "parallel/test-worker-thread-name.js": {},
+    "parallel/test-worker-voluntarily-exit-followed-by-addition.js": {},
+    "parallel/test-worker-voluntarily-exit-followed-by-throw.js": {},
     "parallel/test-worker.js": {},
     "parallel/test-worker.mjs": {},
     "parallel/test-zlib-brotli-16GB.js": {},