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);
kill()返回true:信号请求已成功发出,不保证目标已退出。child.killed === true:曾成功发出过信号,不表示进程已死亡。child.exitCode !== null或收到exit:进程体已结束。- 收到
close:进程结束且 stdio 已关闭。
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();
- 看到
child.killed就标记“已停止”。 - 首次超时直接强杀,不留优雅退出机会。
- 忘记清除计时器和监听器。
- 只杀 Shell,遗留孙进程。
最佳实践
- 取消后始终等待
close,再清理文件。 - 把“取消中”和“已取消”作为不同任务状态。
- 先温和终止,宽限期后采用明确的平台策略。
- 记录 child PID、code、signal、超时来源和时长。
- 需要可靠进程树管理时使用进程管理器或容器能力。
练习
- 子脚本运行 30 秒,父进程 5 秒后取消并等待
close。 - 让子脚本捕获 SIGTERM,完成清理后退出。
- 编写 Bash 启动孙进程,观察只终止 Bash 后的结果。
验收清单
- [ ] 能解释 Promise 超时为何不等于子进程停止。
- [ ] 不会把
killed当成“已退出”。 - [ ] 取消后等待
close再清理。 - [ ] 知道进程树终止存在 Windows/POSIX 差异。