11. 如何界定启动、错误、退出与成功

四个不同问题

判断第三方脚本时,必须分别回答:

  1. 操作系统是否成功创建进程?
  2. 进程是否已经退出?
  3. stdout/stderr 是否全部关闭?
  4. 业务结果是否真的有效?

四个核心事件

事件 严格含义 适合用途
spawn 子进程成功创建 记录“已启动”
error 无法创建进程,或发送信号等操作失败 识别 ENOENT、EACCES
exit 子进程进程体结束 尽早获知 code/signal
close 进程结束且 stdio 已关闭 汇总输出、最终结算

error 不等同于子程序“非零退出”。命令成功启动但返回 code 2 时,一般触发 exitclose,不触发 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 实际接收总量。

为什么 exitclose 分开

exit 只说明目标进程结束。stdio 是独立资源,可能仍有缓冲数据,甚至被孙进程继承。需要完整输出时以 close 为准。

错误案例

// 错误:stderr 有数据就杀掉进程。
child.stderr.once("data", () => child.kill());

// 错误:只监听 exit,可能遗漏启动失败和尾部输出。
child.once("exit", () => markTaskCompleted());

最佳实践

练习

分别模拟命令不存在、code 0、code 3、SIGTERM、大量 stderr,记录事件顺序。再让 code 0 的脚本不产生预期文件,补充业务校验。

验收清单