mirror of
https://github.com/oven-sh/bun
synced 2026-02-13 20:39:05 +00:00
* feat(child_process): beginning of child_process, add ChildProcess and spawn base case * fix(child_process): remove invalid single arg array syntax (thanks Copilot) * refactor(child_process): unhack Readable.on, move stuff into node:stream * feat(child_process): add more params for spawn, refactor, add fromWeb() to Readable * feat(child_process): finish rest of exports (minus fork), refactor, add tests * cleanup(streams): remove a bunch of unnecessary stuff * cleanup(child_process): remove dead refs * fix(child_process): fix stdio * fix(child_process): change stdio to bunStdio * test(child_process): uncomment timeout test * test(child_process): fix hanging tests * test(child_process): remove stray console.log * test(child_process): fix cwd test for linux * refactor(child_process): divide paths for encoded vs raw execFile stdio * fix(child_process): fix logic for execFile slow path
571 lines
15 KiB
JavaScript
571 lines
15 KiB
JavaScript
import { describe, expect, it } from "bun:test";
|
|
import { ChildProcess, spawn, exec } from "node:child_process";
|
|
import { EOL } from "node:os";
|
|
import assertNode from "node:assert";
|
|
import { inspect } from "node:util";
|
|
|
|
const debug = console.log;
|
|
|
|
// 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 = {
|
|
// // TODO: Fix the implementations of these functions, they may be ruining everything...
|
|
// mustCallAtLeast: function mustCallAtLeast(callback) {
|
|
// return (...args) => {
|
|
// callback(...args);
|
|
// expect(true).toBe(true);
|
|
// };
|
|
// },
|
|
// mustCall: function mustCall(callback) {
|
|
// return (...args) => {
|
|
// callback(...args);
|
|
// expect(true).toBe(true);
|
|
// };
|
|
// },
|
|
pwdCommand: ["pwd", []],
|
|
};
|
|
|
|
const mustCallChecks = [];
|
|
|
|
function runCallChecks(exitCode) {
|
|
if (exitCode !== 0) return;
|
|
|
|
const failed = mustCallChecks.filter(function (context) {
|
|
if ("minimum" in context) {
|
|
context.messageSegment = `at least ${context.minimum}`;
|
|
return context.actual < context.minimum;
|
|
}
|
|
context.messageSegment = `exactly ${context.exact}`;
|
|
return context.actual !== context.exact;
|
|
});
|
|
|
|
failed.forEach(function (context) {
|
|
console.log(
|
|
"Mismatched %s function calls. Expected %s, actual %d.",
|
|
context.name,
|
|
context.messageSegment,
|
|
context.actual
|
|
);
|
|
console.log(context.stack.split("\n").slice(2).join("\n"));
|
|
});
|
|
|
|
if (failed.length) process.exit(1);
|
|
}
|
|
|
|
function mustCall(fn, exact) {
|
|
return _mustCallInner(fn, exact, "exact");
|
|
}
|
|
|
|
function mustSucceed(fn, exact) {
|
|
return mustCall(function (err, ...args) {
|
|
assert.ifError(err);
|
|
if (typeof fn === "function") return fn.apply(this, args);
|
|
}, exact);
|
|
}
|
|
|
|
function mustCallAtLeast(fn, minimum) {
|
|
return _mustCallInner(fn, minimum, "minimum");
|
|
}
|
|
|
|
function _mustCallInner(fn, criteria = 1, field) {
|
|
if (process._exiting)
|
|
throw new Error("Cannot use common.mustCall*() in process exit handler");
|
|
if (typeof fn === "number") {
|
|
criteria = fn;
|
|
fn = noop;
|
|
} else if (fn === undefined) {
|
|
fn = noop;
|
|
}
|
|
|
|
if (typeof criteria !== "number")
|
|
throw new TypeError(`Invalid ${field} value: ${criteria}`);
|
|
|
|
const context = {
|
|
[field]: criteria,
|
|
actual: 0,
|
|
stack: inspect(new Error()),
|
|
name: fn.name || "<anonymous>",
|
|
};
|
|
|
|
// Add the exit listener only once to avoid listener leak warnings
|
|
if (mustCallChecks.length === 0) process.on("exit", runCallChecks);
|
|
|
|
mustCallChecks.push(context);
|
|
|
|
const _return = function () {
|
|
// eslint-disable-line func-style
|
|
context.actual++;
|
|
return fn.apply(this, arguments);
|
|
};
|
|
// Function instances have own properties that may be relevant.
|
|
// Let's replicate those properties to the returned function.
|
|
// Refs: https://tc39.es/ecma262/#sec-function-instances
|
|
Object.defineProperties(_return, {
|
|
name: {
|
|
value: fn.name,
|
|
writable: false,
|
|
enumerable: false,
|
|
configurable: true,
|
|
},
|
|
length: {
|
|
value: fn.length,
|
|
writable: false,
|
|
enumerable: false,
|
|
configurable: true,
|
|
},
|
|
});
|
|
return _return;
|
|
}
|
|
|
|
const strictEqual = (...args) => {
|
|
let error = null;
|
|
try {
|
|
assertNode.strictEqual(...args);
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
expect(error).toBe(null);
|
|
};
|
|
|
|
const throws = (...args) => {
|
|
let error = null;
|
|
try {
|
|
assertNode.throws(...args);
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
expect(error).toBe(null);
|
|
};
|
|
|
|
const assert = (...args) => {
|
|
let error = null;
|
|
try {
|
|
assertNode(...args);
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
expect(error).toBe(null);
|
|
};
|
|
|
|
const assertOk = (...args) => {
|
|
let error = null;
|
|
try {
|
|
assertNode.ok(...args);
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
expect(error).toBe(null);
|
|
};
|
|
|
|
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: "TypeError",
|
|
// 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: "TypeError",
|
|
// 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: "TypeError",
|
|
// 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: "TypeError",
|
|
// message:
|
|
// 'The "options.args" property must be an instance of Array.' +
|
|
// common.invalidArgTypeHelper(args),
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ChildProcess.spawn", () => {
|
|
const child = new ChildProcess();
|
|
child.spawn({
|
|
file: "bun",
|
|
// file: process.execPath,
|
|
args: ["--interactive"],
|
|
cwd: process.cwd(),
|
|
stdio: "pipe",
|
|
});
|
|
|
|
it("should spawn a process", () => {
|
|
// Test that we can call spawn
|
|
|
|
strictEqual(Object.hasOwn(child, "pid"), true);
|
|
assert(Number.isInteger(child.pid));
|
|
});
|
|
|
|
it("should throw error on invalid signal", () => {
|
|
// Try killing with invalid signal
|
|
throws(
|
|
() => {
|
|
child.kill("foo");
|
|
},
|
|
{ code: "ERR_UNKNOWN_SIGNAL", name: "TypeError" }
|
|
);
|
|
});
|
|
|
|
it("should die when killed", () => {
|
|
strictEqual(child.kill(), true);
|
|
});
|
|
});
|
|
|
|
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.
|
|
const original = ChildProcess.prototype.spawn;
|
|
|
|
ChildProcess.prototype.spawn = function () {
|
|
const err = original.apply(this, arguments);
|
|
|
|
this.stdout.destroy();
|
|
this.stderr.destroy();
|
|
this.stdout = null;
|
|
this.stderr = null;
|
|
|
|
return err;
|
|
};
|
|
|
|
function createChild(options, callback) {
|
|
const cmd = `"${process.execPath}" "${import.meta.path}" child`;
|
|
return exec(cmd, options, mustCall(callback));
|
|
}
|
|
|
|
it("should handle normal execution of child process", () => {
|
|
createChild({}, (err, stdout, stderr) => {
|
|
strictEqual(err, null);
|
|
strictEqual(stdout, "");
|
|
strictEqual(stderr, "");
|
|
});
|
|
});
|
|
|
|
it("should handle error event of child process", () => {
|
|
const error = new Error("foo");
|
|
const child = createChild({}, (err, stdout, stderr) => {
|
|
strictEqual(err, error);
|
|
strictEqual(stdout, "");
|
|
strictEqual(stderr, "");
|
|
});
|
|
|
|
child.emit("error", error);
|
|
});
|
|
|
|
it("should handle killed process", () => {
|
|
createChild({ timeout: 1 }, (err, stdout, stderr) => {
|
|
strictEqual(err.killed, true);
|
|
strictEqual(stdout, "");
|
|
strictEqual(stderr, "");
|
|
});
|
|
});
|
|
|
|
ChildProcess.prototype.spawn = original;
|
|
});
|
|
|
|
describe("child_process cwd", () => {
|
|
const tmpdir = { path: Bun.env.TMPDIR };
|
|
|
|
// 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) {
|
|
const child = spawn(...common.pwdCommand, 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", function (chunk) {
|
|
data += chunk;
|
|
});
|
|
|
|
// 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", function (code, signal) {
|
|
strictEqual(code, expectCode).bind(this);
|
|
});
|
|
|
|
child.on(
|
|
"close",
|
|
mustCall(function () {
|
|
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", () => {
|
|
// Assume these exist, and 'pwd' gives us the right directory back
|
|
testCwd({ cwd: tmpdir.path }, "number", 0, tmpdir.path);
|
|
const shouldExistDir = "/dev";
|
|
testCwd({ cwd: shouldExistDir }, "number", 0, shouldExistDir);
|
|
testCwd({ cwd: Bun.pathToFileURL(tmpdir.path) }, "number", 0, tmpdir.path);
|
|
});
|
|
|
|
it("shouldn't try to chdir to an invalid cwd", () => {
|
|
// Spawn() shouldn't try to chdir() to invalid arg, so this should just work
|
|
testCwd({ cwd: "" }, "number");
|
|
testCwd({ cwd: undefined }, "number");
|
|
testCwd({ cwd: null }, "number");
|
|
});
|
|
});
|
|
|
|
describe("child_process default options", () => {
|
|
process.env.HELLO = "WORLD";
|
|
|
|
let child = spawn("/usr/bin/env", [], {});
|
|
let response = "";
|
|
|
|
child.stdout.setEncoding("utf8");
|
|
|
|
it("should use process.env as default env", () => {
|
|
child.stdout.on("data", function (chunk) {
|
|
debug(`stdout: ${chunk}`);
|
|
response += chunk;
|
|
});
|
|
|
|
process.on("exit", function () {
|
|
assertOk(
|
|
response.includes("HELLO=WORLD"),
|
|
"spawn did not use process.env as default " +
|
|
`(process.env.HELLO = ${process.env.HELLO})`
|
|
);
|
|
});
|
|
});
|
|
|
|
delete process.env.HELLO;
|
|
});
|
|
|
|
describe("child_process double pipe", () => {
|
|
let grep, sed, echo;
|
|
grep = spawn("grep", ["o"]);
|
|
sed = spawn("sed", ["s/o/O/"]);
|
|
echo = spawn("echo", ["hello\nnode\nand\nworld\n"]);
|
|
|
|
it("should allow two pipes to be used at once", () => {
|
|
// pipe echo | grep
|
|
echo.stdout.on(
|
|
"data",
|
|
mustCallAtLeast((data) => {
|
|
debug(`grep stdin write ${data.length}`);
|
|
if (!grep.stdin.write(data)) {
|
|
echo.stdout.pause();
|
|
}
|
|
})
|
|
);
|
|
|
|
// TODO(Derrick): We don't implement the full API for this yet,
|
|
// So stdin has no 'drain' event.
|
|
// // TODO(@jasnell): This does not appear to ever be
|
|
// // emitted. It's not clear if it is necessary.
|
|
// grep.stdin.on("drain", (data) => {
|
|
// echo.stdout.resume();
|
|
// });
|
|
|
|
// Propagate end from echo to grep
|
|
echo.stdout.on(
|
|
"end",
|
|
mustCall((code) => {
|
|
grep.stdin.end();
|
|
})
|
|
);
|
|
|
|
echo.on(
|
|
"exit",
|
|
mustCall(() => {
|
|
debug("echo exit");
|
|
})
|
|
);
|
|
|
|
grep.on(
|
|
"exit",
|
|
mustCall(() => {
|
|
debug("grep exit");
|
|
})
|
|
);
|
|
|
|
sed.on(
|
|
"exit",
|
|
mustCall(() => {
|
|
debug("sed exit");
|
|
})
|
|
);
|
|
|
|
// pipe grep | sed
|
|
grep.stdout.on(
|
|
"data",
|
|
mustCallAtLeast((data) => {
|
|
debug(`grep stdout ${data.length}`);
|
|
if (!sed.stdin.write(data)) {
|
|
grep.stdout.pause();
|
|
}
|
|
})
|
|
);
|
|
|
|
// // TODO(@jasnell): This does not appear to ever be
|
|
// // emitted. It's not clear if it is necessary.
|
|
// sed.stdin.on("drain", (data) => {
|
|
// grep.stdout.resume();
|
|
// });
|
|
|
|
// Propagate end from grep to sed
|
|
grep.stdout.on(
|
|
"end",
|
|
mustCall((code) => {
|
|
debug("grep stdout end");
|
|
sed.stdin.end();
|
|
})
|
|
);
|
|
|
|
let result = "";
|
|
|
|
// print sed's output
|
|
sed.stdout.on(
|
|
"data",
|
|
mustCallAtLeast((data) => {
|
|
result += data.toString("utf8", 0, data.length);
|
|
debug(data);
|
|
})
|
|
);
|
|
|
|
sed.stdout.on(
|
|
"end",
|
|
mustCall((code) => {
|
|
strictEqual(result, `hellO${EOL}nOde${EOL}wOrld${EOL}`);
|
|
})
|
|
);
|
|
});
|
|
});
|