| 项 | 值 |
|---|---|
| 规范编号 | SPEC-011 |
| 标题 | 同一工程上的并发运行:构建目录锁、原子发布、乐观写入与取消 |
| 状态 | 评审中 v1.0 |
| 版本 | 1.0 |
| 最后修改 | 2026-10-26 |
| 对应实现 | mcpp >= 2026.10.26.1 |
| 相关设计文档 | .agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md §3、§4、§5.4 |
| 相关 issue | #839 |
| 使用文档 | 50 — 机器可读输出 §9、§11 |
用语按 RFC 2119:必须 / 禁止为强制,应当为建议,可以为可选。
一个工程同时被多条调用链驱动:终端里的人、只有 shell 的 agent、IDE 的保存时构建、
CI。在 2026.10.24.1 上,同一配置的两次冷构建相隔 0.1–0.5 s 启动,5 轮中有 3 轮双方
都失败(failed to write/read compiled module,#839)。全局一侧(索引刷新、BMI 缓存、
工具链目录)已有细粒度保护,工程的构建目录是唯一没有跨进程保护的共享可变资源。本规范
规定每一种共享资源的保护方式,以及读者可以依赖的保证。
一次构建分为三段:
| 段 | 内容 | 写入 | 锁 |
|---|---|---|---|
| 规划 | 读清单、解析依赖与工具链(可联网)、目标侧、扫描,直到配置指纹确定 | 不写构建目录 | 无 |
| 修改 | 准备的收尾(动作、resolution.json、build.ninja、配置级编译数据库)、ninja、产物校验、配置记录与结果记录 |
构建目录 | 构建目录锁 |
| 执行 | mcpp run 启动程序;mcpp test 之后的程序运行由测试命令持锁(§2.3) |
无 | run:无 |
配置目录 target/<triple>/<fingerprint>/ 由指纹决定,指纹在规划末尾才确定。规划段
禁止写入构建目录。
- 每个构建目录一把锁,文件为
<outputDir>/.mcpp-build.lock,旁边的.owner记录持有者:pid、开始时间、命令行与调用方标签(MCPP_CLIENT)。 - 锁必须在修改段开始时获取(完整规划时在准备的收尾阶段之前,快速路径在接纳记录的 计划之前),持有到修改段结束。规划段禁止持有它。
- 后到者必须等待而不是失败。等待超过 300 ms 时,标准错误上出现一行
Waiting for the build directory, held by pid N (<command>, from <client>, Ns),--message-format json下另有waiting事件。取得锁后,ninja 只做前者未做完的事。 - 不同配置的构建使用不同的目录与不同的锁,禁止相互等待。
- 锁描述符必须带 close-on-exec;
mcpp run必须在 exec 程序之前释放锁,程序 运行期间同配置的构建不被阻塞。mcpp test持锁直到测试结束,因为它在同一构建目录中 运行刚链接的测试程序。 - 进程崩溃或被 SIGKILL 时由操作系统释放锁,不存在僵死锁。Windows 使用
LockFileEx, 语义相同(实测见设计 E24)。
| 资源 | 规则 |
|---|---|
| 链接产物(可执行文件、共享库、测试程序) | POSIX 非 PE 目标:链接写 <out>.tmp,再 mv -f 改名。Windows 宿主:mcpp link-output 先把旧产物改名为 <out>.old-<n>,再原地链接;运行中的程序保留旧文件,以后的链接清理不再被占用的 .old-*。POSIX 宿主上的 PE 目标:原地链接 |
result.json、target/.last-result.json、resolution.json、配置级与根目录的 compile_commands.json |
写临时文件后改名替换 |
target/.build_cache(配置记录) |
读改写在 target/.build_cache.lock 下进行,写临时文件后改名替换;格式不变 |
mcpp.lock |
写临时文件后改名替换;乐观并发(§4) |
读者(查询命令、IDE、mcpp emit build-database、读取记录的程序)禁止加锁,并且
必须只会看到某一次完整写入的内容。链接产物在任何时刻要么是旧文件、要么是完整的
新文件;在链接进行时反复启动程序,不会出现 ENOEXEC、ETXTBSY 或执行半个文件
(设计 E23)。
解析依赖可能联网,持锁会把一次慢的下载变成对整个工程的阻塞。因此:
- 规划开始读取
mcpp.lock时,记录读到的内容(换行统一为 LF;文件不存在也是一种内容)。 - 写入前必须重新读取。当前内容与记录相同,或与本次将要写入的内容相同,则写入 (后一种情况下无需改写)。
- 否则禁止覆盖,运行以
[lockfile-concurrent]失败(退出 1),并以help: run the command again给出补救:重跑即按当前的锁文件重新解析。
- SIGINT、SIGTERM、SIGHUP 结束修改段中的运行时,mcpp 必须先结束它启动的进程组,
再写下
cancelled的结果记录,然后按信号的默认动作结束(退出状态见 SPEC-003 §3.7)。记录的这一形态在修改段开始时预先生成,信号处理器 只调用异步信号安全的open、write、close、rename。 - 锁由进程结束释放。构建目录保持一致:ninja 的日志只记录完成的步骤,被中断的步骤在 下一次构建中重做。
- SIGKILL 不经过处理器;
status为running而pid已不存在的记录,读者应当 读作"被强制结束"。
全局 MCPP_HOME 保持既有的细粒度保护:索引刷新使用非阻塞锁,BMI 缓存按条目加锁,
工具链按版本目录安装。编译器探测缓存(<home>/cache/probe)与提供者握手缓存
(<home>/cache/provider-handshake)的条目以写临时文件后改名的方式发布,并发的写入者
写出相同的内容。它们只由声明了 write-global-cache 的运行(规划或构建)写入,其他命令
只读取:self doctor、self env、toolchain list 不改变 home。
| 版本 | 日期 | 变更 |
|---|---|---|
| 1.0 | 2026-10-26 | 首版:三段、构建目录锁、原子发布、mcpp.lock 的乐观写入、取消(#839) |