六、输出捕获与截断

本篇讲解 src/outputCapture.ts------子进程 stdout/stderr 的流式落盘和字节上限截断。

1. 为什么需要输出捕获?

子进程的输出(stdout/stderr)需要两个处理:

  1. 落盘:写到文件里,供审计和事后查看
  2. 截断:限制最大字节数,防止一条命令把磁盘写满

直接用 child.stdout.pipe(fs.createWriteStream(...)) 行不行?行,但它无法做截断------数据来多少写多少。

outputCapture.ts 就是在"流式写入"和"字节限制"之间找平衡。

2. createOutputCapture------核心函数

typescript 复制代码
/**
 * 创建一个带字节上限的输出捕获器。
 *
 * 子进程的 stdout/stderr 会被流式写入文件,但写入量不能超过 maxBytes。
 * 超过上限后,后续数据会被丢弃,并将 truncated 标记为 true。
 *
 * @param filePath - 输出文件的完整路径(目录不存在会自动创建)
 * @param maxBytes - 最大允许写入的字节数(0 表示只截断不写入)
 * @returns OutputCapture 实例,用于追加数据和读取状态
 */
export function createOutputCapture(filePath: string, maxBytes: number): OutputCapture {
  // 确保输出目录存在
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  // 以同步写方式打开文件(子进程输出是同步写入的,避免异步开销)
  const fd = fs.openSync(filePath, 'w');
  // 字节上限,最小为 0(防止传入负数)
  const byteLimit = Math.max(0, maxBytes);
  let bytesWritten = 0; // 已写入字节数
  let truncated = false; // 是否已触发截断
  let closed = false; // 文件是否已关闭

  /** 追加一段 Buffer 到输出文件 */
  function appendBuffer(chunk: Buffer): void {
    // 已关闭或空数据,直接跳过
    if (closed || chunk.length === 0) return;

    // 计算剩余可写空间
    const remaining = byteLimit - bytesWritten;
    if (remaining <= 0) {
      // 空间已满,标记截断并丢弃本段数据
      truncated = true;
      return;
    }

    // 如果本段数据超过剩余空间,只写入能装下的部分
    const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
    fs.writeSync(fd, slice, 0, slice.length);
    bytesWritten += slice.length;
    if (slice.length < chunk.length) {
      // 有部分数据被截断丢弃,标记截断
      truncated = true;
    }
  }

  /** 追加一段文本到输出文件(内部转为 Buffer 再写入) */
  function appendText(text: string): void {
    appendBuffer(Buffer.from(text, 'utf8'));
  }

  /** 关闭输出文件,释放文件描述符 */
  function close(): void {
    if (closed) return;
    fs.closeSync(fd);
    closed = true;
  }

  // 返回 OutputCapture 接口实例
  return {
    filePath,
    get bytesWritten() {
      return bytesWritten;
    },
    get truncated() {
      return truncated;
    },
    appendBuffer,
    appendText,
    close,
  };
}

3. 逐步拆解

3.1 初始化:打开文件

typescript 复制代码
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const fd = fs.openSync(filePath, 'w');
  • 先确保目标目录存在
  • openSync 打开文件(比 createWriteStream 更底层,写入更可控)

3.2 appendBuffer------核心写入逻辑

typescript 复制代码
function appendBuffer(chunk: Buffer): void {
  if (closed || chunk.length === 0) return;  // 已关闭或空数据,跳过

  const remaining = byteLimit - bytesWritten;
  if (remaining <= 0) {
    truncated = true;
    return;  // 已满,标记截断并丢弃
  }

  // 如果这一块数据比剩余空间大,只写前 remaining 字节
  const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
  fs.writeSync(fd, slice, 0, slice.length);
  bytesWritten += slice.length;

  if (slice.length < chunk.length) {
    truncated = true;  // 这一截写进去了,但后面被截了
  }
}

为什么用 fs.writeSync 而不是 fs.write

因为回调来自 child.stdout.on('data'),是同步触发的。用同步写入可以保证数据不会乱序。

3.3 appendText------文本写入的便捷封装

typescript 复制代码
function appendText(text: string): void {
  appendBuffer(Buffer.from(text, 'utf8'));
}

把字符串转成 Buffer 再走 appendBuffer

3.4 close------关闭文件描述符

typescript 复制代码
function close(): void {
  if (closed) return;
  fs.closeSync(fd);
  closed = true;
}

注意close 只能调一次。重复调用不会报错(因为 closed 标志位保护),也不会重复关文件。

4. 使用示例

AgentSandbox.executeCommand 里是这样用的:

typescript 复制代码
const stdoutCapture = createOutputCapture(ioPaths.stdoutPath, this.config.maxStdoutBytes);
const stderrCapture = createOutputCapture(ioPaths.stderrPath, this.config.maxStderrBytes);

// 执行命令,IO 回调里流式写入
const result = await runSandboxedCommand({
  commandSpec,
  // ...
  io: {
    onStdout: (chunk) => stdoutCapture.appendBuffer(chunk),
    onStderr: (chunk) => stderrCapture.appendBuffer(chunk),
  },
});

// 命令跑完了,关闭文件
stdoutCapture.close();
stderrCapture.close();

// 在返回结果里记录截断状态
return {
  // ...
  stdoutBytes: stdoutCapture.bytesWritten,
  stderrBytes: stderrCapture.bytesWritten,
  stdoutTruncated: stdoutCapture.truncated,
  stderrTruncated: stderrCapture.truncated,
};

5. 截断示例

假设 maxStdoutBytes = 100,子进程输出了 150 字节:

  1. 第一块数据 80 字节 → 全部写入,bytesWritten = 80
  2. 第二块数据 70 字节 → 只写前 20 字节,truncated = truebytesWritten = 100
  3. 第三块数据 50 字节 → remaining = 0,直接丢弃

最终文件里有 100 字节,truncated = true,告诉调用方"输出被截了"。

6. 小结

要点 说明
流式写入 子进程每输出一块数据就写到文件,不等跑完
字节上限 超过 maxBytes 后丢弃,标记 truncated
同步写入 fs.writeSync 保证数据不乱序
文件描述符 底层用 fs.openSync / fs.closeSync 精确控制
默认上限 stdout/stderr 各 512KB

这个模块虽然只有 52 行代码,但它是"防止子进程撑爆磁盘"的第一道防线。

相关推荐
嘉子的秃头日记1 小时前
TRO 2026|轮椅也能“猜到”用户想往哪走?
大数据·人工智能·机器学习
2601_957190901 小时前
极致裸眼沉浸!飞行影院重塑文旅游玩新体验
大数据·人工智能·旅游
Meinianda1 小时前
我用Agent 使用瑞幸官方MCP下了一单:过程全记录,优缺点分析
人工智能
没事别瞎琢磨1 小时前
七、敏感路径预检——Protected Paths
人工智能·node.js
啦啦啦_99991 小时前
4. Transformer_4_输出部分
人工智能·深度学习·transformer
用户600071819101 小时前
【翻译】构建 Claude Code 的经验:我们如何使用 Skills
人工智能
没事别瞎琢磨1 小时前
五、进程执行——spawn、超时与进程树清理
人工智能·node.js
没事别瞎琢磨1 小时前
四、命令风险分级与审批策略
人工智能·node.js
阿乔外贸日记1 小时前
埃塞俄比亚出口全流程注意事项
大数据·人工智能·智能手机·云计算·汽车