import { bunEnv, bunExe, isWindows } from "harness"; import { createTest } from "node-harness"; import { ChildProcess, exec, fork, spawn } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import util from "node:util"; const { beforeAll, beforeEach, afterAll, describe, expect, it, throws, assert, createCallCheckCtx, createDoneDotAll } = createTest(import.meta.path); const origProcessEnv = process.env; beforeEach(() => { process.env = { ...bunEnv }; }); afterAll(() => { process.env = origProcessEnv; }); const strictEqual = (a, b) => expect(a).toStrictEqual(b); const debug = process.env.DEBUG ? console.log : () => {}; const platformTmpDir = require("fs").realpathSync(tmpdir()); const TYPE_ERR_NAME = "TypeError"; const fixturesDir = path.join(__dirname, "fixtures"); const fixtures = { path(...args) { const strings = [fixturesDir, ...args].filter(util.isString); return path.join(...strings); }, }; // Copyright Joyent, Inc. and other Node contributors. // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the // "Software"), to deal in the Software without restriction, including // without limitation the rights to use, copy, modify, merge, publish, // distribute, sublicense, and/or sell copies of the Software, and to permit // persons to whom the Software is furnished to do so, subject to the // following conditions: // // The above copyright notice and this permission notice shall be included // in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN // NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. const common = { pwdCommand: isWindows ? ["node", ["-e", "process.stdout.write(process.cwd() + '\\n')"]] : ["pwd", []], }; describe("ChildProcess.constructor", () => { it("should be a function", () => { strictEqual(typeof ChildProcess, "function"); }); }); describe("ChildProcess.spawn()", () => { it("should throw on invalid options", () => { // Verify that invalid options to spawn() throw. const child = new ChildProcess(); [undefined, null, "foo", 0, 1, NaN, true, false].forEach(options => { throws( () => { child.spawn(options); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options" argument must be of type object.' + // `${common.invalidArgTypeHelper(options)}`, }, ); }); }); it("should throw if file is not a string", () => { // Verify that spawn throws if file is not a string. const child = new ChildProcess(); [undefined, null, 0, 1, NaN, true, false, {}].forEach(file => { throws( () => { child.spawn({ file }); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options.file" property must be of type string.' + // `${common.invalidArgTypeHelper(file)}`, }, ); }); }); it("should throw if envPairs is not an array or undefined", () => { // Verify that spawn throws if envPairs is not an array or undefined. const child = new ChildProcess(); [null, 0, 1, NaN, true, false, {}, "foo"].forEach(envPairs => { throws( () => { child.spawn({ envPairs, stdio: ["ignore", "ignore", "ignore", "ipc"], }); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options.envPairs" property must be an instance of Array.' + // common.invalidArgTypeHelper(envPairs), }, ); }); }); it("should throw if stdio is not an array or undefined", () => { // Verify that spawn throws if args is not an array or undefined. const child = new ChildProcess(); [null, 0, 1, NaN, true, false, {}, "foo"].forEach(args => { throws( () => { child.spawn({ file: "foo", args }); }, { code: "ERR_INVALID_ARG_TYPE", name: TYPE_ERR_NAME, // message: // 'The "options.args" property must be an instance of Array.' + // common.invalidArgTypeHelper(args), }, ); }); }); }); describe("ChildProcess.spawn", () => { function getChild() { const child = new ChildProcess(); child.spawn({ file: "node", // file: process.execPath, args: ["node", "--interactive"], cwd: process.cwd(), stdio: ["ignore", "ignore", "ignore"], }); return child; } it("should spawn a process", () => { const child = getChild(); // Test that we can call spawn strictEqual(Object.hasOwn(child, "pid"), true); assert(Number.isInteger(child.pid)); child.kill(); }); it("should throw error on invalid signal", () => { const child = getChild(); // Try killing with invalid signal throws( () => { child.kill("foo"); }, { code: "ERR_UNKNOWN_SIGNAL", name: TYPE_ERR_NAME }, ); }); }); describe("ChildProcess spawn bad stdio", () => { // Monkey patch spawn() to create a child process normally, but destroy the // stdout and stderr streams. This replicates the conditions where the streams // cannot be properly created. function createChild(options, callback, target) { return new Promise((resolve, reject) => { var __originalSpawn = ChildProcess.prototype.spawn; ChildProcess.prototype.spawn = function () { const err = __originalSpawn.apply(this, arguments); this.stdout.destroy(); this.stderr.destroy(); return err; }; let cmd = target === "sleep" ? // The process can exit before returning which breaks tests. ((target = ""), `${bunExe()} -e "setTimeout(() => {}, 100)"`) : `${bunExe()} ${path.join(import.meta.dir, "spawned-child.js")}`; if (target) cmd += " " + target; const child = exec(cmd, options, async (err, stdout, stderr) => { try { await callback(err, stdout, stderr); resolve(); } catch (e) { reject(e); } }); ChildProcess.prototype.spawn = __originalSpawn; }); } it("should handle normal execution of child process", async () => { await createChild({}, (err, stdout, stderr) => { strictEqual(err, null); strictEqual(stdout, ""); strictEqual(stderr, ""); }); }); it.todo("should handle error event of child process", async () => { const error = new Error(`Command failed: bun ${import.meta.dir}/spawned-child.js ERROR`); await createChild( {}, (err, stdout, stderr) => { strictEqual(stdout, ""); strictEqual(stderr, ""); strictEqual(err?.message, error.message); }, "ERROR", ); }); it("should handle killed process", async () => { await createChild( { timeout: 1 }, (err, stdout, stderr) => { strictEqual(err.killed, true); strictEqual(stdout, ""); strictEqual(stderr, ""); }, "sleep", ); }); }); describe("child_process cwd", () => { // Spawns 'pwd' with given options, then test // - whether the child pid is undefined or number, // - whether the exit code equals expectCode, // - optionally whether the trimmed stdout result matches expectData function testCwd(options, { expectPidType, expectCode = 0, expectData }, done = () => {}) { const createDone = createDoneDotAll(done); const { mustCall } = createCallCheckCtx(createDone(1500)); const exitDone = createDone(5000); const child = spawn(...common.pwdCommand, { stdio: ["inherit", "pipe", "inherit"], ...options }); strictEqual(typeof child.pid, expectPidType); child.stdout.setEncoding("utf8"); // No need to assert callback since `data` is asserted. let data = ""; child.stdout.on("data", chunk => { data += chunk; }); // TODO: Test exit events // // Can't assert callback, as stayed in to API: // // _The 'exit' event may or may not fire after an error has occurred._ child.on("exit", (code, signal) => { try { strictEqual(code, expectCode); exitDone(); } catch (err) { exitDone(err); } }); child.stdout.on( "close", mustCall(() => { expectData && strictEqual(data.trim(), expectData); }), ); return child; } // TODO: Make sure this isn't important // Currently Bun.spawn will still spawn even though cwd doesn't exist // // Assume does-not-exist doesn't exist, expect exitCode=-1 and errno=ENOENT // it("should throw an error when given cwd doesn't exist", () => { // testCwd({ cwd: "does-not-exist" }, "undefined", -1).on( // "error", // mustCall(function (e) { // console.log(e); // strictEqual(e.code, "ENOENT"); // }), // ); // }); // TODO: Make sure this isn't an important test // it("should throw when cwd is a non-file url", () => { // throws(() => { // testCwd( // { // cwd: new URL("http://example.com/"), // }, // "number", // 0, // tmpdir.path // ); // }, /The URL must be of scheme file/); // // if (process.platform !== "win32") { // // throws(() => { // // testCwd( // // { // // cwd: new URL("file://host/dev/null"), // // }, // // "number", // // 0, // // tmpdir.path // // ); // // }, /File URL host must be "localhost" or empty on/); // // } // }); it("should work for valid given cwd", done => { const tmpdir = { path: platformTmpDir }; const createDone = createDoneDotAll(done); // Assume these exist, and 'pwd' gives us the right directory back testCwd( { cwd: tmpdir.path }, { expectPidType: "number", expectCode: 0, expectData: platformTmpDir, }, createDone(1500), ); const shouldExistDir = isWindows ? "C:\\Windows\\System32" : "/dev"; testCwd( { cwd: shouldExistDir }, { expectPidType: "number", expectCode: 0, expectData: shouldExistDir, }, createDone(1500), ); testCwd( { cwd: Bun.pathToFileURL(tmpdir.path) }, { expectPidType: "number", expectCode: 0, expectData: platformTmpDir, }, createDone(1500), ); }); it("shouldn't try to chdir to an invalid cwd", done => { const createDone = createDoneDotAll(done); // Spawn() shouldn't try to chdir() to invalid arg, so this should just work testCwd({ cwd: "" }, { expectPidType: "number" }, createDone(1500)); testCwd({ cwd: undefined }, { expectPidType: "number" }, createDone(1500)); testCwd({ cwd: null }, { expectPidType: "number" }, createDone(1500)); }); }); describe("child_process default options", () => { it("should use process.env as default env", done => { process.env.TMPDIR = platformTmpDir; // fake printenv let child = spawn(bunExe(), ["--print", "process.env"], {}); let response = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", chunk => { debug(`stdout: ${chunk}`); response += chunk; }); // NOTE: Original test used child.on("exit"), but this is unreliable // because the process can exit before the stream is closed and the data is read child.stdout.on("close", () => { try { expect(response).toContain(`TMPDIR: "${platformTmpDir.replace(/\\/g, "\\\\")}"`); done(); } catch (e) { done(e); } }); }); }); describe("child_process double pipe", () => { it("should allow two pipes to be used at once", done => { // const { mustCallAtLeast, mustCall } = createCallCheckCtx(done); const mustCallAtLeast = fn => fn; const mustCall = fn => fn; let fakeGrep, fakeSed, fakeEcho; fakeGrep = spawn(bunExe(), [ "-e", "process.stdin.on('data', (data) => { const dataStr = data.toString(); if (dataStr.includes('o')) process.stdout.write(dataStr); });", ]); fakeSed = spawn(bunExe(), [ "-e", "process.stdin.on('data', (data) => { process.stdout.write(data.toString().replace(/o/g, 'O')); });", ]); fakeEcho = spawn(bunExe(), ["-e", "console.log('hello');console.log('node');console.log('world');"]); // pipe grep | sed fakeGrep.stdout.on( "data", mustCallAtLeast(data => { debug(`grep stdout ${data.length}`); if (!fakeSed.stdin.write(data)) { fakeGrep.stdout.pause(); } }), ); // print sed's output fakeSed.stdout.on( "data", mustCallAtLeast(data => { result += data.toString("utf8"); debug(data); }), ); fakeEcho.stdout.on( "data", mustCallAtLeast(data => { debug(`grep stdin write ${data.length}`); if (!fakeGrep.stdin.write(data)) { debug("echo stdout pause"); fakeEcho.stdout.pause(); } }), ); // TODO(@jasnell): This does not appear to ever be // emitted. It's not clear if it is necessary. fakeGrep.stdin.on("drain", () => { debug("echo stdout resume"); fakeEcho.stdout.resume(); }); // Propagate end from echo to grep fakeEcho.stdout.on( "end", mustCall(() => { debug("echo stdout end"); fakeGrep.stdin.end(); }), ); fakeEcho.on( "exit", mustCall(() => { debug("echo exit"); }), ); fakeGrep.on( "exit", mustCall(() => { debug("grep exit"); }), ); fakeSed.on( "exit", mustCall(() => { debug("sed exit"); }), ); // TODO(@jasnell): This does not appear to ever be // emitted. It's not clear if it is necessary. fakeSed.stdin.on("drain", () => { fakeGrep.stdout.resume(); }); // Propagate end from grep to sed fakeGrep.stdout.on( "end", mustCall(() => { debug("grep stdout end"); fakeSed.stdin.end(); }), ); let result = ""; fakeSed.stdout.on( "end", mustCall(() => { debug("result: " + result); strictEqual(result, `hellO\nnOde\nwOrld\n`); done(); }), ); }); }); describe("fork", () => { const expectedEnv = { foo: "bar" }; describe("abort-signal", () => { it("Test aborting a forked child_process after calling fork", done => { const { mustCall } = createCallCheckCtx(done); const ac = new AbortController(); const { signal } = ac; const cp = fork(fixtures.path("child-process-stay-alive-forever.js"), { signal, env: bunEnv, }); cp.on( "exit", mustCall((code, killSignal) => { strictEqual(code, null); strictEqual(killSignal, "SIGTERM"); }), ); cp.on( "error", mustCall(err => { strictEqual(err.name, "AbortError"); }), ); process.nextTick(() => ac.abort()); }); it("Test aborting with custom error", done => { const { mustCall } = createCallCheckCtx(done); const ac = new AbortController(); const { signal } = ac; const cp = fork(fixtures.path("child-process-stay-alive-forever.js"), { signal, env: bunEnv, }); cp.on( "exit", mustCall((code, killSignal) => { strictEqual(code, null); strictEqual(killSignal, "SIGTERM"); }), ); cp.on( "error", mustCall(err => { strictEqual(err.name, "AbortError"); strictEqual(err.cause.name, "Error"); strictEqual(err.cause.message, "boom"); }), ); process.nextTick(() => ac.abort(new Error("boom"))); }); it("Test passing an already aborted signal to a forked child_process", done => { const { mustCall } = createCallCheckCtx(done); const signal = AbortSignal.abort(); const cp = fork(fixtures.path("child-process-stay-alive-forever.js"), { signal, env: bunEnv, }); cp.on( "exit", mustCall((code, killSignal) => { strictEqual(code, null); strictEqual(killSignal, "SIGTERM"); }), ); cp.on( "error", mustCall(err => { strictEqual(err.name, "AbortError"); }), ); }); it("Test passing an aborted signal with custom error to a forked child_process", done => { const { mustCall } = createCallCheckCtx(done); const signal = AbortSignal.abort(new Error("boom")); const cp = fork(fixtures.path("child-process-stay-alive-forever.js"), { signal, }); cp.on( "exit", mustCall((code, killSignal) => { strictEqual(code, null); strictEqual(killSignal, "SIGTERM"); }), ); cp.on( "error", mustCall(err => { strictEqual(err.name, "AbortError"); strictEqual(err.cause.name, "Error"); strictEqual(err.cause.message, "boom"); }), ); }); it("Test passing a different kill signal", done => { const { mustCall } = createCallCheckCtx(done); const signal = AbortSignal.abort(); const cp = fork(fixtures.path("child-process-stay-alive-forever.js"), { signal, killSignal: "SIGKILL", env: bunEnv, }); cp.on( "exit", mustCall((code, killSignal) => { strictEqual(code, null); strictEqual(killSignal, "SIGKILL"); }), ); cp.on( "error", mustCall(err => { strictEqual(err.name, "AbortError"); }), ); }); // This event doesn't run it.todo("Test aborting a cp before close but after exit", done => { const { mustCall, mustNotCall } = createCallCheckCtx(done); const ac = new AbortController(); const { signal } = ac; const cp = fork(fixtures.path("child-process-stay-alive-forever.js"), { signal, env: bunEnv, }); cp.on( "exit", mustCall(() => { ac.abort(); }), ); cp.on("error", mustNotCall()); setTimeout(() => cp.kill(), 1); }); }); describe("args", () => { const invalidModulePath = [0, true, undefined, null, [], {}, () => {}, Symbol("t")]; invalidModulePath.forEach(modulePath => { it(`Ensure that first argument \`modulePath\` must be provided and be of type string :: ${String(modulePath)}`, () => { expect(() => fork(modulePath, { env: bunEnv })).toThrow( expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE", name: "TypeError", message: expect.stringContaining( `The "modulePath" argument must be of type string, Buffer, or URL. Received `, ), }), ); }); }); // This test fails due to a DataCloneError or due to "Unable to deserialize data." // This test was originally marked as TODO before the process changes. it.todo( "Ensure that the second argument of `fork` and `fork` should parse options correctly if args is undefined or null", done => { const invalidSecondArgs = [0, true, () => {}, Symbol("t")]; try { invalidSecondArgs.forEach(arg => { expect(() => fork(fixtures.path("child-process-echo-options.js"), arg)).toThrow({ code: "ERR_INVALID_ARG_TYPE", name: "TypeError", message: `The \"args\" argument must be of type Array. Received ${arg?.toString()}`, }); }); } catch (e) { done(e); return; } const argsLists = [[]]; const { mustCall } = createCallCheckCtx(done); argsLists.forEach(args => { const cp = fork(fixtures.path("child-process-echo-options.js"), args, { env: { ...bunEnv, ...expectedEnv }, }); cp.on( "message", mustCall(({ env }) => { assert.strictEqual(env.foo, expectedEnv.foo); }), ); cp.on( "exit", mustCall(code => { assert.strictEqual(code, 0); }), ); }); }, ); const invalidThirdArgs = [0, true, () => {}, Symbol("t")]; invalidThirdArgs.forEach(arg => { it(`Ensure that the third argument should be type of object if provided :: ${String(arg)}`, () => { expect(() => { fork(fixtures.path("child-process-echo-options.js"), [], arg); }).toThrow( expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE", name: "TypeError", message: expect.stringContaining(`The "options" argument must be of type object. Received `), }), ); }); }); }); describe.todo("close", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-close.js }); describe.todo("detached", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-detached.js }); describe.todo("dgram", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-dgram.js }); describe.todo("net", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-net.js }); describe.todo("net-server", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-net-server.js }); describe.todo("net-socket", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-net-socket.js }); describe.todo("no-shell", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-no-shell.js }); describe.todo("ref", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-ref.js }); describe.todo("stdio", () => { // https://github.com/nodejs/node/blob/v20.5.0/test/parallel/test-child-process-fork-stdio.js }); describe("fork", () => { it.todo("message", done => { // TODO - bun has no `send` method in the process const { mustCall } = createCallCheckCtx(done); const args = ["foo", "bar"]; const n = fork(fixtures.path("child-process-spawn-node.js"), args); assert.strictEqual(n.channel, n._channel); assert.deepStrictEqual(args, ["foo", "bar"]); n.on("message", m => { debug("PARENT got message:", m); assert.ok(m.foo); }); expect(() => n.send(undefined)).toThrow({ name: "TypeError", message: 'The "message" argument must be specified', code: "ERR_MISSING_ARGS", }); expect(() => n.send()).toThrow({ name: "TypeError", message: 'The "message" argument must be specified', code: "ERR_MISSING_ARGS", }); expect(() => n.send(Symbol())).toThrow({ name: "TypeError", message: 'The "message" argument must be one of type string,' + " object, number, or boolean. Received type symbol (Symbol())", code: "ERR_INVALID_ARG_TYPE", }); n.send({ hello: "world" }); n.on( "exit", mustCall(c => { assert.strictEqual(c, 0); }), ); }); }); });