Repository: electron/electron
Pull request: fix: utilityProcess running user script after process.exit is called

#### Description of Change

Fixes https://github.com/electron/electron/issues/44174

Relies on the `node::DefaultProcessExitHandler` to ensure termination of the process after performing our own cleanup related to the utility process.

#### Release Notes

Notes: fix utilityProcess running user script after process.exit is called

Changed files:
filename: docs/breaking-changes.md
patch: @@ -36,6 +36,16 @@ process.on('unhandledRejection', () => {
 })
 ```
 
+### Behavior Changed: `process.exit()` kills utility process synchronously
+
+Calling `process.exit()` in a utility process will now kill the utility process synchronously.
+This brings the behavior of `process.exit()` in line with Node.js behavior.
+
+Please refer to the
+[Node.js docs](https://nodejs.org/docs/latest-v22.x/api/process.html#processexitcode) and
+[PR #45690](https://github.com/electron/electron/pull/45690) to understand the potential
+implications of that, e.g., when calling `console.log()` before `process.exit()`.
+
 ### Behavior Changed: WebUSB and WebSerial Blocklist Support
 
 [WebUSB](https://developer.mozilla.org/en-US/docs/Web/API/WebUSB_API) and [Web Serial](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) now support the [WebUSB Blocklist](https://wicg.github.io/webusb/#blocklist) and [Web Serial Blocklist](https://wicg.github.io/serial/#blocklist) used by Chromium and outlined in their respective specifications.

filename: shell/services/node/node_service.cc
patch: @@ -9,6 +9,7 @@
 
 #include "base/command_line.h"
 #include "base/no_destructor.h"
+#include "base/process/process.h"
 #include "base/strings/utf_string_conversions.h"
 #include "electron/mas.h"
 #include "services/network/public/cpp/wrapper_shared_url_loader_factory.h"
@@ -99,8 +100,6 @@ NodeService::~NodeService() {
     ParentPort::GetInstance()->Close();
     js_env_->DestroyMicrotasksRunner();
     node::Stop(node_env_.get(), node::StopFlags::kDoNotTerminateIsolate);
-  }
-  if (g_client_remote.is_bound()) {
     g_client_remote.reset();
   }
 }
@@ -147,12 +146,12 @@ void NodeService::Initialize(
   node::SetProcessExitHandler(
       node_env_.get(), [this](node::Environment* env, int exit_code) {
         // Destroy node platform.
-        env->set_trace_sync_io(false);
+        node_env_stopped_ = true;
         ParentPort::GetInstance()->Close();
         js_env_->DestroyMicrotasksRunner();
-        node::Stop(env, node::StopFlags::kDoNotTerminateIsolate);
-        node_env_stopped_ = true;
+        g_client_remote.reset();
         receiver_.ResetWithReason(exit_code, "process_exit_termination");
+        node::DefaultProcessExitHandler(env, exit_code);
       });
 
   node_env_->set_trace_sync_io(node_env_->options()->trace_sync_io);

filename: spec/api-utility-process-spec.ts
patch: @@ -129,6 +129,26 @@ describe('utilityProcess module', () => {
       expect(code).to.equal(exitCode);
     });
 
+    it('does not run JS after process.exit is called', async () => {
+      const file = path.join(os.tmpdir(), `no-js-after-exit-log-${Math.random()}`);
+      const child = utilityProcess.fork(path.join(fixturesPath, 'no-js-after-exit.js'), [`--testPath=${file}`]);
+      const [code] = await once(child, 'exit');
+      expect(code).to.equal(1);
+      let handle = null;
+      const lines = [];
+      try {
+        handle = await fs.open(file);
+        for await (const line of handle.readLines()) {
+          lines.push(line);
+        }
+      } finally {
+        await handle?.close();
+        await fs.rm(file, { force: true });
+      }
+      expect(lines.length).to.equal(1);
+      expect(lines[0]).to.equal('before exit');
+    });
+
     // 32-bit system will not have V8 Sandbox enabled.
     // WoA testing does not have VS toolchain configured to build native addons.
     ifit(process.arch !== 'ia32' && process.arch !== 'arm' && !isWindowsOnArm)('emits \'error\' when fatal error is triggered from V8', async () => {

filename: spec/fixtures/api/utility-process/no-js-after-exit.js
patch: @@ -0,0 +1,7 @@
+const { writeFileSync } = require('node:fs');
+
+const arg = process.argv[2];
+const file = arg.split('=')[1];
+writeFileSync(file, 'before exit');
+process.exit(1);
+writeFileSync(file, 'after exit');