Skip to content

Commit b047012

Browse files
authored
fix: clean build error output (#79)
* fix: clean build error output * fix: avoid dyld env injection on macos * fix: keep clang link flags out of compile commands
1 parent 3ac172a commit b047012

7 files changed

Lines changed: 862 additions & 73 deletions

File tree

Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
# mcpp build 报错输出优化方案
2+
3+
**Date**: 2026-05-26
4+
**Status**: Proposed
5+
6+
## 1. 目标
7+
8+
当用户代码有编译错误时,默认 `mcpp build` 输出应聚焦在用户真正需要修复的
9+
编译器诊断上:
10+
11+
- 不显示 ninja 进度信息,如 `[1/9] OBJ obj/main.o`
12+
- 不把 `env LD_LIBRARY_PATH=...` 这种运行时环境前缀暴露在失败命令行里
13+
- 不重复打印同一段 ninja / compiler 输出
14+
- `--verbose` 仍保留足够的构建细节,便于定位 mcpp 自身问题
15+
16+
## 2. 真实复现
17+
18+
复现目录:
19+
20+
```bash
21+
cd /home/speak/test/mcpp/helloworld
22+
mcpp build
23+
```
24+
25+
样例里 `src/main.cpp` 当前有明确语法错误:
26+
27+
```cpp
28+
int main(int argc, char* argv[]) {
29+
hw::
30+
return 0;
31+
}
32+
```
33+
34+
当前普通 `mcpp build` 的关键输出:
35+
36+
```text
37+
ninja: Entering directory `/home/speak/test/mcpp/helloworld/target/...'
38+
[1/2] OBJ obj/main.o
39+
FAILED: obj/main.o
40+
env LD_LIBRARY_PATH='/home/speak/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7/lib:...'${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} /home/speak/.mcpp/.../clang++ ...
41+
/home/speak/test/mcpp/helloworld/src/main.cpp:8:5: error: expected unqualified-id
42+
8 | return 0;
43+
| ^
44+
1 error generated.
45+
ninja: build stopped: subcommand failed.
46+
error: auto-installing default toolchain gcc@15.1.0-musl failed: ...
47+
```
48+
49+
为单独观察完整 backend 失败路径,使用已有 `/home/speak/.mcpp` 配置运行:
50+
51+
```bash
52+
MCPP_HOME=/home/speak/.mcpp mcpp build --no-cache
53+
```
54+
55+
可以稳定看到同一段 ninja 输出被打印两次:第一次由 backend 直接 `fputs`
56+
第二次被塞入 `BuildError.message` 后由 CLI 的 `ui::error()` 再打印。
57+
58+
## 3. 当前代码路径
59+
60+
### 3.1 ninja 进度和失败包装
61+
62+
`src/build/ninja_backend.cppm`
63+
64+
- `NinjaBackend::build()` 生成 `build.ninja`,然后执行
65+
`ninja -C <outputDir> 2>&1`
66+
- 非 verbose 模式也会在失败时输出完整 ninja 捕获文本:
67+
68+
```cpp
69+
if (opts.verbose || !ok) {
70+
std::fputs(out.c_str(), stdout);
71+
}
72+
```
73+
74+
- 失败时又把同一份 `out` 放入 `BuildError.message`
75+
76+
```cpp
77+
return std::unexpected(BuildError{
78+
std::format("ninja failed (exit non-zero):\n{}", out),
79+
plan.outputDir / "build.ninja"});
80+
```
81+
82+
`src/cli.cppm`
83+
84+
- `run_build_plan()` 收到失败后再次打印:
85+
86+
```cpp
87+
mcpp::ui::error(r.error().message);
88+
```
89+
90+
这就是重复输出的直接原因。
91+
92+
### 3.2 fast-path 失败会先打印再回退
93+
94+
`src/cli.cppm::try_fast_build()` 在 build cache 命中时直接运行旧
95+
`build.ninja`。如果 ninja 非零退出,它会先打印输出,再返回 `nullopt`
96+
97+
```cpp
98+
if (!ok) {
99+
if (!verbose) std::fputs(out.c_str(), stdout);
100+
return std::nullopt;
101+
}
102+
```
103+
104+
随后 `cmd_build()` 进入完整 `prepare_build()`。在当前复现环境中,PATH 下的
105+
mcpp 使用的 `MCPP_HOME` 没有默认 toolchain,所以用户代码编译错误后又追加了
106+
一次默认 toolchain 自动安装失败提示,导致错误主题混杂。
107+
108+
### 3.3 `LD_LIBRARY_PATH` 进入失败命令行
109+
110+
`src/toolchain/probe.cppm`
111+
112+
- `detect()` 填充 `Toolchain::compilerRuntimeDirs`
113+
- `compiler_env_prefix()` 把 runtime dirs 转成 shell 前缀
114+
115+
```cpp
116+
return mcpp::platform::linux_::build_ld_library_path_prefix(dirs);
117+
```
118+
119+
`src/platform/linux.cppm`
120+
121+
```cpp
122+
return std::format("env LD_LIBRARY_PATH={}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}} ",
123+
mcpp::platform::shell::quote(joined));
124+
```
125+
126+
`src/build/flags.cppm`
127+
128+
```cpp
129+
f.toolEnv = mcpp::toolchain::compiler_env_prefix(plan.toolchain);
130+
```
131+
132+
`src/build/ninja_backend.cppm`
133+
134+
```ninja
135+
toolenv = env LD_LIBRARY_PATH='...'${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
136+
rule cxx_object
137+
command = $toolenv $cxx $cxxflags -c $in -o $out
138+
```
139+
140+
ninja 在命令失败时会打印失败 command,因此这个 `toolenv` 会直接暴露给用户。
141+
142+
## 4. 优化方案
143+
144+
### 4.1 默认使用安静的 ninja 输出
145+
146+
默认构建命令改为安静模式:
147+
148+
```bash
149+
ninja --quiet -C <outputDir>
150+
```
151+
152+
作用:
153+
154+
- 隐藏 `ninja: Entering directory ...`
155+
- 隐藏 `[x/y] DESCRIPTION ...`
156+
- 保留失败时的必要 stderr/stdout
157+
158+
已在复现目录手动验证:`ninja --quiet -C ...` 不再显示 `[x/y]` 进度,但仍会
159+
显示失败 command 和 compiler diagnostic。因此它只能解决第一类噪声,不能单独
160+
解决 `LD_LIBRARY_PATH` 和重复输出。
161+
162+
`--verbose` 模式保持当前行为:不加 `--quiet`,继续追加 `-v`
163+
164+
### 4.2 用 ScopedEnv / ScopedRun 代替 `toolenv` 命令前缀
165+
166+
把运行时库路径从 `build.ninja` 规则里移出,改为启动 ninja 子进程时注入环境。
167+
168+
建议新增跨平台 API:
169+
170+
```cpp
171+
namespace mcpp::platform::env {
172+
173+
struct ScopedEnv {
174+
ScopedEnv(std::string key, std::optional<std::string> value);
175+
~ScopedEnv();
176+
};
177+
178+
std::string path_list_separator();
179+
std::string runtime_library_path_key();
180+
std::string prepend_path_list(std::string_view key,
181+
std::span<const std::filesystem::path> dirs);
182+
183+
} // namespace mcpp::platform::env
184+
```
185+
186+
平台策略:
187+
188+
| 平台 | 变量 | 说明 |
189+
|------|------|------|
190+
| Linux | `LD_LIBRARY_PATH` | 当前实际需要,替代 `toolenv` |
191+
| macOS | 空 | 不设置 `DYLD_LIBRARY_PATH`;它会影响 ninja 自身和系统 framework 的 dyld 解析,依赖 toolchain/rpath |
192+
| Windows | `PATH` | 如果私有工具链 DLL 需要搜索路径,按 `;` prepend;当前可先空实现 |
193+
194+
`NinjaBackend::build()` 中:
195+
196+
1. 从 `plan.toolchain.compilerRuntimeDirs` 计算 scoped env
197+
2. 在 `process::capture(ninjaCmd)` 前创建 `ScopedEnv`
198+
3. `build.ninja` 不再写 `toolenv` 变量
199+
4. 所有规则从:
200+
201+
```ninja
202+
command = $toolenv $cxx $cxxflags -c $in -o $out
203+
```
204+
205+
改为:
206+
207+
```ninja
208+
command = $cxx $cxxflags -c $in -o $out
209+
```
210+
211+
这样失败时 ninja 最多打印 compiler 命令,不会再出现
212+
`env LD_LIBRARY_PATH=...`。同时 `clang-scan-deps``clang++``llvm-ar`
213+
ninja 子命令都会继承同一份环境,不需要每条 rule 单独拼 shell 前缀。
214+
215+
后续如果要进一步消除全局环境 mutation,可把 `ScopedEnv` 升级为
216+
`process::run({ .argv, .cwd, .env })`,POSIX 走 `fork/execve`,Windows 走
217+
`CreateProcessW` environment block。但第一阶段用 RAII env 已能满足当前目标,
218+
改动面更小。
219+
220+
### 4.3 明确输出所有权,避免重复打印
221+
222+
需要规定:ninja 输出只能由一个层级负责展示。
223+
224+
推荐方案:
225+
226+
```cpp
227+
struct BuildError {
228+
std::string summary; // "build failed"
229+
std::string diagnosticOutput; // 过滤后的 compiler/ninja 输出
230+
std::optional<std::filesystem::path> where;
231+
};
232+
```
233+
234+
调用关系:
235+
236+
1. `NinjaBackend::build()` 只捕获并返回,不直接 `fputs`
237+
2. `run_build_plan()` 负责打印一次
238+
3. 默认模式打印:
239+
240+
```text
241+
error: build failed
242+
<filtered compiler diagnostics>
243+
```
244+
245+
4. verbose 模式打印完整 ninja 输出,但仍只打印一次
246+
247+
如果暂时不想扩展 `BuildError`,最小改法是:
248+
249+
- `NinjaBackend::build()` 失败时不再 `fputs(out)`
250+
- `BuildError.message` 保留一份输出
251+
252+
但这个最小改法仍会把整段输出挂在 `error:` 后面,可读性不如结构化字段。
253+
254+
### 4.4 过滤默认模式下的 ninja 包装行
255+
256+
`--quiet` 解决进度行,但失败输出仍会包含:
257+
258+
```text
259+
FAILED: obj/main.o
260+
<compiler command>
261+
ninja: build stopped: subcommand failed.
262+
```
263+
264+
建议增加 `filter_ninja_output(out, flags, mode)`
265+
266+
默认模式移除:
267+
268+
- `ninja: Entering directory ...`
269+
- `ninja: build stopped: subcommand failed.`
270+
- `FAILED: ...`
271+
- 已知工具命令行:以当前 `cxxBinary``ccBinary``arBinary``scanDepsPath`
272+
开头的行
273+
- 旧 build.ninja 中残留的 `env LD_LIBRARY_PATH=... <tool>` 命令行
274+
- ninja 进度行:`^\[[0-9]+/[0-9]+\] `
275+
276+
默认模式保留:
277+
278+
- compiler warning/error diagnostic
279+
- 源码路径、行列号和 caret
280+
- linker diagnostic
281+
- mcpp 自己的 dyndep / manifest diagnostic
282+
283+
`--verbose` 模式不过滤。
284+
285+
目标默认输出示例:
286+
287+
```text
288+
Compiling helloworld v0.1.0 (.)
289+
error: build failed
290+
/home/speak/test/mcpp/helloworld/src/main.cpp:8:5: error: expected unqualified-id
291+
8 | return 0;
292+
| ^
293+
1 error generated.
294+
```
295+
296+
### 4.5 修正 fast-path 失败语义
297+
298+
`try_fast_build()` 当前在失败时“先打印失败,再回退完整构建”。这会导致:
299+
300+
- 同一编译错误可能先由 fast-path 打印一次,再由完整构建打印一次
301+
-`build.ninja` 的 toolchain 和当前 config 不一致时,用户会看到两个不同主题的错误
302+
303+
建议把返回值从 `std::optional<int>` 改成 tri-state:
304+
305+
```cpp
306+
enum class FastBuildKind { NotApplicable, Success, BuildFailed, StaleOrInvalid };
307+
308+
struct FastBuildResult {
309+
FastBuildKind kind;
310+
int exitCode = 0;
311+
std::string output;
312+
};
313+
```
314+
315+
策略:
316+
317+
- cache 不存在、fingerprint 不匹配、`build.ninja` 缺失:`NotApplicable`
318+
- ninja 成功:`Success`
319+
- ninja 报 `loading build.ninja` / `unknown target` / manifest 结构明显不匹配:
320+
`StaleOrInvalid`,静默回到完整 prepare
321+
- 普通 compile/link 失败:`BuildFailed`,直接按同一套过滤和单次打印逻辑返回,
322+
不再触发自动安装默认 toolchain
323+
324+
这样用户代码错误不会被后续 toolchain resolve/install 错误污染。
325+
326+
## 5. 实施顺序
327+
328+
1. **先去重输出**
329+
- 调整 `NinjaBackend::build()` 和 `run_build_plan()` 的打印职责
330+
- 增加 syntax-error e2e,断言同一 compiler error 只出现一次
331+
332+
2. **移动 toolenv 到 scoped env**
333+
- 增加 `platform::env::ScopedEnv`
334+
- `emit_ninja_string()` 删除 `toolenv` 变量和 `$toolenv` 前缀
335+
- `NinjaBackend::build()` 和 `try_fast_build()` 启动 ninja 前设置运行时库路径
336+
- 断言生成的 `build.ninja` 不包含 `LD_LIBRARY_PATH`
337+
338+
3. **安静化 ninja 默认输出**
339+
- 非 verbose 加 `--quiet`
340+
- 增加 `filter_ninja_output()`
341+
- 断言默认失败输出不包含 `[1/`、`FAILED:`、`ninja: build stopped`
342+
343+
4. **修正 fast-path 失败**
344+
- 将 `try_fast_build()` 改为 tri-state
345+
- 编译失败直接返回失败,不进入完整 prepare
346+
- stale/invalid ninja 才允许静默回退
347+
348+
## 6. 验证要求
349+
350+
新增或调整 E2E:
351+
352+
```bash
353+
bash tests/e2e/05_errors.sh
354+
```
355+
356+
新增专门用例,例如 `tests/e2e/48_build_error_output.sh`
357+
358+
- 创建项目并写入语法错误
359+
- 运行 `mcpp build`
360+
- 断言输出包含源码诊断:`src/main.cpp:*: error:`
361+
- 断言输出不包含:
362+
- `LD_LIBRARY_PATH=`
363+
- `[1/`
364+
- `FAILED:`
365+
- `ninja: Entering directory`
366+
- `ninja: build stopped`
367+
- 断言 `expected unqualified-id` 只出现一次
368+
- 运行 `mcpp build --verbose`,断言 verbose 仍保留 ninja/command 细节
369+
- 检查生成的 `build.ninja` 不包含 `toolenv``LD_LIBRARY_PATH`
370+
371+
相关单元测试:
372+
373+
- `filter_ninja_output()`:覆盖 progress、FAILED、command、compiler diagnostic 混合文本
374+
- `ScopedEnv`:覆盖变量不存在、变量已存在、prepend 后析构恢复
375+
376+
本地验证命令:
377+
378+
```bash
379+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 bash tests/e2e/05_errors.sh
380+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 bash tests/e2e/48_build_error_output.sh
381+
mcpp build
382+
```
383+
384+
## 7. 风险和边界
385+
386+
- `ScopedEnv` 会短暂修改当前进程环境。mcpp CLI 当前是单构建流程,风险可控;
387+
如果未来 backend 并发运行多个 ninja,应升级为 `ProcessOptions.env`
388+
- 过滤 command line 不能误删 compiler diagnostic。规则应只删除“已知工具路径开头”
389+
的整行,不做宽泛字符串匹配。
390+
- `--verbose` 必须不丢信息,否则会降低 mcpp 自身问题的可诊断性。
391+
- fast-path 回退判断要保守:无法确认是 stale/invalid 时,按普通构建失败处理并
392+
打印一次,避免再次污染错误输出。

0 commit comments

Comments
 (0)