< Summary - Envilder CLI

Information
Class: src/envilder/core/infrastructure/process/CrossSpawnProcessRunner.ts
Assembly: Default
File(s): src/envilder/core/infrastructure/process/CrossSpawnProcessRunner.ts
Tag: 561_41292037308
Line coverage
100%
Covered lines: 58
Uncovered lines: 0
Coverable lines: 58
Total lines: 168
Line coverage: 100%
Branch coverage
96%
Covered branches: 27
Total branches: 28
Branch coverage: 96.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/infrastructure/process/CrossSpawnProcessRunner.ts

#LineLine coverage
 1import type { ChildProcess } from 'node:child_process';
 2import spawn from 'cross-spawn';
 3import {
 4  CommandNotExecutableError,
 5  CommandNotFoundError,
 6  CommandStartError,
 7  EnvironmentTooLargeError,
 8} from '../../domain/errors/DomainErrors.js';
 9import type {
 10  IProcessRunner,
 11  ProcessExit,
 12  ProcessRequest,
 13} from '../../domain/ports/IProcessRunner.js';
 14
 1015const FORWARDED_SIGNALS = ['SIGTERM', 'SIGHUP'] as const;
 16
 17/**
 18 * Starts a child process with `cross-spawn`: argv-based, no shell, and with
 19 * Windows `.cmd`/`.bat` shims (npm, npx) resolved safely.
 20 *
 21 * While the child runs, termination signals received by Envilder are
 22 * forwarded to it and Envilder waits for it to exit, with no timeout of its
 23 * own: escalation belongs to the process manager. `SIGINT` is not forwarded
 24 * when attached to a terminal, because the terminal already delivers it to
 25 * the whole process group; Envilder only stays alive until the child exits.
 26 */
 27export class CrossSpawnProcessRunner implements IProcessRunner {
 28  run(request: ProcessRequest): Promise<ProcessExit> {
 3329    return new Promise((resolve, reject) => {
 30      let child: ChildProcess;
 3331      try {
 3332        child = spawn(request.command, request.args, {
 33          env: request.env,
 34          stdio: 'inherit',
 35          shell: false,
 36        });
 37      } catch (error) {
 1038        reject(toSpawnError(error, request.command));
 1039        return;
 40      }
 41
 2342      const removeSignalForwarding = forwardSignals(child);
 2343      let settled = false;
 2344      const settle = (finish: () => void) => {
 3745        if (settled) {
 1446          return;
 47        }
 2348        settled = true;
 2349        removeSignalForwarding();
 2350        finish();
 51      };
 52
 2353      let spawned = false;
 2354      child.once('spawn', () => {
 1455        spawned = true;
 56      });
 57      // Kept attached for the child's whole life: a later 'error' with no
 58      // listener would crash Envilder while the child is still running.
 2359      child.on('error', (error) => {
 1260        if (spawned && !isSpawnFailure(error)) {
 61          // For example a failed signal delivery (EPERM from kill): the child
 62          // is still running, so keep forwarding signals and wait for it.
 363          return;
 64        }
 965        settle(() => reject(toSpawnError(error, request.command)));
 66      });
 2367      const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
 2868        settle(() => resolve({ code, signal }));
 69      };
 2370      child.once('exit', onExit);
 2371      child.once('close', onExit);
 72    });
 73  }
 74}
 75
 76function forwardSignals(child: ChildProcess): () => void {
 2377  const listeners = new Map<NodeJS.Signals, () => void>();
 6978  const forward = (signal: NodeJS.Signals) => () => {
 579    if (child.exitCode === null && child.signalCode === null) {
 580      child.kill(signal);
 81    }
 82  };
 83
 2384  for (const signal of FORWARDED_SIGNALS) {
 4685    listeners.set(signal, forward(signal));
 86  }
 87  // Listening to SIGINT also keeps Envilder alive until the child exits.
 2388  const forwardInterrupt = forward('SIGINT');
 2389  listeners.set('SIGINT', () => {
 290    if (!process.stdin.isTTY) {
 191      forwardInterrupt();
 92    }
 93  });
 94
 2395  for (const [signal, listener] of listeners) {
 6996    process.on(signal, listener);
 97  }
 2398  return () => {
 2399    for (const [signal, listener] of listeners) {
 69100      process.removeListener(signal, listener);
 101    }
 102  };
 103}
 104
 105/**
 106 * Whether an error reports that the command could not be started. cross-spawn
 107 * reports an unresolvable command on Windows after the `cmd.exe` wrapper has
 108 * spawned, with a `spawn <command>` syscall, so the syscall is checked rather
 109 * than only the moment the error arrives.
 110 */
 111function isSpawnFailure(error: unknown): boolean {
 3112  const syscall = (error as NodeJS.ErrnoException | undefined)?.syscall;
 3113  return typeof syscall === 'string' && syscall.startsWith('spawn');
 114}
 115
 116/**
 117 * Known reasons a command exists but cannot be executed (exit code 126).
 118 * Any other error code gets a generic reason with the code.
 119 */
 10120const NOT_EXECUTABLE_REASONS: Record<string, string> = {
 121  ETXTBSY: 'text file busy',
 122  ELOOP: 'too many levels of symbolic links',
 123  ENOEXEC: 'exec format error',
 124  EISDIR: 'is a directory',
 125  ENOTDIR: 'a path component is not a directory',
 126  ENAMETOOLONG: 'file name too long',
 127};
 10128const GENERIC_NOT_EXECUTABLE_REASON = 'the operating system refused to run it';
 129
 130/** The system could not provide what starting a process needs (exit 125). */
 10131const RESOURCE_ERRORS = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM']);
 132
 133/**
 134 * Maps a spawn failure to a domain error. Node throws some failures
 135 * synchronously and emits others on the child, so both paths use this.
 136 *
 137 * - ENOENT: the command was not found (127).
 138 * - E2BIG, resource exhaustion and failures without a code: Envilder could
 139 *   not start the command (125).
 140 * - EACCES and every other code: the command cannot be executed (126).
 141 *
 142 * Messages name the command only, never its arguments.
 143 */
 144function toSpawnError(error: unknown, command: string): Error {
 19145  const code = (error as NodeJS.ErrnoException | undefined)?.code;
 19146  if (typeof code !== 'string' || code === '') {
 1147    return new CommandStartError(command, 'the process could not be created');
 148  }
 18149  if (code === 'ENOENT') {
 2150    return new CommandNotFoundError(command);
 151  }
 16152  if (code === 'E2BIG') {
 1153    return new EnvironmentTooLargeError();
 154  }
 15155  if (RESOURCE_ERRORS.has(code)) {
 4156    return new CommandStartError(
 157      command,
 158      `system resources unavailable (${code})`,
 159    );
 160  }
 11161  if (code === 'EACCES') {
 3162    return new CommandNotExecutableError(command);
 163  }
 8164  const reason = Object.hasOwn(NOT_EXECUTABLE_REASONS, code)
 165    ? NOT_EXECUTABLE_REASONS[code]
 166    : GENERIC_NOT_EXECUTABLE_REASON;
 19167  return new CommandNotExecutableError(command, `${reason} (${code})`);
 168}