All files / src/utils spawn-with-kill.ts

97.22% Statements 35/36
60% Branches 3/5
100% Functions 9/9
100% Lines 35/35

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 11040x                                   40x                       40x       12x 12x 12x   12x           12x 12x 12x   12x 12x   12x 7x     12x 3x     12x 12x 12x 12x 12x     12x   12x   12x           12x 4x 4x   4x           4x 1x 1x               12x 12x       12x 1x              
import { spawn, ChildProcess } from 'child_process';
 
export interface ExecWithKillResult {
  stdout: string;
  stderr: string;
  /** True if the process was killed due to timeout */
  killed: boolean;
  /** Process exit code (null if killed by signal) */
  exitCode: number | null;
}
 
export interface ExecWithKillOptions {
  cwd: string;
  timeout: number;
  /** Grace period in ms after SIGTERM before sending SIGKILL (default: 5000) */
  sigkillGraceMs?: number;
}
 
const DEFAULT_SIGKILL_GRACE_MS = 5000;
 
/**
 * Executes a command using `spawn` with `detached: true` so the child becomes
 * a new process-group leader. On timeout, kills the **entire process group**
 * via `process.kill(-pid, 'SIGTERM')`, then falls back to SIGKILL after a
 * grace period if the group is still alive.
 *
 * @param command The shell command string to execute
 * @param options Execution options (cwd, timeout, sigkillGraceMs)
 * @returns Promise resolving to `{ stdout, stderr, killed, exitCode }`
 */
export function execWithProcessGroupKill(
  command: string,
  options: ExecWithKillOptions,
): Promise<ExecWithKillResult> {
  return new Promise((resolve) => {
    const shell = process.env.SHELL || '/bin/bash';
    const sigkillGraceMs = options.sigkillGraceMs ?? DEFAULT_SIGKILL_GRACE_MS;
 
    const childProcess: ChildProcess = spawn(shell, ['-c', command], {
      cwd: options.cwd,
      detached: true, // child becomes new session/process-group leader
      stdio: ['pipe', 'pipe', 'pipe'],
    });
 
    let killed = false;
    let resolved = false;
    let extraStderr = '';
 
    const stdoutChunks: Buffer[] = [];
    const stderrChunks: Buffer[] = [];
 
    childProcess.stdout?.on('data', (chunk: Buffer) => {
      stdoutChunks.push(chunk);
    });
 
    childProcess.stderr?.on('data', (chunk: Buffer) => {
      stderrChunks.push(chunk);
    });
 
    const doResolve = (exitCode: number | null) => {
      Iif (resolved) return;
      resolved = true;
      clearTimeout(timeoutTimer);
      clearTimeout(sigkillTimer);
 
      // Concatenate buffered chunks
      const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
      const stderr =
        Buffer.concat(stderrChunks).toString('utf-8') + extraStderr;
 
      resolve({ stdout, stderr, killed, exitCode });
    };
 
    // Timeout handler
    let sigkillTimer: ReturnType<typeof setTimeout>;
 
    const timeoutTimer = setTimeout(() => {
      killed = true;
      try {
        // Kill entire process group (negative PID)
        process.kill(-childProcess.pid!, 'SIGTERM');
      } catch {
        // Process may have already exited
      }
 
      // Schedule SIGKILL fallback
      sigkillTimer = setTimeout(() => {
        try {
          process.kill(-childProcess.pid!, 'SIGKILL');
        } catch {
          // Process already dead
        }
      }, sigkillGraceMs);
    }, options.timeout);
 
    // Handle process exit
    childProcess.on('close', (code) => {
      doResolve(code);
    });
 
    // Handle spawn errors (e.g., command not found)
    childProcess.on('error', (err) => {
      extraStderr += `\nSpawn error: ${err.message}`;
      // If there's no close event coming (e.g. ENOENT), resolve now
      // Spawn errors on 'error' are followed by 'close' in Node >= 16
      // but we resolve on close, so just append the error info
    });
  });
}