DeepSeek Harness 子进程 spill 文件短写丢字节怎么排查?循环写、零进度判失败与调高内存上限
这不是磁盘写坏了,是 DeepSeek Harness 写文件时少写了字节却没人检查,文件还被继续当成完整输出公布。 排查时先别怀疑子进程本身,内存里的尾部往往完全正常,被截断的只有那份「用来兜底」的 spill 文件(来源)。
DeepSeek Harness 子进程 spill 短写的症状与 4 字节复现
典型表现是「模型看到的末尾没问题,但按提示去读完整输出文件时内容缺尾」——因为这两个数据源根本不是同一份。
- 找一个输出量很大的命令(例如全量构建日志、大文件
cat),让输出明显超过内存上限。预期界面提示有 spill 文件可读。 - 对比内存尾部的最后若干行与 spill 文件的最后若干行。预期尾部一致,但 spill 文件比预期短。
- 用
wc -c量 spill 文件大小,与命令真实输出的字节数比较。预期文件偏小,且差值不是固定的截断长度。 - 检查进程日志里有没有写入异常。预期什么都没有——这是关键判据,短写不抛错。
- 若文件大小正常但内容有替换字符,那不是本文的问题,去看 子进程输出按字节截断丢 U+FFFD。
- 记录一次「文件应该多大」的基准:把同一命令重定向到本地文件量一次字节数,之后所有比对都以它为准。预期后面每一步都有可对账的数字。
先复现:把 DeepSeek Harness 的内存上限压到 4 字节
讨论里给出的复现方式不需要真的等磁盘写满:把上限调到极小,人为让某次写入只前进 1 字节,就能稳定命中。
- 准备一个能配置输出内存上限的实例,把上限设为 4 字节。预期稍大的输出立刻触发 spill。
- 拦截收集器自己文件描述符上的写入,让选定的调用实际只写 1 字节并返回 1,其余 I/O 交给 Node 原生实现。预期其余逻辑完全不变。
- 依次推入
AAAA、BBBB、CCCC。预期磁盘上应当出现 12 字节的AAAABBBBCCCC。 - 读回文件比对。预期只看到
A+BBBB+CCCC,即ABBBBCCCC,少掉的 3 个字节正是回放阶段被短写吞掉的部分。 - 换一种拦截点,让当前分块的写入短写。预期得到
ABC之类的更短结果,说明两条写入路径都能独立丢字节。
根因:DeepSeek Harness 两处 writeSync 不读返回值,异常处理只认 throw
spillAll() 里有两处写入:回放已缓冲的分块,以及写入当前分块;两处都丢弃了 writeSync 的返回值,之后也没有任何「已写字节 vs 缓冲区长度」的比较。
社区核对源码的描述是:writeSync(fd, prior) 与 writeSync(spillFd, chunk) 各自独立调用,外层只有 try/catch,而短写既不抛错也不改变控制流(据讨论,社区核对源码)。于是不完整的文件被原样保留,路径继续对外公布。
还有两个细节让影响变大:
- spill 路径在两处写入之前就已经赋值公布,所以第一次短写发生时,上层已经拿到了这个路径。
- 唯一能撤销它的是
discardSpill(),而它只在写入抛错与整条流超限两处被调用,短写两条都不沾。
路径一:DeepSeek Harness 改成循环写,直到缓冲区耗尽
正确的基本原语是带偏移的循环写:每次只提交剩余部分,按返回值推进偏移,直到整个缓冲区写完为止。
- 把两处
writeSync(fd, buf)改成writeSync(fd, buf, offset, length)形式,先算剩余长度。预期调用签名与 Node 的 fs API 对齐。骨架大致如下:
let written = 0;
while (written < buf.length) {
const n = writeSync(fd, buf, written, buf.length - written);
if (n <= 0) throw new Error('short write made no progress');
written += n;
}
- 每轮把返回值累加到已写字节数,下一次从新的偏移继续。预期短写会被下一轮补齐。
- 循环条件写成「已写 < 总长」,不要写成「写了一次就结束」。预期不再依赖单次调用写满。
- 对两处写入分别实现,别只改一处——回放路径和当前分块路径都可能短写。预期两处都覆盖后,任一路径被拦截都能补齐。
- 在循环外保留原有的
try/catch,让真正的异常仍然走失败分支。预期异常路径行为不变。 - 用本文第 1 节的 4 字节复现跑一遍,预期磁盘文件为完整的 12 字节。
路径二:DeepSeek Harness 把零进度视为失败,走 discardSpill
只加循环还不够:如果 writeSync 对非空缓冲区返回 0,循环会原地打转。把 0 当失败并接入既有失败出口,才不会把「卡死」换成「静默截断」。
- 在循环里判断返回值,
0直接判定为失败,不再重试。预期不会出现死循环。 - 失败后调用
discardSpill(),撤销已公布的路径。预期上层拿到的是「没有 spill 文件」,而不是一份残缺文件。 - 同时触发收集器已有的失败回调,让这次输出被标记为不完整。预期日志或状态里能看出失败,而不是静默成功。
- 检查失败分支是否与整条流超限的处理复用同一出口。预期「不完整产物不作数」这条契约只有一处实现。
- 回归测试要覆盖两种短写:回放阶段短写、当前分块短写,以及返回 0 的情况。预期三种都走 discardSpill。
- 验证失败后再次写入同一命令,预期会重新创建 spill 文件,不会复用旧路径。
路径三:调高 DeepSeek Harness 的内存上限,从源头不 spill
如果你的场景输出量可控,最省事的做法是让输出压根不触发 spill:上限调大后短写路径不存在,也就不存在截断。
- 统计这类命令的实际最大输出字节数(取多次运行的 p95,不要取最大值)。预期得到一个可解释的量级。
- 把子进程输出的内存上限调到该量级之上,留出余量。预期常规命令不再产生 spill 文件。
- 用同一批命令重跑,确认界面不再提示 spill。预期内存占用上升但仍在可接受范围。
- 设一个监控:一旦重新出现 spill 就说明上限不够或命令行为变了。预期能及时发现回退。
- 明确这只是概率缓解——只要还有一次超限,短写仍可能发生,适合当过渡而非终局。这不是某个 DSH插件 的行为,开关插件也绕不开宿主自己的输出收集器。
DeepSeek Harness spill 修复后的验证与取舍
验收标准是可量化的:同一段输出,spill 文件字节数必须等于完整输出字节数,且失败时不能留下被公布的残缺文件。
- 用 4 字节上限的复现用例跑三条路径,预期前两条得到完整文件,第三条不产生文件。
- 用真实大输出命令跑一遍,
wc -c对账。预期字节数一致。 - 人为制造磁盘满或配额耗尽,预期走 discardSpill 并留下可读的失败信号。
- 观察内存占用:路径一与路径二几乎不增加,路径三会增加,按机器规格取舍。
这类缺陷的边界
- 这不是「普通写入经常短写」的抱怨——正常文件写入通常一次写满,触发条件是磁盘满、配额、信号打断与 Windows 部分完成。
- 短写与 UTF-8 边界截断是两个缺陷,别用同一个补丁一起改。
- 循环写必须有零进度保护,否则会把静默截断换成 CPU 空转。
discardSpill()的调用点目前只有两处,新增失败出口时要确认没有绕过它。- 上述位置与行为来自讨论中的社区核对源码(基线为 0.2.1-rc.2),上游未修,升级后需重新确认。
常见问题
DeepSeek Harness 的 spill 之所以静默截断,是因为 writeSync 返回的字节数没有被读取,短写不会抛异常。spill 路径在写入之前就已经公布,只有写入抛错或整条流超限才会走 discardSpill,短写两条都不命中。
不是同一个问题:DeepSeek Harness 的 spill 短写是写文件时少写了字节,文件尾部缺失;按字节截断是内存缓冲区在解码前被切在 UTF-8 字符中间,会出现替换字符。两者都在输出收集器里,但触发点和修法不同。
DeepSeek Harness 的复现办法是把输出的内存上限压到极小(讨论里用 4 字节),再拦截收集器自身描述符的写入,让选定的 writeSync 调用只写 1 字节并返回 1。推入 AAAABBBBCCCC,预期磁盘上应得到完整 12 字节。
DeepSeek Harness 调高内存上限只能降低触发概率,不能修掉它。上限调高只是让 spill 更少发生,一旦输出仍然超过上限,短写路径还在。它适合当临时缓解,不适合当修复。
DeepSeek Harness 的写循环必须判零进度,因为 writeSync 对非空缓冲区返回 0 时循环不会前进,会变成死循环。把 0 当失败并接入 discardSpill,才能既补齐短写,又不把截断换成空转。
相关术语
- spill 文件
- spill 文件是子进程输出超过内存上限后落盘的临时文件,用来保留完整输出,内存里只留一段有界尾部。它的路径会被公布给上层,因此内容必须完整。— https://github.com/deepseek-ai/deepseek-harness/discussions/10101
- 短写(short write)
- 短写指 write 类调用返回的正数小于请求写入的字节数,剩余字节没有落盘。常见触发条件包括磁盘满、配额耗尽、写入被信号打断,以及 Windows 上的部分完成。— https://github.com/deepseek-ai/deepseek-harness/discussions/10101
- discardSpill()
- discardSpill 是输出收集器里撤销 spill 路径的方法,表示「这个不完整产物不作数」。现有代码只在写入抛错和整条流超限两处调用它。— https://github.com/deepseek-ai/deepseek-harness/discussions/10101
来源
- [Bug] Subprocess spill files can silently lose bytes after short writes· deepseek-ai/deepseek-harness Discussions
- DeepSeek Harness 官方仓库· deepseek-ai