current commit
261029.6 · child_process: add subprocess.timedOut
// 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 {
  ArrayIsArray,
  ArrayPrototypeFilter,
  ArrayPrototypeIncludes,
  ArrayPrototypeJoin,
  ArrayPrototypeLastIndexOf,
  ArrayPrototypePush,
  ArrayPrototypePushApply,
  ArrayPrototypeSlice,
  ArrayPrototypeSort,
  ArrayPrototypeSplice,
  ArrayPrototypeUnshift,
  ObjectAssign,
  ObjectDefineProperty,
  ObjectPrototypeHasOwnProperty,
  PromiseWithResolvers,
  RegExpPrototypeExec,
  RegExpPrototypeSymbolReplace,
  SafeSet,
  StringPrototypeIncludes,
  StringPrototypeIndexOf,
  StringPrototypeSlice,
  StringPrototypeToUpperCase,
  SymbolDispose,
} = primordials;

const {
  assignFunctionName,
  convertToValidSignal,
  getSystemErrorName,
  kEmptyObject,
  promisify,
} = require('internal/util');
const { isArrayBufferView } = require('internal/util/types');
let debug = require('internal/util/debuglog').debuglog(
  'child_process',
  (fn) => {
    debug = fn;
  },
);
const { Buffer } = require('buffer');
const { Pipe, constants: PipeConstants } = internalBinding('pipe_wrap');

const {
  AbortError,
  codes: {
    ERR_CHILD_PROCESS_IPC_REQUIRED,
    ERR_CHILD_PROCESS_STDIO_MAXBUFFER,
    ERR_INVALID_ARG_TYPE,
    ERR_INVALID_ARG_VALUE,
  },
  genericNodeError,
} = require('internal/errors');
const { clearTimeout, setTimeout } = require('timers');
const { getValidatedPath } = require('internal/fs/utils');
const { getOptionValue } = require('internal/options');
const {
  validateAbortSignal,
  validateArray,
  validateBoolean,
  validateFunction,
  validateInteger,
  validateInt32,
  validateNumber,
  validateObject,
  validateString,
} = require('internal/validators');
const child_process = require('internal/child_process');
const {
  getValidStdio,
  setupChannel,
  ChildProcess,
  stdioStringToArray,
} = child_process;

const { getEnvPairs } = internalBinding('process_wrap');

const MAX_BUFFER = 1024 * 1024;

const permission = require('internal/process/permission');

const isZOS = process.platform === 'os390';
let addAbortListener;

/**
 * Spawns a new Node.js process + fork.
 * @param {string|URL} modulePath
 * @param {string[]} [args]
 * @param {{
 *   cwd?: string | URL;
 *   detached?: boolean;
 *   env?: Record<string, string>;
 *   execPath?: string;
 *   execArgv?: string[];
 *   gid?: number;
 *   serialization?: string;
 *   signal?: AbortSignal;
 *   killSignal?: string | number;
 *   silent?: boolean;
 *   stdio?: Array | string;
 *   uid?: number;
 *   windowsVerbatimArguments?: boolean;
 *   timeout?: number;
 *   }} [options]
 * @returns {ChildProcess}
 */
function fork(modulePath, args = [], options) {
  modulePath = getValidatedPath(modulePath, 'modulePath');

  // Get options and args arguments.
  let execArgv;

  if (args == null) {
    args = [];
  } else if (typeof args === 'object' && !ArrayIsArray(args)) {
    options = args;
    args = [];
  } else {
    validateArray(args, 'args');
  }

  if (options != null) {
    validateObject(options, 'options');
  }
  options = { __proto__: null, ...options, shell: false };
  options.execPath ||= process.execPath;
  validateArgumentNullCheck(options.execPath, 'options.execPath');

  // Prepare arguments for fork:
  execArgv = options.execArgv || process.execArgv;
  validateArgumentsNullCheck(execArgv, 'options.execArgv');

  if (execArgv === process.execArgv && process._eval != null) {
    const index = ArrayPrototypeLastIndexOf(execArgv, process._eval);
    if (index > 0) {
      // Remove the -e switch to avoid fork bombing ourselves.
      execArgv = ArrayPrototypeSlice(execArgv);
      ArrayPrototypeSplice(execArgv, index - 1, 2);
    }
  }

  args = [...execArgv, modulePath, ...args];

  if (typeof options.stdio === 'string') {
    options.stdio = stdioStringToArray(options.stdio, 'ipc');
  } else if (!ArrayIsArray(options.stdio)) {
    // Use a separate fd=3 for the IPC channel. Inherit stdin, stdout,
    // and stderr from the parent if silent isn't set.
    options.stdio = stdioStringToArray(
      options.silent ? 'pipe' : 'inherit',
      'ipc');
  } else if (!ArrayPrototypeIncludes(options.stdio, 'ipc')) {
    throw new ERR_CHILD_PROCESS_IPC_REQUIRED('options.stdio');
  }

  return spawn(options.execPath, args, options);
}

function _forkChild(fd, serializationMode) {
  // set process.send()
  const p = new Pipe(PipeConstants.IPC);
  p.open(fd);
  p.unref();
  const control = setupChannel(process, p, serializationMode);
  process.on('newListener', function onNewListener(name) {
    if (name === 'message' || name === 'disconnect') control.refCounted();
  });
  process.on('removeListener', function onRemoveListener(name) {
    if (name === 'message' || name === 'disconnect') control.unrefCounted();
  });
}

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 = { __proto__: null, ...options };
  options.shell = typeof options.shell === 'string' ? options.shell : true;

  return {
    file: command,
    options: options,
    callback: callback,
  };
}

/**
 * Spawns a shell executing the given command.
 * @param {string} command
 * @param {{
 *   cmd?: string;
 *   env?: Record<string, string>;
 *   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}
 */
function exec(command, options, callback) {
  const opts = normalizeExecArgs(command, options, callback);
  return module.exports.execFile(opts.file,
                                 opts.options,
                                 opts.callback);
}

const customPromiseExecFunction = (orig) => {
  return assignFunctionName(orig.name, function(...args) {
    const { promise, resolve, reject } = PromiseWithResolvers();

    promise.child = orig(...args, (err, stdout, stderr) => {
      if (err !== null) {
        err.stdout = stdout;
        err.stderr = stderr;
        reject(err);
      } else {
        resolve({ stdout, stderr });
      }
    });

    return promise;
  });
};

ObjectDefineProperty(exec, promisify.custom, {
  __proto__: null,
  enumerable: false,
  value: customPromiseExecFunction(exec),
});

function normalizeExecFileArgs(file, args, options, callback) {
  if (ArrayIsArray(args)) {
    args = ArrayPrototypeSlice(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;
  }

  args ??= [];

  if (typeof options === 'function') {
    callback = options;
  } else if (options != null) {
    validateObject(options, 'options');
  }

  options ??= kEmptyObject;

  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 };
}

/**
 * Spawns the specified file as a shell.
 * @param {string} file
 * @param {string[]} [args]
 * @param {{
 *   cwd?: string | URL;
 *   env?: Record<string, string>;
 *   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}
 */
function execFile(file, args, options, callback) {
  ({ file, args, options, callback } = normalizeExecFileArgs(file, args, options, callback));

  options = {
    __proto__: null,
    encoding: 'utf8',
    timeout: 0,
    maxBuffer: MAX_BUFFER,
    killSignal: 'SIGTERM',
    cwd: null,
    env: null,
    shell: false,
    ...options,
  };

  // Validate the timeout, if present.
  validateTimeout(options.timeout);

  // 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,
    windowsHide: !!options.windowsHide,
    windowsVerbatimArguments: !!options.windowsVerbatimArguments,
  });

  let encoding;
  const _stdout = [];
  const _stderr = [];
  if (options.encoding !== 'buffer' && Buffer.isEncoding(options.encoding)) {
    encoding = options.encoding;
  } else {
    encoding = null;
  }
  let stdoutLen = 0;
  let stderrLen = 0;
  let killed = false;
  let exited = false;
  let timeoutId;

  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?.readableEncoding
      )) {
      stdout = ArrayPrototypeJoin(_stdout, '');
    } else {
      stdout = Buffer.concat(_stdout);
    }
    if (encoding ||
      (
        child.stderr?.readableEncoding
      )) {
      stderr = ArrayPrototypeJoin(_stderr, '');
    } else {
      stderr = Buffer.concat(_stderr);
    }

    if (!ex && code === 0 && signal === null) {
      callback(null, stdout, stderr);
      return;
    }

    if (args?.length)
      cmd += ` ${ArrayPrototypeJoin(args, ' ')}`;

    ex ||= genericNodeError(`Command failed: ${cmd}\n${stderr}`, {
      code: code < 0 ? getSystemErrorName(code) : code,
      killed: child.killed || killed,
      signal: signal,
    });

    ex.cmd = cmd;
    ex.timedOut = child.timedOut;
    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 {
      return child.kill(options.killSignal);
    } catch (e) {
      ex = e;
      exithandler();
    }
    return false;
  }

  if (options.timeout > 0) {
    timeoutId = setTimeout(function delayedKill() {
      // The child may have exited already, with a descendant keeping its
      // stdio open: kill() reports whether the signal was actually sent.
      child.timedOut = 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(_stdout, chunk);
        return;
      }
      const encoding = child.stdout.readableEncoding;
      const length = encoding ?
        Buffer.byteLength(chunk, encoding) :
        chunk.length;
      const slice = encoding ? StringPrototypeSlice :
        (buf, ...args) => buf.slice(...args);
      stdoutLen += length;

      if (stdoutLen > options.maxBuffer) {
        const truncatedLen = options.maxBuffer - (stdoutLen - length);
        ArrayPrototypePush(_stdout, slice(chunk, 0, truncatedLen));

        ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER('stdout');
        kill();
      } else {
        ArrayPrototypePush(_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(_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(_stderr,
                           chunk.slice(0, truncatedLen));

        ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER('stderr');
        kill();
      } else {
        ArrayPrototypePush(_stderr, chunk);
      }
    });
  }

  child.addListener('close', exithandler);
  child.addListener('error', errorhandler);

  return child;
}

ObjectDefineProperty(execFile, promisify.custom, {
  __proto__: null,
  enumerable: false,
  value: customPromiseExecFunction(execFile),
});

function copyProcessEnvToEnv(env, name, optionEnv) {
  if (process.env[name] &&
      (!optionEnv ||
       !ObjectPrototypeHasOwnProperty(optionEnv, name))) {
    env[name] = process.env[name];
  }
}

let permissionModelFlagsToCopy;

function getPermissionModelFlagsToCopy() {
  if (permissionModelFlagsToCopy === undefined) {
    permissionModelFlagsToCopy = [...permission.availableFlags(), '--permission'];
  }
  return permissionModelFlagsToCopy;
}

function hasPermissionFlagInEnv(nodeOptions) {
  // Parse NODE_OPTIONS into individual tokens and check if any token
  // is an actual --permission or --permission-audit flag. We use exact
  // token matching rather than substring matching to avoid false positives
  // when unrelated option values contain '--permission' (e.g.,
  // --title=--permission).
  if (!nodeOptions) return false;
  const tokens = nodeOptions.split(/\s+/);
  return tokens.some((token) =>
    token === '--permission' ||
    token.startsWith('--permission=') ||
    token === '--permission-audit' ||
    token.startsWith('--permission-audit='),
  );
}

function copyPermissionModelFlagsToEnv(env, key, args) {
  // Do not override if permission was already passed to file
  if (args.includes('--permission') || args.includes('--permission-audit') ||
      hasPermissionFlagInEnv(env[key])) {
    return;
  }

  // Enforcing the permission model removed the variables --allow-env does not
  // grant access to at startup, so everything left in the environment a child
  // inherits is accessible. A child can never see more than its parent had.
  const allowAllEnv = !permission.isAuditMode();

  const flagsToCopy = getPermissionModelFlagsToCopy();
  const copiedFlags = new SafeSet();
  for (const arg of process.execArgv) {
    if (allowAllEnv && arg.startsWith('--allow-env')) {
      continue;
    }
    for (const flag of flagsToCopy) {
      if (arg.startsWith(flag)) {
        copiedFlags.add(flag);
        env[key] = `${env[key] ? env[key] + ' ' + arg : arg}`;
      }
    }
  }

  // Flags set through --config-file are not part of process.execArgv.
  for (const flag of flagsToCopy) {
    if (copiedFlags.has(flag) || (allowAllEnv && flag === '--allow-env')) {
      continue;
    }
    const value = getOptionValue(flag);
    if (value === true) {
      env[key] = `${env[key] ? env[key] + ' ' : ''}${flag}`;
    } else if (ArrayIsArray(value)) {
      for (const item of value) {
        // Values may contain spaces or quotes (e.g. the implicitly allowed
        // entry point), so quote them as NODE_OPTIONS expects.
        const quoted = RegExpPrototypeSymbolReplace(/["\\]/g, item, '\\$&');
        env[key] = `${env[key] ? env[key] + ' ' : ''}"${flag}=${quoted}"`;
      }
    }
  }

  if (allowAllEnv) {
    env[key] = `${env[key] ? env[key] + ' ' : ''}--allow-env=*`;
  }
}

let emittedDEP0190Already = false;
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');

  options = { __proto__: null, ...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) {
    validateInt32(options.uid, 'options.uid');
  }

  // Validate the gid, if present.
  if (options.gid != null) {
    validateInt32(options.gid, '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');
    if (args.length > 0 && !emittedDEP0190Already) {
      process.emitWarning(
        'Passing args to a child process with shell option true can lead to security ' +
        'vulnerabilities, as the arguments are not escaped, only concatenated.',
        'DeprecationWarning',
        'DEP0190');
      emittedDEP0190Already = true;
    }

    const command = args.length > 0 ? `${file} ${ArrayPrototypeJoin(args, ' ')}` : file;
    // 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);
  }

  let envPairs;
  if (!options.env && !permission.isEnabled()) {
    // Default: the child inherits this process's environment. Take it as
    // 'KEY=value' strings in one native pass over the environment block
    // instead of copying process.env (one interceptor round trip and one
    // getenv() scan per variable). Everything copyProcessEnvToEnv() would
    // propagate below is part of it by definition, and entries of the real
    // environment block cannot contain null bytes.
    envPairs = getEnvPairs();
    if (process.platform === 'win32') {
      envPairs = dedupeWindowsEnvPairs(envPairs);
    }
  } else {
    // Shallow copy to guarantee changes won't impact process.env
    const env = options.env || { ...process.env };
    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);

    if (isZOS) {
      // The following environment variables must always propagate if set.
      copyProcessEnvToEnv(env, '_BPXK_AUTOCVT', options.env);
      copyProcessEnvToEnv(env, '_CEE_RUNOPTS', options.env);
      copyProcessEnvToEnv(env, '_TAG_REDIR_ERR', options.env);
      copyProcessEnvToEnv(env, '_TAG_REDIR_IN', options.env);
      copyProcessEnvToEnv(env, '_TAG_REDIR_OUT', options.env);
      copyProcessEnvToEnv(env, 'STEPLIB', options.env);
      copyProcessEnvToEnv(env, 'LIBPATH', options.env);
      copyProcessEnvToEnv(env, '_EDC_SIG_DFLT', options.env);
      copyProcessEnvToEnv(env, '_EDC_SUSV3', options.env);
    }

    if (permission.isEnabled()) {
      copyPermissionModelFlagsToEnv(env, 'NODE_OPTIONS', args);
    }

    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.
    __proto__: null,
    ...options,
    args,
    cwd,
    detached: !!options.detached,
    envPairs,
    file,
    windowsHide: !!options.windowsHide,
    windowsVerbatimArguments: !!windowsVerbatimArguments,
  };
}

/**
 * Windows environment variable names are case-insensitive: keep only the
 * first entry for each name in lexicographic order of the names, exactly like
 * the key filtering applied to a user-supplied `options.env`.
 * @param {string[]} envPairs 'KEY=value' strings
 * @returns {string[]}
 */
function dedupeWindowsEnvPairs(envPairs) {
  const keyed = [];
  for (let i = 0; i < envPairs.length; i++) {
    const pair = envPairs[i];
    // Names never start with '=' here (hidden variables are not enumerated),
    // so the first '=' ends the name.
    const eq = StringPrototypeIndexOf(pair, '=');
    ArrayPrototypePush(keyed, { key: eq === -1 ? pair : StringPrototypeSlice(pair, 0, eq), pair });
  }
  ArrayPrototypeSort(keyed, (a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
  const sawKey = new SafeSet();
  const result = [];
  for (let i = 0; i < keyed.length; i++) {
    const uppercaseKey = StringPrototypeToUpperCase(keyed[i].key);
    if (sawKey.has(uppercaseKey)) continue;
    sawKey.add(uppercaseKey);
    ArrayPrototypePush(result, keyed[i].pair);
  }
  return result;
}

function abortChildProcess(child, killSignal, reason) {
  if (!child)
    return;
  try {
    if (child.kill(killSignal)) {
      child.emit('error', new AbortError(undefined, { cause: reason }));
    }
  } catch (err) {
    child.emit('error', err);
  }
}

/**
 * Spawns a new process using the given `file`.
 * @param {string} file
 * @param {string[]} [args]
 * @param {{
 *   cwd?: string | URL;
 *   env?: Record<string, string>;
 *   argv0?: string;
 *   stdio?: Array | string;
 *   detached?: boolean;
 *   uid?: number;
 *   gid?: number;
 *   serialization?: string;
 *   shell?: boolean | string;
 *   windowsVerbatimArguments?: boolean;
 *   windowsHide?: boolean;
 *   signal?: AbortSignal;
 *   timeout?: number;
 *   killSignal?: string | number;
 *   }} [options]
 * @returns {ChildProcess}
 */
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 {
          // The child may have exited already, with a descendant keeping its
          // stdio open: kill() reports whether the signal was actually sent.
          child.timedOut = child.kill(killSignal);
        } catch (err) {
          child.emit('error', err);
        }
        timeoutId = null;
      }
    }, options.timeout);

    // 'close' always fires after 'exit', or after 'error' on a spawn failure.
    child.once('close', () => {
      if (timeoutId) {
        clearTimeout(timeoutId);
        timeoutId = null;
      }
    });
  }

  if (options.signal) {
    const signal = options.signal;
    if (signal.aborted) {
      process.nextTick(onAbortListener);
    } else {
      addAbortListener ??= require('internal/events/abort_listener').addAbortListener;
      const disposable = addAbortListener(signal, onAbortListener);
      child.once('exit', disposable[SymbolDispose]);
    }

    function onAbortListener() {
      abortChildProcess(child, killSignal, options.signal.reason);
    }
  }

  return child;
}

/**
 * Spawns a new process synchronously using the given `file`.
 * @param {string} file
 * @param {string[]} [args]
 * @param {{
 *   cwd?: string | URL;
 *   input?: string | Buffer | TypedArray | DataView;
 *   argv0?: string;
 *   stdio?: string | Array;
 *   env?: Record<string, string>;
 *   uid?: number;
 *   gid?: number;
 *   timeout?: number;
 *   killSignal?: string | number;
 *   maxBuffer?: number;
 *   encoding?: string;
 *   shell?: boolean | string;
 *   windowsVerbatimArguments?: boolean;
 *   windowsHide?: boolean;
 *   }} [options]
 * @returns {{
 *   pid: number;
 *   output: Array;
 *   stdout: Buffer | string | null;
 *   stderr: Buffer | string | null;
 *   status: number | null;
 *   signal: string | null;
 *   error: Error;
 *   }}
 */
function spawnSync(file, args, options) {
  options = {
    __proto__: null,
    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]?.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);
      }
    }
  }

  return child_process.spawnSync(options);
}


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(args, ' ');
    if (ret.stderr && ret.stderr.length > 0)
      msg += `\n${ret.stderr.toString()}`;
    err = genericNodeError(msg, ret);
  }
  return err;
}

/**
 * Spawns a file as a shell synchronously.
 * @param {string} file
 * @param {string[]} [args]
 * @param {{
 *   cwd?: string | URL;
 *   input?: string | Buffer | TypedArray | DataView;
 *   stdio?: string | Array;
 *   env?: Record<string, string>;
 *   uid?: number;
 *   gid?: number;
 *   timeout?: number;
 *   killSignal?: string | number;
 *   maxBuffer?: number;
 *   encoding?: string;
 *   windowsHide?: boolean;
 *   shell?: boolean | string;
 *   }} [options]
 * @returns {Buffer | string | null}
 */
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];
  ArrayPrototypePushApply(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 | URL;
 *   input?: string | Buffer | TypedArray | DataView;
 *   stdio?: string | Array;
 *   env?: Record<string, string>;
 *   shell?: string;
 *   uid?: number;
 *   gid?: number;
 *   timeout?: number;
 *   killSignal?: string | number;
 *   maxBuffer?: number;
 *   encoding?: string;
 *   windowsHide?: boolean;
 *   }} [options]
 * @returns {Buffer | string | null}
 */
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;
}


function validateArgumentNullCheck(arg, propName) {
  if (typeof arg === 'string' && StringPrototypeIncludes(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) {
    validateInteger(timeout, 'timeout', 0);
  }
}


function validateMaxBuffer(maxBuffer) {
  if (maxBuffer != null) {
    validateNumber(maxBuffer, 'options.maxBuffer', 0);
  }
}


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);
  }
}

module.exports = {
  _forkChild,
  ChildProcess,
  exec,
  execFile,
  execFileSync,
  execSync,
  fork,
  spawn,
  spawnSync,
};