11. 如何界定启动、错误、退出与成功
四个不同问题
判断第三方脚本时,必须分别回答:
- 操作系统是否成功创建进程?
- 进程是否已经退出?
- stdout/stderr 是否全部关闭?
- 业务结果是否真的有效?
四个核心事件
| 事件 | 严格含义 | 适合用途 |
|---|---|---|
spawn |
子进程成功创建 | 记录“已启动” |
error |
无法创建进程,或发送信号等操作失败 | 识别 ENOENT、EACCES |
exit |
子进程进程体结束 | 尽早获知 code/signal |
close |
进程结束且 stdio 已关闭 | 汇总输出、最终结算 |
error 不等同于子程序“非零退出”。命令成功启动但返回 code 2 时,一般触发 exit 和 close,不触发 error。
典型时间线
成功: spawn → data... → exit(0, null) → close(0, null)
业务失败: spawn → stderr... → exit(2, null) → close(2, null)
启动失败: error(ENOENT) → close(数值细节与平台相关)
信号终止: spawn → exit(null, SIGTERM) → close(null, SIGTERM)
启动失败时应保存原始 error.code,不要依赖 close 的 code 数值。
stderr 不等于失败
许多工具把进度、警告写入 stderr,以免污染 stdout 的机器数据:
stderr 有内容 ≠ 失败
stdout 为空 ≠ 失败
exit code 0 = 程序声明成功,但仍需验证业务结果
exit code 非 0 = 通常视为程序失败
推荐协议:stdout 输出 JSON/JSON Lines,stderr 输出日志,exit 0 表示成功,非零表示失败。即使 code 0,也应验证 JSON 结构、结果文件存在性和大小。
防止 Promise 重复结算
import {
spawn,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
interface ProcessResult {
code: number;
stdout: string;
stderr: string;
}
function collect(
child: ChildProcessWithoutNullStreams,
maxBytes = 1024 * 1024,
): Promise<ProcessResult> {
return new Promise((resolve, reject) => {
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let terminalError: Error | undefined;
// error 记录启动或 ChildProcess 操作错误;最终仍在 close 中结算,
// 这样调用者不会在 stdio 尚未关闭时开始清理。
child.once("error", (error) => {
terminalError = error;
});
child.stdout.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes > maxBytes) {
terminalError ??= new Error("stdout 超过限制");
child.kill();
return;
}
stdout.push(chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes <= maxBytes) stderr.push(chunk);
});
child.once("close", (code, signal) => {
if (terminalError !== undefined) {
reject(terminalError);
return;
}
const stdoutText = Buffer.concat(stdout).toString("utf8");
const stderrText = Buffer.concat(stderr).toString("utf8");
if (code === 0) {
resolve({ code, stdout: stdoutText, stderr: stderrText });
} else {
reject(new Error(
`子进程失败:code=${code}, signal=${signal}, stderr=${stderrText}`,
));
}
});
});
}
const child = spawn(process.execPath, ["task.js"]);
void collect(child);
超过上限后,示例先记录错误并请求终止,直到 close 才 reject;调用者随后才能安全清理部分结果。生产版本还应给终止设置宽限期,并限制 stderr 实际接收总量。
为什么 exit 与 close 分开
exit 只说明目标进程结束。stdio 是独立资源,可能仍有缓冲数据,甚至被孙进程继承。需要完整输出时以 close 为准。
错误案例
// 错误:stderr 有数据就杀掉进程。
child.stderr.once("data", () => child.kill());
// 错误:只监听 exit,可能遗漏启动失败和尾部输出。
child.once("exit", () => markTaskCompleted());
- code 为
null时直接做数字比较。 - code 0 就跳过结果文件验证。
- 将无限长 stderr 原样写数据库或返回客户端。
- 多个事件回调重复更新任务状态。
最佳实践
error处理“未启动/操作失败”,close处理最终结算。- 保存 code、signal、持续时间和受限的 stderr 摘要。
- 同时约定输出格式、退出码和结果校验。
- 外部错误对用户脱敏,完整诊断写入受控日志。
练习
分别模拟命令不存在、code 0、code 3、SIGTERM、大量 stderr,记录事件顺序。再让 code 0 的脚本不产生预期文件,补充业务校验。
验收清单
- [ ] 能严格解释
spawn、error、exit、close。 - [ ] 不用 stderr 是否为空判断成功。
- [ ] 会同时判断退出码、信号和业务结果。
- [ ] Promise 不会被多个事件重复结算。