diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index ba167f5fe96f..dd34134ae42a 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -42,6 +42,23 @@ var Uint8ArrayPrototypeIncludes = Uint8Array.prototype.includes; const MAX_BUFFER = 1024 * 1024; const kFromNode = Symbol("kFromNode"); +// Lazily-initialized diagnostics_channel handles for child_process events. +// Mirrors Node.js: https://github.com/nodejs/node/blob/main/lib/internal/child_process.js +let childProcessChannel; +let childProcessSpawnTracingChannel; +function getChildProcessChannel() { + if (!childProcessChannel) { + childProcessChannel = require("node:diagnostics_channel").channel("child_process"); + } + return childProcessChannel; +} +function getChildProcessSpawnTracingChannel() { + if (!childProcessSpawnTracingChannel) { + childProcessSpawnTracingChannel = require("node:diagnostics_channel").tracingChannel("child_process.spawn"); + } + return childProcessSpawnTracingChannel; +} + // Pass DEBUG_CHILD_PROCESS=1 to enable debug output if ($debug) { $debug("child_process: debug mode on"); @@ -1362,6 +1379,21 @@ class ChildProcess extends EventEmitter { // Bun.spawn() expects cmd[0] to be the command to run, and argv0 to replace the first arg when running the command, // so we have to set argv0 to spawnargs[0] and cmd[0] to file + // Publish to the 'child_process' diagnostics channel. Node fires this in + // the ChildProcess constructor, before the native spawn call — Bun's + // equivalent is at the top of .spawn(), before Bun.spawn(). + const cpChannel = getChildProcessChannel(); + if (cpChannel.hasSubscribers) { + cpChannel.publish({ process: this }); + } + + // 'child_process.spawn' tracing channel: publish start before Bun.spawn(), + // and either end (on success) or error (on failure). + const cpSpawn = getChildProcessSpawnTracingChannel(); + if (cpSpawn.start.hasSubscribers) { + cpSpawn.start.publish({ process: this, options }); + } + try { this.#handle = Bun.spawn({ cmd: [file, ...Array.prototype.slice.$call(spawnargs, 1)], @@ -1424,6 +1456,10 @@ class ChildProcess extends EventEmitter { item?.ref?.(); } } + + if (cpSpawn.end.hasSubscribers) { + cpSpawn.end.publish({ process: this }); + } } catch (ex) { if ( ex != null && @@ -1449,7 +1485,13 @@ class ChildProcess extends EventEmitter { this.#stdioOptions[1] = "undefined"; this.#stdioOptions[2] = "undefined"; } + if (cpSpawn.error.hasSubscribers) { + cpSpawn.error.publish({ process: this, error: ex }); + } } else { + if (cpSpawn.error.hasSubscribers) { + cpSpawn.error.publish({ process: this, error: ex }); + } throw ex; } } diff --git a/test/js/node/child_process/child_process.test.ts b/test/js/node/child_process/child_process.test.ts index 478edff178b0..42f0b83fdceb 100644 --- a/test/js/node/child_process/child_process.test.ts +++ b/test/js/node/child_process/child_process.test.ts @@ -1,8 +1,9 @@ import { semver, write } from "bun"; import { afterAll, beforeEach, describe, expect, it } from "bun:test"; import fs from "fs"; -import { bunEnv, bunExe, isLinux, isWindows, nodeExe, runBunInstall, shellExe, tmpdirSync } from "harness"; -import { ChildProcess, exec, execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process"; +import { bunEnv, bunExe, isLinux, isWindows, nodeExe, runBunInstall, shellExe, tempDir, tmpdirSync } from "harness"; +import { ChildProcess, exec, execFile, execFileSync, execSync, fork, spawn, spawnSync } from "node:child_process"; +import * as dc from "node:diagnostics_channel"; import { promisify } from "node:util"; import path from "path"; const debug = process.env.DEBUG ? console.log : () => {}; @@ -566,3 +567,137 @@ it.if(isLinux)("spawn still works with more than 10240 fds open", async () => { }); expect(exitCode).toBe(0); }); + +describe("diagnostics_channel 'child_process'", () => { + it("publishes { process } when spawn() creates a ChildProcess", async () => { + const received: any[] = []; + const onMessage = (msg: any) => received.push(msg); + dc.subscribe("child_process", onMessage); + try { + const child = spawn(bunExe(), ["-e", "process.exit(0)"], { env: bunEnv }); + const exitCode = await new Promise(resolve => child.on("close", resolve)); + expect(exitCode).toBe(0); + expect(received).toHaveLength(1); + expect(received[0]).toHaveProperty("process"); + expect(received[0].process).toBe(child); + expect(received[0].process).toBeInstanceOf(ChildProcess); + } finally { + dc.unsubscribe("child_process", onMessage); + } + }); + + it("publishes for fork()", async () => { + const received: any[] = []; + const onMessage = (msg: any) => received.push(msg); + dc.subscribe("child_process", onMessage); + using dir = tempDir("cp-diag-fork", { + "child.js": "process.disconnect(); process.exit(0);", + }); + try { + const child = fork(`${String(dir)}/child.js`, [], { env: bunEnv, silent: true }); + // The channel publishes synchronously during fork()/spawn(), so we can assert + // immediately. Then wait for exit for cleanup. + expect(received).toHaveLength(1); + expect(received[0].process).toBe(child); + expect(received[0].process).toBeInstanceOf(ChildProcess); + await new Promise(resolve => child.on("exit", () => resolve())); + } finally { + dc.unsubscribe("child_process", onMessage); + } + }); + + it("publishes for execFile()", async () => { + const received: any[] = []; + const onMessage = (msg: any) => received.push(msg); + dc.subscribe("child_process", onMessage); + try { + const child = execFile(bunExe(), ["-e", "process.exit(0)"], { env: bunEnv }); + const exitCode = await new Promise(resolve => child.on("close", resolve)); + expect(exitCode).toBe(0); + expect(received).toHaveLength(1); + expect(received[0].process).toBe(child); + expect(received[0].process).toBeInstanceOf(ChildProcess); + } finally { + dc.unsubscribe("child_process", onMessage); + } + }); + + it("publishes for exec()", async () => { + const received: any[] = []; + const onMessage = (msg: any) => received.push(msg); + dc.subscribe("child_process", onMessage); + try { + const child = exec(`${JSON.stringify(bunExe())} -e "process.exit(0)"`, { env: bunEnv }); + const exitCode = await new Promise(resolve => child.on("close", resolve)); + expect(exitCode).toBe(0); + expect(received).toHaveLength(1); + expect(received[0].process).toBe(child); + expect(received[0].process).toBeInstanceOf(ChildProcess); + } finally { + dc.unsubscribe("child_process", onMessage); + } + }); + + it("does not publish for spawnSync (matches Node)", () => { + const received: any[] = []; + const onMessage = (msg: any) => received.push(msg); + dc.subscribe("child_process", onMessage); + try { + spawnSync(bunExe(), ["-e", "process.exit(0)"], { env: bunEnv }); + expect(received).toHaveLength(0); + } finally { + dc.unsubscribe("child_process", onMessage); + } + }); +}); + +describe("diagnostics_channel 'child_process.spawn' tracing", () => { + it("publishes start + end on successful spawn", async () => { + const events: Array<{ kind: string; msg: any }> = []; + const handlers = { + start: (msg: any) => events.push({ kind: "start", msg }), + end: (msg: any) => events.push({ kind: "end", msg }), + error: (msg: any) => events.push({ kind: "error", msg }), + }; + const tc = dc.tracingChannel("child_process.spawn"); + tc.subscribe(handlers); + try { + const child = spawn(bunExe(), ["-e", "process.exit(0)"], { env: bunEnv }); + const exitCode = await new Promise(resolve => child.on("close", resolve)); + expect(exitCode).toBe(0); + const kinds = events.map(e => e.kind); + expect(kinds).toEqual(["start", "end"]); + expect(events[0].msg.process).toBe(child); + expect(events[0].msg.process).toBeInstanceOf(ChildProcess); + // Node's start payload carries an options object with a `file` field. + expect(events[0].msg.options?.file).toBe(bunExe()); + expect(events[1].msg.process).toBe(child); + } finally { + tc.unsubscribe(handlers); + } + }); + + it("publishes start + error on ENOENT", async () => { + const events: Array<{ kind: string; msg: any }> = []; + const handlers = { + start: (msg: any) => events.push({ kind: "start", msg }), + end: (msg: any) => events.push({ kind: "end", msg }), + error: (msg: any) => events.push({ kind: "error", msg }), + }; + const tc = dc.tracingChannel("child_process.spawn"); + tc.subscribe(handlers); + try { + const child = spawn("does-not-exist-bun-test"); + child.on("error", () => {}); // suppress unhandled 'error' + await new Promise(resolve => child.on("close", () => resolve())); + const kinds = events.map(e => e.kind); + expect(kinds).toEqual(["start", "error"]); + expect(events[0].msg.process).toBeInstanceOf(ChildProcess); + expect(events[0].msg.options?.file).toBe("does-not-exist-bun-test"); + expect(events[1].msg.process).toBeInstanceOf(ChildProcess); + expect(events[1].msg.error?.code).toBe("ENOENT"); + } finally { + tc.unsubscribe(handlers); + } + }); +}); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-child-process.js b/test/js/node/test/parallel/test-diagnostics-channel-child-process.js new file mode 100644 index 000000000000..1d16fd429877 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-child-process.js @@ -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();