Skip to content

Commit 72c14eb

Browse files
committed
docs(b3): target-aware artifact naming — the real symptom is a relink every build
Writes up B3 properly and corrects what the original §6.5 claimed. The original said it was 'symmetrically wrong' — Windows→Linux produces mcpp.exe for an ELF, Linux→Windows produces mcpp for a PE. The second half is false. Measured: a Linux host cross-compiling to x86_64-windows-gnu produces b3probe.exe (PE32+), because mingw's GCC driver appends .exe itself when the -o name has no extension. mcpp never participates in that decision. The actual defect is elsewhere and matters more. ninja is told the output is bin/foo while GCC writes bin/foo.exe, so the declared file never exists and ninja reruns the link edge on every build. Verified by mtime across two consecutive builds — the artifact is relinked every time. Incremental builds are effectively off for PE targets, which is the path CI exercises daily. That also explains why nothing caught it: 102_mingw_cross_wine.sh looks for the real artifact (find -name '*.exe'), not for what ninja declared, so both of its assertions hold while the inconsistency sits underneath them. Two further findings the fix has to account for: - naming is an (os, env) function, not an os one. windows-gnu uses the GNU convention (libfoo.a); only windows-msvc is foo.lib. The current _WIN32 branch hardcodes the latter, so building a static library with mingw ON a Windows host is already misnamed today — a pre-existing defect unrelated to cross-compilation. - the blast radius is much smaller than §6.5 feared. e2e and CI need essentially no changes, precisely because they match the real artifact. Also confirms the other 15 exe_suffix references are correct host semantics (locating ninja / xlings / clang++ on the build machine) and must not be touched; the change is confined to src/build/plan.cppm.
1 parent 831cd89 commit 72c14eb

2 files changed

Lines changed: 300 additions & 14 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# B3 — 产物命名按 target 而非 host(交叉构建每次重链)— 修复方案
2+
3+
> 2026-08-03 · 基于 `9a696d2`(2026.8.3.2)代码审计 + 本机实测
4+
> 来源:`2026-08-03-windows-host-linux-cross-design.md` §6.5 登记的 follow-up
5+
> 记忆:[[windows-host-linux-cross-canadian]][[soname-alias-explicit-ninja-goals]]
6+
> [[explicit-ninja-goals-two-regressions]]
7+
8+
## 0. 一句话
9+
10+
`plan.cppm::target_output()`**主机**常量拼产物名,交叉构建时 **ninja 声明的输出文件根本不会
11+
出现**,于是 **每次 `mcpp build` 都重新链接一遍** —— 这不是命名难看,是增量构建对 PE 目标失效。
12+
13+
---
14+
15+
## 1. 先更正一处判断
16+
17+
`windows-host-linux-cross-design.md` §6.5 把 B3 写成「对称地错」:
18+
19+
> | 方向 | 现状 | 应当 |
20+
> |---|---|---|
21+
> | Windows → linux-musl | `mcpp.exe`(却是 ELF) | `mcpp` |
22+
> | **Linux → windows-gnu** | **`mcpp`(却是 PE)** | `mcpp.exe` |
23+
24+
**下面那一行是错的。** 本机实测,Linux 主机交叉到 `x86_64-windows-gnu`:
25+
26+
```console
27+
$ mcpp build --target x86_64-windows-gnu
28+
$ find target -type f -path "*/bin/*"
29+
target/x86_64-windows-gnu/751cb693195ee82c/bin/b3probe.exe ← 有 .exe
30+
$ file …/b3probe.exe
31+
PE32+ executable (console) x86-64, for MS Windows
32+
```
33+
34+
产物名是对的。原因是 **mingw 的 GCC driver 自己补的**:`-o foo``foo` 无扩展名时,
35+
mingw GCC 输出 `foo.exe`。mcpp 从未参与这个决定。
36+
37+
真正的问题因此换了一个形态,而且更实际。
38+
39+
---
40+
41+
## 2. 真实症状:ninja 的输出声明与产物不符 ⇒ 每次重链
42+
43+
`target_output()` 在 Linux 主机上返回 `bin/<name>`(`exe_suffix` 为空),ninja 规则照抄:
44+
45+
```ninja
46+
build bin/b3probe : cxx_link obj/main.o # ← 声明的产物
47+
```
48+
49+
GCC 却写出 `bin/b3probe.exe`。于是 `bin/b3probe` **从来不存在**:
50+
51+
```console
52+
$ test -f target/x86_64-windows-gnu/*/bin/b3probe && echo EXISTS || echo MISSING
53+
MISSING
54+
```
55+
56+
ninja 每次都发现声明的输出缺失 ⇒ 重跑链接边。实测(连续两次 build 比对 mtime):
57+
58+
```console
59+
mtime before: 1785736156
60+
mtime after rebuild: 1785736180
61+
RESULT: RELINKED — declared output never exists, so ninja reruns the link every build
62+
```
63+
64+
**两个方向的实际后果因此并不对称:**
65+
66+
| 方向 | ninja 声明 | 实际产物 | 后果 |
67+
|---|---|---|---|
68+
| Linux → windows-gnu | `bin/foo` | `bin/foo.exe`(GCC 补) |**声明永不满足 ⇒ 每次重链** |
69+
| Windows → linux-musl | `bin/foo.exe` | `bin/foo.exe`(ELF) | ⚠️ 声明与产物一致,构建正常;只是 ELF 顶着 `.exe` |
70+
71+
也就是说:**Windows→Linux 只是难看,Linux→Windows 是功能缺陷。** 后者恰恰是已经发布、
72+
CI 每天在跑的那条路径(`mingw-cross linux→windows`)。
73+
74+
> **为什么 CI 一直绿**:`102_mingw_cross_wine.sh:58``find target/$TRIPLE -name '*.exe'`
75+
> 找产物 —— 它找的是**真实产物**,不是 ninja 声明的那个,所以完全感知不到这层不一致。
76+
> 测试断言的是「产物存在且是 PE32+」,这两条都成立。**一个测试可以全绿而问题就在它脚下**,
77+
> [[explicit-ninja-goals-two-regressions]] 同型。
78+
79+
---
80+
81+
## 3. 根因
82+
83+
`src/build/plan.cppm:200-213`:
84+
85+
```cpp
86+
std::filesystem::path target_output(const mcpp::manifest::Target& t) {
87+
if (t.kind == Library)
88+
return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name,
89+
platform::static_lib_ext);
90+
if (t.kind == SharedLibrary)
91+
return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name,
92+
platform::shared_lib_ext);
93+
return "bin" / std::format("{}{}", t.name, platform::exe_suffix);
94+
}
95+
```
96+
97+
四个 `platform::` 常量(`platform/common.cppm:18-33`)按 `#if defined(_WIN32)/__APPLE__` 编译期
98+
分支,描述的是**这台机器**的约定。函数签名 `(const Target&)` 里**没有任何 target 信息**,
99+
所以它连"想按 target 求值"都做不到 —— 这才是问题的结构性来源。
100+
101+
同一文件里 `shared_library_link_flags()`(`:236-243`)有同样的毛病,而且更隐蔽:
102+
103+
```cpp
104+
if constexpr (mcpp::platform::is_windows) { // ← host
105+
flags.push_back(target_output(t).generic_string());
106+
} else {
107+
flags.push_back("-L" + …);
108+
if constexpr (mcpp::platform::supports_rpath) { // ← host
109+
if constexpr (mcpp::platform::is_macos) { … } // ← host
110+
```
111+
112+
「链接一个共享库要用完整路径还是 `-L`/`-l`/`-rpath`」是 **target** 的属性(PE 无 rpath,
113+
Mach-O 用 `@loader_path`,ELF 用 `$ORIGIN`)。今天交叉构建共享库会发出宿主形态的链接参数。
114+
115+
### 3.1 已核对:其余 `platform::` 用法都是对的
116+
117+
`exe_suffix` 全仓 16 处引用,除 `plan.cppm` 外全部在找**主机上的可执行文件**
118+
(`ninja`/`xlings`/`nasm`/`llvm-ar`/`clang-scan-deps`/`clang++`)——
119+
`config.cppm``xlings.cppm``clang.cppm``llvm.cppm``ninja_backend.cppm:1394`
120+
`fallback/xlings_binary.cppm`**这些是正确的 host 语义,不要动。**
121+
122+
`build_program.cppm:506``build.mcpp.exe` 也是 host 语义(L3 构建程序跑在主机上),
123+
同样不动 —— 参见 [[build-mcpp-helper-self-containment]]
124+
125+
**本方案的改动面只有 `src/build/plan.cppm` 一个文件。**
126+
127+
---
128+
129+
## 4. 正确的命名是 (os, env) 二元函数,不是 os 一元
130+
131+
这是本方案唯一需要动脑的地方 —— 直觉上「Windows 就是 `.lib`」是错的:
132+
133+
| target | exe | 静态库 | 共享库 | 导入库 |
134+
|---|---|---|---|---|
135+
| `*-linux-*` | *(无)* | `libfoo.a` | `libfoo.so` ||
136+
| `*-macos` | *(无)* | `libfoo.a` | `libfoo.dylib` ||
137+
| `x86_64-windows-**gnu**` | `.exe` | **`libfoo.a`** | `foo.dll` | `libfoo.dll.a` |
138+
| `x86_64-windows-**msvc**` | `.exe` | **`foo.lib`** | `foo.dll` | `foo.lib` |
139+
140+
`windows-gnu` 用的是 **GNU 约定**(`lib` 前缀 + `.a`),而现行的 `_WIN32` 分支写死
141+
`lib_prefix=""` / `static_lib_ext=".lib"` —— 也就是说**在 Windows 主机上用 mingw 构建静态库,
142+
今天的命名就已经是错的**(会叫 `foo.lib` 而 mingw 的 `ar` 产出的是 GNU archive)。
143+
这一条与交叉无关,是存量缺陷,顺带一并修掉。
144+
145+
---
146+
147+
## 5. 方案
148+
149+
### 5.1 引入 `ArtifactNaming`(从 triple 导出,一次求值)
150+
151+
放在 `toolchain/triple.cppm` 旁边(它是 triple 语义的家),或 `build/plan.cppm` 内部:
152+
153+
```cpp
154+
// 产物命名约定 —— 由 TARGET 的 (os, env) 决定,与构建主机无关。
155+
struct ArtifactNaming {
156+
std::string_view exeSuffix; // "" | ".exe"
157+
std::string_view libPrefix; // "lib" | ""
158+
std::string_view staticLibExt; // ".a" | ".lib"
159+
std::string_view sharedLibExt; // ".so" | ".dylib" | ".dll"
160+
bool sharedNeedsImportLib; // PE: 链接消费者要 import lib
161+
};
162+
163+
ArtifactNaming artifact_naming(const triple::Triple& t);
164+
```
165+
166+
空 triple(host target)回退到今天的 `platform::` 常量 —— 与 B2 的
167+
`target_supports_full_static(triple, hostCapability)` 完全同型,**保证 host 构建逐位不变**。
168+
169+
### 5.2 `target_output` 接受它
170+
171+
```cpp
172+
std::filesystem::path target_output(const Target& t, const ArtifactNaming& n);
173+
```
174+
175+
9 处调用点全在 `plan.cppm` 内,且都在 `make_plan()` 的作用域里 ——
176+
`make_plan` 已经有 `const Toolchain& tc`(`plan.cppm:129`),`tc.targetTriple` 直接可达,
177+
在函数入口构造一次 `ArtifactNaming` 传下去即可。**不需要改任何跨模块签名。**
178+
179+
### 5.3 `shared_library_link_flags` 同步改为按 target
180+
181+
`is_windows``n.sharedNeedsImportLib`(或直接判 `triple.is_pe()`);
182+
`supports_rpath` / `is_macos` → 按 target 的 os 求值。
183+
184+
### 5.4 `runtime_aliases_for_target` 要一起看
185+
186+
`plan.cppm:215-228` 依赖 `target_output()` 的结果去比对 `t.soname`
187+
188+
```cpp
189+
auto output = target_output(t);
190+
if (t.soname != output.filename().string())
191+
aliases.push_back(output.parent_path() / t.soname);
192+
```
193+
194+
签名要一起加参数。**这条边曾经因为类似改动漏生成过 soname 别名**
195+
(见 [[soname-alias-explicit-ninja-goals]]:别名是独立 ninja 边,只挂 `default`,
196+
改成显式目标后被跳过)。**改完必须有一条断言 soname 别名仍然生成的测试**,不能只靠 review。
197+
198+
---
199+
200+
## 6. 影响面(已实测清点)
201+
202+
### 6.1 会改变行为的只有交叉构建
203+
204+
host 构建(host == target)命名逐位不变,因为空 triple 回退到今天的常量。
205+
**22 个 e2e 里出现 `.exe` 的**绝大多数是 Windows 上的 host 构建,不受影响。
206+
207+
真正受影响的是三处交叉断言:
208+
209+
| 文件 | 现状 | 改后 |
210+
|---|---|---|
211+
| `tests/e2e/102_mingw_cross_wine.sh:58` | `find target/$TRIPLE -name '*.exe'` | ✅ 仍然匹配(产物名不变,只是 ninja 声明对上了) |
212+
| `tests/e2e/112_build_mcpp_cross.sh:53` | `find target -name 'crossbp.exe'` | ✅ 仍然匹配 |
213+
| `.github/workflows/cross-build-test.yml` windows→linux job | 已写成 `\( -name "mcpp" -o -name "mcpp.exe" \)` | ✅ 两种拼写都收,改前改后都对 |
214+
215+
**结论与 §6.5 当初的担心相反:e2e 与 CI 基本不用改。** 因为 Linux→Windows 的产物名本来就由
216+
GCC 决定为 `.exe`,修复只是让 **ninja 的声明** 追上事实;Windows→Linux 的产物名会从
217+
`mcpp.exe` 变成 `mcpp`,而唯一消费它的 CI job 已经两种都匹配。
218+
219+
### 6.2 需要确认的一处
220+
221+
`release.yml` / `bootstrap-macos.yml` 里的打包路径(`find target -path "*/bin/mcpp"` 等)全部是
222+
**host 构建**的产物,不经过交叉路径,不受影响。已逐条核对。
223+
224+
---
225+
226+
## 7. 验证判据(缺一不可)
227+
228+
1. **回归断言先行**:新增测试,断言 `artifact_naming()` 对
229+
`x86_64-linux-musl` / `x86_64-windows-gnu` / `x86_64-windows-msvc` / `aarch64-macos` / 空 triple
230+
的五元组取值。**先跑一次确认它在修复前是红的** —— 与 B2 的 commit 1 同样的纪律,
231+
[[windows-host-linux-cross-canadian]]
232+
2. **`windows-gnu` 用 GNU 约定**:静态库断言为 `libfoo.a`,**不是** `foo.lib`(§4 的存量缺陷)。
233+
3. **不再重链**(本方案的核心收益,也是唯一能证伪「修好了」的判据):
234+
```bash
235+
mcpp build --target x86_64-windows-gnu
236+
T1=$(stat -c %Y <artifact>)
237+
mcpp build --target x86_64-windows-gnu
238+
T2=$(stat -c %Y <artifact>)
239+
[ "$T1" = "$T2" ] # 修复前必失败
240+
```
241+
这条要进 e2e,否则它会悄悄退化回去。
242+
4. **host 构建逐位不变**:Linux/macOS/Windows 三个 host 的 `mcpp build` 产物名与改前一致。
243+
5. **soname 别名仍然生成**(§5.4)。
244+
6. 现有 22 个含 `.exe` 的 e2e 全绿。
245+
246+
---
247+
248+
## 8. 分期
249+
250+
单 PR,提交分层(与 B2 同款,理由见 [[windows-host-linux-cross-canadian]]):
251+
252+
| commit | 内容 |
253+
|---|---|
254+
| 1 | `artifact_naming()` + 判据 1/2 的断言,**实现为返回 host 常量的桩** ⇒ 测试红 |
255+
| 2 | 实现按 (os, env) 求值 ⇒ 测试绿 |
256+
| 3 | `target_output` / `runtime_aliases_for_target` 接受 `ArtifactNaming` |
257+
| 4 | `shared_library_link_flags` 按 target 求值 |
258+
| 5 | e2e:判据 3 的「不重链」断言 |
259+
260+
---
261+
262+
## 9. 未决
263+
264+
1. **`windows-gnu` 静态库从 `foo.lib` 改名为 `libfoo.a`** 是唯一一处会改变 **host 构建**
265+
产物名的改动(Windows 主机 + mingw 工具链)。它是修正存量缺陷,但确实是行为变更 ——
266+
是否要同期做,还是拆一个独立 PR?我倾向同期,因为把「命名由 (os,env) 决定」做成半截
267+
反而更难解释。
268+
2. **PE 的 import lib(`libfoo.dll.a` / `foo.lib`)目前 mcpp 完全没建模**。共享库交叉到
269+
Windows 时消费者拿什么链接,现在靠 `shared_library_link_flags` 直接塞产物路径蒙混过去。
270+
本方案的 `sharedNeedsImportLib` 只是给它留了个位置,**真正的 import lib 支持不在本期范围**,
271+
建议另开。

.agents/docs/2026-08-03-windows-host-linux-cross-design.md

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -643,21 +643,36 @@ return std::filesystem::path("bin") /
643643
```
644644
645645
`exe_suffix` / `lib_prefix` / `static_lib_ext` / `shared_lib_ext` **四个都是 host 常量**
646-
(`platform/common.cppm:18-29`),却用来命名 **target** 产物。与 B2 完全同构,而且**对称地错**:
646+
(`platform/common.cppm:18-33`),却用来命名 **target** 产物。与 B2 同构。
647647
648-
| 方向 | 现状 | 应当 |
649-
|---|---|---|
650-
| Windows → linux-musl | `mcpp.exe`(却是 ELF) | `mcpp` |
651-
| Linux → windows-gnu | `mcpp`(却是 PE) | `mcpp.exe` |
652-
653-
**为什么本期不修**:改产物命名是行为变更,会同时动到 `tests/e2e/102_mingw_cross_wine.sh`、
654-
release 打包路径和任何用户脚本;而两个方向的现状都**不致命**(扩展名在 Linux 上无意义,
655-
Windows 命令行也能跑无扩展名的 PE)。把一个有回归面的重命名塞进本 PR,违背了 §1.3 定下的
656-
「单 PR 但提交分层、失败可归因」的初衷。
657-
658-
**修的时候要一起改的四个常量**,并且要注意 `runtime_aliases_for_target()`(`plan.cppm:215`)
659-
依赖 `target_output()` 的结果去比对 soname —— 见 [[soname-alias-explicit-ninja-goals]],
660-
那条边曾经因为类似改动漏生成过。
648+
> ### ⚠️ 本节初版的判断有误,已在专项文档中更正
649+
>
650+
> 初版写的是「对称地错:Linux→Windows 产出 `mcpp` 却是 PE」。**后半句是错的** ——
651+
> 实测 Linux 主机交叉到 `x86_64-windows-gnu`,产物就是 `b3probe.exe`(PE32+),
652+
> 因为 **mingw 的 GCC driver 自己会补 `.exe`**,mcpp 从未参与这个决定。
653+
>
654+
> 真正的症状是另一件事,而且更实际:**ninja 声明的输出是 `bin/foo`,GCC 写出 `bin/foo.exe`,
655+
> 声明的那个文件从来不存在 ⇒ 每次 `mcpp build` 都重跑链接边**。实测连续两次构建
656+
> 产物 mtime 会变。也就是说 Linux→Windows 是**功能缺陷(增量构建失效)**,
657+
> Windows→Linux 才只是「ELF 顶着 `.exe`」的观感问题。
658+
659+
**完整分析、修复方案与验证判据见专项文档:**
660+
`2026-08-03-b3-target-aware-artifact-naming.md`
661+
662+
要点摘录:
663+
- 改动面只有 `src/build/plan.cppm` 一个文件;其余 15 处 `exe_suffix` 引用都是找主机上的
664+
`ninja`/`xlings`/`clang++` 等,**是正确的 host 语义,不要动**
665+
- 正确的命名是 **(os, env) 二元函数**:`windows-gnu` 用 GNU 约定(`libfoo.a`),
666+
`windows-msvc` 才是 `foo.lib` —— 现行 `_WIN32` 分支写死后者,**Windows 主机上用 mingw
667+
构建静态库今天就已经命名错了**,与交叉无关的存量缺陷
668+
- e2e/CI 基本不用改(与本节初版的担心相反):`102_mingw_cross_wine.sh` 找的是真实产物
669+
而非 ninja 声明,改前改后都匹配
670+
- `runtime_aliases_for_target()`(`plan.cppm:215`)依赖 `target_output()` 比对 soname,
671+
必须一起改并补断言 —— 见 [[soname-alias-explicit-ninja-goals]]
672+
673+
**为什么当期没修**:发现时 #339 已进入 CI 验证阶段,把一个尚未查清真实形态的改动塞进去,
674+
会毁掉 §1.3 定下的「提交分层、失败可归因」。事后看这个决定是对的 —— 初版对症状的描述
675+
本身就是错的,当场改只会改错方向。
661676
662677
---
663678

0 commit comments

Comments
 (0)