From c5acc7afc51179cf6356cf64bac91b015a7c6ee6 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:40:09 +0800 Subject: [PATCH] fix(run): `mcpp build` then `mcpp run` exec'd a bare-metal ELF on the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped in 2026.8.19.2 and found by running the released binary through the flow a new user takes: $ mcpp new blinky --template riscv-virt-rt && cd blinky $ mcpp run # ok — boots in qemu $ mcpp build && mcpp run # Running `target/riscv64-none-elf/…/bin/blinky` # no emulator, no output, exit=1 `try_fast_run` exec's the cached artifact directly, and matched cache entries whose targetTriple is "" on the reasoning — written in its own header — that "`mcpp run` never takes a --target flag". The caller does guard the flag, but a project can name its target in the MANIFEST instead, and that spelling never reaches the cache key: a cross build's entry is written as "" and read back as if it were a host build. The fix states the function's actual precondition: it exec's the artifact, so it is valid only when the artifact is for this machine. A declared default target turns the fast path off and the full prepare — which is what resolves the runner — takes over. ⚠️ The first version of the regression test was a false green: added to an existing project in e2e/131 it passed with the fix disabled. Probing all 16 bail-outs showed the fast path was never reached there, because mcpp.toml was newer than build.ninja — a rebuild does not rewrite build.ninja when its content is unchanged, so in a directory whose manifest was edited in place the fast path is already off. The test now builds a fresh project, and was checked red-then-green against the fix. --- ...2026-08-19-baremetal-phase3-usable-plan.md | 33 +++++++++++++ mcpp.toml | 2 +- src/build/execute.cppm | 28 ++++++++++- src/version.cppm | 2 +- ...31_freestanding_bsp_supplies_everything.sh | 47 +++++++++++++++++++ 5 files changed, 109 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md index 593145bf..1e0bc25b 100644 --- a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -437,3 +437,36 @@ mcpplibs.std.freestanding = "0.1" ⚠️ 一般化:**诊断里的每一条建议都是一个承诺**,而承诺是要被兑现的。写「加这一行」 之前必须先确认那一行今天能跑通 —— 这条和 [[issue427-absence-treated-as-contradiction]] 里那条错误建议是同一形状。 + +### 11.4 ⚠️ 2026.8.19.2 发出去就带着一个 bug:`mcpp build` 之后 `mcpp run` 直接执行裸机 ELF + +**发布后**用发布的二进制做真实验证时才发现 —— 这正是「本地真实验证」这一步存在的理由。 + +``` +$ mcpp new blinky --template riscv-virt-rt && cd blinky +$ mcpp run # ✅ 走 qemu,打印正常 +$ mcpp build && mcpp run # ❌ Running `target/riscv64-none-elf/…/bin/blinky` + # 没有模拟器、没有输出、exit=1 +``` + +**根因**:`try_fast_run` 直接 exec 缓存里的产物,它的注释写着 +「`mcpp run` never takes a --target flag」所以只匹配 `targetTriple == ""` 的条目。 +调用点确实挡住了 `--target` 旗标 —— 但**工程可以把目标写在 manifest 里** +(`[build] target`),而这个拼写**从来没进过缓存键**:交叉构建写下的条目 key 是 `""`, +读回来就被当成宿主构建。 + +⇒ 修法是把这个函数的**前置条件**写成代码:它 exec 产物本身,所以只在产物属于**本机**时 +才成立。声明了默认目标就一律退回完整 prepare(runner 正是在那里解析的)。 + +#### ⚠️ 第一版回归测试是假绿 —— 而且我差点就信了 + +把 build-then-run 加进 e2e 131 的既有工程后,**关掉修复它照样通过**。 +探针(给 `try_fast_run` 的 16 个 `return nullopt` 各打一个编号)指出是 **BAIL 11**: +`mcpp.toml` 比 `build.ninja` 新,快路径**本来就没被走到**。 + +原因很反直觉:**重建并不会重写内容未变的 `build.ninja`**,所以在一个「原地编辑过 +manifest」的目录里,manifest 永远是最新的那个文件,快路径**永久关闭**。 +⇒ 测试必须**新建一个干净工程**。改完后先确认它在关掉修复时**变红**,再确认修复后变绿。 + +**教训**:`mcpp run` 单独跑是对的,`mcpp build && mcpp run` 才错 —— **顺序本身就是被测 +对象**。而 130/131/132 三个测试都恰好先 `run`,所以谁也看不见。 diff --git a/mcpp.toml b/mcpp.toml index 3cde1f91..7dc6d173 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.19.2" +version = "2026.8.19.3" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 39b1dcfe..a9a625eb 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -800,6 +800,9 @@ struct FastPathIdentity { // extensions are module interfaces in THIS project, and this is already // the only manifest read on the fast path. mcpp::ExtensionTable extTable; + // `[build] target` — the project's DEFAULT cross target, and the reason + // try_fast_run cannot assume the artifact runs here. See its use. + std::string defaultTarget; }; std::optional @@ -813,6 +816,7 @@ fast_path_identity(const std::filesystem::path& projectRoot, mcpp::build::resolve_cache_mode(*m, ""))), m->resources.files, mcpp::extension_table_for(m->buildConfig.moduleExtensions), + m->buildConfig.target, }; } @@ -916,7 +920,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // mcpp#225 (E2): `mcpp run`'s fast path. Mirrors try_fast_build's // fingerprint/freshness gate against the SAME cache entry `mcpp build` -// wrote (targetTriple == "" — `mcpp run` never takes a --target flag), then +// wrote (targetTriple == "" — a HOST build; see the precondition below), then // on a hit runs ninja and execs the cached run-target directly — skipping // prepare_build (toolchain resolution + full modgraph scan) entirely. // Returns nullopt when there's no usable cache entry (build_run_target @@ -928,6 +932,28 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, auto want = fast_path_identity(projectRoot); if (!want) return std::nullopt; + // ⚠️ THE precondition of this whole function: it exec's the cached + // artifact itself, so it is only ever valid when that artifact is for THIS + // machine. + // + // The header above used to justify matching `targetTriple == ""` with + // "`mcpp run` never takes a --target flag". The caller does guard the + // flag — but a project can name its target in the MANIFEST instead, and + // that spelling never reaches the cache key, so a cross build's entry is + // written as "" and read back as if it were a host build. + // + // Measured on the shipped 2026.8.19.2, on the first two commands a + // bare-metal user runs after `mcpp new`: + // + // $ mcpp build && mcpp run + // Running `target/riscv64-none-elf/…/bin/blinky` ← no emulator + // exit=1 + // + // `mcpp run` alone was correct; only build-then-run reached the cache. So + // the fast path is off whenever a default target is declared, and the full + // prepare — which is what resolves the runner — takes over. + if (!want->defaultTarget.empty()) return std::nullopt; + auto entries = read_build_cache(projectRoot); const BuildCacheEntry* match = nullptr; for (auto& e : entries) { diff --git a/src/version.cppm b/src/version.cppm index 798bdb34..b939a1ce 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.19.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.19.3"; } // namespace mcpp diff --git a/tests/e2e/131_freestanding_bsp_supplies_everything.sh b/tests/e2e/131_freestanding_bsp_supplies_everything.sh index b1c88510..6b730a33 100755 --- a/tests/e2e/131_freestanding_bsp_supplies_everything.sh +++ b/tests/e2e/131_freestanding_bsp_supplies_everything.sh @@ -182,4 +182,51 @@ if "$MCPP" build --target riscv64-none-elf > leak.log 2>&1; then exit 1 fi +# ── ⚠️ build, THEN run: the sequence that shipped broken in 2026.8.19.2 ───── +# +# `mcpp run` on its own was correct; the SECOND invocation was not. With the +# target named in the MANIFEST rather than on the command line, `mcpp build` +# wrote a fast-path cache entry keyed "" (the CLI override, which was empty), +# and `mcpp run` matched that as a HOST build and exec'd the RISC-V ELF: +# +# Running `target/riscv64-none-elf/.../bin/firmware` ← no emulator +# exit=1 +# +# ⚠️ A FRESH project, not the one above, and that is load-bearing. The run +# fast path also requires mcpp.toml to be older than build.ninja, and a +# rebuild does not rewrite build.ninja when its content is unchanged — so in a +# directory whose manifest has been edited in place the fast path is already +# off and this check silently tests nothing. Verified by disabling the fix and +# watching this go red. +cd "$TMP" +mkdir -p order/src +cd order +cat > mcpp.toml <<'EOF' +[package] +name = "order" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" + +[dependencies] +board = { path = "../board" } +EOF +cat > src/main.cpp <<'EOF' +import board; +extern "C" int main() { board::print("ORDER-OK\n"); return 0; } +EOF + +"$MCPP" build > buildfirst.log 2>&1 || { + cat buildfirst.log; echo "build with a manifest target failed"; exit 1; } +"$MCPP" run > runafter.log 2>&1 || true +grep -q 'ORDER-OK' runafter.log || { + cat runafter.log + echo "build-then-run lost the runner (the artifact was exec'd directly)" + exit 1; } +# Two-sided: assert the emulator is really in the command line, so a future +# change that happens to make the ELF runnable here cannot pass this silently. +grep -q 'qemu-system-riscv64' runafter.log || { + cat runafter.log; echo "the run did not go through the emulator"; exit 1; } + echo "PASS: BSP supplies the sysroot, linker script and runtime; consumer only depends on it"