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/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index bfaf5f21..2c686df8 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" @@ -222,3 +241,146 @@ 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 + + # ⚠️ 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 + 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 + # ⭐ 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 \ + "OK: the C library decides whether 'dynamic' can be honoured" || fail=1 + [ "$fail" = 0 ] || exit 1 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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 997c08a5..1c74eb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,225 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.25.1] — 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。 + + ⭐ **判据是层的来源,不是情形的列举。** `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, … }`。夹具现在照实写。 + +- **⭐⭐ 缓存键漏掉了新参数,于是跨着一处不兼容命中了。** + + `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 库。** + + ``` + 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 库在用时它才可用。 + +- **⭐ 第四条同型:`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` 前置项目声明的那个环境。** + + ``` + PATH=<被声明环境的 bin>: + ``` + + 构建程序想用某个工具,此前只能像 shell 脚本一样去问 `PATH` —— 而 `command -v` + 回答的是「这台机器有什么」,不是「这次构建用什么」。实测代价: + `command -v qemu-system-riscv64` 命中一个 shim,执行时答 + `[error] qemu-system-riscv64 is not installed in this subos` —— 找到了、报告 + 为存在、却跑不了,而可用的那份就在项目自己的环境里,不在 `PATH` 上。 + + ⚠️ **只对声明了 `[xlings].subos` 的项目生效,没声明的逐字节不变。** 一个更早 + 的草案无条件前置 mcpp 共享的 `subos/default/bin`,那会让「构建看见什么」取决 + 于这台机器上还装过什么 —— 同一台机器上的两个项目彼此一致,而同一个项目在两台 + 机器上不一致。是声明本身把它放到前面的。 + + ⚠️ **前置而非替换。** 构建程序合理地会调 `git`、`python3`、shell,这些都不在 + SubOS 里;只有被声明目录的 `PATH` 会把它们全部弄坏。 + + ⭐ **没有新的决定点。** `mcpp::xlings::runtime` 早就是「项目用哪个 SubOS」的 + 唯一策略,`RuntimeBinding::subosDir` 是它已解析的答案;本次只是把这个答案多交 + 付给一个消费者。`projectSubosBin` 在绑定解析后算**一次**,两个交付点各自取用。 + 本次发布修的三条缺陷全部来自「一个事实在多处各自推导」。各包的载荷路径由 + `MCPP_XPKG_*_DIR` 另行回答,与 `PATH` 是两个问题。 + + 新增 `examples/07-project-subos/` 与 [第 17 章](docs/17-the-project-environment.md)。 + +### 测试 + +- 五条单元测试,**按 `Origin` 的四个值各一条**,外加一条把缺陷本身写成断言 + (内核接口的来源不参与这个决定)。按枚举写而不按想到的情形写:后者只覆盖 + 作者想到的,前者在枚举新增取值时会留下可见的缺口。 + +- 三条缓存键测试,**直接打在 `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 的覆盖,这里一个循环 | + | 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 同形,同因。 + + ⭐ **这个 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/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 7986bb96..4d5488a0 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -482,6 +482,41 @@ 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` — the environment the project declared (mcpp 2026.8.25.1+) + +A project that declares `[xlings].subos` runs its build programs with that +environment's `bin` at the front of `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 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 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+) A dependency that ships a `build.mcpp` gets it compiled and run too (the 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/mcpp.toml b/mcpp.toml index 8e95ee3d..d1f40df4 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.24.6" +version = "2026.8.25.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 887e86d8..eb3e4546 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -105,6 +105,38 @@ 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 `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 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 (_) + // + // — 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. + // + // ⚠️ 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. + // + // ⚠️ 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 // ordinary mcpp packages (`import mcpp.rules.protobuf;`) instead of a @@ -412,6 +444,23 @@ 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 the project's own SubOS in front ──────────── + // + // 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()) { + std::string path = env.toolsBin; + // ⚠️ 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; + } + 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/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/build/flags.cppm b/src/build/flags.cppm index 75b1809f..1e332dbb 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; @@ -1005,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 @@ -1229,7 +1248,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.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 @@ -1423,7 +1448,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 + // 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/build/prepare.cppm b/src/build/prepare.cppm index bac66cd9..7f958eca 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1301,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(); @@ -5542,6 +5567,7 @@ prepare_build(bool print_fingerprint, // 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); + bpEnv.toolsBin = projectSubosBin; bpEnv.profile = effectiveProfile; bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp" / "deps" @@ -5955,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 " @@ -6019,6 +6057,7 @@ prepare_build(bool print_fingerprint, // 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); + 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/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/src/targetside/model.cppm b/src/targetside/model.cppm index 67ced1a6..382b11a7 100644 --- a/src/targetside/model.cppm +++ b/src/targetside/model.cppm @@ -194,6 +194,39 @@ 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 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 c50c6245..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.6"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.25.1"; } // namespace mcpp 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" 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..bf294df2 --- /dev/null +++ b/tests/e2e/285_kernel_abi_from_graph_keeps_the_payload_c_library.sh @@ -0,0 +1,132 @@ +#!/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 +# ⭐ 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' +#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=$? + +# 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 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 +fi + +echo "OK: a graph-supplied kernel interface leaves the payload's C library reachable" 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..29d14208 --- /dev/null +++ b/tests/e2e/286_the_openkal_stack_still_builds.sh @@ -0,0 +1,143 @@ +#!/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 + 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" + printf '%s\n' "$phdrs" | grep -A1 INTERP | sed 's/^/ /' + exit 1 + fi + 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 ───────────── +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..0d6b8118 --- /dev/null +++ b/tests/e2e/287_the_openkal_stack_crosses_to_aarch64.sh @@ -0,0 +1,163 @@ +#!/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 + +# ⚠️⚠️ 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 + 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 + 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 + +# ── 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" 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..e3528d6d --- /dev/null +++ b/tests/e2e/288_the_openkal_stack_on_a_machine_with_no_os.sh @@ -0,0 +1,174 @@ +#!/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" + +# ⚠️ `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. +[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 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" + echo " $c_abi_line"; exit 1 ;; +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 ────────────────── +# ⚠️ 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" +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 <&1 || true)" + printf '%s\n' "$out" | grep -oP 'PROBE_PATH=\K.*' | head -1 +} + +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" +} + +# 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")" +if [ -z "$plain" ]; then + echo "SKIP: the build program did not report — it may not have run here" + exit 0 +fi + +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 ───────────────────────────── +# +# `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 + +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 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: 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 + +echo "OK: the declaration puts an environment in front, and only the declaration does" 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..2f208631 --- /dev/null +++ b/tests/e2e/291_dynamic_linkage_is_refused_only_when_the_c_library_is_the_graphs.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# 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 +# 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. +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 + 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" diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index 274ee906..e579bf83 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -291,3 +291,79 @@ TEST(CacheKey, GeneratedFilesAreOrderIndependent) { EXPECT_EQ(build({{"a.h", "1"}, {"b.h", "2"}}), build({{"b.h", "2"}, {"a.h", "1"}})); } + +// ── The key must describe the compilation that will happen ────────────────── +// +// ⚠️⚠️ THIS KEY ONCE DERIVED ITS OWN INPUTS INSTEAD OF READING THE BUILD'S. +// +// `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, and so 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, so the +// second build loaded the first's BMIs: +// +// error: exception handling was enabled in precompiled file +// 'openkal.stream.pcm' but is currently disabled +// +// In the cache itself, `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. +// +// ⚠️ IT WAS DORMANT UNTIL THE PARAMETER EXISTED. Before the flag became +// conditional the two computations agreed for every input, so ignoring one of +// them was still correct. That is what makes the test worth writing against +// `build_axes` rather than against a hand-built `BuildAxes`: the fixture above +// cannot express the defect, because the defect is in the derivation. +namespace { +mcpp::toolchain::Toolchain freestanding_tc(bool cxxRuntimeFromGraph) { + mcpp::toolchain::Toolchain tc; + tc.compiler = mcpp::toolchain::CompilerId::Clang; + tc.version = "22.1.8"; + tc.driverIdent = "clang-22.1.8"; + tc.targetTriple = "riscv64-none-elf"; + tc.targetCxxRuntime = cxxRuntimeFromGraph; + return tc; +} +} + +TEST(CacheKey, AGraphSuppliedCxxRuntimeChangesTheFreestandingFlags) { + mcpp::manifest::Manifest m; + m.package.standard = "c++23"; + const auto without = ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""); + const auto with = ck::build_axes(freestanding_tc(true), m, "-std=c++23", {}, ""); + + // The flags themselves differ — this is the fact the key has to carry. + EXPECT_NE(without.targetImpliedFlags, with.targetImpliedFlags); + + // And the one that has no C++ runtime is the one that gets the pair. + const auto has = [](const std::vector& 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); +} 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()); + } +}