|
| 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