给 Agent 包装 CLI:为什么外层 try/catch 没接住 stdin EPIPE?

把进程错误与输入流错误分别接住,让调用方收到可解释、可验证的失败。

你能带走什么
  • ChildProcess 与 stdin 的 error 需要分别监听
  • 写回调收到错误不等于消费后续 error 事件
  • close 时保留退出状态、输入错误和完整 stderr
  • exit=0 与写回调成功都不能代替输入回执
  • 用固定字节预期和隔离负控验证错误传播

1. 问题不在 catch 写得够不够大,而在错误从哪里出来

你给一个 Agent 包装 CLI:启动子进程,把任务正文写入 stdin,等待结果。如果 CLI 在读完输入前就因参数错误退出,终端最后留下的可能是 write EPIPE。更令人困惑的是,调用处明明写了 try { await run() } catch { ... },宿主进程还是直接退出,原本能解释问题的 stderr 也没有进入业务错误对象。

这篇教程把问题缩到 Node 标准库的两个对象:ChildProcess 与它的 stdin 流。我们用原创合成子进程验证:先接住各自的事件,保留证据,再把结算结果明确连接到 Promise。正常路径还要由子进程报告完整字节数和 SHA-256,不能只看 exit=0。

实测范围:2026-10-13,Linux x64、Node v24.19.0。全部案例只启动合成 Node 子进程,不安装或运行 Codex SDK、不调用模型、不使用账号,也不读取现有项目文件。这是错误优先级和事件边界的教学实验,不是官方修复补丁,也不是生产级进程监督器。

ChildProcess error 与 stdin error 分别记录,close 汇总并拒绝 Promise;不监听 stdin 可能导致宿主退出。
图 1:错误对象不同,接到 Promise 的路径也要分别建立(原创示意)

2. 需求来自一个具体报告,结论不要越过证据

openai/codex #49670 的 2026-09-30 报告描述了较大输入与 CLI 提前退出时的未处理 stdin EPIPE,报告环境为 macOS、Node 22;2026-10-07 的补充评论报告了 Linux、Node 24.19.0 的复现及本地方案检查。它们是公开用户报告,不能说明所有 SDK 版本都存在问题,也不能用来推算搜索热度或发生率。

2026-10-13 读取的官方仓库 main 分支 exec.ts,可见对子进程自身监听 error 后写 stdin 的路径。main 会继续变化;这次页面观察不是对所有发行版本的核验。本文没有重新运行上述 SDK,也不把评论中的本地方案称为已发布修复。下面的标准库实验是独立证据。

3. 先区分三个时刻:写回调、流错误、子进程 close

Node v24.19.0 Events 文档说明:EventEmitter 发出 error 却没有相应监听器时,可能抛出错误并终止进程。监听 child 的 error 不会替 child.stdin 安装监听器;外层 await 也不会自动把后者转换成 Promise rejection。

同版本 Stream 文档说明,write 回调异步执行;发生写入错误时,它会先于 error 事件收到错误。即使回调保存了 error,后续事件仍需要监听器。回调成功也不证明目标程序已经消费全部输入,更不证明业务接受了任务。

Child process 文档把 exit 与 close 分开:exit 时 stdio 仍可能打开;close 对应子进程结束且 stdio 关闭。本文只在 close 结算,避免较早抛出的 EPIPE 遮住稍后收全的 stderr。缺少可执行文件也通过 error 后的 close 结算,而不是永远等待可能不存在的 exit。

4. 先在空目录复跑,再改自己的包装器

需要已安装的 Node 24。本例不需要 npm 包。将文末四个完整文件保存为 fixture-child.mjs、runner.mjs、test-runner.mjs 和 expected.json,放在同一个新目录,使用 UTF-8 编码。离线包也包含相同文件和实测报告。

node --version
node test-runner.mjs > report.json
node runner.mjs read
node runner.mjs reject2
node runner.mjs reject0
node runner.mjs missing

测试通过时 report.json 的 checkGroups 为 10,isolatedRuns 为 67,negativeControlsRejected 为 2,eventTraceNegativeControlsRejected 为 4。最后四个是对已捕获事件记录的删项负控,不会新增子进程运行。任何断言失败,测试进程都会非零退出。重定向会覆盖当前目录的同名 report.json,所以请使用新建的实验目录。本文环境另外使用系统已有的 timeout 90s 为整套测试设置外部截止时间;这条命令不是 Node 的跨平台内置命令。

请优先运行测试入口。测试用 spawnSync 为每个隔离包装器设置 8 秒超时,并用 SIGKILL 结束超时的包装器;合成子进程另有 2.5 秒看门计时器和 IPC 断开退出分支。这些只限制本例进程,不提供任意 CLI 的进程树回收保证。本文所有 67 次运行都在截止时间内结束;没有测试强制终止后的通用恢复流程。

5. 门控夹具:把“碰巧写得够快”换成明确先后关系

直接启动一个立即退出的程序,再写 4 MiB,通常能观察到 EPIPE,但管道容量和调度会影响结果。为了稳定教学实验,reject2 与 reject0 模式先关闭自己的 fd 0,写完两行诊断,再通过独立 IPC 通道发出 ready。父进程收到 ready 才写入,因此读端已经关闭。父进程在写回调中保存结果并发送 release,让子进程按约定退出;随后 stdin error 监听器记录流错误。

这个门控刻意制造“目标已不再接收 stdin”的状态;它不是完整复制真实 CLI 的退出时序。正常 read 模式则先安装 stdin 读取器,再发 ready,累计全部 Buffer 字节并返回摘要。我们没有手动 emit 一个假的 EPIPE,错误来自这次真实管道写入;但只断言本次 Linux/Node 组合实测到 EPIPE,不保证每种系统或关闭方式都返回同一个错误码。

关闭 fd 0、输出诊断、ready、父进程写入;写回调保存错误并发送 release,随后监听器记录 error;child close 汇总进程结束与 stdio 关闭。
图 2:IPC 只负责实验同步,普通 CLI 包装器不应照搬这条控制协议(原创实测时序)

6. 正常路径也必须有独立答案:4 MiB 加中文与 emoji

大输入由 4,194,304 个 0x78 字节,后接 \n中文🙂\n 的 UTF-8 字节构成,总长 4,194,316。小输入单独使用 中文🙂\n,共 11 字节。字符串的 JavaScript length 不是 UTF-8 字节数,所以子进程以 Buffer chunk.length 计数。

large.bytes = 4194316
large.sha256 = 29581dfe5572dd2487b8140b0e709397fb89e57ab5028ab9b33447c384ee4aca
unicode.bytes = 11
unicode.sha256 = 0c204cf14b2a7ef8f804a9a2299c3fcdf44383f454c11d4126d12f2d3b03d364

expected.json 的这些值是固定答案:制作时用独立的 Python 字节构造和 SHA-256 得到,测试运行时不会导入生成器常量再重新生成期望值。测试先核对父进程输入,再核对子进程实际读到的数据。长度相同但字节被截断、替换或编码错误,都不能仅靠“进程成功”混过去。

这份摘要回执只证明本例合成子进程读到了约定字节。真实服务是否解析、接受或执行了任务,需要它自己的协议回执;不能把 write 回调或本文的字节摘要当作业务完成凭据。

7. 错误优先级写成合同,别让后来的 EPIPE 抢走根因

本例的结算顺序是:启动失败 SPAWN_FAILED;启动后的进程控制错误 PROCESS_FAILED;非零退出或信号 CHILD_FAILED;输入错误或写入未完成 INPUT_NOT_DELIVERED;否则返回成功结果供调用方校验回执。PROCESS_FAILED 是保留给 IPC 等控制失败的分支,不在本文实测的故障矩阵中。

reject2 同时带有 code=2、两行 stderr、write 回调的 EPIPE 和 stdin error 的 EPIPE。主错误选 CHILD_FAILED,但附带字段全部保留。reject0 的 code=0 不能覆盖输入错误,结果必须是 INPUT_NOT_DELIVERED。缺失可执行文件则保留 ENOENT,测试也确认该次专用临时目录已由 finally 删除。

完整读取、启动失败、退出2加EPIPE、退出0加EPIPE四种结算结果,外加遗漏监听和吞掉错误两个负控。
图 3:主错误用于分类,detail 用于解释;退出码不是唯一验收条件(原创实测矩阵)
reject2 → CAUGHT / CHILD_FAILED
  code=2, inputError.code=EPIPE, callbackError.code=EPIPE
  stderr="CONFIG_REJECTED\nfixture detail: exit=2; stdin closed before write\n"

reject0 → CAUGHT / INPUT_NOT_DELIVERED
  code=0, inputError.code=EPIPE, callbackError.code=EPIPE
  stderr="EARLY_ZERO\nfixture detail: exit=0; stdin closed before write\n"

8. 两种看似修好、实际仍错的改法

负控一:只写 try/catch 或只在 write 回调里记错误。测试在单独 Node 包装器里关闭 stdin error 监听,避免让测试主进程一起崩溃。实测该包装器退出码为 1、stdout 为空,stderr 包含未处理 error 与 EPIPE;因此外层 CAUGHT 标记没有到达。它已经安装了 ChildProcess error 监听和 write 回调,仍然不够。

负控二:加空监听器,把错误吞掉。swallow 变体消费 stdin error 但丢弃证据,也把失败的写回调当作完成。它在 reject0 上返回假 SUCCESS,stdout 没有任何完整读取回执。测试把这条结果送进与正确实现相同的 validate 断言,必须得到 AssertionError;“没有再崩溃”不是测试通过的条件。

两个负控只通过 mutation 参数在这个受控实验中开启,默认值 none 使用正确记录方式。它们不是建议在应用里提供的运行选项,也不应通过全局 uncaughtException 处理器掩盖程序生命周期问题。

9. 本次测试到底证明了什么

{
  "node": "v24.19.0",
  "platform": "linux",
  "arch": "x64",
  "checkGroups": 10,
  "isolatedRuns": 67,
  "repeatedRounds": 12,
  "perWrapperTimeoutMs": 8000,
  "childWatchdogMs": 2500,
  "observedWrapperTimeouts": 0,
  "negativeControlsRejected": 2,
  "eventTraceNegativeControlsRejected": 4
}

2026-10-20 在相同 Linux x64 / Node v24.19.0 环境补测后,检查组由 9 组增至 10 组;原有 67 次隔离执行数量不变。67 次隔离执行由 5 个基础正确案例、2 个负控和 12 轮 × 5 个正确案例组成。每轮覆盖大输入、小 UTF-8 输入、缺少可执行文件、退出 2 加 EPIPE、退出 0 加 EPIPE。断言同时核对完整两行 stderr、输入错误、退出状态,以及写回调先于流 error、exit 先于 close 的相关顺序;不固定无关事件之间的全部排序。

事件顺序断言的补漏:旧版直接比较 indexOf 的结果。如果前一个事件缺失,返回的 -1 仍小于后一个事件的位置,测试会误把“没记录到”判成“发生在前”。本次用 reject2 与 reject0 的真实运行记录各删去 write-callback 或 exit,共四个删项记录,复现旧验证器全部放行;新版先确认四个相关事件都存在,再比较顺序,四个删项记录均被拒绝。它们只验证测试判据,不代表真实运行中丢失了事件,也不是四个新增子进程案例。反向移除新版存在性检查后,新增负控如预期使测试非零退出。

本次记录没有包装器超时;缺失可执行文件的专用目录删除检查通过;正确包装器均观察到了 stdin close 和最终 child close。没有据此宣称任意失败都不会留下进程,也没有做系统级全进程扫描、断电恢复或恶意子进程测试。合成子进程断开 IPC 后退出的代码,是实验保护措施,不等于已证明任意进程树都能回收。

未测 Windows、macOS、真实 Codex SDK/CLI、模型调用和浏览器原生版式。三幅 SVG 已离线渲染检查。代码把本例有限 stdout/stderr 收进内存,只写一个有限 Buffer;它没有实现无限流背压调度、输出上限、取消协议或生产服务的期限管理。

10. 完整源码一:fixture-child.mjs

合成子进程只接收本文定义的三个模式。退出失败、输入丢失和正常回执都在这个小程序里可见。

// Synthetic lab child. No SDK, model, credentials, network, or input files.
import { closeSync } from 'node:fs';
import { createHash } from 'node:crypto';

const mode = process.argv[2];
if (!['read', 'reject2', 'reject0'].includes(mode) || !process.send) {
  process.exit(64);
}
const intendedCode = mode === 'reject2' ? 2 : 0;
let finishing = false;
const watchdog = setTimeout(() => finish(70), 2500);
function finish(code) {
  if (finishing) return;
  finishing = true;
  clearTimeout(watchdog);
  process.exit(code);
}
// The isolated bad wrapper may crash. This lab child then exits too.
process.on('disconnect', () => finish(intendedCode));
process.on('error', () => finish(71));

if (mode === 'read') {
  let bytes = 0;
  const hash = createHash('sha256');
  process.stdin.on('data', (chunk) => {
    bytes += chunk.length;
    hash.update(chunk);
  });
  process.stdin.on('error', () => finish(72));
  process.stdin.on('end', () => {
    const receipt = { bytes, sha256: hash.digest('hex') };
    process.stdout.write(`${JSON.stringify(receipt)}\n`, () => finish(0));
  });
  process.send({ type: 'ready', stdinClosed: false });
} else {
  // Do not initialize process.stdin first. Close fd 0 before opening the gate.
  // This controlled Linux fixture removes the "did the child exit yet?" race.
  closeSync(0);
  const reason = mode === 'reject2' ? 'CONFIG_REJECTED' : 'EARLY_ZERO';
  process.stderr.write(`${reason}\n`);
  process.stderr.write(
    `fixture detail: exit=${intendedCode}; stdin closed before write\n`,
    () => process.send({ type: 'ready', stdinClosed: true }),
  );
  process.on('message', (message) => {
    if (message?.type === 'release') finish(intendedCode);
  });
}

11. 完整源码二:runner.mjs

把监听器放在写入前,只在 close 结算。detail 保留原始诊断;默认 none 路径不吞掉输入错误。

// Teaching error-priority contract, NOT a production process supervisor.
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const fixture = fileURLToPath(new URL('./fixture-child.mjs', import.meta.url));
export function inputFor(kind = 'large') {
  if (kind === 'unicode') return Buffer.from('中文🙂\n', 'utf8');
  if (kind !== 'large') throw new Error('unknown input kind');
  return Buffer.concat([
    Buffer.alloc(4 * 1024 * 1024, 0x78),
    Buffer.from('\n中文🙂\n', 'utf8'),
  ]);
}
const errorInfo = (error) => error ? {
  code: error.code ?? error.name,
  message: error.message,
  ...(error.path ? { path: error.path } : {}),
} : null;

export async function runFixture(mode, { input = inputFor(), mutation = 'none' } = {}) {
  if (!['read', 'reject2', 'reject0', 'missing'].includes(mode)) {
    throw new Error('unknown fixture mode');
  }
  if (!['none', 'no-listener', 'swallow'].includes(mutation)) {
    throw new Error('unknown mutation');
  }
  if (!Buffer.isBuffer(input)) throw new TypeError('input must be a Buffer');
  // The missing executable path belongs to this run, not to an existing project.
  const owned = mode === 'missing' ? await mkdtemp(join(tmpdir(), 'stdin-lab-')) : null;
  try {
    return await new Promise((resolve, reject) => {
      const child = spawn(owned ? join(owned, 'absent') : process.execPath,
        owned ? [] : [fixture, mode], {
          stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
          env: {},
        });
      let spawned = false;
      let spawnError = null;
      let processError = null;
      let inputError = null;
      let callbackError = null;
      let writeFinished = false;
      let gateSeen = false;
      let stdinClosedAtGate = null;
      let stdout = '';
      let stderr = '';
      const events = [];
      child.stdout.setEncoding('utf8');
      child.stderr.setEncoding('utf8');
      child.stdout.on('data', (text) => { stdout += text; });
      child.stderr.on('data', (text) => { stderr += text; });
      child.once('spawn', () => { spawned = true; events.push('spawn'); });
      child.on('error', (error) => {
        if (!spawned) spawnError ??= error;
        else processError ??= error;
        events.push('child-error');
      });
      // Attach this BEFORE any write. ChildProcess and child.stdin differ.
      if (mutation !== 'no-listener') {
        child.stdin.on('error', (error) => {
          events.push('stdin-error');
          if (mutation !== 'swallow') inputError ??= error;
        });
      }
      child.stdin.on('close', () => events.push('stdin-close'));
      child.once('exit', () => events.push('exit'));
      // close is the sole settlement point: stderr has also closed by now.
      child.once('close', (code, signal) => {
        events.push('close');
        const detail = { code, signal, spawnError: errorInfo(spawnError),
          processError: errorInfo(processError), inputError: errorInfo(inputError),
          callbackError: errorInfo(callbackError), writeFinished,
          gateSeen, stdinClosedAtGate, stdout, stderr, events };
        const reason = spawnError ? 'SPAWN_FAILED'
          : processError ? 'PROCESS_FAILED'
          : (code !== 0 || signal !== null) ? 'CHILD_FAILED'
          : (inputError || callbackError || !writeFinished) ? 'INPUT_NOT_DELIVERED'
          : null;
        if (reason) reject(Object.assign(new Error(reason), { detail }));
        else resolve(detail);
      });
      child.on('message', (message) => {
        if (message?.type !== 'ready' || gateSeen) return;
        gateSeen = true;
        stdinClosedAtGate = message.stdinClosed;
        events.push('gate');
        // Exactly one bounded write. No additional chunks ignore backpressure.
        child.stdin.write(input, (error) => {
          events.push('write-callback');
          if (mutation !== 'swallow') callbackError = error ?? null;
          writeFinished = mutation === 'swallow' ? true : !error;
          // The callback does NOT consume the stream's later error event.
          if (!error) child.stdin.end();
          // Lab control only: allow the already-closed-input child to exit.
          if (message.stdinClosed && child.connected) {
            child.send({ type: 'release' }, (sendError) => {
              if (sendError) processError ??= sendError;
            });
          }
        });
      });
    });
  } finally {
    if (owned) await rm(owned, { recursive: true, force: true });
  }
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  try {
    const detail = await runFixture(process.argv[2] ?? 'read', {
      mutation: process.argv[3] ?? 'none', input: inputFor(process.argv[4] ?? 'large'),
    });
    console.log(JSON.stringify({ state: 'SUCCESS', detail }));
  } catch (error) {
    // This marker must be absent when an unhandled stream event kills this wrapper.
    console.log(JSON.stringify({ state: 'CAUGHT', error: error.message,
      detail: error.detail ?? null }));
  }
}

12. 完整源码三:test-runner.mjs

所有有意破坏的包装器都在独立进程里运行。固定 expected.json 不随实现动态生成;同一个验证器也用于拒绝假成功。另有四个事件记录删项负控,确保缺少前置事件时不会因 -1 小于后续下标而误通过。

// Run from a new lab directory: node test-runner.mjs > report.json
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { inputFor } from './runner.mjs';

const runner = fileURLToPath(new URL('./runner.mjs', import.meta.url));
const expected = JSON.parse(readFileSync(new URL('./expected.json', import.meta.url)));
let isolatedRuns = 0;
const checks = [];
function check(name, fn) { fn(); checks.push(name); }
function isolated(mode, mutation = 'none', input = 'large') {
  isolatedRuns++;
  const result = spawnSync(process.execPath, [runner, mode, mutation, input], {
    encoding: 'utf8', env: {}, timeout: 8000, killSignal: 'SIGKILL',
    maxBuffer: 1024 * 1024,
  });
  assert.equal(result.error, undefined, 'external per-wrapper deadline or spawn failure');
  assert.equal(result.signal, null);
  if (mutation === 'no-listener') return result;
  assert.equal(result.status, 0, result.stderr);
  assert.equal(result.stderr, '');
  return JSON.parse(result.stdout);
}
function validate(row, mode, input = 'large') {
  const d = row.detail;
  assert.equal(d.events.at(-1), 'close');
  assert.ok(d.events.includes('stdin-close'));
  assert.equal(d.signal, null);
  assert.equal(d.processError, null);
  if (mode === 'read') {
    assert.equal(row.state, 'SUCCESS');
    assert.equal(d.code, 0);
    assert.equal(d.inputError, null);
    assert.equal(d.callbackError, null);
    assert.equal(d.writeFinished, true);
    assert.equal(d.stdinClosedAtGate, false);
    assert.equal(d.stderr, '');
    assert.deepEqual(JSON.parse(d.stdout), expected[input]);
  } else if (mode === 'missing') {
    assert.equal(row.state, 'CAUGHT');
    assert.equal(row.error, expected.codes.missing);
    assert.equal(d.spawnError.code, 'ENOENT');
    assert.equal(d.gateSeen, false);
    assert.equal(d.stdout, '');
    assert.equal(d.stderr, '');
    assert.equal(existsSync(dirname(d.spawnError.path)), false,
      'owned missing-executable directory must be removed');
  } else {
    assert.equal(row.state, 'CAUGHT');
    assert.equal(row.error, expected.codes[mode]);
    assert.equal(d.code, mode === 'reject2' ? 2 : 0);
    assert.equal(d.spawnError, null);
    assert.equal(d.stdinClosedAtGate, true);
    assert.equal(d.inputError.code, 'EPIPE');
    assert.equal(d.callbackError.code, 'EPIPE');
    assert.equal(d.writeFinished, false);
    assert.equal(d.stdout, '');
    assert.equal(d.stderr, expected.stderr[mode]);
    // Presence must be checked first: indexOf(missing) === -1.
    for (const event of ['write-callback', 'stdin-error', 'exit', 'close']) {
      assert.ok(d.events.includes(event), `missing required event: ${event}`);
    }
    assert.ok(d.events.indexOf('write-callback') < d.events.indexOf('stdin-error'));
    assert.ok(d.events.indexOf('exit') < d.events.indexOf('close'));
  }
}
check('fixed independent byte counts and SHA-256', () => {
  for (const kind of ['large', 'unicode']) {
    const input = inputFor(kind);
    assert.equal(input.length, expected[kind].bytes);
    assert.equal(createHash('sha256').update(input).digest('hex'), expected[kind].sha256);
  }
});
const observations = {};
for (const [label, mode, input] of [
  ['full_large', 'read', 'large'], ['full_unicode', 'read', 'unicode'],
  ['spawn_failure', 'missing', 'large'], ['child_failure', 'reject2', 'large'],
  ['zero_exit_input_loss', 'reject0', 'large'],
]) {
  check(label, () => { const row = isolated(mode, 'none', input);
    validate(row, mode, input); observations[label] = row; });
}
check('swallowed input error is rejected by the same oracle', () => {
  const row = isolated('reject0', 'swallow');
  assert.equal(row.state, 'SUCCESS'); // Intentional wrong implementation.
  assert.equal(row.detail.stdout, '');
  assert.ok(row.detail.events.includes('stdin-error'));
  assert.throws(() => validate(row, 'reject0'), assert.AssertionError);
  observations.swallow_negative = { wrongState: row.state, oracleRejected: true };
});
check('unhandled stdin error bypasses the outer catch in an isolated wrapper', () => {
  const result = isolated('reject2', 'no-listener');
  assert.equal(result.status, 1);
  assert.equal(result.stdout, '');
  assert.ok(result.stderr.includes("Unhandled 'error' event"));
  assert.ok(result.stderr.includes('EPIPE'));
  observations.no_listener_negative = {
    wrapperExit: result.status, outerCatchReached: false,
    unhandledError: true, observedCode: 'EPIPE',
  };
});
let eventTraceNegativeControlsRejected = 0;
check('missing earlier events cannot pass order comparisons', () => {
  for (const [label, mode] of [
    ['child_failure', 'reject2'], ['zero_exit_input_loss', 'reject0'],
  ]) {
    for (const event of ['write-callback', 'exit']) {
      // Corrupt a captured trace, not the child process or its event delivery.
      const row = structuredClone(observations[label]);
      assert.ok(row.detail.events.includes(event));
      row.detail.events = row.detail.events.filter((value) => value !== event);
      assert.throws(() => validate(row, mode), assert.AssertionError);
      eventTraceNegativeControlsRejected++;
    }
  }
});
check('12 repeated rounds, five good-wrapper cases per round', () => {
  for (let round = 0; round < 12; round++) {
    for (const [mode, input] of [
      ['read', 'large'], ['read', 'unicode'], ['missing', 'large'],
      ['reject2', 'large'], ['reject0', 'large'],
    ]) validate(isolated(mode, 'none', input), mode, input);
  }
});
console.log(JSON.stringify({
  node: process.version, platform: process.platform, arch: process.arch,
  scope: 'synthetic standard-library fixtures only',
  checkGroups: checks.length, checks, isolatedRuns, repeatedRounds: 12,
  perWrapperTimeoutMs: 8000, childWatchdogMs: 2500,
  observedWrapperTimeouts: 0, negativeControlsRejected: 2,
  eventTraceNegativeControlsRejected,
  expected, observations,
  limits: 'Linux observation; no SDK/model/account/Windows/macOS test; not a process-tree supervisor',
}, null, 2));

13. 完整固定预期:expected.json

保存这个 JSON 文件后即可执行测试,无需下载额外数据。

{
  "large": {
    "bytes": 4194316,
    "sha256": "29581dfe5572dd2487b8140b0e709397fb89e57ab5028ab9b33447c384ee4aca"
  },
  "unicode": {
    "bytes": 11,
    "sha256": "0c204cf14b2a7ef8f804a9a2299c3fcdf44383f454c11d4126d12f2d3b03d364"
  },
  "stderr": {
    "reject2": "CONFIG_REJECTED\nfixture detail: exit=2; stdin closed before write\n",
    "reject0": "EARLY_ZERO\nfixture detail: exit=0; stdin closed before write\n"
  },
  "codes": {
    "missing": "SPAWN_FAILED",
    "reject2": "CHILD_FAILED",
    "reject0": "INPUT_NOT_DELIVERED"
  }
}

14. 带回自己的 Agent 封装时,先补证据,再考虑动作

先列出你实际创建的对象:进程、stdin、stdout、stderr,以及各自的错误与结束事件。再确认每个异步错误怎样进入调用方等待的 Promise,哪里负责最终结算。审阅失败记录时,同时保留退出码、信号、流错误和必要的 stderr;对外展示前还要遵循应用已有的日志脱敏规则。

把“成功”写成可测合同:对于本文是完整字节与摘要,对于真实 CLI 则应使用它已经定义的响应协议。不要因为 EPIPE 就自动重试;它不单独说明远端或子进程已经做了多少工作。先确认失败阶段和应用自身的重试条件。

最后把三条回归测试留下:正常完整读取;非零退出并伴随输入失败;退出 0 但输入失败。再加一条故意移除监听器的隔离负控,确保测试真的能识别宿主崩溃。这样你验证的是错误传播合同,而不只是终端里有没有红字。

外层 try/catch 为什么接不到 stdin EPIPE?

它能处理所等待 Promise 的拒绝,但不会自动监听独立流对象的 error。本文的 no-listener 负控在写回调已经收到错误后,仍因未处理流事件退出,外层 CAUGHT 标记缺失。

在 child 上监听 error 就够了吗?

不够。child.stdin 是另一个对象。本例在启动前后分别记录进程错误,并在任何写入前安装 stdin error 监听器。

吞掉 EPIPE,继续按退出码判断可以吗?

不可以。本文的 exit=0 加 EPIPE 案例必须返回 INPUT_NOT_DELIVERED。swallow 负控会返回假成功,且没有字节摘要回执,因此被同一验证器拒绝。

这能直接作为生产级 CLI 包装器吗?

不能。它只展示有限输入、有限输出和合成子进程的错误优先级,未实现任意进程树清理、无限流调度、生产取消协议或通用输出上限。

这是 Codex SDK 已发布的官方修复吗?

不是。公开 issue 只用于说明具体需求;本地运行的是独立 Node 标准库实验。main 源码观察、用户复现和发行版本验收是不同证据。