Repository: electron/electron
Pull request: fix: ensure `utilityProcess` only emits one 'exit' event

#### Description of Change

Refs https://github.com/electron/electron/pull/42297
Closes https://github.com/electron/electron/issues/44012

Fixes an issue where the `exit` event could be emitted twice from the `utilityProcess`. `HandleTermination` is called from multiple callsites, and so we need to ensure we only process it for the first callsite.

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Fixed an issue where the `exit` event could be emitted twice from the `utilityProcess`.


Changed files:
filename: shell/browser/api/electron_api_utility_process.cc
patch: @@ -247,12 +247,16 @@ void UtilityProcessWrapper::OnServiceProcessLaunch(
 }
 
 void UtilityProcessWrapper::HandleTermination(uint64_t exit_code) {
+  // HandleTermination is called from multiple callsites,
+  // we need to ensure we only process it for the first callsite.
+  if (terminated_)
+    return;
+  terminated_ = true;
+
   if (pid_ != base::kNullProcessId)
     GetAllUtilityProcessWrappers().Remove(pid_);
   CloseConnectorPort();
-
   EmitWithoutEvent("exit", exit_code);
-
   Unpin();
 }
 
@@ -292,13 +296,8 @@ void UtilityProcessWrapper::CloseConnectorPort() {
 }
 
 void UtilityProcessWrapper::Shutdown(uint64_t exit_code) {
-  if (pid_ != base::kNullProcessId)
-    GetAllUtilityProcessWrappers().Remove(pid_);
   node_service_remote_.reset();
-  CloseConnectorPort();
-  // Emit 'exit' event
-  EmitWithoutEvent("exit", exit_code);
-  Unpin();
+  HandleTermination(exit_code);
 }
 
 void UtilityProcessWrapper::PostMessage(gin::Arguments* args) {

filename: shell/browser/api/electron_api_utility_process.h
patch: @@ -105,6 +105,7 @@ class UtilityProcessWrapper final
   int stdout_read_fd_ = -1;
   int stderr_read_fd_ = -1;
   bool connector_closed_ = false;
+  bool terminated_ = false;
   std::unique_ptr<mojo::Connector> connector_;
   blink::MessagePortDescriptor host_port_;
   mojo::Receiver<node::mojom::NodeServiceClient> receiver_{this};

filename: spec/api-utility-process-spec.ts
patch: @@ -58,10 +58,20 @@ describe('utilityProcess module', () => {
       await once(child, 'spawn');
     });
 
-    it('emits \'exit\' when child process exits gracefully', async () => {
+    it('emits \'exit\' when child process exits gracefully', (done) => {
       const child = utilityProcess.fork(path.join(fixturesPath, 'empty.js'));
-      const [code] = await once(child, 'exit');
-      expect(code).to.equal(0);
+      child.on('exit', (code) => {
+        expect(code).to.equal(0);
+        done();
+      });
+    });
+
+    it('emits \'exit\' when the child process file does not exist', (done) => {
+      const child = utilityProcess.fork('nonexistent');
+      child.on('exit', (code) => {
+        expect(code).to.equal(1);
+        done();
+      });
     });
 
     ifit(!isWindows32Bit)('emits the correct error code when child process exits nonzero', async () => {