五、进程执行——spawn、超时与进程树清理

1. 为什么需要自己的执行器?

Node.js 自带 child_process.spawn(),为什么还要封装?

  1. 超时自动杀:一条命令如果跑飞了(死循环、等待网络),需要自动杀掉
  2. 进程树清理spawn 出来的子进程可能再 fork 出孙进程,杀子进程时孙进程也要一起清理
  3. 统一 IO 回调:stdout/stderr 的数据需要流式地传给上层,不能等命令跑完再读

2. spawnAndWait------核心执行函数

typescript 复制代码
export function spawnAndWait(
  command: string,
  args: string[],
  timeoutMs: number,
  options?: SpawnOptions,
  io?: SpawnIoHooks,
): Promise<number> {
  return new Promise((resolve) => {
    const child: ChildProcess = spawn(command, args, {
      stdio: ['ignore', 'pipe', 'pipe'],
      detached: process.platform !== 'win32',
      ...options,
    });

    const timer = setTimeout(() => {
      io?.onTimeout?.();
      killProcessTree(child);
    }, timeoutMs);

    child.stdout?.on('data', (chunk: Buffer) => {
      io?.onStdout?.(chunk);
    });

    child.stderr?.on('data', (chunk: Buffer) => {
      io?.onStderr?.(chunk);
    });

    child.on('error', (error) => {
      clearTimeout(timer);
      io?.onSpawnError?.(error.message);
      resolve(1);
    });

    child.on('close', (code) => {
      clearTimeout(timer);
      resolve(typeof code === 'number' ? code : 1);
    });
  });
}

2.1 逐步拆解

第一步:启动子进程

typescript 复制代码
const child = spawn(command, args, {
  stdio: ['ignore', 'pipe', 'pipe'],  // stdin 忽略,stdout/stderr 用管道
  detached: process.platform !== 'win32',  // 非 Windows 用进程组
  ...options,
});
  • stdio: ['ignore', 'pipe', 'pipe']:不需要向子进程输入,但需要读它的输出
  • detached: true(非 Windows):让子进程成为新进程组的组长。这样 process.kill(-child.pid) 可以杀掉整个进程组

第二步:设超时定时器

typescript 复制代码
const timer = setTimeout(() => {
  io?.onTimeout?.();       // 通知上层"超时了"
  killProcessTree(child);   // 杀掉整个进程树
}, timeoutMs);

第三步:监听 stdout/stderr

typescript 复制代码
child.stdout?.on('data', (chunk: Buffer) => {
  io?.onStdout?.(chunk);   // 流式传给上层(outputCapture 会接住)
});

第四步:监听退出

typescript 复制代码
child.on('close', (code) => {
  clearTimeout(timer);     // 命令正常结束了,取消定时器
  resolve(typeof code === 'number' ? code : 1);
});

2.2 关键设计决策

为什么 on('error') 不抛异常而是 resolve(1)?

typescript 复制代码
child.on('error', (error) => {
  clearTimeout(timer);
  io?.onSpawnError?.(error.message);
  resolve(1);  // 不抛异常!
});

因为这样可以方便上层订立统一的错误处理流程------无论命令是"跑完但退出码非0"还是"根本没跑起来",都走同一条审计路径。抛异常会打断这个流程。

3. killProcessTree------杀进程树

typescript 复制代码
function killProcessTree(child: ChildProcess): void {
  if (!child.pid) return;

  if (process.platform !== 'win32') {
    try {
      process.kill(-child.pid, 'SIGKILL');  // 杀整个进程组
      return;
    } catch {
      // ignore and fallback
    }
  }

  try {
    child.kill('SIGKILL');  // fallback:只杀子进程本身
  } catch {
    // ignore
  }
}

3.1 为什么需要杀进程树?

假设子进程是 bash -c "sleep 1000 & sleep 2000"

  • bash fork 出了两个 sleep 子进程
  • 如果你只杀 bash,两个 sleep 会变成孤儿进程继续跑
  • process.kill(-child.pid, 'SIGKILL') 会杀掉整个进程组,包括所有孙进程

3.2 为什么用 detached + 负 PID?

typescript 复制代码
detached: process.platform !== 'win32'

在 macOS/Linux 上,detached: true 会让子进程成为新进程组的组长。进程组有一个组 ID,等于组长进程的 PID。用 process.kill(-pid, 'SIGKILL')(注意负号)可以杀掉这个进程组里的所有进程。

Windows 不支持这种机制,所以 detached 设为 false,fallback 到只杀子进程本身。

4. spawnShellUnsafe------Shell 执行

typescript 复制代码
export function spawnShellUnsafe(
  shellCommand: string,
  cwd: string,
  timeoutMs: number,
  spawnEnv?: SpawnOptions['env'],
  io?: SpawnIoHooks,
): Promise<number> {
  const shellSpec =
    process.platform === 'win32'
      ? { command: 'cmd.exe', args: ['/d', '/s', '/c', shellCommand] }
      : { command: '/bin/bash', args: ['-lc', shellCommand] };

  return spawnAndWait(
    shellSpec.command,
    shellSpec.args,
    timeoutMs,
    {
      cwd,
      env: spawnEnv ?? { ...process.env, PATH: process.env.PATH ?? '' },
    },
    io,
  );
}

4.1 为什么叫 "Unsafe"?

因为 shell 命令可以解释管道 |、重定向 >、变量展开 $VAR、命令替换 cmd 等。这些特性使得命令的实际行为比字符串看起来复杂得多。

命名上就提醒调用方:用这个函数要小心,命令可能干出你意料之外的事

4.2 跨平台 Shell

平台 Shell 参数
Windows cmd.exe /d /c "command"
macOS/Linux /bin/bash -lc "command"

5. SpawnIoHooks 回调

typescript 复制代码
export interface SpawnIoHooks {
  onStdout?: (chunk: Buffer) => void;   // 收到一块 stdout
  onStderr?: (chunk: Buffer) => void;   // 收到一块 stderr
  onTimeout?: () => void;                // 命令超时了
  onSpawnError?: (message: string) => void;  // spawn 失败(比如命令不存在)
}

这些回调让上层可以:

  • 流式落盘 :每收到一块数据就写到文件(outputCapture
  • 超时感知:知道命令是因为超时被杀的
  • spawn 错误感知:知道命令根本没跑起来

6. 小结

函数 作用
spawnAndWait 底层 spawn:启动子进程、超时杀、流式 IO
spawnShellUnsafe Shell 执行的便捷封装
killProcessTree 杀整个进程组(不只是子进程)

关键设计点:

  1. 不抛异常,只返回退出码------让上层统一处理
  2. detached + 负 PID------杀进程组而非单个进程
  3. Shell 执行标记 "Unsafe"------提醒调用方注意安全风险
  4. 流式 IO 回调------数据不等命令跑完,来一块传一块
相关推荐
没事别瞎琢磨1 小时前
四、命令风险分级与审批策略
人工智能·node.js
阿乔外贸日记1 小时前
埃塞俄比亚出口全流程注意事项
大数据·人工智能·智能手机·云计算·汽车
程序员cxuan1 小时前
Agents.md 是什么
人工智能·后端·程序员
人工小情绪1 小时前
Windows 安装 Codex 桌面版,并用 CC Switch 管理配置
人工智能·windows·codex·cc switch
godspeed_lucip1 小时前
LLM和Agent——专题6:Multi Agent 入门(5)
人工智能·python
网安情报局1 小时前
告别排队与高延迟:直连GPT全系列,解锁低门槛、高稳定的AI生产力
人工智能·gpt·api·ai大模型
Hali_Botebie1 小时前
非共轭先验(Non-conjugate Prior)和共轭先验(Conjugate Prior)
人工智能·机器学习
没事别瞎琢磨2 小时前
三、配置系统——默认值与解析
人工智能·node.js
拓朗工控2 小时前
视觉检测行业工控机选型指南:核心要素与避坑策略
人工智能·数码相机·视觉检测·工控机·工业电脑