diff --git a/node/_tools/config.json b/node/_tools/config.json index 38e6636f072c..aebe8c339cd5 100644 --- a/node/_tools/config.json +++ b/node/_tools/config.json @@ -22,6 +22,11 @@ "test-buffer-from.js", "test-buffer-includes.js", "test-buffer-indexof.js", + "test-child-process-exec-abortcontroller-promisified.js", + "test-child-process-exec-encoding.js", + "test-child-process-exec-kill-throws.js", + "test-child-process-exec-maxbuf.js", + "test-child-process-exec-std-encoding.js", "test-child-process-spawnsync-env.js", "test-child-process-spawnsync-maxbuf.js", "test-console-instance.js", @@ -166,6 +171,15 @@ "test-buffer-zero-fill-cli.js", "test-buffer-zero-fill-reset.js", "test-buffer-zero-fill.js", + "test-child-process-exec-abortcontroller-promisified.js", + "test-child-process-exec-cwd.js", + "test-child-process-exec-encoding.js", + "test-child-process-exec-env.js", + "test-child-process-exec-error.js", + "test-child-process-exec-kill-throws.js", + "test-child-process-exec-maxbuf.js", + "test-child-process-exec-std-encoding.js", + "test-child-process-exec-stdout-stderr-data-string.js", "test-child-process-kill.js", "test-child-process-spawnsync-args.js", "test-child-process-spawnsync-env.js", @@ -684,6 +698,12 @@ }, "windowsIgnore": { "parallel": [ + "test-child-process-exec-cwd.js", + "test-child-process-exec-encoding.js", + "test-child-process-exec-env.js", + "test-child-process-exec-maxbuf.js", + "test-child-process-exec-std-encoding.js", + "test-child-process-exec-stdout-stderr-data-string.js", "test-child-process-kill.js", "test-child-process-spawnsync-args.js", "test-console-log-throw-primitive.js", diff --git a/node/_tools/test/parallel/test-child-process-exec-abortcontroller-promisified.js b/node/_tools/test/parallel/test-child-process-exec-abortcontroller-promisified.js new file mode 100644 index 000000000000..64a39f50f25f --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-abortcontroller-promisified.js @@ -0,0 +1,54 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// TODO(#2674): The "eval" subcommand passed to execPromisifed() should be the "-e" option. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const exec = require('child_process').exec; +const { promisify } = require('util'); + +const execPromisifed = promisify(exec); +const invalidArgTypeError = { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError' +}; + +const waitCommand = common.isLinux ? + 'sleep 2m' : + `${process.execPath} eval "setInterval(()=>{}, 99)"`; + +{ + const ac = new AbortController(); + const signal = ac.signal; + const promise = execPromisifed(waitCommand, { signal }); + assert.rejects(promise, /AbortError/, 'post aborted sync signal failed') + .then(common.mustCall()); + ac.abort(); +} + +{ + assert.throws(() => { + execPromisifed(waitCommand, { signal: {} }); + }, invalidArgTypeError); +} + +{ + function signal() {} + assert.throws(() => { + execPromisifed(waitCommand, { signal }); + }, invalidArgTypeError); +} + +{ + const signal = AbortSignal.abort(); // Abort in advance + const promise = execPromisifed(waitCommand, { signal }); + + assert.rejects(promise, /AbortError/, 'pre aborted signal failed') + .then(common.mustCall()); +} diff --git a/node/_tools/test/parallel/test-child-process-exec-cwd.js b/node/_tools/test/parallel/test-child-process-exec-cwd.js new file mode 100644 index 000000000000..d5ca518956d3 --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-cwd.js @@ -0,0 +1,46 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const exec = require('child_process').exec; + +let pwdcommand, dir; + +if (common.isWindows) { + pwdcommand = 'echo %cd%'; + dir = 'c:\\windows'; +} else { + pwdcommand = 'pwd'; + dir = '/dev'; +} + +exec(pwdcommand, { cwd: dir }, common.mustSucceed((stdout, stderr) => { + assert(stdout.startsWith(dir)); +})); diff --git a/node/_tools/test/parallel/test-child-process-exec-encoding.js b/node/_tools/test/parallel/test-child-process-exec-encoding.js new file mode 100644 index 000000000000..e96fa1102143 --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-encoding.js @@ -0,0 +1,59 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// TODO(#2674): The process.argv[3] check should be argv[2], and the +// command passed to exec() should not need to include "run", "-A", +// "--unstable", and "require.ts". + +'use strict'; +const common = require('../common'); +const stdoutData = 'foo'; +const stderrData = 'bar'; + +if (process.argv[3] === 'child') { + // The following console calls are part of the test. + console.log(stdoutData); + console.error(stderrData); +} else { + const assert = require('assert'); + const cp = require('child_process'); + const expectedStdout = `${stdoutData}\n`; + const expectedStderr = `${stderrData}\n`; + function run(options, callback) { + const cmd = `"${process.execPath}" run -A --unstable require.ts "${__filename}" child`; + + cp.exec(cmd, options, common.mustSucceed((stdout, stderr) => { + callback(stdout, stderr); + })); + } + + // Test default encoding, which should be utf8. + run({}, (stdout, stderr) => { + assert.strictEqual(typeof stdout, 'string'); + assert.strictEqual(typeof stderr, 'string'); + assert.strictEqual(stdout, expectedStdout); + assert.strictEqual(stderr, expectedStderr); + }); + + // Test explicit utf8 encoding. + run({ encoding: 'utf8' }, (stdout, stderr) => { + assert.strictEqual(typeof stdout, 'string'); + assert.strictEqual(typeof stderr, 'string'); + assert.strictEqual(stdout, expectedStdout); + assert.strictEqual(stderr, expectedStderr); + }); + + // Test cases that result in buffer encodings. + [undefined, null, 'buffer', 'invalid'].forEach((encoding) => { + run({ encoding }, (stdout, stderr) => { + assert(stdout instanceof Buffer); + assert(stdout instanceof Buffer); + assert.strictEqual(stdout.toString(), expectedStdout); + assert.strictEqual(stderr.toString(), expectedStderr); + }); + }); +} diff --git a/node/_tools/test/parallel/test-child-process-exec-env.js b/node/_tools/test/parallel/test-child-process-exec-env.js new file mode 100644 index 000000000000..d6e0e662a94a --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-env.js @@ -0,0 +1,71 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// 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. + +'use strict'; +const { isWindows } = require('../common'); +const assert = require('assert'); +const exec = require('child_process').exec; +const debug = require('util').debuglog('test'); + +let success_count = 0; +let error_count = 0; +let response = ''; +let child; + +function after(err, stdout, stderr) { + if (err) { + error_count++; + debug(`error!: ${err.code}`); + debug(`stdout: ${JSON.stringify(stdout)}`); + debug(`stderr: ${JSON.stringify(stderr)}`); + assert.strictEqual(err.killed, false); + } else { + success_count++; + assert.notStrictEqual(stdout, ''); + } +} + +if (!isWindows) { + child = exec('/usr/bin/env', { env: { 'HELLO': 'WORLD' } }, after); +} else { + child = exec('set', + { env: { ...process.env, 'HELLO': 'WORLD' } }, + after); +} + +child.stdout.setEncoding('utf8'); +child.stdout.on('data', function(chunk) { + response += chunk; +}); + +process.on('exit', function() { + debug('response: ', response); + assert.strictEqual(success_count, 1); + assert.strictEqual(error_count, 0); + assert.ok(response.includes('HELLO=WORLD')); +}); diff --git a/node/_tools/test/parallel/test-child-process-exec-error.js b/node/_tools/test/parallel/test-child-process-exec-error.js new file mode 100644 index 000000000000..cb58516219d9 --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-error.js @@ -0,0 +1,51 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); + +function test(fn, code, expectPidType = 'number') { + const child = fn('does-not-exist', common.mustCall(function(err) { + assert.strictEqual(err.code, code); + assert(err.cmd.includes('does-not-exist')); + })); + + assert.strictEqual(typeof child.pid, expectPidType); +} + +// With `shell: true`, expect pid (of the shell) +if (common.isWindows) { + test(child_process.exec, 1, 'number'); // Exit code of cmd.exe +} else { + test(child_process.exec, 127, 'number'); // Exit code of /bin/sh +} + +// With `shell: false`, expect no pid +test(child_process.execFile, 'ENOENT', 'undefined'); diff --git a/node/_tools/test/parallel/test-child-process-exec-kill-throws.js b/node/_tools/test/parallel/test-child-process-exec-kill-throws.js new file mode 100644 index 000000000000..c908d15c1a24 --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-kill-throws.js @@ -0,0 +1,42 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// TODO(#2674): The process.argv[3] check should be argv[2], and the +// command passed to exec() should not need to include "run", "-A", +// "--unstable", and "require.ts". + +'use strict'; +// Flags: --expose-internals +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); + +if (process.argv[3] === 'child') { + // Since maxBuffer is 0, this should trigger an error. + console.log('foo'); +} else { + const internalCp = require('internal/child_process'); + + // Monkey patch ChildProcess#kill() to kill the process and then throw. + const kill = internalCp.ChildProcess.prototype.kill; + + internalCp.ChildProcess.prototype.kill = function() { + kill.apply(this, arguments); + throw new Error('mock error'); + }; + + const cmd = `"${process.execPath}" run -A --unstable require.ts "${__filename}" child`; + const options = { maxBuffer: 0, killSignal: 'SIGKILL' }; + + const child = cp.exec(cmd, options, common.mustCall((err, stdout, stderr) => { + // Verify that if ChildProcess#kill() throws, the error is reported. + assert.strictEqual(err.message, 'mock error', err); + assert.strictEqual(stdout, ''); + assert.strictEqual(stderr, ''); + assert.strictEqual(child.killed, true); + })); +} diff --git a/node/_tools/test/parallel/test-child-process-exec-maxbuf.js b/node/_tools/test/parallel/test-child-process-exec-maxbuf.js new file mode 100644 index 000000000000..deafc7a6e73f --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-maxbuf.js @@ -0,0 +1,161 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// TODO(#2674): The "eval" subcommand passed to exec() should be the "-e" option. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); + +function runChecks(err, stdio, streamName, expected) { + assert.strictEqual(err.message, `${streamName} maxBuffer length exceeded`); + assert(err instanceof RangeError); + assert.strictEqual(err.code, 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'); + assert.deepStrictEqual(stdio[streamName], expected); +} + +// default value +{ + const cmd = + `"${process.execPath}" eval "console.log('a'.repeat(1024 * 1024))"`; + + cp.exec(cmd, common.mustCall((err) => { + assert(err instanceof RangeError); + assert.strictEqual(err.message, 'stdout maxBuffer length exceeded'); + assert.strictEqual(err.code, 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'); + })); +} + +// default value +{ + const cmd = + `${process.execPath} eval "console.log('a'.repeat(1024 * 1024 - 1))"`; + + cp.exec(cmd, common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'a'.repeat(1024 * 1024 - 1)); + assert.strictEqual(stderr, ''); + })); +} + +{ + const cmd = `"${process.execPath}" eval "console.log('hello world');"`; + const options = { maxBuffer: Infinity }; + + cp.exec(cmd, options, common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'hello world'); + assert.strictEqual(stderr, ''); + })); +} + +{ + const cmd = 'echo hello world'; + + cp.exec( + cmd, + { maxBuffer: 5 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stdout', 'hello'); + }) + ); +} + +// default value +{ + const cmd = + `"${process.execPath}" eval "console.log('a'.repeat(1024 * 1024))"`; + + cp.exec( + cmd, + common.mustCall((err, stdout, stderr) => { + runChecks( + err, + { stdout, stderr }, + 'stdout', + 'a'.repeat(1024 * 1024) + ); + }) + ); +} + +// default value +{ + const cmd = + `"${process.execPath}" eval "console.log('a'.repeat(1024 * 1024 - 1))"`; + + cp.exec(cmd, common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'a'.repeat(1024 * 1024 - 1)); + assert.strictEqual(stderr, ''); + })); +} + +const unicode = '中文测试'; // length = 4, byte length = 12 + +{ + const cmd = `"${process.execPath}" eval "console.log('${unicode}');"`; + + cp.exec( + cmd, + { maxBuffer: 10 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stdout', '中文测试\n'); + }) + ); +} + +{ + const cmd = `"${process.execPath}" eval "console.error('${unicode}');"`; + + cp.exec( + cmd, + { maxBuffer: 3 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stderr', '中文测'); + }) + ); +} + +{ + const cmd = `"${process.execPath}" eval "console.log('${unicode}');"`; + + const child = cp.exec( + cmd, + { encoding: null, maxBuffer: 10 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stdout', '中文测试\n'); + }) + ); + + child.stdout.setEncoding('utf-8'); +} + +{ + const cmd = `"${process.execPath}" eval "console.error('${unicode}');"`; + + const child = cp.exec( + cmd, + { encoding: null, maxBuffer: 3 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stderr', '中文测'); + }) + ); + + child.stderr.setEncoding('utf-8'); +} + +{ + const cmd = `"${process.execPath}" eval "console.error('${unicode}');"`; + + cp.exec( + cmd, + { encoding: null, maxBuffer: 5 }, + common.mustCall((err, stdout, stderr) => { + const buf = Buffer.from(unicode).slice(0, 5); + runChecks(err, { stdout, stderr }, 'stderr', buf); + }) + ); +} diff --git a/node/_tools/test/parallel/test-child-process-exec-std-encoding.js b/node/_tools/test/parallel/test-child-process-exec-std-encoding.js new file mode 100644 index 000000000000..2ef794a64277 --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-std-encoding.js @@ -0,0 +1,33 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +// TODO(#2674): The process.argv[3] check should be argv[2], and the +// command passed to exec() should not need to include "run", "-A", +// "--unstable", and "require.ts". + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); +const stdoutData = 'foo'; +const stderrData = 'bar'; +const expectedStdout = `${stdoutData}\n`; +const expectedStderr = `${stderrData}\n`; + +if (process.argv[3] === 'child') { + // The following console calls are part of the test. + console.log(stdoutData); + console.error(stderrData); +} else { + const cmd = `"${process.execPath}" run -A --unstable require.ts "${__filename}" child`; + const child = cp.exec(cmd, common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout, expectedStdout); + assert.strictEqual(stderr, expectedStderr); + })); + child.stdout.setEncoding('utf-8'); + child.stderr.setEncoding('utf-8'); +} diff --git a/node/_tools/test/parallel/test-child-process-exec-stdout-stderr-data-string.js b/node/_tools/test/parallel/test-child-process-exec-stdout-stderr-data-string.js new file mode 100644 index 000000000000..d7eb5d3ed741 --- /dev/null +++ b/node/_tools/test/parallel/test-child-process-exec-stdout-stderr-data-string.js @@ -0,0 +1,20 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file + +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +// Taken from Node 18.8.0 +// This file is automatically generated by "node/_tools/setup.ts". Do not modify this file manually + +'use strict'; +// Refs: https://github.com/nodejs/node/issues/7342 +const common = require('../common'); +const assert = require('assert'); +const exec = require('child_process').exec; + +const command = common.isWindows ? 'dir' : 'ls'; + +exec(command).stdout.on('data', common.mustCallAtLeast()); + +exec('fhqwhgads').stderr.on('data', common.mustCallAtLeast((data) => { + assert.strictEqual(typeof data, 'string'); +})); diff --git a/node/child_process.ts b/node/child_process.ts index 74894a6182d2..d63cd9e07adb 100644 --- a/node/child_process.ts +++ b/node/child_process.ts @@ -10,14 +10,19 @@ import { type SpawnSyncResult, stdioStringToArray, } from "./internal/child_process.ts"; -import { validateString } from "./internal/validators.mjs"; +import { validateAbortSignal, validateString } from "./internal/validators.mjs"; import { ERR_CHILD_PROCESS_IPC_REQUIRED, ERR_CHILD_PROCESS_STDIO_MAXBUFFER, ERR_INVALID_ARG_VALUE, ERR_OUT_OF_RANGE, } from "./internal/errors.ts"; -import { getSystemErrorName } from "./util.ts"; +import { + ArrayPrototypePush, + StringPrototypeSlice, +} from "./internal/primordials.mjs"; +import { getSystemErrorName, promisify } from "./util.ts"; +import { createDeferredPromise } from "./internal/util.mjs"; import { process } from "./process.ts"; import { Buffer } from "./buffer.ts"; import { notImplemented } from "./_utils.ts"; @@ -140,6 +145,7 @@ export function spawn( const options = !Array.isArray(argsOrOptions) && argsOrOptions != null ? argsOrOptions : maybeOptions; + validateAbortSignal(options?.signal, "options.signal"); return new ChildProcess(command, args, options); } @@ -186,16 +192,126 @@ export function spawnSync( return _spawnSync(command, args, options); } +interface ExecOptions extends + Pick< + ChildProcessOptions, + | "cwd" + | "env" + | "signal" + | "uid" + | "gid" + | "windowsHide" + > { + encoding?: string; + /** + * Shell to execute the command with. + */ + shell?: string; + timeout?: number; + /** + * Largest amount of data in bytes allowed on stdout or stderr. If exceeded, the child process is terminated and any output is truncated. + */ + maxBuffer?: number; + killSignal?: string | number; +} +type ExecException = ChildProcessError; +type ExecCallback = ( + error: ExecException | null, + stdout?: string | Buffer, + stderr?: string | Buffer, +) => void; + +function normalizeExecArgs( + command: string, + optionsOrCallback?: ExecOptions | ExecCallback, + maybeCallback?: ExecCallback, +) { + let options: ExecFileOptions | undefined = undefined; + let callback: ExecFileCallback | undefined = maybeCallback; + + if (typeof optionsOrCallback === "function") { + callback = optionsOrCallback; + optionsOrCallback = undefined; + } + + // Make a shallow copy so we don't clobber the user's options object. + options = { ...optionsOrCallback }; + options.shell = typeof options.shell === "string" ? options.shell : true; + + return { + file: command, + options: options!, + callback: callback!, + }; +} + +/** + * Spawns a shell executing the given command. + */ +export function exec(command: string): ChildProcess; +export function exec(command: string, options: ExecOptions): ChildProcess; +export function exec(command: string, callback: ExecCallback): ChildProcess; +export function exec( + command: string, + options: ExecOptions, + callback: ExecCallback, +): ChildProcess; +export function exec( + command: string, + optionsOrCallback?: ExecOptions | ExecCallback, + maybeCallback?: ExecCallback, +): ChildProcess { + const opts = normalizeExecArgs(command, optionsOrCallback, maybeCallback); + return execFile(opts.file, opts.options, opts.callback); +} + +interface PromiseWithChild extends Promise { + child: ChildProcess; +} +type ExecOutputForPromisify = { + stdout?: string | Buffer; + stderr?: string | Buffer; +}; +type ExecExceptionForPromisify = ExecException & ExecOutputForPromisify; + +const customPromiseExecFunction = (orig: typeof exec) => { + return (...args: [command: string, options: ExecOptions]) => { + const { promise, resolve, reject } = createDeferredPromise() as unknown as { + promise: PromiseWithChild; + resolve?: (value: ExecOutputForPromisify) => void; + reject?: (reason?: ExecExceptionForPromisify) => void; + }; + + promise.child = orig(...args, (err, stdout, stderr) => { + if (err !== null) { + const _err: ExecExceptionForPromisify = err; + _err.stdout = stdout; + _err.stderr = stderr; + reject && reject(_err); + } else { + resolve && resolve({ stdout, stderr }); + } + }); + + return promise; + }; +}; + +Object.defineProperty(exec, promisify.custom, { + enumerable: false, + value: customPromiseExecFunction(exec), +}); + interface ExecFileOptions extends ChildProcessOptions { encoding?: string; timeout?: number; maxBuffer?: number; - killSignal?: string; + killSignal?: string | number; } interface ChildProcessError extends Error { code?: string | number; killed?: boolean; - signal?: string; + signal?: AbortSignal; cmd?: string; } class ExecFileError extends Error implements ChildProcessError { @@ -263,6 +379,7 @@ export function execFile( timeout: 0, maxBuffer: MAX_BUFFER, killSignal: "SIGTERM", + shell: false, ...options, }; if (!Number.isInteger(execOptions.timeout) || execOptions.timeout < 0) { @@ -281,16 +398,22 @@ export function execFile( execOptions.maxBuffer, ); } - const spawnOptions = { - shell: false, - ...options, + const spawnOptions: SpawnOptions = { + cwd: execOptions.cwd, + env: execOptions.env, + gid: execOptions.gid, + shell: execOptions.shell, + signal: execOptions.signal, + uid: execOptions.uid, + windowsHide: !!execOptions.windowsHide, + windowsVerbatimArguments: !!execOptions.windowsVerbatimArguments, }; const child = spawn(file, args, spawnOptions); let encoding: string | null; - const _stdout: Uint8Array[] = []; - const _stderr: Uint8Array[] = []; + const _stdout: (string | Uint8Array)[] = []; + const _stderr: (string | Uint8Array)[] = []; if ( execOptions.encoding !== "buffer" && Buffer.isEncoding(execOptions.encoding) ) { @@ -300,6 +423,7 @@ export function execFile( } let stdoutLen = 0; let stderrLen = 0; + let killed = false; let exited = false; let timeoutId: number | null; @@ -307,7 +431,7 @@ export function execFile( let cmd = file; - function exithandler(code = 0, signal?: string) { + function exithandler(code = 0, signal?: AbortSignal) { if (exited) return; exited = true; @@ -330,7 +454,7 @@ export function execFile( ) { stdout = _stdout.join(""); } else { - stdout = Buffer.concat(_stdout); + stdout = Buffer.concat(_stdout as Buffer[]); } if ( encoding || @@ -341,7 +465,7 @@ export function execFile( ) { stderr = _stderr.join(""); } else { - stderr = Buffer.concat(_stderr); + stderr = Buffer.concat(_stderr as Buffer[]); } if (!ex && code === 0 && signal === null) { @@ -358,7 +482,7 @@ export function execFile( "Command failed: " + cmd + "\n" + stderr, ); ex.code = code < 0 ? getSystemErrorName(code) : code; - ex.killed = child.killed; + ex.killed = child.killed || killed; ex.signal = signal; } @@ -389,8 +513,9 @@ export function execFile( child.stderr.destroy(); } + killed = true; try { - child.kill(/** TODO use execOptions.killSignal */); + child.kill(execOptions.killSignal); } catch (e) { if (e) { ex = e as ChildProcessError; @@ -412,32 +537,29 @@ export function execFile( } child.stdout.on("data", function onChildStdout(chunk: string | Buffer) { - const encoding = child.stdout?.readableEncoding; - - let chunkBuffer: Buffer; - if (Buffer.isBuffer(chunk)) { - chunkBuffer = chunk; - } else { - if (encoding) { - chunkBuffer = Buffer.from(chunk as string, encoding); - } else { - // TODO choose what to do if encoding is not set but chunk is a string (should not happen) - chunkBuffer = Buffer.from(""); - } + // Do not need to count the length + if (execOptions.maxBuffer === Infinity) { + ArrayPrototypePush(_stdout, chunk); + return; } - const length = chunkBuffer.length; + const encoding = child.stdout?.readableEncoding; + const length = encoding + ? Buffer.byteLength(chunk, encoding) + : chunk.length; + const slice = encoding + ? StringPrototypeSlice + : (buf: string | Buffer, ...args: number[]) => buf.slice(...args); stdoutLen += length; if (stdoutLen > execOptions.maxBuffer) { const truncatedLen = execOptions.maxBuffer - (stdoutLen - length); - const truncatedSlice = chunkBuffer.slice(0, truncatedLen).valueOf(); - _stdout.push(truncatedSlice); + ArrayPrototypePush(_stdout, slice(chunk, 0, truncatedLen)); ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stdout"); kill(); } else { - _stdout.push(chunkBuffer.valueOf()); + ArrayPrototypePush(_stdout, chunk); } }); } @@ -448,32 +570,29 @@ export function execFile( } child.stderr.on("data", function onChildStderr(chunk: string | Buffer) { - const encoding = child.stderr?.readableEncoding; - - let chunkBuffer: Buffer; - if (Buffer.isBuffer(chunk)) { - chunkBuffer = chunk; - } else { - if (encoding) { - chunkBuffer = Buffer.from(chunk as string, encoding); - } else { - // TODO choose what to do if encoding is not set but chunk is a string (should not happen) - chunkBuffer = Buffer.from(""); - } + // Do not need to count the length + if (execOptions.maxBuffer === Infinity) { + ArrayPrototypePush(_stderr, chunk); + return; } - const length = chunkBuffer.length; + const encoding = child.stderr?.readableEncoding; + const length = encoding + ? Buffer.byteLength(chunk, encoding) + : chunk.length; + const slice = encoding + ? StringPrototypeSlice + : (buf: string | Buffer, ...args: number[]) => buf.slice(...args); stderrLen += length; if (stderrLen > execOptions.maxBuffer) { const truncatedLen = execOptions.maxBuffer - (stderrLen - length); - const truncatedSlice = chunkBuffer.slice(0, truncatedLen).valueOf(); - _stderr.push(truncatedSlice); + ArrayPrototypePush(_stderr, slice(chunk, 0, truncatedLen)); ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER("stderr"); kill(); } else { - _stderr.push(chunkBuffer.valueOf()); + ArrayPrototypePush(_stderr, chunk); } }); } @@ -488,5 +607,13 @@ export function execSync() { throw new Error("execSync is currently not supported"); } -export default { fork, spawn, execFile, execSync, ChildProcess, spawnSync }; +export default { + fork, + spawn, + exec, + execFile, + execSync, + ChildProcess, + spawnSync, +}; export { ChildProcess }; diff --git a/node/internal/child_process.ts b/node/internal/child_process.ts index 11cc6ee21f5c..2246950719fa 100644 --- a/node/internal/child_process.ts +++ b/node/internal/child_process.ts @@ -116,6 +116,7 @@ export class ChildProcess extends EventEmitter { const { env = {}, stdio = ["pipe", "pipe", "pipe"], + cwd, shell = false, signal, } = options || {}; @@ -138,6 +139,7 @@ export class ChildProcess extends EventEmitter { try { this.#process = Deno.spawnChild(cmd, { args: cmdArgs, + cwd, env: stringEnv, stdin: toDenoStdio(stdin as NodeStdio | number), stdout: toDenoStdio(stdout as NodeStdio | number), @@ -572,3 +574,9 @@ export function spawnSync( } return result; } + +export default { + ChildProcess, + stdioStringToArray, + spawnSync, +}; diff --git a/node/module_all.ts b/node/module_all.ts index 16172df240eb..da66a88b02db 100644 --- a/node/module_all.ts +++ b/node/module_all.ts @@ -27,6 +27,7 @@ import http from "./http.ts"; import http2 from "./http2.ts"; import https from "./https.ts"; import inspector from "./inspector.ts"; +import internalCp from "./internal/child_process.ts"; import internalCryptoCertificate from "./internal/crypto/certificate.ts"; import internalCryptoCipher from "./internal/crypto/cipher.ts"; import internalCryptoDiffiehellman from "./internal/crypto/diffiehellman.ts"; @@ -115,6 +116,7 @@ export default { http2, https, inspector, + "internal/child_process": internalCp, "internal/crypto/certificate": internalCryptoCertificate, "internal/crypto/cipher": internalCryptoCipher, "internal/crypto/diffiehellman": internalCryptoDiffiehellman,