Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -1424,6 +1456,10 @@ class ChildProcess extends EventEmitter {
item?.ref?.();
}
}

if (cpSpawn.end.hasSubscribers) {
cpSpawn.end.publish({ process: this });
}
} catch (ex) {
if (
ex != null &&
Expand All @@ -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;
}
}
Expand Down
139 changes: 137 additions & 2 deletions test/js/node/child_process/child_process.test.ts
Original file line number Diff line number Diff line change
@@ -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 : () => {};
Expand Down Expand Up @@ -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<number | null>(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<void>(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<number | null>(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<number | null>(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<number | null>(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<void>(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);
}
});
});
Original file line number Diff line number Diff line change
@@ -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();
Loading