From 56272d09066dbaeedf1aacb3bc2e9657ad28912f Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Sat, 29 Oct 2022 12:57:19 -0500 Subject: [PATCH 01/15] feat(child_process): beginning of child_process, add ChildProcess and spawn base case --- src/bun.js/child_process.exports.js | 993 +++++++++++++++++++++++++++- test/bun.js/child_process.test.ts | 73 ++ 2 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 test/bun.js/child_process.test.ts diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index 7ed5159279d6d5..2d784ee16967a0 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -1,3 +1,994 @@ +const EventEmitter = import.meta.require("node:events"); +const { Readable } = import.meta.require("node:stream"); -export {}; +// Sections: +// 1. Exported child_process functions +// 2. child_process helpers +// 3. ChildProcess "class" +// 4. ChildProcess helpers +// 5. Validators +// 6. Node error polyfills +// 7. Node stream polyfills +// TODO: +// IPC support +// Add more tests +// Implement various stdio options +// Finish getValidStdio +// Make sure flushStdio is working +// Finish normalizing spawn args + +//------------------------------------------------------------------------------ +// Section 1. Exported child_process functions +//------------------------------------------------------------------------------ + +// TODO: Implement these props when Windows is supported +// * windowsVerbatimArguments?: boolean; +// * windowsHide?: boolean; + +// TODO: +// argv0 support +// Detached child process support +// Allow setting uid and gid +// Advanced serialization of IPC messages +// Shell support +// Abort signal +// Kill signal + +// 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. + +/** + * Spawns a new process using the given `file`. + * @param {string} file + * @param {string[]} [args] + * @param {{ + * cwd?: string; + * env?: Record; + * argv0?: string; + * stdio?: Array | string; + * detached?: boolean; + * uid?: number; + * gid?: number; + * serialization?: string; + * shell?: boolean | string; + * signal?: AbortSignal; + * timeout?: number; + * killSignal?: string | number; + * }} [options] + * @returns {ChildProcess} + */ +export function spawn(file, args, options) { + options = normalizeSpawnArguments(file, args, options); + // validateTimeout(options.timeout); + // validateAbortSignal(options.signal, "options.signal"); + // const killSignal = sanitizeKillSignal(options.killSignal); + const child = new ChildProcess(); + + // debug('spawn', options); + child.spawn(options); + + if (options.timeout > 0) { + let timeoutId = setTimeout(() => { + if (timeoutId) { + try { + child.kill(killSignal); + } catch (err) { + child.emit("error", err); + } + timeoutId = null; + } + }, options.timeout); + + child.once("exit", () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }); + } + + // if (options.signal) { + // const signal = options.signal; + // if (signal.aborted) { + // process.nextTick(onAbortListener); + // } else { + // signal.addEventListener("abort", onAbortListener, { once: true }); + // child.once("exit", () => + // signal.removeEventListener("abort", onAbortListener) + // ); + // } + + // function onAbortListener() { + // abortChildProcess(child, killSignal); + // } + // } + return child; +} + +//------------------------------------------------------------------------------ +// Section 2. child_process helpers +//------------------------------------------------------------------------------ + +function normalizeSpawnArguments(file, args, options) { + if (file == null) { + throw new ERR_INVALID_ARG_TYPE("file", "string", file); + } + + if (Array.isArray(file)) { + options = args; + args = file; + file = args[0]; + } else if (args == null) { + args = []; + } else if (!Array.isArray(args)) { + options = args; + args = []; + } + + if (options == null) { + options = {}; + } else if (typeof options !== "object") { + throw new ERR_INVALID_ARG_TYPE("options", "Object", options); + } + + validateString(file, "file"); + validateArray(args, "args"); + validateObject(options, "options"); + + return { ...options, file, args }; +} + +// function normalizeSpawnArguments(file, args, options) { +// validateString(file, "file"); +// validateArgumentNullCheck(file, "file"); + +// if (file.length === 0) +// throw new ERR_INVALID_ARG_VALUE("file", file, "cannot be empty"); + +// if (ArrayIsArray(args)) { +// args = ArrayPrototypeSlice(args); +// } else if (args == null) { +// args = []; +// } else if (typeof args !== "object") { +// throw new ERR_INVALID_ARG_TYPE("args", "object", args); +// } else { +// options = args; +// args = []; +// } + +// validateArgumentsNullCheck(args, "args"); + +// if (options === undefined) options = kEmptyObject; +// else validateObject(options, "options"); + +// let cwd = options.cwd; + +// // Validate the cwd, if present. +// if (cwd != null) { +// cwd = getValidatedPath(cwd, "options.cwd"); +// } + +// // Validate detached, if present. +// if (options.detached != null) { +// validateBoolean(options.detached, "options.detached"); +// } + +// // Validate the uid, if present. +// if (options.uid != null && !isInt32(options.uid)) { +// throw new ERR_INVALID_ARG_TYPE("options.uid", "int32", options.uid); +// } + +// // Validate the gid, if present. +// if (options.gid != null && !isInt32(options.gid)) { +// throw new ERR_INVALID_ARG_TYPE("options.gid", "int32", options.gid); +// } + +// // Validate the shell, if present. +// if ( +// options.shell != null && +// typeof options.shell !== "boolean" && +// typeof options.shell !== "string" +// ) { +// throw new ERR_INVALID_ARG_TYPE( +// "options.shell", +// ["boolean", "string"], +// options.shell +// ); +// } + +// // Validate argv0, if present. +// if (options.argv0 != null) { +// validateString(options.argv0, "options.argv0"); +// validateArgumentNullCheck(options.argv0, "options.argv0"); +// } + +// // // Validate windowsHide, if present. +// // if (options.windowsHide != null) { +// // validateBoolean(options.windowsHide, "options.windowsHide"); +// // } + +// // // Validate windowsVerbatimArguments, if present. +// // let { windowsVerbatimArguments } = options; +// // if (windowsVerbatimArguments != null) { +// // validateBoolean( +// // windowsVerbatimArguments, +// // "options.windowsVerbatimArguments" +// // ); +// // } + +// if (options.shell) { +// validateArgumentNullCheck(options.shell, "options.shell"); +// const command = ArrayPrototypeJoin([file, ...args], " "); +// // Set the shell, switches, and commands. +// if (process.platform === "win32") { +// if (typeof options.shell === "string") file = options.shell; +// else file = process.env.comspec || "cmd.exe"; +// // '/d /s /c' is used only for cmd.exe. +// if (RegExpPrototypeExec(/^(?:.*\\)?cmd(?:\.exe)?$/i, file) !== null) { +// args = ["/d", "/s", "/c", `"${command}"`]; +// windowsVerbatimArguments = true; +// } else { +// args = ["-c", command]; +// } +// } else { +// if (typeof options.shell === "string") file = options.shell; +// else if (process.platform === "android") file = "/system/bin/sh"; +// else file = "/bin/sh"; +// args = ["-c", command]; +// } +// } + +// if (typeof options.argv0 === "string") { +// ArrayPrototypeUnshift(args, options.argv0); +// } else { +// ArrayPrototypeUnshift(args, file); +// } + +// const env = options.env || process.env; +// const envPairs = []; + +// // process.env.NODE_V8_COVERAGE always propagates, making it possible to +// // collect coverage for programs that spawn with white-listed environment. +// copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env); + +// let envKeys = []; +// // Prototype values are intentionally included. +// for (const key in env) { +// ArrayPrototypePush(envKeys, key); +// } + +// if (process.platform === "win32") { +// // On Windows env keys are case insensitive. Filter out duplicates, +// // keeping only the first one (in lexicographic order) +// const sawKey = new SafeSet(); +// envKeys = ArrayPrototypeFilter(ArrayPrototypeSort(envKeys), (key) => { +// const uppercaseKey = StringPrototypeToUpperCase(key); +// if (sawKey.has(uppercaseKey)) { +// return false; +// } +// sawKey.add(uppercaseKey); +// return true; +// }); +// } + +// for (const key of envKeys) { +// const value = env[key]; +// if (value !== undefined) { +// validateArgumentNullCheck(key, `options.env['${key}']`); +// validateArgumentNullCheck(value, `options.env['${key}']`); +// ArrayPrototypePush(envPairs, `${key}=${value}`); +// } +// } + +// return { +// // Make a shallow copy so we don't clobber the user's options object. +// ...options, +// args, +// cwd, +// detached: !!options.detached, +// envPairs, +// file, +// // windowsHide: !!options.windowsHide, +// // windowsVerbatimArguments: !!windowsVerbatimArguments, +// }; +// } + +//------------------------------------------------------------------------------ +// Section 3. ChildProcess class +//------------------------------------------------------------------------------ + +export class ChildProcess extends EventEmitter { + constructor() { + super(); + this._closesNeeded = 0; + this._closesGot = 0; + this.connected = false; + this.signalCode = null; + this.exitCode = null; + this.killed = false; + this.spawnfile = undefined; + this.spawnargs = undefined; + this.pid = undefined; + this.stdin = undefined; + this.stdout = undefined; + this.stderr = undefined; + this.stdio = undefined; + this.channel = undefined; + this._handle = undefined; + this._handleQueue = undefined; + this._pendingMessage = undefined; + this._pendingHandle = undefined; + this._channel = undefined; + this._serialization = undefined; + this._eventsCount = undefined; + this._events = undefined; + this._error = null; + this._maxListeners = undefined; + this._exited = false; + } + + _handleOnExit(exitCode, signalCode) { + if (this._exited) return; + if (signalCode) { + this.signalCode = signalCode; + } else { + this.exitCode = exitCode; + } + + if (this.stdin) { + this.stdin.destroy(); + } + if (this._handle) { + this._handle = null; + } + + if (exitCode < 0) { + const syscall = this.spawnfile ? "spawn " + this.spawnfile : "spawn"; + const err = errnoException(exitCode, syscall); + + if (this.spawnfile) err.path = this.spawnfile; + + err.spawnargs = ArrayPrototypeSlice(this.spawnargs, 1); + this.emit("error", err); + } else { + this.emit("exit", this.exitCode, this.signalCode); + } + + // If any of the stdio streams have not been touched, + // then pull all the data through so that it can get the + // eof and emit a 'close' event. + // Do it on nextTick so that the user has one last chance + // to consume the output, if for example they only want to + // start reading the data once the process exits. + process.nextTick(flushStdio, this); + + maybeClose(this); + this._exited = true; + } + // this._handle[owner_symbol] = this; + + spawn(options) { + validateObject(options, "options"); + + // validateOneOf(options.serialization, "options.serialization", [ + // undefined, + // "json", + // // "advanced", // TODO + // ]); + // const serialization = options.serialization || "json"; + + // if (ipc !== undefined) { + // // Let child process know about opened IPC channel + // if (options.envPairs === undefined) options.envPairs = []; + // else validateArray(options.envPairs, "options.envPairs"); + + // ArrayPrototypePush(options.envPairs, `NODE_CHANNEL_FD=${ipcFd}`); + // ArrayPrototypePush( + // options.envPairs, + // `NODE_CHANNEL_SERIALIZATION_MODE=${serialization}` + // ); + // } + + validateString(options.file, "options.file"); + this.spawnfile = options.file; + + if (options.args === undefined) { + this.spawnargs = []; + } else { + validateArray(options.args, "options.args"); + this.spawnargs = options.args; + } + + this._handle = Bun.spawn({ + cmd: [this.spawnfile, ...this.spawnargs], + stdin: "pipe", // TODO: Unhardcode + stdout: "pipe", // TODO: Unhardcode + stderr: "pipe", // TODO: Unhardcode + onExit: this._handleOnExit.bind(this), + }); + // NOTE: We need these for supporting the `ChildProcess` EventEmitter-style API for pipes + // There may be a better way to do this... + this.stdout = newStreamReadableFromReadableStream(this._handle.stdout, { + encoding: "utf8", + }); + this.stderr = newStreamReadableFromReadableStream(this._handle.stderr, { + encoding: "utf8", + }); + // const err = this._handle.spawn(options); + process.nextTick(onSpawnNT, this); + + this.pid = this._handle.pid; + + // If no `stdio` option was given - use default + // let stdio = options.stdio || "pipe"; // TODO: reset default + // let stdio = options.stdio || ["pipe", "pipe", "pipe"]; + + // stdio = getValidStdio(stdio, false); + + // const ipc = stdio.ipc; + // const ipcFd = stdio.ipcFd; + // stdio = options.stdio = stdio.stdio; + + // for (i = 0; i < stdio.length; i++) { + // const stream = stdio[i]; + // if (stream.type === "ignore") continue; + + // if (stream.ipc) { + // this._closesNeeded++; + // continue; + // } + + // // The stream is already cloned and piped, thus stop its readable side, + // // otherwise we might attempt to read from the stream when at the same time + // // the child process does. + // if (stream.type === "wrap") { + // stream.handle.reading = false; + // stream.handle.readStop(); + // stream._stdio.pause(); + // stream._stdio.readableFlowing = false; + // stream._stdio._readableState.reading = false; + // stream._stdio[kIsUsedAsStdio] = true; + // continue; + // } + + // if (stream.handle) { + // stream.socket = createSocket( + // this.pid !== 0 ? stream.handle : null, + // i > 0 + // ); + + // if (i > 0 && this.pid !== 0) { + // this._closesNeeded++; + // stream.socket.on("close", () => { + // maybeClose(this); + // }); + // } + // } + // } + + // this.stdin = + // stdio.length >= 1 && stdio[0].socket !== undefined ? stdio[0].socket : null; + // this.stdout = + // stdio.length >= 2 && stdio[1].socket !== undefined ? stdio[1].socket : null; + // this.stderr = + // stdio.length >= 3 && stdio[2].socket !== undefined ? stdio[2].socket : null; + + // this.stdio = []; + + // for (i = 0; i < stdio.length; i++) + // ArrayPrototypePush( + // this.stdio, + // stdio[i].socket === undefined ? null : stdio[i].socket + // ); + + // // Add .send() method and start listening for IPC data + // if (ipc !== undefined) setupChannel(this, ipc, serialization); + } + + kill(signal) { + if (this.killed) return; + + if (this._handle) { + this._handle.kill(signal); + } + + this.killed = true; + this.emit("exit", null, signal); + maybeClose(this); + } +} + +//------------------------------------------------------------------------------ +// Section 4. ChildProcess helpers +//------------------------------------------------------------------------------ + +function flushStdio(subprocess) { + const stdio = subprocess.stdio; + + if (stdio == null) return; + + for (let i = 0; i < stdio.length; i++) { + const stream = stdio[i]; + // TODO(addaleax): This doesn't necessarily account for all the ways in + // which data can be read from a stream, e.g. being consumed on the + // native layer directly as a StreamBase. + if (!stream || !stream.readable) { + continue; + } + stream.resume(); + } +} + +function onSpawnNT(self) { + self.emit("spawn"); +} + +// TODO: Deps for getValidStdio(): +// stdioStringToArray +// Pipe +// ERR_IPC_ONE_PIPE +// PipeConstants.SOCKET +// inspect +// getHandleWrapType + +// function getValidStdio(stdio, sync) { +// let ipc; +// let ipcFd; + +// // Replace shortcut with an array +// if (typeof stdio === "string") { +// stdio = stdioStringToArray(stdio); +// } else if (!ArrayIsArray(stdio)) { +// throw new ERR_INVALID_ARG_VALUE("stdio", stdio); +// } + +// // At least 3 stdio will be created +// // Don't concat() a new Array() because it would be sparse, and +// // stdio.reduce() would skip the sparse elements of stdio. +// // See https://stackoverflow.com/a/5501711/3561 +// while (stdio.length < 3) ArrayPrototypePush(stdio, undefined); + +// // Translate stdio into C++-readable form +// // (i.e. PipeWraps or fds) +// stdio = ArrayPrototypeReduce( +// stdio, +// (acc, stdio, i) => { +// function cleanup() { +// for (let i = 0; i < acc.length; i++) { +// if ( +// (acc[i].type === "pipe" || acc[i].type === "ipc") && +// acc[i].handle +// ) +// acc[i].handle.close(); +// } +// } + +// // Defaults +// if (stdio == null) { +// stdio = i < 3 ? "pipe" : "ignore"; +// } + +// if (stdio === "ignore") { +// ArrayPrototypePush(acc, { type: "ignore" }); +// } else if ( +// stdio === "pipe" || +// stdio === "overlapped" || +// (typeof stdio === "number" && stdio < 0) +// ) { +// const a = { +// type: stdio === "overlapped" ? "overlapped" : "pipe", +// readable: i === 0, +// writable: i !== 0, +// }; + +// if (!sync) a.handle = new Pipe(PipeConstants.SOCKET); + +// ArrayPrototypePush(acc, a); +// } else if (stdio === "ipc") { +// if (sync || ipc !== undefined) { +// // Cleanup previously created pipes +// cleanup(); +// if (!sync) throw new ERR_IPC_ONE_PIPE(); +// else throw new ERR_IPC_SYNC_FORK(); +// } + +// ipc = new Pipe(PipeConstants.IPC); +// ipcFd = i; + +// ArrayPrototypePush(acc, { +// type: "pipe", +// handle: ipc, +// ipc: true, +// }); +// } else if (stdio === "inherit") { +// ArrayPrototypePush(acc, { +// type: "inherit", +// fd: i, +// }); +// } else if (typeof stdio === "number" || typeof stdio.fd === "number") { +// ArrayPrototypePush(acc, { +// type: "fd", +// fd: typeof stdio === "number" ? stdio : stdio.fd, +// }); +// } else if ( +// getHandleWrapType(stdio) || +// getHandleWrapType(stdio.handle) || +// getHandleWrapType(stdio._handle) +// ) { +// const handle = getHandleWrapType(stdio) +// ? stdio +// : getHandleWrapType(stdio.handle) +// ? stdio.handle +// : stdio._handle; + +// ArrayPrototypePush(acc, { +// type: "wrap", +// wrapType: getHandleWrapType(handle), +// handle: handle, +// _stdio: stdio, +// }); +// } else if (isArrayBufferView(stdio) || typeof stdio === "string") { +// if (!sync) { +// cleanup(); +// throw new ERR_INVALID_SYNC_FORK_INPUT(inspect(stdio)); +// } +// } else { +// // Cleanup +// cleanup(); +// throw new ERR_INVALID_ARG_VALUE("stdio", stdio); +// } + +// return acc; +// }, +// [] +// ); + +// return { stdio, ipc, ipcFd }; +// } + +function stdioStringToArray(stdio, channel) { + const options = []; + + switch (stdio) { + case "ignore": + case "overlapped": + case "pipe": + ArrayPrototypePush(options, stdio, stdio, stdio); + break; + case "inherit": + ArrayPrototypePush(options, 0, 1, 2); + break; + default: + throw new ERR_INVALID_ARG_VALUE("stdio", stdio); + } + + if (channel) ArrayPrototypePush(options, channel); + + return options; +} + +function getHandleWrapType(stream) { + if (stream instanceof Pipe) return "pipe"; + if (stream instanceof TTY) return "tty"; + if (stream instanceof TCP) return "tcp"; + if (stream instanceof UDP) return "udp"; + + return false; +} + +function maybeClose(subprocess) { + subprocess._closesGot++; + + if (subprocess._closesGot === subprocess._closesNeeded) { + subprocess.emit("close", subprocess.exitCode, subprocess.signalCode); + } +} + +function abortChildProcess(child, killSignal) { + if (!child) return; + try { + if (child.kill(killSignal)) { + child.emit("error", new AbortError()); + } + } catch (err) { + child.emit("error", err); + } +} + +//------------------------------------------------------------------------------ +// Section 5. Validators +//------------------------------------------------------------------------------ + +function validateBoolean(value, name) { + if (typeof value !== "boolean") + throw new ERR_INVALID_ARG_TYPE(name, "boolean", value); +} + +/** + * @callback validateOneOf + * @template T + * @param {T} value + * @param {string} name + * @param {T[]} oneOf + */ + +/** @type {validateOneOf} */ +const validateOneOf = (value, name, oneOf) => { + // const validateOneOf = hideStackFrames((value, name, oneOf) => { + if (!ArrayPrototypeIncludes(oneOf, value)) { + const allowed = ArrayPrototypeJoin( + ArrayPrototypeMap(oneOf, (v) => + typeof v === "string" ? `'${v}'` : String(v) + ), + ", " + ); + const reason = "must be one of: " + allowed; + throw new ERR_INVALID_ARG_VALUE(name, value, reason); + } +}; + +/** + * @callback validateObject + * @param {*} value + * @param {string} name + * @param {{ + * allowArray?: boolean, + * allowFunction?: boolean, + * nullable?: boolean + * }} [options] + */ + +/** @type {validateObject} */ +const validateObject = (value, name, options = null) => { + // const validateObject = hideStackFrames((value, name, options = null) => { + const allowArray = getOwnPropertyValueOrDefault(options, "allowArray", false); + const allowFunction = getOwnPropertyValueOrDefault( + options, + "allowFunction", + false + ); + const nullable = getOwnPropertyValueOrDefault(options, "nullable", false); + if ( + (!nullable && value === null) || + (!allowArray && ArrayIsArray(value)) || + (typeof value !== "object" && + (!allowFunction || typeof value !== "function")) + ) { + throw new ERR_INVALID_ARG_TYPE(name, "Object", value); + } +}; + +/** + * @callback validateArray + * @param {*} value + * @param {string} name + * @param {number} [minLength] + * @returns {asserts value is any[]} + */ + +/** @type {validateArray} */ +const validateArray = (value, name, minLength = 0) => { + // const validateArray = hideStackFrames((value, name, minLength = 0) => { + if (!ArrayIsArray(value)) { + throw new ERR_INVALID_ARG_TYPE(name, "Array", value); + } + if (value.length < minLength) { + const reason = `must be longer than ${minLength}`; + throw new ERR_INVALID_ARG_VALUE(name, value, reason); + } +}; + +/** + * @callback validateString + * @param {*} value + * @param {string} name + * @returns {asserts value is string} + */ + +/** @type {validateString} */ +function validateString(value, name) { + if (typeof value !== "string") + throw new ERR_INVALID_ARG_TYPE(name, "string", value); +} + +/** + * @param {?object} options + * @param {string} key + * @param {boolean} defaultValue + * @returns {boolean} + */ +function getOwnPropertyValueOrDefault(options, key, defaultValue) { + return options == null || !ObjectPrototypeHasOwnProperty(options, key) + ? defaultValue + : options[key]; +} + +function ObjectPrototypeHasOwnProperty(obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop); +} + +function ArrayPrototypePush(array, ...items) { + return array.push(...items); +} + +function ArrayPrototypeReduce(array, callback, initialValue) { + return array.reduce(callback, initialValue); +} + +function ArrayPrototypeJoin(array, separator) { + return array.join(separator); +} + +function ArrayPrototypeMap(array, callback) { + return array.map(callback); +} + +function ArrayPrototypeSlice(array, begin, end) { + return array.slice(begin, end); +} + +function ArrayPrototypeFilter(array, callback) { + return array.filter(callback); +} + +function ArrayPrototypeIncludes(array, searchElement, fromIndex) { + return array.includes(searchElement, fromIndex); +} + +function ArrayIsArray(arg) { + return Array.isArray(arg); +} + +function isArrayBufferView(value) { + return value instanceof ArrayBufferView; +} + +//------------------------------------------------------------------------------ +// 6. Node error polyfills +//------------------------------------------------------------------------------ + +function ERR_INVALID_ARG_TYPE(name, type, value) { + return new Error( + `The argument '${name}' is invalid. Received '${value}' for type '${type}'` + ); +} + +function ERR_INVALID_ARG_VALUE(name, value, reason) { + return new Error( + `The value '${value}' is invalid for argument '${name}'. Reason: ${reason}` + ); +} + +// TODO: Add actual proper error implementation here +function errnoException(err, name) { + return new Error(`Error: ${name}. Internal error: ${err.message}`); +} + +//------------------------------------------------------------------------------ +// 7. Node stream polyfills +//------------------------------------------------------------------------------ +Readable.prototype.on = function (event, listener) { + EventEmitter.prototype.on.call(this, event, listener); + if (event === "data") { + this._readableState.flowing = true; + this._read(); + } +}; +/** + * @param {ReadableStream} readableStream + * @param {{ + * highWaterMark? : number, + * encoding? : string, + * objectMode? : boolean, + * signal? : AbortSignal, + * }} [options] + * @returns {Readable} + */ +export function newStreamReadableFromReadableStream( + readableStream, + options = {} +) { + if (!isReadableStream(readableStream)) { + throw new ERR_INVALID_ARG_TYPE( + "readableStream", + "ReadableStream", + readableStream + ); + } + + validateObject(options, "options"); + const { highWaterMark, encoding, objectMode = false, signal } = options; + + if (encoding !== undefined && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE(encoding, "options.encoding"); + validateBoolean(objectMode, "options.objectMode"); + + const reader = readableStream.getReader(); + + let closed = false; + + const readable = new Readable({ + objectMode, + highWaterMark, + encoding, + signal, + + read() { + reader + .read() + .then((chunk) => { + if (chunk.done) { + // Value should always be undefined here. + readable.push(null); + } else { + readable.push(chunk.value); + } + }) + .catch((error) => destroy(readable, error)); + }, + + destroy(error, callback) { + function done() { + try { + callback(error); + } catch (error) { + // In a next tick because this is happening within + // a promise context, and if there are any errors + // thrown we don't want those to cause an unhandled + // rejection. Let's just escape the promise and + // handle it separately. + process.nextTick(() => { + throw error; + }); + } + } + + if (!closed) { + reader.cancel(error).then(done).catch(done); + return; + } + done(); + }, + }); + + reader.closed + .then(() => { + closed = true; + }) + .catch((error) => { + closed = true; + destroy(readable, error); + }); + + return readable; +} + +function isReadableStream(value) { + return value instanceof ReadableStream; +} + +export default { + ChildProcess, + spawn, + + [Symbol.for("CommonJS")]: 0, +}; diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts new file mode 100644 index 00000000000000..cd443b3de26f63 --- /dev/null +++ b/test/bun.js/child_process.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "bun:test"; +import { ChildProcess, spawn } from "node:child_process"; + +describe("ChildProcess.spawn()", () => { + it("should emit `spawn` on spawn", async () => { + const proc = new ChildProcess(); + const result = await new Promise((resolve) => { + proc.on("spawn", () => { + resolve(true); + }); + proc.spawn({ file: "bun", args: ["-v"] }); + }); + expect(result).toBe(true); + }); + + it("should emit `exit` when killed", async () => { + const proc = new ChildProcess(); + const result = await new Promise((resolve) => { + proc.on("exit", () => { + resolve(true); + }); + + proc.spawn({ file: "bun", args: ["-v"] }); + proc.kill(); + }); + expect(result).toBe(true); + }); +}); + +describe("spawn()", () => { + it("should spawn a process", () => { + const child = spawn("echo", ["hello"]); + expect(!!child).toBe(true); + }); + + it("should allow array syntax", () => { + const child = spawn(["echo", "hello"]); + expect(!!child).toBe(true); + }); + + it("should disallow invalid filename", () => { + let child; + try { + child = spawn(123); + } catch (e) { + console.error(e); + } + expect(!!child).toBe(false); + }); + + it("should allow stdout to be read via Node stream.Readable API", async () => { + const child = spawn("bun", ["-v"]); + const result: string = await new Promise((resolve) => { + child.stdout.on("error", (e) => { + console.error(e); + }); + child.stdout.on("data", (data) => { + console.log(`stdout: ${data}`); + resolve(data); + }); + child.stderr.on("data", (data) => { + console.log(`stderr: ${data}`); + }); + }); + expect( + // Semver regex: https://gist.github.com/jhorsman/62eeea161a13b80e39f5249281e17c39?permalink_comment_id=2896416#gistcomment-2896416 + // Not 100% accurate, but good enough for this test + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[a-zA-Z\d][-a-zA-Z.\d]*)?(\+[a-zA-Z\d][-a-zA-Z.\d]*)?$/.test( + result.trim() + ) + ).toBe(true); + }); +}); From 50d9ce8878ba7ca1b4ce7029d32a358f233f222a Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Sun, 30 Oct 2022 17:43:01 -0500 Subject: [PATCH 02/15] fix(child_process): remove invalid single arg array syntax (thanks Copilot) --- src/bun.js/child_process.exports.js | 17 +++++++++-------- test/bun.js/child_process.test.ts | 8 +++----- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index 2d784ee16967a0..7eadf6a3cd7705 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -129,21 +129,22 @@ export function spawn(file, args, options) { //------------------------------------------------------------------------------ function normalizeSpawnArguments(file, args, options) { - if (file == null) { - throw new ERR_INVALID_ARG_TYPE("file", "string", file); - } + if (file.length === 0) + throw new ERR_INVALID_ARG_VALUE("file", file, "cannot be empty"); - if (Array.isArray(file)) { - options = args; - args = file; - file = args[0]; + if (ArrayIsArray(args)) { + args = ArrayPrototypeSlice(args); } else if (args == null) { args = []; - } else if (!Array.isArray(args)) { + } else if (typeof args !== "object") { + throw new ERR_INVALID_ARG_TYPE("args", "object", args); + } else { options = args; args = []; } + // validateArgumentsNullCheck(args, "args"); + if (options == null) { options = {}; } else if (typeof options !== "object") { diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index cd443b3de26f63..cf066fd9924f83 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -33,19 +33,17 @@ describe("spawn()", () => { expect(!!child).toBe(true); }); - it("should allow array syntax", () => { - const child = spawn(["echo", "hello"]); - expect(!!child).toBe(true); - }); - it("should disallow invalid filename", () => { let child; + let child2; try { child = spawn(123); + child2 = spawn(["echo", "hello"]); } catch (e) { console.error(e); } expect(!!child).toBe(false); + expect(!!child2).toBe(false); }); it("should allow stdout to be read via Node stream.Readable API", async () => { From 55d7957970f5ef599911bdd5c15cdc0eb17c57f3 Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Mon, 31 Oct 2022 22:49:23 -0500 Subject: [PATCH 03/15] refactor(child_process): unhack Readable.on, move stuff into node:stream --- src/bun.js/child_process.exports.js | 166 +++++++++--------------- src/bun.js/streams.exports.js | 188 +++++++++++++++++++++++++++- test/bun.js/child_process.test.ts | 34 +++-- 3 files changed, 269 insertions(+), 119 deletions(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index 7eadf6a3cd7705..baedc57f1613ff 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -8,9 +8,12 @@ const { Readable } = import.meta.require("node:stream"); // 4. ChildProcess helpers // 5. Validators // 6. Node error polyfills -// 7. Node stream polyfills // TODO: +// Add stdin support +// Add timeout support +// Add encoding support +// Check if Bun.spawn launches a shell // IPC support // Add more tests // Implement various stdio options @@ -18,6 +21,12 @@ const { Readable } = import.meta.require("node:stream"); // Make sure flushStdio is working // Finish normalizing spawn args +// From node child_process docs(https://nodejs.org/api/child_process.html#optionsstdio): +// 'ipc': Create an IPC channel for passing messages/file descriptors between parent and child. +// A ChildProcess may have at most one IPC stdio file descriptor. Setting this option enables the subprocess.send() method. +// If the child is a Node.js process, the presence of an IPC channel will enable process.send() and process.disconnect() methods, +// as well as 'disconnect' and 'message' events within the child. + //------------------------------------------------------------------------------ // Section 1. Exported child_process functions //------------------------------------------------------------------------------ @@ -317,6 +326,10 @@ function normalizeSpawnArguments(file, args, options) { // Section 3. ChildProcess class //------------------------------------------------------------------------------ +// TODO: +// AFTER IPC +// `disconnect` event +// `message` event export class ChildProcess extends EventEmitter { constructor() { super(); @@ -345,6 +358,8 @@ export class ChildProcess extends EventEmitter { this._error = null; this._maxListeners = undefined; this._exited = false; + + this._handleOnExit = this._handleOnExit.bind(this); } _handleOnExit(exitCode, signalCode) { @@ -424,16 +439,18 @@ export class ChildProcess extends EventEmitter { stdin: "pipe", // TODO: Unhardcode stdout: "pipe", // TODO: Unhardcode stderr: "pipe", // TODO: Unhardcode - onExit: this._handleOnExit.bind(this), + onExit: this._handleOnExit, }); - // NOTE: We need these for supporting the `ChildProcess` EventEmitter-style API for pipes - // There may be a better way to do this... - this.stdout = newStreamReadableFromReadableStream(this._handle.stdout, { + + this.stdout = Readable.fromWeb(this._handle.stdout, { encoding: "utf8", }); - this.stderr = newStreamReadableFromReadableStream(this._handle.stderr, { + this.stderr = Readable.fromWeb(this._handle.stderr, { encoding: "utf8", }); + // Put the readables into flowing mode by default... + // This only means that when you connect an on('data') handler, + // that they will automatically start receiving data events // const err = this._handle.spawn(options); process.nextTick(onSpawnNT, this); @@ -515,6 +532,40 @@ export class ChildProcess extends EventEmitter { this.killed = true; this.emit("exit", null, signal); maybeClose(this); + + // const signal = + // sig === 0 + // ? sig + // : convertToValidSignal(sig === undefined ? "SIGTERM" : sig); + + // if (this._handle) { + // const err = this._handle.kill(signal); + // if (err === 0) { + // /* Success. */ + // this.killed = true; + // return true; + // } + // if (err === UV_ESRCH) { + // /* Already dead. */ + // } else if (err === UV_EINVAL || err === UV_ENOSYS) { + // /* The underlying platform doesn't support this signal. */ + // throw errnoException(err, 'kill'); + // } else { + // /* Other error, almost certainly EPERM. */ + // this.emit('error', errnoException(err, 'kill')); + // } + // } + + // /* Kill didn't succeed. */ + // return false; + } + + ref() { + if (this._handle) this._handle.ref(); + } + + unref() { + if (this._handle) this._handle.unref(); } } @@ -884,109 +935,6 @@ function errnoException(err, name) { return new Error(`Error: ${name}. Internal error: ${err.message}`); } -//------------------------------------------------------------------------------ -// 7. Node stream polyfills -//------------------------------------------------------------------------------ -Readable.prototype.on = function (event, listener) { - EventEmitter.prototype.on.call(this, event, listener); - if (event === "data") { - this._readableState.flowing = true; - this._read(); - } -}; -/** - * @param {ReadableStream} readableStream - * @param {{ - * highWaterMark? : number, - * encoding? : string, - * objectMode? : boolean, - * signal? : AbortSignal, - * }} [options] - * @returns {Readable} - */ -export function newStreamReadableFromReadableStream( - readableStream, - options = {} -) { - if (!isReadableStream(readableStream)) { - throw new ERR_INVALID_ARG_TYPE( - "readableStream", - "ReadableStream", - readableStream - ); - } - - validateObject(options, "options"); - const { highWaterMark, encoding, objectMode = false, signal } = options; - - if (encoding !== undefined && !Buffer.isEncoding(encoding)) - throw new ERR_INVALID_ARG_VALUE(encoding, "options.encoding"); - validateBoolean(objectMode, "options.objectMode"); - - const reader = readableStream.getReader(); - - let closed = false; - - const readable = new Readable({ - objectMode, - highWaterMark, - encoding, - signal, - - read() { - reader - .read() - .then((chunk) => { - if (chunk.done) { - // Value should always be undefined here. - readable.push(null); - } else { - readable.push(chunk.value); - } - }) - .catch((error) => destroy(readable, error)); - }, - - destroy(error, callback) { - function done() { - try { - callback(error); - } catch (error) { - // In a next tick because this is happening within - // a promise context, and if there are any errors - // thrown we don't want those to cause an unhandled - // rejection. Let's just escape the promise and - // handle it separately. - process.nextTick(() => { - throw error; - }); - } - } - - if (!closed) { - reader.cancel(error).then(done).catch(done); - return; - } - done(); - }, - }); - - reader.closed - .then(() => { - closed = true; - }) - .catch((error) => { - closed = true; - destroy(readable, error); - }); - - return readable; -} - -function isReadableStream(value) { - return value instanceof ReadableStream; -} - export default { ChildProcess, spawn, diff --git a/src/bun.js/streams.exports.js b/src/bun.js/streams.exports.js index 979ef32f64ce91..fdad540f4bcf87 100644 --- a/src/bun.js/streams.exports.js +++ b/src/bun.js/streams.exports.js @@ -33,6 +33,186 @@ var __copyProps = (to, from, except, desc) => { var runOnNextTick = process.nextTick; +/** + * @param {ReadableStream} readableStream + * @param {{ + * highWaterMark? : number, + * encoding? : string, + * objectMode? : boolean, + * signal? : AbortSignal, + * }} [options] + * @returns {Readable} + */ +function newStreamReadableFromReadableStream(readableStream, options = {}) { + if (!isReadableStream(readableStream)) { + throw new ERR_INVALID_ARG_TYPE( + "readableStream", + "ReadableStream", + readableStream + ); + } + + validateObject(options, "options"); + const { highWaterMark, encoding, objectMode = false, signal } = options; + + if (encoding !== undefined && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE(encoding, "options.encoding"); + validateBoolean(objectMode, "options.objectMode"); + + const reader = readableStream.getReader(); + + let closed = false; + + const readable = new Readable({ + objectMode, + highWaterMark, + encoding, + signal, + + read() { + reader + .read() + .then((chunk) => { + if (chunk.done) { + // Value should always be undefined here. + readable.push(null); + } else { + readable.push(chunk.value); + } + }) + .catch((error) => destroy(readable, error)); + }, + destroy(error, callback) { + function done() { + try { + callback(error); + } catch (error) { + // In a next tick because this is happening within + // a promise context, and if there are any errors + // thrown we don't want those to cause an unhandled + // rejection. Let's just escape the promise and + // handle it separately. + process.nextTick(() => { + throw error; + }); + } + } + + if (!closed) { + reader.cancel(error).then(done).catch(done); + return; + } + done(); + }, + // NOTE(Derrick): For whatever reason this seems to be necessary to make this work + // I couldn't find out why .constructed was getting set to false + // even though construct() was getting called + construct() { + this._readableState.constructed = true; + }, + }); + + reader.closed + .then(() => { + closed = true; + }) + .catch((error) => { + closed = true; + reader.destroy(readable, error); + }); + + return readable; +} + +function isReadableStream(value) { + return value instanceof ReadableStream; +} + +function validateBoolean(value, name) { + if (typeof value !== "boolean") + throw new ERR_INVALID_ARG_TYPE(name, "boolean", value); +} + +/** + * @callback validateObject + * @param {*} value + * @param {string} name + * @param {{ + * allowArray?: boolean, + * allowFunction?: boolean, + * nullable?: boolean + * }} [options] + */ + +/** @type {validateObject} */ +const validateObject = (value, name, options = null) => { + // const validateObject = hideStackFrames((value, name, options = null) => { + const allowArray = getOwnPropertyValueOrDefault(options, "allowArray", false); + const allowFunction = getOwnPropertyValueOrDefault( + options, + "allowFunction", + false + ); + const nullable = getOwnPropertyValueOrDefault(options, "nullable", false); + if ( + (!nullable && value === null) || + (!allowArray && ArrayIsArray(value)) || + (typeof value !== "object" && + (!allowFunction || typeof value !== "function")) + ) { + throw new ERR_INVALID_ARG_TYPE(name, "Object", value); + } +}; + +/** + * @callback validateString + * @param {*} value + * @param {string} name + * @returns {asserts value is string} + */ + +/** @type {validateString} */ +function validateString(value, name) { + if (typeof value !== "string") + throw new ERR_INVALID_ARG_TYPE(name, "string", value); +} + +/** + * @param {?object} options + * @param {string} key + * @param {boolean} defaultValue + * @returns {boolean} + */ +function getOwnPropertyValueOrDefault(options, key, defaultValue) { + return options == null || !ObjectPrototypeHasOwnProperty(options, key) + ? defaultValue + : options[key]; +} + +function ObjectPrototypeHasOwnProperty(obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop); +} + +function ArrayIsArray(arg) { + return Array.isArray(arg); +} + +//------------------------------------------------------------------------------ +// Node error polyfills +//------------------------------------------------------------------------------ + +function ERR_INVALID_ARG_TYPE(name, type, value) { + return new Error( + `The argument '${name}' is invalid. Received '${value}' for type '${type}'` + ); +} + +function ERR_INVALID_ARG_VALUE(name, value, reason) { + return new Error( + `The value '${value}' is invalid for argument '${name}'. Reason: ${reason}` + ); +} + // node_modules/readable-stream/lib/ours/primordials.js var require_primordials = __commonJS({ "node_modules/readable-stream/lib/ours/primordials.js"(exports, module) { @@ -2509,7 +2689,9 @@ var require_readable = __commonJS({ const state = this._readableState; if (ev === "data") { state.readableListening = this.listenerCount("readable") > 0; - if (state.flowing !== false) this.resume(); + if (state.flowing !== false) { + this.resume(); + } } else if (ev === "readable") { if (!state.endEmitted && !state.readableListening) { state.readableListening = state.needReadable = true; @@ -3327,7 +3509,9 @@ var require_readable = __commonJS({ Readable.from = function (iterable, opts) { return from(Readable, iterable, opts); }; - var webStreamsAdapters; + var webStreamsAdapters = { + newStreamReadableFromReadableStream, + }; function lazyWebStreams() { if (webStreamsAdapters === void 0) webStreamsAdapters = {}; return webStreamsAdapters; diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index cf066fd9924f83..f8c4678efbc978 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "bun:test"; import { ChildProcess, spawn } from "node:child_process"; +// Semver regex: https://gist.github.com/jhorsman/62eeea161a13b80e39f5249281e17c39?permalink_comment_id=2896416#gistcomment-2896416 +// Not 100% accurate, but good enough for this test +const SEMVER_REGEX = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[a-zA-Z\d][-a-zA-Z.\d]*)?(\+[a-zA-Z\d][-a-zA-Z.\d]*)?$/; + describe("ChildProcess.spawn()", () => { it("should emit `spawn` on spawn", async () => { const proc = new ChildProcess(); @@ -46,7 +51,7 @@ describe("spawn()", () => { expect(!!child2).toBe(false); }); - it("should allow stdout to be read via Node stream.Readable API", async () => { + it("should allow stdout to be read via Node stream.Readable `data` events", async () => { const child = spawn("bun", ["-v"]); const result: string = await new Promise((resolve) => { child.stdout.on("error", (e) => { @@ -60,12 +65,25 @@ describe("spawn()", () => { console.log(`stderr: ${data}`); }); }); - expect( - // Semver regex: https://gist.github.com/jhorsman/62eeea161a13b80e39f5249281e17c39?permalink_comment_id=2896416#gistcomment-2896416 - // Not 100% accurate, but good enough for this test - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[a-zA-Z\d][-a-zA-Z.\d]*)?(\+[a-zA-Z\d][-a-zA-Z.\d]*)?$/.test( - result.trim() - ) - ).toBe(true); + expect(SEMVER_REGEX.test(result.trim())).toBe(true); + }); + + it("should allow stdout to be read via .read() API", async () => { + const child = spawn("bun", ["-v"]); + const result: string = await new Promise((resolve) => { + let finalData = ""; + child.stdout.on("error", (e) => { + console.error(e); + }); + child.stdout.on("readable", () => { + let data; + + while ((data = child.stdout.read()) !== null) { + finalData += data; + } + resolve(finalData); + }); + }); + expect(SEMVER_REGEX.test(result.trim())).toBe(true); }); }); From e2f7fe4c3aaa1c8480d8f3e2424037b22ef2b641 Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Wed, 2 Nov 2022 17:20:39 -0500 Subject: [PATCH 04/15] feat(child_process): add more params for spawn, refactor, add fromWeb() to Readable --- src/bun.js/child_process.exports.js | 1018 ++++++++++++++------------- src/bun.js/streams.exports.js | 241 ++++--- test/bun.js/child_process.test.ts | 91 ++- 3 files changed, 772 insertions(+), 578 deletions(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index baedc57f1613ff..ce4598a5c70f63 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -1,5 +1,11 @@ const EventEmitter = import.meta.require("node:events"); const { Readable } = import.meta.require("node:stream"); +const { + constants: { signals }, +} = import.meta.require("node:os"); +const assert = import.meta.require("node:assert"); + +const debug = process.env.DEBUG ? console.log : () => {}; // Sections: // 1. Exported child_process functions @@ -7,19 +13,33 @@ const { Readable } = import.meta.require("node:stream"); // 3. ChildProcess "class" // 4. ChildProcess helpers // 5. Validators -// 6. Node error polyfills +// 6. Primordials +// 7. Random utilities +// 8. Node errors / error polyfills // TODO: -// Add stdin support -// Add timeout support -// Add encoding support -// Check if Bun.spawn launches a shell -// IPC support +// Make commit +// Port some tests for spawn +// Make commit / push +// ------------------------------ +// Make sure stdin works as expected (Writeable API) // Add more tests -// Implement various stdio options -// Finish getValidStdio -// Make sure flushStdio is working -// Finish normalizing spawn args +// Check if need to handle spawn error +// ------------------------------ +// TODO: Look at Pipe to see if we can support passing Node Pipe objects to stdio param + +// TODO: Add these params after support added in Bun.spawn +// uid Sets the user identity of the process (see setuid(2)). +// gid Sets the group identity of the process (see setgid(2)). +// detached Prepare child to run independently of its parent process. Specific behavior depends on the platform, see options.detached). + +// TODO: After IPC channels can be opened +// serialization Specify the kind of serialization used for sending messages between processes. Possible values are 'json' and 'advanced'. See Advanced serialization for more details. Default: 'json'. + +// TODO: Add support for ipc option, verify only one IPC channel in array +// stdio | Child's stdio configuration (see options.stdio). +// Support wrapped ipc types (e.g. net.Socket, dgram.Socket, TTY, etc.) +// IPC FD passing support // From node child_process docs(https://nodejs.org/api/child_process.html#optionsstdio): // 'ipc': Create an IPC channel for passing messages/file descriptors between parent and child. @@ -35,15 +55,6 @@ const { Readable } = import.meta.require("node:stream"); // * windowsVerbatimArguments?: boolean; // * windowsHide?: boolean; -// TODO: -// argv0 support -// Detached child process support -// Allow setting uid and gid -// Advanced serialization of IPC messages -// Shell support -// Abort signal -// Kill signal - // Copyright Joyent, Inc. and other Node contributors. // // Permission is hereby granted, free of charge, to any person obtaining a @@ -65,6 +76,10 @@ const { Readable } = import.meta.require("node:stream"); // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. +// TODO: Validate kill process works (don't need to await?) +// TODO: Test kill signal +// TODO: Test abort signal + /** * Spawns a new process using the given `file`. * @param {string} file @@ -87,12 +102,12 @@ const { Readable } = import.meta.require("node:stream"); */ export function spawn(file, args, options) { options = normalizeSpawnArguments(file, args, options); - // validateTimeout(options.timeout); - // validateAbortSignal(options.signal, "options.signal"); - // const killSignal = sanitizeKillSignal(options.killSignal); + validateTimeout(options.timeout); + validateAbortSignal(options.signal, "options.signal"); + const killSignal = sanitizeKillSignal(options.killSignal); const child = new ChildProcess(); - // debug('spawn', options); + debug("spawn", options); child.spawn(options); if (options.timeout > 0) { @@ -115,34 +130,72 @@ export function spawn(file, args, options) { }); } - // if (options.signal) { - // const signal = options.signal; - // if (signal.aborted) { - // process.nextTick(onAbortListener); - // } else { - // signal.addEventListener("abort", onAbortListener, { once: true }); - // child.once("exit", () => - // signal.removeEventListener("abort", onAbortListener) - // ); - // } + if (options.signal) { + const signal = options.signal; + if (signal.aborted) { + process.nextTick(onAbortListener); + } else { + signal.addEventListener("abort", onAbortListener, { once: true }); + child.once("exit", () => + signal.removeEventListener("abort", onAbortListener) + ); + } - // function onAbortListener() { - // abortChildProcess(child, killSignal); - // } - // } + function onAbortListener() { + abortChildProcess(child, killSignal); + } + } return child; } //------------------------------------------------------------------------------ // Section 2. child_process helpers //------------------------------------------------------------------------------ +let signalsToNamesMapping; +function getSignalsToNamesMapping() { + if (signalsToNamesMapping !== undefined) return signalsToNamesMapping; + + signalsToNamesMapping = ObjectCreate(null); + for (const key in signals) { + signalsToNamesMapping[signals[key]] = key; + } + + return signalsToNamesMapping; +} + +function convertToValidSignal(signal) { + if (typeof signal === "number" && getSignalsToNamesMapping()[signal]) + return signal; + + if (typeof signal === "string") { + const signalName = signals[StringPrototypeToUpperCase.call(signal)]; + if (signalName) return signalName; + } + + throw new ERR_UNKNOWN_SIGNAL(signal); +} + +function sanitizeKillSignal(killSignal) { + if (typeof killSignal === "string" || typeof killSignal === "number") { + return convertToValidSignal(killSignal); + } else if (killSignal != null) { + throw new ERR_INVALID_ARG_TYPE( + "options.killSignal", + ["string", "number"], + killSignal + ); + } +} function normalizeSpawnArguments(file, args, options) { + validateString(file, "file"); + validateArgumentNullCheck(file, "file"); + if (file.length === 0) throw new ERR_INVALID_ARG_VALUE("file", file, "cannot be empty"); if (ArrayIsArray(args)) { - args = ArrayPrototypeSlice(args); + args = ArrayPrototypeSlice.call(args); } else if (args == null) { args = []; } else if (typeof args !== "object") { @@ -152,218 +205,130 @@ function normalizeSpawnArguments(file, args, options) { args = []; } - // validateArgumentsNullCheck(args, "args"); - - if (options == null) { - options = {}; - } else if (typeof options !== "object") { - throw new ERR_INVALID_ARG_TYPE("options", "Object", options); - } - - validateString(file, "file"); - validateArray(args, "args"); - validateObject(options, "options"); - - return { ...options, file, args }; -} + validateArgumentsNullCheck(args, "args"); -// function normalizeSpawnArguments(file, args, options) { -// validateString(file, "file"); -// validateArgumentNullCheck(file, "file"); + if (options === undefined) options = {}; + else validateObject(options, "options"); -// if (file.length === 0) -// throw new ERR_INVALID_ARG_VALUE("file", file, "cannot be empty"); + let cwd = options.cwd; -// if (ArrayIsArray(args)) { -// args = ArrayPrototypeSlice(args); -// } else if (args == null) { -// args = []; -// } else if (typeof args !== "object") { -// throw new ERR_INVALID_ARG_TYPE("args", "object", args); -// } else { -// options = args; -// args = []; -// } - -// validateArgumentsNullCheck(args, "args"); - -// if (options === undefined) options = kEmptyObject; -// else validateObject(options, "options"); - -// let cwd = options.cwd; - -// // Validate the cwd, if present. -// if (cwd != null) { -// cwd = getValidatedPath(cwd, "options.cwd"); -// } - -// // Validate detached, if present. -// if (options.detached != null) { -// validateBoolean(options.detached, "options.detached"); -// } - -// // Validate the uid, if present. -// if (options.uid != null && !isInt32(options.uid)) { -// throw new ERR_INVALID_ARG_TYPE("options.uid", "int32", options.uid); -// } + // Validate the cwd, if present. + if (cwd != null) { + cwd = getValidatedPath(cwd, "options.cwd"); + } -// // Validate the gid, if present. -// if (options.gid != null && !isInt32(options.gid)) { -// throw new ERR_INVALID_ARG_TYPE("options.gid", "int32", options.gid); -// } + // TODO: Detached check + // TODO: Gid check + // TODO: Uid check -// // Validate the shell, if present. -// if ( -// options.shell != null && -// typeof options.shell !== "boolean" && -// typeof options.shell !== "string" -// ) { -// throw new ERR_INVALID_ARG_TYPE( -// "options.shell", -// ["boolean", "string"], -// options.shell -// ); -// } + // Validate the shell, if present. + if ( + options.shell != null && + typeof options.shell !== "boolean" && + typeof options.shell !== "string" + ) { + throw new ERR_INVALID_ARG_TYPE( + "options.shell", + ["boolean", "string"], + options.shell + ); + } -// // Validate argv0, if present. -// if (options.argv0 != null) { -// validateString(options.argv0, "options.argv0"); -// validateArgumentNullCheck(options.argv0, "options.argv0"); -// } + // Validate argv0, if present. + if (options.argv0 != null) { + validateString(options.argv0, "options.argv0"); + validateArgumentNullCheck(options.argv0, "options.argv0"); + } -// // // Validate windowsHide, if present. -// // if (options.windowsHide != null) { -// // validateBoolean(options.windowsHide, "options.windowsHide"); -// // } - -// // // Validate windowsVerbatimArguments, if present. -// // let { windowsVerbatimArguments } = options; -// // if (windowsVerbatimArguments != null) { -// // validateBoolean( -// // windowsVerbatimArguments, -// // "options.windowsVerbatimArguments" -// // ); -// // } - -// if (options.shell) { -// validateArgumentNullCheck(options.shell, "options.shell"); -// const command = ArrayPrototypeJoin([file, ...args], " "); -// // Set the shell, switches, and commands. -// if (process.platform === "win32") { -// if (typeof options.shell === "string") file = options.shell; -// else file = process.env.comspec || "cmd.exe"; -// // '/d /s /c' is used only for cmd.exe. -// if (RegExpPrototypeExec(/^(?:.*\\)?cmd(?:\.exe)?$/i, file) !== null) { -// args = ["/d", "/s", "/c", `"${command}"`]; -// windowsVerbatimArguments = true; -// } else { -// args = ["-c", command]; -// } -// } else { -// if (typeof options.shell === "string") file = options.shell; -// else if (process.platform === "android") file = "/system/bin/sh"; -// else file = "/bin/sh"; -// args = ["-c", command]; -// } -// } + // TODO: Windows checks for Windows specific options + + // Handle shell + if (options.shell) { + validateArgumentNullCheck(options.shell, "options.shell"); + const command = ArrayPrototypeJoin.call([file, ...args], " "); + // TODO: Windows moment + // Set the shell, switches, and commands. + // if (process.platform === "win32") { + // if (typeof options.shell === "string") file = options.shell; + // else file = process.env.comspec || "cmd.exe"; + // // '/d /s /c' is used only for cmd.exe. + // if (RegExpPrototypeExec(/^(?:.*\\)?cmd(?:\.exe)?$/i, file) !== null) { + // args = ["/d", "/s", "/c", `"${command}"`]; + // windowsVerbatimArguments = true; + // } else { + // args = ["-c", command]; + // } + // } else { + if (typeof options.shell === "string") file = options.shell; + else if (process.platform === "android") file = "sh"; + else file = "sh"; + args = ["-c", command]; + // } + } -// if (typeof options.argv0 === "string") { -// ArrayPrototypeUnshift(args, options.argv0); -// } else { -// ArrayPrototypeUnshift(args, file); -// } + // Handle argv0 + if (typeof options.argv0 === "string") { + ArrayPrototypeUnshift.call(args, options.argv0); + } else { + ArrayPrototypeUnshift.call(args, file); + } -// const env = options.env || process.env; -// const envPairs = []; + const env = options.env || process.env; + const envPairs = []; -// // process.env.NODE_V8_COVERAGE always propagates, making it possible to -// // collect coverage for programs that spawn with white-listed environment. -// copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env); + // // process.env.NODE_V8_COVERAGE always propagates, making it possible to + // // collect coverage for programs that spawn with white-listed environment. + // copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env); -// let envKeys = []; -// // Prototype values are intentionally included. -// for (const key in env) { -// ArrayPrototypePush(envKeys, key); -// } + let envKeys = []; + // Prototype values are intentionally included. + for (const key in env) { + ArrayPrototypePush.call(envKeys, key); + } -// if (process.platform === "win32") { -// // On Windows env keys are case insensitive. Filter out duplicates, -// // keeping only the first one (in lexicographic order) -// const sawKey = new SafeSet(); -// envKeys = ArrayPrototypeFilter(ArrayPrototypeSort(envKeys), (key) => { -// const uppercaseKey = StringPrototypeToUpperCase(key); -// if (sawKey.has(uppercaseKey)) { -// return false; -// } -// sawKey.add(uppercaseKey); -// return true; -// }); -// } + // TODO: Windows env support here... -// for (const key of envKeys) { -// const value = env[key]; -// if (value !== undefined) { -// validateArgumentNullCheck(key, `options.env['${key}']`); -// validateArgumentNullCheck(value, `options.env['${key}']`); -// ArrayPrototypePush(envPairs, `${key}=${value}`); -// } -// } + for (const key of envKeys) { + const value = env[key]; + if (value !== undefined) { + validateArgumentNullCheck(key, `options.env['${key}']`); + validateArgumentNullCheck(value, `options.env['${key}']`); + ArrayPrototypePush.call(envPairs, `${key}=${value}`); + } + } -// return { -// // Make a shallow copy so we don't clobber the user's options object. -// ...options, -// args, -// cwd, -// detached: !!options.detached, -// envPairs, -// file, -// // windowsHide: !!options.windowsHide, -// // windowsVerbatimArguments: !!windowsVerbatimArguments, -// }; -// } + return { ...options, file, args, cwd, envPairs }; +} //------------------------------------------------------------------------------ // Section 3. ChildProcess class //------------------------------------------------------------------------------ - -// TODO: -// AFTER IPC -// `disconnect` event -// `message` event export class ChildProcess extends EventEmitter { - constructor() { - super(); - this._closesNeeded = 0; - this._closesGot = 0; - this.connected = false; - this.signalCode = null; - this.exitCode = null; - this.killed = false; - this.spawnfile = undefined; - this.spawnargs = undefined; - this.pid = undefined; - this.stdin = undefined; - this.stdout = undefined; - this.stderr = undefined; - this.stdio = undefined; - this.channel = undefined; - this._handle = undefined; - this._handleQueue = undefined; - this._pendingMessage = undefined; - this._pendingHandle = undefined; - this._channel = undefined; - this._serialization = undefined; - this._eventsCount = undefined; - this._events = undefined; - this._error = null; - this._maxListeners = undefined; - this._exited = false; - - this._handleOnExit = this._handleOnExit.bind(this); - } - - _handleOnExit(exitCode, signalCode) { - if (this._exited) return; + #exited = false; + #handle = undefined; + #closesNeeded = 0; + #closesGot = 0; + + connected = false; + signalCode = null; + exitCode = null; + killed = false; + spawnfile = undefined; + spawnargs = undefined; + pid = undefined; + stdin = undefined; + stdout = undefined; + stderr = undefined; + stdio = undefined; + channel = undefined; + + // constructor(options) { + // super(options); + // this.#handle[owner_symbol] = this; + // } + + #handleOnExit(exitCode, signalCode) { + if (this.#exited) return; if (signalCode) { this.signalCode = signalCode; } else { @@ -373,8 +338,8 @@ export class ChildProcess extends EventEmitter { if (this.stdin) { this.stdin.destroy(); } - if (this._handle) { - this._handle = null; + if (this.#handle) { + this.#handle = null; } if (exitCode < 0) { @@ -383,7 +348,7 @@ export class ChildProcess extends EventEmitter { if (this.spawnfile) err.path = this.spawnfile; - err.spawnargs = ArrayPrototypeSlice(this.spawnargs, 1); + err.spawnargs = ArrayPrototypeSlice.call(this.spawnargs, 1); this.emit("error", err); } else { this.emit("exit", this.exitCode, this.signalCode); @@ -397,10 +362,9 @@ export class ChildProcess extends EventEmitter { // start reading the data once the process exits. process.nextTick(flushStdio, this); - maybeClose(this); - this._exited = true; + this.#maybeClose(); + this.#exited = true; } - // this._handle[owner_symbol] = this; spawn(options) { validateObject(options, "options"); @@ -417,8 +381,8 @@ export class ChildProcess extends EventEmitter { // if (options.envPairs === undefined) options.envPairs = []; // else validateArray(options.envPairs, "options.envPairs"); - // ArrayPrototypePush(options.envPairs, `NODE_CHANNEL_FD=${ipcFd}`); - // ArrayPrototypePush( + // ArrayPrototypePush.call(options.envPairs, `NODE_CHANNEL_FD=${ipcFd}`); + // ArrayPrototypePush.call( // options.envPairs, // `NODE_CHANNEL_SERIALIZATION_MODE=${serialization}` // ); @@ -434,27 +398,39 @@ export class ChildProcess extends EventEmitter { this.spawnargs = options.args; } - this._handle = Bun.spawn({ - cmd: [this.spawnfile, ...this.spawnargs], - stdin: "pipe", // TODO: Unhardcode - stdout: "pipe", // TODO: Unhardcode - stderr: "pipe", // TODO: Unhardcode - onExit: this._handleOnExit, - }); + const stdio = options.stdio || "pipe"; + const bunStdio = getBunStdioOptions(stdio); - this.stdout = Readable.fromWeb(this._handle.stdout, { - encoding: "utf8", - }); - this.stderr = Readable.fromWeb(this._handle.stderr, { - encoding: "utf8", + console.log(this.spawnargs); + + this.#handle = Bun.spawn({ + cmd: [...this.spawnargs], + stdin: bunStdio[0], + stdout: bunStdio[1], + stderr: bunStdio[2], + cwd: options.cwd || undefined, + env: options.envPairs || undefined, + onExit: this.#handleOnExit.bind(this), }); - // Put the readables into flowing mode by default... - // This only means that when you connect an on('data') handler, - // that they will automatically start receiving data events - // const err = this._handle.spawn(options); + + // TODO: Handle stdin + this.stdin = bunStdio[0] ? undefined : null; + + this.stdout = bunStdio[1] + ? Readable.fromWeb(this.#handle.stdout, { + encoding: "utf8", + }) + : null; + + this.stderr = bunStdio[2] + ? Readable.fromWeb(this.#handle.stderr, { + encoding: "utf8", + }) + : null; + process.nextTick(onSpawnNT, this); - this.pid = this._handle.pid; + this.pid = this.#handle.pid; // If no `stdio` option was given - use default // let stdio = options.stdio || "pipe"; // TODO: reset default @@ -513,7 +489,7 @@ export class ChildProcess extends EventEmitter { // this.stdio = []; // for (i = 0; i < stdio.length; i++) - // ArrayPrototypePush( + // ArrayPrototypePush.call( // this.stdio, // stdio[i].socket === undefined ? null : stdio[i].socket // ); @@ -522,56 +498,117 @@ export class ChildProcess extends EventEmitter { // if (ipc !== undefined) setupChannel(this, ipc, serialization); } - kill(signal) { - if (this.killed) return; + kill(sig) { + const signal = + sig === 0 + ? sig + : convertToValidSignal(sig === undefined ? "SIGTERM" : sig); - if (this._handle) { - this._handle.kill(signal); + if (this.#handle) { + this.#handle.kill(signal); } this.killed = true; this.emit("exit", null, signal); - maybeClose(this); - - // const signal = - // sig === 0 - // ? sig - // : convertToValidSignal(sig === undefined ? "SIGTERM" : sig); - - // if (this._handle) { - // const err = this._handle.kill(signal); - // if (err === 0) { - // /* Success. */ - // this.killed = true; - // return true; - // } - // if (err === UV_ESRCH) { - // /* Already dead. */ - // } else if (err === UV_EINVAL || err === UV_ENOSYS) { - // /* The underlying platform doesn't support this signal. */ - // throw errnoException(err, 'kill'); - // } else { - // /* Other error, almost certainly EPERM. */ - // this.emit('error', errnoException(err, 'kill')); - // } - // } + this.#maybeClose(); - // /* Kill didn't succeed. */ - // return false; + return this.#handle.killed; + } + + #maybeClose() { + this.#closesGot++; + + if (this.#closesGot === this.#closesNeeded) { + this.emit("close", this.exitCode, this.signalCode); + } } ref() { - if (this._handle) this._handle.ref(); + if (this.#handle) this.#handle.ref(); } unref() { - if (this._handle) this._handle.unref(); + if (this.#handle) this.#handle.unref(); } } //------------------------------------------------------------------------------ // Section 4. ChildProcess helpers //------------------------------------------------------------------------------ +const nodeToBunLookup = { + ignore: null, + pipe: "pipe", + overlapped: "pipe", // TODO: this may need to work differently for Windows + inherit: "inherit", +}; + +function nodeToBun(item) { + // If inherit and we are referencing stdin/stdout/stderr index, + // we can get the fd from the ReadStream for the corresponding stdio + if (typeof item === "number") { + return item; + } else { + const result = nodeToBunLookup[item]; + if (result === undefined) throw new Error("Invalid stdio option"); + return result; + } +} + +function getBunStdioOptions(stdio) { + const normalizedStdio = normalizeStdio(stdio); + // Node options: + // pipe: just a pipe + // ipc = can only be one in array + // overlapped -- same as pipe on Unix based systems + // inherit -- 'inherit': equivalent to ['inherit', 'inherit', 'inherit'] or [0, 1, 2] + // ignore -- > /dev/null, more or less same as null option for Bun.spawn stdio + // TODO: Stream -- use this stream + // number -- used as FD + // null, undefined: Use default value. Not same as ignore, which is Bun.spawn null. + // null/undefined: For stdio fds 0, 1, and 2 (in other words, stdin, stdout, and stderr) a pipe is created. For fd 3 and up, the default is 'ignore' + + // Important Bun options + // pipe + // fd + // null - no stdin/stdout/stderr + + // Translations: node -> bun + // pipe -> pipe + // overlapped -> pipe + // ignore -> null + // inherit -> inherit (stdin/stdout/stderr) + // Stream -> throw err for now + + return normalizedStdio.map((item) => nodeToBun(item)); +} + +function normalizeStdio(stdio) { + if (typeof stdio === "string") { + switch (stdio) { + case "ignore": + return ["ignore", "ignore", "ignore"]; + case "pipe": + return ["pipe", "pipe", "pipe"]; + case "inherit": + return ["inherit", "inherit", "inherit"]; + default: + throw new ERR_INVALID_OPT_VALUE("stdio", stdio); + } + } else if (ArrayIsArray(stdio)) { + // Validate if each is a valid stdio type + // TODO: Support wrapped types here + + let processedStdio; + if (stdio.length === 0) processedStdio = ["pipe", "pipe", "pipe"]; + else if (stdio.length === 1) processedStdio = [stdio[0], "pipe", "pipe"]; + else if (stdio.length === 2) processedStdio = [stdio[0], stdio[1], "pipe"]; + else if (stdio.length >= 3) processedStdio = [stdio[0], stdio[1], stdio[2]]; + + return processedStdio.map((item) => (!item ? "pipe" : item)); + } else { + throw new ERR_INVALID_OPT_VALUE("stdio", stdio); + } +} function flushStdio(subprocess) { const stdio = subprocess.stdio; @@ -594,167 +631,6 @@ function onSpawnNT(self) { self.emit("spawn"); } -// TODO: Deps for getValidStdio(): -// stdioStringToArray -// Pipe -// ERR_IPC_ONE_PIPE -// PipeConstants.SOCKET -// inspect -// getHandleWrapType - -// function getValidStdio(stdio, sync) { -// let ipc; -// let ipcFd; - -// // Replace shortcut with an array -// if (typeof stdio === "string") { -// stdio = stdioStringToArray(stdio); -// } else if (!ArrayIsArray(stdio)) { -// throw new ERR_INVALID_ARG_VALUE("stdio", stdio); -// } - -// // At least 3 stdio will be created -// // Don't concat() a new Array() because it would be sparse, and -// // stdio.reduce() would skip the sparse elements of stdio. -// // See https://stackoverflow.com/a/5501711/3561 -// while (stdio.length < 3) ArrayPrototypePush(stdio, undefined); - -// // Translate stdio into C++-readable form -// // (i.e. PipeWraps or fds) -// stdio = ArrayPrototypeReduce( -// stdio, -// (acc, stdio, i) => { -// function cleanup() { -// for (let i = 0; i < acc.length; i++) { -// if ( -// (acc[i].type === "pipe" || acc[i].type === "ipc") && -// acc[i].handle -// ) -// acc[i].handle.close(); -// } -// } - -// // Defaults -// if (stdio == null) { -// stdio = i < 3 ? "pipe" : "ignore"; -// } - -// if (stdio === "ignore") { -// ArrayPrototypePush(acc, { type: "ignore" }); -// } else if ( -// stdio === "pipe" || -// stdio === "overlapped" || -// (typeof stdio === "number" && stdio < 0) -// ) { -// const a = { -// type: stdio === "overlapped" ? "overlapped" : "pipe", -// readable: i === 0, -// writable: i !== 0, -// }; - -// if (!sync) a.handle = new Pipe(PipeConstants.SOCKET); - -// ArrayPrototypePush(acc, a); -// } else if (stdio === "ipc") { -// if (sync || ipc !== undefined) { -// // Cleanup previously created pipes -// cleanup(); -// if (!sync) throw new ERR_IPC_ONE_PIPE(); -// else throw new ERR_IPC_SYNC_FORK(); -// } - -// ipc = new Pipe(PipeConstants.IPC); -// ipcFd = i; - -// ArrayPrototypePush(acc, { -// type: "pipe", -// handle: ipc, -// ipc: true, -// }); -// } else if (stdio === "inherit") { -// ArrayPrototypePush(acc, { -// type: "inherit", -// fd: i, -// }); -// } else if (typeof stdio === "number" || typeof stdio.fd === "number") { -// ArrayPrototypePush(acc, { -// type: "fd", -// fd: typeof stdio === "number" ? stdio : stdio.fd, -// }); -// } else if ( -// getHandleWrapType(stdio) || -// getHandleWrapType(stdio.handle) || -// getHandleWrapType(stdio._handle) -// ) { -// const handle = getHandleWrapType(stdio) -// ? stdio -// : getHandleWrapType(stdio.handle) -// ? stdio.handle -// : stdio._handle; - -// ArrayPrototypePush(acc, { -// type: "wrap", -// wrapType: getHandleWrapType(handle), -// handle: handle, -// _stdio: stdio, -// }); -// } else if (isArrayBufferView(stdio) || typeof stdio === "string") { -// if (!sync) { -// cleanup(); -// throw new ERR_INVALID_SYNC_FORK_INPUT(inspect(stdio)); -// } -// } else { -// // Cleanup -// cleanup(); -// throw new ERR_INVALID_ARG_VALUE("stdio", stdio); -// } - -// return acc; -// }, -// [] -// ); - -// return { stdio, ipc, ipcFd }; -// } - -function stdioStringToArray(stdio, channel) { - const options = []; - - switch (stdio) { - case "ignore": - case "overlapped": - case "pipe": - ArrayPrototypePush(options, stdio, stdio, stdio); - break; - case "inherit": - ArrayPrototypePush(options, 0, 1, 2); - break; - default: - throw new ERR_INVALID_ARG_VALUE("stdio", stdio); - } - - if (channel) ArrayPrototypePush(options, channel); - - return options; -} - -function getHandleWrapType(stream) { - if (stream instanceof Pipe) return "pipe"; - if (stream instanceof TTY) return "tty"; - if (stream instanceof TCP) return "tcp"; - if (stream instanceof UDP) return "udp"; - - return false; -} - -function maybeClose(subprocess) { - subprocess._closesGot++; - - if (subprocess._closesGot === subprocess._closesNeeded) { - subprocess.emit("close", subprocess.exitCode, subprocess.signalCode); - } -} - function abortChildProcess(child, killSignal) { if (!child) return; try { @@ -770,11 +646,49 @@ function abortChildProcess(child, killSignal) { // Section 5. Validators //------------------------------------------------------------------------------ +function validateArgumentNullCheck(arg, propName) { + if (typeof arg === "string" && StringPrototypeIncludes.call(arg, "\u0000")) { + throw new ERR_INVALID_ARG_VALUE( + propName, + arg, + "must be a string without null bytes" + ); + } +} + +function validateArgumentsNullCheck(args, propName) { + for (let i = 0; i < args.length; ++i) { + validateArgumentNullCheck(args[i], `${propName}[${i}]`); + } +} + +function validateTimeout(timeout) { + if (timeout != null && !(NumberIsInteger(timeout) && timeout >= 0)) { + throw new ERR_OUT_OF_RANGE("timeout", "an unsigned integer", timeout); + } +} + function validateBoolean(value, name) { if (typeof value !== "boolean") throw new ERR_INVALID_ARG_TYPE(name, "boolean", value); } +/** + * @callback validateAbortSignal + * @param {*} signal + * @param {string} name + */ + +/** @type {validateAbortSignal} */ +const validateAbortSignal = (signal, name) => { + if ( + signal !== undefined && + (signal === null || typeof signal !== "object" || !("aborted" in signal)) + ) { + throw new ERR_INVALID_ARG_TYPE(name, "AbortSignal", signal); + } +}; + /** * @callback validateOneOf * @template T @@ -786,9 +700,9 @@ function validateBoolean(value, name) { /** @type {validateOneOf} */ const validateOneOf = (value, name, oneOf) => { // const validateOneOf = hideStackFrames((value, name, oneOf) => { - if (!ArrayPrototypeIncludes(oneOf, value)) { - const allowed = ArrayPrototypeJoin( - ArrayPrototypeMap(oneOf, (v) => + if (!ArrayPrototypeIncludes.call(oneOf, value)) { + const allowed = ArrayPrototypeJoin.call( + ArrayPrototypeMap.call(oneOf, (v) => typeof v === "string" ? `'${v}'` : String(v) ), ", " @@ -821,7 +735,7 @@ const validateObject = (value, name, options = null) => { const nullable = getOwnPropertyValueOrDefault(options, "nullable", false); if ( (!nullable && value === null) || - (!allowArray && ArrayIsArray(value)) || + (!allowArray && ArrayIsArray.call(value)) || (typeof value !== "object" && (!allowFunction || typeof value !== "function")) ) { @@ -869,61 +783,209 @@ function validateString(value, name) { * @returns {boolean} */ function getOwnPropertyValueOrDefault(options, key, defaultValue) { - return options == null || !ObjectPrototypeHasOwnProperty(options, key) + return options == null || !ObjectPrototypeHasOwnProperty.call(options, key) ? defaultValue : options[key]; } -function ObjectPrototypeHasOwnProperty(obj, prop) { - return Object.prototype.hasOwnProperty.call(obj, prop); -} +function nullCheck(path, propName, throwError = true) { + const pathIsString = typeof path === "string"; + const pathIsUint8Array = isUint8Array(path); + + // We can only perform meaningful checks on strings and Uint8Arrays. + if ( + (!pathIsString && !pathIsUint8Array) || + (pathIsString && !StringPrototypeIncludes.call(path, "\u0000")) || + (pathIsUint8Array && !Uint8ArrayPrototypeIncludes.call(path, 0)) + ) { + return; + } -function ArrayPrototypePush(array, ...items) { - return array.push(...items); + const err = new ERR_INVALID_ARG_VALUE( + propName, + path, + "must be a string or Uint8Array without null bytes" + ); + if (throwError) { + throw err; + } + return err; } -function ArrayPrototypeReduce(array, callback, initialValue) { - return array.reduce(callback, initialValue); +function validatePath(path, propName = "path") { + if (typeof path !== "string" && !isUint8Array(path)) { + throw new ERR_INVALID_ARG_TYPE(propName, ["string", "Buffer", "URL"], path); + } + + const err = nullCheck(path, propName, false); + + if (err !== undefined) { + throw err; + } } -function ArrayPrototypeJoin(array, separator) { - return array.join(separator); +function getValidatedPath(fileURLOrPath, propName = "path") { + const path = toPathIfFileURL(fileURLOrPath); + validatePath(path, propName); + return path; } -function ArrayPrototypeMap(array, callback) { - return array.map(callback); +//------------------------------------------------------------------------------ +// Section 6. Primordials +//------------------------------------------------------------------------------ +var ArrayBufferView = globalThis.ArrayBufferView; +var Uint8Array = globalThis.Uint8Array; +var String = globalThis.String; +var Object = globalThis.Object; + +var ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +var ObjectCreate = Object.create; + +var ArrayPrototypePush = Array.prototype.push; +var ArrayPrototypeReduce = Array.prototype.reduce; +var ArrayPrototypeFilter = Array.prototype.filter; +var ArrayPrototypeJoin = Array.prototype.join; +var ArrayPrototypeMap = Array.prototype.map; +var ArrayPrototypeIncludes = Array.prototype.includes; +var ArrayPrototypeSlice = Array.prototype.slice; +var ArrayPrototypeUnshift = Array.prototype.unshift; +var ArrayIsArray = Array.isArray; + +var NumberIsInteger = Number.isInteger; +var MathAbs = Math.abs; + +var StringPrototypeToUpperCase = String.prototype.toUpperCase; +var StringPrototypeIncludes = String.prototype.includes; +var Uint8ArrayPrototypeIncludes = Uint8Array.prototype.includes; + +function isArrayBufferView(value) { + return ( + typeof value === "object" && + value !== null && + value instanceof ArrayBufferView + ); } -function ArrayPrototypeSlice(array, begin, end) { - return array.slice(begin, end); +function isUint8Array(value) { + return ( + typeof value === "object" && value !== null && value instanceof Uint8Array + ); } -function ArrayPrototypeFilter(array, callback) { - return array.filter(callback); +//------------------------------------------------------------------------------ +// Section 7. Random utilities +//------------------------------------------------------------------------------ + +function isURLInstance(fileURLOrPath) { + return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin; } -function ArrayPrototypeIncludes(array, searchElement, fromIndex) { - return array.includes(searchElement, fromIndex); +function getPathFromURLPosix(url) { + if (url.hostname !== "") { + throw new ERR_INVALID_FILE_URL_HOST(platform); + } + const pathname = url.pathname; + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] === "%") { + const third = pathname.codePointAt(n + 2) | 0x20; + if (pathname[n + 1] === "2" && third === 102) { + throw new ERR_INVALID_FILE_URL_PATH( + "must not include encoded / characters" + ); + } + } + } + return decodeURIComponent(pathname); } -function ArrayIsArray(arg) { - return Array.isArray(arg); +function fileURLToPath(path) { + if (typeof path === "string") path = new URL(path); + else if (!isURLInstance(path)) + throw new ERR_INVALID_ARG_TYPE("path", ["string", "URL"], path); + if (path.protocol !== "file:") throw new ERR_INVALID_URL_SCHEME("file"); + // TODO: Windows support + // return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path); + return getPathFromURLPosix(path); } -function isArrayBufferView(value) { - return value instanceof ArrayBufferView; +function toPathIfFileURL(fileURLOrPath) { + if (!isURLInstance(fileURLOrPath)) return fileURLOrPath; + return fileURLToPath(fileURLOrPath); } //------------------------------------------------------------------------------ -// 6. Node error polyfills +// Section 8. Node errors / error polyfills //------------------------------------------------------------------------------ +var Error = globalThis.Error; +var TypeError = globalThis.TypeError; +var RangeError = globalThis.RangeError; +var AbortError = globalThis.AbortError; + +console.log(AbortError); + +// class AbortError extends Error { +// name = "AbortError"; +// code = "ABORT_ERR"; +// constructor(message) { +// super(message || "The process was aborted"); +// } +// } + +function ERR_OUT_OF_RANGE(str, range, input, replaceDefaultBoolean = false) { + // Node implementation: + // assert(range, 'Missing "range" argument'); + // let msg = replaceDefaultBoolean + // ? str + // : `The value of "${str}" is out of range.`; + // let received; + // if (NumberIsInteger(input) && MathAbs(input) > 2 ** 32) { + // received = addNumericalSeparator(String(input)); + // } else if (typeof input === "bigint") { + // received = String(input); + // if (input > 2n ** 32n || input < -(2n ** 32n)) { + // received = addNumericalSeparator(received); + // } + // received += "n"; + // } else { + // received = lazyInternalUtilInspect().inspect(input); + // } + // msg += ` It must be ${range}. Received ${received}`; + // return new RangeError(msg); + return new RangeError( + `The value of ${str} is out of range. It must be ${range}. Received ${input}` + ); +} + +function ERR_UNKNOWN_SIGNAL(name) { + return new TypeError(`Unknown signal: ${name}`); +} function ERR_INVALID_ARG_TYPE(name, type, value) { - return new Error( + return new TypeError( `The argument '${name}' is invalid. Received '${value}' for type '${type}'` ); } +function ERR_INVALID_FILE_URL_HOST(platform) { + return new TypeError( + `File URL host must be "localhost" or empty on ${platform}` + ); +} + +function ERR_INVALID_FILE_URL_PATH(path) { + return new TypeError(`File URL path: ${path}`); +} + +function ERR_INVALID_URL_SCHEME(expected) { + if (typeof expected === "string") expected = [expected]; + assert(expected.length <= 2); + const res = + expected.length === 2 + ? `one of scheme ${expected[0]} or ${expected[1]}` + : `of scheme ${expected[0]}`; + return `The URL must be ${res}`; +} + function ERR_INVALID_ARG_VALUE(name, value, reason) { return new Error( `The value '${value}' is invalid for argument '${name}'. Reason: ${reason}` diff --git a/src/bun.js/streams.exports.js b/src/bun.js/streams.exports.js index fdad540f4bcf87..cbd2fc35c121c4 100644 --- a/src/bun.js/streams.exports.js +++ b/src/bun.js/streams.exports.js @@ -33,99 +33,20 @@ var __copyProps = (to, from, except, desc) => { var runOnNextTick = process.nextTick; -/** - * @param {ReadableStream} readableStream - * @param {{ - * highWaterMark? : number, - * encoding? : string, - * objectMode? : boolean, - * signal? : AbortSignal, - * }} [options] - * @returns {Readable} - */ -function newStreamReadableFromReadableStream(readableStream, options = {}) { - if (!isReadableStream(readableStream)) { - throw new ERR_INVALID_ARG_TYPE( - "readableStream", - "ReadableStream", - readableStream - ); - } - - validateObject(options, "options"); - const { highWaterMark, encoding, objectMode = false, signal } = options; - - if (encoding !== undefined && !Buffer.isEncoding(encoding)) - throw new ERR_INVALID_ARG_VALUE(encoding, "options.encoding"); - validateBoolean(objectMode, "options.objectMode"); - - const reader = readableStream.getReader(); - - let closed = false; - - const readable = new Readable({ - objectMode, - highWaterMark, - encoding, - signal, - - read() { - reader - .read() - .then((chunk) => { - if (chunk.done) { - // Value should always be undefined here. - readable.push(null); - } else { - readable.push(chunk.value); - } - }) - .catch((error) => destroy(readable, error)); - }, - destroy(error, callback) { - function done() { - try { - callback(error); - } catch (error) { - // In a next tick because this is happening within - // a promise context, and if there are any errors - // thrown we don't want those to cause an unhandled - // rejection. Let's just escape the promise and - // handle it separately. - process.nextTick(() => { - throw error; - }); - } - } - - if (!closed) { - reader.cancel(error).then(done).catch(done); - return; - } - done(); - }, - // NOTE(Derrick): For whatever reason this seems to be necessary to make this work - // I couldn't find out why .constructed was getting set to false - // even though construct() was getting called - construct() { - this._readableState.constructed = true; - }, - }); - - reader.closed - .then(() => { - closed = true; - }) - .catch((error) => { - closed = true; - reader.destroy(readable, error); - }); - - return readable; +function isPromise(toTest) { + return ( + Boolean(toTest) && + (typeof toTest === "object" || typeof toTest === "function") && + typeof toTest.then === "function" + ); } function isReadableStream(value) { - return value instanceof ReadableStream; + return ( + typeof value === "object" && + value !== null && + value instanceof ReadableStream + ); } function validateBoolean(value, name) { @@ -184,18 +105,13 @@ function validateString(value, name) { * @returns {boolean} */ function getOwnPropertyValueOrDefault(options, key, defaultValue) { - return options == null || !ObjectPrototypeHasOwnProperty(options, key) + return options == null || !ObjectPrototypeHasOwnProperty.call(options, key) ? defaultValue : options[key]; } -function ObjectPrototypeHasOwnProperty(obj, prop) { - return Object.prototype.hasOwnProperty.call(obj, prop); -} - -function ArrayIsArray(arg) { - return Array.isArray(arg); -} +var ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +var ArrayIsArray = Array.isArray; //------------------------------------------------------------------------------ // Node error polyfills @@ -2710,6 +2626,135 @@ var require_readable = __commonJS({ static ReadableState = ReadableState; } + + class ReadableFromWeb extends Readable { + #reader; + + constructor(options) { + const { objectMode, highWaterMark, encoding, signal, reader } = options; + let closed = false; + super({ + objectMode, + highWaterMark, + encoding, + signal, + + async read() { + var deferredError; + try { + var done, value; + const firstResult = reader.readMany(); + + if (isPromise(firstResult)) { + const result = await firstResult; + done = result.done; + value = result.value; + } else { + done = firstResult.done; + value = firstResult.value; + } + + if (done) { + this.push(null); + return; + } + + if (!value) + throw new Error( + `Invalid value from ReadableStream reader: ${value}` + ); + if (ArrayIsArray(value)) { + this.push(...value); + } else { + this.push(value); + } + } catch (e) { + deferredError = e; + } finally { + if (deferredError) throw deferredError; + } + }, + destroy(error, callback) { + function done() { + try { + callback(error); + } catch (error) { + // In a next tick because this is happening within + // a promise context, and if there are any errors + // thrown we don't want those to cause an unhandled + // rejection. Let's just escape the promise and + // handle it separately. + process.nextTick(() => { + throw error; + }); + } + } + + if (!closed) { + reader.releaseLock(); + reader.cancel(error).then(done).catch(done); + return; + } + done(); + }, + // NOTE(Derrick): For whatever reason this seems to be necessary to make this work + // I couldn't find out why .constructed was getting set to false + // even though construct() was getting called + construct() { + this._readableState.constructed = true; + }, + }); + + this.#reader = reader; + this.#reader.closed + .then(() => { + closed = true; + }) + .catch((error) => { + closed = true; + destroy(this, error); + }); + } + } + + /** + * @param {ReadableStream} readableStream + * @param {{ + * highWaterMark? : number, + * encoding? : string, + * objectMode? : boolean, + * signal? : AbortSignal, + * }} [options] + * @returns {Readable} + */ + function newStreamReadableFromReadableStream(readableStream, options = {}) { + if (!isReadableStream(readableStream)) { + throw new ERR_INVALID_ARG_TYPE( + "readableStream", + "ReadableStream", + readableStream + ); + } + + validateObject(options, "options"); + const { highWaterMark, encoding, objectMode = false, signal } = options; + + if (encoding !== undefined && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE(encoding, "options.encoding"); + validateBoolean(objectMode, "options.objectMode"); + + const reader = readableStream.getReader(); + const readable = new ReadableFromWeb({ + highWaterMark, + encoding, + objectMode, + signal, + reader, + }); + + return readable; + } + module.exports = Readable; var { addAbortSignal } = require_add_abort_signal(); diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index f8c4678efbc978..e9aef3ae22a371 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "bun:test"; import { ChildProcess, spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { compileSchemaCPP } from "../../node_modules/peechy/peechy"; // Semver regex: https://gist.github.com/jhorsman/62eeea161a13b80e39f5249281e17c39?permalink_comment_id=2896416#gistcomment-2896416 // Not 100% accurate, but good enough for this test @@ -13,7 +15,7 @@ describe("ChildProcess.spawn()", () => { proc.on("spawn", () => { resolve(true); }); - proc.spawn({ file: "bun", args: ["-v"] }); + proc.spawn({ file: "bun", args: ["bun", "-v"] }); }); expect(result).toBe(true); }); @@ -25,7 +27,7 @@ describe("ChildProcess.spawn()", () => { resolve(true); }); - proc.spawn({ file: "bun", args: ["-v"] }); + proc.spawn({ file: "bun", args: ["bun", "-v"] }); proc.kill(); }); expect(result).toBe(true); @@ -86,4 +88,89 @@ describe("spawn()", () => { }); expect(SEMVER_REGEX.test(result.trim())).toBe(true); }); + + it("should accept stdio option with 'ignore' for no stdio fds", async () => { + const child1 = spawn("bun", ["-v"], { + stdio: "ignore", + }); + const child2 = spawn("bun", ["-v"], { + stdio: ["ignore", "ignore", "ignore"], + }); + + expect(!!child1).toBe(true); + expect(child1.stdin).toBe(null); + expect(child1.stdout).toBe(null); + expect(child1.stderr).toBe(null); + + expect(!!child2).toBe(true); + expect(child2.stdin).toBe(null); + expect(child2.stdout).toBe(null); + expect(child2.stderr).toBe(null); + }); + + it("should allow us to set cwd", async () => { + const PRIVATE_DIR = "/private"; + const child = spawn("pwd", { cwd: "/tmp" }); + const result: string = await new Promise((resolve) => { + child.stdout.on("data", (data) => { + resolve(data); + }); + }); + expect(result.trim()).toBe(`${PRIVATE_DIR}/tmp`); + }); + + it("should allow us to timeout hanging processes", async () => { + const child = spawn("sleep", ["750"], { timeout: 250 }); + const start = performance.now(); + let end; + await new Promise((resolve) => { + child.on("exit", () => { + end = performance.now(); + resolve(0); + }); + }); + expect(end - start < 750).toBe(true); + }); + + it("should allow us to set env", async () => { + const child = spawn("env", { env: { TEST: "test" } }); + const result: string = await new Promise((resolve) => { + child.stdout.on("data", (data) => { + resolve(data); + }); + }); + expect(/TEST\=test/.test(result)).toBe(true); + }); + + it("should allow explicit setting of argv0", async () => { + const child = spawn("node", ["--help"], { argv0: "bun" }); + const result: string = await new Promise((resolve) => { + let msg; + child.stdout.on("data", (data) => { + msg += data; + }); + + child.stdout.on("close", () => { + resolve(msg); + }); + }); + expect(/bun:/.test(result)).toBe(true); + }); + + it("should allow us to spawn in a shell", async () => { + const child1 = spawn("echo", ["$0"], { shell: true }); + const child2 = spawn("echo", ["$0"], { shell: "bash" }); + const result1: string = await new Promise((resolve) => { + child1.stdout.on("data", (data) => { + resolve(data); + }); + }); + const result2: string = await new Promise((resolve) => { + child2.stdout.on("data", (data) => { + resolve(data); + }); + }); + expect(result1.trim()).toBe("/bin/sh"); + expect(result2.trim()).toBe("/bin/bash"); + }); }); From d6af33ff419dc579248a01b8b7fa163648050d54 Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 00:37:58 -0500 Subject: [PATCH 05/15] feat(child_process): finish rest of exports (minus fork), refactor, add tests --- src/bun.js/child_process.exports.js | 900 +++++++++++++++++++++---- src/bun.js/streams.exports.js | 129 ++-- test/bun.js/child_process-node.test.js | 550 +++++++++++++++ test/bun.js/child_process.test.ts | 142 +++- 4 files changed, 1497 insertions(+), 224 deletions(-) create mode 100644 test/bun.js/child_process-node.test.js diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index ce4598a5c70f63..b331c74d334577 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -1,10 +1,12 @@ const EventEmitter = import.meta.require("node:events"); -const { Readable } = import.meta.require("node:stream"); +const { + Readable: { fromWeb: ReadableFromWeb }, +} = import.meta.require("node:stream"); const { constants: { signals }, } = import.meta.require("node:os"); -const assert = import.meta.require("node:assert"); +const MAX_BUFFER = 1024 * 1024; const debug = process.env.DEBUG ? console.log : () => {}; // Sections: @@ -18,13 +20,11 @@ const debug = process.env.DEBUG ? console.log : () => {}; // 8. Node errors / error polyfills // TODO: -// Make commit -// Port some tests for spawn -// Make commit / push +// Port rest of node tests +// Fix exit codes with Bun.spawn // ------------------------------ -// Make sure stdin works as expected (Writeable API) -// Add more tests -// Check if need to handle spawn error +// Fix errors +// Support file descriptors being passed in for stdio // ------------------------------ // TODO: Look at Pipe to see if we can support passing Node Pipe objects to stdio param @@ -76,10 +76,6 @@ const debug = process.env.DEBUG ? console.log : () => {}; // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. -// TODO: Validate kill process works (don't need to await?) -// TODO: Test kill signal -// TODO: Test abort signal - /** * Spawns a new process using the given `file`. * @param {string} file @@ -148,21 +144,435 @@ export function spawn(file, args, options) { return child; } -//------------------------------------------------------------------------------ -// Section 2. child_process helpers -//------------------------------------------------------------------------------ -let signalsToNamesMapping; -function getSignalsToNamesMapping() { - if (signalsToNamesMapping !== undefined) return signalsToNamesMapping; +/** + * Spawns the specified file as a shell. + * @param {string} file + * @param {string[]} [args] + * @param {{ + * cwd?: string; + * env?: Record; + * encoding?: string; + * timeout?: number; + * maxBuffer?: number; + * killSignal?: string | number; + * uid?: number; + * gid?: number; + * windowsHide?: boolean; + * windowsVerbatimArguments?: boolean; + * shell?: boolean | string; + * signal?: AbortSignal; + * }} [options] + * @param {( + * error?: Error, + * stdout?: string | Buffer, + * stderr?: string | Buffer + * ) => any} [callback] + * @returns {ChildProcess} + */ +export function execFile(file, args, options, callback) { + ({ file, args, options, callback } = normalizeExecFileArgs( + file, + args, + options, + callback + )); + + options = { + encoding: "utf8", + timeout: 0, + maxBuffer: MAX_BUFFER, + killSignal: "SIGTERM", + cwd: null, + env: null, + shell: false, + ...options, + }; + + // Validate the timeout, if present. + validateTimeout(options.timeout); - signalsToNamesMapping = ObjectCreate(null); - for (const key in signals) { - signalsToNamesMapping[signals[key]] = key; + // Validate maxBuffer, if present. + validateMaxBuffer(options.maxBuffer); + + options.killSignal = sanitizeKillSignal(options.killSignal); + + const child = spawn(file, args, { + cwd: options.cwd, + env: options.env, + // gid: options.gid, + shell: options.shell, + signal: options.signal, + // uid: options.uid, + }); + + let encoding; + const _stdout = []; + const _stderr = []; + if (options.encoding !== "buffer" && BufferIsEncoding(options.encoding)) { + encoding = options.encoding; + } else { + encoding = null; } + let stdoutLen = 0; + let stderrLen = 0; + let killed = false; + let exited = false; + let timeoutId; - return signalsToNamesMapping; + let ex = null; + + let cmd = file; + + function exitHandler(code, signal) { + if (exited) return; + exited = true; + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + if (!callback) return; + + // merge chunks + let stdout; + let stderr; + if (encoding || (child.stdout && child.stdout.readableEncoding)) { + stdout = ArrayPrototypeJoin.call(_stdout, ""); + } else { + stdout = BufferConcat(_stdout); + } + if (encoding || (child.stderr && child.stderr.readableEncoding)) { + stderr = ArrayPrototypeJoin.call(_stderr, ""); + } else { + stderr = BufferConcat(_stderr); + } + + // TODO: Make this check code === 0 when Bun.spawn fixes exit code issue + if (!ex && code >= 0 && signal === null) { + callback(null, stdout, stderr); + return; + } + + if (args?.length) cmd += ` ${ArrayPrototypeJoin.call(args, " ")}`; + + if (!ex) { + ex = genericNodeError(`Command failed: ${cmd}\n${stderr}`, { + // code: code < 0 ? getSystemErrorName(code) : code, // TODO: Add getSystemErrorName + code: code, + killed: child.killed || killed, + signal: signal, + }); + } + + ex.cmd = cmd; + callback(ex, stdout, stderr); + } + + function errorHandler(e) { + ex = e; + + if (child.stdout) child.stdout.destroy(); + if (child.stderr) child.stderr.destroy(); + + exitHandler(); + } + + function kill() { + if (child.stdout) child.stdout.destroy(); + if (child.stderr) child.stderr.destroy(); + + killed = true; + try { + child.kill(options.killSignal); + } catch (e) { + ex = e; + exitHandler(); + } + } + + if (options.timeout > 0) { + timeoutId = setTimeout(function delayedKill() { + kill(); + timeoutId = null; + }, options.timeout); + } + + if (child.stdout) { + if (encoding) child.stdout.setEncoding(encoding); + + child.stdout.on("data", function onChildStdout(chunk) { + // Do not need to count the length + if (options.maxBuffer === Infinity) { + ArrayPrototypePush.call(_stdout, chunk); + return; + } + const encoding = child.stdout.readableEncoding; + const length = encoding + ? Buffer.byteLength(chunk, encoding) + : chunk.length; + const slice = encoding + ? (buf, ...args) => StringPrototypeSlice.call(buf, ...args) + : (buf, ...args) => buf.slice(...args); + stdoutLen += length; + + if (stdoutLen > options.maxBuffer) { + const truncatedLen = options.maxBuffer - (stdoutLen - length); + ArrayPrototypePush.call(_stdout, slice(chunk, 0, truncatedLen)); + + ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stdout"); + kill(); + } else { + ArrayPrototypePush.call(_stdout, chunk); + } + }); + } + + if (child.stderr) { + if (encoding) child.stderr.setEncoding(encoding); + + child.stderr.on("data", function onChildStderr(chunk) { + // Do not need to count the length + if (options.maxBuffer === Infinity) { + ArrayPrototypePush.call(_stderr, chunk); + return; + } + const encoding = child.stderr.readableEncoding; + const length = encoding + ? Buffer.byteLength(chunk, encoding) + : chunk.length; + stderrLen += length; + + if (stderrLen > options.maxBuffer) { + const truncatedLen = options.maxBuffer - (stderrLen - length); + ArrayPrototypePush.call(_stderr, chunk.slice(0, truncatedLen)); + + ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stderr"); + kill(); + } else { + ArrayPrototypePush.call(_stderr, chunk); + } + }); + } + + child.addListener("close", exitHandler); + child.addListener("error", errorHandler); + + return child; +} + +/** + * Spawns a shell executing the given command. + * @param {string} command + * @param {{ + * cmd?: string; + * env?: Record; + * encoding?: string; + * shell?: string; + * signal?: AbortSignal; + * timeout?: number; + * maxBuffer?: number; + * killSignal?: string | number; + * uid?: number; + * gid?: number; + * windowsHide?: boolean; + * }} [options] + * @param {( + * error?: Error, + * stdout?: string | Buffer, + * stderr?: string | Buffer + * ) => any} [callback] + * @returns {ChildProcess} + */ +export function exec(command, options, callback) { + const opts = normalizeExecArgs(command, options, callback); + return execFile(opts.file, opts.options, opts.callback); +} + +/** + * Spawns a new process synchronously using the given `file`. + * @param {string} file + * @param {string[]} [args] + * @param {{ + * cwd?: string; + * input?: string | Buffer | TypedArray | DataView; + * argv0?: string; + * stdio?: string | Array; + * env?: Record; + * uid?: number; + * gid?: number; + * timeout?: number; + * killSignal?: string | number; + * maxBuffer?: number; + * encoding?: string; + * shell?: boolean | string; + * }} [options] + * @returns {{ + * pid: number; + * output: Array; + * stdout: Buffer | string; + * stderr: Buffer | string; + * status: number | null; + * signal: string | null; + * error: Error; + * }} + */ +export function spawnSync(file, args, options) { + options = { + maxBuffer: MAX_BUFFER, + ...normalizeSpawnArguments(file, args, options), + }; + + debug("spawnSync", options); + + // Validate the timeout, if present. + validateTimeout(options.timeout); + + // Validate maxBuffer, if present. + validateMaxBuffer(options.maxBuffer); + + // Validate and translate the kill signal, if present. + options.killSignal = sanitizeKillSignal(options.killSignal); + + // options.stdio = getValidStdio(options.stdio || "pipe", true).stdio; + // if (options.input) { + // const stdin = (options.stdio[0] = { ...options.stdio[0] }); + // stdin.input = options.input; + // } + // // We may want to pass data in on any given fd, ensure it is a valid buffer + // for (let i = 0; i < options.stdio.length; i++) { + // const input = options.stdio[i] && options.stdio[i].input; + // if (input != null) { + // const pipe = (options.stdio[i] = { ...options.stdio[i] }); + // if (isArrayBufferView(input)) { + // pipe.input = input; + // } else if (typeof input === "string") { + // pipe.input = Buffer.from(input, options.encoding); + // } else { + // throw new ERR_INVALID_ARG_TYPE( + // `options.stdio[${i}]`, + // ["Buffer", "TypedArray", "DataView", "string"], + // input + // ); + // } + // } + // } + + const stdio = options.stdio || "pipe"; + const bunStdio = getBunStdioOptions(stdio); + const { stdout, stderr, success, exitCode } = Bun.spawnSync({ + cmd: options.args, + env: options.env || undefined, + cwd: options.cwd || undefined, + stdin: bunStdio[0], + stdout: bunStdio[1], + stderr: bunStdio[2], + }); + + const result = { + signal: null, + status: exitCode, + output: [null, stdout, stderr], + }; + + if (stdout && options.encoding && options.encoding !== "buffer") { + result.output[1] = result.output[1]?.toString(options.encoding); + } + + if (stderr && options.encoding && options.encoding !== "buffer") { + result.output[2] = result.output[2]?.toString(options.encoding); + } + + result.stdout = result.output[1]; + result.stderr = result.output[2]; + + if (!success) { + result.error = errnoException(result.stderr, "spawnSync " + options.file); + result.error.path = options.file; + result.error.spawnargs = ArrayPrototypeSlice.call(options.args, 1); + } + + return result; +} + +/** + * Spawns a file as a shell synchronously. + * @param {string} file + * @param {string[]} [args] + * @param {{ + * cwd?: string; + * input?: string | Buffer | TypedArray | DataView; + * stdio?: string | Array; + * env?: Record; + * uid?: number; + * gid?: number; + * timeout?: number; + * killSignal?: string | number; + * maxBuffer?: number; + * encoding?: string; + * windowsHide?: boolean; + * shell?: boolean | string; + * }} [options] + * @returns {Buffer | string} + */ +export function execFileSync(file, args, options) { + ({ file, args, options } = normalizeExecFileArgs(file, args, options)); + + const inheritStderr = !options.stdio; + const ret = spawnSync(file, args, options); + + if (inheritStderr && ret.stderr) process.stderr.write(ret.stderr); + + const errArgs = [options.argv0 || file]; + ArrayPrototypePush.apply(errArgs, args); + const err = checkExecSyncError(ret, errArgs); + + if (err) throw err; + + return ret.stdout; +} + +/** + * Spawns a shell executing the given `command` synchronously. + * @param {string} command + * @param {{ + * cwd?: string; + * input?: string | Buffer | TypedArray | DataView; + * stdio?: string | Array; + * env?: Record; + * shell?: string; + * uid?: number; + * gid?: number; + * timeout?: number; + * killSignal?: string | number; + * maxBuffer?: number; + * encoding?: string; + * windowsHide?: boolean; + * }} [options] + * @returns {Buffer | string} + */ +export function execSync(command, options) { + const opts = normalizeExecArgs(command, options, null); + const inheritStderr = !opts.options.stdio; + + const ret = spawnSync(opts.file, opts.options); + + if (inheritStderr && ret.stderr) process.stderr.write(ret.stderr); + + const err = checkExecSyncError(ret, undefined, command); + + if (err) throw err; + + return ret.stdout; } +export function fork() { + throw new Error("Not implemented"); +} + +//------------------------------------------------------------------------------ +// Section 2. child_process helpers +//------------------------------------------------------------------------------ function convertToValidSignal(signal) { if (typeof signal === "number" && getSignalsToNamesMapping()[signal]) return signal; @@ -187,6 +597,78 @@ function sanitizeKillSignal(killSignal) { } } +let signalsToNamesMapping; +function getSignalsToNamesMapping() { + if (signalsToNamesMapping !== undefined) return signalsToNamesMapping; + + signalsToNamesMapping = ObjectCreate(null); + for (const key in signals) { + signalsToNamesMapping[signals[key]] = key; + } + + return signalsToNamesMapping; +} + +function normalizeExecFileArgs(file, args, options, callback) { + if (ArrayIsArray(args)) { + args = ArrayPrototypeSlice.call(args); + } else if (args != null && typeof args === "object") { + callback = options; + options = args; + args = null; + } else if (typeof args === "function") { + callback = args; + options = null; + args = null; + } + + if (args == null) { + args = []; + } + + if (typeof options === "function") { + callback = options; + } else if (options != null) { + validateObject(options, "options"); + } + + if (options == null) { + options = {}; + } + + if (callback != null) { + validateFunction(callback, "callback"); + } + + // Validate argv0, if present. + if (options.argv0 != null) { + validateString(options.argv0, "options.argv0"); + validateArgumentNullCheck(options.argv0, "options.argv0"); + } + + return { file, args, options, callback }; +} + +function normalizeExecArgs(command, options, callback) { + validateString(command, "command"); + validateArgumentNullCheck(command, "command"); + + if (typeof options === "function") { + callback = options; + options = undefined; + } + + // Make a shallow copy so we don't clobber the user's options object. + options = { ...options }; + options.shell = typeof options.shell === "string" ? options.shell : true; + + return { + file: command, + options: options, + callback: callback, + }; +} + function normalizeSpawnArguments(file, args, options) { validateString(file, "file"); validateArgumentNullCheck(file, "file"); @@ -274,53 +756,53 @@ function normalizeSpawnArguments(file, args, options) { } const env = options.env || process.env; - const envPairs = []; + const envPairs = env; // // process.env.NODE_V8_COVERAGE always propagates, making it possible to // // collect coverage for programs that spawn with white-listed environment. // copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env); - let envKeys = []; - // Prototype values are intentionally included. - for (const key in env) { - ArrayPrototypePush.call(envKeys, key); - } - // TODO: Windows env support here... - for (const key of envKeys) { - const value = env[key]; - if (value !== undefined) { - validateArgumentNullCheck(key, `options.env['${key}']`); - validateArgumentNullCheck(value, `options.env['${key}']`); - ArrayPrototypePush.call(envPairs, `${key}=${value}`); - } - } - return { ...options, file, args, cwd, envPairs }; } +function checkExecSyncError(ret, args, cmd) { + let err; + if (ret.error) { + err = ret.error; + ObjectAssign(err, ret); + } else if (ret.status !== 0) { + let msg = "Command failed: "; + msg += cmd || ArrayPrototypeJoin.call(args, " "); + if (ret.stderr && ret.stderr.length > 0) + msg += `\n${ret.stderr.toString()}`; + err = genericNodeError(msg, ret); + } + return err; +} + //------------------------------------------------------------------------------ // Section 3. ChildProcess class //------------------------------------------------------------------------------ export class ChildProcess extends EventEmitter { + #handle; #exited = false; - #handle = undefined; - #closesNeeded = 0; + #closesNeeded = 1; #closesGot = 0; connected = false; signalCode = null; exitCode = null; killed = false; - spawnfile = undefined; - spawnargs = undefined; - pid = undefined; - stdin = undefined; - stdout = undefined; - stderr = undefined; - stdio = undefined; - channel = undefined; + spawnfile; + spawnargs; + pid; + stdin; + stdout; + stderr; + stdio; + channel; // constructor(options) { // super(options); @@ -338,6 +820,7 @@ export class ChildProcess extends EventEmitter { if (this.stdin) { this.stdin.destroy(); } + if (this.#handle) { this.#handle = null; } @@ -401,10 +884,9 @@ export class ChildProcess extends EventEmitter { const stdio = options.stdio || "pipe"; const bunStdio = getBunStdioOptions(stdio); - console.log(this.spawnargs); - + const cmd = options.args; this.#handle = Bun.spawn({ - cmd: [...this.spawnargs], + cmd, stdin: bunStdio[0], stdout: bunStdio[1], stderr: bunStdio[2], @@ -413,18 +895,17 @@ export class ChildProcess extends EventEmitter { onExit: this.#handleOnExit.bind(this), }); - // TODO: Handle stdin - this.stdin = bunStdio[0] ? undefined : null; + this.stdin = bunStdio[0] ? new WrappedFileSink(this.#handle.stdin) : null; this.stdout = bunStdio[1] - ? Readable.fromWeb(this.#handle.stdout, { - encoding: "utf8", + ? ReadableFromWeb(this.#handle.stdout, { + encoding: options.encoding || undefined, }) : null; this.stderr = bunStdio[2] - ? Readable.fromWeb(this.#handle.stderr, { - encoding: "utf8", + ? ReadableFromWeb(this.#handle.stderr, { + encoding: options.encoding || undefined, }) : null; @@ -512,7 +993,10 @@ export class ChildProcess extends EventEmitter { this.emit("exit", null, signal); this.#maybeClose(); - return this.#handle.killed; + // TODO: Make this actually ensure the process has exited before returning + // await this.#handle.exited() + // return this.#handle.killed; + return this.killed; } #maybeClose() { @@ -642,10 +1126,41 @@ function abortChildProcess(child, killSignal) { } } +class WrappedFileSink { + #fileSink; + + constructor(fileSink) { + this.#fileSink = fileSink; + } + + write(data) { + this.#fileSink.write(data); + this.#fileSink.flush(true); + } + + destroy() { + this.#fileSink.end(); + } + + end() { + this.#fileSink.end(); + } +} + //------------------------------------------------------------------------------ // Section 5. Validators //------------------------------------------------------------------------------ +function validateMaxBuffer(maxBuffer) { + if (maxBuffer != null && !(typeof maxBuffer === "number" && maxBuffer >= 0)) { + throw new ERR_OUT_OF_RANGE( + "options.maxBuffer", + "a positive number", + maxBuffer + ); + } +} + function validateArgumentNullCheck(arg, propName) { if (typeof arg === "string" && StringPrototypeIncludes.call(arg, "\u0000")) { throw new ERR_INVALID_ARG_VALUE( @@ -673,6 +1188,19 @@ function validateBoolean(value, name) { throw new ERR_INVALID_ARG_TYPE(name, "boolean", value); } +/** + * @callback validateFunction + * @param {*} value + * @param {string} name + * @returns {asserts value is Function} + */ + +/** @type {validateFunction} */ +function validateFunction(value, name) { + if (typeof value !== "function") + throw new ERR_INVALID_ARG_TYPE(name, "Function", value); +} + /** * @callback validateAbortSignal * @param {*} signal @@ -726,20 +1254,16 @@ const validateOneOf = (value, name, oneOf) => { /** @type {validateObject} */ const validateObject = (value, name, options = null) => { // const validateObject = hideStackFrames((value, name, options = null) => { - const allowArray = getOwnPropertyValueOrDefault(options, "allowArray", false); - const allowFunction = getOwnPropertyValueOrDefault( - options, - "allowFunction", - false - ); - const nullable = getOwnPropertyValueOrDefault(options, "nullable", false); + const allowArray = options?.allowArray ?? false; + const allowFunction = options?.allowFunction ?? false; + const nullable = options?.nullable ?? false; if ( (!nullable && value === null) || (!allowArray && ArrayIsArray.call(value)) || (typeof value !== "object" && (!allowFunction || typeof value !== "function")) ) { - throw new ERR_INVALID_ARG_TYPE(name, "Object", value); + throw new ERR_INVALID_ARG_TYPE(name, "object", value); } }; @@ -776,18 +1300,6 @@ function validateString(value, name) { throw new ERR_INVALID_ARG_TYPE(name, "string", value); } -/** - * @param {?object} options - * @param {string} key - * @param {boolean} defaultValue - * @returns {boolean} - */ -function getOwnPropertyValueOrDefault(options, key, defaultValue) { - return options == null || !ObjectPrototypeHasOwnProperty.call(options, key) - ? defaultValue - : options[key]; -} - function nullCheck(path, propName, throwError = true) { const pathIsString = typeof path === "string"; const pathIsUint8Array = isUint8Array(path); @@ -837,9 +1349,13 @@ var ArrayBufferView = globalThis.ArrayBufferView; var Uint8Array = globalThis.Uint8Array; var String = globalThis.String; var Object = globalThis.Object; +var Buffer = globalThis.Buffer; var ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; var ObjectCreate = Object.create; +var ObjectAssign = Object.assign; +var BufferConcat = Buffer.concat; +var BufferIsEncoding = Buffer.isEncoding; var ArrayPrototypePush = Array.prototype.push; var ArrayPrototypeReduce = Array.prototype.reduce; @@ -880,37 +1396,9 @@ function isURLInstance(fileURLOrPath) { return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin; } -function getPathFromURLPosix(url) { - if (url.hostname !== "") { - throw new ERR_INVALID_FILE_URL_HOST(platform); - } - const pathname = url.pathname; - for (let n = 0; n < pathname.length; n++) { - if (pathname[n] === "%") { - const third = pathname.codePointAt(n + 2) | 0x20; - if (pathname[n + 1] === "2" && third === 102) { - throw new ERR_INVALID_FILE_URL_PATH( - "must not include encoded / characters" - ); - } - } - } - return decodeURIComponent(pathname); -} - -function fileURLToPath(path) { - if (typeof path === "string") path = new URL(path); - else if (!isURLInstance(path)) - throw new ERR_INVALID_ARG_TYPE("path", ["string", "URL"], path); - if (path.protocol !== "file:") throw new ERR_INVALID_URL_SCHEME("file"); - // TODO: Windows support - // return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path); - return getPathFromURLPosix(path); -} - function toPathIfFileURL(fileURLOrPath) { if (!isURLInstance(fileURLOrPath)) return fileURLOrPath; - return fileURLToPath(fileURLOrPath); + return Bun.fileURLToPath(fileURLOrPath); } //------------------------------------------------------------------------------ @@ -919,18 +1407,158 @@ function toPathIfFileURL(fileURLOrPath) { var Error = globalThis.Error; var TypeError = globalThis.TypeError; var RangeError = globalThis.RangeError; -var AbortError = globalThis.AbortError; -console.log(AbortError); +// Node uses a slightly different abort error than standard DOM. See: https://github.com/nodejs/node/blob/main/lib/internal/errors.js +class AbortError extends Error { + code = "ABORT_ERR"; + name = "AbortError"; + constructor(message = "The operation was aborted", options = undefined) { + if (options !== undefined && typeof options !== "object") { + throw new ERR_INVALID_ARG_TYPE("options", "Object", options); + } + super(message, options); + } +} -// class AbortError extends Error { -// name = "AbortError"; -// code = "ABORT_ERR"; -// constructor(message) { -// super(message || "The process was aborted"); -// } +function genericNodeError(message, options) { + const err = new Error(message); + err.code = options.code; + err.killed = options.killed; + err.signal = options.signal; + return err; +} + +// const messages = new Map(); + +// Utility function for registering the error codes. Only used here. Exported +// *only* to allow for testing. +// function E(sym, val, def) { +// messages.set(sym, val); +// def = makeNodeErrorWithCode(def, sym); +// errorCodes[sym] = def; +// } + +// function makeNodeErrorWithCode(Base, key) { +// return function NodeError(...args) { +// // const limit = Error.stackTraceLimit; +// // if (isErrorStackTraceLimitWritable()) Error.stackTraceLimit = 0; +// const error = new Base(); +// // Reset the limit and setting the name property. +// // if (isErrorStackTraceLimitWritable()) Error.stackTraceLimit = limit; +// const message = getMessage(key, args); +// error.message = message; +// // captureLargerStackTrace(error); +// error.code = key; +// return error; +// }; // } +// function getMessage(key, args) { +// const msgFn = messages.get(key); +// if (args.length !== msgFn.length) +// throw new Error( +// `Invalid number of args for error message ${key}. Got ${args.length}, expected ${msgFn.length}.` +// ); +// return msgFn(...args); +// } + +// E( +// "ERR_INVALID_ARG_TYPE", +// (name, expected, actual) => { +// assert(typeof name === "string", "'name' must be a string"); +// if (!ArrayIsArray(expected)) { +// expected = [expected]; +// } + +// let msg = "The "; +// if (StringPrototypeEndsWith(name, " argument")) { +// // For cases like 'first argument' +// msg += `${name} `; +// } else { +// const type = StringPrototypeIncludes(name, ".") ? "property" : "argument"; +// msg += `"${name}" ${type} `; +// } +// msg += "must be "; + +// const types = []; +// const instances = []; +// const other = []; + +// for (const value of expected) { +// assert( +// typeof value === "string", +// "All expected entries have to be of type string" +// ); +// if (ArrayPrototypeIncludes.call(kTypes, value)) { +// ArrayPrototypePush(types, StringPrototypeToLowerCase(value)); +// } else if (RegExpPrototypeExec(classRegExp, value) !== null) { +// ArrayPrototypePush(instances, value); +// } else { +// assert( +// value !== "object", +// 'The value "object" should be written as "Object"' +// ); +// ArrayPrototypePush(other, value); +// } +// } + +// // Special handle `object` in case other instances are allowed to outline +// // the differences between each other. +// if (instances.length > 0) { +// const pos = ArrayPrototypeIndexOf(types, "object"); +// if (pos !== -1) { +// ArrayPrototypeSplice.call(types, pos, 1); +// ArrayPrototypePush.call(instances, "Object"); +// } +// } + +// if (types.length > 0) { +// if (types.length > 2) { +// const last = ArrayPrototypePop(types); +// msg += `one of type ${ArrayPrototypeJoin(types, ", ")}, or ${last}`; +// } else if (types.length === 2) { +// msg += `one of type ${types[0]} or ${types[1]}`; +// } else { +// msg += `of type ${types[0]}`; +// } +// if (instances.length > 0 || other.length > 0) msg += " or "; +// } + +// if (instances.length > 0) { +// if (instances.length > 2) { +// const last = ArrayPrototypePop(instances); +// msg += `an instance of ${ArrayPrototypeJoin( +// instances, +// ", " +// )}, or ${last}`; +// } else { +// msg += `an instance of ${instances[0]}`; +// if (instances.length === 2) { +// msg += ` or ${instances[1]}`; +// } +// } +// if (other.length > 0) msg += " or "; +// } + +// if (other.length > 0) { +// if (other.length > 2) { +// const last = ArrayPrototypePop(other); +// msg += `one of ${ArrayPrototypeJoin.call(other, ", ")}, or ${last}`; +// } else if (other.length === 2) { +// msg += `one of ${other[0]} or ${other[1]}`; +// } else { +// if (StringPrototypeToLowerCase(other[0]) !== other[0]) msg += "an "; +// msg += `${other[0]}`; +// } +// } + +// msg += `. Received ${determineSpecificType(actual)}`; + +// return msg; +// }, +// TypeError +// ); + function ERR_OUT_OF_RANGE(str, range, input, replaceDefaultBoolean = false) { // Node implementation: // assert(range, 'Missing "range" argument'); @@ -956,39 +1584,31 @@ function ERR_OUT_OF_RANGE(str, range, input, replaceDefaultBoolean = false) { ); } -function ERR_UNKNOWN_SIGNAL(name) { - return new TypeError(`Unknown signal: ${name}`); +function ERR_CHILD_PROCESS_STDIO_MAXBUFFER(stdio) { + return Error(`${stdio} maxBuffer length exceeded`); } -function ERR_INVALID_ARG_TYPE(name, type, value) { - return new TypeError( - `The argument '${name}' is invalid. Received '${value}' for type '${type}'` - ); +function ERR_UNKNOWN_SIGNAL(name) { + const err = new TypeError(`Unknown signal: ${name}`); + err.code = "ERR_UNKNOWN_SIGNAL"; + return err; } -function ERR_INVALID_FILE_URL_HOST(platform) { - return new TypeError( - `File URL host must be "localhost" or empty on ${platform}` +function ERR_INVALID_ARG_TYPE(name, type, value) { + const err = new TypeError( + `The "${name}" argument must be of type ${type}. Received ${value}` ); + err.code = "ERR_INVALID_ARG_TYPE"; + return err; } -function ERR_INVALID_FILE_URL_PATH(path) { - return new TypeError(`File URL path: ${path}`); -} - -function ERR_INVALID_URL_SCHEME(expected) { - if (typeof expected === "string") expected = [expected]; - assert(expected.length <= 2); - const res = - expected.length === 2 - ? `one of scheme ${expected[0]} or ${expected[1]}` - : `of scheme ${expected[0]}`; - return `The URL must be ${res}`; +function ERR_INVALID_OPT_VALUE(name, value) { + return new TypeError(`The value "${value}" is invalid for option "${name}"`); } function ERR_INVALID_ARG_VALUE(name, value, reason) { return new Error( - `The value '${value}' is invalid for argument '${name}'. Reason: ${reason}` + `The value "${value}" is invalid for argument '${name}'. Reason: ${reason}` ); } @@ -1000,6 +1620,12 @@ function errnoException(err, name) { export default { ChildProcess, spawn, + execFile, + exec, + fork, + spawnSync, + execFileSync, + execSync, [Symbol.for("CommonJS")]: 0, }; diff --git a/src/bun.js/streams.exports.js b/src/bun.js/streams.exports.js index cbd2fc35c121c4..7fa66bd6ffd4ae 100644 --- a/src/bun.js/streams.exports.js +++ b/src/bun.js/streams.exports.js @@ -1,5 +1,6 @@ // "readable-stream" npm package // just transpiled +// var { isPromise } = import.meta.primordials; var __create = Object.create; var __defProp = Object.defineProperty; @@ -2163,6 +2164,7 @@ var require_destroy = __commonJS({ } } function errorOrDestroy(stream, err, sync) { + console.log(stream); const r = stream._readableState; const w = stream._writableState; if ((w && w.destroyed) || (r && r.destroyed)) { @@ -2629,92 +2631,83 @@ var require_readable = __commonJS({ class ReadableFromWeb extends Readable { #reader; + #closed; constructor(options) { const { objectMode, highWaterMark, encoding, signal, reader } = options; - let closed = false; super({ objectMode, highWaterMark, encoding, signal, - - async read() { - var deferredError; - try { - var done, value; - const firstResult = reader.readMany(); - - if (isPromise(firstResult)) { - const result = await firstResult; - done = result.done; - value = result.value; - } else { - done = firstResult.done; - value = firstResult.value; - } - - if (done) { - this.push(null); - return; - } - - if (!value) - throw new Error( - `Invalid value from ReadableStream reader: ${value}` - ); - if (ArrayIsArray(value)) { - this.push(...value); - } else { - this.push(value); - } - } catch (e) { - deferredError = e; - } finally { - if (deferredError) throw deferredError; - } - }, - destroy(error, callback) { - function done() { - try { - callback(error); - } catch (error) { - // In a next tick because this is happening within - // a promise context, and if there are any errors - // thrown we don't want those to cause an unhandled - // rejection. Let's just escape the promise and - // handle it separately. - process.nextTick(() => { - throw error; - }); - } - } - - if (!closed) { - reader.releaseLock(); - reader.cancel(error).then(done).catch(done); - return; - } - done(); - }, - // NOTE(Derrick): For whatever reason this seems to be necessary to make this work - // I couldn't find out why .constructed was getting set to false - // even though construct() was getting called - construct() { - this._readableState.constructed = true; - }, }); this.#reader = reader; this.#reader.closed .then(() => { - closed = true; + this.#closed = true; }) .catch((error) => { - closed = true; + this.#closed = true; destroy(this, error); }); } + + async _read() { + var deferredError; + try { + var done, value; + const firstResult = this.#reader.readMany(); + + if (isPromise(firstResult)) { + const result = await firstResult; + done = result.done; + value = result.value; + } else { + done = firstResult.done; + value = firstResult.value; + } + + if (done) { + this.push(null); + return; + } + + if (!value) + throw new Error( + `Invalid value from ReadableStream reader: ${value}` + ); + if (ArrayIsArray(value)) { + this.push(...value); + } else { + this.push(value); + } + } catch (e) { + deferredError = e; + } finally { + if (deferredError) throw deferredError; + } + } + + _destroy(error, callback) { + if (!this.#closed) { + this.#reader.releaseLock(); + this.#reader.cancel(error).then(done).catch(done); + return; + } + try { + callback(error); + } catch (error) { + globalThis.reportError(error); + } + } + + // NOTE(Derrick): For whatever reason this seems to be necessary to make this work + // I couldn't find out why .constructed was getting set to false + // even though construct() was getting called + _construct() { + this._readableState.constructed = true; + } } /** diff --git a/test/bun.js/child_process-node.test.js b/test/bun.js/child_process-node.test.js new file mode 100644 index 00000000000000..16a4520e47ad05 --- /dev/null +++ b/test/bun.js/child_process-node.test.js @@ -0,0 +1,550 @@ +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 || "", + }; + + // 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) => { + assertNode.strictEqual(...args); + expect(true).toBe(true); +}; + +const throws = (...args) => { + assertNode.throws(...args); + expect(true).toBe(true); +}; + +const assert = (...args) => { + assertNode(...args); + expect(true).toBe(true); +}; + +const assertOk = (...args) => { + assertNode.ok(...args); + expect(true).toBe(true); +}; + +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); + }); + + 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}`); + }) + ); + }); +}); diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index e9aef3ae22a371..81af5a8d0bfdfa 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect } from "bun:test"; -import { ChildProcess, spawn } from "node:child_process"; -import { resolve } from "node:path"; -import { compileSchemaCPP } from "../../node_modules/peechy/peechy"; +import { + ChildProcess, + spawn, + execFile, + exec, + fork, + spawnSync, + execFileSync, + execSync, +} from "node:child_process"; // Semver regex: https://gist.github.com/jhorsman/62eeea161a13b80e39f5249281e17c39?permalink_comment_id=2896416#gistcomment-2896416 // Not 100% accurate, but good enough for this test @@ -61,7 +68,7 @@ describe("spawn()", () => { }); child.stdout.on("data", (data) => { console.log(`stdout: ${data}`); - resolve(data); + resolve(data.toString()); }); child.stderr.on("data", (data) => { console.log(`stderr: ${data}`); @@ -81,7 +88,7 @@ describe("spawn()", () => { let data; while ((data = child.stdout.read()) !== null) { - finalData += data; + finalData += data.toString(); } resolve(finalData); }); @@ -113,30 +120,43 @@ describe("spawn()", () => { const child = spawn("pwd", { cwd: "/tmp" }); const result: string = await new Promise((resolve) => { child.stdout.on("data", (data) => { - resolve(data); + resolve(data.toString()); }); }); expect(result.trim()).toBe(`${PRIVATE_DIR}/tmp`); }); - it("should allow us to timeout hanging processes", async () => { - const child = spawn("sleep", ["750"], { timeout: 250 }); - const start = performance.now(); - let end; - await new Promise((resolve) => { - child.on("exit", () => { - end = performance.now(); - resolve(0); + it("should allow us to write to stdin", async () => { + const child = spawn("tee"); + const result: string = await new Promise((resolve) => { + child.stdin.write("hello"); + child.stdout.on("data", (data) => { + resolve(data.toString()); }); }); - expect(end - start < 750).toBe(true); + expect(result.trim()).toBe("hello"); }); + // TODO: Uncomment after segfault is fixed + // it("should allow us to timeout hanging processes", async () => { + // const child = spawn("sleep", ["750"], { timeout: 250 }); + // const start = performance.now(); + // let end; + // await new Promise((resolve) => { + // child.on("exit", () => { + // end = performance.now(); + // resolve(true); + // }); + // }); + // console.log("here"); + // expect(end - start < 750).toBe(true); + // }); + it("should allow us to set env", async () => { const child = spawn("env", { env: { TEST: "test" } }); const result: string = await new Promise((resolve) => { child.stdout.on("data", (data) => { - resolve(data); + resolve(data.toString()); }); }); expect(/TEST\=test/.test(result)).toBe(true); @@ -147,7 +167,7 @@ describe("spawn()", () => { const result: string = await new Promise((resolve) => { let msg; child.stdout.on("data", (data) => { - msg += data; + msg += data.toString(); }); child.stdout.on("close", () => { @@ -162,15 +182,99 @@ describe("spawn()", () => { const child2 = spawn("echo", ["$0"], { shell: "bash" }); const result1: string = await new Promise((resolve) => { child1.stdout.on("data", (data) => { - resolve(data); + resolve(data.toString()); }); }); const result2: string = await new Promise((resolve) => { child2.stdout.on("data", (data) => { - resolve(data); + resolve(data.toString()); }); }); expect(result1.trim()).toBe("/bin/sh"); expect(result2.trim()).toBe("/bin/bash"); }); + it("should spawn a process synchronously", () => { + const { stdout } = spawnSync("echo", ["hello"], { encoding: "utf8" }); + expect(stdout.trim()).toBe("hello"); + }); +}); + +describe("execFile()", () => { + it("should execute a file", async () => { + const result: Buffer = await new Promise((resolve, reject) => { + execFile("bun", ["-v"], (error, stdout, stderr) => { + if (error) { + reject(error); + } + resolve(stdout); + }); + }); + expect(SEMVER_REGEX.test(result.toString().trim())).toBe(true); + }); +}); + +describe("exec()", () => { + it("should execute a command in a shell", async () => { + const result: Buffer = await new Promise((resolve, reject) => { + exec("bun -v", (error, stdout, stderr) => { + if (error) { + reject(error); + } + resolve(stdout); + }); + }); + console.log(result); + expect(SEMVER_REGEX.test(result.toString().trim())).toBe(true); + }); +}); + +describe("fork()", () => { + it("should throw an error when used", () => { + let err; + try { + fork("index.js"); + } catch (e) { + err = e; + } + expect(err instanceof Error).toBe(true); + }); }); + +describe("spawnSync()", () => { + it("should spawn a process synchronously", () => { + const { stdout } = spawnSync("echo", ["hello"], { encoding: "utf8" }); + expect(stdout.trim()).toBe("hello"); + }); +}); + +describe("execFileSync()", () => { + it("should execute a file synchronously", () => { + const result = execFileSync("bun", ["-v"], { encoding: "utf8" }); + expect(SEMVER_REGEX.test(result.trim())).toBe(true); + }); +}); + +describe("execSync()", () => { + it("should execute a command in the shell synchronously", () => { + const result = execSync("bun -v", { encoding: "utf8" }); + expect(SEMVER_REGEX.test(result.trim())).toBe(true); + }); +}); + +// describe("Bun.spawn", () => { +// it("should return exit code 0 on successful execution", async () => { +// const result = await new Promise((resolve) => { +// Bun.spawn({ +// cmd: ["echo", "hello"], +// encoding: "utf8", +// onExit: (code) => resolve(code), +// stdout: "inherit", +// }); +// }); +// expect(result).toBe(0); +// }); +// it("should fail when given an invalid cwd", () => { +// const child = Bun.spawn({ cmd: ["echo", "hello"], cwd: "/invalid" }); +// expect(child.pid).toBe(undefined); +// }); +// }); From b39ee43727badf789d1e1dfe5bc3fe72ecb5dd00 Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 00:44:32 -0500 Subject: [PATCH 06/15] cleanup(streams): remove a bunch of unnecessary stuff --- src/bun.js/streams.exports.js | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/src/bun.js/streams.exports.js b/src/bun.js/streams.exports.js index 7fa66bd6ffd4ae..10b03e2ab8ff26 100644 --- a/src/bun.js/streams.exports.js +++ b/src/bun.js/streams.exports.js @@ -1,6 +1,6 @@ // "readable-stream" npm package // just transpiled -// var { isPromise } = import.meta.primordials; +var { isPromise } = import.meta.primordials; var __create = Object.create; var __defProp = Object.defineProperty; @@ -34,14 +34,6 @@ var __copyProps = (to, from, except, desc) => { var runOnNextTick = process.nextTick; -function isPromise(toTest) { - return ( - Boolean(toTest) && - (typeof toTest === "object" || typeof toTest === "function") && - typeof toTest.then === "function" - ); -} - function isReadableStream(value) { return ( typeof value === "object" && @@ -68,14 +60,9 @@ function validateBoolean(value, name) { /** @type {validateObject} */ const validateObject = (value, name, options = null) => { - // const validateObject = hideStackFrames((value, name, options = null) => { - const allowArray = getOwnPropertyValueOrDefault(options, "allowArray", false); - const allowFunction = getOwnPropertyValueOrDefault( - options, - "allowFunction", - false - ); - const nullable = getOwnPropertyValueOrDefault(options, "nullable", false); + const allowArray = options?.allowArray ?? false; + const allowFunction = options?.allowFunction ?? false; + const nullable = options?.nullable ?? false; if ( (!nullable && value === null) || (!allowArray && ArrayIsArray(value)) || @@ -99,19 +86,6 @@ function validateString(value, name) { throw new ERR_INVALID_ARG_TYPE(name, "string", value); } -/** - * @param {?object} options - * @param {string} key - * @param {boolean} defaultValue - * @returns {boolean} - */ -function getOwnPropertyValueOrDefault(options, key, defaultValue) { - return options == null || !ObjectPrototypeHasOwnProperty.call(options, key) - ? defaultValue - : options[key]; -} - -var ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; var ArrayIsArray = Array.isArray; //------------------------------------------------------------------------------ From d359da022f990b869720e1db8f6f3c0ce04a5c95 Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 00:45:57 -0500 Subject: [PATCH 07/15] cleanup(child_process): remove dead refs --- src/bun.js/child_process.exports.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index b331c74d334577..5cf0cf6426a72e 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -1345,7 +1345,6 @@ function getValidatedPath(fileURLOrPath, propName = "path") { //------------------------------------------------------------------------------ // Section 6. Primordials //------------------------------------------------------------------------------ -var ArrayBufferView = globalThis.ArrayBufferView; var Uint8Array = globalThis.Uint8Array; var String = globalThis.String; var Object = globalThis.Object; @@ -1374,14 +1373,6 @@ var StringPrototypeToUpperCase = String.prototype.toUpperCase; var StringPrototypeIncludes = String.prototype.includes; var Uint8ArrayPrototypeIncludes = Uint8Array.prototype.includes; -function isArrayBufferView(value) { - return ( - typeof value === "object" && - value !== null && - value instanceof ArrayBufferView - ); -} - function isUint8Array(value) { return ( typeof value === "object" && value !== null && value instanceof Uint8Array From 32d00f405d812432ffd319146e729da138d4179e Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 11:01:13 -0500 Subject: [PATCH 08/15] fix(child_process): fix stdio --- src/bun.js/child_process.exports.js | 62 ++++++++++++++++++++++------- src/bun.js/streams.exports.js | 1 - 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index 5cf0cf6426a72e..b11911f84b21d5 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -849,6 +849,35 @@ export class ChildProcess extends EventEmitter { this.#exited = true; } + #getBunSpawnIo(stdio, options) { + const result = []; + switch (stdio[0]) { + case "pipe": + result[0] = new WrappedFileSink(this.#handle.stdin); + break; + case "inherit": + result[0] = process.stdin; + default: + result[0] = null; + } + let i = 1; + for (; i < stdio.length; i++) { + switch (stdio[i]) { + case "pipe": + result[i] = ReadableFromWeb(this.#handle[fdToStdioName(i)], { + encoding: options.encoding || undefined, + }); + break; + case "inherit": + result[i] = process[fdToStdioName(i)]; + break; + default: + result[i] = null; + } + } + return result; + } + spawn(options) { validateObject(options, "options"); @@ -895,19 +924,10 @@ export class ChildProcess extends EventEmitter { onExit: this.#handleOnExit.bind(this), }); - this.stdin = bunStdio[0] ? new WrappedFileSink(this.#handle.stdin) : null; - - this.stdout = bunStdio[1] - ? ReadableFromWeb(this.#handle.stdout, { - encoding: options.encoding || undefined, - }) - : null; - - this.stderr = bunStdio[2] - ? ReadableFromWeb(this.#handle.stderr, { - encoding: options.encoding || undefined, - }) - : null; + this.stdio = this.#getBunSpawnIo(stdio, options); + this.stdin = this.stdio[0]; + this.stdout = this.stdio[1]; + this.stderr = this.stdio[2]; process.nextTick(onSpawnNT, this); @@ -1038,6 +1058,19 @@ function nodeToBun(item) { } } +function fdToStdioName(fd) { + switch (fd) { + case 0: + return "stdin"; + case 1: + return "stdout"; + case 2: + return "stderr"; + default: + return null; + } +} + function getBunStdioOptions(stdio) { const normalizedStdio = normalizeStdio(stdio); // Node options: @@ -1126,10 +1159,11 @@ function abortChildProcess(child, killSignal) { } } -class WrappedFileSink { +class WrappedFileSink extends EventEmitter { #fileSink; constructor(fileSink) { + super(); this.#fileSink = fileSink; } diff --git a/src/bun.js/streams.exports.js b/src/bun.js/streams.exports.js index 10b03e2ab8ff26..576f3ea89252e8 100644 --- a/src/bun.js/streams.exports.js +++ b/src/bun.js/streams.exports.js @@ -2138,7 +2138,6 @@ var require_destroy = __commonJS({ } } function errorOrDestroy(stream, err, sync) { - console.log(stream); const r = stream._readableState; const w = stream._writableState; if ((w && w.destroyed) || (r && r.destroyed)) { From c506b45e4cd4b49dd298749dc9e3b9115bc7a87b Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 12:35:42 -0500 Subject: [PATCH 09/15] fix(child_process): change stdio to bunStdio --- src/bun.js/child_process.exports.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index b11911f84b21d5..0e0b13d146bbed 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -924,7 +924,7 @@ export class ChildProcess extends EventEmitter { onExit: this.#handleOnExit.bind(this), }); - this.stdio = this.#getBunSpawnIo(stdio, options); + this.stdio = this.#getBunSpawnIo(bunStdio, options); this.stdin = this.stdio[0]; this.stdout = this.stdio[1]; this.stderr = this.stdio[2]; From 2c850b3c75783fedf6242cc55419410fda780ffb Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 12:44:47 -0500 Subject: [PATCH 10/15] test(child_process): uncomment timeout test --- test/bun.js/child_process.test.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index 81af5a8d0bfdfa..8ec6e5a4f7d3f9 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -137,20 +137,19 @@ describe("spawn()", () => { expect(result.trim()).toBe("hello"); }); - // TODO: Uncomment after segfault is fixed - // it("should allow us to timeout hanging processes", async () => { - // const child = spawn("sleep", ["750"], { timeout: 250 }); - // const start = performance.now(); - // let end; - // await new Promise((resolve) => { - // child.on("exit", () => { - // end = performance.now(); - // resolve(true); - // }); - // }); - // console.log("here"); - // expect(end - start < 750).toBe(true); - // }); + it("should allow us to timeout hanging processes", async () => { + const child = spawn("sleep", ["750"], { timeout: 250 }); + const start = performance.now(); + let end; + await new Promise((resolve) => { + child.on("exit", () => { + end = performance.now(); + resolve(true); + }); + }); + console.log("here"); + expect(end - start < 750).toBe(true); + }); it("should allow us to set env", async () => { const child = spawn("env", { env: { TEST: "test" } }); @@ -261,7 +260,7 @@ describe("execSync()", () => { }); }); -// describe("Bun.spawn", () => { +// describe("Bun.spawn()", () => { // it("should return exit code 0 on successful execution", async () => { // const result = await new Promise((resolve) => { // Bun.spawn({ From 5f89e10603ab6b5b29dac62a9bd793dc5996fe8c Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 12:54:25 -0500 Subject: [PATCH 11/15] test(child_process): fix hanging tests --- test/bun.js/child_process.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index 8ec6e5a4f7d3f9..11e22ea13a9ceb 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -138,7 +138,7 @@ describe("spawn()", () => { }); it("should allow us to timeout hanging processes", async () => { - const child = spawn("sleep", ["750"], { timeout: 250 }); + const child = spawn("sleep", ["2"], { timeout: 400 }); const start = performance.now(); let end; await new Promise((resolve) => { @@ -147,8 +147,7 @@ describe("spawn()", () => { resolve(true); }); }); - console.log("here"); - expect(end - start < 750).toBe(true); + expect(end - start < 2000).toBe(true); }); it("should allow us to set env", async () => { @@ -177,14 +176,14 @@ describe("spawn()", () => { }); it("should allow us to spawn in a shell", async () => { - const child1 = spawn("echo", ["$0"], { shell: true }); - const child2 = spawn("echo", ["$0"], { shell: "bash" }); const result1: string = await new Promise((resolve) => { + const child1 = spawn("echo", ["$0"], { shell: true }); child1.stdout.on("data", (data) => { resolve(data.toString()); }); }); const result2: string = await new Promise((resolve) => { + const child2 = spawn("echo", ["$0"], { shell: "bash" }); child2.stdout.on("data", (data) => { resolve(data.toString()); }); From 3ae4b18c47abaa9e5bcc250bffee630d6dbf33b4 Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 12:55:22 -0500 Subject: [PATCH 12/15] test(child_process): remove stray console.log --- test/bun.js/child_process.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index 11e22ea13a9ceb..07d402c5e65dcb 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -221,7 +221,6 @@ describe("exec()", () => { resolve(stdout); }); }); - console.log(result); expect(SEMVER_REGEX.test(result.toString().trim())).toBe(true); }); }); From ca2a2940a87b71d5ee088739f9eb206fe3cd515e Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Fri, 4 Nov 2022 13:19:29 -0500 Subject: [PATCH 13/15] test(child_process): fix cwd test for linux --- test/bun.js/child_process-node.test.js | 38 ++++++++++++++++++++------ test/bun.js/child_process.test.ts | 8 ++++-- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/test/bun.js/child_process-node.test.js b/test/bun.js/child_process-node.test.js index 16a4520e47ad05..10135affacf1e9 100644 --- a/test/bun.js/child_process-node.test.js +++ b/test/bun.js/child_process-node.test.js @@ -137,23 +137,43 @@ function _mustCallInner(fn, criteria = 1, field) { } const strictEqual = (...args) => { - assertNode.strictEqual(...args); - expect(true).toBe(true); + let error = null; + try { + assertNode.strictEqual(...args); + } catch (err) { + error = err; + } + expect(error).toBe(null); }; const throws = (...args) => { - assertNode.throws(...args); - expect(true).toBe(true); + let error = null; + try { + assertNode.throws(...args); + } catch (err) { + error = err; + } + expect(error).toBe(null); }; const assert = (...args) => { - assertNode(...args); - expect(true).toBe(true); + let error = null; + try { + assertNode(...args); + } catch (err) { + error = err; + } + expect(error).toBe(null); }; const assertOk = (...args) => { - assertNode.ok(...args); - expect(true).toBe(true); + let error = null; + try { + assertNode.ok(...args); + } catch (err) { + error = err; + } + expect(error).toBe(null); }; describe("ChildProcess.constructor", () => { @@ -353,7 +373,7 @@ describe("child_process cwd", () => { // 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); + strictEqual(code, expectCode).bind(this); }); child.on( diff --git a/test/bun.js/child_process.test.ts b/test/bun.js/child_process.test.ts index 07d402c5e65dcb..fd1c27ae7a8ded 100644 --- a/test/bun.js/child_process.test.ts +++ b/test/bun.js/child_process.test.ts @@ -116,14 +116,16 @@ describe("spawn()", () => { }); it("should allow us to set cwd", async () => { - const PRIVATE_DIR = "/private"; - const child = spawn("pwd", { cwd: "/tmp" }); + const child = spawn("pwd", { cwd: process.env.TMPDIR }); const result: string = await new Promise((resolve) => { child.stdout.on("data", (data) => { resolve(data.toString()); }); }); - expect(result.trim()).toBe(`${PRIVATE_DIR}/tmp`); + const platformTmpDir = `${process.platform === "darwin" ? "/private" : ""}${ + process.env.TMPDIR + }`; + expect(`${result.trim()}/`).toBe(platformTmpDir); }); it("should allow us to write to stdin", async () => { From a500763f7d6ecb944ab5698b85d09cae93d5a191 Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Sun, 6 Nov 2022 00:57:17 -0500 Subject: [PATCH 14/15] refactor(child_process): divide paths for encoded vs raw execFile stdio --- src/bun.js/child_process.exports.js | 142 +++++++++++++++++++--------- 1 file changed, 95 insertions(+), 47 deletions(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index 0e0b13d146bbed..db1e802950302a 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -301,58 +301,106 @@ export function execFile(file, args, options, callback) { if (child.stdout) { if (encoding) child.stdout.setEncoding(encoding); - child.stdout.on("data", function onChildStdout(chunk) { - // Do not need to count the length - if (options.maxBuffer === Infinity) { - ArrayPrototypePush.call(_stdout, chunk); - return; - } - const encoding = child.stdout.readableEncoding; - const length = encoding - ? Buffer.byteLength(chunk, encoding) - : chunk.length; - const slice = encoding - ? (buf, ...args) => StringPrototypeSlice.call(buf, ...args) - : (buf, ...args) => buf.slice(...args); - stdoutLen += length; - - if (stdoutLen > options.maxBuffer) { - const truncatedLen = options.maxBuffer - (stdoutLen - length); - ArrayPrototypePush.call(_stdout, slice(chunk, 0, truncatedLen)); - - ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stdout"); - kill(); - } else { - ArrayPrototypePush.call(_stdout, chunk); - } - }); + child.stdout.on( + "data", + encoding + ? function onChildStdoutEncoded(chunk) { + // Do not need to count the length + if (options.maxBuffer === Infinity) { + ArrayPrototypePush.call(_stdout, chunk); + return; + } + stdoutLen += chunk.length; + + if (stdoutLen * 4 > options.maxBuffer) { + const encoding = child.stdout.readableEncoding; + const actualLen = Buffer.byteLength(chunk, encoding); + const truncatedLen = options.maxBuffer - (stdoutLen - actualLen); + ArrayPrototypePush.call( + _stdout, + StringPrototypeSlice.apply(chunk, 0, truncatedLen) + ); + + ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stdout"); + kill(); + } else { + ArrayPrototypePush.call(_stdout, chunk); + } + } + : function onChildStdoutRaw(chunk) { + // Do not need to count the length + if (options.maxBuffer === Infinity) { + ArrayPrototypePush.call(_stdout, chunk); + return; + } + stdoutLen += chunk.length; + + if (stdoutLen > options.maxBuffer) { + const truncatedLen = + options.maxBuffer - (stdoutLen - chunk.length); + ArrayPrototypePush.call(_stdout, chunk.slice(0, truncatedLen)); + + ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stdout"); + kill(); + } else { + ArrayPrototypePush.call(_stdout, chunk); + } + } + ); } if (child.stderr) { if (encoding) child.stderr.setEncoding(encoding); - child.stderr.on("data", function onChildStderr(chunk) { - // Do not need to count the length - if (options.maxBuffer === Infinity) { - ArrayPrototypePush.call(_stderr, chunk); - return; - } - const encoding = child.stderr.readableEncoding; - const length = encoding - ? Buffer.byteLength(chunk, encoding) - : chunk.length; - stderrLen += length; - - if (stderrLen > options.maxBuffer) { - const truncatedLen = options.maxBuffer - (stderrLen - length); - ArrayPrototypePush.call(_stderr, chunk.slice(0, truncatedLen)); - - ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stderr"); - kill(); - } else { - ArrayPrototypePush.call(_stderr, chunk); - } - }); + child.stderr.on( + "data", + encoding + ? function onChildStderrEncoded(chunk) { + if (options.maxBuffer === Infinity) { + ArrayPrototypePush.call(_stderr, chunk); + return; + } + + stderrLen += chunk.length; + + if (stderrLen * 4 > options.maxBuffer) { + const encoding = child.stderr.readableEncoding; + const actualLen = Buffer.byteLength(chunk, encoding); + const truncatedLen = options.maxBuffer - (stderrLen - actualLen); + ArrayPrototypePush.call( + _stderr, + StringPrototypeSlice.call(chunk, 0, truncatedLen) + ); + + ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stderr"); + kill(); + } else { + ArrayPrototypePush.call(_stderr, chunk); + } + } + : function onChildStderrRaw(chunk) { + if (options.maxBuffer === Infinity) { + ArrayPrototypePush.call(_stderr, chunk); + return; + } + + stderrLen += chunk.length; + + if (stderrLen > options.maxBuffer) { + const truncatedLen = + options.maxBuffer - (stderrLen - chunk.length); + ArrayPrototypePush.call( + _stderr, + StringPrototypeSlice.call(chunk, 0, truncatedLen) + ); + + ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stderr"); + kill(); + } else { + ArrayPrototypePush.call(_stderr, chunk); + } + } + ); } child.addListener("close", exitHandler); From b493d23a923adb7de06e5fb0e4e74a6ae1f605cd Mon Sep 17 00:00:00 2001 From: Derrick Farris Date: Sun, 6 Nov 2022 01:54:25 -0600 Subject: [PATCH 15/15] fix(child_process): fix logic for execFile slow path --- src/bun.js/child_process.exports.js | 88 ++++++++++++++++------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/src/bun.js/child_process.exports.js b/src/bun.js/child_process.exports.js index db1e802950302a..9219e3ab691ce8 100644 --- a/src/bun.js/child_process.exports.js +++ b/src/bun.js/child_process.exports.js @@ -188,11 +188,13 @@ export function execFile(file, args, options, callback) { ...options, }; + const maxBuffer = options.maxBuffer; + // Validate the timeout, if present. validateTimeout(options.timeout); // Validate maxBuffer, if present. - validateMaxBuffer(options.maxBuffer); + validateMaxBuffer(maxBuffer); options.killSignal = sanitizeKillSignal(options.killSignal); @@ -218,6 +220,8 @@ export function execFile(file, args, options, callback) { let killed = false; let exited = false; let timeoutId; + let encodedStdoutLen; + let encodedStderrLen; let ex = null; @@ -234,15 +238,16 @@ export function execFile(file, args, options, callback) { if (!callback) return; + const readableEncoding = child?.stdout?.readableEncoding; // merge chunks let stdout; let stderr; - if (encoding || (child.stdout && child.stdout.readableEncoding)) { + if (encoding || (child.stdout && readableEncoding)) { stdout = ArrayPrototypeJoin.call(_stdout, ""); } else { stdout = BufferConcat(_stdout); } - if (encoding || (child.stderr && child.stderr.readableEncoding)) { + if (encoding || (child.stderr && readableEncoding)) { stderr = ArrayPrototypeJoin.call(_stderr, ""); } else { stderr = BufferConcat(_stderr); @@ -303,19 +308,25 @@ export function execFile(file, args, options, callback) { child.stdout.on( "data", - encoding + maxBuffer === Infinity + ? function onUnlimitedSizeBufferedData(chunk) { + ArrayPrototypePush.call(_stdout, chunk); + } + : encoding ? function onChildStdoutEncoded(chunk) { - // Do not need to count the length - if (options.maxBuffer === Infinity) { - ArrayPrototypePush.call(_stdout, chunk); - return; - } stdoutLen += chunk.length; - if (stdoutLen * 4 > options.maxBuffer) { + if (stdoutLen * 4 > maxBuffer) { const encoding = child.stdout.readableEncoding; const actualLen = Buffer.byteLength(chunk, encoding); - const truncatedLen = options.maxBuffer - (stdoutLen - actualLen); + if (encodedStdoutLen === undefined) { + for (let i = 0; i < _stdout.length; i++) { + encodedStdoutLen += Buffer.byteLength(_stdout[i], encoding); + } + } else { + encodedStdoutLen += actualLen; + } + const truncatedLen = maxBuffer - (encodedStdoutLen - actualLen); ArrayPrototypePush.call( _stdout, StringPrototypeSlice.apply(chunk, 0, truncatedLen) @@ -328,16 +339,10 @@ export function execFile(file, args, options, callback) { } } : function onChildStdoutRaw(chunk) { - // Do not need to count the length - if (options.maxBuffer === Infinity) { - ArrayPrototypePush.call(_stdout, chunk); - return; - } stdoutLen += chunk.length; - if (stdoutLen > options.maxBuffer) { - const truncatedLen = - options.maxBuffer - (stdoutLen - chunk.length); + if (stdoutLen > maxBuffer) { + const truncatedLen = maxBuffer - (stdoutLen - chunk.length); ArrayPrototypePush.call(_stdout, chunk.slice(0, truncatedLen)); ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stdout"); @@ -354,19 +359,25 @@ export function execFile(file, args, options, callback) { child.stderr.on( "data", - encoding + maxBuffer === Infinity + ? function onUnlimitedSizeBufferedData(chunk) { + ArrayPrototypePush.call(_stderr, chunk); + } + : encoding ? function onChildStderrEncoded(chunk) { - if (options.maxBuffer === Infinity) { - ArrayPrototypePush.call(_stderr, chunk); - return; - } - stderrLen += chunk.length; - if (stderrLen * 4 > options.maxBuffer) { + if (stderrLen * 4 > maxBuffer) { const encoding = child.stderr.readableEncoding; const actualLen = Buffer.byteLength(chunk, encoding); - const truncatedLen = options.maxBuffer - (stderrLen - actualLen); + if (encodedStderrLen === undefined) { + for (let i = 0; i < _stderr.length; i++) { + encodedStderrLen += Buffer.byteLength(_stderr[i], encoding); + } + } else { + encodedStderrLen += actualLen; + } + const truncatedLen = maxBuffer - (encodedStderrLen - actualLen); ArrayPrototypePush.call( _stderr, StringPrototypeSlice.call(chunk, 0, truncatedLen) @@ -379,16 +390,10 @@ export function execFile(file, args, options, callback) { } } : function onChildStderrRaw(chunk) { - if (options.maxBuffer === Infinity) { - ArrayPrototypePush.call(_stderr, chunk); - return; - } - stderrLen += chunk.length; - if (stderrLen > options.maxBuffer) { - const truncatedLen = - options.maxBuffer - (stderrLen - chunk.length); + if (stderrLen > maxBuffer) { + const truncatedLen = maxBuffer - (stderrLen - chunk.length); ArrayPrototypePush.call( _stderr, StringPrototypeSlice.call(chunk, 0, truncatedLen) @@ -471,13 +476,16 @@ export function spawnSync(file, args, options) { ...normalizeSpawnArguments(file, args, options), }; + const maxBuffer = options.maxBuffer; + const encoding = options.encoding; + debug("spawnSync", options); // Validate the timeout, if present. validateTimeout(options.timeout); // Validate maxBuffer, if present. - validateMaxBuffer(options.maxBuffer); + validateMaxBuffer(maxBuffer); // Validate and translate the kill signal, if present. options.killSignal = sanitizeKillSignal(options.killSignal); @@ -523,12 +531,12 @@ export function spawnSync(file, args, options) { output: [null, stdout, stderr], }; - if (stdout && options.encoding && options.encoding !== "buffer") { - result.output[1] = result.output[1]?.toString(options.encoding); + if (stdout && encoding && encoding !== "buffer") { + result.output[1] = result.output[1]?.toString(encoding); } - if (stderr && options.encoding && options.encoding !== "buffer") { - result.output[2] = result.output[2]?.toString(options.encoding); + if (stderr && encoding && encoding !== "buffer") { + result.output[2] = result.output[2]?.toString(encoding); } result.stdout = result.output[1];