Skip to content

Commit b794856

Browse files
fix(build): stage BMIs through mcpp instead of an in-place shell copy (2026.7.30.1, fixes #311) (#312)
* fix(build): stage BMIs through mcpp instead of an in-place shell copy (#311) Windows-only symptom, three overlapping defects. 1. `rule cp_bmi` overwrote the destination IN PLACE (`powershell Copy-Item -Force` / `cp -f`). mcpp writes the staged std BMI path into compile_commands.json so clangd can resolve `import std;`, clangd memory-maps that very file, and Windows refuses to replace a file with an open user-mapped section — error 1224, reported as a bare "build failed". Staging now runs through `mcpp stage` (new internal subcommand, same shape as `mcpp dyndep`): skip when the destination is already equivalent, else temp-file + rename, else in-place, with retries, and a failure message that names the file and the likely holder. Never downgraded to a warning — a stale BMI turns into a confusing "module 'std' not found" or a silently mismatched link. The rule is shared with Windows runtime-DLL deployment, which had the same hazard against a program still running from a previous `mcpp run`. 2. `default_cache_root()` was a private copy of the home resolution, unchanged since v0.0.1: no %USERPROFILE% branch, no self-contained detection. On Windows PowerShell (no $HOME) the std BMI cache landed in the *current working directory* as `.mcpp-bmi/` while dep BMIs went to %USERPROFILE%\.mcpp\bmi — two roots for one cache, the second cwd-dependent, which is what made a re-stage a routine event. New leaf module `mcpp.home` is now the single resolver (config, stdmod, prepare's git cache and two more copies in doctor all route through it). 3. The staging rule carried no `restat`, so any re-stage recompiled everything that imports std even when the bytes were identical. Adding `restat = 1` on top of the no-write path fixes that — measured: touching the cache-side BMI now runs the stage edge alone, and the next build is "no work to do". Note for the record: aligning the destination's mtime with the source defeats restat and re-triggers the cascade, so a skipped stage touches no timestamps at all. Also: `FAILED: <target>` survives the ninja output filter (normalized to `failed: <target>`) — dropping it is why the report couldn't tell a staging failure from a compile error; `mcpp new` ignores `.mcpp/`; `mcpp doctor` points at a leftover `.mcpp-bmi/`. Design + plan: .agents/docs/2026-07-30-issue311-*.md Tests: unit test_home (4), test_build_stage (10), test_ninja_backend (+4), e2e 170 (no-cascade + cache root), e2e 171 (held destination, PowerShell MemoryMappedFile on Windows — reproduces #311 without needing clangd). * chore: 2026.7.30.1 — BMI staging + cache-root convergence (#311) Bootstrap pin stays at 2026.7.29.1: it names an already-published mcpp and is bumped separately, after the release exists in xim-pkgindex. * fix(build): report the staged file's absolute path and drop ninja's trailing pad Two cosmetics found by driving a real staging failure end-to-end through ninja: the `FAILED: <target>` line ninja emits carries a trailing space, and `$out` is relative because ninja runs staging with cwd = the build directory — the reader of that diagnostic is the person who has to go unlock the file. * test(e2e): make the locked-destination case unable to pass for the wrong reason Two holes in 171 as first written: a PowerShell holder that failed to start left the build trivially succeeding (silent pass), and `chmod 444` is toothless under root — which the container e2e job runs as. The holder now signals readiness through a sentinel and the test fails if it never appears, and the load-bearing assertion is inode+mtime+size invariance of the destination: 'nothing was written', not 'the write happened to succeed'. * fix(build): verify staging by content, and route the last in-place copy through it Windows CI caught two things the Linux-only self-checks structurally could not. 1. `rule runtime_alias` was a SECOND `Copy-Item -Force`. PE has no soname symlink, so an alias is a copy of a freshly built DLL — the same hazard as BMI staging (a program still running from a previous `mcpp run` holds the old one). Its Windows branch now goes through `$mcpp stage` too; POSIX keeps `ln -s`, where the symlink is semantics and not merely how the file is written. The 'no Copy-Item anywhere' assertion is a no-op on Linux, which is why only the Windows job could find this. 2. Size-only equivalence was unsound for exactly those DLL payloads: PE section padding makes 'genuinely rebuilt, identical size' ordinary, so a stale DLL could survive in the build dir. Content comparison is unconditionally correct — a destination equal to the source needs no write — and it only runs when ninja has already decided the edge is dirty. It is now the default; `--verify size` / MCPP_STAGE_VERIFY=size stays for callers that know the source is fingerprint-scoped, and an unrecognized value falls back to the SAFE mode rather than the fast one. Also from CI: a read-only destination is replaceable on POSIX (rename rewrites the directory entry) but not on Windows, so that test now asserts both outcomes per platform instead of one; the e2e scripts unescape ninja node names (a Windows drive letter arrives as `C$:/Users/...`) and use BSD `stat -f` when GNU `stat -c` is absent (macOS). * test(e2e): don't let `wait` on the killed holder abort 171 (exit 143) `wait` on a job we just killed returns 143, and it sat as the last command of an `&&` list under `set -e` — so on Windows the script died the moment the mapping was released, before asserting anything about the build it had just run. Probed the shell semantics directly rather than guessing: the old form exits 143 on Linux too, it only never ran there because HOLDER is empty on POSIX. * fix(env): private-glibc strip was defeated by the composed override Found while attributing an e2e 156 failure on this branch (NOT caused by the staging change — proven by diffing the generated build.ninja for that exact project: it differs only in an unused rule's text). `process.cppm::merged_environ` strips mcpp's private-glibc payload entries from an INHERITED LD_LIBRARY_PATH, and plan.cppm's comment states that guarantee. But merged_environ takes explicit `extra` overrides verbatim, and `env::prepend_path_list` — which composes exactly such an override for ninja and for run targets — appended the inherited value RAW. So the strip was bypassed precisely in the case it exists for: a nested `mcpp run` → `mcpp test` chain, where a payload tool patched against a different glibc then segfaults inside the dynamic linker before main (bare `__vdso_time`, then SIGSEGV). The predicate moves to mcpp.platform.env, next to path-list composition, since both halves of the guarantee need it; prepend_path_list now sanitizes only the inherited TAIL, so a payload dir the caller passed explicitly — the entry the sandbox binary actually needs — always survives. PATH is untouched. Why it looked like a regression: the two CI runs restored DIFFERENT sandbox caches (…-01baa227… vs …-0e74cc64…), so the payload version set differed and the latent bug only showed on one side. Before this fix, 156 was green only when the poisoned payload version happened to match what the tools expected. * fix(build): verify fingerprint-scoped staging by size, so a read-denying holder can't fail the build The Windows job's own repro exposed the gap: my holder maps the destination with FileShare.None, so `same_content` cannot even OPEN it → equivalence is undecidable → mcpp attempts the write → ERROR_SHARING_VIOLATION(32) → build failed. Which is precisely the #311 outcome, arrived at from the other side. Reading needs an open; SIZE comes from directory metadata and does not. So the verify mode is now per-edge rather than one global setting: std BMI / std.o / std.compat.* --verify size fingerprint-scoped: the cache dir and the build dir share the fp covering compiler identity, triple, stdlib, std source hash and dialect flags ⇒ equal size IS equivalence Windows DLL deploy / runtime_alias content not fp-scoped, and a rebuilt DLL keeps its size (PE sections are page-padded) So #311's actual path — clangd mapping the std BMI — now survives even an EXCLUSIVE lock, while a possibly-stale DLL is still gated byte-for-byte. Carried on a per-edge `$verify` ninja variable; the separating space lives in the rule's command string because ninja trims trailing whitespace in variable values. Verified on Linux with chmod 000 (the closest analogue of a read-denying holder): the fp-scoped edge skips and the build succeeds. Also: bootstrap pin 2026.7.29.1 → 2026.7.29.2. Unrelated to this work and pre-existing on main — the index no longer serves .1 (`available: 2026.7.29.2`), so the aarch64 fresh-install job fails on main too. .2 is released and indexed, and the guard's rule (both pins equal, never newer than the building version) still holds. --------- Co-authored-by: sunrisepeak <speakshen@163.com>
1 parent 3b1cb6b commit b794856

24 files changed

Lines changed: 1942 additions & 131 deletions

.agents/docs/2026-07-30-issue311-bmi-staging-and-cache-root-design.md

Lines changed: 386 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# BMI staging 原语 + BMI 缓存根收敛 — 实施计划
2+
3+
配套设计:`2026-07-30-issue311-bmi-staging-and-cache-root-design.md`
4+
关联 issue:#311
5+
建议目标版本:**2026.7.30.1**(常规迭代)
6+
7+
单 PR 交付。阶段有依赖顺序:**P1 → P2 → P3**(P3 的 ninja 文本依赖 P2 的子命令存在,
8+
P2 的判据依赖 P1 收敛后的缓存根不再随 cwd 漂移)。P4/P5/P6 可与 P3 并行收尾。
9+
10+
---
11+
12+
## P1 — 单一 home 解析器(`mcpp.home`
13+
14+
**新建** `src/home.cppm`
15+
16+
```cpp
17+
export module mcpp.home;
18+
import std;
19+
import mcpp.platform;
20+
21+
export namespace mcpp::home {
22+
std::filesystem::path root(); // MCPP_HOME
23+
std::filesystem::path bmi_root(); // root()/bmi
24+
}
25+
```
26+
27+
- `root()` = 把 `src/config.cppm:313-353` 的 `default_mcpp_home()` + `home_dir()`
28+
**逐字搬迁**(含 `USERPROFILE` 分支、self-contained 探测、`target/` 与 `data/xpkgs/`
29+
两条 disqualify)。两个函数都是纯函数、无副作用,搬迁是安全的。
30+
- `bmi_root()` = `root() / "bmi"`。
31+
32+
**改动点(4 处调用方 + 3 处旧实现)**:
33+
34+
| 文件 | 位置 | 改动 |
35+
|---|---|---|
36+
| `src/config.cppm` | :313-353 | 删除两个本地函数 |
37+
| `src/config.cppm` | :500 / :510 | `cfg.mcppHome = mcpp::home::root()`;`cfg.bmiCacheDir = mcpp::home::bmi_root()` |
38+
| `src/toolchain/stdmod.cppm` | :179-187 | 整体替换为 `return mcpp::home::bmi_root();`(新增 `import mcpp.home;`) |
39+
| `src/build/prepare.cppm` | :2826-2832 | 内联 lambda → `mcpp::home::root()` |
40+
41+
**编译系统**:`mcpp.toml` 若显式列源文件需同步;当前是 `src/**` 推导(`mcpp build -v`
42+
里 `Inferred sources`),无需改。
43+
44+
**无环性核对**(改前必须自查一次):`mcpp.home` 只 import `std` + `mcpp.platform`。
45+
`mcpp.config` 的闭包内无 `mcpp.toolchain.*`,故 `mcpp.toolchain.stdmod → mcpp.home` 不成环。
46+
47+
**测试**(`tests/unit/test_config.cpp` 扩写,或新建 `tests/unit/test_home.cpp`):
48+
49+
| 用例 | 断言 |
50+
|---|---|
51+
| `MCPP_HOME` 优先 | 设环境变量 → `root()` == 该值;`bmi_root()` == `<it>/bmi` |
52+
| Windows 无 HOME | `is_windows` 下 `USERPROFILE` 生效(用 `if constexpr` 分支或跳过非 Windows) |
53+
| 兜底形态 | 两个变量都不设时,路径以 `.mcpp` 结尾、**不再**以 `.mcpp-bmi` 结尾 |
54+
| 与 config 一致 | `load()` 后 `cfg.bmiCacheDir == mcpp::home::bmi_root()`(同一进程同一环境下必须相等) |
55+
56+
最后一条是这次的核心不变量,**必须机器校验**——它就是 D2 的回归闸。
57+
58+
---
59+
60+
## P2 — `mcpp stage` 子命令
61+
62+
**新建** `src/build/stage.cppm`(`export module mcpp.build.stage;`),承载纯逻辑:
63+
64+
```cpp
65+
struct StageResult { bool copied; }; // copied=false ⇒ 判等跳过
66+
struct StageError { std::string message; }; // 已含 hint 文案
67+
std::expected<StageResult, StageError> stage_file(
68+
const std::filesystem::path& src,
69+
const std::filesystem::path& dst,
70+
bool verify_hash);
71+
```
72+
73+
实现按设计 §S1 语义表:
74+
75+
1. `src` 不存在 → error。
76+
2. `create_directories(dst.parent_path())`
77+
3. `dst` 存在 && `file_size` 相等 && (verify == Size || 逐字节相等)
78+
**不写字节、不碰时间戳**`StageOutcome{.copied = false}`
79+
(对齐 mtime 会让 `restat` 失效并重新引发级联——实测过,见设计 §S1 的表)
80+
4. 否则:`copy_file(src, dst.tmp.<pid>)``rename(tmp, dst)`
81+
5. rename 失败 → `copy_file(src, dst, overwrite_existing)`
82+
6. 4/5 均失败 → 睡 100 / 300 / 900 ms 重试整个 4-5 序列,共 3 轮。
83+
7. 仍失败 → `StageError`,文案照设计 §S4 的模板(file / from / os error / hint 四段,
84+
hint 必须点名 clangd 与 `compile_commands.json` 的因果)。
85+
8. 每次退出前清理残留 `dst.tmp.<pid>`
86+
87+
`Verify::Content`**默认**`--verify size` / `MCPP_STAGE_VERIFY=size` 才退回只比 size。实现是分块
88+
逐字节比较(同 I/O 成本、无碰撞面、可提前退出),不引入 hash 依赖。
89+
90+
**CLI 接线**(照 `dyndep` 的形状):
91+
92+
| 文件 | 改动 |
93+
|---|---|
94+
| `src/cli/cmd_build.cppm` | 新增 `export int cmd_stage(const ParsedArgs&)`,就近放在 `cmd_dyndep`(:187)旁 |
95+
| `src/cli.cppm` | :496 附近新增 `.subcommand(cl::App("stage") ...)`,描述以 `(internal: invoked by ninja)` 开头,选项 `--output/-o``--verify``.action(wrap_rc(cmd_stage))` |
96+
| `src/cli.cppm` | :547-551 的 `known` 白名单加 `"stage"`**数组长度 22 → 23**(写死的模板实参,漏改即编译失败/静默拒命令) |
97+
98+
**测试**(新建 `tests/unit/test_build_stage.cpp`):
99+
100+
| 用例 | 断言 |
101+
|---|---|
102+
| 目标不存在 | 复制发生,`copied == true`,内容一致 |
103+
| 目标已存在且等长等内容 | `copied == false`,且**目标 mtime 一点没变**(不是"未变成 now",是完全不变) |
104+
| 等长但内容不同 + `Verify::Content` | `copied == true` |
105+
| 等长但内容不同 + `Verify::Size` | `copied == false`(这是**有意的** fp 判据取舍,注释写清) |
106+
| `src` 缺失 | error,message 含 src 路径 |
107+
| 目标目录不存在 | 自动创建 |
108+
| 只读目标(POSIX `chmod 444`| 走 rename 分支成功;断言最终内容正确 |
109+
| 错误文案 | 人为构造失败(目标是个目录)→ message 含 `clangd``hint:` |
110+
111+
---
112+
113+
## P3 — ninja 后端切到新 rule
114+
115+
**文件**`src/build/ninja_backend.cppm`
116+
117+
1. **`mcpp` 变量提取**:把 :404 的
118+
`append(std::format("mcpp = {}\n", escape_ninja_path(mcpp_exe_path())))`
119+
移出 `if (dyndep)`(:403),改为无条件绑定;`scan_deps` 仍留在 `if (dyndep)` 内。
120+
2. **rule 重写**(:411-419),跨平台单一形态、不再分叉 PowerShell/`cp`
121+
122+
```
123+
rule stage_file
124+
command = $mcpp stage --output $out $in
125+
description = STAGE $out
126+
restat = 1
127+
```
128+
129+
3. **rule 更名**`cp_bmi``stage_file`,四处 staging edge(:793/:795/:807/:810)与
130+
DLL 部署(:1080)同步改名。
131+
4. `command_prefixes()`(:278-291)追加 `mcpp_exe_path()`
132+
5. `filter_ninja_output()`(:323-345):`FAILED:` 不再整行丢弃,改为归一成
133+
`failed: <target>` 保留。
134+
135+
**文件**`src/build/execute.cppm`
136+
137+
6. `read_ninja_command_prefixes()`(:181-205)白名单 key 加 `"mcpp"`
138+
139+
**测试**`tests/unit/test_ninja_backend.cpp`):
140+
141+
| 用例 | 断言 |
142+
|---|---|
143+
| rule 文本 |`rule stage_file``$mcpp stage --output $out $in``restat = 1`**不含** `Copy-Item``cp -f` |
144+
| `mcpp` 绑定 | dyndep 开/关两种 plan 下都出现 `mcpp = `|
145+
| staging edge | 四条 edge 的 rule 名是 `stage_file``std.compat` 仍有 `| pcm.cache/std.pcm` order-only 前置 |
146+
| DLL 部署 | `runtimeDeployFiles` 非空时用同一 rule 名 |
147+
| 过滤器 | `filter_ninja_output` 对含 `<mcpp路径> stage ...` 的回显行过滤掉、对 `failed:``hint:` 正文保留 |
148+
149+
---
150+
151+
## P4 — 兜底路径的可见性
152+
153+
| 文件 | 改动 |
154+
|---|---|
155+
| `src/scaffold/create.cppm` | :284-287 的 `.gitignore` 模板:`target/` + `.mcpp/` |
156+
| `src/doctor.cppm` | :155/:192 附近:若 cwd 或工程根存在 `.mcpp-bmi/`,输出一行 `legacy BMI cache at <path> — safe to delete`**不自动删**|
157+
158+
`.gitignore` 模板变更需同步 e2e 中断言过 scaffold 产物的用例(`grep -rn "gitignore" tests/e2e`
159+
先扫一遍)。
160+
161+
---
162+
163+
## P5 — e2e
164+
165+
**新建 `tests/e2e/170_bmi_staging_no_cascade.sh`**(全平台跑,锁 D3 + S1 步 2):
166+
167+
1. `mcpp new` 一个 bin 工程 → `mcpp build`(产出 staged BMI);
168+
2.`mcpp build -v` 的 STAGE 行或 `build.ninja` 解析出 staging edge 的 `$in`
169+
**不要**`awk '{print $NF}'` 直接切 `rule` 行——本次调查里就踩过,取到的是 `cp_bmi`
170+
这个字面量;正确做法是匹配 `^build .*: (stage_file|cp_bmi) ` 的行再取最后一个字段);
171+
3. `touch "$in"` 让 edge 变脏;
172+
4. `mcpp build -v` 断言:
173+
- 退出 0;
174+
- 输出**不含** `src/main.cpp` 的编译行(反级联,今天会失败);
175+
- staged BMI 的内容与 `$in` 一致。
176+
177+
**新建 `tests/e2e/171_bmi_staging_locked_dest.sh`**
178+
179+
- **Windows 分支**`case "$(uname -s)" in *NT*|MINGW*|MSYS*)`):用 PowerShell 在子进程里
180+
映射住 staged BMI,**不依赖 clangd**
181+
182+
```powershell
183+
$f = [System.IO.MemoryMappedFiles.MemoryMappedFile]::CreateFromFile(
184+
$path, [System.IO.FileMode]::Open)
185+
Start-Sleep -Seconds 30 # 持有期覆盖被测构建
186+
```
187+
188+
然后 `touch` 缓存侧 BMI(保持内容不变)→ `mcpp build` 必须**成功**(走判等跳过)。
189+
这就是 #311 的最小复现,且不需要装 clangd。
190+
- **POSIX 分支**`chmod 444` staged BMI + 让缓存侧内容真的不同(改用另一个 fingerprint 的
191+
BMI 或人为构造一份等长-不同内容的假文件)→ 断言走 rename 分支成功。
192+
- 负例(两个平台):把 staged BMI 换成一个**目录**同名占位 → 断言构建失败且 stderr 含
193+
`hint:``clangd`
194+
195+
`tests/e2e/run_all.sh` 若是显式清单则登记两个新脚本;编号接 169(上游 169 已被 semver 用例占用)。
196+
197+
---
198+
199+
## P6 — 收尾
200+
201+
1. `CHANGELOG.md`
202+
- fix(#311):Windows 上被 clangd 映射的 std BMI 不再让构建失败;
203+
- **behavior change**:BMI 缓存根统一为 `$MCPP_HOME/bmi`(Windows 从 `<cwd>\.mcpp-bmi`
204+
self-contained 安装从 `~/.mcpp/bmi` 迁走);首次构建会重编一次 std(10–60 s),
205+
遗留目录可手动删除。
206+
2. 版本号:`mcpp.toml``2026.7.30.1`(注意 `git status``mcpp.toml` 已有本地改动,
207+
提交前先核对那处改动是否该一起进)。
208+
3. 发布闭环按既有 runbook 走(release → 镜像 xlings-res 双端 → xim-pkgindex → 真装验证 →
209+
bootstrap pin)。**bootstrap pin 与本次发布版本是两组,不要一起 bump**
210+
4. 不要在 issue #311 下评论(按本次任务要求);发布后再回复。
211+
212+
---
213+
214+
## 自查清单(提交前逐条打勾)
215+
216+
- [ ] `grep -rn "\.mcpp-bmi" src/` 只剩 doctor 的遗留提示与注释,无路径构造
217+
- [ ] `grep -rn "getenv(\"HOME\")" src/` 不再出现在 BMI/home 解析路径上
218+
- [ ] `grep -rn "Copy-Item" src/` 归零
219+
- [ ] `grep -rn "cp_bmi" src/` 归零
220+
- [ ] `cli.cppm``known` 数组长度与元素数一致(22 → 23)
221+
- [ ] `if (dyndep)` 之外能拿到 `$mcpp`(用 GCC 非 dyndep plan 生成一次 build.ninja 目视核对)
222+
- [ ] 单测全绿 + `tests/e2e/170``171` 全绿(Linux 本机 + Windows CI)
223+
- [ ] Windows CI 上确认 STAGE 行不再 spawn PowerShell(顺带的启动开销收益)

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,47 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [2026.7.30.1] — 2026-07-30
7+
8+
### 修复
9+
10+
- **[#311](https://github.com/mcpp-community/mcpp/issues/311) Windows 上 clangd 映射住 std BMI 会让整个构建报 `build failed`** mcpp 把 staged std BMI 的路径写进 `compile_commands.json`,好让 clangd 解析 `import std;` —— clangd 于是把这个几十 MB 的文件 mmap 住;而 staging 步骤(`rule cp_bmi`)用 `powershell Copy-Item -Force` **原地覆写同一个文件**,Windows 拒绝替换带 user-mapped section 的文件(error 1224)。也就是说,mcpp 一边把这个路径交给别人读,一边在原地重写它。
11+
12+
POSIX 侧看不见:GNU `cp -f` 在目标打不开时会 unlink 重建,POSIX 本身也允许覆写被 mmap 的文件 —— 所以 CI 一直全绿,只有 Windows 用户中招。
13+
14+
staging 改为走 mcpp 自己的内部子命令 `mcpp stage`(形态对齐既有的 `mcpp dyndep`):**目标已等价就一个字节都不写**,否则先写同目录临时文件再 rename,再退化为原地覆写,失败按退避重试,最终失败时给出点名文件与可能持有者(clangd / 编辑器索引 / 杀软 / 上一次 `mcpp run` 还在跑的程序)的诊断。**绝不降级为 warning** —— staged BMI 过期或缺失会变成难以归因的 `module 'std' not found`,或者更糟:旧 BMI 配新 `std.o` 静默链接。
15+
16+
「已等价」由**逐字节比较**判定 —— 无条件正确:内容相同就是不需要写。这个判断只在 ninja 已经认定 edge 脏了才会跑,所以代价可以忽略。(`--verify size` 保留给确知源是 fingerprint 作用域的调用方:build dir 与缓存目录共享同一个 fp,而 fp 已覆盖编译器身份/target triple/stdlib/std 源哈希/标准与方言 flag。但它**不是默认** —— 同一条 rule 还搬 DLL,而 PE 的节对齐让「真的重建了、大小却一样」十分常见。)dep BMI 缓存一直就是「已存在就不动」的(`bmi_cache.cppm`: *"Existing project outputs are left untouched"*)—— std staging 是全仓库唯一强制覆写的那一处,这个不对称本身就是缺陷。
17+
18+
**verify 档位是 per-edge 的**:std BMI / `std.o` / `std.compat.*``--verify size`,Windows DLL 部署与 `runtime_alias` 走默认的逐字节比较。原因是判等要读目标就必须 open 它,而持有者可以连读都不给(Windows `FileShare.None` 映射 → open 即 `ERROR_SHARING_VIOLATION`);size 来自目录元数据、不需要 open。于是 #311 那条真实路径(clangd 映射 std BMI)**连排他锁都扛得住**,而可能过期的 DLL 仍逐字节把关 —— 前者靠 fingerprint 作用域保证等长即等价,后者没有这个保证。
19+
20+
同一条 rule 也用于 **Windows 运行期 DLL 部署**,以及 Windows 上的 `runtime_alias`(PE 没有 soname 符号链接,别名就是刚建出来的 DLL 的副本),因此「往上一次 `mcpp run` 还加载着的 DLL 上覆写」这个同类失败一并治好。POSIX 的 `runtime_alias` 保持符号链接不变 —— 那里符号链接是语义,不只是写法。
21+
22+
- **重新 stage 一个未变的 std BMI 不再重编整张模块图。** staged BMI 是每个 importer 的 implicit input,而 staging rule 既不保留 mtime 也没有 `restat`,于是缓存侧 BMI 只要 mtime 变新(在下面那个缓存根缺陷下,**换个 cwd 跑就会发生**),所有 `import std` 的 TU 全部重编 —— 即使字节完全相同。
23+
24+
修法是「不写字节」+ `restat = 1`。实测:`touch` 缓存侧 BMI 后,只有 staging edge 重跑,`main.cpp` 不再重编,下一次构建回到 `no work to do`
25+
26+
一条记录在案的实现约束:**跳过时对 mtime 的任何触碰(包括对齐到 src 的 mtime)都会让 restat 失效并重新引发级联** —— ninja 的 restat 只把「mtime 未被命令改变」的输出视为从未需要构建。所以跳过路径不动任何时间戳。
27+
28+
- **私有 glibc 的 strip 不再被「组合出的显式覆盖」绕过。** `process.cppm::merged_environ` 会把继承来的 `LD_LIBRARY_PATH` 里的私有 glibc payload 条目剥掉,但**显式 `extra` 覆盖是原样采用的**;而 `env::prepend_path_list` 组合该覆盖时,把继承值(含毒)整段追加了进去 —— 于是 strip 恰好在它唯一有用的场景下失效:嵌套的 `mcpp run``mcpp test` 链里,子工具拿到一个**版本不匹配**的 libc.so.6,在动态链接器里 main 之前 SIGSEGV(签名:一行裸 `__vdso_time`)。
29+
30+
修法是把判据下沉到 `mcpp.platform.env`(路径列表组合的所在地),并让 `prepend_path_list` 只清洗**继承来的尾部**:调用方显式传入的 payload 目录必须保留 —— 那正是沙箱二进制需要的那一条。PATH 不受影响。
31+
32+
这个缺陷与 #311 无关,是排查 e2e 156 在本 PR 上失败时找出来的:两次 CI 恢复了**不同的 sandbox 缓存**(`…-01baa227…` vs `…-0e74cc64…`),payload 版本集不同,于是同一个潜伏缺陷只在一侧显形。本改动前该测试的绿灯取决于「毒化的 payload 版本恰好与工具期望的一致」。
33+
34+
35+
### 变更
36+
37+
- **BMI 缓存根统一为 `$MCPP_HOME/bmi`** `toolchain/stdmod.cppm``default_cache_root()` 是 home 解析逻辑的一份私有拷贝,自 v0.0.1 起一字未改:**没有 Windows 的 `USERPROFILE` 分支,也没有 self-contained 安装探测**。后果是 Windows PowerShell(不设 `HOME`)下 std BMI 缓存落进**当前工作目录**`.mcpp-bmi/`,而 dep BMI 缓存在 `%USERPROFILE%\.mcpp\bmi` —— 一个缓存两个根,其中一个还随 cwd 漂移(从子目录跑就重编一次 std);release tarball 形态的安装在 Linux 上同样分家。
38+
39+
新增叶模块 `mcpp.home` 作为唯一解析器,`config` / `stdmod` / `prepare` 的 git 缓存,以及 `doctor` 里另外两份拷贝(其中 `self init --force` 那份在 self-contained 安装下会去删错的树)全部收敛过来。单测锁死 `default_cache_root() == mcpp::home::bmi_root()`
40+
41+
**升级影响**:Windows 用户与 self-contained 安装的用户首次构建会重新编一次 std 模块(10–60 s);遗留的 `.mcpp-bmi/` 不再使用,`mcpp doctor` 会指出它,可手动删除。
42+
43+
- **`FAILED: <target>` 不再被输出过滤器整行丢掉**,归一成 `failed: <target>` 保留。#311 的报告读不出「失败的是 BMI staging 而不是编译」,一半原因就是这行被丢了。同时把 mcpp 自身的可执行路径纳入命令行前缀集合,于是被回显的命令行被过滤、mcpp 打印的诊断保留。
44+
45+
- **`mcpp new` 生成的 `.gitignore` 加上 `.mcpp/`**(per-project xlings sandbox,以及解析不出 MCPP_HOME 时的本地 BMI 缓存)。
46+
647
## [2026.7.27.1] — 2026-07-27
748

849
### 变更

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.7.29.2"
3+
version = "2026.7.30.1"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/execute.cppm

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@ std::vector<std::string> read_ninja_command_prefixes(const std::filesystem::path
190190
auto key = line.substr(0, eq);
191191
while (!key.empty() && std::isspace(static_cast<unsigned char>(key.back())))
192192
key.pop_back();
193-
if (key != "cxx" && key != "cc" && key != "ar" && key != "scan_deps")
193+
// `mcpp` drives the dyndep + stage_file rules; treating it as a command
194+
// prefix filters the echoed command line while keeping the diagnostic
195+
// mcpp itself printed (#311).
196+
if (key != "cxx" && key != "cc" && key != "ar" && key != "scan_deps"
197+
&& key != "mcpp")
194198
continue;
195199

196200
std::string value = line.substr(eq + 1);

0 commit comments

Comments
 (0)