From 86026c81a2f05084e25c2522b3fc63dc04f21586 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:00:33 +0800 Subject: [PATCH 01/16] =?UTF-8?q?2026.8.24.7=20=E2=80=94=20the=20C=20libra?= =?UTF-8?q?ry=20layer=20decides=20the=20link=20line,=20not=20the=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A predicate that ORs two layers was deciding something that depends on one of them: bool system_from_graph() const { return kernelAbi.fromGraph() || cAbi.fromGraph(); } Everything the link-side replacement drops — the payload's binutils prefix, its library directories, its rpaths — is a way of reaching the payload's C LIBRARY. The two layers move together in the arrangement the predicate was written for, where an openkal target takes its kernel interface and its C library from the same graph, and come apart in one that is just as ordinary: [dependencies] openkal-linux = "0.5.4" A backend that implements openkal ON TOP OF Linux, linked by a program that still uses the payload's glibc. The driver went on asking for startup files and the linker had nowhere to look: error: hermetic link check failed crt1.o (bare name — the linker cannot resolve it) crti.o (bare name — the linker cannot resolve it) crtn.o (bare name — the linker cannot resolve it) ⚠️ THIS SHIPPED IN 2026.8.24.1 AND REACHED EVERY BACKEND IN THE ECOSYSTEM. That shape is how a backend is tested — openkal-linux, openkal-macos and openkal-windows all build their conformance suite against the platform's own C library. Their CI was pinned to an older mcpp and kept passing; the defect surfaced only when the pin moved, four releases later. ⚠️ AND NOTHING HERE HAD THE SHAPE. 278 e2e scripts and the combination "kernel-abi from the graph, C library from the payload" appeared in none. `285_kernel_abi_from_graph_keeps_the_payload_c_library.sh` asserts it, and asserts the artefact RUNS — a program with no C library at all would exit 0 from a link that merely succeeded. The mingw branch takes the same predicate because the two are complementary: its comment says the replacement "covers all three at once", which holds only while each is the other's negation. Left disagreeing, a mingw build whose kernel interface came from the graph and whose C library did not would enter neither and emit no link line. `graphTargetSide` is gone rather than left unused — keeping the name in scope leaves the wrong question one keystroke away. Located by bisecting in CI rather than locally: three branches, one line each, run in the environment that was failing. 8.21.3 green 8.24.1 red ← #486 8.24.4 red Local full e2e against the fix: 252 passed, 2 failed, 24 skipped. The two failures are `208_private_libc_stays_in_the_binary` and `62_runtime_library_dirs`, which fail identically before and after this change and pass in CI — a property of this machine, and stated here rather than folded into the count. --- CHANGELOG.md | 53 +++++++++ mcpp.toml | 2 +- src/build/flags.cppm | 22 +++- src/targetside/model.cppm | 37 ++++++ src/version.cppm | 2 +- ..._from_graph_keeps_the_payload_c_library.sh | 109 ++++++++++++++++++ 6 files changed, 220 insertions(+), 5 deletions(-) create mode 100755 tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 997c08a5..ca9646e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,59 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.24.7] — 2026-08-25 + +### 修复 + +- **⭐⭐ 图供给内核接口时,载荷那份 C 库被一起断开了。** + + ``` + error: hermetic link check failed + crt1.o (bare name — the linker cannot resolve it) + crti.o (bare name — the linker cannot resolve it) + crtn.o (bare name — the linker cannot resolve it) + ``` + + 三行清单即可复现: + + ```toml + [dependencies] + openkal-linux = "0.5.4" + ``` + + 判据是一个**跨两层的 `OR`**,被用来决定只取决于其中一层的事: + + ```cpp + bool system_from_graph() const { + return kernelAbi.fromGraph() || cAbi.fromGraph(); + } + ``` + + 链接侧在它为真时**整体替换** `f.ld`,丢掉载荷的 binutils 前缀、库目录与 + rpath —— 而这些全是通往**载荷那份 C 库**的路。两层在它被写出来时针对的场景里 + 同进同退(openkal 目标的内核接口与 C 库都来自图),在另一个同样普通的场景里 + 分开:**一个在平台之上实现 openkal 的后端,而程序仍用载荷的 C 库**。 + 驱动照样索取启动文件,链接器却没有了可查的路径。 + + ⚠️ **这个形状正是每个 openkal 后端被测试的方式** —— openkal-linux、 + openkal-macos、openkal-windows 三家的一致性套件都对着平台自己的 C 库构建。 + 它们的 CI 钉在旧 mcpp 上,所以一直绿;pin 一动就全红,而这是缺陷被发现的 + 唯一原因。 + + ⚠️ **mcpp 278 条 e2e 里,「kernel-abi 来自图 + C 库来自载荷」这个组合一条 + 都没有。** 新增 `285_kernel_abi_from_graph_keeps_the_payload_c_library.sh`, + 断言到**产物能跑**,而不只是链接成功。 + + 判据两向(2026.8.24.6 与本修复): + + ``` + 已发布 24.6 error: hermetic link check failed — crt1.o (bare name) + 本修复 ok it links against the payload's C library, and runs + ``` + + 区间由 CI 侧三分支并行二分给出:`8.21.3 绿 → 8.24.1 红`,唯一实质提交是 + #486。 + ## [2026.8.24.6] — 2026-08-25 ### 新增 diff --git a/mcpp.toml b/mcpp.toml index 8e95ee3d..fd0f71e0 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.24.6" +version = "2026.8.24.7" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 75b1809f..2e7c985a 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -555,7 +555,10 @@ CompileFlags compute_flags(const BuildPlan& plan) { // `mcpp.targetside` answers the question directly, after resolution, for // every layer separately. Reading it here means this site and the gate // cannot disagree, because there is nothing left to disagree about. - const bool graphTargetSide = plan.targetSide.system_from_graph(); + // (The `system_from_graph` reading that stood here is gone: both of its + // former users ask about the C library, and one of them was getting a + // different answer than it needed. Leaving the name in scope would have + // left the wrong question one keystroke away.) // LLVM root of a clang-with-cfg toolchain — used by the macOS link // path below to locate libc++.a/libc++abi.a for staticStdlib. std::filesystem::path llvmRootForStdlib; @@ -1229,7 +1232,13 @@ CompileFlags compute_flags(const BuildPlan& plan) { // dependency graph the compiler is an ordinary retargetable clang, and this // branch is one of three shaped by the HOST rather than by the target — see // the replacement below, which covers all three at once. - if (isMingwTc && !graphTargetSide) { + // + // ⚠️ THE SAME PREDICATE AS THE REPLACEMENT, BECAUSE THEY ARE COMPLEMENTARY. + // "covers all three at once" is only true while the two conditions are each + // other's negation; if this one said `system` and that one said `C library`, + // a mingw build whose kernel interface came from the graph and whose C + // library did not would enter neither, and emit no link line at all. + if (isMingwTc && !plan.targetSide.c_library_off_payload()) { // `-static` / `-static-libstdc++` now come from the contract table via // unit_ldflags (dist::Format::Pe) — the whole-link `-static` is what // "self-contained" means here, since the piecemeal recipe still leaves @@ -1423,7 +1432,14 @@ CompileFlags compute_flags(const BuildPlan& plan) { // `-rpath` beside them (this host's payload directories — measured on a // Mach-O link as `-Wl,-rpath,…/lib/x86_64-unknown-linux-gnu`, which ld64 // accepts and writes into the image), `payload_ld`, `atomic_ld`. - if (!isFreestandingTarget && graphTargetSide) { + // + // ⚠️ AND THE CONDITION IS THE C LIBRARY, NOT THE SYSTEM. Everything the + // replacement drops is a way of reaching the PAYLOAD's C library, so a + // build whose C library still comes from the payload must not enter here + // — however much of the rest of its target side the graph supplies. See + // `TargetSide::c_library_off_payload`, which records the shape this got + // wrong and what it cost. + if (!isFreestandingTarget && plan.targetSide.c_library_off_payload()) { // ⚠️ ASSEMBLED HERE RATHER THAN TAKEN FROM `link_toolchain_flags`, // BECAUSE THAT STRING IS ONLY POPULATED WHEN THE PAYLOAD HAS A CONFIG // FILE (`isClangWithCfg`). The Linux payload ships one and the Windows diff --git a/src/targetside/model.cppm b/src/targetside/model.cppm index 67ced1a6..d62b6d3d 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -194,6 +194,43 @@ struct TargetSide { bool system_from_graph() const { return kernelAbi.fromGraph() || cAbi.fromGraph(); } + + // ⚠️ AND THE C LIBRARY IS A SEPARATE QUESTION, WHICH THE ONE ABOVE WAS + // ANSWERING FOR IT AND GETTING WRONG. + // + // `system_from_graph` is an OR over two layers, and the link line's + // decision about the payload's C-library search paths depends on ONE of + // them. The two coincide in the arrangement they were written for — an + // openkal target takes both its kernel interface and its C library from + // the graph — and come apart in one that is just as ordinary: + // + // [dependencies] + // openkal-linux = { version = "0.5.4", features = ["standalone"] } + // + // A backend that implements openkal ON TOP OF Linux, linked by a program + // that still uses the payload's glibc. `kernelAbi.fromGraph()` is true, + // `cAbi` is the payload's, and the link side replaced `f.ld` — dropping + // the search paths for a C library it was still going to link. The + // driver asked for the startup files anyway and the linker had nowhere + // to look: + // + // error: hermetic link check failed + // crt1.o (bare name — the linker cannot resolve it) + // crti.o (bare name — the linker cannot resolve it) + // crtn.o (bare name — the linker cannot resolve it) + // + // ⚠️ THIS SHIPPED. It reached every conformance suite in the openkal + // ecosystem, because that shape is exactly how a backend is tested: + // openkal-linux, openkal-macos and openkal-windows all build their suite + // against the platform's own C library. Their CI was pinned to an older + // mcpp and so kept passing, which is why nothing said so until the pin + // moved. + // + // Absent counts as not-from-the-payload: a target with no C-ABI layer has + // no startup files to find, and the branch below adds `-nostdlib` for it. + bool c_library_off_payload() const { + return cAbi.fromGraph() || cAbi.absent(); + } }; // ── Capability grammar: mcpp:[=] ─────────────────────────── diff --git a/src/version.cppm b/src/version.cppm index c50c6245..74ef3071 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.24.6"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.24.7"; } // namespace mcpp diff --git a/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh b/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh new file mode 100755 index 00000000..31f1a015 --- /dev/null +++ b/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# A package may supply the kernel interface while the C library stays the +# payload's, and the link line has to keep reaching the payload. +# +# ⚠️ THE PREDICATE THAT DECIDED THIS WAS AN `OR` OVER TWO LAYERS. +# +# bool system_from_graph() const { +# return kernelAbi.fromGraph() || cAbi.fromGraph(); +# } +# +# The link side replaced `f.ld` when that was true — dropping the payload's +# binutils prefix, its library directories and its rpaths, all of which are +# ways of reaching the payload's C LIBRARY. In the arrangement it was written +# for the two layers move together: an openkal target takes its kernel +# interface and its C library from the same graph. They come apart here, and +# the driver went on asking for startup files nobody had given it a path to: +# +# error: hermetic link check failed +# crt1.o (bare name — the linker cannot resolve it) +# crti.o (bare name — the linker cannot resolve it) +# crtn.o (bare name — the linker cannot resolve it) +# +# ⭐ THE SHAPE IS NOT EXOTIC — IT IS HOW EVERY openkal BACKEND IS TESTED. +# openkal-linux, openkal-macos and openkal-windows each build their conformance +# suite against the platform's own C library, because a backend implements +# openkal ON TOP OF that platform. All three went red the moment their CI pin +# moved onto the release carrying this, and stayed green before it — which is +# the only reason the defect shipped. +# +# ⚠️ AND NOTHING IN THIS SUITE HAD THIS SHAPE. 278 e2e scripts, and the +# combination "kernel-abi from the graph, C library from the payload" appeared +# in none of them. That is what this file is for. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/app/src" +cd "$work/app" + +# openkal-linux provides `mcpp:kernel-abi=openkal` and nothing below it. A +# program naming it alone gets its kernel interface from the graph and +# everything else — C library, C++ runtime, startup files — from the payload. +# +# ⚠️ AND WITHOUT `features = ["standalone"]`, WHICH IS A DIFFERENT SHAPE. +# That feature says this implementation is the whole of the program's +# environment, so it supplies the entry point; asked for beside a C library +# that supplies one too, the link ends in +# +# multiple definition of `_start` +# +# which is both correct and not what this file is about. The conformance +# suites that hit the defect do not select it either — a backend is tested as +# a library on top of its platform, not as a replacement for it. +cat > mcpp.toml <<'TOML' +[package] +name = "kabi" +version = "0.1.0" + +[toolchain] +default = "gcc@16.1.0" + +[dependencies] +openkal-linux = "0.5.4" +TOML +cat > src/main.cpp <<'CPP' +int main() { return 0; } +CPP + +out="$("$MCPP" build 2>&1)" && rc=0 || rc=$? + +# The target side must report what it actually is: the kernel interface from +# the graph, and no `c-abi … graph` line beside it. If that ever stops being +# true this test is measuring something else and should be re-read, not +# re-pinned. +case "$out" in + *"kernel-abi"*"graph"*) ;; + *) + echo "SKIP: the graph did not supply the kernel interface here" + printf '%s\n' "$out" | grep -iE 'error|abi' | head -3 + exit 0 ;; +esac +case "$out" in + *"c-abi"*"graph"*) + echo "SKIP: the C library came from the graph too — not the shape under test" + exit 0 ;; +esac + +if [ "$rc" != 0 ]; then + echo "FAIL: the build failed with the C library on the payload" + printf '%s\n' "$out" | grep -iE 'error|crt|bare name|outside the sandbox' | head -6 + exit 1 +fi + +# ⭐ AND THE ARTEFACT, BECAUSE A LINK THAT SUCCEEDS IS NOT THE CLAIM. +# The claim is that the payload's C runtime was reached; a program that links +# without a C library at all would also exit 0 here. +bin="$(find target -type f -name kabi | head -1)" +[ -n "$bin" ] || { echo "FAIL: no artefact was produced"; exit 1; } + +if "$bin"; then + echo " ok it links against the payload's C library, and runs" +else + echo "FAIL: the artefact does not run" + exit 1 +fi + +echo "OK: a graph-supplied kernel interface leaves the payload's C library reachable" From afb56d9e141a6f603aee20ea11884d01dc33dff3 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:32:49 +0800 Subject: [PATCH 02/16] =?UTF-8?q?2026.8.25.1=20=E2=80=94=20ask=20the=20lay?= =?UTF-8?q?er,=20and=20give=20the=20fixture=20a=20target=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections to the fix in the previous commit, each from a measurement rather than from re-reading it. ⭐ THE PREDICATE IS `cAbi.prebuilt()`, WHICH ALREADY EXISTED. `c_library_off_payload()` spelled the question as a list of the origins it was not — `fromGraph() || absent()` — and `Origin` has four values, so it answered three and went quiet about `Xpkg`. A C library from a prebuilt sysroot is no more the payload's than a graph-built one is, and it would have taken the wrong branch. The question the link line asks is whether the C library came from a directory that existed before resolution, which is the distinction this module's own header draws and the one `Layer::prebuilt` is named for. No new predicate is needed; this is the third site in the file to read that same fact. ⭐ AND THE NINJA FIXTURE NOW HAS A TARGET SIDE. `minimal_plan()` left `TargetSide` default-constructed — every layer at `Origin::None`, which is not a native build but "nothing resolved". It got away with that while the gate was `kernelAbi.fromGraph() || cAbi.fromGraph()`, false for all-None and for a payload build alike. Asking the real question separates them, and the fixture then described a target with no C library while all 44 assertions in the file are about one that has the payload's. `resolve` gives a plain native build `cAbi = { Payload, … }`; the fixture now says so. ⚠️ AND THE VERSION IS 2026.8.25.1, NOT 2026.8.24.7. The date rolled over while this was being written. Five unit tests, one per `Origin` plus one asserting that the kernel interface's origin does not participate — the defect itself, written as a check. A predicate spelled as a list of cases covers the cases its author thought of; four tests against a four-valued enum leave a visible hole when a fifth value arrives. unit 93 passed; 0 failed e2e 285 fix → ok, links against the payload's C library and runs 24.6 → hermetic link check failed (crt1.o, crti.o, crtn.o) --- CHANGELOG.md | 2 +- mcpp.toml | 2 +- src/build/flags.cppm | 8 ++-- src/targetside/model.cppm | 37 ++++++++++++++--- src/version.cppm | 2 +- tests/unit/test_ninja_backend.cpp | 20 +++++++++ tests/unit/test_targetside.cpp | 67 +++++++++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9646e8..2f86ed81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 -## [2026.8.24.7] — 2026-08-25 +## [2026.8.25.1] — 2026-08-25 ### 修复 diff --git a/mcpp.toml b/mcpp.toml index fd0f71e0..d1f40df4 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.24.7" +version = "2026.8.25.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 2e7c985a..ff2bff01 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -1238,7 +1238,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { // other's negation; if this one said `system` and that one said `C library`, // a mingw build whose kernel interface came from the graph and whose C // library did not would enter neither, and emit no link line at all. - if (isMingwTc && !plan.targetSide.c_library_off_payload()) { + if (isMingwTc && plan.targetSide.cAbi.prebuilt()) { // `-static` / `-static-libstdc++` now come from the contract table via // unit_ldflags (dist::Format::Pe) — the whole-link `-static` is what // "self-contained" means here, since the piecemeal recipe still leaves @@ -1437,9 +1437,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { // replacement drops is a way of reaching the PAYLOAD's C library, so a // build whose C library still comes from the payload must not enter here // — however much of the rest of its target side the graph supplies. See - // `TargetSide::c_library_off_payload`, which records the shape this got - // wrong and what it cost. - if (!isFreestandingTarget && plan.targetSide.c_library_off_payload()) { + // the note beside `Layer::prebuilt` in mcpp.targetside, which records what + // this got wrong and what it cost. + if (!isFreestandingTarget && !plan.targetSide.cAbi.prebuilt()) { // ⚠️ ASSEMBLED HERE RATHER THAN TAKEN FROM `link_toolchain_flags`, // BECAUSE THAT STRING IS ONLY POPULATED WHEN THE PAYLOAD HAS A CONFIG // FILE (`isClangWithCfg`). The Linux payload ships one and the Windows diff --git a/src/targetside/model.cppm b/src/targetside/model.cppm index d62b6d3d..6bf95dac 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -226,11 +226,38 @@ struct TargetSide { // mcpp and so kept passing, which is why nothing said so until the pin // moved. // - // Absent counts as not-from-the-payload: a target with no C-ABI layer has - // no startup files to find, and the branch below adds `-nostdlib` for it. - bool c_library_off_payload() const { - return cAbi.fromGraph() || cAbi.absent(); - } + // ⚠️ AND THE C LIBRARY IS A SEPARATE QUESTION, WHICH THE ONE ABOVE WAS + // ANSWERING FOR IT AND GETTING WRONG. + // + // `system_from_graph` is an OR over two layers, and the link line's + // decision about the payload's C-library flags depends on ONE of them. + // The two coincide in the arrangement it was written for — an openkal + // target takes both its kernel interface and its C library from the graph + // — and come apart in one that is just as ordinary: + // + // [dependencies] + // openkal-linux = "0.5.4" + // + // A backend that implements openkal ON TOP OF Linux, linked by a program + // that still uses the payload's glibc. `kernelAbi.fromGraph()` is true, + // `cAbi.origin` is `Payload`, and the link side replaced `f.ld` — dropping + // the `-B` that lets the driver find startup files for a C library it was + // still going to link: + // + // error: hermetic link check failed + // crt1.o (bare name — the linker cannot resolve it) + // + // ⚠️ THIS SHIPPED, in 2026.8.24.1, and reached every backend in the + // ecosystem — that shape is how a backend is tested. Their CI was pinned + // to an older mcpp and kept passing. + // + // ⭐ THERE IS NO PREDICATE HERE, BECAUSE `Layer::prebuilt()` ALREADY IS + // ONE. The question the link line asks is whether the C library came from + // a DIRECTORY that existed before resolution (a payload, an xpkg sysroot) + // or from packages that had to be resolved first — which is the very + // distinction the head of this file draws, and the one `prebuilt()` was + // named for. `cAbi.prebuilt()` says it; anything spelled out case by case + // answers three of the four origins and goes quiet about the fourth. }; // ── Capability grammar: mcpp:[=] ─────────────────────────── diff --git a/src/version.cppm b/src/version.cppm index 74ef3071..ba10d94f 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.24.7"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.25.1"; } // namespace mcpp diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 76a07180..46c8820b 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -12,6 +12,7 @@ import mcpp.toolchain.dialect; import mcpp.toolchain.model; import mcpp.platform; import mcpp.platform.runtime_search; +import mcpp.targetside; using namespace mcpp::build; @@ -49,6 +50,25 @@ BuildPlan minimal_plan() { plan.toolchain.version = "test"; plan.toolchain.binaryPath = "/usr/bin/g++"; plan.toolchain.targetTriple = "x86_64-linux-gnu"; + + // ⚠️ AND THE TARGET SIDE, WHICH THIS FIXTURE USED TO LEAVE DEFAULT. + // + // A default-constructed `TargetSide` has every layer at `Origin::None`, + // which is not a native build — it is "nothing has been resolved". No + // production path reaches the flag builder with one: `resolve` gives a + // plain native build `cAbi = { Payload, … }`, from its final branch. + // + // The fixture got away with it while the link line was gated on + // `kernelAbi.fromGraph() || cAbi.fromGraph()`, which is false for all-None + // and false for a payload build alike. Asking the question the link line + // actually has — did the C library come from a directory that existed + // before resolution — separates them, and the fixture then described a + // target with no C library while every assertion in this file is about one + // that has the payload's. + plan.targetSide.compiler = { mcpp::targetside::Origin::Payload, "gcc", "", false }; + plan.targetSide.kernelAbi = { mcpp::targetside::Origin::Payload, "linux", "", false }; + plan.targetSide.cAbi = { mcpp::targetside::Origin::Payload, "glibc", "", false }; + plan.targetSide.cxx = { mcpp::targetside::Origin::Payload, "libstdc++", "", false }; return plan; } diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp index 007aa08d..45d6d228 100644 --- a/tests/unit/test_targetside.cpp +++ b/tests/unit/test_targetside.cpp @@ -612,3 +612,70 @@ TEST(TargetSideConflict, TwoSuppliersAreNamedTogetherWithHowEachArrived) { << "the reason matters: choosing wrong does not fail the link, it " "produces a program that runs and occasionally does not"; } + +// ── Which layer decides the payload's C-library flags ─────────────────────── +// +// ⚠️ THE PREDICATE THIS REPLACES WAS AN `OR` OVER TWO LAYERS, AND SHIPPED. +// +// bool system_from_graph() const { +// return kernelAbi.fromGraph() || cAbi.fromGraph(); +// } +// +// The link side replaced `f.ld` when that was true, dropping the `-B` that +// lets a driver find startup files. The two layers move together for an +// openkal target and come apart for a backend that implements openkal on top +// of a platform whose C library the program still uses. Measured, on three +// lines of manifest: +// +// error: hermetic link check failed +// crt1.o (bare name — the linker cannot resolve it) +// +// ⭐ ONE TEST PER `Origin`, BECAUSE THE QUESTION HAS ONE ANSWER PER VALUE. +// A predicate written as a list of cases answers the ones its author thought +// of; four tests against a four-valued enum make the fifth value's absence +// visible when someone adds it. +namespace { +mcpp::targetside::TargetSide side_with(mcpp::targetside::Origin cAbiOrigin, + mcpp::targetside::Origin kernelOrigin + = mcpp::targetside::Origin::Graph) { + using namespace mcpp::targetside; + TargetSide ts; + ts.kernelAbi = { kernelOrigin, "openkal", "openkal-linux@0.5.4", false }; + ts.cAbi = { cAbiOrigin, + cAbiOrigin == Origin::None ? "" : "glibc", "", false }; + return ts; +} +} + +TEST(TargetSideCLibrary, ThePayloadsCLibraryIsPrebuilt) { + // The shape that failed: the graph supplies the kernel interface and the C + // library is still the payload's, so the payload's flags must survive. + EXPECT_TRUE(side_with(mcpp::targetside::Origin::Payload).cAbi.prebuilt()); +} + +TEST(TargetSideCLibrary, AnXpkgSysrootIsPrebuiltToo) { + // The value a hand-written `fromGraph() || absent()` was silent about. A + // sysroot from an xpkg is a directory that existed before resolution, like + // a payload and unlike a package. + EXPECT_TRUE(side_with(mcpp::targetside::Origin::Xpkg).cAbi.prebuilt()); +} + +TEST(TargetSideCLibrary, AGraphBuiltCLibraryIsNotPrebuilt) { + EXPECT_FALSE(side_with(mcpp::targetside::Origin::Graph).cAbi.prebuilt()); +} + +TEST(TargetSideCLibrary, NoCLibraryIsNotPrebuilt) { + // Nothing to reach, and the link line adds `-nostdlib -static` for it. + EXPECT_FALSE(side_with(mcpp::targetside::Origin::None).cAbi.prebuilt()); + EXPECT_TRUE(side_with(mcpp::targetside::Origin::None).cAbi.absent()); +} + +TEST(TargetSideCLibrary, TheKernelInterfaceDoesNotDecideIt) { + // The defect itself, as an assertion: every kernel-interface origin leaves + // the answer to the C-ABI layer alone. + using namespace mcpp::targetside; + for (auto k : { Origin::Payload, Origin::Xpkg, Origin::Graph, Origin::None }) { + EXPECT_TRUE(side_with(Origin::Payload, k).cAbi.prebuilt()); + EXPECT_FALSE(side_with(Origin::Graph, k).cAbi.prebuilt()); + } +} From f0938284d6c6a6e7a22cc31cab4b0329d5ea4c9a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:32:56 +0800 Subject: [PATCH 03/16] chore: ignore the scratch dir `mcpp test` writes --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1850f27a..a039560e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ bench-child.log # next time rather than reviewed again. binDir examples/*/target/ + +# `mcpp test` writes a per-invocation scratch dir here. +.mcpp/ From 4f6edf3e16244d05c12387d154b0f4c1e983c027 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:37:29 +0800 Subject: [PATCH 04/16] docs(changelog): record the fix that shipped, not the one first written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry described `c_library_off_payload()` — a predicate that was replaced before this branch was pushed, because spelling the question as a list of the origins it is not answers three of `Origin`'s four values and goes quiet about `Xpkg`. What the code does is read `Layer::prebuilt()`, which already names the distinction this module's header draws: a directory that existed before resolution, against packages that had to be resolved first. Adds what was missing beside it — the rejected intermediate spelling, the ninja fixture's target side, and why the unit tests are written one per enum value rather than one per case the author thought of. ⚠️ A changelog that describes an earlier draft of a fix is worse than one that omits it: a reader who greps for `c_library_off_payload` finds nothing in the source and has no way to tell which of the two is stale. --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f86ed81..512cbc5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,33 @@ 区间由 CI 侧三分支并行二分给出:`8.21.3 绿 → 8.24.1 红`,唯一实质提交是 #486。 + ⭐ **判据是层的来源,不是情形的列举。** `Origin` 有四个值 + (`Payload` / `Xpkg` / `Graph` / `None`),而链接线要问的是「C 库是不是来自 + 解析之前就存在的目录」—— 这正是 `Layer::prebuilt()` 的定义,也是本模块开头 + 那段注释划分世界的方式(prebuilt 在解析前可知,composed 在解析后才知)。 + 因此**没有新增谓词**:两处改用 `cAbi.prebuilt()`,与既有的两处读法(C++ 层 + 能否用载荷运行时、`check_layering`)成为同一个事实的第三次读取。 + + ⚠️ 一版写成 `fromGraph() || absent()` 的中间修法被否掉了:它答对三个来源、 + 对 `Xpkg`(来自预构建 sysroot 的 C 库)沉默,而那同样不是载荷的。 + +- **ninja 后端的测试夹具补上了目标侧。** + + `minimal_plan()` 此前让 `TargetSide` 保持默认构造 —— 四层全 `Origin::None`, + 那不是「本机构建」,是「什么都还没解析」。它能一直蒙混过去,是因为旧判据 + 对「全 None」与「载荷构建」给出同一个答案;换成链接线真正要问的问题,两者 + 才分开,而夹具描述的于是变成「没有 C 库的目标」——它拿到 `-nostdlib -static` + 且不带任何运行期搜索路径,而这个文件里 44 条断言全是关于「有载荷 C 库」的。 + + ⚠️ **没有任何生产路径会带着全 `None` 的 `TargetSide` 走到 flags**:`resolve` + 给普通本机构建的是 `cAbi = { Payload, … }`。夹具现在照实写。 + +### 测试 + +- 五条单元测试,**按 `Origin` 的四个值各一条**,外加一条把缺陷本身写成断言 + (内核接口的来源不参与这个决定)。按枚举写而不按想到的情形写:后者只覆盖 + 作者想到的,前者在枚举新增取值时会留下可见的缺口。 + ## [2026.8.24.6] — 2026-08-25 ### 新增 From a8d8bec0b05d626e1a8d048528eec644d083d034 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:43:42 +0800 Subject: [PATCH 05/16] test(e2e): cover the ecosystem, not only a declaration about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight e2e scripts mentioned openkal before today and SEVEN used a synthetic manifest — a package invented on the spot that claims `provides = ["mcpp:kernel-abi=…"]`. Those test what the engine does with a declaration. They cannot test what it does with the ecosystem, and the difference is not academic: #486 replaced the link line whenever `kernelAbi.fromGraph() || cAbi.fromGraph()`, which is right for a graph-supplied C library and wrong for a payload one. Every openkal backend broke. mcpp's CI stayed green through four releases. mcpp names no implementation, and that separation is the point of the `mcpp:` capabilities. It is not a reason to leave the engine untested against the one ecosystem that exercises every layer it models — the direction that matters here is mcpp → openkal: a change to the engine must not break a stack that did nothing. 286 — the whole target side from packages, on this machine. Asserts each layer separately (a report that lost one would still have three, and a test grepping for `graph` would pass on a build that took its C library from the payload), that the image is static, that it has no INTERP segment, and that it runs and prints the right thing. 287 — the same stack for aarch64. `aarch64-linux-musl` is a `verified` row and the acceptance target of #492, and it appeared in ZERO of 278 scripts; what verified it was a probe run by hand, once. Crossing is where a target side from the graph earns its keep and where its mistakes show: a native build that quietly takes the payload's C library still runs, a cross that does produces an artefact for the wrong machine. ⭐ AND 287 ASSERTS THE HELPERS, NOT ONLY THE ARCHITECTURE. clang enables `+outline-atomics` on aarch64 whenever the compiler runtime is compiler-rt, and the `__aarch64_*` helpers live in compiler-rt rather than anywhere the engine could supply. A build that links because the feature was switched OFF reads identically from the outside; the LSE instruction count separates the two. Measured locally against this branch's binary: 286 kernel-abi / c-abi / c++-abi from the graph · static · no INTERP · prints `x0x1x2x3 4` 287 ARM aarch64 · static · 14 helpers defined, 0 undefined · 7 LSE instructions · `1 42 47` under qemu-aarch64 --- .../e2e/286_the_openkal_stack_still_builds.sh | 131 ++++++++++++++++++ ...87_the_openkal_stack_crosses_to_aarch64.sh | 127 +++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100755 tests/e2e/286_the_openkal_stack_still_builds.sh create mode 100755 tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh diff --git a/tests/e2e/286_the_openkal_stack_still_builds.sh b/tests/e2e/286_the_openkal_stack_still_builds.sh new file mode 100755 index 00000000..12e87dd4 --- /dev/null +++ b/tests/e2e/286_the_openkal_stack_still_builds.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# The whole target side from packages: kernel interface, C library, C++ runtime. +# +# ⚠️ WHY THIS FILE EXISTS, AND WHAT IT COST NOT TO HAVE IT. +# +# mcpp and openkal are separate projects, and the engine names no +# implementation — that separation is the point of `mcpp:` capabilities. +# It is not a reason for the engine to be untested against the one ecosystem +# that exercises every layer it models, and until this file there were eight +# e2e scripts mentioning openkal of which SEVEN used synthetic manifests: a +# package invented on the spot that claims `provides = ["mcpp:kernel-abi=…"]`. +# Those test what the engine does with a declaration. They cannot test what it +# does with the ecosystem. +# +# Measured 2026-08-25. A change to how the target side is resolved (#486) +# replaced the link line whenever `kernelAbi.fromGraph() || cAbi.fromGraph()`, +# which is right for a graph-supplied C library and wrong for a payload one. +# Every openkal backend broke — that combination is how a backend is tested — +# and mcpp's own CI stayed green through four releases, because no synthetic +# manifest had the shape. See `285_…` for the narrow case; this file covers the +# arrangement the ecosystem actually ships. +# +# ⭐ THE ASSERTIONS ARE ABOUT THE ARTEFACT, NOT ABOUT EXIT CODES. A build that +# resolves the wrong C library still exits 0; what it cannot do is produce a +# static image with no interpreter and no reference to the host's loader. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/app/src" +cd "$work/app" + +cat > mcpp.toml <<'TOML' +[package] +name = "okstack" +version = "0.1.0" + +# openkal-llvm-runtime IS libc++, libc++abi and libunwind; a build of it with +# gcc is not a thing that exists, and the package says so. Declaring the +# toolchain here rather than relying on a global default keeps this test from +# depending on how the machine running it is configured. +[toolchain] +default = "llvm@22.1.8" + +[dependencies] +openkal-musl = "0.3.5" +openkal-llvm-runtime = "0.1.3" +TOML + +cat > src/main.cpp <<'CPP' +#include +#include +#include + +// Enough of the standard library to need the C++ runtime, the C library and +// the platform interface at once: a heap allocation, a formatted write, and a +// container that grows. +int main() { + std::vector v; + for (int i = 0; i < 4; ++i) v.push_back("x" + std::to_string(i)); + std::string joined; + for (auto const& s : v) joined += s; + std::printf("%s %zu\n", joined.c_str(), v.size()); + return joined == "x0x1x2x3" && v.size() == 4 ? 0 : 1; +} +CPP + +if ! out="$("$MCPP" build 2>&1)"; then + case "$out" in + *"not found in the synced index"*|*"install_packages failed"*) + echo "SKIP: the openkal packages are not reachable from here" + exit 0 ;; + esac + echo "FAIL: the openkal stack did not build" + printf '%s\n' "$out" | grep -iE 'error' | head -5 + exit 1 +fi + +# ── Every layer came from the graph, and the report says which ────────────── +# +# Asserted by layer rather than by counting lines: a report that lost a layer +# would still have three of them, and a test that only checked for the word +# `graph` would pass on a build that took its C library from the payload. +rc=0 +for layer in kernel-abi c-abi 'c++-abi'; do + case "$out" in + *"$layer"*graph*) echo " ok $layer from the graph" ;; + *) echo "FAIL: $layer did not come from the graph" + printf '%s\n' "$out" | grep -E 'kernel-abi|c-abi|c\+\+-abi' | sed 's/^/ /' + rc=1 ;; + esac +done +[ "$rc" = 0 ] || exit 1 + +bin="$(find target -type f -name okstack | head -1)" +[ -n "$bin" ] || { echo "FAIL: no artefact"; exit 1; } + +# ── The artefact is what a graph-supplied target side produces ────────────── +desc="$(file -b "$bin")" +case "$desc" in + *"statically linked"*) echo " ok statically linked" ;; + *) echo "FAIL: not static — the payload's C library was linked instead" + echo " $desc"; exit 1 ;; +esac + +# ⭐ NO INTERPRETER. `statically linked` from `file` is a summary; the program +# header is the fact. A dynamic image names the host's loader here, and that is +# a path the target machine has no reason to have. +if command -v readelf > /dev/null 2>&1; then + if readelf -l "$bin" 2>/dev/null | grep -q 'INTERP'; then + echo "FAIL: the image names an interpreter" + readelf -l "$bin" | grep -A1 INTERP | sed 's/^/ /' + exit 1 + fi + echo " ok no INTERP segment — nothing for a loader to resolve" +fi + +# ── And it runs, which is the only check the others cannot fake ───────────── +if out="$("$bin" 2>&1)"; then + case "$out" in + "x0x1x2x3 4") echo " ok it runs and prints the right thing: $out" ;; + *) echo "FAIL: wrong output: $out"; exit 1 ;; + esac +else + echo "FAIL: the artefact does not run: $out" + exit 1 +fi + +echo "OK: the openkal stack builds, links statically and runs" diff --git a/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh b/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh new file mode 100755 index 00000000..5fc10ff0 --- /dev/null +++ b/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# The same stack, for a machine this one is not. +# +# ⚠️ `aarch64-linux-musl` APPEARED IN NO e2e SCRIPT UNTIL THIS ONE. It is a +# `verified` row of the target table and the acceptance target named in +# mcpp-community/mcpp#492, and 278 scripts mentioned it zero times. What +# verified it was a hand-written probe run once — which says the target worked +# on the day someone looked, and nothing about tomorrow. +# +# ⭐ CROSSING IS WHERE A TARGET SIDE FROM THE GRAPH EARNS ITS KEEP, AND WHERE +# ITS MISTAKES ARE VISIBLE. A native build that quietly takes the payload's C +# library still runs; a cross that does produces an artefact for the wrong +# machine, and `file` says so in one line. +# +# ⚠️ AND THE HELPERS ARE ASSERTED, NOT JUST THE ARCHITECTURE. clang turns on +# `+outline-atomics` for aarch64 whenever the compiler runtime is compiler-rt, +# and the `__aarch64_*` helpers that feature calls live in compiler-rt rather +# than anywhere the engine could supply. A build that links without them is not +# possible; a build that links because the feature was switched OFF is, and +# reads identically from the outside. The LSE instruction count separates them. +set -e + +MCPP="${MCPP:-mcpp}" +TARGET=aarch64-linux-musl +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/app/src" +cd "$work/app" + +cat > mcpp.toml <<'TOML' +[package] +name = "okcross" +version = "0.1.0" + +[toolchain] +default = "llvm@22.1.8" + +[dependencies] +openkal-musl = "0.3.5" +openkal-llvm-runtime = "0.1.3" +TOML + +# `compare_exchange` and `fetch_add` are the two operations that reach the +# outline-atomics helpers on this architecture; everything else compiles to a +# single instruction and would prove nothing. +cat > src/main.cpp <<'CPP' +#include +#include + +int main() { + std::atomic a{7}; + int expected = 7; + const bool swapped = a.compare_exchange_strong(expected, 42); + const int before = a.fetch_add(5); + std::printf("%d %d %d\n", swapped ? 1 : 0, before, a.load()); + return (swapped && before == 42 && a.load() == 47) ? 0 : 1; +} +CPP + +if ! out="$("$MCPP" build --target "$TARGET" 2>&1)"; then + case "$out" in + *"not found in the synced index"*|*"install_packages failed"*) + echo "SKIP: the openkal packages are not reachable from here"; exit 0 ;; + *"cannot be built on this host"*) + echo "SKIP: $TARGET cannot be built here"; exit 0 ;; + esac + echo "FAIL: the cross build failed" + printf '%s\n' "$out" | grep -iE 'error' | head -5 + exit 1 +fi + +bin="$(find target -type f -name okcross | head -1)" +[ -n "$bin" ] || { echo "FAIL: no artefact"; exit 1; } + +desc="$(file -b "$bin")" +case "$desc" in + *"ARM aarch64"*) echo " ok ARM aarch64" ;; + *) echo "FAIL: wrong machine — $desc"; exit 1 ;; +esac +case "$desc" in + *"statically linked"*) echo " ok statically linked" ;; + *) echo "FAIL: not static — $desc"; exit 1 ;; +esac + +# ── The helpers are supplied, and the feature is on ───────────────────────── +nm="$(command -v llvm-nm || command -v nm || true)" +if [ -n "$nm" ]; then + defined="$("$nm" "$bin" 2>/dev/null | grep -cE ' [TtWw] __aarch64_' || true)" + undef="$("$nm" "$bin" 2>/dev/null | grep -cE ' U __aarch64_' || true)" + if [ "${defined:-0}" -gt 0 ] && [ "${undef:-0}" = 0 ]; then + echo " ok $defined outline-atomics helpers defined, 0 undefined" + else + echo "FAIL: ${defined:-0} defined, ${undef:-0} undefined — the graph did not supply them" + "$nm" "$bin" 2>/dev/null | grep '__aarch64_' | head -4 | sed 's/^/ /' + exit 1 + fi +fi + +objdump="$(command -v llvm-objdump || command -v objdump || true)" +if [ -n "$objdump" ]; then + lse="$("$objdump" -d "$bin" 2>/dev/null | grep -cE '\b(casal|cas|ldaddal|ldadd|swpal|swp)\b' || true)" + if [ "${lse:-0}" -gt 0 ]; then + echo " ok $lse LSE instructions — the feature is on, not switched off" + else + echo "FAIL: no LSE instruction; \`+outline-atomics\` looks disabled rather than supported" + exit 1 + fi +fi + +# ── And it runs, if this machine can run it ──────────────────────────────── +runner="$(command -v qemu-aarch64 || command -v qemu-aarch64-static || true)" +if [ -z "$runner" ]; then + echo " SKIP no aarch64 emulator here — building is not running" +else + if ran="$("$runner" "$bin" 2>&1)"; then + case "$ran" in + "1 42 47") echo " ok it runs under $(basename "$runner"): $ran" ;; + *) echo "FAIL: wrong output under emulation: $ran"; exit 1 ;; + esac + else + echo "FAIL: non-zero exit under $(basename "$runner"): $ran" + exit 1 + fi +fi + +echo "OK: the openkal stack crosses to aarch64, supplies its atomics helpers and runs" From 77dd9653ba8e6ec45d42bc7a1b836630df6672b9 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:06:57 +0800 Subject: [PATCH 06/16] ci(openkal-cross): the seventh call site, and a pin nobody was reminded to move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in one workflow, both found by reading a failure that was neither of them — `build 3 targets on macos` went red on PR #503 with: fatal: unable to access '…/openkal-llvm-runtime/': Could not resolve host: github.com ⭐ THE CLONE NOW USES `git_clone_retry.sh`, WHICH ALREADY EXISTED. That helper's own note counts "six call sites, one failure mode"; this workflow was not among them, and the mode duly arrived here. One name that did not resolve ended a 120-minute job in its first minute, beside a real failure it had nothing to do with. Every `git clone` in this repository now retries, except two inline copies in `ci-aarch64-fresh-install.yml` whose comment explains why they cannot use the script: that job checks the repository out LAST on purpose, so the helper is not on disk yet. ⚠️ AND `OPENKAL_BRANCH` WAS `feat/openkal-closure`, LONG AFTER THAT BRANCH MERGED. The comment beside it said "when they are on `main` this becomes `main`" — and the moment for that passed without anyone reading it again. Every run since verified this engine against a tree the ecosystem had left behind. That shape is not incidental today. Both regressions found while reviewing #503 hid behind exactly it: an ecosystem repository pinned to an older mcpp, an engine change that broke it, and two green CIs on either side of the break. A fixed reference kept a check passing by keeping it out of date. --- .github/workflows/openkal-cross.yml | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index bfaf5f21..a88b6cc4 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -68,9 +68,16 @@ env: # (.github/tools/check_version_pins.sh) enforces the ones that exist and would # not know about a copy in this file. XLINGS_NON_INTERACTIVE: '1' - # The branch of the openkal packages this change is verified against. They - # move together with it; when they are on `main` this becomes `main`. - OPENKAL_BRANCH: feat/openkal-closure + # The branch of the openkal packages this change is verified against. + # + # ⚠️ IT WAS `feat/openkal-closure` UNTIL 2026-08-25, LONG AFTER THAT BRANCH + # MERGED. A fixed name here is a pin nobody is reminded to move: the comment + # said "when they are on `main` this becomes `main`" and the moment for that + # passed without anyone reading it again. Every run since was verifying this + # engine against a tree the ecosystem had left behind — and the two + # regressions found today both hid behind exactly this shape, a pin that + # keeps a check green by keeping it out of date. + OPENKAL_BRANCH: main jobs: build: @@ -140,7 +147,19 @@ jobs: - name: The program — one source, three targets run: | set -euo pipefail - git clone --quiet --depth 1 -b "$OPENKAL_BRANCH" \ + # ⚠️ THE SEVENTH CALL SITE. `git_clone_retry.sh` was written because a + # runner's DNS hiccup is not a red build, and its own note counts + # "six call sites, one failure mode" — this workflow was not among + # them, and the mode duly arrived here. Measured on this job, + # 2026-08-25: + # + # fatal: unable to access '…/openkal-llvm-runtime/': + # Could not resolve host: github.com + # + # One name that did not resolve ended a 120-minute job in its first + # minute, beside a real failure it had nothing to do with. + "$GITHUB_WORKSPACE/.github/tools/git_clone_retry.sh" \ + --quiet --depth 1 -b "$OPENKAL_BRANCH" \ https://github.com/mcpplibs/openkal-llvm-runtime "$RUNNER_TEMP/okl" cd "$RUNNER_TEMP/okl/examples/same-source" mkdir -p "$RUNNER_TEMP/out" From 4adf9d71257b433df54f575426e09081d59ef9ab Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:18:31 +0800 Subject: [PATCH 07/16] fix(cache): the key derived its inputs instead of reading the build's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `freestanding::compile_flags` takes `targetCxxRuntime` because the answer changes with it: a freestanding target whose graph supplies a C++ runtime is compiled WITHOUT `-fno-exceptions`, one whose graph does not is compiled with it. `flags.cppm` passes it. `build_axes` did not, so the cache key hashed the flags of the other configuration. ⭐ THE FAILURE IS A HIT ACROSS AN INCOMPATIBILITY, NOT A MISS. Two configurations that must not share a slot produced the same key, and the second build loaded the first's BMIs: error: exception handling was enabled in precompiled file 'openkal.stream.pcm' but is currently disabled The cache itself showed the shape: `openkal@0.7.0` held six fingerprint slots with five differently-sized copies of that one BMI. Slotting per configuration was working; choosing the slot was not. ⚠️ DORMANT UNTIL THE PARAMETER EXISTED. Before #486 the two computations agreed for every input, so a key that ignored one of them was still correct. The defect is that a cache key derived its inputs a second time rather than reading what the build uses — which stops being harmless the moment the derivation gains an argument. This is the second regression from that PR found today, and both have the same shape: a new condition that holds only in the arrangement its author had in front of them. Bisected against the ecosystem's own bare-metal example, which its CI builds green on every run because that CI is pinned to an older mcpp: 8.20.2 green ELF 64-bit LSB executable, UCB RISC-V, 40080 bytes 8.21.2 green 8.21.3 green 24.6 red 25.1 red Three unit tests, on `build_axes` rather than on a hand-built `BuildAxes` — that fixture cannot express this, because the defect is in the derivation. Proved both ways: with the argument removed again, FAILED CacheKey.AGraphSuppliedCxxRuntimeChangesTheFreestandingFlags FAILED CacheKey.TheTwoFreestandingConfigurationsDoNotShareASlot and the third — a hosted target's flags are unchanged either way — stays green in both, which is what keeps this from being read as "the key now varies with something it should not". ⚠️ The other unparameterised call site, in `linkline.cppm`, is left alone and now says why: it builds a LINK line, and `-fno-exceptions` means nothing to a linker. e2e 288 and 289 come with it. 289 sweeps every target the ecosystem serves from one host — which is cheap precisely because this stack takes its target side from packages rather than from a payload that exists on one machine — and it is what found this. --- CHANGELOG.md | 17 ++ src/build/cache_key.cppm | 30 +++- src/freestanding/linkline.cppm | 6 + ...e_openkal_stack_on_a_machine_with_no_os.sh | 132 ++++++++++++++++ ...9_one_host_reaches_every_openkal_target.sh | 149 ++++++++++++++++++ tests/unit/test_cache_key.cpp | 76 +++++++++ 6 files changed, 409 insertions(+), 1 deletion(-) create mode 100755 tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh create mode 100755 tests/e2e/289_one_host_reaches_every_openkal_target.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 512cbc5b..5795d9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,23 @@ (内核接口的来源不参与这个决定)。按枚举写而不按想到的情形写:后者只覆盖 作者想到的,前者在枚举新增取值时会留下可见的缺口。 +- 三条缓存键测试,**直接打在 `build_axes()` 上**而不是手搭 `BuildAxes`: + 那个夹具表达不了这个缺陷,因为缺陷在推导里。两种配置的 flag 必须不同、 + 缓存键必须不同,而**宿主目标必须不受影响** —— 最后一条是防止修过头的控制项。 + +- 五条真实依赖 openkal 包的 e2e。此前八条提到 openkal 的脚本里有七条用的是 + **合成清单**(当场编造一个自称 `provides = ["mcpp:kernel-abi=…"]` 的包), + 测的是引擎对一句声明的处理,测不到生态实际的形状 —— 今天的两条回归都从这条缝 + 里出去。 + + | | 覆盖 | + |---|---| + | 285 | kernel-abi 来自图 + C 库来自**载荷**(后端跑在平台之上) | + | 286 | 三层全来自图,断言静态、无 INTERP、能跑 | + | 287 | 交叉到 aarch64,断言 outline-atomics 辅助函数与 LSE 指令数,qemu 真跑 | + | 288 | 无 OS 无 C 库,断言报告里**没有 c-abi 那一行**,并在 qemu 里真启动 | + | 289 | **一台宿主横扫四个目标** —— 这个体系本就是通用交叉构建,传统栈要六个 runner 的覆盖,这里一个循环 | + ## [2026.8.24.6] — 2026-08-25 ### 新增 diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 0efc3489..8de9119f 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -315,9 +315,37 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, : (tc.binaryPath.empty() ? std::string{} : mcpp::toolchain::hash_file(tc.binaryPath)); b.targetTriple = tc.targetTriple; + // ⚠️⚠️ `tc.targetCxxRuntime` IS PASSED, AND OMITTING IT MADE THIS KEY + // DESCRIBE A COMPILATION THAT DOES NOT HAPPEN. + // + // `compile_flags` takes that argument because the answer changes with it: + // a freestanding target whose graph supplies a C++ runtime is compiled + // WITHOUT `-fno-exceptions`, and one whose graph does not is compiled with + // it. The flag builder reads it (flags.cppm passes + // `plan.toolchain.targetCxxRuntime`); this key did not, so it defaulted to + // `false` and hashed the flags of the other configuration. + // + // ⭐ THE CONSEQUENCE IS A CACHE HIT ACROSS AN INCOMPATIBILITY, NOT A MISS. + // Two configurations that must not share a slot hashed to the same key, so + // the second build loaded the first's BMIs. Measured 2026-08-25 on a + // bare-metal program over openkal-opensbi: + // + // error: exception handling was enabled in precompiled file + // 'openkal.stream.pcm' but is currently disabled + // + // and in the cache itself — `openkal@0.7.0` had six fingerprint slots + // holding five differently-sized copies of that one BMI. Slotting per + // configuration was working; choosing the slot was not. + // + // ⚠️ IT WAS DORMANT UNTIL THE ARGUMENT EXISTED. Before #486 the two layers + // computed identical flag lists for every input, so a key that ignored one + // of them was still correct. The defect is that a cache key derived its + // inputs a second time instead of reading what the build uses — and that + // stops being harmless the moment the derivation gains a parameter. if (auto ft = mcpp::toolchain::triple::parse(tc.targetTriple)) if (auto spec = mcpp::freestanding::resolve(*ft)) - b.targetImpliedFlags = mcpp::freestanding::compile_flags(*spec); + b.targetImpliedFlags = + mcpp::freestanding::compile_flags(*spec, tc.targetCxxRuntime); b.stdlibId = tc.stdlibId; b.stdlibVersion = tc.stdlibVersion; diff --git a/src/freestanding/linkline.cppm b/src/freestanding/linkline.cppm index b5c9fff6..913a9c09 100644 --- a/src/freestanding/linkline.cppm +++ b/src/freestanding/linkline.cppm @@ -81,6 +81,12 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, { std::string out; out += " --target=" + std::string(s.triple); + // The default `targetCxxRuntime` is deliberate here and not an omission of + // the kind fixed in cache_key.cppm: what this list contributes to a LINK + // line is the machine description (`-march`, `-mabi`, `-mcmodel`), and the + // two flags the argument governs — `-fno-exceptions`, `-fno-rtti` — say + // nothing to a linker. Passing it would change no byte of the output while + // suggesting it does. for (auto const& f : compile_flags(s)) { out += ' '; out += f; } out += " -nostdlib -nostartfiles -static"; if (!in.lld.empty()) diff --git a/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh b/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh new file mode 100755 index 00000000..f62a438d --- /dev/null +++ b/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell qemu-riscv +# openkal where there is no operating system, and no C library either. +# +# ⚠️ THE FREESTANDING SHAPE IS THE ONE THE ENGINE MODELS MOST AND TESTS LEAST. +# `riscv64-none-elf` appears in ten e2e scripts and in none of them does a real +# openkal package supply the platform: they build a program with no dependency +# at all, which exercises the freestanding link path and not the seam this +# ecosystem exists for. The layer that changes here is `kernelAbi` — supplied +# by openkal-opensbi, which answers to the SBI a machine's firmware provides — +# while `cAbi` is genuinely absent. +# +# ⭐ `cAbi.absent()` IS A DISTINCT ORIGIN, AND THE LINK LINE BRANCHES ON IT. +# A hosted target whose C library comes from a package and one that has no C +# library at all take different flags (`-nostdlib -static` for the latter), and +# the predicate that chooses between them was, until 2026-08-25, an OR over two +# layers. Four origins, four behaviours; this covers the one no other e2e does. +# +# ⚠️ AND THE PROGRAM RUNS UNDER QEMU RATHER THAN MERELY LINKING. A freestanding +# image that links is not evidence: the entry point, the linker script and the +# ordering of the startup objects are BSP facts, and the only check that reaches +# them is the machine printing what the program told it to. +set -e + +MCPP="${MCPP:-mcpp}" +TARGET=riscv64-none-elf +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/app/src" +cd "$work/app" + +cat > mcpp.toml <<'TOML' +[package] +name = "okbare" +version = "0.1.0" + +[toolchain] +default = "llvm@22.1.8" + +# The platform, and nothing above it. `standalone` says this implementation is +# the whole of the program's environment: it supplies the entry point, because +# no C runtime is going to. +[dependencies] +openkal-opensbi = { version = "0.1.5", features = ["standalone"] } +TOML + +cat > src/main.cpp <<'CPP' +import openkal.stream; +import openkal.abort; + +// No : there is no C library here. The only way out of this program is +// the interface the platform package implements. +// ⚠️ `extern "C"`, AND THE THREE-ARGUMENT SIGNATURE THE ENTRY POINT DECLARES. +// `-ffreestanding` removes the compiler's special handling of `main`, so the +// name is an ordinary symbol and has to match what calls it. The platform +// package's `standalone` feature runs the initialisers and then calls +// `main(0, ¬hing, ¬hing)` — see `__okb_start_c` in its src/start.cpp. A +// plain `int main()` compiled as C++ mangles to something else entirely: +// +// ld.lld: error: undefined symbol: main +extern "C" int main(int, char**, char**) { + const char msg[] = "okbare alive\n"; + kal_stream_write(kal_stdout(), msg, sizeof msg - 1); + return 0; +} +CPP + +if ! out="$("$MCPP" build --target "$TARGET" 2>&1)"; then + case "$out" in + *"not found in the synced index"*|*"install_packages failed"*) + echo "SKIP: openkal-opensbi is not reachable from here"; exit 0 ;; + *"cannot be built on this host"*) + echo "SKIP: $TARGET cannot be built here"; exit 0 ;; + esac + echo "FAIL: the freestanding openkal build failed" + printf '%s\n' "$out" | grep -iE 'error' | head -5 + exit 1 +fi + +# ── The report says what this target side is, and is not ─────────────────── +case "$out" in + *"kernel-abi"*graph*) echo " ok kernel-abi from the graph" ;; + *) echo "FAIL: the platform did not come from the graph" + printf '%s\n' "$out" | grep -E 'abi' | sed 's/^/ /'; exit 1 ;; +esac +# ⭐ AND NO `c-abi` LINE AT ALL. A target with no C library must not report one; +# if this ever prints, something resolved a C library nobody asked for. +case "$out" in + *"c-abi"*) + echo "FAIL: a C library was resolved for a target that has none" + printf '%s\n' "$out" | grep 'c-abi' | sed 's/^/ /'; exit 1 ;; + *) echo " ok no c-abi layer — the target has no C library" ;; +esac + +bin="$(find target -type f -name okbare | head -1)" +[ -n "$bin" ] || { echo "FAIL: no artefact"; exit 1; } + +desc="$(file -b "$bin")" +case "$desc" in + *RISC-V*) echo " ok RISC-V" ;; + *) echo "FAIL: wrong machine — $desc"; exit 1 ;; +esac + +# ── It boots on a machine whose firmware provides the SBI ────────────────── +q="$(command -v qemu-system-riscv64 || true)" +if [ -z "$q" ]; then + q="$(ls -d "$HOME"/.mcpp/registry/data/xpkgs/xim-x-qemu-riscv/*/bin/qemu-system-riscv64 2>/dev/null | head -1)" +fi +if [ -z "$q" ]; then + echo " SKIP no riscv64 machine emulator here — linking is not booting" +else + log="$work/run.log" + # A watchdog: a program that never returns fails as surely as one that + # returns wrongly, and without this the test would spend its whole timeout + # discovering that. + ( "$q" -machine virt -nographic -no-reboot -bios default -kernel "$bin" > "$log" 2>&1 ) & pid=$! + ( sleep 40; kill -9 $pid 2>/dev/null ) & guard=$! + wait $pid 2>/dev/null || true + kill $guard 2>/dev/null || true + + # ⚠️ The emulated console ends its lines with CRLF, so a comparison that + # does not strip them fails on a line that is correct. + if tr -d '\r' < "$log" | grep -q 'okbare alive'; then + echo " ok it boots and prints through openkal.stream" + else + echo "FAIL: the machine did not print what the program wrote" + sed 's/^/ /' "$log" | head -8 + exit 1 + fi +fi + +echo "OK: openkal runs on a machine with no operating system and no C library" diff --git a/tests/e2e/289_one_host_reaches_every_openkal_target.sh b/tests/e2e/289_one_host_reaches_every_openkal_target.sh new file mode 100755 index 00000000..0673477c --- /dev/null +++ b/tests/e2e/289_one_host_reaches_every_openkal_target.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# One source, one host, every machine the ecosystem serves. +# +# ⭐⭐ THIS IS CHEAP BECAUSE OF WHAT THE ECOSYSTEM IS, AND THAT IS THE POINT. +# +# A traditional stack needs a macOS runner to test macOS and a Windows runner +# to test Windows, because the target side comes from a payload that only +# exists there. Over openkal it comes from PACKAGES built from source by one +# retargetable clang — so a single Linux machine can produce an artefact for +# every target, and the shape of that artefact is checkable without leaving it. +# Coverage that would cost six runners costs one loop. +# +# ⚠️ WHAT THIS FILE IS FOR IS BREADTH, NOT DEPTH. 286, 287 and 288 each go deep +# on one arrangement — the native stack, the aarch64 cross with its +# outline-atomics helpers, the machine with no operating system. This asserts +# the one property they cannot: that a change to the engine did not silently +# stop serving a target nobody happened to build that day. +# +# ⚠️ A TARGET THAT CANNOT BE BUILT HERE IS SKIPPED WITH ITS REASON, NEVER +# PASSED OVER SILENTLY. A sweep whose failure mode is "produced no output" +# would report success on a run that swept nothing. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# target | extra dependency lines | what `file` must say +# +# The dependency list is per-target because the platform layer is: openkal is +# an interface and each machine has its own implementation of it. That the C +# library and C++ runtime lines are IDENTICAL across every row is the claim the +# ecosystem makes, and it is visible here as repetition rather than stated. +rows=' +x86_64-linux-musl|openkal-musl = "0.3.5"\nopenkal-llvm-runtime = "0.1.3"|ELF 64-bit LSB executable, x86-64|statically linked +aarch64-linux-musl|openkal-musl = "0.3.5"\nopenkal-llvm-runtime = "0.1.3"|ELF 64-bit LSB executable, ARM aarch64|statically linked +x86_64-windows-gnu|openkal-musl = "0.3.5"\nopenkal-llvm-runtime = "0.1.3"\nopenkal-windows = "0.1.5"|PE32+| +riscv64-none-elf|openkal = "0.7.0"\nopenkal-opensbi = { version = "0.1.5", features = ["standalone"] }|ELF 64-bit LSB executable, UCB RISC-V|statically linked +' + +built=0; skipped=0; failed=0 +printf '\n' +while IFS='|' read -r target deps want_fmt want_link; do + [ -n "$target" ] || continue + + d="$work/$target"; mkdir -p "$d/src"; cd "$d" + { + printf '[package]\nname = "sweep"\nversion = "0.1.0"\n\n' + printf '[toolchain]\ndefault = "llvm@22.1.8"\n\n' + # ⚠️ `sysroot = ""` IS HOW A PROJECT SAYS "NO C LIBRARY", AND IT IS NOT + # IMPLIED BY THE TARGET. `riscv64-none-elf` names a machine with no + # operating system; whether the program has a C library is a separate + # statement, and the ecosystem's own bare-metal example makes it. Left + # out, the spec package's modules are compiled with exceptions enabled + # and the platform package's `-fno-exceptions` sources then cannot read + # the BMI: + # + # error: exception handling was enabled in precompiled file + # 'openkal.stream.pcm' but is currently disabled + case "$target" in + *-none-*) printf '[target.%s]\nsysroot = ""\n\n' "$target" ;; + esac + printf '[dependencies]\n' + printf "$deps\n" + } > mcpp.toml + + # ⚠️ TWO SOURCES, BECAUSE TWO OF THESE TARGETS HAVE NO C LIBRARY TO PRINT + # WITH. The bare-metal row reaches the platform interface directly; every + # other row is an ordinary hosted program. Writing one source that works + # everywhere would mean writing to the lowest common denominator, which is + # not what a user of a hosted target does. + case "$target" in + *-none-*) + cat > src/main.cpp <<'CPP' +import openkal.stream; +// `extern "C"` with the signature the platform's entry point calls: with +// `-ffreestanding` the name is an ordinary symbol, not a special one. +extern "C" int main(int, char**, char**) { + const char m[] = "sweep\n"; + kal_stream_write(kal_stdout(), m, sizeof m - 1); + return 0; +} +CPP + ;; + *) + cat > src/main.cpp <<'CPP' +#include +#include +int main() { + std::string s = "sweep"; + std::printf("%s\n", s.c_str()); + return s.size() == 5 ? 0 : 1; +} +CPP + ;; + esac + + if ! out="$("$MCPP" build --target "$target" 2>&1)"; then + case "$out" in + *"not found in the synced index"*|*"install_packages failed"*) + echo " SKIP $target — its packages are not reachable from here" + skipped=$((skipped+1)); continue ;; + *"cannot be built on this host"*) + echo " SKIP $target — this host cannot build it" + skipped=$((skipped+1)); continue ;; + esac + echo " FAIL $target — the build failed" + printf '%s\n' "$out" | grep -iE 'error' | head -3 | sed 's/^/ /' + failed=$((failed+1)); continue + fi + + bin="$(find target -type f \( -name sweep -o -name sweep.exe \) | head -1)" + if [ -z "$bin" ]; then + echo " FAIL $target — the build reported success and produced nothing" + failed=$((failed+1)); continue + fi + + desc="$(file -b "$bin")" + ok=1 + case "$desc" in *"$want_fmt"*) ;; *) ok=0 ;; esac + if [ -n "$want_link" ]; then + case "$desc" in *"$want_link"*) ;; *) ok=0 ;; esac + fi + if [ "$ok" = 1 ]; then + echo " ok $target → $want_fmt${want_link:+, $want_link}" + built=$((built+1)) + else + echo " FAIL $target — wrong artefact" + echo " want: $want_fmt${want_link:+ + $want_link}" + echo " got: $desc" + failed=$((failed+1)) + fi +done <& v, std::string_view f) { + return std::ranges::find(v, f) != v.end(); + }; + EXPECT_TRUE(has(without.targetImpliedFlags, "-fno-exceptions")); + EXPECT_FALSE(has(with.targetImpliedFlags, "-fno-exceptions")); +} + +TEST(CacheKey, TheTwoFreestandingConfigurationsDoNotShareASlot) { + mcpp::manifest::Manifest m; + m.package.standard = "c++23"; + EXPECT_NE(ck::key_hex(ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""), pkg()), + ck::key_hex(ck::build_axes(freestanding_tc(true), m, "-std=c++23", {}, ""), pkg())); +} + +// ⭐ AND A HOSTED TARGET IS UNAFFECTED, so the fix cannot be read as "the key +// now changes with something it should not". `freestanding::resolve` returns +// nothing for a hosted triple, and the flags stay empty either way. +TEST(CacheKey, AHostedTargetHasNoTargetImpliedFlagsEitherWay) { + mcpp::manifest::Manifest m; + m.package.standard = "c++23"; + auto tc = freestanding_tc(false); tc.targetTriple = "x86_64-linux-gnu"; + auto a = ck::build_axes(tc, m, "-std=c++23", {}, ""); + tc.targetCxxRuntime = true; + auto b = ck::build_axes(tc, m, "-std=c++23", {}, ""); + EXPECT_TRUE(a.targetImpliedFlags.empty()) << a.targetImpliedFlags.size(); + EXPECT_EQ(a.targetImpliedFlags, b.targetImpliedFlags); +} From 28c21c291b2a17299b6391ab7dd23b3e06ccb912 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:26:56 +0800 Subject: [PATCH 08/16] fix(dist): the payload's C++ runtime serves a payload C library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third site with the same shape, found by the first item of the review this PR's own tests made possible. `mi.graphCxxRuntime` decides whether the contract table reaches for the payload's C++ archives, and it asked `system_from_graph()` — an OR over two layers. A program whose kernel interface comes from a package while its C library and C++ runtime are the payload's is served by those archives, and the OR said otherwise: undefined reference to `__cxa_allocate_exception' undefined reference to `std::runtime_error::runtime_error(char const*)' measured on `openkal-linux = "0.5.4"` with a `throw` in main. ⚠️ THIS SITE HAS NOW BEEN WRONG IN BOTH DIRECTIONS. It was `targetCxxRuntime` first, which failed a C program (no C++ runtime in its graph, answer "no", read as "the payload's is right"); #486 replaced it with the OR, which overshot. The C library decides it, for the reason `check_layering` already states in the other direction: the payload's C++ runtime was configured against the payload's C library, so it is eligible when and only when that C library is in use. ⭐ AND e2e 285 COULD NOT HAVE CAUGHT THIS, WHICH IS WHY IT CHANGED TOO. Its program was `int main() { return 0; }` — a link that needs almost nothing, and passes with the C++ runtime removed. It now throws and builds a `std::string`: one reaches the C++ runtime, the other the C library, so one program covers both layers this shape gets wrong. A test that cannot fail need not have an empty assertion; a program too simple to reach the defect does the same thing. Also in this commit, from the same review: ⚠️ 288 WAS COVERING `Origin::Xpkg` WHILE CLAIMING `Origin::None`. The target table gives `riscv64-none-elf` a default C library — `xim:picolibc-riscv@1.8.12` — so "bare metal" does not imply "no C library"; a project says that with `sysroot = ""`. What a test covers is not decided by its name. ⚠️ AND ITS ASSERTION WAS THE WRONG WAY ROUND. It required the `c-abi` row to be absent; the report prints one row per layer and writes `—` when a layer resolved to nothing, which is right — a row that vanished would be indistinguishable from a row nobody looked at. The assertion is now about that row's VALUE. ⚠️ AND ITS EMULATOR PROBE PREFERRED PATH. `command -v qemu-system-riscv64` finds an xlings shim that answers "is not installed in this subos" when run, so a PATH-first probe selects a program that cannot run and reports its failure as the test's. The payload's copy is tried first and both candidates are checked with `--version` before use. 31 lines of comment duplicated across two drafts of the first fix are removed from targetside/model.cppm; the surviving copy is the one that describes the code as it stands. --- src/build/flags.cppm | 38 ++++++++---- src/targetside/model.cppm | 31 ---------- ..._from_graph_keeps_the_payload_c_library.sh | 29 ++++++++- ...e_openkal_stack_on_a_machine_with_no_os.sh | 60 ++++++++++++++++--- 4 files changed, 104 insertions(+), 54 deletions(-) diff --git a/src/build/flags.cppm b/src/build/flags.cppm index ff2bff01..1e332dbb 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -1008,18 +1008,34 @@ CompileFlags compute_flags(const BuildPlan& plan) { // "incompatible with elf64lriscv". See MechanismInput::freestanding. if (auto ft = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple)) mi.freestanding = ft->is_freestanding(); - // AND THE HOSTED FORM OF THE SAME FACT. The target's system comes from - // the graph — so, exactly as on bare metal, every archive the table - // below would reach for is the HOST's. + // AND THE HOSTED FORM OF THE SAME FACT. The archives the table below + // reaches for are the PAYLOAD's, and the question is whether this + // target is served by them. // - // The condition is the SYSTEM's origin and not the C++ runtime's. It - // was the latter until this line, and that is precisely why a C - // program over the same packages kept the payload's libc++ on its link - // line: the table asked whether a C++ runtime came from the graph, a C - // program has none, and the answer "no" was read as "so the payload's - // is right". A program with no C++ runtime needs the driver stopped - // from adding one just as much as a program that brought its own. - mi.graphCxxRuntime = plan.targetSide.system_from_graph(); + // It was `targetCxxRuntime` once, and that was wrong for a C program: + // the table asked whether a C++ runtime came from the graph, a C + // program has none, and "no" was read as "so the payload's is right". + // A program with no C++ runtime needs the driver stopped from adding + // one just as much as a program that brought its own. + // + // ⚠️ IT WAS THEN `system_from_graph()`, WHICH OVERSHOT IN THE OTHER + // DIRECTION. That is an OR over two layers, and a program whose kernel + // interface comes from a package while its C library and C++ runtime + // are the payload's is served by the payload's archives — yet the OR + // said otherwise and `-nostdlib++` removed the one it needed: + // + // undefined reference to `std::runtime_error::runtime_error(char const*)' + // undefined reference to `typeinfo for std::runtime_error' + // + // measured on `openkal-linux = "0.5.4"` with a `throw` in main. + // + // ⭐ THE C LIBRARY IS WHAT DECIDES IT, for the same reason it decides + // the link line's search paths: the payload's C++ runtime was + // configured against the payload's C library, so it is eligible when + // and only when that C library is the one in use. `check_layering` + // states the same rule in the other direction, refusing the + // combination this predicate must not create. + mi.graphCxxRuntime = !plan.targetSide.cAbi.prebuilt(); const bool wantsArchives = (base == dist::Contract::SelfContained diff --git a/src/targetside/model.cppm b/src/targetside/model.cppm index 6bf95dac..382b11a7 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -195,37 +195,6 @@ struct TargetSide { return kernelAbi.fromGraph() || cAbi.fromGraph(); } - // ⚠️ AND THE C LIBRARY IS A SEPARATE QUESTION, WHICH THE ONE ABOVE WAS - // ANSWERING FOR IT AND GETTING WRONG. - // - // `system_from_graph` is an OR over two layers, and the link line's - // decision about the payload's C-library search paths depends on ONE of - // them. The two coincide in the arrangement they were written for — an - // openkal target takes both its kernel interface and its C library from - // the graph — and come apart in one that is just as ordinary: - // - // [dependencies] - // openkal-linux = { version = "0.5.4", features = ["standalone"] } - // - // A backend that implements openkal ON TOP OF Linux, linked by a program - // that still uses the payload's glibc. `kernelAbi.fromGraph()` is true, - // `cAbi` is the payload's, and the link side replaced `f.ld` — dropping - // the search paths for a C library it was still going to link. The - // driver asked for the startup files anyway and the linker had nowhere - // to look: - // - // error: hermetic link check failed - // crt1.o (bare name — the linker cannot resolve it) - // crti.o (bare name — the linker cannot resolve it) - // crtn.o (bare name — the linker cannot resolve it) - // - // ⚠️ THIS SHIPPED. It reached every conformance suite in the openkal - // ecosystem, because that shape is exactly how a backend is tested: - // openkal-linux, openkal-macos and openkal-windows all build their suite - // against the platform's own C library. Their CI was pinned to an older - // mcpp and so kept passing, which is why nothing said so until the pin - // moved. - // // ⚠️ AND THE C LIBRARY IS A SEPARATE QUESTION, WHICH THE ONE ABOVE WAS // ANSWERING FOR IT AND GETTING WRONG. // diff --git a/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh b/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh index 31f1a015..bf294df2 100755 --- a/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh +++ b/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh @@ -64,8 +64,30 @@ default = "gcc@16.1.0" [dependencies] openkal-linux = "0.5.4" TOML +# ⭐ THE PROGRAM USES THE C++ RUNTIME, NOT ONLY THE C LIBRARY. +# +# `int main() { return 0; }` links against almost nothing and would pass while +# a second defect of the same family was live: the contract table decided +# whether the payload's C++ archives serve this target with the same two-layer +# OR, so `-nostdlib++` was emitted for this shape and a program that throws +# could not link — +# +# undefined reference to `__cxa_allocate_exception' +# undefined reference to `std::runtime_error::runtime_error(char const*)' +# +# A `throw` and a `std::string` reach the C++ runtime and the C library +# respectively, so one program covers both layers this shape gets wrong. cat > src/main.cpp <<'CPP' -int main() { return 0; } +#include +#include +#include + +int main() { + try { throw std::runtime_error("boom"); } + catch (const std::exception& e) { std::printf("%s\n", e.what()); } + std::string s = "x"; s += "y"; + return s == "xy" ? 0 : 1; +} CPP out="$("$MCPP" build 2>&1)" && rc=0 || rc=$? @@ -99,8 +121,9 @@ fi bin="$(find target -type f -name kabi | head -1)" [ -n "$bin" ] || { echo "FAIL: no artefact was produced"; exit 1; } -if "$bin"; then - echo " ok it links against the payload's C library, and runs" +if out="$("$bin" 2>&1)"; then + [ "$out" = "boom" ] || { echo "FAIL: wrong output: $out"; exit 1; } + echo " ok it links against the payload's C library and C++ runtime, and runs" else echo "FAIL: the artefact does not run" exit 1 diff --git a/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh b/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh index f62a438d..e3528d6d 100755 --- a/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh +++ b/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh @@ -37,6 +37,22 @@ version = "0.1.0" [toolchain] default = "llvm@22.1.8" +# ⚠️ `sysroot = ""` IS WHAT MAKES THIS THE NO-C-LIBRARY SHAPE, AND THE TARGET +# NAME DOES NOT IMPLY IT. +# +# `riscv64-none-elf` names a machine with no operating system; whether the +# program has a C library is a separate statement, and the target table answers +# it with a default — `xim:picolibc-riscv@1.8.12`. Measured while writing this +# file, whose whole point is the layer being ABSENT: +# +# c-abi picolibc-riscv (xim:picolibc-riscv@1.8.12, prebuilt) +# +# So the absence has to be asked for. This is the one e2e that covers +# `cAbi.absent()`, the fourth `Origin` value, and it would have been covering +# `Xpkg` instead. +[target.riscv64-none-elf] +sysroot = "" + # The platform, and nothing above it. `standalone` says this implementation is # the whole of the program's environment: it supplies the entry point, because # no C runtime is going to. @@ -83,13 +99,27 @@ case "$out" in *) echo "FAIL: the platform did not come from the graph" printf '%s\n' "$out" | grep -E 'abi' | sed 's/^/ /'; exit 1 ;; esac -# ⭐ AND NO `c-abi` LINE AT ALL. A target with no C library must not report one; -# if this ever prints, something resolved a C library nobody asked for. -case "$out" in - *"c-abi"*) +# ⭐ AND THE `c-abi` LINE NAMES NOTHING. +# +# ⚠️ THE FIRST VERSION OF THIS ASSERTED THE LINE WAS ABSENT, WHICH IS THE WRONG +# CRITERION AND WOULD HAVE FAILED ON A CORRECT BUILD. The report prints one row +# per layer and says what each resolved to; a layer that resolved to nothing is +# reported as nothing: +# +# c-abi — +# +# That is the report doing its job — a row that vanished would be +# indistinguishable from a row nobody looked at. What must not appear is a +# NAME, which is what the earlier draft of this file actually saw: +# +# c-abi picolibc-riscv (xim:picolibc-riscv@1.8.12, prebuilt) +c_abi_line="$(printf '%s\n' "$out" | grep -E '^\s*c-abi' | head -1)" +case "$c_abi_line" in + *"—"*|"") + echo " ok the c-abi layer names nothing — the target has no C library" ;; + *) echo "FAIL: a C library was resolved for a target that has none" - printf '%s\n' "$out" | grep 'c-abi' | sed 's/^/ /'; exit 1 ;; - *) echo " ok no c-abi layer — the target has no C library" ;; + echo " $c_abi_line"; exit 1 ;; esac bin="$(find target -type f -name okbare | head -1)" @@ -102,9 +132,21 @@ case "$desc" in esac # ── It boots on a machine whose firmware provides the SBI ────────────────── -q="$(command -v qemu-system-riscv64 || true)" -if [ -z "$q" ]; then - q="$(ls -d "$HOME"/.mcpp/registry/data/xpkgs/xim-x-qemu-riscv/*/bin/qemu-system-riscv64 2>/dev/null | head -1)" +# ⚠️ THE PAYLOAD'S COPY FIRST, AND `command -v` ONLY AS A FALLBACK. +# +# On a machine with xlings there is a `qemu-system-riscv64` on PATH that is a +# shim, and asking it to run anything answers: +# +# [error] qemu-system-riscv64 is not installed in this subos (_) +# +# `command -v` finds it and reports success, so a probe written PATH-first +# selects a program that cannot run and reports the failure as the test's. +# The payload's copy is the one this build system installed on purpose. +q="$(ls -d "$HOME"/.mcpp/registry/data/xpkgs/xim-x-qemu-riscv/*/bin/qemu-system-riscv64 2>/dev/null | head -1)" +if [ -z "$q" ] || ! "$q" --version > /dev/null 2>&1; then + q="$(command -v qemu-system-riscv64 || true)" + # And the fallback is checked the same way, for the same reason. + [ -n "$q" ] && ! "$q" --version > /dev/null 2>&1 && q="" fi if [ -z "$q" ]; then echo " SKIP no riscv64 machine emulator here — linking is not booting" From 9291f1c81b3a2610afe87298c4c9e8532c93a950 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:47:35 +0800 Subject: [PATCH 09/16] feat(build.mcpp): this build system's own tools come first on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpp installs its tools into its own sub-OS — 221 programs on the machine this was written on — and a build program inherited a PATH with none of them on it. A program that wanted a tool had to ask PATH the way a shell script would, and `command -v` answers about the machine rather than about this build. Measured with a build program that printed its own PATH: /home/…/.xlings/data/xpkgs/xim-x-claude/2.1.222 /home/…/mcpp/.xlings/subos/_/bin /home/…/.xlings/subos/current/bin subos/bin in PATH: NO and the concrete cost, from the same day: `command -v qemu-system-riscv64` returned a shim that answers, when run, [error] qemu-system-riscv64 is not installed in this subos (_) — found, reported present, unable to execute, while the working copy sat in mcpp's own directory unreachable. ⚠️ PREPENDED, NOT SUBSTITUTED. A build program legitimately calls `git`, `python3` or a shell, none of which this build system ships; a PATH holding only mcpp's directory would break every one of them for a guarantee nobody asked for. Front position makes the isolated copy the default answer and leaves the host reachable behind it. The inherited value is read in `contract_env` rather than assumed, because `extraEnv` replaces a variable outright in the child. ⭐ RESOLVED ONCE. `toolsBinDir` is computed after `get_cfg` and handed to `fill_target_build_env`, whose comment already promised "all four in one call" — it is now the fifth answer that call gives. The first draft of this change derived it separately at each of the two call sites (root package and dependency), which is precisely the shape behind all three defects fixed in this release: one fact, computed in more than one place, agreeing until it does not. Read through the config resolver rather than `~/.mcpp`, like every other consumer of that directory (doctor, resources): `MCPP_HOME` moves it. Empty when no sub-OS exists yet, and PATH is then left as inherited. e2e 290 asserts both halves — that the first entry is mcpp's directory, AND that the inherited entries survive behind it. A test checking only the first would pass on a PATH that had thrown the rest away. Measured both ways: 40 entries after, and before the change the first entry was an unrelated tool. docs/07 gains a section stating the contract for package authors. --- CHANGELOG.md | 39 ++++++++ docs/07-build-mcpp.md | 24 +++++ src/build/build_program.cppm | 42 ++++++++ src/build/prepare.cppm | 39 +++++++- ...ram_sees_this_build_systems_tools_first.sh | 95 +++++++++++++++++++ 5 files changed, 236 insertions(+), 3 deletions(-) create mode 100755 tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5795d9ec..432a4407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,45 @@ ⚠️ **没有任何生产路径会带着全 `None` 的 `TargetSide` 走到 flags**:`resolve` 给普通本机构建的是 `cAbi = { Payload, … }`。夹具现在照实写。 +- **⭐ 载荷的 C++ 运行时,服务的是载荷的 C 库。** + + ``` + undefined reference to `__cxa_allocate_exception' + undefined reference to `std::runtime_error::runtime_error(char const*)' + ``` + + 契约表用 `system_from_graph()` 决定要不要取载荷的 C++ 归档 —— 又是那个跨两层的 + OR。一个「内核接口来自包、而 C 库与 C++ 运行时都来自载荷」的程序本该由那些归档 + 服务,OR 却说不是,`-nostdlib++` 于是砍掉了它要用的那一份。 + + ⚠️ **这一处在两个方向上都错过。** 最初是 `targetCxxRuntime`,对 C 程序失败 + (它没有 C++ 运行时,答「否」被读成「载荷的是对的」);#486 换成 OR,又矫枉过正。 + **C 库才是决定它的那一层** —— 理由 `check_layering` 早已反向陈述:载荷的 C++ + 运行时是对着载荷的 C 库配置的,所以当且仅当那份 C 库在用时它才可用。 + +- **⭐ `build.mcpp` 的 `PATH` 前置 mcpp 自己的工具目录。** + + ``` + PATH=: + ``` + + mcpp 把工具装进自己的 sub-OS(写下这条时那台机器上有 221 个),而此前 + build.mcpp 继承的 PATH 里**一个都没有**。它想用某个工具,只能像 shell 脚本 + 一样去问 PATH —— 而 `command -v` 回答的是「这台机器有什么」,不是「这次构建 + 用什么」。 + + 实测代价:`command -v qemu-system-riscv64` 命中一个 shim,执行时答 + `[error] qemu-system-riscv64 is not installed in this subos` —— 找到了、 + 报告为存在、却跑不了,而真正的那份就在 mcpp 自己的目录里,不在 PATH 上。 + + ⚠️ **前置而非替换。** build.mcpp 合理地会调 `git`、`python3`、shell,这些 + mcpp 都不提供;只有 mcpp 的目录的 PATH 会把它们全部弄坏。前置让隔离的那份 + 成为默认答案,宿主留在后面仍可达。 + + ⭐ 解析**只发生一次**:`toolsBinDir` 在 `get_cfg` 之后算好,交给 + `fill_target_build_env` —— 而不是在它的两个调用点各算一遍。本次发布修的三条 + 缺陷全部来自「一个事实在多处各自推导」,这个字段不再添一处。 + ### 测试 - 五条单元测试,**按 `Origin` 的四个值各一条**,外加一条把缺陷本身写成断言 diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 7986bb96..cd8a3d2c 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -482,6 +482,30 @@ These values are folded into the re-run key **unconditionally** — changing the target, profile, or feature set re-runs the program without any `rerun-if-env-changed` declaration. +### `PATH` — this build system's tools first (mcpp 2026.8.25.1+) + +The child's `PATH` is the one mcpp was started with, **prefixed** with the +directory mcpp installs its own tools into. A build program that looks up a +tool therefore finds the copy this build system placed there, and still reaches +anything else the machine has. + +``` +PATH=: +``` + +Why it is a prefix and not a replacement: a build program legitimately calls +`git`, `python3` or a shell, none of which mcpp ships. Front position makes the +isolated copy the default answer; the host stays reachable behind it. + +⚠️ **`command -v` answers about the machine, not about this build.** Before +this, a program asking `PATH` for a tool mcpp had installed could get an +unrelated one — measured on `qemu-system-riscv64`, where the answer was a shim +that reports "is not installed in this subos" when executed, while the working +copy sat in mcpp's own directory and was not on `PATH` at all. + +The directory is empty on an installation that has no sub-OS yet, and `PATH` is +then left exactly as inherited. + ## Dependencies' build.mcpp (mcpp 0.0.95+) A dependency that ships a `build.mcpp` gets it compiled and run too (the diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 887e86d8..9a94a5e9 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -105,6 +105,30 @@ struct BuildProgramEnv { // the same re-run key: a rebuilt tool re-runs the program that uses it, // with no `rerun-if-changed` needed from the author. std::vector> toolPaths; + // ⭐⭐ THE DIRECTORY THIS BUILD SYSTEM'S OWN TOOLS ARE IN, PUT AT THE FRONT + // OF THE CHILD'S `PATH`. + // + // A build program that wants a tool has, until this field, had to find it + // the way any shell script would — and `command -v` answers about the + // machine, not about this build. Measured 2026-08-25: mcpp installs + // `qemu-system-riscv64` into its own subos, and a probe that asked PATH + // got an xlings shim that answers, when run, + // + // [error] qemu-system-riscv64 is not installed in this subos (_) + // + // — found, reported as present, and unable to execute. The 221 programs + // mcpp had installed on purpose were not on that PATH at all. + // + // ⚠️ PREPENDED, NOT SUBSTITUTED. A build program legitimately reaches for + // things this build system does not ship — `git`, `python3`, a shell — and + // a PATH containing only mcpp's directory would break every one of them for + // the sake of a guarantee nobody asked for. Front position is what makes + // the isolated copy the default answer; the host stays reachable behind it. + // + // This is the same division of labour as `toolchainDir` and `compilerId` + // above: a package should be able to ASK rather than guess, and the thing + // that knows the answer is the one that installed the tools. + std::string toolsBin; // #355 step 5: dependency-provided modules to compile FOR THE HOST and make // importable from this build.mcpp — reusable build rules distributed as // ordinary mcpp packages (`import mcpp.rules.protobuf;`) instead of a @@ -412,6 +436,24 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv // append the platform's exe suffix itself, and a tool's adjacent DATA // (protoc's well-known .proto files, say) lives in the package tree, which // dep_dir() already exposes. + // ── The child's PATH, with this build system's own tools in front ─────── + // + // See `BuildProgramEnv::toolsBin` for why this exists and why it is a + // prefix rather than a replacement. + // + // ⚠️ THE INHERITED VALUE IS READ HERE AND NOT ASSUMED. `extraEnv` replaces + // a variable outright in the child, so writing only mcpp's directory would + // silently be the substitution this deliberately is not. + if (!env.toolsBin.empty()) { + const char* inherited = std::getenv("PATH"); + std::string path = env.toolsBin; + if (inherited && *inherited) { + path += mcpp::platform::env::path_list_separator(); + path += inherited; + } + e.emplace_back("PATH", path); + } + for (auto const& [var, path] : env.toolPaths) { auto [it, inserted] = depVarValue.try_emplace(var, path); if (inserted) { diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index bac66cd9..5d62a941 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -854,8 +854,19 @@ mcpp::platform::process::RunResult run_with_network_retry( } void fill_target_build_env(mcpp::build::BuildProgramEnv& e, - const mcpp::toolchain::Toolchain* tc) + const mcpp::toolchain::Toolchain* tc, + const std::string& toolsBin) { + // ⭐ ONE CALL, AND THAT IS THE POINT OF THIS FUNCTION. + // + // Every answer a build program would otherwise hardcode is filled here, so + // that the two call sites below cannot drift apart. `toolsBin` arrives as + // an argument rather than being resolved here because this is a free + // function with no configuration in scope — and resolving it twice at the + // call sites is exactly the shape that produced three regressions in + // #486: one fact, derived in more than one place, agreeing until it did + // not. + e.toolsBin = toolsBin; e.toolchainDir = (tc && !tc->binaryPath.empty()) ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; e.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{}; @@ -1280,6 +1291,28 @@ prepare_build(bool print_fingerprint, return &*cfg_opt; }; + // ⭐ WHERE THIS BUILD SYSTEM'S OWN TOOLS ARE, RESOLVED ONCE. + // + // Read once here rather than at each `fill_target_build_env` call, because + // there are two of them (root package and dependency) and a value derived + // separately at each is a value that can disagree. That is not a + // hypothetical shape in this file: three defects fixed in this release + // came from one fact being computed in more than one place — see + // `TargetSide::c_library_is_the_payloads` and the note in + // `cache_key.cppm`. + // + // Through the config resolver, not `~/.mcpp`: `MCPP_HOME` moves this + // directory, and every other consumer of it (doctor, resources) reads it + // the same way. Empty when the directory does not exist — a fresh + // installation has no sub-OS yet, and `contract_env` then leaves `PATH` + // untouched rather than prefixing a path to nothing. + std::string toolsBinDir; + if (auto c = get_cfg(/*requireBootstrap=*/false)) { + auto bin = (*c)->xlingsHome() / "subos" / "default" / "bin"; + std::error_code tec; + if (std::filesystem::is_directory(bin, tec)) toolsBinDir = bin.string(); + } + // Resolve one exact runtime contract before resolving/fixing a toolchain. // The fixup is itself a consumer of RuntimeBinding: doing it first would // recreate #392 by letting directory order choose a libc and only later @@ -5541,7 +5574,7 @@ prepare_build(bool print_fingerprint, // The payload ROOT (not the driver), the target's C library, and the // three answers that keep a board package from hardcoding a toolchain // or a libc. All four in one call — see fill_target_build_env. - fill_target_build_env(bpEnv, tc ? &*tc : nullptr); + fill_target_build_env(bpEnv, tc ? &*tc : nullptr, toolsBinDir); bpEnv.profile = effectiveProfile; bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp" / "deps" @@ -6018,7 +6051,7 @@ prepare_build(bool print_fingerprint, // The payload ROOT (not the driver), the target's C library, and the // three answers that keep a board package from hardcoding a toolchain // or a libc. All four in one call — see fill_target_build_env. - fill_target_build_env(bpEnv, tc ? &*tc : nullptr); + fill_target_build_env(bpEnv, tc ? &*tc : nullptr, toolsBinDir); bpEnv.profile = effectiveProfile; // Set explicitly rather than relying on build_dir()'s root-relative // default: under BuildOverrides::work_dir the package root is shared diff --git a/tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh b/tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh new file mode 100755 index 00000000..b785a87e --- /dev/null +++ b/tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# A build program looks up a tool and finds the one mcpp installed. +# +# ⚠️ IT USED TO FIND WHATEVER THE MACHINE HAD, AND `command -v` CANNOT TELL THE +# DIFFERENCE. +# +# mcpp installs its tools into its own sub-OS — 221 programs on the machine +# this was written on, cross-compilers and emulators among them — and until +# 2026.8.25.1 none of them were on the PATH a build program inherited. +# Measured with a build program that printed its own PATH: +# +# /home/…/.xlings/data/xpkgs/xim-x-claude/2.1.222 +# /home/…/mcpp/.xlings/subos/_/bin +# /home/…/.xlings/subos/current/bin +# subos/bin in PATH: NO +# +# The concrete cost, from the same day: `command -v qemu-system-riscv64` +# returned an xlings shim that answers, when run, +# +# [error] qemu-system-riscv64 is not installed in this subos (_) +# +# — found, reported present, unable to execute, while the real one sat in +# mcpp's own directory unreachable. +# +# ⭐ PREPENDED, NOT SUBSTITUTED, AND THIS FILE ASSERTS BOTH HALVES. A build +# program legitimately reaches for things mcpp does not ship, so the host's +# entries must survive behind mcpp's. A test that only checked the first entry +# would pass on a PATH that had thrown the rest away. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/app/src" +cd "$work/app" + +cat > mcpp.toml <<'TOML' +[package] +name = "pathprobe" +version = "0.1.0" +TOML +printf 'int main() { return 0; }\n' > src/main.cpp + +# ⚠️ A NON-ZERO EXIT, BECAUSE THAT IS WHAT MAKES THE OUTPUT VISIBLE. mcpp +# prints a build program's stdout only when it fails — a probe that succeeds +# says nothing, which is a property of the protocol and not of this test. +cat > build.mcpp <<'CPP' +import std; + +int main() { + const char* p = std::getenv("PATH"); + std::string_view path(p ? p : ""); + std::string_view first; + for (auto part : std::views::split(path, ':')) { + first = std::string_view(part); + break; + } + std::println("PROBE_FIRST={}", first); + std::println("PROBE_ENTRIES={}", std::ranges::count(path, ':') + 1); + return 1; +} +CPP + +out="$("$MCPP" build 2>&1 || true)" + +first="$(printf '%s\n' "$out" | grep -oP 'PROBE_FIRST=\K.*' | head -1)" +entries="$(printf '%s\n' "$out" | grep -oP 'PROBE_ENTRIES=\K[0-9]+' | head -1)" + +if [ -z "$first" ]; then + echo "SKIP: the build program did not report — it may not have run here" + printf '%s\n' "$out" | grep -iE 'error' | head -3 + exit 0 +fi + +case "$first" in + */subos/default/bin) + echo " ok the first PATH entry is this build system's own tools: $first" ;; + *) + echo "FAIL: the first PATH entry is not mcpp's tools directory" + echo " got: $first" + exit 1 ;; +esac + +# ⭐ AND THE HOST IS STILL BEHIND IT. One entry means the inherited PATH was +# replaced rather than extended, which would break every build program that +# calls `git`, `python3` or a shell. +if [ "${entries:-1}" -gt 1 ]; then + echo " ok the inherited PATH survives behind it ($entries entries)" +else + echo "FAIL: PATH was replaced, not prefixed — only $entries entry" + exit 1 +fi + +echo "OK: a build program finds this build system's tools first, and the host's after" From f3b9f2f54a9ad8340b6b67dd352d3a76407e372d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:08:18 +0800 Subject: [PATCH 10/16] build.mcpp: the declaration puts an environment in front, not the build system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit prepended mcpp's shared `subos/default/bin` to every build program's PATH. That makes what a build sees depend on what else has been installed on the machine: two projects on one machine agree with each other, and the same project on two machines does not. `[xlings].subos` already answers "which environment does this project build in" — it is what decides the C library the project links against, and `mcpp::xlings::runtime` calls itself the sole project runtime-selection policy. This delivers that same resolved answer to one more consumer: PATH=: A project that declares nothing comes out byte-for-byte unchanged. No new decision point. `projectSubosBin` is derived once, from `RuntimeBinding::subosDir`, right where the binding is resolved; the two delivery sites read it. The per-package payload paths a build program may also need are answered separately by `MCPP_XPKG_*_DIR` — a different question that keeps a different answer. e2e 290 asserts both directions, because only one of them is the feature: prepending unconditionally passes the "declared" half, and that is the design being withdrawn here. Adds examples/07-project-subos/ and docs chapter 17. --- CHANGELOG.md | 38 +++-- docs/07-build-mcpp.md | 41 ++++-- docs/17-the-project-environment.md | 134 ++++++++++++++++++ docs/README.md | 1 + docs/zh/07-build-mcpp.md | 30 ++++ docs/zh/17-the-project-environment.md | 112 +++++++++++++++ docs/zh/README.md | 1 + examples/07-project-subos/README.md | 98 +++++++++++++ examples/07-project-subos/build.mcpp | 23 +++ examples/07-project-subos/mcpp.toml | 25 ++++ examples/07-project-subos/src/main.cpp | 2 + src/build/build_program.cppm | 59 ++++---- src/build/prepare.cppm | 66 ++++----- ...ram_sees_this_build_systems_tools_first.sh | 95 ------------- ...eclaration_puts_an_environment_in_front.sh | 118 +++++++++++++++ 15 files changed, 656 insertions(+), 187 deletions(-) create mode 100644 docs/17-the-project-environment.md create mode 100644 docs/zh/17-the-project-environment.md create mode 100644 examples/07-project-subos/README.md create mode 100644 examples/07-project-subos/build.mcpp create mode 100644 examples/07-project-subos/mcpp.toml create mode 100644 examples/07-project-subos/src/main.cpp delete mode 100755 tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh create mode 100755 tests/e2e/290_the_declaration_puts_an_environment_in_front.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 432a4407..d3e521f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,28 +93,33 @@ **C 库才是决定它的那一层** —— 理由 `check_layering` 早已反向陈述:载荷的 C++ 运行时是对着载荷的 C 库配置的,所以当且仅当那份 C 库在用时它才可用。 -- **⭐ `build.mcpp` 的 `PATH` 前置 mcpp 自己的工具目录。** +- **⭐ `build.mcpp` 的 `PATH` 前置项目声明的那个环境。** ``` - PATH=: + PATH=<被声明环境的 bin>: ``` - mcpp 把工具装进自己的 sub-OS(写下这条时那台机器上有 221 个),而此前 - build.mcpp 继承的 PATH 里**一个都没有**。它想用某个工具,只能像 shell 脚本 - 一样去问 PATH —— 而 `command -v` 回答的是「这台机器有什么」,不是「这次构建 - 用什么」。 + 构建程序想用某个工具,此前只能像 shell 脚本一样去问 `PATH` —— 而 `command -v` + 回答的是「这台机器有什么」,不是「这次构建用什么」。实测代价: + `command -v qemu-system-riscv64` 命中一个 shim,执行时答 + `[error] qemu-system-riscv64 is not installed in this subos` —— 找到了、报告 + 为存在、却跑不了,而可用的那份就在项目自己的环境里,不在 `PATH` 上。 - 实测代价:`command -v qemu-system-riscv64` 命中一个 shim,执行时答 - `[error] qemu-system-riscv64 is not installed in this subos` —— 找到了、 - 报告为存在、却跑不了,而真正的那份就在 mcpp 自己的目录里,不在 PATH 上。 + ⚠️ **只对声明了 `[xlings].subos` 的项目生效,没声明的逐字节不变。** 一个更早 + 的草案无条件前置 mcpp 共享的 `subos/default/bin`,那会让「构建看见什么」取决 + 于这台机器上还装过什么 —— 同一台机器上的两个项目彼此一致,而同一个项目在两台 + 机器上不一致。是声明本身把它放到前面的。 - ⚠️ **前置而非替换。** build.mcpp 合理地会调 `git`、`python3`、shell,这些 - mcpp 都不提供;只有 mcpp 的目录的 PATH 会把它们全部弄坏。前置让隔离的那份 - 成为默认答案,宿主留在后面仍可达。 + ⚠️ **前置而非替换。** 构建程序合理地会调 `git`、`python3`、shell,这些都不在 + SubOS 里;只有被声明目录的 `PATH` 会把它们全部弄坏。 - ⭐ 解析**只发生一次**:`toolsBinDir` 在 `get_cfg` 之后算好,交给 - `fill_target_build_env` —— 而不是在它的两个调用点各算一遍。本次发布修的三条 - 缺陷全部来自「一个事实在多处各自推导」,这个字段不再添一处。 + ⭐ **没有新的决定点。** `mcpp::xlings::runtime` 早就是「项目用哪个 SubOS」的 + 唯一策略,`RuntimeBinding::subosDir` 是它已解析的答案;本次只是把这个答案多交 + 付给一个消费者。`projectSubosBin` 在绑定解析后算**一次**,两个交付点各自取用。 + 本次发布修的三条缺陷全部来自「一个事实在多处各自推导」。各包的载荷路径由 + `MCPP_XPKG_*_DIR` 另行回答,与 `PATH` 是两个问题。 + + 新增 `examples/07-project-subos/` 与 [第 17 章](docs/17-the-project-environment.md)。 ### 测试 @@ -138,6 +143,9 @@ | 287 | 交叉到 aarch64,断言 outline-atomics 辅助函数与 LSE 指令数,qemu 真跑 | | 288 | 无 OS 无 C 库,断言报告里**没有 c-abi 那一行**,并在 qemu 里真启动 | | 289 | **一台宿主横扫四个目标** —— 这个体系本就是通用交叉构建,传统栈要六个 runner 的覆盖,这里一个循环 | + | 290 | 声明把环境放到 `PATH` 前面,**而且只有声明会** —— 两个方向各一条断言 | + + 290 的两半只有一半是特性:无条件前置能通过前一半,而那正是被撤回的设计。 ## [2026.8.24.6] — 2026-08-25 diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index cd8a3d2c..4d5488a0 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -482,29 +482,40 @@ These values are folded into the re-run key **unconditionally** — changing the target, profile, or feature set re-runs the program without any `rerun-if-env-changed` declaration. -### `PATH` — this build system's tools first (mcpp 2026.8.25.1+) +### `PATH` — the environment the project declared (mcpp 2026.8.25.1+) -The child's `PATH` is the one mcpp was started with, **prefixed** with the -directory mcpp installs its own tools into. A build program that looks up a -tool therefore finds the copy this build system placed there, and still reaches -anything else the machine has. +A project that declares `[xlings].subos` runs its build programs with that +environment's `bin` at the front of `PATH`: ``` -PATH=: +PATH=: ``` +so a bare command name in a build program resolves inside the environment the +project named, on every machine that builds it. + +⚠️ **Only for projects that declare one.** A project with no `[xlings].subos` +gets the `PATH` mcpp was started with, byte for byte. A shared directory in +front of every project would make what a build sees depend on what else had +been installed on that machine — two projects on one machine would agree with +each other, and the same project on two machines would not. + Why it is a prefix and not a replacement: a build program legitimately calls -`git`, `python3` or a shell, none of which mcpp ships. Front position makes the -isolated copy the default answer; the host stays reachable behind it. +`git`, `python3` or a shell, none of which live in a sub-OS. Front position +makes the declared environment the default answer; the host stays reachable +behind it. ⚠️ **`command -v` answers about the machine, not about this build.** Before -this, a program asking `PATH` for a tool mcpp had installed could get an -unrelated one — measured on `qemu-system-riscv64`, where the answer was a shim -that reports "is not installed in this subos" when executed, while the working -copy sat in mcpp's own directory and was not on `PATH` at all. - -The directory is empty on an installation that has no sub-OS yet, and `PATH` is -then left exactly as inherited. +this, a program asking `PATH` for a declared tool could get an unrelated one — +measured on `qemu-system-riscv64`, where the answer was a shim that reports +"is not installed in this subos" when executed, while a working copy sat in the +project's own environment and was not on `PATH` at all. + +The selection is the one [chapter 8](08-toolchain-internals.md) already +describes — the same declaration that decides which C library the project links +against, delivered to one more consumer. See +[chapter 17](17-the-project-environment.md) for what a declared environment is +and when to want one; `examples/07-project-subos/` is a working project. ## Dependencies' build.mcpp (mcpp 0.0.95+) diff --git a/docs/17-the-project-environment.md b/docs/17-the-project-environment.md new file mode 100644 index 00000000..4e86c43e --- /dev/null +++ b/docs/17-the-project-environment.md @@ -0,0 +1,134 @@ +# 17 - The Project Environment + +A project can declare the environment it builds in. That one declaration +decides which C library the project links against and which tools its build +programs find — so a `mcpp.toml` means the same build on a developer's laptop +and in CI, whatever else those two machines happen to have installed. + +```toml +[xlings] +subos = "tools" +deps = ["xim:qemu-riscv@9.2.4-1"] +``` + +Working project: `examples/07-project-subos/`. + +## 1. What a SubOS is + +A SubOS is a directory that holds a userspace: its own `bin`, its own library +view, its own installed package versions, and a `subos_info` block describing +itself. mcpp treats it as the answer to "what does this project build +against", and it is the only mechanism that answers that question — not the +compiler's path, not `XLINGS_ACTIVE_SUBOS`, not the shell. + +Two kinds exist, and the difference is where the directory lives: + +| Declaration | Directory | Shared with | +|---|---|---| +| none | mcpp's initialized `subos/default` | every project on the machine | +| `subos = "default"` | the same directory, named explicitly | every project on the machine | +| `subos = ""` | `/.mcpp/.xlings/subos//` | nothing | + +The third row is the isolated one. It belongs to the project, it sits beside +the manifest, and removing the project removes it. + +## 2. What the declaration decides + +**The C library.** A payload-first build links against one specific glibc, and +which one is a fact about the project rather than about the machine. Chapter 8 +covers the binding, the degradation rules, and what a SubOS that does not +describe itself does to them. + +**The tools a build program sees** (mcpp 2026.8.25.1+). The declared +environment's `bin` goes to the front of the `PATH` that `build.mcpp` runs +with: + +``` +PATH=: +``` + +A build program that spells `qemu-system-riscv64` as a bare name therefore gets +the copy inside the declared environment. Chapter 7 covers the contract this +rides on. + +⚠️ **Only for projects that declare one.** A project with no `[xlings].subos` +gets the `PATH` mcpp was started with, byte for byte. Putting a shared +directory in front of every project would make what a build sees depend on what +else had been installed on that machine — two projects on one machine would +agree with each other, and the same project on two machines would not. +Declaring it is what puts it there. + +⚠️ **Prefixed, not replaced.** A build program legitimately calls `git`, +`python3` or a shell, and none of those live in a SubOS. Front position makes +the declared environment the default answer; everything else stays reachable +behind it. + +## 3. What the declaration does not decide + +`[xlings] deps` names packages to be present in the environment, and each one's +payload directory is delivered separately as `MCPP_XPKG__DIR`. That is a +different question from `PATH` and stays a different answer: a build program +that needs a package's data files (protoc's well-known `.proto` files, say) +asks for the directory, and one that needs to *run* a program asks `PATH`. + +A dependency's own `[xlings]` declaration is never consulted or propagated. In +a workspace build the workspace root owns the selection; a member's declaration +applies only when that member is built as an independent root. + +## 4. Reading an environment, never creating one + +mcpp resolves a declared name and reads what it finds. A name that does not +resolve is a hard error: + +``` +error: selected SubOS 'tools' does not exist at …/.mcpp/.xlings/subos/tools; +create/bootstrap that environment instead of falling back to active/default +``` + +Falling back to the default or to whatever is active would substitute a +different environment for the one the manifest named, which is precisely what +would make one `mcpp.toml` mean two different builds. Creating and populating +a SubOS is xlings' layer — `xlings subos new` — and mcpp managing SubOS state +would invert that layering. + +An environment that exists but carries no `subos_info` block **degrades rather +than fails**: the runtime binding reports `inconclusive`, no payload-first +binding is available, a note is printed, and the build continues. Chapter 8 +gives the full rule. + +## 5. When a private environment is worth it + +- **A generator whose version changes what it emits.** `protoc`, `flatc`, a + shader compiler: the output is an input to everything downstream, so the + project pins the producer instead of hoping the machine has a compatible one. +- **An emulator a build program runs.** Several bare-metal packages boot an + artefact under QEMU as part of proving it works; which QEMU is part of what + was proven. +- **A project whose CI and developer machines differ**, where neither is wrong + and the build must not notice. +- **Two projects on one machine that need different versions of one tool.** + Sharing a directory means one of them loses; a private environment means the + question does not arise. + +Against that: an isolated environment is a directory that has to be created and +populated before the first build, and mcpp will not do it. A project whose +tools are ordinary and whose versions do not matter is better off declaring +nothing and inheriting the machine's. + +## 6. What belongs somewhere else + +| Need | Where it goes | +|---|---| +| a library the program links | `[dependencies]` | +| the compiler | `[toolchain]`, chapter 3 | +| a host tool a dependency produces | `tools = [...]`, chapter 7 | +| a tool present in the environment | `[xlings] deps` | +| which environment | `[xlings] subos` | + +## 7. Related chapters + +- [7 - build.mcpp](07-build-mcpp.md) — the contract a build program receives, + including the `PATH` it runs with. +- [8 - Toolchain Internals](08-toolchain-internals.md) — runtime selection, + the `RuntimeBinding` snapshot, and the degradation rules. +- [5 - mcpp.toml](05-mcpp-toml.md) — every manifest key, including `[xlings]`. diff --git a/docs/README.md b/docs/README.md index 1487c073..b371328f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ - [14 - The Target Side](14-target-side.md) - [15 - Cross-Compilation Over openkal](15-openkal-cross.md) - [16 - The Target Triple](16-the-target-triple.md) +- [17 - The Project Environment](17-the-project-environment.md) ## Specifications diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 32971c1a..7d2e22f9 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -426,6 +426,36 @@ mcpp 会把它自己构建时用的**同一份** std 模块暂存过来,缓存 这些契约值**无条件**折入重跑键——换 target、换 profile、开关 feature 都会触发重跑, 不需要任何 `rerun-if-env-changed` 声明。 +### `PATH` —— 项目声明的那个环境(mcpp 2026.8.25.1+) + +声明了 `[xlings].subos` 的项目,其构建程序运行时,该环境的 `bin` 在 `PATH` 的 +最前面: + +``` +PATH=<被声明环境的 bin>: +``` + +于是构建程序里的裸名命令,在每一台构建它的机器上都解析到项目点名的那个环境 +里面。 + +⚠️ **只对声明了的项目生效。** 没有 `[xlings].subos` 的项目拿到的是 mcpp 启动时 +的 `PATH`,逐字节不变。把一个共享目录放到每个项目前面,会让「构建看见什么」取 +决于这台机器上还装过什么——同一台机器上的两个项目彼此一致,而同一个项目在两台 +机器上不一致。 + +前置而非替换的理由:构建程序理应会调 `git`、`python3` 或 shell,这些都不在 +SubOS 里。前置让被声明的环境成为默认答案;宿主仍在其后可达。 + +⚠️ **`command -v` 回答的是这台机器,不是这次构建。** 在此之前,构建程序拿 +`PATH` 去问一个被声明过的工具,可能问到无关的那个——实测于 +`qemu-system-riscv64`:答案是一个执行时报「is not installed in this subos」的 +shim,而可用的那份就在项目自己的环境里,根本不在 `PATH` 上。 + +这个选择就是[第 8 章](08-toolchain-internals.md)已经描述的那一个——决定项目链接 +哪个 C 库的同一条声明,多交付给了一个消费者。被声明的环境是什么、什么时候需要 +它,见[第 17 章](17-the-project-environment.md);`examples/07-project-subos/` 是 +一个可运行的工程。 + ## 依赖包的 build.mcpp(mcpp 0.0.95+) 带 `build.mcpp` 的依赖包也会被编译并运行(Cargo `build.rs` 模型——构建一个包 diff --git a/docs/zh/17-the-project-environment.md b/docs/zh/17-the-project-environment.md new file mode 100644 index 00000000..37077e15 --- /dev/null +++ b/docs/zh/17-the-project-environment.md @@ -0,0 +1,112 @@ +# 17 - 项目环境 + +项目可以声明自己在哪个环境里构建。这一条声明决定项目链接哪个 C 库、以及它的 +构建程序找到哪些工具——于是同一份 `mcpp.toml` 在开发机和 CI 上是同一个构建, +不论这两台机器上还装了别的什么。 + +```toml +[xlings] +subos = "tools" +deps = ["xim:qemu-riscv@9.2.4-1"] +``` + +可运行的工程:`examples/07-project-subos/`。 + +## 1. SubOS 是什么 + +SubOS 是一个目录,里面是一份用户态:它自己的 `bin`、自己的库视图、自己那套已 +装包版本,以及一个自述用的 `subos_info` 块。mcpp 把它当作「这个项目对着什么 +构建」的答案,而且是回答这个问题的唯一机制——不是编译器所在路径,不是 +`XLINGS_ACTIVE_SUBOS`,也不是当前 shell。 + +存在两种,区别在于目录落在哪里: + +| 声明 | 目录 | 与谁共享 | +|---|---|---| +| 未声明 | mcpp 初始化的 `subos/default` | 机器上的每个项目 | +| `subos = "default"` | 同一个目录,只是被显式点名 | 机器上的每个项目 | +| `subos = ""` | `/.mcpp/.xlings/subos//` | 不共享 | + +第三行是隔离的那种。它属于该项目,就放在清单旁边,删掉项目它也随之消失。 + +## 2. 这条声明决定什么 + +**C 库。** payload-first 的构建链接的是某一个确定的 glibc,而「哪一个」是项目 +的性质而非机器的性质。第 8 章讲绑定本身、降级规则,以及一个不自述的 SubOS 会 +让这些规则变成什么。 + +**构建程序看见哪些工具**(mcpp 2026.8.25.1+)。被声明环境的 `bin` 放在 +`build.mcpp` 运行时 `PATH` 的最前面: + +``` +PATH=<被声明环境的 bin>: +``` + +因此构建程序里把 `qemu-system-riscv64` 写成裸名,拿到的就是那个环境里的副本。 +这条通道的契约见第 7 章。 + +⚠️ **只对声明了的项目生效。** 没有 `[xlings].subos` 的项目拿到的是 mcpp 启动时 +的 `PATH`,逐字节不变。把一个共享目录放到每个项目前面,会让「构建看见什么」 +取决于这台机器上还装过什么——同一台机器上的两个项目彼此一致,而同一个项目在 +两台机器上不一致。是声明本身把它放到了前面。 + +⚠️ **前置而非替换。** 构建程序理应会调 `git`、`python3` 或 shell,这些都不在 +SubOS 里。前置让被声明的环境成为默认答案;其余的仍在它后面可达。 + +## 3. 这条声明不决定什么 + +`[xlings] deps` 声明的是「环境里要有哪些包」,而每个包的载荷目录另有通道交付, +即 `MCPP_XPKG__DIR`。这与 `PATH` 是两个问题,答案也保持分开:需要某个包 +的数据文件(比如 protoc 自带的 well-known `.proto`)的构建程序问目录,需要 +**运行**某个程序的构建程序问 `PATH`。 + +依赖自己的 `[xlings]` 声明从不被读取也不被传播。工作区构建中由工作区根持有这个 +选择;成员的声明只在该成员作为独立根被构建时生效。 + +## 4. 只读取环境,从不创建环境 + +mcpp 解析被声明的名字,并读取它找到的东西。解析不到的名字是硬失败: + +``` +error: selected SubOS 'tools' does not exist at …/.mcpp/.xlings/subos/tools; +create/bootstrap that environment instead of falling back to active/default +``` + +回退到 default 或回退到当前活跃的那个,等于用另一个环境顶替清单点名的那个,而 +这恰恰会让一份 `mcpp.toml` 意味着两个不同的构建。创建并填充 SubOS 属于 xlings +这一层——`xlings subos new`——mcpp 去管理 SubOS 状态则是把分层倒置。 + +一个存在但**不携带 `subos_info` 块**的环境是**降级而非失败**:运行时绑定报 +`inconclusive`,没有 payload-first 绑定可用,打印一条提示,构建继续。完整规则见 +第 8 章。 + +## 5. 什么时候值得用私有环境 + +- **产物取决于版本的代码生成器。** `protoc`、`flatc`、着色器编译器:它的输出是 + 下游一切的输入,所以项目钉住生产者,而不是指望机器上那个恰好兼容。 +- **构建程序要运行的模拟器。** 若干裸机包把「在 QEMU 里启动产物」作为验证的一 + 部分;是哪个 QEMU 属于「验证了什么」的一部分。 +- **CI 与开发机不一致的项目**,两边都没错,而构建不该察觉到差异。 +- **同一台机器上两个项目需要同一工具的不同版本。** 共享目录意味着必有一方落 + 败;私有环境让这个问题不成立。 + +代价一侧:隔离环境是一个必须在首次构建前创建并填充的目录,而 mcpp 不会代劳。 +工具很普通、版本也无所谓的项目,不声明、直接继承机器的那份更划算。 + +## 6. 什么该写在别处 + +| 需求 | 写在哪里 | +|---|---| +| 程序链接的库 | `[dependencies]` | +| 编译器 | `[toolchain]`,第 3 章 | +| 依赖产出的宿主工具 | `tools = [...]`,第 7 章 | +| 环境里要有的工具 | `[xlings] deps` | +| 用哪个环境 | `[xlings] subos` | + +## 7. 相关章节 + +- [7 - build.mcpp](07-build-mcpp.md) —— 构建程序收到的契约,含它运行时的 + `PATH`。 +- [8 - 工具链内部](08-toolchain-internals.md) —— 运行时选择、`RuntimeBinding` + 快照与降级规则。 +- [5 - mcpp.toml](05-mcpp-toml.md) —— 全部清单键,含 `[xlings]`。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 0a04259f..5db3b231 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -19,6 +19,7 @@ - [14 - 目标侧](14-target-side.md) - [15 - 基于 openkal 的交叉构建](15-openkal-cross.md) - [16 - 目标三元组](16-the-target-triple.md) +- [17 - 项目环境](17-the-project-environment.md) ## 规范文档 diff --git a/examples/07-project-subos/README.md b/examples/07-project-subos/README.md new file mode 100644 index 00000000..517d983b --- /dev/null +++ b/examples/07-project-subos/README.md @@ -0,0 +1,98 @@ +# 07 — The Environment This Project Asked For + +A build program that finds its tools in an environment the project declared, +instead of asking the machine what it happens to have. + +```bash +mcpp build +``` + +``` +warning: project-subos: PATH begins at …/subos/default/bin +warning: project-subos: this project's subos is populated +``` + +## What the section does + +```toml +[xlings] +subos = "default" +deps = ["xim:qemu-riscv@9.2.4-1"] +``` + +`subos` names the environment this project builds in. mcpp already used that +declaration to decide which C library the project links against — one +`mcpp.toml` must not mean different ABIs on different machines — and it now +also puts that environment's `bin` at the front of the `PATH` it runs +`build.mcpp` with. + +So `qemu-system-riscv64`, spelled as a bare name in a build program, resolves +inside the environment the project named. + +## Only for projects that ask + +A project with no `[xlings].subos` gets the `PATH` mcpp was started with, +unchanged. That is deliberate. A build system that put a shared directory in +front of every project would make what a build sees depend on what else had +been installed on that machine — two projects on one machine would agree with +each other, and the same project on two machines would not. + +Declaring it is what puts it there. + +## Why a bare name and not a constructed path + +`MCPP_XPKG_QEMU_RISCV_DIR` gives the payload directory, and a build program can +join `/bin/qemu-system-riscv64` onto it. That works, and it means every build +program in the ecosystem repeats the same joining, each with its own idea of +the layout — one of them will get it wrong on the platform its author does not +have. + +Asking `PATH` is what a program would do anyway. What the declaration changed +is the answer. + +## The host stays reachable + +The directory is **prepended**, not substituted. A build program legitimately +calls `git`, `python3` or a shell, and none of those live in a subos. Front +position makes the declared environment the default answer; everything else is +still behind it. + +## A private environment, not the shared one + +This example names `"default"` so it builds on a clean checkout. The stronger +form is an environment that belongs to the project: + +```toml +[xlings] +subos = "tools" +``` + +which mcpp resolves to `/.mcpp/.xlings/subos/tools/` — its own `bin`, +its own package versions, isolated from every other project on the machine. + +⚠️ **mcpp reads such an environment and never creates one.** A name that does +not resolve is a hard error, not a fallback: + +``` +error: selected SubOS 'tools' does not exist at …/.mcpp/.xlings/subos/tools; +create/bootstrap that environment instead of falling back to active/default +``` + +Substituting a different environment is exactly what would make one `mcpp.toml` +mean two different builds. Creating and populating one is xlings' layer +(`xlings subos new`), and a project that ships one puts the directory beside +its manifest. + +## When to reach for this + +- **A generator whose version changes what it emits.** `protoc`, `flatc`, a + shader compiler: the output is an input to everything downstream, so the + project pins the producer rather than hoping. +- **An emulator a build program runs.** Several bare-metal packages boot an + artefact under QEMU as part of proving it; which QEMU is part of what was + proven. +- **A project that must build the same way on a developer's machine and in + CI**, where the two have different things installed and neither is wrong. + +What it is *not* for: libraries the program links, which are `[dependencies]`, +and the compiler itself, which is `[toolchain]`. diff --git a/examples/07-project-subos/build.mcpp b/examples/07-project-subos/build.mcpp new file mode 100644 index 00000000..e7ef2f02 --- /dev/null +++ b/examples/07-project-subos/build.mcpp @@ -0,0 +1,23 @@ +import std; + +// A build program that answers one question: whose tools does this build see? +// +// ⚠️ IT DOES NOT CONSTRUCT A PATH TO ONE. `MCPP_XPKG_QEMU_RISCV` gives the +// payload directory, and a program can join `/bin/qemu-system-riscv64` onto it +// — that works, and it means every build program in the ecosystem repeats the +// same joining, each with its own idea of the layout. Asking `PATH` is what a +// program would do anyway; what `[xlings].subos` changed is the answer. +int main() { + std::string_view path(std::getenv("PATH") ? std::getenv("PATH") : ""); + std::string_view first; + for (auto part : std::views::split(path, ':')) { first = std::string_view(part); break; } + + // Reported through the advisory channel, so a successful build still says + // it. mcpp prints a build program's stdout only when the program fails, + // and a probe whose evidence only appears on failure has none. + std::println("mcpp:warning=PATH begins at {}", first); + std::println("mcpp:warning=this project's subos is {}", + std::getenv("MCPP_XPKG_QEMU_RISCV_DIR") + ? "populated" : "not installed yet"); + return 0; +} diff --git a/examples/07-project-subos/mcpp.toml b/examples/07-project-subos/mcpp.toml new file mode 100644 index 00000000..e0c4be4b --- /dev/null +++ b/examples/07-project-subos/mcpp.toml @@ -0,0 +1,25 @@ +[package] +name = "project-subos" +version = "0.1.0" +description = "A build program that finds its tools in an environment the project declared" + +# ⭐ THIS SECTION IS WHAT THE DIRECTORY IS FOR. +# +# `subos` names the environment this project builds in. mcpp already used that +# declaration to decide which C library the project links against, and it now +# also puts that environment's `bin` at the front of the `PATH` it runs +# `build.mcpp` with — so a bare command name in a build program resolves inside +# the environment the project named. +# +# ⚠️ A PROJECT THAT DECLARES NO `subos` GETS THE `PATH` mcpp WAS STARTED WITH. +# There is no shared directory quietly in front of every build; declaring one +# is what puts it there. +# +# ⚠️ `"default"` SO THIS EXAMPLE BUILDS ON A CLEAN CHECKOUT. The isolated form +# is a private name — `subos = "tools"`, resolved to +# `/.mcpp/.xlings/subos/tools/` — and mcpp READS such an environment +# but never creates one, so a name nobody has bootstrapped is a hard error by +# design. See README.md. +[xlings] +subos = "default" +deps = ["xim:qemu-riscv@9.2.4-1"] diff --git a/examples/07-project-subos/src/main.cpp b/examples/07-project-subos/src/main.cpp new file mode 100644 index 00000000..c9fbea86 --- /dev/null +++ b/examples/07-project-subos/src/main.cpp @@ -0,0 +1,2 @@ +#include +int main() { std::printf("built\n"); } diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 9a94a5e9..eb3e4546 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -105,29 +105,37 @@ struct BuildProgramEnv { // the same re-run key: a rebuilt tool re-runs the program that uses it, // with no `rerun-if-changed` needed from the author. std::vector> toolPaths; - // ⭐⭐ THE DIRECTORY THIS BUILD SYSTEM'S OWN TOOLS ARE IN, PUT AT THE FRONT - // OF THE CHILD'S `PATH`. + // ⭐⭐ THE `bin` OF THIS PROJECT'S OWN SubOS, AT THE FRONT OF THE CHILD'S + // `PATH`. Empty for a project that has not declared one, and an empty + // value means the child's `PATH` is left exactly as mcpp received it. // - // A build program that wants a tool has, until this field, had to find it - // the way any shell script would — and `command -v` answers about the - // machine, not about this build. Measured 2026-08-25: mcpp installs - // `qemu-system-riscv64` into its own subos, and a probe that asked PATH - // got an xlings shim that answers, when run, + // A build program that needs a tool has, until this field, had to ask + // `PATH` the way a shell script would — and `PATH` answers about the + // MACHINE, not about this build. Measured 2026-08-25: a probe for + // `qemu-system-riscv64` found one that answers, when executed, // // [error] qemu-system-riscv64 is not installed in this subos (_) // - // — found, reported as present, and unable to execute. The 221 programs - // mcpp had installed on purpose were not on that PATH at all. + // — present, and unable to run — while the copy the project had declared + // sat in its own payload directory, reachable only by a path the program + // would have had to construct itself. // - // ⚠️ PREPENDED, NOT SUBSTITUTED. A build program legitimately reaches for - // things this build system does not ship — `git`, `python3`, a shell — and - // a PATH containing only mcpp's directory would break every one of them for - // the sake of a guarantee nobody asked for. Front position is what makes - // the isolated copy the default answer; the host stays reachable behind it. + // ⚠️ THE PROJECT'S SubOS, NEVER A GLOBAL ONE. An earlier draft put this + // build system's shared `subos/default/bin` in front, which makes what a + // build sees depend on what else has been installed on the machine — two + // projects on one machine would agree with each other, and the same + // project on two machines would not. A declared `[xlings].subos` is a + // directory that belongs to the project and travels with it. // - // This is the same division of labour as `toolchainDir` and `compilerId` - // above: a package should be able to ASK rather than guess, and the thing - // that knows the answer is the one that installed the tools. + // ⚠️ WHO DECIDES IS NOT DECIDED HERE. `mcpp::xlings::runtime` is the sole + // project runtime-selection policy and `RuntimeBinding::subosDir` is its + // resolved answer; this field carries that answer to the child. Deriving + // it a second time — from the manifest, from `[xlings] deps`, from the + // config — is how a build ends up with two subos and no way to say which + // one it used. + // + // ⚠️ PREPENDED, NOT SUBSTITUTED. A build program legitimately calls `git`, + // `python3` or a shell, none of which arrive this way. std::string toolsBin; // #355 step 5: dependency-provided modules to compile FOR THE HOST and make // importable from this build.mcpp — reusable build rules distributed as @@ -436,18 +444,17 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv // append the platform's exe suffix itself, and a tool's adjacent DATA // (protoc's well-known .proto files, say) lives in the package tree, which // dep_dir() already exposes. - // ── The child's PATH, with this build system's own tools in front ─────── - // - // See `BuildProgramEnv::toolsBin` for why this exists and why it is a - // prefix rather than a replacement. + // ── The child's PATH, with the project's own SubOS in front ──────────── // - // ⚠️ THE INHERITED VALUE IS READ HERE AND NOT ASSUMED. `extraEnv` replaces - // a variable outright in the child, so writing only mcpp's directory would - // silently be the substitution this deliberately is not. + // See `BuildProgramEnv::toolsBin` for why this exists, why it is a prefix + // rather than a replacement, and why the decision is not made here. if (!env.toolsBin.empty()) { - const char* inherited = std::getenv("PATH"); std::string path = env.toolsBin; - if (inherited && *inherited) { + // ⚠️ THE INHERITED VALUE IS READ HERE AND NOT ASSUMED. `extraEnv` + // replaces a variable outright in the child, so writing only the + // project's own directory would silently be the substitution this + // deliberately is not. + if (const char* inherited = std::getenv("PATH"); inherited && *inherited) { path += mcpp::platform::env::path_list_separator(); path += inherited; } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 5d62a941..e410aaf3 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -854,19 +854,8 @@ mcpp::platform::process::RunResult run_with_network_retry( } void fill_target_build_env(mcpp::build::BuildProgramEnv& e, - const mcpp::toolchain::Toolchain* tc, - const std::string& toolsBin) + const mcpp::toolchain::Toolchain* tc) { - // ⭐ ONE CALL, AND THAT IS THE POINT OF THIS FUNCTION. - // - // Every answer a build program would otherwise hardcode is filled here, so - // that the two call sites below cannot drift apart. `toolsBin` arrives as - // an argument rather than being resolved here because this is a free - // function with no configuration in scope — and resolving it twice at the - // call sites is exactly the shape that produced three regressions in - // #486: one fact, derived in more than one place, agreeing until it did - // not. - e.toolsBin = toolsBin; e.toolchainDir = (tc && !tc->binaryPath.empty()) ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; e.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{}; @@ -1291,28 +1280,6 @@ prepare_build(bool print_fingerprint, return &*cfg_opt; }; - // ⭐ WHERE THIS BUILD SYSTEM'S OWN TOOLS ARE, RESOLVED ONCE. - // - // Read once here rather than at each `fill_target_build_env` call, because - // there are two of them (root package and dependency) and a value derived - // separately at each is a value that can disagree. That is not a - // hypothetical shape in this file: three defects fixed in this release - // came from one fact being computed in more than one place — see - // `TargetSide::c_library_is_the_payloads` and the note in - // `cache_key.cppm`. - // - // Through the config resolver, not `~/.mcpp`: `MCPP_HOME` moves this - // directory, and every other consumer of it (doctor, resources) reads it - // the same way. Empty when the directory does not exist — a fresh - // installation has no sub-OS yet, and `contract_env` then leaves `PATH` - // untouched rather than prefixing a path to nothing. - std::string toolsBinDir; - if (auto c = get_cfg(/*requireBootstrap=*/false)) { - auto bin = (*c)->xlingsHome() / "subos" / "default" / "bin"; - std::error_code tec; - if (std::filesystem::is_directory(bin, tec)) toolsBinDir = bin.string(); - } - // Resolve one exact runtime contract before resolving/fixing a toolchain. // The fixup is itself a consumer of RuntimeBinding: doing it first would // recreate #392 by letting directory order choose a libc and only later @@ -1334,6 +1301,31 @@ prepare_build(bool print_fingerprint, if (!runtimeBindingSnapshot.note.empty()) mcpp::ui::info("Runtime", runtimeBindingSnapshot.note); } + // ⭐⭐ THE `bin` THIS PROJECT'S BUILD PROGRAMS SEE FIRST — derived ONCE, + // here, from the selection that has just been resolved. + // + // Empty unless the manifest declared `[xlings].subos`. That is deliberate: + // prepending the SHARED `subos/default/bin` would make what a build sees + // depend on what else has been installed on this machine, so a project + // that has not asked for an environment of its own gets the `PATH` mcpp + // was started with, byte for byte. + // + // ⚠️ NOT RE-DERIVED AT THE TWO DELIVERY SITES BELOW, AND NOT FROM + // `[xlings] deps`. `mcpp::xlings::runtime` is the sole runtime-selection + // policy and `RuntimeBinding::subosDir` is its resolved answer; a second + // derivation is how a build ends up with two subos and no way to say which + // one it used. The per-package payload paths a program may also need are + // already answered, separately, by `MCPP_XPKG_*_DIR`. + const std::string projectSubosBin = [&]() -> std::string { + using Mode = mcpp::xlings::runtime::RuntimeSelection::Mode; + if (runtimeBindingSnapshot.selection.mode != Mode::NamedSubos) + return {}; + auto bin = runtimeBindingSnapshot.subosDir / "bin"; + std::error_code ec; + if (!std::filesystem::is_directory(bin, ec)) return {}; + return bin.string(); + }(); + const auto runtimePayload = runtimeBindingSnapshot.libc.value_or(""); const auto runtimeLibDir = runtimeBindingSnapshot.libraryDirs.empty() ? std::filesystem::path{} : runtimeBindingSnapshot.libraryDirs.front(); @@ -5574,7 +5566,8 @@ prepare_build(bool print_fingerprint, // The payload ROOT (not the driver), the target's C library, and the // three answers that keep a board package from hardcoding a toolchain // or a libc. All four in one call — see fill_target_build_env. - fill_target_build_env(bpEnv, tc ? &*tc : nullptr, toolsBinDir); + fill_target_build_env(bpEnv, tc ? &*tc : nullptr); + bpEnv.toolsBin = projectSubosBin; bpEnv.profile = effectiveProfile; bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp" / "deps" @@ -6051,7 +6044,8 @@ prepare_build(bool print_fingerprint, // The payload ROOT (not the driver), the target's C library, and the // three answers that keep a board package from hardcoding a toolchain // or a libc. All four in one call — see fill_target_build_env. - fill_target_build_env(bpEnv, tc ? &*tc : nullptr, toolsBinDir); + fill_target_build_env(bpEnv, tc ? &*tc : nullptr); + bpEnv.toolsBin = projectSubosBin; bpEnv.profile = effectiveProfile; // Set explicitly rather than relying on build_dir()'s root-relative // default: under BuildOverrides::work_dir the package root is shared diff --git a/tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh b/tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh deleted file mode 100755 index b785a87e..00000000 --- a/tests/e2e/290_build_program_sees_this_build_systems_tools_first.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# requires: gcc unix-shell -# A build program looks up a tool and finds the one mcpp installed. -# -# ⚠️ IT USED TO FIND WHATEVER THE MACHINE HAD, AND `command -v` CANNOT TELL THE -# DIFFERENCE. -# -# mcpp installs its tools into its own sub-OS — 221 programs on the machine -# this was written on, cross-compilers and emulators among them — and until -# 2026.8.25.1 none of them were on the PATH a build program inherited. -# Measured with a build program that printed its own PATH: -# -# /home/…/.xlings/data/xpkgs/xim-x-claude/2.1.222 -# /home/…/mcpp/.xlings/subos/_/bin -# /home/…/.xlings/subos/current/bin -# subos/bin in PATH: NO -# -# The concrete cost, from the same day: `command -v qemu-system-riscv64` -# returned an xlings shim that answers, when run, -# -# [error] qemu-system-riscv64 is not installed in this subos (_) -# -# — found, reported present, unable to execute, while the real one sat in -# mcpp's own directory unreachable. -# -# ⭐ PREPENDED, NOT SUBSTITUTED, AND THIS FILE ASSERTS BOTH HALVES. A build -# program legitimately reaches for things mcpp does not ship, so the host's -# entries must survive behind mcpp's. A test that only checked the first entry -# would pass on a PATH that had thrown the rest away. -set -e - -MCPP="${MCPP:-mcpp}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -mkdir -p "$work/app/src" -cd "$work/app" - -cat > mcpp.toml <<'TOML' -[package] -name = "pathprobe" -version = "0.1.0" -TOML -printf 'int main() { return 0; }\n' > src/main.cpp - -# ⚠️ A NON-ZERO EXIT, BECAUSE THAT IS WHAT MAKES THE OUTPUT VISIBLE. mcpp -# prints a build program's stdout only when it fails — a probe that succeeds -# says nothing, which is a property of the protocol and not of this test. -cat > build.mcpp <<'CPP' -import std; - -int main() { - const char* p = std::getenv("PATH"); - std::string_view path(p ? p : ""); - std::string_view first; - for (auto part : std::views::split(path, ':')) { - first = std::string_view(part); - break; - } - std::println("PROBE_FIRST={}", first); - std::println("PROBE_ENTRIES={}", std::ranges::count(path, ':') + 1); - return 1; -} -CPP - -out="$("$MCPP" build 2>&1 || true)" - -first="$(printf '%s\n' "$out" | grep -oP 'PROBE_FIRST=\K.*' | head -1)" -entries="$(printf '%s\n' "$out" | grep -oP 'PROBE_ENTRIES=\K[0-9]+' | head -1)" - -if [ -z "$first" ]; then - echo "SKIP: the build program did not report — it may not have run here" - printf '%s\n' "$out" | grep -iE 'error' | head -3 - exit 0 -fi - -case "$first" in - */subos/default/bin) - echo " ok the first PATH entry is this build system's own tools: $first" ;; - *) - echo "FAIL: the first PATH entry is not mcpp's tools directory" - echo " got: $first" - exit 1 ;; -esac - -# ⭐ AND THE HOST IS STILL BEHIND IT. One entry means the inherited PATH was -# replaced rather than extended, which would break every build program that -# calls `git`, `python3` or a shell. -if [ "${entries:-1}" -gt 1 ]; then - echo " ok the inherited PATH survives behind it ($entries entries)" -else - echo "FAIL: PATH was replaced, not prefixed — only $entries entry" - exit 1 -fi - -echo "OK: a build program finds this build system's tools first, and the host's after" diff --git a/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh b/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh new file mode 100755 index 00000000..0dbb047c --- /dev/null +++ b/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# A build program looks up a tool and finds the one the PROJECT declared. +# +# ⚠️ IT USED TO FIND WHATEVER THE MACHINE HAD, AND `command -v` CANNOT TELL THE +# DIFFERENCE. Measured 2026-08-25 with a build program that printed its own +# PATH: mcpp's own environment appeared nowhere in it, and +# `command -v qemu-system-riscv64` returned a shim that answers, when run, +# +# [error] qemu-system-riscv64 is not installed in this subos (_) +# +# — found, reported present, unable to execute, while a working copy sat in an +# environment the build could not reach. +# +# ⭐⭐ THIS FILE ASSERTS BOTH DIRECTIONS, BECAUSE ONLY ONE OF THEM IS THE +# FEATURE. Prepending unconditionally would have passed the first half and is +# the design that was withdrawn: a shared directory in front of every project +# makes what a build sees depend on what else was installed on that machine. +# The declaration is what puts it there, so a project that declares nothing +# must come out byte-for-byte unchanged. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# ⚠️ A NON-ZERO EXIT, BECAUSE THAT IS WHAT MAKES THE OUTPUT VISIBLE. mcpp +# prints a build program's stdout only when it fails — a probe that succeeds +# says nothing, which is a property of the protocol and not of this test. +probe='import std; + +int main() { + const char* p = std::getenv("PATH"); + std::string_view path(p ? p : ""); + std::string_view first; + for (auto part : std::views::split(path, '"'"':'"'"')) { + first = std::string_view(part); + break; + } + std::println("PROBE_FIRST={}", first); + std::println("PROBE_ENTRIES={}", std::ranges::count(path, '"'"':'"'"') + 1); + return 1; +}' + +# Returns "|", or nothing if the program did not run. +run_probe() { + local dir="$1" + local out + out="$(cd "$dir" && "$MCPP" build 2>&1 || true)" + local f e + f="$(printf '%s\n' "$out" | grep -oP 'PROBE_FIRST=\K.*' | head -1)" + e="$(printf '%s\n' "$out" | grep -oP 'PROBE_ENTRIES=\K[0-9]+' | head -1)" + [ -n "$f" ] && printf '%s|%s\n' "$f" "$e" +} + +make_project() { + local dir="$1" xlings="$2" + mkdir -p "$dir/src" + { printf '[package]\nname = "pathprobe"\nversion = "0.1.0"\n' + [ -n "$xlings" ] && printf '\n%s\n' "$xlings"; } > "$dir/mcpp.toml" + printf 'int main() { return 0; }\n' > "$dir/src/main.cpp" + printf '%s\n' "$probe" > "$dir/build.mcpp" +} + +# ── Half one: a project that declared nothing ───────────────────────────── +make_project "$work/plain" "" +plain="$(run_probe "$work/plain")" +if [ -z "$plain" ]; then + echo "SKIP: the build program did not report — it may not have run here" + exit 0 +fi +plain_first="${plain%%|*}" + +case "$plain_first" in + */subos/*/bin) + echo "FAIL: a project that declared no environment got one in front anyway" + echo " got: $plain_first" + exit 1 ;; + *) + echo " ok a project that declares nothing keeps the PATH it was given" ;; +esac + +# ── Half two: the same project, declaring one ───────────────────────────── +# +# `default` rather than a private name: mcpp READS an environment and never +# creates one, so a name nobody has bootstrapped is a hard error by design. +# What is under test is the prepending, and `default` exercises it on any +# machine that has run `mcpp self init`. +make_project "$work/declared" '[xlings] +subos = "default"' +declared="$(run_probe "$work/declared")" +if [ -z "$declared" ]; then + echo "SKIP: the declaring project's build program did not report" + exit 0 +fi +declared_first="${declared%%|*}" +declared_entries="${declared##*|}" + +case "$declared_first" in + */subos/*/bin) + echo " ok a project that declares one gets it first: $declared_first" ;; + *) + echo "FAIL: the declared environment is not at the front of PATH" + echo " got: $declared_first" + exit 1 ;; +esac + +# ⭐ AND THE HOST IS STILL BEHIND IT. One entry means the inherited PATH was +# replaced rather than extended, which would break every build program that +# calls `git`, `python3` or a shell. +if [ "${declared_entries:-1}" -gt 1 ]; then + echo " ok the inherited PATH survives behind it ($declared_entries entries)" +else + echo "FAIL: PATH was replaced, not prefixed — only $declared_entries entry" + exit 1 +fi + +echo "OK: the declaration puts an environment in front, and only the declaration does" From 9189e2e74a5886c7d706107add51783a2f23c509 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:16:33 +0800 Subject: [PATCH 11/16] target: the C library decides whether `dynamic` can be honoured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `linkage = "dynamic"` was reported as ineffective whenever the target's system came from the graph, and the warning's own reason is narrower than its predicate: "those packages are compiled into this build as objects, and there is no shared object to link against" is a property of the C library. Measured on a backend running ON a platform — kernel interface from the graph, C library from the payload: warning: `linkage = "dynamic"` has no effect … The artifact is static. $ file → dynamically linked $ readelf → NEEDED libm.so.6, libgcc_s.so.1, libc.so.6 A payload libc has a shared object, so `dynamic` was honoured and the diagnostic was false. Reads `cAbi.fromGraph()` instead. The two other `system_from_graph()` uses in prepare.cppm stay: both ask whether the graph supplies any part of the system, which is genuinely the two-layer question. e2e 291 asserts both directions — deleting the warning also stops it lying, and that would lose the diagnostic the directive needs when the C library really is the graph's. It also asserts the artifact's DT_NEEDED count, not just the absence of the text. Fourth defect of this shape in this release. --- CHANGELOG.md | 19 +++ src/build/prepare.cppm | 18 ++- ...d_only_when_the_c_library_is_the_graphs.sh | 119 ++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100755 tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e521f3..ded413f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,24 @@ **C 库才是决定它的那一层** —— 理由 `check_layering` 早已反向陈述:载荷的 C++ 运行时是对着载荷的 C 库配置的,所以当且仅当那份 C 库在用时它才可用。 +- **⭐ 第四条同型:`linkage = "dynamic"` 的「无效」诊断在说谎。** + + 实测 2026-08-25,在 285 的形状上(kernel-abi 来自图 + C 库来自载荷): + + ``` + warning: `linkage = "dynamic"` has no effect … The artifact is static. + $ file → dynamically linked + $ readelf → NEEDED libm.so.6, libgcc_s.so.1, libc.so.6 + ``` + + 谓词用的是 `system_from_graph()`(跨 kernel-abi 与 c-abi 两层的 OR),而这条 + 警告自己给的理由——「那些包被当作对象编进本次构建,没有共享对象可链接」—— + 是**C 库单独一层**的性质。载荷的 libc 有共享对象,`dynamic` 就被兑现了,这里 + 本来无话可说。改为 `cAbi.fromGraph()`。 + + prepare.cppm 里另外两处 `system_from_graph()` 保留:它们问的是「图有没有供给 + 系统的任一部分」,那确实是两层的问题。 + - **⭐ `build.mcpp` 的 `PATH` 前置项目声明的那个环境。** ``` @@ -144,6 +162,7 @@ | 288 | 无 OS 无 C 库,断言报告里**没有 c-abi 那一行**,并在 qemu 里真启动 | | 289 | **一台宿主横扫四个目标** —— 这个体系本就是通用交叉构建,传统栈要六个 runner 的覆盖,这里一个循环 | | 290 | 声明把环境放到 `PATH` 前面,**而且只有声明会** —— 两个方向各一条断言 | + | 291 | `dynamic` 只在 C 库来自图时被拒 —— 且断言产物的 `DT_NEEDED` 而非只断言文案 | 290 的两半只有一半是特性:无条件前置能通过前一半,而那正是被撤回的设计。 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index e410aaf3..7f958eca 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -5981,14 +5981,26 @@ prepare_build(bool print_fingerprint, // A request that cannot be honoured is said so rather than dropped. // - // Measured 2026-08-23: `[build] linkage = "dynamic"` on a project whose - // system comes from the graph produced a statically linked artifact and + // Measured 2026-08-23: `linkage = "dynamic"` on a project whose system + // comes from the graph produced a statically linked artifact and // printed nothing. The outcome is correct — the graph supplies its // libraries as objects compiled into this build, and there is no shared // object for a loader to resolve at run time — but a directive that has // no effect and no diagnostic is indistinguishable from one that was // never read. - if (resolvedTargetSide.system_from_graph() + // + // ⚠️ THE C LIBRARY IS THE LAYER THIS DEPENDS ON, NOT "THE SYSTEM". + // `system_from_graph()` spans two layers, and the arrangement that + // separates them is real: a backend running ON a platform takes its + // kernel interface from the graph while the C library stays the + // payload's. Measured 2026-08-25 on exactly that project — the + // predicate was true, this warning printed "The artifact is static", + // and the artifact had three DT_NEEDED entries including `libc.so.6`. + // The reason the message gives is a property of the C library alone: + // a payload libc has a shared object, so `dynamic` is honoured and + // there is nothing to warn about. Same shape as the three defects this + // release fixes — see `TargetSide::system_from_graph`'s own note. + if (resolvedTargetSide.cAbi.fromGraph() && m->buildConfig.linkage == "dynamic") mcpp::ui::warning( "`linkage = \"dynamic\"` has no effect when the " diff --git a/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh b/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh new file mode 100755 index 00000000..0956b416 --- /dev/null +++ b/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# requires: gcc elf network +# `linkage = "dynamic"` is reported as ineffective only when it actually is. +# +# ⚠️ THE PREDICATE USED TO SPAN TWO LAYERS AND THE REASON SPANS ONE. The +# warning's own justification — "those packages are compiled into this build as +# objects, and there is no shared object to link against" — is a property of +# the C LIBRARY. A backend that runs ON a platform takes its kernel interface +# from the graph and keeps the payload's C library, and a payload libc has a +# shared object, so `dynamic` is honoured there. +# +# Measured 2026-08-25 before the fix, on the project this file builds: +# +# warning: `linkage = "dynamic"` has no effect … The artifact is static. +# $ file …/dynprobe → dynamically linked +# $ readelf -d … → NEEDED libm.so.6, libgcc_s.so.1, libc.so.6 +# +# ⭐⭐ BOTH DIRECTIONS, BECAUSE ONLY ONE OF THEM IS THE FIX. Deleting the +# warning outright also stops it lying, and that would lose the diagnostic the +# directive needs when the C library really does come from the graph — which is +# why the second half builds that arrangement and requires the warning to +# appear. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# ⚠️ AN EXPLICIT `--target` IS LOAD-BEARING. `[target.]` applies to the +# target that was REQUESTED; a bare `mcpp build` requests none, the row never +# applies, `linkage` stays empty, and both halves of this test would pass +# without exercising anything. +TARGET=x86_64-linux-gnu + +make_project() { + local dir="$1" tc="$2" deps="$3" + mkdir -p "$dir/src" + cat > "$dir/mcpp.toml" <\nint main() { std::printf("ok\\n"); }\n' \ + > "$dir/src/main.cpp" +} + +warned() { printf '%s\n' "$1" | grep -q 'has no effect'; } + +# ── Half one: kernel interface from the graph, C library from the payload ── +make_project "$work/onplatform" "gcc@16.1.0" 'openkal-linux = "0.5.4"' +out="$(cd "$work/onplatform" && "$MCPP" build --target "$TARGET" 2>&1)" || { + echo "SKIP: the on-platform project did not build here" + printf '%s\n' "$out" | grep -iE '^.*error.*$' | head -3 + exit 0 +} + +# The arrangement has to be the one this is about, or the assertion below is +# about nothing. +case "$out" in + *kernel-abi*graph*) ;; + *) echo "SKIP: the kernel interface did not come from the graph here" + printf '%s\n' "$out" | grep -E 'abi' | sed 's/^/ /' + exit 0 ;; +esac + +bin="$(find "$work/onplatform/target" -name linkprobe -type f -perm -u+x | head -1)" +[ -n "$bin" ] || { echo "FAIL: no artifact was produced"; exit 1; } + +if warned "$out"; then + echo "FAIL: 'dynamic' was reported as ineffective while the payload's C library was in use" + printf '%s\n' "$out" | grep 'has no effect' | sed 's/^/ /' + exit 1 +fi +echo " ok no warning when the C library is the payload's" + +# ⭐ AND THE ARTIFACT AGREES. The warning's claim is "The artifact is static"; +# a test that only checked for the absence of the text would pass on a build +# that silently produced a static binary anyway. +needed="$(readelf -d "$bin" 2>/dev/null | grep -c NEEDED || true)" +if [ "${needed:-0}" -gt 0 ]; then + echo " ok 'dynamic' was honoured — $needed DT_NEEDED entries" +else + echo "FAIL: the artifact is static, so the warning would have been right" + exit 1 +fi + +# ── Half two: the C library itself comes from the graph ──────────────────── +# The same stack e2e 286 builds. openkal-llvm-runtime IS libc++/libc++abi/ +# libunwind, so the toolchain has to be the one that package exists for — and +# it is the C library coming from the graph, not the C++ runtime, that this +# half is about. +make_project "$work/fullgraph" "llvm@22.1.8" 'openkal-musl = "0.3.5" +openkal-llvm-runtime = "0.1.3"' +out2="$(cd "$work/fullgraph" && "$MCPP" build --target "$TARGET" 2>&1)" || true + +case "$out2" in + *c-abi*graph*) ;; + *) echo "SKIP: the C library did not come from the graph here" + printf '%s\n' "$out2" | grep -E 'abi' | sed 's/^/ /' + exit 0 ;; +esac + +if warned "$out2"; then + echo " ok the warning still appears when the C library is the graph's" +else + echo "FAIL: 'dynamic' cannot be honoured here and nothing said so" + exit 1 +fi + +echo "OK: the C library decides whether 'dynamic' can be honoured" From 02a534e099f895b5693c23fd0a11c59199fcaa12 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:27:50 +0800 Subject: [PATCH 12/16] ci: run the openkal e2e on a runner that has what they ask for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 285-289 declare `# requires: llvm`. Both linux e2e shards report Detected capabilities: elf unix-shell fresh-sandbox gcc patchelf pack … with no `llvm`, because the shard workflow never installs one, and `run_all.sh` exits 0 on a skip. The suite stayed green while the five tests written to measure this ecosystem did not run at all. No new token: run_all.sh's own note explains why a hard-requires cannot work — one word cannot distinguish "this runner is misconfigured" from "this platform legitimately lacks the capability", and the one test that tried it broke the macOS suite. The guard has to know which runner it is talking to, so it lives in the job. `ecosystem-e2e` installs gcc and llvm, runs the six scripts directly, and then asserts each PASS line appeared. Same shape as ci-linux-e2e.yml's `baremetal` job, for the same reason. Also fixes 291's requires line: `network` is not a known capability, and the runner's guard hard-fails on an unknown token rather than silently skipping — which is what turned three CI checks red. That guard is right; the declaration was wrong. --- .github/workflows/openkal-cross.yml | 104 ++++++++++++++++++ CHANGELOG.md | 19 ++++ ...d_only_when_the_c_library_is_the_graphs.sh | 2 +- 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index a88b6cc4..59f6637f 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -241,3 +241,107 @@ jobs: done [ "$fail" = 0 ] || exit 1 echo "three builds, one system, same four lines" + + # ────────────────────────────────────────────────────────────────── + # The e2e scripts that BUILD the openkal ecosystem, on a runner that + # has what they ask for. + # + # ⚠️ THEY WERE WRITTEN AND THEY WERE NEVER RUN. `285`–`289` declare + # `# requires: llvm`, and the linux e2e shards report + # + # Detected capabilities: elf unix-shell fresh-sandbox gcc + # patchelf pack symlink python3 … + # + # — no `llvm`, on either shard, because the shard workflow never + # installs one. `run_all.sh` exits 0 on a skip, so the suite stayed + # green while the five tests measuring this ecosystem did not run. + # + # run_all.sh's own note says why no token can fix this: a hard-requires + # cannot tell "this runner is misconfigured" from "this platform + # legitimately lacks the capability". The guard has to know which + # runner it is, so it lives in the job — install the capability, then + # assert each script's PASS line actually appeared. Same shape as + # ci-linux-e2e.yml's `baremetal` job, for the same reason. + # ────────────────────────────────────────────────────────────────── + ecosystem-e2e: + name: openkal e2e (the scripts, on a runner that has llvm) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bootstrap-mcpp + + - name: Build the mcpp in this pull request + run: | + set -euo pipefail + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$XLINGS_BIN" config --mirror GLOBAL 2>/dev/null || true + "$MCPP" self config --mirror GLOBAL 2>/dev/null || true + "$MCPP" build --dev + BUILT=$(find target -type f -name 'mcpp' | head -1) + [ -n "$BUILT" ] || { echo "::error::mcpp did not build"; exit 1; } + BUILT=$(cd "$(dirname "$BUILT")" && pwd)/$(basename "$BUILT") + echo "MCPP_UNDER_TEST=$BUILT" >> "$GITHUB_ENV" + "$BUILT" --version + + - name: Install what the scripts declare + run: | + set -euo pipefail + "$MCPP_UNDER_TEST" self config --mirror GLOBAL 2>/dev/null || true + # Both, and both are load-bearing: 285 and 291's first half build + # with gcc (a backend running ON a platform, keeping the payload's + # C library), 286-289 and 291's second half with llvm (the whole + # stack from the graph, where openkal-llvm-runtime IS libc++). + "$MCPP_UNDER_TEST" toolchain install gcc 16.1.0 + "$MCPP_UNDER_TEST" toolchain install llvm 22.1.8 + + - name: The scripts + run: | + set -euo pipefail + export MCPP="$MCPP_UNDER_TEST" + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + # Directly rather than through run_all.sh: it accepts no filter, and + # it exits 0 on a skip — which is the condition this job exists to + # detect. + fail=0 + for t in tests/e2e/285_*.sh tests/e2e/286_*.sh tests/e2e/287_*.sh \ + tests/e2e/288_*.sh tests/e2e/289_*.sh tests/e2e/291_*.sh; do + echo "=== $t ===" + bash "$t" 2>&1 | tee "$(basename "$t").log" || true + rc=${PIPESTATUS[0]} + [ "$rc" = "0" ] || { echo "::error::$t failed (exit $rc)"; fail=1; } + done + [ "$fail" = 0 ] || exit 1 + + - name: Each one RAN + run: | + set -euo pipefail + # ⭐ THE ASSERTION THIS JOB EXISTS FOR. A zero exit code cannot + # distinguish "passed" from "skipped" — every one of these scripts + # has an early `exit 0` for a capability or an arrangement it did + # not find. The PASS line can. + check() { + grep -qF "$2" "$1".log || { + echo "::error::$1 did not run to its conclusion on the runner that must run it" + tail -5 "$1".log 2>/dev/null | sed 's/^/ /' + return 1 + } + echo " ok $1" + } + fail=0 + check 285_kernel_abi_from_graph_keeps_the_payload_c_library.sh \ + "OK: a graph-supplied kernel interface leaves the payload's C library reachable" || fail=1 + check 286_the_openkal_stack_still_builds.sh \ + "OK: the openkal stack builds, links statically and runs" || fail=1 + check 287_the_openkal_stack_crosses_to_aarch64.sh \ + "OK: the openkal stack crosses to aarch64, supplies its atomics helpers and runs" || fail=1 + check 288_the_openkal_stack_on_a_machine_with_no_os.sh \ + "OK: openkal runs on a machine with no operating system and no C library" || fail=1 + check 289_one_host_reaches_every_openkal_target.sh \ + "OK: one host reached" || fail=1 + check 291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh \ + "OK: the C library decides whether 'dynamic' can be honoured" || fail=1 + [ "$fail" = 0 ] || exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index ded413f1..9d3690bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,25 @@ | 290 | 声明把环境放到 `PATH` 前面,**而且只有声明会** —— 两个方向各一条断言 | | 291 | `dynamic` 只在 C 库来自图时被拒 —— 且断言产物的 `DT_NEEDED` 而非只断言文案 | +- **⚠️ 上面这张表里的 285–289,此前一条都没在 CI 跑过。** + + 它们声明 `# requires: llvm`,而两个 linux e2e shard 报的能力行是 + + ``` + Detected capabilities: elf unix-shell fresh-sandbox gcc patchelf pack … + ``` + + 没有 `llvm`——shard 的 workflow 从不装。`run_all.sh` 在 skip 时退 0,于是 + 套件一直绿,而专门用来衡量这个生态的五条测试一次都没执行。**「我加了测试」 + 和「测试跑过」是两件事**,这一条我自己又犯了一次。 + + 修法用仓库已有的范式,而不是新造一个 token:`run_all.sh` 自己的注释写明了 + 为什么没有 hard-requires——一个 token 分不清「这台 runner 配错了」和「这个 + 平台本来就没有」。有效的守卫必须知道自己在跟哪台 runner 说话,所以它住在 + job 里。新增 `openkal-cross.yml` 的 `ecosystem-e2e`:装 gcc + llvm,直接跑 + 这六条,再逐条断言它们的 PASS 行真的出现了。与 `ci-linux-e2e.yml` 的 + `baremetal` job 同形,同因。 + 290 的两半只有一半是特性:无条件前置能通过前一半,而那正是被撤回的设计。 ## [2026.8.24.6] — 2026-08-25 diff --git a/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh b/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh index 0956b416..6834053b 100755 --- a/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh +++ b/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# requires: gcc elf network +# requires: gcc llvm elf unix-shell # `linkage = "dynamic"` is reported as ineffective only when it actually is. # # ⚠️ THE PREDICATE USED TO SPAN TWO LAYERS AND THE REASON SPANS ONE. The From e63c9a2633c016359eacfa4bb2bdc4281662a98f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:38:56 +0800 Subject: [PATCH 13/16] e2e 287: a disassembler that cannot read the file is not evidence about LSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new ecosystem-e2e job's first run caught this. 287 picked its disassembler with command -v llvm-objdump || command -v objdump On CI the first is not on PATH and the second is host GNU binutils, whose BFD is built for one architecture. Asked to disassemble an aarch64 binary on x86_64 it prints a header, no instructions, and no error: $ objdump -d a-aarch64.o | grep -cE '^\s*[0-9a-f]+:' 0 $ llvm-objdump -d a-aarch64.o | grep -cE '^\s*[0-9a-f]+:' 5 `grep -c` returned 0 and the script reported "`+outline-atomics` looks disabled". It passed on the machine it was written on, where /usr/bin/llvm-objdump happens to exist and is picked first. The tool now comes from the toolchain that built the binary — mcpp installed llvm to compile this, and its llvm-objdump reads every target clang emits. The count of instruction lines is checked first: zero means the tool could not read the file, which is a SKIP, not a verdict. The assertion carries its denominator now (7 LSE out of 148906). Same run showed 287's and 288's run phases both degrading to a SKIP while their OK lines printed anyway. The job installs both emulators and asserts the run-phase lines separately — linking is not booting, as 288 itself says. --- .github/workflows/openkal-cross.yml | 39 +++++++++++++++ CHANGELOG.md | 39 ++++++++++++++- ...87_the_openkal_stack_crosses_to_aarch64.sh | 48 ++++++++++++++++--- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index 59f6637f..2c686df8 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -298,6 +298,35 @@ jobs: "$MCPP_UNDER_TEST" toolchain install gcc 16.1.0 "$MCPP_UNDER_TEST" toolchain install llvm 22.1.8 + # ⚠️ THE EMULATORS, OR TWO OF THE SIX MEASURE HALF OF WHAT THEY SAY. + # + # 287 and 288 both end by RUNNING what they built — an aarch64 binary and + # a riscv64 machine image — and both degrade to a SKIP when no emulator + # is here. Measured on this job's first run: 288 printed + # + # SKIP no riscv64 machine emulator here — linking is not booting + # + # and still reached its OK line, so the PASS-line assertion below would + # have called that covered. Linking is not booting, as the script itself + # says. + # + # ⚠️ BOTH homes, for the reason ci-linux-e2e.yml's baremetal job records: + # the shim on PATH dispatches against whichever home owns it, so an + # emulator installed only in the ambient one answers "not installed" when + # mcpp asks. + - name: Install the emulators the last two scripts need + run: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq qemu-user + "$XLINGS_BIN" install xim:qemu-riscv -y + XLINGS_HOME="${MCPP_HOME:-$HOME/.mcpp}/registry" \ + "$XLINGS_BIN" install xim:qemu-riscv -y + # Reachable AND runnable, asserted before the tests: without this the + # scripts would simply skip and say so in a line nobody reads. + qemu-aarch64 --version | head -1 + "$XLINGS_BIN" run qemu-system-riscv64 --version 2>/dev/null | head -1 \ + || command -v qemu-system-riscv64 + - name: The scripts run: | set -euo pipefail @@ -338,8 +367,18 @@ jobs: "OK: the openkal stack builds, links statically and runs" || fail=1 check 287_the_openkal_stack_crosses_to_aarch64.sh \ "OK: the openkal stack crosses to aarch64, supplies its atomics helpers and runs" || fail=1 + # ⭐ AND IT REACHED THE PARTS THAT NEED A TOOL. Both of 287's last two + # assertions degrade to a SKIP, and the OK line prints either way. + check 287_the_openkal_stack_crosses_to_aarch64.sh \ + "LSE instructions out of" || fail=1 + check 287_the_openkal_stack_crosses_to_aarch64.sh \ + "it runs under qemu-aarch64" || fail=1 check 288_the_openkal_stack_on_a_machine_with_no_os.sh \ "OK: openkal runs on a machine with no operating system and no C library" || fail=1 + # ⭐ 288's name says "runs"; without this it can print that line + # having only linked. + check 288_the_openkal_stack_on_a_machine_with_no_os.sh \ + "it boots" || fail=1 check 289_one_host_reaches_every_openkal_target.sh \ "OK: one host reached" || fail=1 check 291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3690bc..890efa7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,23 @@ ⚠️ **没有任何生产路径会带着全 `None` 的 `TargetSide` 走到 flags**:`resolve` 给普通本机构建的是 `cAbi = { Payload, … }`。夹具现在照实写。 +- **⭐⭐ 缓存键漏掉了新参数,于是跨着一处不兼容命中了。** + + `compile_flags(spec)` 在 #486 长出第二个参数 `targetCxxRuntime`,而缓存键 + 仍按一个参数算: + + ```cpp + b.targetImpliedFlags = mcpp::freestanding::compile_flags(*spec); + ``` + + 同一个键因此覆盖两套实际不同的编译 flag。**命中不是「跳过一次重编」,是 + 「拿到一份为另一套 flag 建的产物」**——实测 `openkal@0.7.0` 出现 6 个槽位 + 对应 5 个尺寸各异的 BMI。 + + ⚠️ 这类缺陷不会在加参数的那天失败,它在下一次缓存命中时失败,而那时改动 + 已经不在视野里了。三条单元测试因此**直接打在 `build_axes()` 上**,而不是手 + 搭一个 `BuildAxes`——后者表达不出「推导过程本身错了」这件事。 + - **⭐ 载荷的 C++ 运行时,服务的是载荷的 C 库。** ``` @@ -159,7 +176,7 @@ | 285 | kernel-abi 来自图 + C 库来自**载荷**(后端跑在平台之上) | | 286 | 三层全来自图,断言静态、无 INTERP、能跑 | | 287 | 交叉到 aarch64,断言 outline-atomics 辅助函数与 LSE 指令数,qemu 真跑 | - | 288 | 无 OS 无 C 库,断言报告里**没有 c-abi 那一行**,并在 qemu 里真启动 | + | 288 | 无 OS 无 C 库,断言 c-abi 那一行的**值**是 `—`(不是断言它缺席),并在 qemu 里真启动 | | 289 | **一台宿主横扫四个目标** —— 这个体系本就是通用交叉构建,传统栈要六个 runner 的覆盖,这里一个循环 | | 290 | 声明把环境放到 `PATH` 前面,**而且只有声明会** —— 两个方向各一条断言 | | 291 | `dynamic` 只在 C 库来自图时被拒 —— 且断言产物的 `DT_NEEDED` 而非只断言文案 | @@ -183,6 +200,26 @@ 这六条,再逐条断言它们的 PASS 行真的出现了。与 `ci-linux-e2e.yml` 的 `baremetal` job 同形,同因。 + ⭐ **这个 job 第一次跑就抓到 287 在说谎。** 它用 + `command -v llvm-objdump || command -v objdump` 找反汇编器,而 CI 上前者不在 + PATH、后者是宿主 GNU binutils —— BFD 只编了 x86_64。让它反汇编 aarch64 会打印 + 一个文件头、**零条指令、零报错**: + + ``` + $ objdump -d a-aarch64.o | grep -cE '^\s*[0-9a-f]+:' + 0 + $ llvm-objdump -d a-aarch64.o | grep -cE '^\s*[0-9a-f]+:' + 5 + ``` + + `grep -c` 得 0,脚本报「`+outline-atomics` 看起来是被关掉了」。它在写它的那台 + 机器上通过,因为那里恰好有 `/usr/bin/llvm-objdump`。现在工具取自**编译这个 + 产物的那条工具链**,并且先数指令总行数:零条 = 工具读不了这个文件,那不是 + 关于 LSE 的证据。判据也随之带上分母(`7 LSE instructions out of 148906`)。 + + 同一轮还发现 287/288 的「运行」两步在 CI 上都降级成了 SKIP 而 OK 行照印。job + 因此装上两个模拟器,并对这两条**额外断言运行阶段的那一行**。 + 290 的两半只有一半是特性:无条件前置能通过前一半,而那正是被撤回的设计。 ## [2026.8.24.6] — 2026-08-25 diff --git a/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh b/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh index 5fc10ff0..0d6b8118 100755 --- a/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh +++ b/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh @@ -97,14 +97,50 @@ if [ -n "$nm" ]; then fi fi -objdump="$(command -v llvm-objdump || command -v objdump || true)" +# ⚠️⚠️ A DISASSEMBLER THAT CANNOT READ THIS FILE ANSWERS "NO LSE", AND THE +# FIRST VERSION OF THIS BELIEVED IT. +# +# `command -v objdump` on a Linux runner finds GNU binutils, whose BFD is built +# for ONE architecture. Asked to disassemble an aarch64 binary on an x86_64 +# host it prints a header, no instructions, and — this is the part that costs — +# NO ERROR: +# +# $ objdump --info | head -2 +# elf64-x86-64 +# $ objdump -d a-aarch64.o | wc -l +# 5 # …of which 0 are instructions, 0 are errors +# +# `grep -c` on that is 0, which this then reported as "`+outline-atomics` looks +# disabled". Measured 2026-08-25: it passed on the machine it was written on, +# where /usr/bin/llvm-objdump happens to exist and is picked first, and failed +# in CI where it does not — the first time this test had ever run there. +# +# ⭐ SO THE TOOL IS TAKEN FROM THE TOOLCHAIN THAT BUILT THE BINARY. mcpp +# installed llvm to compile this; its llvm-objdump reads every target clang +# emits, by construction. +objdump="" +for c in "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-llvm/*/bin/llvm-objdump; do + [ -x "$c" ] && objdump="$c" +done +[ -n "$objdump" ] || objdump="$(command -v llvm-objdump || true)" +[ -n "$objdump" ] || objdump="$(command -v objdump || true)" + if [ -n "$objdump" ]; then - lse="$("$objdump" -d "$bin" 2>/dev/null | grep -cE '\b(casal|cas|ldaddal|ldadd|swpal|swp)\b' || true)" - if [ "${lse:-0}" -gt 0 ]; then - echo " ok $lse LSE instructions — the feature is on, not switched off" + disasm="$("$objdump" -d "$bin" 2>/dev/null || true)" + # ⭐ THE GUARD THAT SEPARATES THE TWO ANSWERS. Zero instruction lines means + # the tool could not read the file; it is not evidence about LSE, and + # reporting it as such is how a green suite hides a broken measurement. + insns="$(printf '%s\n' "$disasm" | grep -cE '^\s*[0-9a-f]+:' || true)" + if [ "${insns:-0}" = 0 ]; then + echo " SKIP $(basename "$objdump") disassembled nothing — it cannot read aarch64 here" else - echo "FAIL: no LSE instruction; \`+outline-atomics\` looks disabled rather than supported" - exit 1 + lse="$(printf '%s\n' "$disasm" | grep -cE '\b(casal|cas|ldaddal|ldadd|swpal|swp)\b' || true)" + if [ "${lse:-0}" -gt 0 ]; then + echo " ok $lse LSE instructions out of $insns — the feature is on, not switched off" + else + echo "FAIL: $insns instructions disassembled and not one LSE; \`+outline-atomics\` is disabled" + exit 1 + fi fi fi From 3271d3d035691438a7d71d0716498e380a53576e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:40:26 +0800 Subject: [PATCH 14/16] e2e 286, 291: establish that something was read before reading an absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class as the 287 defect CI just caught, found by sweeping the family for it. 286 asserts there is no INTERP segment. `readelf -l` on a file it cannot parse prints zero lines and exits quietly, and `grep -q INTERP` then finds nothing — which reads exactly like a static image. It now counts program headers first and says how many it saw: ok no INTERP among 9 program headers — nothing for a loader to resolve 291 counts DT_NEEDED entries. "no dynamic section" and "readelf said nothing" both count zero, and only the first is about this build; a static ELF says so in words, an unreadable file says nothing at all. Empty output is now its own failure. Neither was failing. Both could pass without measuring anything, which is the property 287 demonstrated is not hypothetical. --- .../e2e/286_the_openkal_stack_still_builds.sh | 18 +++++++++++++++--- ...ed_only_when_the_c_library_is_the_graphs.sh | 12 +++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/e2e/286_the_openkal_stack_still_builds.sh b/tests/e2e/286_the_openkal_stack_still_builds.sh index 12e87dd4..29d14208 100755 --- a/tests/e2e/286_the_openkal_stack_still_builds.sh +++ b/tests/e2e/286_the_openkal_stack_still_builds.sh @@ -109,12 +109,24 @@ esac # header is the fact. A dynamic image names the host's loader here, and that is # a path the target machine has no reason to have. if command -v readelf > /dev/null 2>&1; then - if readelf -l "$bin" 2>/dev/null | grep -q 'INTERP'; then + phdrs="$(readelf -l "$bin" 2>/dev/null || true)" + # ⚠️ THIS ASSERTS AN ABSENCE, SO IT MUST FIRST ESTABLISH THAT SOMETHING WAS + # READ. `readelf -l` on a file it cannot parse prints zero lines and exits + # quietly; `grep -q INTERP` then finds nothing, which reads exactly like a + # static image. e2e 287 shipped that mistake with a disassembler and CI + # caught it — the same shape, one file over. + segs="$(printf '%s\n' "$phdrs" \ + | grep -cE '^\s+(LOAD|PHDR|NOTE|GNU_|INTERP|DYNAMIC|TLS)' || true)" + if [ "${segs:-0}" = 0 ]; then + echo "FAIL: readelf reported no program headers at all — it did not read $bin" + exit 1 + fi + if printf '%s\n' "$phdrs" | grep -q 'INTERP'; then echo "FAIL: the image names an interpreter" - readelf -l "$bin" | grep -A1 INTERP | sed 's/^/ /' + printf '%s\n' "$phdrs" | grep -A1 INTERP | sed 's/^/ /' exit 1 fi - echo " ok no INTERP segment — nothing for a loader to resolve" + echo " ok no INTERP among $segs program headers — nothing for a loader to resolve" fi # ── And it runs, which is the only check the others cannot fake ───────────── diff --git a/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh b/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh index 6834053b..2f208631 100755 --- a/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh +++ b/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh @@ -85,7 +85,17 @@ echo " ok no warning when the C library is the payload's" # ⭐ AND THE ARTIFACT AGREES. The warning's claim is "The artifact is static"; # a test that only checked for the absence of the text would pass on a build # that silently produced a static binary anyway. -needed="$(readelf -d "$bin" 2>/dev/null | grep -c NEEDED || true)" +dyn="$(readelf -d "$bin" 2>&1 || true)" +# ⚠️ "no dynamic section" AND "readelf said nothing" BOTH COUNT ZERO `NEEDED`. +# The first is the answer a static binary gives and the second is the answer a +# tool that could not read the file gives, and only one of them is about this +# build. `readelf -d` on a static ELF says so in words; on a file it cannot +# parse it says nothing at all. +if [ -z "$dyn" ]; then + echo "FAIL: readelf produced no output for $bin — the measurement did not happen" + exit 1 +fi +needed="$(printf '%s\n' "$dyn" | grep -c NEEDED || true)" if [ "${needed:-0}" -gt 0 ]; then echo " ok 'dynamic' was honoured — $needed DT_NEEDED entries" else From 42ae38596778c3df21a6d0ad39f107513b2e43fc Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:54:40 +0800 Subject: [PATCH 15/16] e2e 290: "unchanged" is compared against the inherited value, not a pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Half one rejected a first PATH entry matching `*/subos/*/bin`. CI's own PATH already begins with one — the runner activates an xlings environment to get mcpp at all: MCPP: /home/runner/.xlings/subos/default/bin/mcpp so the test reported mcpp prepending something that was already there, and turned the shard red on a build that behaved correctly. A test for "did not change it" has to hold the before and the after side by side. Both halves now compare against `$PATH` as the test process had it: the plain project's child PATH must equal it byte for byte, and the declaring project's must be one new entry followed by exactly it. Verified under a PATH shaped like CI's, where the old criterion lied. --- CHANGELOG.md | 2 +- ...eclaration_puts_an_environment_in_front.sh | 98 ++++++++++--------- 2 files changed, 54 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 890efa7d..1c74eb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -178,7 +178,7 @@ | 287 | 交叉到 aarch64,断言 outline-atomics 辅助函数与 LSE 指令数,qemu 真跑 | | 288 | 无 OS 无 C 库,断言 c-abi 那一行的**值**是 `—`(不是断言它缺席),并在 qemu 里真启动 | | 289 | **一台宿主横扫四个目标** —— 这个体系本就是通用交叉构建,传统栈要六个 runner 的覆盖,这里一个循环 | - | 290 | 声明把环境放到 `PATH` 前面,**而且只有声明会** —— 两个方向各一条断言 | + | 290 | 声明把环境放到 `PATH` 前面,**而且只有声明会** —— 两半都对着**继承的那个值**比对,不是比对一个模式 | | 291 | `dynamic` 只在 C 库来自图时被拒 —— 且断言产物的 `DT_NEEDED` 而非只断言文案 | - **⚠️ 上面这张表里的 285–289,此前一条都没在 CI 跑过。** diff --git a/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh b/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh index 0dbb047c..ba33ec69 100755 --- a/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh +++ b/tests/e2e/290_the_declaration_puts_an_environment_in_front.sh @@ -4,7 +4,7 @@ # # ⚠️ IT USED TO FIND WHATEVER THE MACHINE HAD, AND `command -v` CANNOT TELL THE # DIFFERENCE. Measured 2026-08-25 with a build program that printed its own -# PATH: mcpp's own environment appeared nowhere in it, and +# PATH: the project's declared environment appeared nowhere in it, and # `command -v qemu-system-riscv64` returned a shim that answers, when run, # # [error] qemu-system-riscv64 is not installed in this subos (_) @@ -13,11 +13,21 @@ # environment the build could not reach. # # ⭐⭐ THIS FILE ASSERTS BOTH DIRECTIONS, BECAUSE ONLY ONE OF THEM IS THE -# FEATURE. Prepending unconditionally would have passed the first half and is -# the design that was withdrawn: a shared directory in front of every project -# makes what a build sees depend on what else was installed on that machine. -# The declaration is what puts it there, so a project that declares nothing -# must come out byte-for-byte unchanged. +# FEATURE. Prepending unconditionally would pass the "declared" half, and that +# is the design that was withdrawn: a shared directory in front of every +# project makes what a build sees depend on what else was installed on that +# machine. The declaration is what puts it there, so a project that declares +# nothing must come out unchanged. +# +# ⚠️⚠️ AND "UNCHANGED" IS COMPARED AGAINST THE INHERITED VALUE, NOT AGAINST A +# PATTERN. The first version of this half rejected a first entry matching +# `*/subos/*/bin` — and CI's own PATH already begins with one, because the +# runner activates an xlings environment to get mcpp at all: +# +# MCPP: /home/runner/.xlings/subos/default/bin/mcpp +# +# so it reported mcpp prepending something that was already there. A test for +# "did not change it" has to hold the before and the after side by side. set -e MCPP="${MCPP:-mcpp}" @@ -31,26 +41,15 @@ probe='import std; int main() { const char* p = std::getenv("PATH"); - std::string_view path(p ? p : ""); - std::string_view first; - for (auto part : std::views::split(path, '"'"':'"'"')) { - first = std::string_view(part); - break; - } - std::println("PROBE_FIRST={}", first); - std::println("PROBE_ENTRIES={}", std::ranges::count(path, '"'"':'"'"') + 1); + std::println("PROBE_PATH={}", p ? p : ""); return 1; }' -# Returns "|", or nothing if the program did not run. +# Echoes the child's PATH, or nothing if the build program did not run. run_probe() { - local dir="$1" - local out + local dir="$1" out out="$(cd "$dir" && "$MCPP" build 2>&1 || true)" - local f e - f="$(printf '%s\n' "$out" | grep -oP 'PROBE_FIRST=\K.*' | head -1)" - e="$(printf '%s\n' "$out" | grep -oP 'PROBE_ENTRIES=\K[0-9]+' | head -1)" - [ -n "$f" ] && printf '%s|%s\n' "$f" "$e" + printf '%s\n' "$out" | grep -oP 'PROBE_PATH=\K.*' | head -1 } make_project() { @@ -62,6 +61,10 @@ make_project() { printf '%s\n' "$probe" > "$dir/build.mcpp" } +# The value every assertion below is relative to. mcpp inherits this shell's +# PATH, so this is exactly what an unchanged child would report. +inherited="$PATH" + # ── Half one: a project that declared nothing ───────────────────────────── make_project "$work/plain" "" plain="$(run_probe "$work/plain")" @@ -69,16 +72,15 @@ if [ -z "$plain" ]; then echo "SKIP: the build program did not report — it may not have run here" exit 0 fi -plain_first="${plain%%|*}" -case "$plain_first" in - */subos/*/bin) - echo "FAIL: a project that declared no environment got one in front anyway" - echo " got: $plain_first" - exit 1 ;; - *) - echo " ok a project that declares nothing keeps the PATH it was given" ;; -esac +if [ "$plain" = "$inherited" ]; then + echo " ok a project that declares nothing gets the PATH mcpp was started with" +else + echo "FAIL: a project that declared no environment had its PATH changed" + diff <(printf '%s\n' "$inherited" | tr ':' '\n') \ + <(printf '%s\n' "$plain" | tr ':' '\n') | head -6 | sed 's/^/ /' + exit 1 +fi # ── Half two: the same project, declaring one ───────────────────────────── # @@ -93,25 +95,31 @@ if [ -z "$declared" ]; then echo "SKIP: the declaring project's build program did not report" exit 0 fi -declared_first="${declared%%|*}" -declared_entries="${declared##*|}" -case "$declared_first" in - */subos/*/bin) - echo " ok a project that declares one gets it first: $declared_first" ;; - *) - echo "FAIL: the declared environment is not at the front of PATH" - echo " got: $declared_first" - exit 1 ;; +if [ "$declared" = "$inherited" ]; then + echo "FAIL: the declaration changed nothing — the environment is not in front" + echo " PATH: $(printf '%s' "$declared" | cut -c1-100)…" + exit 1 +fi + +first="${declared%%:*}" +case "$first" in + */subos/*/bin) echo " ok the declared environment is first: $first" ;; + *) echo "FAIL: something other than a subos was prepended" + echo " got: $first" + exit 1 ;; esac -# ⭐ AND THE HOST IS STILL BEHIND IT. One entry means the inherited PATH was -# replaced rather than extended, which would break every build program that -# calls `git`, `python3` or a shell. -if [ "${declared_entries:-1}" -gt 1 ]; then - echo " ok the inherited PATH survives behind it ($declared_entries entries)" +# ⭐ AND THE INHERITED VALUE IS STILL THERE, WHOLE, BEHIND IT. Prefixing means +# the rest is untouched; a test that only checked the first entry would pass on +# a PATH that had thrown everything else away, which would break every build +# program that calls `git`, `python3` or a shell. +if [ "${declared#*:}" = "$inherited" ]; then + echo " ok and the inherited PATH follows it, unchanged" else - echo "FAIL: PATH was replaced, not prefixed — only $declared_entries entry" + echo "FAIL: the inherited PATH was not preserved behind the prefix" + diff <(printf '%s\n' "$inherited" | tr ':' '\n') \ + <(printf '%s\n' "${declared#*:}" | tr ':' '\n') | head -6 | sed 's/^/ /' exit 1 fi From e2b1bce0221de22743d18e69102795f7daec9adc Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:13:56 +0800 Subject: [PATCH 16/16] e2e 239: a timeout that leaves 600 seconds of silence says nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The msvc job went red on `239_msvc_managed_toolset.sh (TIMEOUT)`. Two things are wrong with the test, and neither is in mcpp. Its install step fetches ~376 MB (xim:msvc ~85 MB + xim:windows-sdk ~291 MB) and is the whole budget. Measured durations on this runner: 244 / 292 / 313 / 335 / 515s, against run_all.sh's 600s per-test default. A download-bound test whose spread nearly reaches its own deadline reports a red build for a correct one. Raised to 1500s in the one job that runs it, where the step's `timeout-minutes: 30` is the real backstop — which is what run_all.sh's comment says the per-test value sits under. And the output was captured into `$out`, so it printed nothing while it ran: the failing log has the test's header, then the harness line TIMEOUT: 239_msvc_managed_toolset.sh (exceeded 600s — likely network / xlings stall) which is a guess. There was no way to tell a stalled download from a hung install. `tee` now keeps `$out` for the assertions and puts the progress in the log, so the next timeout can be read instead of guessed at. Not introduced by this PR: the same workflow is green on main and passed twice on this branch between the two failures. --- .github/workflows/ci-windows-msvc-xlings.yml | 10 ++++++++++ tests/e2e/239_msvc_managed_toolset.sh | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-windows-msvc-xlings.yml b/.github/workflows/ci-windows-msvc-xlings.yml index a363850e..f493fb5d 100644 --- a/.github/workflows/ci-windows-msvc-xlings.yml +++ b/.github/workflows/ci-windows-msvc-xlings.yml @@ -78,6 +78,16 @@ jobs: # Name what this job runs, so the job title and its contents cannot # drift apart. Widen the glob when a second test joins. E2E_ONLY: '239_*.sh' + # ⚠️ THE DEFAULT PER-TEST CAP IS TOO CLOSE TO WHAT THIS TEST COSTS. + # 239 fetches ~376 MB, and its measured durations on this runner are + # 244 / 292 / 313 / 335 / 515s against run_all.sh's 600s default — a + # download-bound test whose spread nearly reaches its own deadline, + # so a slow mirror reports a red build for a correct one. + # + # Raised only here, where the job runs that one test and the step's + # own `timeout-minutes: 30` is the real backstop — which is what + # run_all.sh's comment says the per-test value is meant to sit under. + E2E_TEST_TIMEOUT: '1500' run: | export MCPP="$MCPP_SELF" export MCPP_VENDORED_XLINGS="$XLINGS_BIN" diff --git a/tests/e2e/239_msvc_managed_toolset.sh b/tests/e2e/239_msvc_managed_toolset.sh index d66b7402..e6fc9e97 100755 --- a/tests/e2e/239_msvc_managed_toolset.sh +++ b/tests/e2e/239_msvc_managed_toolset.sh @@ -60,7 +60,21 @@ if ! "$MCPP" toolchain list 2>&1 | grep -q "$TOOLSET"; then fi # 1) install it. Any failure from here on is a FAILURE. -rc=0; out=$("$MCPP" toolchain install msvc "$TOOLSET" 2>&1) || rc=$? +# +# ⚠️ STREAMED AS WELL AS CAPTURED, BECAUSE THIS STEP IS THE ONE THAT RUNS OUT +# OF TIME. It fetches ~376 MB (xim:msvc ~85 MB + xim:windows-sdk ~291 MB), and +# `out=$(...)` alone prints nothing while it does — so a run killed by the +# per-test deadline leaves 600 seconds of silence and a harness line that can +# only guess ("likely network / xlings stall"). Measured 2026-08-25 in CI: +# exactly that, with no way to tell a stalled download from a hung install. +# +# `tee` keeps `$out` for the assertions below and puts the progress in the log +# where a timeout can be read afterwards. +echo "--- installing msvc $TOOLSET (~376 MB: toolset + Windows SDK) ---" +log="$TMP/install.log" +"$MCPP" toolchain install msvc "$TOOLSET" 2>&1 | tee "$log" +rc=${PIPESTATUS[0]} +out=$(cat "$log") if [[ $rc -ne 0 ]]; then echo "FAIL: install msvc $TOOLSET (rc=$rc):" echo "$out"