本篇讲解
src/outputCapture.ts------子进程 stdout/stderr 的流式落盘和字节上限截断。
1. 为什么需要输出捕获?
子进程的输出(stdout/stderr)需要两个处理:
- 落盘:写到文件里,供审计和事后查看
- 截断:限制最大字节数,防止一条命令把磁盘写满
直接用 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 字节:
- 第一块数据 80 字节 → 全部写入,
bytesWritten = 80 - 第二块数据 70 字节 → 只写前 20 字节,
truncated = true,bytesWritten = 100 - 第三块数据 50 字节 →
remaining = 0,直接丢弃
最终文件里有 100 字节,truncated = true,告诉调用方"输出被截了"。
6. 小结
| 要点 | 说明 |
|---|---|
| 流式写入 | 子进程每输出一块数据就写到文件,不等跑完 |
| 字节上限 | 超过 maxBytes 后丢弃,标记 truncated |
| 同步写入 | 用 fs.writeSync 保证数据不乱序 |
| 文件描述符 | 底层用 fs.openSync / fs.closeSync 精确控制 |
| 默认上限 | stdout/stderr 各 512KB |
这个模块虽然只有 52 行代码,但它是"防止子进程撑爆磁盘"的第一道防线。