Repository: nodejs/node
Pull request: child_process: add tracing channel for spawn

This PR adds a new `child_process.spawn` tracing channel wrapping the `spawn` call. Since `spawn` is synchronous, this uses `start`, `end`, and `error` events (rather than the async variants).

### Events

- **`tracing:child_process.spawn:start`** — published before the native `_handle.spawn()` call, with `{ process, options }`
- **`tracing:child_process.spawn:end`** — published on successful spawn, with `{ process }`
- **`tracing:child_process.spawn:error`** — published on spawn failure, with `{ process, error }`

Since `exec`, `execFile`, and `fork` all call `spawn` internally, this single tracing channel covers all child process creation paths (e.g.: https://github.com/nodejs/node/blob/main/lib/child_process.js#L174)


Changed files:
filename: doc/api/diagnostics_channel.md
patch: @@ -1417,6 +1417,28 @@ added: v16.18.0
 
 Emitted when a new process is created.
 
+`tracing:child_process.spawn:start`
+
+* `process` {ChildProcess}
+* `options` {Object}
+
+Emitted when [`child_process.spawn()`][] is invoked, before the process is
+actually spawned.
+
+`tracing:child_process.spawn:end`
+
+* `process` {ChildProcess}
+
+Emitted when [`child_process.spawn()`][] has completed successfully and the
+process has been created.
+
+`tracing:child_process.spawn:error`
+
+* `process` {ChildProcess}
+* `error` {Error}
+
+Emitted when [`child_process.spawn()`][] encounters an error.
+
 ##### Event: `'execve'`
 
 * `execPath` {string}
@@ -1448,6 +1470,7 @@ Emitted when a new thread is created.
 [`channel.runStores(context, ...)`]: #channelrunstorescontext-fn-thisarg-args
 [`channel.subscribe(onMessage)`]: #channelsubscribeonmessage
 [`channel.unsubscribe(onMessage)`]: #channelunsubscribeonmessage
+[`child_process.spawn()`]: child_process.md#child_processspawncommand-args-options
 [`diagnostics_channel.channel(name)`]: #diagnostics_channelchannelname
 [`diagnostics_channel.subscribe(name, onMessage)`]: #diagnostics_channelsubscribename-onmessage
 [`diagnostics_channel.tracingChannel()`]: #diagnostics_channeltracingchannelnameorchannels

filename: lib/internal/child_process.js
patch: @@ -61,6 +61,7 @@ const spawn_sync = internalBinding('spawn_sync');
 const { kStateSymbol } = require('internal/dgram');
 const dc = require('diagnostics_channel');
 const childProcessChannel = dc.channel('child_process');
+const childProcessSpawn = dc.tracingChannel('child_process.spawn');
 
 const {
   UV_EACCES,
@@ -392,6 +393,10 @@ ChildProcess.prototype.spawn = function spawn(options) {
     this.spawnargs = options.args;
   }
 
+  if (childProcessSpawn.hasSubscribers) {
+    childProcessSpawn.start.publish({ process: this, options });
+  }
+
   const err = this._handle.spawn(options);
 
   // Run-time errors should emit an error, not throw an exception.
@@ -400,6 +405,13 @@ ChildProcess.prototype.spawn = function spawn(options) {
       err === UV_EMFILE ||
       err === UV_ENFILE ||
       err === UV_ENOENT) {
+    if (childProcessSpawn.hasSubscribers) {
+      childProcessSpawn.error.publish({
+        process: this,
+        error: new ErrnoException(err, 'spawn'),
+      });
+    }
+
     process.nextTick(onErrorNT, this, err);
 
     // There is no point in continuing when we've hit EMFILE or ENFILE
@@ -417,8 +429,20 @@ ChildProcess.prototype.spawn = function spawn(options) {
 
     this._handle.close();
     this._handle = null;
+
+    if (childProcessSpawn.hasSubscribers) {
+      childProcessSpawn.error.publish({
+        process: this,
+        error: new ErrnoException(err, 'spawn'),
+      });
+    }
+
     throw new ErrnoException(err, 'spawn');
   } else {
+    if (childProcessSpawn.hasSubscribers) {
+      childProcessSpawn.end.publish({ process: this });
+    }
+
     process.nextTick(onSpawnNT, this);
   }
 

filename: test/parallel/test-diagnostics-channel-child-process.js
patch: @@ -0,0 +1,94 @@
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const { spawn, ChildProcess } = require('child_process');
+const dc = require('diagnostics_channel');
+const path = require('path');
+const fs = require('fs');
+const tmpdir = require('../common/tmpdir');
+
+const isChildProcess = (process) => process instanceof ChildProcess;
+
+function testDiagnosticChannel(subscribers, test, after) {
+  dc.tracingChannel('child_process.spawn').subscribe(subscribers);
+
+  test(common.mustCall(() => {
+    dc.tracingChannel('child_process.spawn').unsubscribe(subscribers);
+    after?.();
+  }));
+}
+
+const testSuccessfulSpawn = common.mustCall(() => {
+  let cb;
+
+  testDiagnosticChannel(
+    {
+      start: common.mustCall(({ process: childProcess, options }) => {
+        assert.strictEqual(isChildProcess(childProcess), true);
+        assert.strictEqual(options.file, process.execPath);
+      }),
+      end: common.mustCall(({ process: childProcess }) => {
+        assert.strictEqual(isChildProcess(childProcess), true);
+      }),
+      error: common.mustNotCall(),
+    },
+    common.mustCall((callback) => {
+      cb = callback;
+      const child = spawn(process.execPath, ['-e', 'process.exit(0)']);
+      child.on('close', () => {
+        cb();
+      });
+    }),
+    testFailingSpawnENOENT
+  );
+});
+
+const testFailingSpawnENOENT = common.mustCall(() => {
+  testDiagnosticChannel(
+    {
+      start: common.mustCall(({ process: childProcess, options }) => {
+        assert.strictEqual(isChildProcess(childProcess), true);
+        assert.strictEqual(options.file, 'does-not-exist');
+      }),
+      end: common.mustNotCall(),
+      error: common.mustCall(({ process: childProcess, error }) => {
+        assert.strictEqual(isChildProcess(childProcess), true);
+        assert.strictEqual(error.code, 'ENOENT');
+      }),
+    },
+    common.mustCall((callback) => {
+      const child = spawn('does-not-exist');
+      child.on('error', () => {});
+      callback();
+    }),
+    common.isWindows ? undefined : testFailingSpawnEACCES,
+  );
+});
+
+const testFailingSpawnEACCES = !common.isWindows ? common.mustCall(() => {
+  tmpdir.refresh();
+  const noExecFile = path.join(tmpdir.path, 'no-exec');
+  fs.writeFileSync(noExecFile, '');
+  fs.chmodSync(noExecFile, 0o644);
+
+  testDiagnosticChannel(
+    {
+      start: common.mustCall(({ process: childProcess, options }) => {
+        assert.strictEqual(isChildProcess(childProcess), true);
+        assert.strictEqual(options.file, noExecFile);
+      }),
+      end: common.mustNotCall(),
+      error: common.mustCall(({ process: childProcess, error }) => {
+        assert.strictEqual(isChildProcess(childProcess), true);
+        assert.strictEqual(error.code, 'EACCES');
+      }),
+    },
+    common.mustCall((callback) => {
+      const child = spawn(noExecFile);
+      child.on('error', () => {});
+      callback();
+    }),
+  );
+}) : undefined;
+
+testSuccessfulSpawn();