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"