DSH plugin 多进程同时启动损坏 profile?cordis.yml 覆盖竞态排查

故障排查发布于 2026-09-12作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH plugincordis.yml并发启动原子写
同一 profile 并发启动多个无头实例,偶发在 boot 阶段报 failed to validate config file,事后文件却又完好?根因是每次启动都对 cordis.yml 做无条件的就地截断写入,文件会出现亚毫秒级的零长度空窗。

如果你把 dsh --profile headless 放进定时任务、还允许它并发跑多个实例,可能会遇到某个实例在启动阶段偶发非零退出,报 failed to validate config file .../cordis.yml——而你事后去看那个文件,它是完好的。 原因是 DeepSeek Harness 每次启动都会对 profile 的 cordis.yml 做一次无条件的就地重写O_TRUNC + 写入,同 inode,无临时文件、无 rename、无锁),文件因此在每次 boot 时有一瞬间是零长度的;恰好在这一瞬间读取的第二个进程读到空文档,过不了顶层数组校验就退出了(#441)。

现象:DeepSeek Harness 并发启动偶发失败,事后文件却完好

这个 bug 的排查难度主要来自「不可复现的现场」——失败瞬间的证据转瞬即逝,事后的文件却一切正常。 具体表现:

  1. 触发方式很日常:同一台机器、同一个共享配置目录,同一时刻启动两个以上无头运行:
dsh --profile headless "task A" &
dsh --profile headless "task B" &

其中一个会在 boot 阶段偶发以非零码退出,报:

Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include):
  failed to validate config file <DSH_HOME>/profiles/headless/cordis.yml

#441) 2. 还有一个近亲变体:当 overlay 在别的编辑器保存过程中被读取时(该读取同样不是原子的),会报另一个错误——同样指向一个「事后完全合法」的文件:

Error: dsh: overlay <DSH_HOME>/profiles/headless/cordis.patch.yml
  must be a top-level YAML array of loader patch entries

#441) 3. 频率符合竞态特征:约 14 次并发启动里观察到约 2 次失败——偶发、不固定,正是竞态应有的样子;而且事后 profile 文件完好无损,这一点最让人摸不着方向(#441)。 4. 证据:重写是「就地覆盖」而不是「替换」cordis.yml 在一次 boot 前后 inode 与 size 都不变而 mtime 前进,说明它没有被写入临时文件再 rename:

before boot: mtime=1786642203  size=223  inode=11562898
after  boot: mtime=1786642867  size=223  inode=11562898

#441) 5. 证据:这次重写会先截断。boot 期间对文件做 inotify 监控看到:一次 OPEN 与其 CLOSE_WRITE 之间夹着两次 MODIFY,这正是 O_TRUNC 后写入的签名(writeFileSync);10 ms 之后的那次 OPEN 就是另一个进程在读同一个文件(#441)。 6. 窗口极短,因此只有并发读者会撞上:以 1 ms 间隔轮询 stat 连续 25 秒(23,693 个样本),从未观察到 223 字节以外的尺寸。换句话说,窗口是亚毫秒级的,只有恰好在这期间 open 的读者才会看到空文档——而这正是并发启动在做的事(#441)。 7. 影响是可靠性而非安全性:进程在 loader 初始化期间就退出,早于任何工具运行,所以不会有未经批准的操作被执行(fail-fast 而非 fail-open)。代价是被调度的任务会静默跳过,除非调度器检查退出码;而且错误文本指向一个「任何人去看时都合法」的配置文件(#441)。

机制:DeepSeek Harness 的 prepareProfile 每次启动无条件就地重写 cordis.yml

把 profile 目录里的两个写入者分开看,才能明白为什么「加锁/随机后缀」这类直觉修法会打偏。 逐层拆解:

  1. boot 路径就是一行无条件写入@deepseek-ai/dsh/lib/profile-boot-DG5t9aNs.js:143prepareProfile() 里:
js
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG); // PROFILE_ROOT_FILENAME === "cordis.yml"

没有临时文件、没有 rename、没有锁,并且在每次 boot 都无条件执行。这就是本报告针对的路径(#441)。 2. 另一个写入者是 include 插件的回写@deepseek-ai/cordis-plugin-include/lib/index.js:243_writeFile() 用的是「临时文件 + rename」(固定后缀、无锁)。它不经过 boot 路径,所以把这一条改成随机后缀或加锁,修不到本报告描述的问题——社区里一开始就把这两个写入者搞混过一次,随后被逐条纠正(#441)。 3. 可观测的判别依据是 inode:rename 会替换 inode,就地写入会保留 inode。实测同一 boot 前后 inode=11562898 保持不变、mtime 前进,并且在一周的每日定时 boot 中一直是同一个 inode。因此这里的危险不是「两个进程争抢同一个 .tmp 文件名」,而是 O_TRUNC 落在正在被另一个进程读取的活文件上——这解释了为什么读者看到空文档、而文件事后完好(#441)。 4. 紧邻的两个文件其实已经有守卫:同一个 prepareProfile 里,cordis.patch.ymlpnpm-workspace.yaml 都是「文件不存在才写」:

js
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE);
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE);

cordis.yml 写进去的内容是编译期常量PROFILE_ROOT_CONFIG),也就是说每次 boot 都在用「文件里已经有的内容」重写一遍它。照旁边两个文件的写法加上守卫,就能彻底关掉这个窗口、且不需要任何锁(#441)。 5. 但「存在就不写」会丢掉一个保证prepareProfile 自己的 docstring 解释了这次重写为什么是无条件的——Loader 的 tree write-back 可能把组合出来的行持久化进 cordis.yml,如果放任这些行留下,下次 boot 会把每个 bundle insert 重复一遍。所以正确的形态是「先比较内容,不同才原子替换」:被写脏的文件内容必然不同、仍会被恢复,而每次普通启动都是 no-op,截断窗口自然消失(#441)。 6. 一个附带的小问题:include 那条 rename 重试路径只把 EACCES / EBUSY / EPERM 当作可重试,所以万一真有两个写入者竞争,输的那一方抛出的 ENOENT 会直接向上传播、而不是被重试。这与本报告的主题相互独立(#441)。

规避与修复:DeepSeek Harness 的 flock 串行化启动与先比较再原子改名

用户侧的规避很便宜——只需串行化启动阶段;官方侧的修复也不复杂,而且社区已经给出分支。 具体做法:

  1. 当前最实用的规避:只锁 boot 窗口。因为重写发生在启动的最初瞬间,把这一段串行化就够了:
bash
exec 9>"$HOME/.dsh-boot.lock"
flock 9
dsh --profile headless "$TASK" &
child=$!
sleep 6          # cover the rewrite; the model-heavy remainder still overlaps
flock -u 9
wait "$child"

实测效果:此前「三次并发丢一次」的运行变得稳定。锁在几秒后就释放,后面耗时的模型推理阶段仍然可以重叠,所以成本可以忽略——社区在一周内用这套办法跑了 207 次无头启动(195 次基准 + 12 次定时巡检),failed to validate config file 失败数为 0#441)。 2. 失败时直接重试即可:这是 fail-fast 的好处——进程死在 loader 初始化期,没执行任何工具;配置文件事后完好,所以重启一次就行,不需要清理残留(#441)。 3. 别让多个入口同时触发启动:例如托盘常驻之外又手动开了一个;同一 profile 一次只启动一个实例,或者用锁文件/等待第一个实例完全起来之后再启动第二个(#441)。 4. 官方修复的参考实现已经存在:分支 ivanusto/deepseek-harness @ fix/profile-root-config-atomic-write(commit 8a493ae)让 prepareProfile 先比较再写——普通启动发现内容已经正确就什么都不写,截断窗口在这条路径上不复存在;内容确实不同时,替换改走新增在 @deepseek-ai/dsh-atomic-write 里的 writeFileAtomicSync:独占创建同目录临时兄弟文件、新 inode 沿用调用方的 mode、rename 提交、失败时删除临时文件(#441)。 5. 为什么是同步版本而不是 await writeFileAtomic:boot 在事件循环承担任何实际工作之前就要写出配置,把这条路径改成异步会让整个 boot 序列的每一个调用方都被染成异步,却毫无行为收益;而在 profile-boot.ts 里手写替换也更糟——逻辑会与异步版本漂移,且很可能被仓库的克隆检测门禁拦下(#441)。 6. 验收门槛(该分支自测):新增/改动的测试文件跑 pnpm vitest run 17 个通过——writeFileAtomicSync 与其异步兄弟覆盖同样的路径(带父目录创建与精确 mode、收窄过宽权限的文件、替换符号链接目标而非穿透写入、dirMode、rename 失败时不残留临时文件);比较逻辑则覆盖「内容相同时 inode mtime 都不变」「文件里带有被烘进去的行时会被恢复」「inode 变化证明更新经由 rename 而非截断到达」。另外 pnpm run lintpnpm run typecheck 干净,pnpm run doc-sync 28 个门禁 0 失败(#441)。 7. 注意贡献渠道:该仓库的 CONTRIBUTING.md 明确表示当前不接受外部 PR,所以作者把分支留在那里,官方若认可这个形态,只需一次 cherry-pick 而不用重新实现。当你在做 DSH插件DeepSeek插件、并在 DSH Plugin Hub 上分发会自行改写 profile 配置的插件时,这个教训同样适用:任何在 boot 期改写共享文件的行为,都要考虑并发读者,写入要么是 no-op,要么必须是原子的(#441)。

DSH plugin 排查注意事项

先记住这是竞态而非配置错误——文件只在亚毫秒级的瞬间是空的,而判别「就地重写」还是「rename 替换」最快的方法就是看 inode。 八条要点:

  1. 窗口是亚毫秒级:25 秒 × 1 ms 轮询都抓不到,只有并发读者会撞上。
  2. 判别看 inode:inode 不变而 mtime 前进 = 就地重写;inode 变化 = rename 替换。
  3. 两个写入者别混淆:include 的回写走 tmp+rename,boot 走无条件 writeFileSync
  4. 别把「存在就不写」当完整修复:它会丢掉 tree write-back 的清理保证。
  5. 正确形态是「先比较再原子替换」:普通启动 no-op,内容变化时原子提交。
  6. 规避只需锁 boot 窗口:flock + 几秒后释放,模型推理阶段仍可重叠。
  7. 失败直接重试:fail-fast 意味着没有副作用需要清理。
  8. 同步而非异步:boot 写配置发生在事件循环承载工作之前,改成异步没有收益。
DSH Plugin Hub 插件市场:安装与分发会改写 profile 配置的插件前先了解其写入时机

来源:Discussion #441fix/profile-root-config-atomic-write

常见问题

DeepSeek Harness 并发启动偶发失败,事后配置文件却完好,这怎么解释?

DeepSeek Harness 的 cordis.yml 只在**极短的窗口里**是坏的,事后检查自然完好。prepareProfile() 每次启动都对 cordis.yml 做**就地写入**(O_TRUNC + 写入,同 inode,无临时文件、无 rename、无锁),所以该文件在每次 boot 时都会有一瞬间长度为 0。恰好在这个窗口里 open 的第二个进程读到空文档,过不了顶层数组校验,就在 agent 存在之前退出了——这正是它难排查的原因。

DeepSeek Harness 的这个重写窗口有多长,为什么只有并发才撞得上?

DeepSeek Harness 的这个重写窗口是**亚毫秒级**的,所以只有恰好在此期间 open 的并发读者才会撞上。有人以 1 ms 间隔轮询 stat 连续 25 秒(23,693 个样本),从未观察到 223 字节以外的尺寸,说明窗口极小。文件的实际内容并未改变(写进去的是初始化时的固定 [] 根配置),所以问题不是「内容写坏了」,而是「读者看到了写到一半的状态」。

把 DeepSeek Harness 的写入改成带锁或随机后缀能修好它吗?

不能——DeepSeek Harness 的 boot 走的不是 include 插件那条临时文件写入路径。profile 目录里其实有**两个写入者**:@deepseek-ai/cordis-plugin-include_writeFile() 用临时文件 + 固定后缀 rename(无锁),而 boot 走的是 prepareProfile() 里对 cordis.yml 的**无条件 writeFileSync**,根本不经过前者。所以把 include 那条改成随机后缀或加锁,修不到这个报告。

DeepSeek Harness 官方为何建议「先比较再原子改名」,而不是「文件存在就不写」?

DeepSeek Harness 官方建议「先比较再原子改名」,是因为「存在就不写」会丢掉一个保证。prepareProfile 自己的 docstring 说明这次重写为什么是无条件的:Loader 的 tree write-back 可能把组合出来的行**持久化进 cordis.yml**,如果放任这些行留下,下次 boot 会把每个 bundle insert **重复一遍**。先比较内容则两者兼得——被写脏的文件内容必然不同,所以仍会被恢复;而每次普通启动又都是 no-op,截断窗口自然消失。

相关术语

in-place rewrite
in-place rewrite 是用 O_TRUNC 打开同一 inode 再写入,而不是写临时文件后 rename。识别特征是 inode 与 size 在 boot 前后不变而 mtime 前进;代价是文件在写入瞬间存在零长度窗口,并发读者可能读到空文档。https://github.com/deepseek-ai/deepseek-harness/discussions/441
prepareProfile
prepareProfile 是 boot 阶段负责准备 profile 目录的函数。它对 cordis.yml 是无条件 writeFileSync,而紧邻的 cordis.patch.yml 与 pnpm-workspace.yaml 已经用 existsSync 守卫——这个不一致正是修复的切入点。https://github.com/deepseek-ai/deepseek-harness/discussions/441
fail-fast
fail-fast 是指进程在 loader 初始化期间就退出,早于任何工具运行,因此不会有未经批准的操作被执行。影响面是可靠性而非安全性:被调度的任务会静默跳过,除非调度器检查退出码。https://github.com/deepseek-ai/deepseek-harness/discussions/441

来源