12. 超时、Abort 与子进程清理

超时不等于停止

HTTP 请求超时、业务任务超时和子进程真正退出是三个状态。只拒绝 Promise 或返回 504,不会自动停止外部程序。

Node.js 22+ 可以把 AbortSignal 交给 spawn()

import { spawn } from "node:child_process";

const controller = new AbortController();
const child = spawn("python", ["slow.py"], {
  signal: controller.signal,
  killSignal: "SIGTERM",
  // 本示例不处理输出,因此明确丢弃;若使用 pipe 就必须持续消费。
  stdio: "ignore",
});

child.on("error", (error: Error) => {
  if (error.name === "AbortError") {
    console.warn("任务已取消");
    return;
  }
  console.error("子进程错误", error);
});

setTimeout(() => controller.abort(), 5_000).unref();

Abort 会请求终止并通过 error 报告 AbortError,仍应等待 close 确认 stdio 收尾。

一个更完整的超时骨架

import { spawn } from "node:child_process";

async function runWithTimeout(timeoutMs: number): Promise<void> {
  const child = spawn("python", ["slow.py"], {
    // 本示例只演示超时;真实任务应消费并限制 stdout/stderr。
    stdio: "ignore",
  });

  let timedOut = false;
  const timeout = setTimeout(() => {
    timedOut = true;
    child.kill("SIGTERM");
  }, timeoutMs);

  try {
    await new Promise<void>((resolve, reject) => {
      let spawnError: Error | undefined;

      child.once("error", (error) => {
        spawnError = error;
      });

      child.once("close", (code, signal) => {
        if (spawnError) return reject(spawnError);
        if (timedOut) {
          return reject(new Error(`任务超时:signal=${signal}`));
        }
        if (code !== 0) {
          return reject(new Error(`任务失败:code=${code}, signal=${signal}`));
        }
        resolve();
      });
    });
  } finally {
    clearTimeout(timeout);
  }
}

这个骨架将“发出信号”和“确认关闭”分开。生产版本还应限制输出,并在宽限期后升级终止策略。

kill()killed 的准确含义

const sent = child.kill("SIGTERM");
console.log(sent, child.killed);

SIGKILL 无法被目标进程捕获,但不是跨平台的第一选择,也不给清理机会。

推荐取消时间线

到达超时/用户取消
  → 标记任务“正在取消”
  → SIGTERM 请求优雅退出
  → 等待宽限期
  → 必要时采用平台相关的强制策略
  → 等待 close
  → 删除临时文件、释放任务占用
  → 记录 timedOut/aborted/code/signal

进程树是最大陷阱

若 Node.js 启动 Bash,Bash 又启动 Python:

Node.js → Bash → Python → 外部工具

终止 Bash 不一定终止 Python 或外部工具。POSIX 上可用独立进程组并向进程组发信号,但细节涉及 detached、负 PID、权限和 PID 复用,必须准确记录目标;Windows 没有等价的 POSIX 进程组信号,通常需要作业对象、服务管理器或受控的 taskkill /T 策略。

不要把 Linux 的 process.kill(-pid) 原样当成跨平台代码,也不要对不确定 PID 执行广泛终止。容器中还要确保 PID 1 正确转发信号。

更易管理的方案是尽量直接启动最终程序,减少 Shell 中间层;复杂任务交给队列 Worker、容器或作业调度器。

错误案例

// 错误:只让等待方超时,子进程仍在运行。
await Promise.race([waitForChild(), timeoutPromise]);
// 错误:kill 后立即删除子进程可能仍在写的文件。
child.kill();
await removeTemporaryFiles();

最佳实践

练习

  1. 子脚本运行 30 秒,父进程 5 秒后取消并等待 close
  2. 让子脚本捕获 SIGTERM,完成清理后退出。
  3. 编写 Bash 启动孙进程,观察只终止 Bash 后的结果。

验收清单