From 09bc6b6203f9853fa8c280c2780325cc1440c370 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:53:28 +0800 Subject: [PATCH 01/14] =?UTF-8?q?refactor(platform):=20xlings=20=E5=BC=BA?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=A8=A1=E5=9D=97=E5=BD=92=E5=85=A5=20src/pl?= =?UTF-8?q?atform/xlings/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xlings 是 mcpp 面对的一个平台 —— 它决定 PT_INTERP、libc、库视图和运行期环境, 与 linux/macos/windows 处在同一层。此前它散在 src/xlings.cppm 与 src/xlings/, 读起来像一个横切工具层,而它其实是分层里最底下那一层。 src/xlings.cppm → src/platform/xlings/xlings.cppm src/xlings/subos_info.cppm → src/platform/xlings/subos_info.cppm src/xlings/runtime_selection.cppm → src/platform/xlings/runtime_selection.cppm 模块名随目录走(mcpp.platform.xlings[.*]),与 src/platform// 的既有约定一致。 命名空间保持 mcpp::xlings::* 不动:238 处调用点的重命名换不来任何架构收益,而 src/platform/unix/bounded_process.cppm 导出 mcpp::platform::unixproc 已经说明 这个仓库不要求模块路径与命名空间逐段镜像。 RuntimeBinding 与后续的 runtime_search 留在 src/platform/ —— 它们是 provider 中立的契约类型(RuntimeBinding 有 providerId 字段正是为此),不是 xlings 专属。 顺带把三处引用旧路径的文本改掉:check_version_pins.sh 读 kXlingsVersion 的 grep 目标、bootstrap-mcpp action 的注释、发布 skill 的「唯一真源」表格。 其中第一处是硬的:不改它,pin 校验会以「读不到 kXlingsVersion」失败 —— 而它 是唯一能机器化捕捉 pin 漂移的东西。 --- .agents/skills/mcpp-release/SKILL.md | 6 +++--- .github/actions/bootstrap-mcpp/action.yml | 4 ++-- .github/tools/check_version_pins.sh | 10 +++++----- src/build/execute.cppm | 4 ++-- src/config.cppm | 4 ++-- src/fallback/probe_sysroot.cppm | 2 +- src/pack/pack.cppm | 2 +- src/{ => platform}/xlings/runtime_selection.cppm | 4 ++-- src/{ => platform}/xlings/subos_info.cppm | 4 ++-- src/{ => platform/xlings}/xlings.cppm | 6 +++--- src/pm/commands.cppm | 2 +- src/pm/index_management.cppm | 2 +- src/pm/index_refresh.cppm | 4 ++-- src/pm/package_fetcher.cppm | 2 +- src/toolchain/clang.cppm | 2 +- src/toolchain/detect.cppm | 2 +- src/toolchain/gcc.cppm | 2 +- src/toolchain/lifecycle.cppm | 2 +- src/toolchain/msvc.cppm | 2 +- src/toolchain/post_install.cppm | 4 ++-- src/toolchain/probe.cppm | 2 +- src/version.cppm | 4 ++-- tests/unit/test_pm_index_refresh.cpp | 2 +- tests/unit/test_subos_info.cpp | 2 +- tests/unit/test_xlings.cpp | 4 ++-- 25 files changed, 42 insertions(+), 42 deletions(-) rename src/{ => platform}/xlings/runtime_selection.cppm (94%) rename src/{ => platform}/xlings/subos_info.cppm (99%) rename src/{ => platform/xlings}/xlings.cppm (99%) diff --git a/.agents/skills/mcpp-release/SKILL.md b/.agents/skills/mcpp-release/SKILL.md index 84c65b7b..3140d7be 100644 --- a/.agents/skills/mcpp-release/SKILL.md +++ b/.agents/skills/mcpp-release/SKILL.md @@ -59,7 +59,7 @@ unexpected`。那是调用它的 shell 的问题,不是脚本的缺陷 —— `#!/usr/bin/env bash`,CI 也是用 `bash` 调的。别据此把这条 guard 当成坏的而跳过: 它是唯一能机器化捕捉 pin 漂移的东西。 -也不要通过修改文档或 workflow 绕开动态 `MCPP_PIN` 设计。`src/xlings.cppm` 的 +也不要通过修改文档或 workflow 绕开动态 `MCPP_PIN` 设计。`src/platform/xlings/xlings.cppm` 的 `pinned::kXlingsVersion` 仍是 xlings 版本的唯一真源。 ## 发布步骤 @@ -259,7 +259,7 @@ gh workflow run bump-formula.yml -R mcpp-community/homebrew-mcpp | 自查 `--version` 显示旧版本,但源码已改 | `target//<指纹>/` 的指纹随版本变,`ls \| head -1` 取到了上一次构建的目录 | 用 `ls -dt … \| head -1` 取最新构建 | | Smoke test 输出旧版本 | CI 缓存了旧的 sandbox/target | 删除 GitHub Actions cache 后重跑 | | e2e `01_help_and_version.sh` 挂 | 只改了 `mcpp.toml` 没改 `fingerprint.cppm`(它把两者交叉比对) | 同步两处正在构建的版本;注意这个 e2e 只在部分分片里跑,可能表现为"只有某个平台红" | -| xlings bootstrap 失败 | xlings 版本不兼容 | 改 `src/xlings.cppm::kXlingsVersion`(唯一真源),再核对引用它的 workflow 与脚本;当前 pin-check 脚本修复前不能依赖它完成扫描 | +| xlings bootstrap 失败 | xlings 版本不兼容 | 改 `src/platform/xlings/xlings.cppm::kXlingsVersion`(唯一真源),再核对引用它的 workflow 与脚本;当前 pin-check 脚本修复前不能依赖它完成扫描 | | macOS/Windows 构建失败 | 需要等 Linux job 先完成 | 检查 Linux job 是否成功 | | `slim: FAIL: ... still not stripped` | strip 工具没生效/被 pack 覆盖 | 别绕过断言——它就是为了拦住 34.8MB 的 tarball 再次发出去 | | mirror leg 报 `missing/unverified` | 资产没传上去或还没传播 | 先 GET 核验(**必须 GET,`curl -I` 会骗你**),gitcode 用 `gitcode.com` 直链而非 `api.` 主机;确认缺件后本地补传再 `gh run rerun --failed`(脚本幂等,已验证的资产会跳过) | @@ -312,7 +312,7 @@ gh workflow run release.yml --ref "v$NEW_VERSION" | `src/version.cppm` | `MCPP_VERSION = "X.Y.Z"` — 编译期版本常量 | | `.xlings.json` | `workspace.mcpp` — CI bootstrap 装哪个 mcpp(发布**后**才 bump) | | `.github/workflows/ci-fresh-install.yml` | `MCPP_PIN` — 由 `wait-index` 从最新 release 推导,**从不手工 bump** | -| `src/xlings.cppm` | `kXlingsVersion` — xlings pin 的**唯一真源** | +| `src/platform/xlings/xlings.cppm` | `kXlingsVersion` — xlings pin 的**唯一真源** | | `.github/tools/check_version_pins.sh` | 版本/pin 校验 guard(**用 `bash` 跑,不能用 `sh`**) | | `.github/tools/slim_linux_payload.sh` | linux 载荷 strip + 断言 | | `.github/tools/mirror_res.sh` | 双端镜像(并发上传 + leg deadline + 完整性 gate) | diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 7e18b592..cf0628eb 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -13,7 +13,7 @@ inputs: xlings-version: description: xlings release to bootstrap from required: false - # Must equal `pinned::kXlingsVersion` in src/xlings.cppm — enforced by + # Must equal `pinned::kXlingsVersion` in src/platform/xlings/xlings.cppm — enforced by # .github/tools/check_version_pins.sh, not by this comment. (The previous # comment here listed the files to keep in lock-step, and that list was # already incomplete; that is why the check exists.) @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.11.1' + default: '2026.8.11.2' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/tools/check_version_pins.sh b/.github/tools/check_version_pins.sh index 438005d8..30f10eb5 100755 --- a/.github/tools/check_version_pins.sh +++ b/.github/tools/check_version_pins.sh @@ -5,13 +5,13 @@ # Two invariants that used to live only in a comment: # # 1. Every xlings version pinned anywhere in .github/ equals -# `pinned::kXlingsVersion` in src/xlings.cppm — which is the version +# `pinned::kXlingsVersion` in src/platform/xlings/xlings.cppm — which is the version # `mcpp self env` reports and the one release.yml bundles into the # tarball as /registry/bin/xlings. # # 2. mcpp's own version is identical in all four places that carry it. # -# Why this exists: src/xlings.cppm used to say "keep in lock-step with the +# Why this exists: src/platform/xlings/xlings.cppm used to say "keep in lock-step with the # XLINGS_VERSION pins in release.yml / cross-build-test.yml / ci-linux-e2e.yml" # and that list was ALREADY incomplete — it omitted both composite actions, # which sat on 0.4.30 while everything else moved to 0.4.69. CI's sandbox @@ -38,12 +38,12 @@ strip_comments() { sed 's/#.*//'; } # ── 1. xlings pins ──────────────────────────────────────────────────────── -XLINGS_EXPECTED=$(grep -oE 'kXlingsVersion[[:space:]]*=[[:space:]]*"[^"]+"' src/xlings.cppm \ +XLINGS_EXPECTED=$(grep -oE 'kXlingsVersion[[:space:]]*=[[:space:]]*"[^"]+"' src/platform/xlings/xlings.cppm \ | grep -oE '"[^"]+"' | tr -d '"' | head -1) [ -n "$XLINGS_EXPECTED" ] || { - echo "FAIL: could not read kXlingsVersion from src/xlings.cppm" >&2; exit 1; } + echo "FAIL: could not read kXlingsVersion from src/platform/xlings/xlings.cppm" >&2; exit 1; } -note "expected xlings pin: $XLINGS_EXPECTED (src/xlings.cppm)" +note "expected xlings pin: $XLINGS_EXPECTED (src/platform/xlings/xlings.cppm)" # Anchored patterns only — a bare "version-looking number on a line mentioning # xlings" would also match `xlings install llvm@20.1.7`, which pins LLVM, not diff --git a/src/build/execute.cppm b/src/build/execute.cppm index a43c4869..68acae33 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -24,8 +24,8 @@ import mcpp.source_kind; import mcpp.modgraph.scanner; import mcpp.toolchain.post_install; import mcpp.toolchain.stdmod; -import mcpp.xlings; -import mcpp.xlings.subos_info; +import mcpp.platform.xlings; +import mcpp.platform.xlings.subos_info; import mcpp.platform.runtime_binding; import mcpp.log; import mcpp.platform; diff --git a/src/config.cppm b/src/config.cppm index cd49375a..ea7367be 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -24,7 +24,7 @@ import std; import mcpp.home; import mcpp.libs.toml; import mcpp.pm.index_spec; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.platform; import mcpp.log; import mcpp.fallback.xlings_binary; @@ -412,7 +412,7 @@ void ensure_sandbox_xlings_binary(const GlobalConfig& /*cfg*/, bool /*quiet*/) n // Intentional no-op: xlingsBinary == xlingsHome()/bin/xlings. } -// Bootstrap install: delegated to mcpp.xlings module. +// Bootstrap install: delegated to mcpp.platform.xlings module. void ensure_sandbox_ninja(const GlobalConfig& cfg, bool quiet, const BootstrapProgressCallback& cb) noexcept diff --git a/src/fallback/probe_sysroot.cppm b/src/fallback/probe_sysroot.cppm index dc91adb9..42f6e691 100644 --- a/src/fallback/probe_sysroot.cppm +++ b/src/fallback/probe_sysroot.cppm @@ -11,7 +11,7 @@ module; export module mcpp.fallback.probe_sysroot; import std; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.platform; import mcpp.log; diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 2832a6e0..d93d5623 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -24,7 +24,7 @@ import mcpp.build.loader_contract; import mcpp.config; import mcpp.pack.host_requirements; import mcpp.platform; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.manifest; export namespace mcpp::pack { diff --git a/src/xlings/runtime_selection.cppm b/src/platform/xlings/runtime_selection.cppm similarity index 94% rename from src/xlings/runtime_selection.cppm rename to src/platform/xlings/runtime_selection.cppm index 5d1eb432..472d2987 100644 --- a/src/xlings/runtime_selection.cppm +++ b/src/platform/xlings/runtime_selection.cppm @@ -1,4 +1,4 @@ -// mcpp.xlings.runtime_selection — the sole project runtime-selection policy. +// mcpp.platform.xlings.runtime_selection — the sole project runtime-selection policy. // // This module chooses a name and an owner. It deliberately does not inspect // the process environment, xlings' active/current state, the compiler path, or @@ -6,7 +6,7 @@ // any of them to choose the build OS would make one mcpp.toml mean different // ABIs in different shells. -export module mcpp.xlings.runtime_selection; +export module mcpp.platform.xlings.runtime_selection; import std; import mcpp.manifest; diff --git a/src/xlings/subos_info.cppm b/src/platform/xlings/subos_info.cppm similarity index 99% rename from src/xlings/subos_info.cppm rename to src/platform/xlings/subos_info.cppm index eb37e102..ca740550 100644 --- a/src/xlings/subos_info.cppm +++ b/src/platform/xlings/subos_info.cppm @@ -1,4 +1,4 @@ -// mcpp.xlings.subos_info — read the `subos_info` block xlings writes into a +// mcpp.platform.xlings.subos_info — read the `subos_info` block xlings writes into a // subos's own `.xlings.json`. // // WHAT THIS IS FOR @@ -33,7 +33,7 @@ // // Design: .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md §3-S3 -export module mcpp.xlings.subos_info; +export module mcpp.platform.xlings.subos_info; import std; import mcpp.libs.json; diff --git a/src/xlings.cppm b/src/platform/xlings/xlings.cppm similarity index 99% rename from src/xlings.cppm rename to src/platform/xlings/xlings.cppm index 437c6b13..2ad39b97 100644 --- a/src/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -1,4 +1,4 @@ -// mcpp.xlings — unified abstraction layer for all xlings (external package +// mcpp.platform.xlings — unified abstraction layer for all xlings (external package // manager) interactions. Consolidates NDJSON event parsing, subprocess // command building, path helpers, and bootstrap progress types that were // previously scattered across config.cppm, package_fetcher.cppm, cli.cppm, @@ -11,7 +11,7 @@ module; #include // stderr #include -export module mcpp.xlings; +export module mcpp.platform.xlings; import std; import mcpp.pm.compat; @@ -44,7 +44,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.11.1"; + inline constexpr std::string_view kXlingsVersion = "2026.8.11.2"; inline constexpr std::string_view kNasmVersion = "3.02"; } diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index 636e310b..9397db7b 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -25,7 +25,7 @@ import mcpp.pm.index_refresh; // shared refresh policy (with mcpp.build.prep import mcpp.pm.resolver; // is_version_constraint import mcpp.project; // shared find_manifest_root import mcpp.ui; -import mcpp.xlings; // index freshness +import mcpp.platform.xlings; // index freshness import mcpplibs.cmdline; namespace mcpp::pm::commands::detail { diff --git a/src/pm/index_management.cppm b/src/pm/index_management.cppm index 60895fde..4f03fe51 100644 --- a/src/pm/index_management.cppm +++ b/src/pm/index_management.cppm @@ -17,7 +17,7 @@ import mcpp.manifest; import mcpp.platform; import mcpp.project; import mcpp.ui; -import mcpp.xlings; +import mcpp.platform.xlings; namespace mcpp::pm { diff --git a/src/pm/index_refresh.cppm b/src/pm/index_refresh.cppm index 591b5d81..437306ce 100644 --- a/src/pm/index_refresh.cppm +++ b/src/pm/index_refresh.cppm @@ -4,7 +4,7 @@ // WHY THIS MODULE EXISTS // // The answer used to be derived independently in five places, and two of them -// disagreed. `mcpp.xlings`' xim gate (ensure_official_package_index_fresh) had +// disagreed. `mcpp.platform.xlings`' xim gate (ensure_official_package_index_fresh) had // been offline-first for a while — it refreshes only when a requested package // is genuinely absent locally, and its comment says in so many words that a // TTL must NOT trigger a network sync, because that is what hangs a build on a @@ -45,7 +45,7 @@ import mcpp.pm.index_contract; import mcpp.pm.index_route; import mcpp.pm.resolver; import mcpp.ui; -import mcpp.xlings; +import mcpp.platform.xlings; export namespace mcpp::pm { diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index 0ea7a0cd..3f9c010d 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -22,7 +22,7 @@ import mcpp.pm.dep_spec; import mcpp.pm.index_contract; // index.toml floor check (E0006) import mcpp.ui; import mcpp.pm.index_spec; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.libs.toml; // re-used for tiny JSON-ish parsing? no — stick with manual import mcpp.fallback.xpkg_copy; import mcpp.fallback.install_integrity; diff --git a/src/toolchain/clang.cppm b/src/toolchain/clang.cppm index 27c91b09..c7438745 100644 --- a/src/toolchain/clang.cppm +++ b/src/toolchain/clang.cppm @@ -6,7 +6,7 @@ import std; import mcpp.toolchain.model; import mcpp.toolchain.msvc; import mcpp.toolchain.probe; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.platform; export namespace mcpp::toolchain::clang { diff --git a/src/toolchain/detect.cppm b/src/toolchain/detect.cppm index 91766727..332fd5c3 100644 --- a/src/toolchain/detect.cppm +++ b/src/toolchain/detect.cppm @@ -9,7 +9,7 @@ import std; import mcpp.toolchain.clang; import mcpp.toolchain.gcc; import mcpp.toolchain.msvc; -import mcpp.xlings; +import mcpp.platform.xlings; export namespace mcpp::toolchain { diff --git a/src/toolchain/gcc.cppm b/src/toolchain/gcc.cppm index ceeabbab..b329f981 100644 --- a/src/toolchain/gcc.cppm +++ b/src/toolchain/gcc.cppm @@ -6,7 +6,7 @@ import std; import mcpp.platform; import mcpp.toolchain.model; import mcpp.toolchain.probe; -import mcpp.xlings; +import mcpp.platform.xlings; export namespace mcpp::toolchain::gcc { diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index 6b26a279..4db539ec 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -22,7 +22,7 @@ import mcpp.toolchain.triple; import mcpp.toolchain.post_install; import mcpp.ui; import mcpp.log; -import mcpp.xlings; +import mcpp.platform.xlings; namespace mcpp::toolchain { diff --git a/src/toolchain/msvc.cppm b/src/toolchain/msvc.cppm index 5149a9da..e8b325c8 100644 --- a/src/toolchain/msvc.cppm +++ b/src/toolchain/msvc.cppm @@ -20,7 +20,7 @@ import std; import mcpp.platform; import mcpp.toolchain.model; import mcpp.toolchain.probe; -import mcpp.xlings; +import mcpp.platform.xlings; export namespace mcpp::toolchain::msvc { diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index 444a6eb9..cc425330 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -19,8 +19,8 @@ import mcpp.platform; import mcpp.toolchain.linkmodel; import mcpp.toolchain.registry; import mcpp.ui; -import mcpp.xlings; -import mcpp.xlings.subos_info; +import mcpp.platform.xlings; +import mcpp.platform.xlings.subos_info; namespace mcpp::toolchain { diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm index 9d02e9f1..3fe41a57 100644 --- a/src/toolchain/probe.cppm +++ b/src/toolchain/probe.cppm @@ -14,7 +14,7 @@ export module mcpp.toolchain.probe; import std; import mcpp.toolchain.model; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.platform; import mcpp.log; import mcpp.fallback.sysroot_complete; diff --git a/src/version.cppm b/src/version.cppm index 09fb58f9..9662249c 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -5,7 +5,7 @@ // The constant used to live in mcpp.toolchain.fingerprint, next to the struct // that folds it into the BMI cache key. That is a reasonable place for a // *consumer* of the version and a bad place for the version itself: fingerprint -// imports mcpp.toolchain.detect, which imports mcpp.xlings, so "what version is +// imports mcpp.toolchain.detect, which imports mcpp.platform.xlings, so "what version is // this binary" transitively dragged in the entire toolchain-detection and // package-manager subsystem. // @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.11.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.11.2"; } // namespace mcpp diff --git a/tests/unit/test_pm_index_refresh.cpp b/tests/unit/test_pm_index_refresh.cpp index a4a912a9..ff249b97 100644 --- a/tests/unit/test_pm_index_refresh.cpp +++ b/tests/unit/test_pm_index_refresh.cpp @@ -21,7 +21,7 @@ import mcpp.pm.index_refresh; import mcpp.pm.index_route; import mcpp.pm.index_spec; import mcpp.platform.axis; -import mcpp.xlings; +import mcpp.platform.xlings; namespace { diff --git a/tests/unit/test_subos_info.cpp b/tests/unit/test_subos_info.cpp index f7622906..7101e88d 100644 --- a/tests/unit/test_subos_info.cpp +++ b/tests/unit/test_subos_info.cpp @@ -11,7 +11,7 @@ import std; import mcpp.platform; -import mcpp.xlings.subos_info; +import mcpp.platform.xlings.subos_info; namespace su = mcpp::xlings::subos; diff --git a/tests/unit/test_xlings.cpp b/tests/unit/test_xlings.cpp index d7afd3d8..9f8a8c2c 100644 --- a/tests/unit/test_xlings.cpp +++ b/tests/unit/test_xlings.cpp @@ -1,7 +1,7 @@ #include import std; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.platform.env; namespace { @@ -23,7 +23,7 @@ std::filesystem::path make_tempdir(std::string_view name) { // `find_sandbox_nasm`, tested here) and a SEPARATE synchronous // provisioning step in mcpp.build.prepare that goes through the same // `Fetcher::resolve_xpkg_path` gate the compiler toolchain uses (that step -// needs `mcpp.config`/`mcpp.fetcher`, which this LEAF module — mcpp.xlings +// needs `mcpp.config`/`mcpp.fetcher`, which this LEAF module — mcpp.platform.xlings // — cannot import, so it isn't unit-testable from here without network; // see tests/e2e/105_asm_sources_nasm.sh for the end-to-end coverage). From 797b4f5dfba7ac61645c58ee150cf0fd1f913add Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:53:48 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix(runtime):=20SubOS=20=E7=BC=BA?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E9=99=8D=E7=BA=A7,=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AE=A9=E6=9E=84=E5=BB=BA=E5=A4=B1=E6=95=88=20(openxlings/xli?= =?UTF-8?q?ngs#543)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 上 xlings 不写 subos_info 块,而 mcpp 把「缺声明」当成错误返回, prepare 无平台条件地把它变成 std::unexpected —— 于是每一次 mcpp build / mcpp test 都停在一条讲 GL 驱动的消息上,在一台没有 ELF、没有 PT_INTERP、没有私有 libc 的机器上。 error: selected SubOS 'default' cannot provide a RuntimeBinding: … does not describe itself … a GL application will not find its drivers 回归窗口已核:git log -S 只命中 PR #400(它把返回 string 的软函数换成了 expected), 且该 commit 在 2026.8.8.4 的 bump 之后 ⇒ 首次随 2026.8.10.2 发布。与报告 「降级到 2026.8.8.4 就能跑」逐字吻合。 判据改为一句话:矛盾报错,缺席降级。 点名的 SubOS 不存在 → 仍是硬错误。该请求无法被满足,换一个环境会让同一份 mcpp.toml 在不同机器上意味着不同 ABI。 SubOS 没有描述自己 → declared=false + 一条调用方必须打印的 note,构建继续。 runtime 规则报 inconclusive 而不是给出判决。 同一位置的第二颗雷一并拆掉:schema 检查是 !=,而它的读取器 subos_info::read 明写着 「更高的 schema 照读,取我们认识的字段」。xlings 写出 schema 2 的那天,全平台所有 构建会同时停摆 —— 这是 index-floor 事故的第二次转生。改为上限语义:发布数据不得 使读它的程序失效。 诚实的边界:Linux 上降级 binding 确实拿不到 payload(mcpp 拒绝猜一个 libc 版本), 所以链接会回落宿主并被 hermeticity 检查如实拦下。note 现在把这句话说出来,而不是 承诺「构建不受影响」—— e2e 221 断言的正是这个:失败可以,但必须是关于 C 运行时的 失败,不能是关于 binding 的失败。 e2e 221 刻意不声明任何能力。带 `# requires: elf` 或 `gcc` 会让它在唯一需要它的平台 上被跳过 —— 与 217 从未在 Windows/macOS 跑过是同一形状。 RuntimeBinding 同时新增 searchDirs 字段(SubOS 库视图),本 commit 内无消费者。 --- src/build/prepare.cppm | 30 +++- src/platform/runtime_binding.cppm | 142 ++++++++++++++-- .../221_subos_without_info_still_builds.sh | 159 ++++++++++++++++++ tests/unit/test_runtime_selection.cpp | 107 +++++++++++- 4 files changed, 416 insertions(+), 22 deletions(-) create mode 100755 tests/e2e/221_subos_without_info_still_builds.sh diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index ebd95691..87cc66bf 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -46,10 +46,11 @@ import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.lockfile; import mcpp.config; -import mcpp.xlings; -import mcpp.xlings.subos_info; -import mcpp.xlings.runtime_selection; +import mcpp.platform.xlings; +import mcpp.platform.xlings.subos_info; +import mcpp.platform.xlings.runtime_selection; import mcpp.platform.runtime_binding; +import mcpp.platform.runtime_search; import mcpp.toolchain.post_install; import mcpp.platform; import mcpp.fetcher; @@ -1293,6 +1294,12 @@ prepare_build(bool print_fingerprint, runtimeSelection, {}, **cfgRuntime); if (!resolved) return std::unexpected(resolved.error()); runtimeBindingSnapshot = std::move(*resolved); + // A degradation that nobody prints is indistinguishable from no + // degradation, which is the failure this whole area keeps paying for. + // A note is not a warning: nothing is wrong with the build, some facts + // are simply unavailable — so it is reported once, at info level. + if (!runtimeBindingSnapshot.note.empty()) + mcpp::ui::info("Runtime", runtimeBindingSnapshot.note); } const auto runtimePayload = runtimeBindingSnapshot.libc.value_or(""); const auto runtimeLibDir = runtimeBindingSnapshot.libraryDirs.empty() @@ -6394,12 +6401,29 @@ prepare_build(bool print_fingerprint, const bool macho = triple.find("darwin") != std::string::npos || triple.find("apple") != std::string::npos; std::string format = pe ? "pe" : macho ? "macho" : "elf"; + // The ORDERED run-time search closure with provenance. Order is + // semantics here, not presentation: it is what the loader will walk, + // and the mutable SubOS farm sitting last is the invariant that keeps + // libc resolving from the pinned payload. Recorded so "why does my GL + // program find its driver" is answerable without readelf, and so a + // regression in the ordering is visible to CI and to `mcpp why`. + nlohmann::json closure = nlohmann::json::array(); + for (auto const& dir : ctx.plan.runtimeSearch) { + closure.push_back({ + {"path", dir.path.generic_string()}, + {"origin", std::string( + mcpp::platform::search::to_string(dir.origin))}, + {"machine_local", + mcpp::platform::search::is_machine_local(dir.origin)}, + }); + } nlohmann::json search = { {"format", format}, {"link_library", pe ? "libpath" : "library_path"}, {"transitive_needed", format == "elf" ? "rpath_link" : "none"}, {"runtime", format == "pe" ? "deploy" : format == "macho" ? "loader_rpath" : "runpath"}, + {"closure", closure}, }; j["runtime"] = { {"library_dirs", dirs}, diff --git a/src/platform/runtime_binding.cppm b/src/platform/runtime_binding.cppm index bdd95b46..ea84bd06 100644 --- a/src/platform/runtime_binding.cppm +++ b/src/platform/runtime_binding.cppm @@ -11,8 +11,8 @@ import std; import mcpp.config; import mcpp.libs.json; import mcpp.platform; -import mcpp.xlings.runtime_selection; -import mcpp.xlings.subos_info; +import mcpp.platform.xlings.runtime_selection; +import mcpp.platform.xlings.subos_info; export namespace mcpp::platform::runtime { @@ -28,7 +28,17 @@ struct RuntimeBinding { std::optional loader; std::optional libc; std::optional hostLibc; + // IMMUTABLE payload directories (`/xim-x-glibc/2.39/lib64`). std::vector libraryDirs; + // The SubOS symlink farm (`/lib`) — a union view of everything + // installed into this environment, rewritten on every re-resolution. + // + // Deliberately a SECOND field rather than more entries in `libraryDirs`: + // merging them discards the immutability distinction, and that + // distinction is the whole of `mcpp.platform.runtime_search`'s ordering + // rule. A payload directory and a farm directory are not interchangeable + // even when they currently resolve to the same file. + std::vector searchDirs; std::vector environment; std::vector providerBindings; std::vector capabilities; @@ -37,6 +47,32 @@ struct RuntimeBinding { std::string provenance; std::filesystem::path subosDir; mcpp::xlings::runtime::RuntimeSelection selection; + + // Did the SubOS describe itself (does it carry a `subos_info` block)? + // + // FALSE IS NOT AN ERROR. A SubOS that says nothing leaves some facts + // unknown — rules A/B become inconclusive, declared environment is + // unavailable — and leaves everything else working. Treating absence as a + // failure is what stopped every `mcpp build` and `mcpp test` on Windows + // (openxlings/xlings#543), on a machine where the missing facts describe + // concepts (ELF, PT_INTERP, a private libc) that do not exist there. + // + // A CONTRADICTION still fails: naming a SubOS that is not present cannot + // be satisfied, so it is reported rather than degraded. + bool declared = false; + + // Why something degraded. Non-empty ⇒ the caller MUST surface it. Never + // an error: "it did not happen" and "it succeeded" producing identical + // output is the property that made mcpp#352 expensive. + std::string note; + + // Does this artifact run under a PRIVATE loader? + // + // The predicate the closure resolver needs: when PT_INTERP points into a + // payload, the HOST loader's built-in default directories are not part of + // the search path, and modelling them is how a binary that cannot start + // was reported as valid. + bool hermetic() const { return loader.has_value(); } }; namespace detail { @@ -80,6 +116,14 @@ std::string canonical_contract(const RuntimeBinding& binding) { append_field(out, binding.hostLibc.value_or("")); for (auto const& p : binding.libraryDirs) append_field(out, p.generic_string()); + // The farm participates in the hash because it participates in the + // artifact: it lands in DT_RPATH, so a build made against one farm is not + // interchangeable with a build made against another. `declared` is in for + // the same reason — a SubOS that gains self-description changes what the + // build knows, and the fast path must not reuse the older answer. + append_field(out, binding.declared ? "declared" : "undeclared"); + for (auto const& p : binding.searchDirs) + append_field(out, p.generic_string()); for (auto const& provider : binding.providerBindings) append_field(out, provider); for (auto const& d : binding.environment) { @@ -213,22 +257,51 @@ resolve_runtime_binding( selection.subosName, out.subosDir.string())); } + // CONTRADICTION vs ABSENCE. The check above is a contradiction: the user + // named a SubOS that is not there, and no amount of degrading makes that + // request satisfiable. Everything below is absence — some facts are + // unavailable, the rest of the build is unaffected — so it degrades. + // + // The distinction is not academic. Collapsing it is what made every + // `mcpp build` and `mcpp test` on Windows fail with a message about GL + // drivers (openxlings/xlings#543), and it is the same shape as the index + // floor incident: DATA THAT IS MISSING OR NEWER MUST NOT INVALIDATE THE + // PROGRAM THAT READS IT. + auto note = [&](std::string message) { + if (!out.note.empty()) out.note += "\n"; + out.note += std::move(message); + }; + auto info = mcpp::xlings::subos::read(out.subosDir); + out.declared = info.present; if (!info.present) { - return std::unexpected(std::format( - "selected SubOS '{}' cannot provide a RuntimeBinding: {}", - selection.subosName, info.note)); - } - if (info.schema != mcpp::xlings::subos::kSupportedSchema) { - return std::unexpected(std::format( - "selected SubOS '{}' uses runtime contract schema {}, but this " - "mcpp requires schema {}; update xlings/mcpp before building", + note(std::format( + "SubOS '{}' does not describe itself: {}\n" + " Runtime facts (identity, loader, declared environment) are " + "unavailable: runtime rules report `inconclusive` rather than a " + "verdict, and a program launched from here gets no environment this " + "SubOS declares.\n" + " Where the C runtime comes from a payload, there is now no " + "declared runtime to bind to — mcpp declines to guess a version, so " + "the link falls back to the host and the hermeticity check will say " + "so. `xlings self update` writes the block.", + selection.subosName, + info.note.empty() ? "no `subos_info` block" : info.note)); + } else if (info.schema > mcpp::xlings::subos::kSupportedSchema) { + // Mirrors `subos_info::read`, which already reads a HIGHER schema and + // says so. A consumer stricter than its own reader is a time bomb: + // the day xlings writes schema 2, an equality check stops every build + // on every platform. + note(std::format( + "SubOS '{}' declares runtime contract schema {}, newer than the {} " + "this mcpp understands; using the fields it knows", selection.subosName, info.schema, mcpp::xlings::subos::kSupportedSchema)); } - if (info.runtime.empty()) { - return std::unexpected(std::format( - "selected SubOS '{}' has no runtime identity in subos_info.runtime", + if (info.present && info.runtime.empty()) { + note(std::format( + "SubOS '{}' has no runtime identity in subos_info.runtime; runtime " + "rules cannot be evaluated for artifacts built here", selection.subosName)); } @@ -247,11 +320,27 @@ resolve_runtime_binding( out.libc = info.runtime; if (!info.hostGlibc.empty()) out.hostLibc = info.hostGlibc; - // Resolve the selected SubOS VIEW to its immutable payload. The view - // already embodies RuntimeSelection, so following these exact links is - // not payload discovery and cannot choose another installed version. + // ONE traversal, TWO answers. + // + // searchDirs the view directory itself — the farm, where every + // library this environment installed is reachable by + // SONAME (`-lGL` already resolves here, because + // `--sysroot=` makes it the linker's default). + // libraryDirs the immutable payload the view's libc RESOLVES to. + // + // Deriving both here rather than in two places is the point: the + // layout knowledge (`lib64` before `lib`) exists exactly once. + // + // The view already embodies RuntimeSelection, so following these exact + // links is not payload discovery and cannot choose another installed + // version. std::vector candidates{ out.subosDir / "lib64", out.subosDir / "lib"}; + for (auto const& candidate : candidates) { + std::error_code fec; + if (std::filesystem::is_directory(candidate, fec)) + out.searchDirs.push_back(candidate.lexically_normal()); + } for (auto const& candidate : candidates) { std::error_code lec; auto libc = candidate / "libc.so.6"; @@ -322,6 +411,11 @@ std::string serialize_runtime_binding(const RuntimeBinding& binding) { j["library_dirs"] = nlohmann::json::array(); for (auto const& path : binding.libraryDirs) j["library_dirs"].push_back(path.generic_string()); + j["declared"] = binding.declared; + j["note"] = binding.note; + j["search_dirs"] = nlohmann::json::array(); + for (auto const& path : binding.searchDirs) + j["search_dirs"].push_back(path.generic_string()); j["environment"] = nlohmann::json::array(); for (auto const& decl : binding.environment) j["environment"].push_back({ @@ -391,6 +485,11 @@ deserialize_runtime_binding(std::string_view encoded) { if (auto it = j.find("library_dirs"); it != j.end() && it->is_array()) for (auto const& v : *it) if (v.is_string()) out.libraryDirs.emplace_back(v.get()); + out.declared = j.value("declared", false); + out.note = j.value("note", ""); + if (auto it = j.find("search_dirs"); it != j.end() && it->is_array()) + for (auto const& v : *it) if (v.is_string()) + out.searchDirs.emplace_back(v.get()); if (auto it = j.find("environment"); it != j.end() && it->is_array()) { for (auto const& v : *it) { if (!v.is_object()) continue; @@ -456,8 +555,15 @@ deserialize_runtime_binding(std::string_view encoded) { : mcpp::xlings::runtime::RuntimeSelection::Source::DefaultPolicy; out.selection.subosName = s.value("name", "default"); out.selection.ownerRoot = s.value("owner_root", ""); - if (out.schema == 0 || out.runtimeId.empty() - || out.contractHash.empty() || out.subosDir.empty()) + // Completeness is conditional on `declared`. An UNDECLARED binding + // legitimately has schema 0 and no runtime identity — that is what + // "the SubOS said nothing" looks like — so demanding those fields + // would make every cached degraded binding undecodable and send the + // build back down the slow path forever. The hash still has to match, + // which is what actually proves the record was not tampered with. + if (out.contractHash.empty() || out.subosDir.empty()) + return std::unexpected("cached RuntimeBinding is incomplete"); + if (out.declared && (out.schema == 0 || out.runtimeId.empty())) return std::unexpected("cached RuntimeBinding is incomplete"); if (detail::hash_contract(detail::canonical_contract(out)) != out.contractHash) diff --git a/tests/e2e/221_subos_without_info_still_builds.sh b/tests/e2e/221_subos_without_info_still_builds.sh new file mode 100755 index 00000000..9f43221c --- /dev/null +++ b/tests/e2e/221_subos_without_info_still_builds.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# requires: +# 221_subos_without_info_still_builds.sh — absence degrades, it does not +# invalidate. +# +# THE REGRESSION THIS PINS (openxlings/xlings#543) +# +# A SubOS with no `subos_info` block made `resolve_runtime_binding` return an +# error, and every caller of `mcpp build` / `mcpp test` propagated it. On +# Windows — where xlings does not write that block, and where the facts it +# carries (PT_INTERP, a private libc, GL vendor directories) do not exist at +# all — that meant the tool stopped, with a message about graphics drivers: +# +# error: selected SubOS 'default' cannot provide a RuntimeBinding: … does not +# describe itself … a GL application will not find its drivers +# +# Shipped in 2026.8.10.2; 2026.8.8.4 was fine. The shape is the index-floor +# incident again: DATA THAT IS MISSING OR NEWER MUST NOT INVALIDATE THE PROGRAM +# THAT READS IT. +# +# WHY THIS TEST DECLARES NO CAPABILITIES +# +# On purpose, and it is the whole point. The regression was Windows-only in +# practice, and a `# requires: elf` or `# requires: gcc` line would skip it on +# exactly the platform it exists to protect — the same way `# requires: gcc` +# kept 217 from ever running on Windows or macOS. It needs no ELF, no graphics, +# and no particular toolchain: only that a build completes. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +mkdir -p "$TMP/proj/src" +cd "$TMP/proj" + +# A named SubOS that exists and says nothing about itself. `[xlings] subos` +# resolves to /.mcpp/.xlings/subos/, so the directory is created +# there. Empty is the extreme form of "no subos_info block", and it is a real +# state: a freshly created SubOS, or any SubOS on a platform whose xlings does +# not write the block. +mkdir -p ".mcpp/.xlings/subos/bare" + +cat > mcpp.toml <<'EOF' +[package] +name = "bare" +version = "0.1.0" + +[xlings] +subos = "bare" +EOF +echo 'int main() { return 0; }' > src/main.cpp + +set +e +out="$("$MCPP" build 2>&1)" +rc=$? +set -e +echo "$out" + +# ── the assertion that IS the regression ──────────────────────────────────── +case "$out" in + *"cannot provide a RuntimeBinding"*) + echo "FAIL: an undeclared SubOS still invalidates the build" + echo " Absence of a declaration leaves some facts unknown; it does" + echo " not make the build wrong. Only a CONTRADICTION (a named" + echo " SubOS that is not there) may fail." + exit 1 + ;; +esac +# A build MAY still fail here, but only for a reason that is about the C +# runtime rather than about the binding — and that distinction is the fix. +# +# An undeclared SubOS names no runtime, so on a platform whose C runtime comes +# from a payload there is nothing to bind to and mcpp declines PayloadFirst +# rather than guessing a version. The hermeticity guard then reports, exactly +# and actionably, that the toolchain resolved its C runtime outside the +# sandbox. That is a different statement from "this SubOS cannot provide a +# RuntimeBinding", and it is one the user can act on. +# +# Anything ELSE failing is not this test passing — it is a new defect wearing +# the old one's clothes. +if [[ $rc -ne 0 ]]; then + case "$out" in + *"hermetic link check failed"*) ;; + *) + echo "FAIL: mcpp build exited $rc for a reason this test does not" + echo " recognise. An undeclared SubOS may cost hermeticity; it" + echo " must not cost anything else." + exit 1 + ;; + esac +fi + +# ── and with the host runtime allowed, it must build everywhere ───────────── +# The green half. Without it the test above could be satisfied by mcpp failing +# for *some* accepted reason on every platform, which is not what "the build is +# unaffected" means. +cat > mcpp.toml <<'EOF' +[package] +name = "bare" +version = "0.1.0" + +[xlings] +subos = "bare" + +[build] +allow_host_libs = true +EOF +"$MCPP" build > allow.log 2>&1 || { + echo "FAIL: even with allow_host_libs, an undeclared SubOS blocks the build" + cat allow.log + exit 1 +} + +# ── and it must SAY so ────────────────────────────────────────────────────── +# A degradation nobody prints is indistinguishable from no degradation, which +# is the property that made mcpp#352 expensive. Passing silently here would +# mean the next person cannot tell "my SubOS is fine" from "my SubOS told mcpp +# nothing and mcpp shrugged". +case "$out" in + *"does not describe itself"*) ;; + *) + echo "FAIL: the build succeeded but never reported the degradation." + echo " 'it did not happen' and 'it succeeded' must not look alike." + exit 1 + ;; +esac + +# ── the contradiction case still fails ────────────────────────────────────── +# The other half of the rule, asserted here so relaxing absence cannot quietly +# relax everything: a SubOS the user NAMED and that does not exist cannot be +# satisfied under any interpretation, and guessing a different one would make +# one mcpp.toml mean different ABIs on different machines. +cat > mcpp.toml <<'EOF' +[package] +name = "bare" +version = "0.1.0" + +[xlings] +subos = "definitely-not-created" +EOF +set +e +out2="$("$MCPP" build 2>&1)" +rc2=$? +set -e +[[ $rc2 -ne 0 ]] || { + echo "FAIL: naming a SubOS that does not exist was accepted" + echo "$out2" + exit 1 +} +case "$out2" in + *"does not exist"*) ;; + *) + echo "FAIL: the missing-SubOS error no longer says what is wrong" + echo "$out2" + exit 1 + ;; +esac + +echo "PASS: an undeclared SubOS degrades and reports it; a missing one still fails" diff --git a/tests/unit/test_runtime_selection.cpp b/tests/unit/test_runtime_selection.cpp index bab49445..ee14a37b 100644 --- a/tests/unit/test_runtime_selection.cpp +++ b/tests/unit/test_runtime_selection.cpp @@ -5,7 +5,7 @@ import mcpp.config; import mcpp.manifest; import mcpp.platform; import mcpp.platform.runtime_binding; -import mcpp.xlings.runtime_selection; +import mcpp.platform.xlings.runtime_selection; namespace runtime = mcpp::xlings::runtime; @@ -294,6 +294,111 @@ TEST(RuntimeBinding, NamedEnvironmentsHaveDistinctContractsAndRoundTrip) { EXPECT_EQ(decoded->runtimeProviders[0].provider.namespace_, "xim"); ASSERT_EQ(decoded->runtimeArtifacts.size(), 1u); EXPECT_EQ(decoded->runtimeArtifacts[0].hostFingerprint, "host-2"); + EXPECT_TRUE(decoded->declared); +} + +// ── absence degrades, contradiction fails (openxlings/xlings#543) ────────── +// +// A SubOS that says nothing used to make `resolve_runtime_binding` return an +// error, which every caller of `mcpp build`/`mcpp test` propagated — stopping +// the tool on Windows with a message about GL drivers, on a platform that has +// no ELF, no PT_INTERP and no private libc. The rule the index-floor incident +// already paid for applies here too: data that is missing or newer must not +// invalidate the program that reads it. + +TEST(RuntimeBindingDegradation, UndeclaredSubosStillResolves) { + RuntimeHome h; + auto manifest = named("bare"); + auto selection = runtime::select_runtime(manifest, std::nullopt, + h.dir / "repo"); + ASSERT_TRUE(selection); + // Exists, describes nothing. The common state of a freshly created SubOS, + // and the permanent state on a platform whose xlings does not write the + // block at all. + std::filesystem::create_directories( + h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "bare"); + + auto binding = mcpp::platform::runtime::resolve_runtime_binding( + *selection, {}, h.cfg); + ASSERT_TRUE(binding.has_value()) + << "absence must degrade, not fail: " << binding.error(); + EXPECT_FALSE(binding->declared); + EXPECT_FALSE(binding->note.empty()) + << "a degradation nobody reports is indistinguishable from none"; + EXPECT_TRUE(binding->runtimeId.empty()); + EXPECT_FALSE(binding->hermetic()); +} + +// The cache must survive it too. A degraded binding legitimately has schema 0 +// and no runtime identity, and a completeness check that demands those fields +// would make every cached degraded binding undecodable — sending the build +// back down the slow path on every single invocation, forever. +TEST(RuntimeBindingDegradation, UndeclaredBindingRoundTrips) { + RuntimeHome h; + auto manifest = named("bare"); + auto selection = runtime::select_runtime(manifest, std::nullopt, + h.dir / "repo"); + ASSERT_TRUE(selection); + std::filesystem::create_directories( + h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "bare"); + auto binding = mcpp::platform::runtime::resolve_runtime_binding( + *selection, {}, h.cfg); + ASSERT_TRUE(binding.has_value()); + + auto decoded = mcpp::platform::runtime::deserialize_runtime_binding( + mcpp::platform::runtime::serialize_runtime_binding(*binding)); + ASSERT_TRUE(decoded.has_value()) << decoded.error(); + EXPECT_EQ(decoded->contractHash, binding->contractHash); + EXPECT_FALSE(decoded->declared); + EXPECT_EQ(decoded->note, binding->note); +} + +// A newer schema is read, not refused. `subos_info::read` already documents +// this ("a HIGHER one on disk is still read"); the consumer used to demand +// equality, so the day xlings writes schema 2 every build on every platform +// would have stopped at once. +TEST(RuntimeBindingDegradation, NewerSchemaIsReadNotRefused) { + RuntimeHome h; + auto manifest = named("future"); + auto selection = runtime::select_runtime(manifest, std::nullopt, + h.dir / "repo"); + ASSERT_TRUE(selection); + auto subos = h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "future"; + std::filesystem::create_directories(subos); + std::ofstream(subos / ".xlings.json") << R"({ + "subos_info": { + "schema_version": 99, + "runtime": "glibc@2.39", + "envs": {}, + "a_field_from_the_future": true + } + })"; + + auto binding = mcpp::platform::runtime::resolve_runtime_binding( + *selection, {}, h.cfg); + ASSERT_TRUE(binding.has_value()) + << "a newer contract must not invalidate an older mcpp: " + << binding.error(); + EXPECT_TRUE(binding->declared); + EXPECT_EQ(binding->runtimeId, "glibc@2.39"); + EXPECT_NE(binding->note.find("newer"), std::string::npos) + << "reading a newer schema silently is the other half of the defect"; +} + +// The boundary. A SubOS the user NAMED and that is not there cannot be +// satisfied under any interpretation, and picking a different one would make +// one mcpp.toml mean different ABIs on different machines. +TEST(RuntimeBindingDegradation, MissingSubosStillFails) { + RuntimeHome h; + auto manifest = named("never-created"); + auto selection = runtime::select_runtime(manifest, std::nullopt, + h.dir / "repo"); + ASSERT_TRUE(selection); + auto binding = mcpp::platform::runtime::resolve_runtime_binding( + *selection, {}, h.cfg); + ASSERT_FALSE(binding.has_value()) + << "a contradiction must be reported, not degraded"; + EXPECT_NE(binding.error().find("does not exist"), std::string::npos); } } // namespace From 1edbc590159e6c4adc1e1418e7c0a564a3e62069 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:55:52 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(runtime):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=9C=9F=E6=90=9C=E7=B4=A2=E9=97=AD=E5=8C=85=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20SubOS=20=E5=BA=93=E8=A7=86=E5=9B=BE=E8=BF=9B=20DT?= =?UTF-8?q?=5FRPATH,=E6=9C=AB=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpp 在编译与链接两条线上都发 --sysroot=,所以 subos 提供的库零 flag 就能 解析。运行期的搜索路径却是另一套独立推导(只有工具链载荷目录)。同一个决策 「subos 是我的根」推导了两次,而第二次的输入集合小了一整个库视图: $ mcpp build rc=0 $ ./bin/app libGL.so.1: cannot open shared object file (127) $ mcpp why runtime validation: pass ← 假绿 新增 mcpp.platform.runtime_search:一条搜索目录的来源、次序、是否机器本地。 放 platform/ 是因为它描述的是加载器怎么搜(物理),而 elf_runtime 必须读它 —— 放 build/ 会让 platform 反向依赖 build。它只 import std,四个消费者共读一份。 次序 = 不可变性递减,farm 在最后。载荷目录装一次不再动,/lib 每次 xlings install 都重写。载荷在前 ⇒ libc/libm/libstdc++ 永远从被 pin 的载荷解析, farm 只补没人提供的;farm 在前 ⇒ 一次安装能在事后换掉一个已链接产物的 libc。 今天 farm 的 libc.so.6 就是指向那个载荷的符号链接,所以两种次序行为相同 —— 这正是它必须被写下来并断言的理由。 假绿是独立缺陷,且必须先修:resolve_needed 搜完 rpath 后回落到宿主默认目录, 而宿主通常自带 libGL.so.1 ⇒ 模型认为解析到了。但产物跑在私有加载器下,它的默认 路径里没有宿主目录 —— 模型模拟的是宿主加载器,产物用的是私有加载器。现在宿主 默认目录只在非 hermetic binding 下参与。 第四种判决 Unresolvable:hermetic 下一个解析不到的 DT_NEEDED 是可证的失败 (整条搜索路径由 mcpp 算出,没有 ld.so.cache 兜底),把它归到 Inconclusive 是 把可证的事说成没查过。失败门改问 verdict.blocking(),而不是枚举状态。 护栏两条,与加载器标签契约同一判据:非 ELF 不发(Mach-O/PE 没有这个概念), 交叉目标不发(farm 属于宿主 subos)。 farm 不进 linkIntent.runtimeSearchDirs —— 那个字段会流向 runtimeLibraryDirs, 再变成 mcpp run 的 LD_LIBRARY_PATH,污染它拉起的每个子进程包括宿主二进制。 farm 只按对象可达(DT_RPATH),这是上一轮图形设计里明确立下的规矩。 载荷目录向发出它们的同一个函数要(resolve_link_model(...).libDirs): plan.toolchain.linkRuntimeDirs 只有 clang 会填,第一版记录只有一条 farm 而产物 DT_RPATH 有三条 —— 记录与产物不一致,正是这套东西要消灭的形状。 不读 $XLINGS_SUBOS_LIB。实测它指向当前 shell 的 subos,而 mcpp 有自己的 registry home,两者由不同物理 glibc 载荷支撑 —— 继承它等于给产物挂第二套 libc。 改为主动声明 XLINGS_SUBOS_LD_PATHS=0(xlings#540 的退出),今天是无操作。 观测:resolution.json 的 runtime.search.closure(保序,带 origin 与 machine_local),mcpp why runtime 按加载器次序打印。 测试:219 断言产物 DT_RPATH 末位是 farm、首位载荷目录真的有 libc,并要求记录与 产物逐条一致;220 从「farm 有而载荷没有」的差集里取一个库,--no-as-needed 让它 成为 DT_NEEDED,然后真的 exec —— 只有跑起来才能戳破假绿,因为宿主也有 libGL; 另一半用一个 SONAME 谁都提供不了的库断言构建变红并指名。215 的机器本地前缀从 data/xpkgs 扩到整个 mcpp home(farm 在 registry/subos 下,原来会漏过去),并补 --mode system 档 —— 它是唯一不重写 rpath 的档,此前没有任何用例检查过它。 --- src/build/flags.cppm | 25 +- src/build/ninja_backend.cppm | 10 +- src/build/plan.cppm | 99 +++++++- src/build/runtime_validation.cppm | 34 ++- src/cli.cppm | 23 ++ src/doctor.cppm | 21 +- src/platform/elf_runtime.cppm | 87 ++++++- src/platform/runtime_search.cppm | 200 ++++++++++++++++ .../215_pack_has_no_build_machine_paths.sh | 46 +++- tests/e2e/219_runtime_search_farm_is_last.sh | 188 +++++++++++++++ tests/e2e/220_farm_only_library_runs.sh | 216 ++++++++++++++++++ tests/unit/test_elf_runtime.cpp | 60 ++++- tests/unit/test_runtime_search.cpp | 127 ++++++++++ 13 files changed, 1114 insertions(+), 22 deletions(-) create mode 100644 src/platform/runtime_search.cppm create mode 100755 tests/e2e/219_runtime_search_farm_is_last.sh create mode 100755 tests/e2e/220_farm_only_library_runs.sh create mode 100644 tests/unit/test_runtime_search.cpp diff --git a/src/build/flags.cppm b/src/build/flags.cppm index ac025eba..08cec2e4 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -17,6 +17,7 @@ import mcpp.build.plan; import mcpp.manifest.types; import mcpp.modgraph.scanner; import mcpp.platform; +import mcpp.platform.runtime_search; import mcpp.toolchain.clang; import mcpp.toolchain.detect; import mcpp.toolchain.dialect; @@ -503,6 +504,26 @@ CompileFlags compute_flags(const BuildPlan& plan) { }(); const std::string link_intent_ld = render_link_intent_flags(plan.linkIntent, linkIntentFlavor); + + // The SubOS farm tail — the only origin in `plan.runtimeSearch` with no + // other producer, appended after everything else so it is LAST in the + // artifact's DT_RPATH (see `runtime_search_closure`). + // + // RUNPATH ONLY, never `-L`. Link-time resolution already works: mcpp + // passes `--sysroot=`, which makes `/lib` the linker's + // default library directory. Emitting `-L` as well would be redundant on + // a link line that has a hard 128KiB ceiling real workspaces already spend + // 43% of. This is the same rule `runtimeSearchDirs` states for package + // dirs, applied to the origin that needed it most. + std::string farm_ld; + if (linkIntentFlavor == LinkIntentFlavor::Elf) { + for (auto const& dir : plan.runtimeSearch) { + if (dir.origin != mcpp::platform::search::Origin::SubosFarm) continue; + farm_ld += ' '; + farm_ld += shell_quote_arg(escape_ninja_chars( + "-Wl,-rpath," + dir.path.string())); + } + } std::filesystem::path binutilsBin; if (!isMuslTc && !isMingwTc && caps.stdlib_id == "libstdc++") { auto ar = mcpp::toolchain::archive_tool(plan.toolchain); @@ -972,9 +993,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { // actually being present (see atomic_link_flag). std::string atomic_ld = atomic_link_flag(plan.toolchain.linkRuntimeDirs, !full_static.empty()); - f.ld = std::format("{}{}{}{}{}{}{}{}{}", full_static, + f.ld = std::format("{}{}{}{}{}{}{}{}{}{}", full_static, link_toolchain_flags, b_flag, runtime_dirs, - link_intent_ld, atomic_ld, payload_ld, + link_intent_ld, farm_ld, atomic_ld, payload_ld, user_ldflags, link_extra); } diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 2f24c243..e668ca2e 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -37,7 +37,7 @@ import mcpp.toolchain.detect; import mcpp.toolchain.dialect; import mcpp.toolchain.provider; import mcpp.toolchain.registry; -import mcpp.xlings; +import mcpp.platform.xlings; import mcpp.platform; import mcpp.ui; @@ -1791,7 +1791,11 @@ std::expected NinjaBackend::build(const BuildPlan& plan for (auto const& checked : runtimeReport.artifacts) { using Status = mcpp::platform::elf::RuntimeVerdict::Status; auto explanation = checked.verdict.explain(); - if (checked.verdict.status == Status::ProvenMismatch) { + // `blocking()` rather than a state list: a proven payload mismatch + // and a DT_NEEDED the artifact's own loader will never find are + // both "this artifact is known bad", and enumerating them here is + // how one of them gets forgotten. + if (checked.verdict.blocking()) { if (runtimeFailure.empty()) { runtimeFailureArtifact = checked.artifact; runtimeFailure = std::move(explanation); @@ -1807,7 +1811,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan } if (!runtimeFailure.empty()) { return std::unexpected(BuildError{ - "runtime closure validation failed (proven Linux ELF mismatch)", + "runtime closure validation failed (proven Linux ELF defect)", runtimeFailureArtifact, std::move(runtimeFailure)}); } // Rule E — the loader-tag contract, checked on what actually landed diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 1f1f105f..048aad72 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -16,11 +16,13 @@ import mcpp.toolchain.cppfly; import mcpp.toolchain.detect; import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; +import mcpp.toolchain.linkmodel; import mcpp.toolchain.triple; import mcpp.platform; import mcpp.platform.runtime_binding; import mcpp.platform.runtime_env_contract; -import mcpp.xlings.subos_info; +import mcpp.platform.runtime_search; +import mcpp.platform.xlings.subos_info; export namespace mcpp::build { @@ -202,6 +204,22 @@ struct BuildPlan { std::vector runtimeRequirements; std::vector runtimeArtifacts; mcpp::manifest::LinkIntent linkIntent; + // The complete run-time search closure of the artifacts this plan will + // produce, in loader order and tagged with where each directory came from + // (`mcpp.platform.runtime_search`). + // + // This is the RECORD — what resolution.json publishes and `mcpp why + // runtime` explains. Emission is still owned by each origin's existing + // producer (payloads by the toolchain link model, package dirs by + // LinkIntent), with one exception: SubosFarm entries have no other + // producer, so `flags.cppm` renders them from here, appended last. + // + // ⚠️ The farm deliberately does NOT enter `runtimeLibraryDirs`. That + // vector becomes LD_LIBRARY_PATH for `mcpp run`, which is inherited by + // every child process including host binaries — measured to kill + // `xdg-open`/`notify-send` outright when a private libc is on it. The farm + // is reachable PER OBJECT (DT_RPATH) and must stay that way. + std::vector runtimeSearch; // Windows runtime-DLL deployment. On PE (`supports_rpath` is false) a // directly-launched .exe cannot RUNPATH-locate a dependency's DLL, so each // *.dll found in a dependency's [runtime] library_dir is copied beside the @@ -233,6 +251,13 @@ void merge_runtime_binding_contract( BuildPlan& plan, const mcpp::platform::runtime::RuntimeBinding& binding); +// The run-time search closure for this plan, in loader order and tagged with +// provenance. Exported so a test can exercise the guards (cross target, non-ELF +// format, undeclared SubOS) without linking a binary for each. +std::vector runtime_search_closure( + const BuildPlan& plan, + const mcpp::platform::runtime::RuntimeBinding& binding); + // Is `p` inside one of `roots`, judged LEXICALLY? // // Lexical is the whole point (mcpp#344). std::filesystem::relative() runs @@ -621,6 +646,76 @@ ResolvedRuntimeContract resolve_runtime_contract( return out; } +// The run-time search closure of everything this plan will link, in the order +// the loader will consult it, tagged with where each directory came from. +// +// ONE assembly, three producers. Payload directories come from the toolchain +// link model, package directories from the resolved LinkIntent, and the SubOS +// farm from the RuntimeBinding — and the farm is the addition that closes the +// gap this whole change exists for: mcpp already passes `--sysroot=` on +// the compile AND link lines, so `-lGL` resolves out of `/lib` with no +// flags from the user, while the RUN-time path was derived from payload +// directories alone. Link succeeded, the artifact could not start. +// +// FARM LAST, and it is the only invariant here. `/lib` is a symlink +// view rewritten by every `xlings install`; payload directories are written +// once and never touched. Payload-first keeps libc / libm / libstdc++ resolving +// from the pinned payload and leaves the farm to supply only what nothing else +// does. Farm-first would let a later install silently change which libc an +// ALREADY LINKED artifact loads. `search::ordered` is what enforces it, and +// e2e 219 asserts it on the produced ELF rather than on this code. +std::vector runtime_search_closure( + const BuildPlan& plan, + const mcpp::platform::runtime::RuntimeBinding& binding) { + using mcpp::platform::search::Dir; + using mcpp::platform::search::Origin; + + // PAYLOAD DIRECTORIES COME FROM THE SAME FUNCTION THAT EMITS THEM. + // + // `resolve_link_model` is a pure function of the toolchain and is what + // `flags.cppm` renders as `-L`/`-Wl,-rpath` for the C runtime; asking it + // here is how the record and the artifact stay the same list. Deriving + // them a second way is what made the first version of this record show a + // one-entry closure while the artifact carried three — `linkRuntimeDirs` + // is populated for CLANG ONLY, so on GCC it is simply empty and the + // payloads arrive through the link model instead. + // + // Both are read, in the order `flags.cppm` concatenates them. + std::vector closure; + for (auto const& dir : mcpp::toolchain::resolve_link_model(plan.toolchain).libDirs) + closure.push_back({dir, Origin::Payload}); + for (auto const& dir : plan.toolchain.linkRuntimeDirs) + closure.push_back({dir, Origin::Payload}); + for (auto const& dir : plan.linkIntent.runtimeSearchDirs) + closure.push_back({dir, Origin::Package}); + + // TWO GUARDS, both about "will this artifact ever run here". + // + // format DT_RPATH exists on ELF only. Mach-O and PE get nothing rather + // than a branch in every consumer — the same shape + // `loader_contract` uses for the tag half of this contract. + // host The farm belongs to THIS host's SubOS. A cross target + // (aarch64-musl, mingw, wasm) would receive a path that is inert + // at best and points at the wrong architecture's libraries at + // worst. + const auto triple = [&] { + auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); + return t ? *t : mcpp::toolchain::triple::Triple{}; + }(); + const bool elfTarget = triple.empty() + ? bool(mcpp::platform::is_linux) + : (triple.os != "macos" && triple.os != "windows"); + const bool hostTarget = binding.platform == "linux" + && (triple.empty() + || (triple.os == "linux" + && (triple.arch.empty() || triple.arch == binding.arch))); + if (elfTarget && hostTarget) + for (auto const& dir : binding.searchDirs) + closure.push_back({dir, Origin::SubosFarm}); + + return mcpp::platform::search::ordered(std::move(closure)); +} + void merge_runtime_binding_contract( BuildPlan& plan, const mcpp::platform::runtime::RuntimeBinding& binding) { @@ -676,6 +771,8 @@ void merge_runtime_binding_contract( })) plan.runtimeArtifacts.push_back(std::move(value)); } + + plan.runtimeSearch = runtime_search_closure(plan, binding); } // True if `src` defines a top-level `int main(` / `auto main(` entry point. diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 02e58676..4055c783 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -38,10 +38,13 @@ struct ValidatedArtifact { struct ValidationReport { std::vector artifacts; - bool has_proven_mismatch() const { + // Any artifact PROVEN bad — payloads mixed, or a DT_NEEDED that the + // artifact's own loader will not find. Asks the verdict rather than + // enumerating states here, so a fifth state cannot be added without this + // gate deciding what it means. + bool has_blocking_failure() const { return std::ranges::any_of(artifacts, [](auto const& artifact) { - return artifact.verdict.status - == mcpp::platform::elf::RuntimeVerdict::Status::ProvenMismatch; + return artifact.verdict.blocking(); }); } }; @@ -150,16 +153,35 @@ std::string status_name(mcpp::platform::elf::RuntimeVerdict::Status status) { switch (status) { case Status::Pass: return "pass"; case Status::ProvenMismatch: return "proven_mismatch"; + case Status::Unresolvable: return "unresolvable"; case Status::Inconclusive: return "inconclusive"; } return "inconclusive"; } +// How bad each state is, for rolling many artifacts into one summary. +// `Unresolvable` sits above `Inconclusive` (it is proven, not unknown) and +// below `ProvenMismatch` (mixing payloads is the more fundamental error, and +// it is usually the CAUSE of anything unresolvable alongside it). +int status_severity(mcpp::platform::elf::RuntimeVerdict::Status status) { + using Status = mcpp::platform::elf::RuntimeVerdict::Status; + switch (status) { + case Status::Pass: return 0; + case Status::Inconclusive: return 1; + case Status::Unresolvable: return 2; + case Status::ProvenMismatch: return 3; + } + return 1; +} + mcpp::platform::elf::RuntimeVerdict::Status parse_status(std::string_view value) { using Status = mcpp::platform::elf::RuntimeVerdict::Status; if (value == "pass") return Status::Pass; if (value == "proven_mismatch") return Status::ProvenMismatch; + if (value == "unresolvable") return Status::Unresolvable; + // Anything unknown reads as `inconclusive`, never as `pass`: a record + // written by a newer mcpp must not be mistaken for a clean bill of health. return Status::Inconclusive; } @@ -273,8 +295,10 @@ void sync_resolution_verdict(const mcpp::build::BuildPlan& plan, if (!it.value().is_object()) continue; any = true; auto status = parse_status(it.value().value("status", "inconclusive")); - if (status == Status::ProvenMismatch - || (status == Status::Inconclusive && summary == Status::Pass)) + // Worst wins, by an explicit severity order rather than a chain of + // pairwise comparisons that has to be re-derived every time a + // state is added. + if (status_severity(status) > status_severity(summary)) summary = status; checked.push_back({ {"path", (plan.outputDir / it.key()).lexically_normal().generic_string()}, diff --git a/src/cli.cppm b/src/cli.cppm index 9a947c3d..6c071af6 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -30,6 +30,7 @@ import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.wire; import mcpp.platform.env; // --offline → MCPP_OFFLINE +import mcpp.platform.runtime_search; // linker-wrapper path-injection opt-out import mcpp.ui; import mcpp.log; @@ -114,6 +115,28 @@ int run(int argc, char** argv) { // it makes `MCPP_OFFLINE=1` and `--offline` literally the same switch. else if (a == "--offline") mcpp::platform::env::set("MCPP_OFFLINE", "1"); } + // Decline xlings' linker-wrapper path injection, for this process and + // everything it spawns (openxlings/xlings#540). + // + // That wrapper appends `-rpath "$XLINGS_SUBOS_LIB"` to every link it sees. + // mcpp wants the TAG half of what it does and must refuse the PATH half: + // `$XLINGS_SUBOS_LIB` names the ACTIVE SHELL's SubOS, which is measurably + // not the one mcpp resolved — mcpp keeps its own xlings home under + // `/registry`, so on an ordinary developer machine the variable + // points at a different farm backed by a DIFFERENT PHYSICAL glibc payload. + // Inheriting it would put a second libc on the artifact's search path, + // which is the one thing rule B exists to prevent. mcpp emits its own farm + // entry, derived from the binding it actually selected. + // + // Set here rather than per link command: the link line has a hard 128KiB + // ceiling that real workspaces already spend 43% of, and children inherit + // the environment for free. Declared BEFORE the wrapper ships, because + // "the exit must be declared, not inferred" is the rule that whole + // negotiation established — today this is a no-op. + mcpp::platform::env::set( + std::string(mcpp::platform::search::kLinkerPathInjectionOptOut), + std::string(mcpp::platform::search::kLinkerPathInjectionOptOutValue)); + // Env override (observability, esp. CI): MCPP_VERBOSE= // turns on verbose logging for EVERY mcpp invocation — including the ones // nested inside e2e test scripts that call $MCPP without flags. Lets a diff --git a/src/doctor.cppm b/src/doctor.cppm index 89312b3d..9bba4aca 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -35,7 +35,7 @@ import mcpp.toolchain.registry; import mcpp.toolchain.stdmod; import mcpp.toolchain.abi; import mcpp.ui; -import mcpp.xlings; +import mcpp.platform.xlings; namespace mcpp::doctor { @@ -281,6 +281,9 @@ export int doctor_report() { } else if (stored->verdict.status == Status::Inconclusive) { warn(std::format("{}: inconclusive{}{}", subject, detail.empty() ? "" : "\n", detail)); + } else if (stored->verdict.status == Status::Unresolvable) { + err(std::format("{}: unresolvable runtime closure{}{}", subject, + detail.empty() ? "" : "\n", detail)); } else { err(std::format("{}: proven mismatch{}{}", subject, detail.empty() ? "" : "\n", detail)); @@ -677,6 +680,22 @@ int print_stored_runtime_resolution() { search->value("format", "?"), search->value("link_library", "?"), search->value("transitive_needed", "?"), search->value("runtime", "?")); + // The closure IN ORDER, with where each directory came from. Order is + // the answer to "why does my GL program find its driver" and to "why + // is my libc still the pinned one" — both invisible when the report + // says only which mechanism is used. + if (auto closure = search->find("closure"); + closure != search->end() && closure->is_array() && !closure->empty()) { + std::println(" runtime search closure (loader order):"); + for (auto const& dir : *closure) { + if (!dir.is_object()) continue; + std::println(" {:<12} {}{}", + dir.value("origin", "?"), dir.value("path", "?"), + dir.value("machine_local", false) ? " [machine-local]" : ""); + } + std::println(" note: the mutable SubOS farm is LAST on purpose — " + "libc stays pinned to its payload"); + } } if (auto validation = runtime->find("validation"); validation != runtime->end() && validation->is_object()) { diff --git a/src/platform/elf_runtime.cppm b/src/platform/elf_runtime.cppm index 053257b5..033a3d98 100644 --- a/src/platform/elf_runtime.cppm +++ b/src/platform/elf_runtime.cppm @@ -72,10 +72,29 @@ struct RuntimeResolution { }; struct RuntimeVerdict { - enum class Status { Pass, ProvenMismatch, Inconclusive }; + // FOUR-VALUED, and the third one is this round's addition. + // + // Pass every rule that applies was checked and held + // ProvenMismatch two runtime payloads are being mixed + // Unresolvable a DT_NEEDED cannot be found anywhere the artifact's + // loader will look — the artifact provably cannot start + // Inconclusive a rule that applies could not be evaluated + // + // `Unresolvable` used to be folded into `Inconclusive`, which reports a + // PROVEN failure as "not checked". Under a hermetic binding the artifact's + // PT_INTERP names a private loader whose search path mcpp computes in + // full, so "not found" is a measurement, not an absence of one. + enum class Status { Pass, ProvenMismatch, Unresolvable, Inconclusive }; Status status = Status::Pass; std::vector diagnostics; + // Does this verdict mean the artifact is known-bad? Both blocking states + // spelled once, so a caller cannot check for one and silently accept the + // other. + bool blocking() const { + return status == Status::ProvenMismatch || status == Status::Unresolvable; + } + std::string explain() const { std::string out; for (auto const& diagnostic : diagnostics) { @@ -279,7 +298,21 @@ std::optional resolve_needed( append_unique_path(dirs, expand_origin(raw, requester.artifact)); for (auto const& dir : additionalSearchDirs) append_unique_path(dirs, dir); for (auto const& dir : binding.libraryDirs) append_unique_path(dirs, dir); - for (auto const& dir : host_library_dirs()) append_unique_path(dirs, dir); + // The SubOS farm. It is where `-lGL` resolved at link time (the SubOS is + // the sysroot), so a model that omits it reports libraries as missing + // that the artifact will in fact find. + for (auto const& dir : binding.searchDirs) append_unique_path(dirs, dir); + // The host loader's built-in defaults — ONLY when the artifact runs under + // the host loader. + // + // A hermetic artifact's PT_INTERP names a private loader compiled with a + // different default path, so adding /usr/lib here models the wrong loader. + // Measured: a GL program that cannot start was reported `validation: pass` + // because the HOST happened to have libGL.so.1 and the model found it + // there. When the model and the artifact disagree about which loader runs, + // the model wins the report and the artifact wins reality. + if (!binding.hermetic()) + for (auto const& dir : host_library_dirs()) append_unique_path(dirs, dir); for (auto const& dir : dirs) { auto candidate = dir / named; @@ -632,6 +665,23 @@ RuntimeVerdict validate_runtime_artifact( return verdict; } if (binding.platform != "linux" || !isGlibc) { + // Two different reasons land here and they are not the same news. + // + // declared, not glibc the rules DO NOT APPLY (musl, macOS SDK, + // ucrt) — nothing to check, so Pass. + // not declared the rules CANNOT BE EVALUATED — the SubOS + // never said what it is, so Inconclusive. + // + // Reporting the second as the first sends the reader looking for a + // runtime they did not select, and quietly counts "unknown" as "fine". + if (!binding.declared) { + verdict.status = RuntimeVerdict::Status::Inconclusive; + verdict.diagnostics.push_back(std::format( + "runtime rules inconclusive: SubOS '{}' does not describe its " + "runtime, so there is no identity to check the artifact against", + binding.selection.subosName)); + return verdict; + } verdict.diagnostics.push_back( "runtime physics: selected runtime is not Linux/glibc; rules A/B are not applicable"); return verdict; @@ -762,9 +812,36 @@ RuntimeVerdict validate_runtime_artifact( if (!names.empty()) names += ", "; names += name; } - inconclusive(std::format( - "runtime closure for {} is inconclusive; unresolved objects: {}", - artifactPath, names)); + // PROVEN under a hermetic binding, merely UNKNOWN otherwise. + // + // Hermetic means the artifact's PT_INTERP is a private loader whose + // entire search path mcpp computed: RPATH/RUNPATH + payloads + farm, + // with no host defaults and no ld.so.cache. Nothing else will be + // consulted, so "not found here" is the same answer the loader will + // give — and the artifact cannot start. Saying `inconclusive` for that + // is reporting a measurement as the absence of one, and it is exactly + // how a GL program that exits 127 was shipped as `validation: pass`. + // + // A non-hermetic artifact runs under the host loader, which also + // consults `ld.so.cache` — something mcpp deliberately does not parse. + // There, unresolved really is unknown. + if (binding.hermetic()) { + verdict.status = RuntimeVerdict::Status::Unresolvable; + verdict.diagnostics.push_back(std::format( + "runtime closure for {} cannot be satisfied: {} not found on the " + "search path this artifact will actually use.\n" + " Its PT_INTERP is a private loader, so the host's " + "/usr/lib is NOT consulted — the program will fail to start with " + "\"cannot open shared object file\".\n" + " Fix: install the provider into the selected SubOS " + "(`xlings install `), or declare the dependency so mcpp " + "resolves it.", + artifactPath, names)); + } else { + inconclusive(std::format( + "runtime closure for {} is inconclusive; unresolved objects: {}", + artifactPath, names)); + } } return verdict; } diff --git a/src/platform/runtime_search.cppm b/src/platform/runtime_search.cppm new file mode 100644 index 00000000..7736b1a1 --- /dev/null +++ b/src/platform/runtime_search.cppm @@ -0,0 +1,200 @@ +// mcpp.platform.runtime_search — where a produced artifact looks for its +// shared libraries at RUN time, in what order, and which of those directories +// may not travel with it. +// +// THE ASYMMETRY THIS EXISTS TO CLOSE +// +// mcpp already treats the selected SubOS as its sysroot: `--sysroot=` +// goes on both the compile and the link line, so `-lGL` resolves out of +// `/lib` with no flags from the user. Nothing carried that same view +// into the RUN-time search path, which was derived separately from the +// toolchain's payload directories alone. A library that lives only in the +// SubOS therefore linked cleanly and produced an executable that cannot +// start: +// +// $ mcpp build → rc=0 +// $ ./bin/app → libGL.so.1: cannot open shared object file +// +// One decision — "the SubOS is my root" — was being derived twice, and the +// second derivation had a smaller input set. +// +// WHY A MODULE RATHER THAN A VECTOR PASSED AROUND +// +// Four parties must agree on the ORDER and on what "machine-local" means: +// the linker command line (`mcpp.build.plan`), the closure resolver +// (`mcpp.platform.elf_runtime`), `mcpp pack` (which must strip what cannot be +// shipped), and the recorder (`resolution.json`). A per-caller decision is how +// this area produced a "validation: pass" for a binary that could not load. +// +// This module knows nothing about ELF, about SubOS layout, or about +// RuntimeBinding. It is the policy — rank, provenance, machine-locality — and +// it imports only `std`, so it can be unit-tested on every platform including +// the ones where none of it applies. + +export module mcpp.platform.runtime_search; + +import std; + +export namespace mcpp::platform::search { + +// Where a runtime search directory came from. +// +// The distinction that matters is MUTABILITY, not ownership: two of these are +// immutable once installed, one is rewritten by every `xlings install`, and +// one belongs to a different machine's loader entirely. +enum class Origin { + // An immutable package payload — `/xim-x-glibc/2.39/lib64`. + // Written once at install time and never touched again. + Payload, + // Declared by a resolved package descriptor's `[runtime]` block. Also + // store-backed and immutable, but it is the ecosystem's statement rather + // than the toolchain's, so it is ranked separately and can be reported + // separately. + Package, + // The SubOS symlink farm — `/lib`. A UNION VIEW of everything + // installed into that environment, rewritten whenever the environment is + // re-resolved. This is the directory that makes `-lGL` link, and the one + // that must never outrank a payload. + SubosFarm, + // The host loader's own built-in defaults (`/usr/lib/x86_64-linux-gnu`, + // `/lib64`, …). Applies ONLY when the artifact will actually run under + // the host loader. An artifact whose PT_INTERP points into a private + // payload never consults these, and pretending otherwise is exactly how a + // binary that cannot start was reported as valid: the host happened to + // have `libGL.so.1`, so the model resolved it and the loader could not. + HostDefault, +}; + +// Search order = decreasing immutability. +// +// This is the one invariant in this module, and it is not stylistic: +// +// payload first ⇒ libc / libm / libstdc++ always resolve from the pinned +// payload, and the farm only supplies what nothing else +// does (libGL, libX11, libEGL, libwayland, …). +// farm first ⇒ a later `xlings install` re-points the farm and silently +// changes which libc an ALREADY BUILT artifact loads. +// +// Today the farm's `libc.so.6` is a symlink to that same payload, so the two +// orders behave identically — which is precisely why the rule has to be +// written down and asserted rather than left to chance. +int rank(Origin origin) { + switch (origin) { + case Origin::Payload: return 0; + case Origin::Package: return 1; + case Origin::SubosFarm: return 2; + case Origin::HostDefault: return 3; + } + return 3; +} + +// Is this directory part of THIS machine's private state? +// +// `mcpp pack` asks this to decide what may not be baked into a distributable +// artifact. A path under the build machine's store or SubOS is meaningless — +// or worse, silently different — anywhere else. +// +// `HostDefault` is NOT machine-local: `/usr/lib` is a convention every Linux +// target machine shares, and a binary relying on it is making an ordinary +// (declarable) host requirement rather than depending on the build box. +bool is_machine_local(Origin origin) { + switch (origin) { + case Origin::Payload: return true; + case Origin::SubosFarm: return true; + case Origin::Package: return true; + case Origin::HostDefault: return false; + } + return false; +} + +std::string_view to_string(Origin origin) { + switch (origin) { + case Origin::Payload: return "payload"; + case Origin::Package: return "package"; + case Origin::SubosFarm: return "subos_farm"; + case Origin::HostDefault: return "host_default"; + } + return "unknown"; +} + +std::optional parse_origin(std::string_view value) { + if (value == "payload") return Origin::Payload; + if (value == "package") return Origin::Package; + if (value == "subos_farm") return Origin::SubosFarm; + if (value == "host_default") return Origin::HostDefault; + return std::nullopt; +} + +struct Dir { + std::filesystem::path path; + Origin origin = Origin::Payload; + + bool operator==(const Dir&) const = default; +}; + +// THE single ordering. Stable within a rank, so insertion order still decides +// among peers (which is what libglvnd's vendor resolution depends on), and +// de-duplicated by path with the STRONGEST origin winning. +// +// Strongest wins rather than first-seen: the same directory reachable both as +// a payload and through the farm view is a payload — the farm is only an +// alternate route to it, and ranking it as farm would push the real libc +// behind whatever else the farm holds. +std::vector ordered(std::vector dirs) { + // `lexically_normal` KEEPS a trailing separator (`/a/./b/` → `/a/b/`), and + // a path with one does not compare equal to the same path without. These + // are all directories, so the separator carries no information — leaving + // it in means the same directory can enter the closure twice, under two + // different origins, and the de-duplication that keeps the farm behind the + // payload silently stops matching. Caught by a unit test, not by reading. + auto normalize = [](const std::filesystem::path& p) { + auto n = p.lexically_normal(); + if (!n.has_filename() && n.has_parent_path()) return n.parent_path(); + return n; + }; + std::vector merged; + for (auto& dir : dirs) { + if (dir.path.empty()) continue; + auto normalized = normalize(dir.path); + auto hit = std::ranges::find_if(merged, [&](Dir const& existing) { + return existing.path == normalized; + }); + if (hit == merged.end()) { + merged.push_back({std::move(normalized), dir.origin}); + continue; + } + if (rank(dir.origin) < rank(hit->origin)) hit->origin = dir.origin; + } + std::ranges::stable_sort(merged, {}, [](Dir const& d) { return rank(d.origin); }); + return merged; +} + +// Just the paths, in contract order — for the callers that render a flag or +// walk a search path and have no use for the provenance. +std::vector paths_of(std::span dirs) { + std::vector out; + out.reserve(dirs.size()); + for (auto const& dir : dirs) out.push_back(dir.path); + return out; +} + +// The declarative exit from xlings' linker wrapper (openxlings/xlings#540). +// +// That wrapper appends `-rpath "$XLINGS_SUBOS_LIB" --disable-new-dtags` to +// every link it sees. mcpp wants the tag half and must refuse the path half: +// `$XLINGS_SUBOS_LIB` names the ACTIVE shell's SubOS, which is measurably not +// the one mcpp resolved — mcpp keeps its own xlings home under +// `/registry`, so the variable points at a different farm backed by +// a different physical glibc payload. Inheriting it would put a second libc on +// the artifact's search path, which is the one thing rule B exists to prevent. +// +// mcpp declares the exit BEFORE the wrapper ships. Today it is a no-op; the +// day the wrapper lands it keeps mcpp's DT_RPATH exactly what mcpp decided. +// Set in mcpp's own process environment rather than per link command: the link +// line has a hard 128KiB ceiling that real workspaces already spend 43% of, +// and children inherit the environment anyway. +inline constexpr std::string_view kLinkerPathInjectionOptOut = + "XLINGS_SUBOS_LD_PATHS"; +inline constexpr std::string_view kLinkerPathInjectionOptOutValue = "0"; + +} // namespace mcpp::platform::search diff --git a/tests/e2e/215_pack_has_no_build_machine_paths.sh b/tests/e2e/215_pack_has_no_build_machine_paths.sh index 243e853c..24d59d51 100755 --- a/tests/e2e/215_pack_has_no_build_machine_paths.sh +++ b/tests/e2e/215_pack_has_no_build_machine_paths.sh @@ -108,9 +108,16 @@ TARBALL="$(ls target/dist/*.tar.gz | head -1)" [[ -n "$TARBALL" ]] || { echo "FAIL: no tarball"; cat "$TMP/pack.log"; exit 1; } mkdir -p "$TMP/x" && tar -xzf "$TARBALL" -C "$TMP/x" -# The build machine's store prefix. Everything under it is machine-local by -# construction, so its presence in a shipped artifact is the defect itself. -STORE="$(cd "$MCPP_HOME/registry/data/xpkgs" 2>/dev/null && pwd || true)" +# EVERYTHING under the mcpp home is machine-local, not just the store. +# +# This used to be `$MCPP_HOME/registry/data/xpkgs`, which is a SUBSET of what +# the sweep claims to cover — and the gap was load-bearing. The SubOS farm +# (`/registry/subos//lib`) now lands in the artifact's DT_RPATH by +# design, and it sits outside `data/xpkgs`, so a leaked farm path would have +# walked straight past this guard while the file's own title says no +# build-machine paths survive. A check narrower than its claim reports "clean" +# for the one thing it cannot see. +STORE="$(cd "$MCPP_HOME" 2>/dev/null && pwd || true)" fail=0 found_exe=0 @@ -168,4 +175,35 @@ if find "$TMP/x" -name HOST-REQUIREMENTS | grep -q .; then exit 1 fi -echo "PASS: no build-machine paths survive packing, and every object carries its contract tag" +# ── the mode that bundles NOTHING ─────────────────────────────────────────── +# +# `--mode system` is the one mode that ships no libraries, and until now no +# test packed with it — so nothing checked the one thing it must get right: +# an artifact that carries no dependencies must also carry no addressing for +# them. It links against a private glibc with the SubOS farm in its DT_RPATH, +# and both of those are this machine's alone. +"$MCPP" pack --mode system > "$TMP/pack-system.log" 2>&1 || { + cat "$TMP/pack-system.log"; exit 1; } +SYSTAR="$(ls target/dist/*-system.tar.gz | head -1)" +[[ -n "$SYSTAR" ]] || { echo "FAIL: no system-mode tarball"; exit 1; } +mkdir -p "$TMP/xs" && tar -xzf "$SYSTAR" -C "$TMP/xs" + +sysfail=0 +sysfound=0 +while IFS= read -r obj; do + head -c4 "$obj" 2>/dev/null | grep -q $'\x7fELF' || continue + read -r form tag paths <<<"$(read_tag "$obj")" + [[ "$form" == "NOT-ELF64" ]] && continue + sysfound=1 + printf ' [system] %-30s %-14s %s %s\n' "${obj#$TMP/xs/}" "$form" "$tag" "$paths" + if [[ -n "$STORE" && "$paths" == *"$STORE"* ]]; then + echo "FAIL: --mode system artifact points at the BUILD MACHINE" + echo " $obj" + echo " $paths" + sysfail=1 + fi +done < <(find "$TMP/xs" -type f) +[[ "$sysfound" == "1" ]] || { echo "FAIL: --mode system swept no ELF"; exit 1; } +[[ "$sysfail" == "0" ]] || exit 1 + +echo "PASS: no build-machine paths survive packing (vendored or system), and every object carries its contract tag" diff --git a/tests/e2e/219_runtime_search_farm_is_last.sh b/tests/e2e/219_runtime_search_farm_is_last.sh new file mode 100755 index 00000000..33d54d87 --- /dev/null +++ b/tests/e2e/219_runtime_search_farm_is_last.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# requires: gcc elf python3 +# 219_runtime_search_farm_is_last.sh — the run-time search closure, and the one +# invariant in it. +# +# WHAT IS BEING FIXED +# +# mcpp already treats the selected SubOS as its sysroot: `--sysroot=` is +# on the compile AND link lines, so `-lGL` resolves out of `/lib` with +# no flags from the user. Nothing carried that view into the RUN-time search +# path, which was built from toolchain payload directories alone. The result +# was a link that succeeded and an executable that could not start. +# +# THE INVARIANT +# +# payload directories first, the SubOS farm LAST +# +# and it is about mutability, not taste. `/lib` is a symlink view +# rewritten by every `xlings install`; a payload directory is written once. +# Payload-first keeps libc / libm / libstdc++ resolving from the pinned payload +# and leaves the farm to supply only what nothing else does. Farm-first would +# let a later install silently change which libc an ALREADY LINKED artifact +# loads — a failure that appears long after the build that caused it. +# +# WHY THIS ASSERTS THE ARTIFACT AND THE RECORD +# +# They fail differently. A wrong DT_RPATH is a regression in what mcpp emits; a +# record that disagrees with the DT_RPATH means resolution.json is describing a +# build that did not happen, and every downstream reader (`mcpp why runtime`, +# `mcpp pack`, CI) is then reasoning about fiction. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +mkdir -p "$TMP/proj/src" +cd "$TMP/proj" +cat > mcpp.toml <<'EOF' +[package] +name = "closure" +version = "0.1.0" +EOF +cat > src/main.cpp <<'EOF' +int main() { return 0; } +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } + +RES="$(ls target/*/*/resolution.json 2>/dev/null | head -1)" +BIN="$(ls target/*/*/bin/closure 2>/dev/null | head -1)" +[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; } +[[ -n "$BIN" ]] || { echo "FAIL: no executable"; ls -R target | head -30; exit 1; } + +# ── the recorded closure ──────────────────────────────────────────────────── +python3 - "$RES" > "$TMP/closure.txt" <<'PY' || exit 1 +import json, sys +doc = json.load(open(sys.argv[1])) +search = doc["runtime"].get("search", {}) +closure = search.get("closure") +if closure is None: + print("MISSING", file=sys.stderr) + raise SystemExit("FAIL: resolution.json records no runtime search closure") +for d in closure: + print(d["origin"], d["path"], sep="\t") +PY +cat "$TMP/closure.txt" + +FARM="$(awk -F'\t' '$1=="subos_farm"{print $2}' "$TMP/closure.txt" | tail -1)" +PAYLOAD_FIRST="$(awk -F'\t' 'NR==1{print $1}' "$TMP/closure.txt")" + +if [[ -z "$FARM" ]]; then + # SKIP, LOUDLY. A SubOS with no lib view is a legitimate configuration + # (a bare or freshly created one), but a test that silently passes in that + # state would report "the farm is last" on a machine that has no farm. + echo "SKIP: this SubOS exposes no library view, so there is no farm entry" + echo " to order. Recorded closure:" + cat "$TMP/closure.txt" + exit 0 +fi + +# ── invariant 1: the closure is ordered, farm last ────────────────────────── +LAST_ORIGIN="$(tail -1 "$TMP/closure.txt" | cut -f1)" +[[ "$LAST_ORIGIN" == "subos_farm" ]] || { + echo "FAIL: the last entry of the search closure is '$LAST_ORIGIN', not the farm" + echo " A mutable symlink view ahead of an immutable payload lets a later" + echo " \`xlings install\` change which libc an already-linked artifact loads." + cat "$TMP/closure.txt" + exit 1 +} +[[ "$PAYLOAD_FIRST" == "payload" ]] || { + echo "FAIL: the closure does not start with a payload directory (got '$PAYLOAD_FIRST')" + cat "$TMP/closure.txt" + exit 1 +} + +# ── invariant 2: the artifact says the same thing ─────────────────────────── +# +# Parsed from the dynamic section directly rather than via readelf: a sandbox +# home does not reliably have binutils, and on at least one real machine its +# shims pointed into a deleted directory. +DT_RPATH="$(python3 - "$BIN" <<'PY' +import struct, sys +d = open(sys.argv[1], 'rb').read() +if d[:4] != b'\x7fELF' or d[4] != 2: + print(""); raise SystemExit +phoff, = struct.unpack_from(' file offset via PT_LOAD + loads = [] + for k in range(phnum): + o = phoff + k * phentsize + t, = struct.unpack_from(' mcpp.toml <<'EOF' +[package] +name = "probe" +version = "0.1.0" +EOF +echo 'int main() { return 0; }' > src/main.cpp +"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } +RES="$(ls target/*/*/resolution.json | head -1)" + +read -r FARM_LIB FARM_DIR <<<"$(python3 - "$RES" <<'PY' +import json, os, sys, re +doc = json.load(open(sys.argv[1])) +closure = doc["runtime"].get("search", {}).get("closure", []) +farms = [d["path"] for d in closure if d["origin"] == "subos_farm"] +payloads = [d["path"] for d in closure if d["origin"] != "subos_farm"] + +# Names the payloads already provide are useless here: the farm would not be +# the reason they load. +provided = set() +for p in payloads: + try: provided.update(os.listdir(p)) + except OSError: pass + +# A link name (`libfoo.so`) whose SONAME-ish sibling exists, skipping the C +# runtime: those are the payload's job and are on the search path ahead of the +# farm by construction. +skip = re.compile(r'^lib(c|m|dl|rt|pthread|gcc_s|stdc\+\+|atomic|c\+\+.*)\.so') +for d in farms: + try: names = sorted(os.listdir(d)) + except OSError: continue + for n in names: + if not n.endswith(".so") or not n.startswith("lib"): continue + if skip.match(n): continue + if n in provided: continue + if not os.path.exists(os.path.join(d, n[:-3] + ".so.1")) \ + and not os.path.islink(os.path.join(d, n)): continue + print(n[3:-3], d) + raise SystemExit +print("", "") +PY +)" + +if [[ -z "$FARM_LIB" ]]; then + # SKIP, LOUDLY, WITH THE REASON. Silence here would mean "the farm works" + # on a machine where nothing was ever loaded from it. + echo "SKIP: this SubOS farm provides no library the payloads do not already" + echo " provide, so there is nothing whose loading proves the farm is" + echo " reachable. Install any library into the SubOS to exercise this." + exit 0 +fi +echo "farm-only library: -l$FARM_LIB (from $FARM_DIR)" + +# ── half 1: it must LINK and RUN ──────────────────────────────────────────── +# +# `--no-as-needed` is what makes this a test of the loader rather than of the +# linker: with no symbol referenced, --as-needed would drop the DT_NEEDED +# entirely and the program would run for a reason that proves nothing. +mkdir -p "$TMP/uses/src" +cd "$TMP/uses" +cat > mcpp.toml < src/main.cpp + +"$MCPP" build > build.log 2>&1 || { + echo "FAIL: linking against a farm-provided library failed" + cat build.log + exit 1 +} +BIN="$(ls target/*/*/bin/uses | head -1)" +python3 - "$BIN" "$FARM_LIB" <<'PY' || exit 1 +import struct, sys +d = open(sys.argv[1], 'rb').read() +phoff, = struct.unpack_from(') and must be reachable at load time too." + exit 1 +} +echo "ran: exit 0 with a farm-only DT_NEEDED" + +# ── half 2: an unsatisfiable DT_NEEDED must turn the build RED ────────────── +# +# Built by giving a real shared library a SONAME that names no file anywhere. +# The link succeeds (ld was handed the file), the DT_NEEDED records the SONAME, +# and nothing on the artifact's search path can ever satisfy it. +mkdir -p "$TMP/ghostlib/src" +cd "$TMP/ghostlib" +cat > mcpp.toml <<'EOF' +[package] +name = "ghostlib" +version = "0.1.0" + +[targets.ghost] +kind = "shared" + +[build] +ldflags = ["-Wl,-soname,libmcpp_ghost_probe.so.1"] +EOF +cat > src/ghost.cppm <<'EOF' +export module ghostlib.ghost; +export int ghost_value() { return 1; } +EOF +"$MCPP" build > build.log 2>&1 || { echo "FAIL: ghost library build"; cat build.log; exit 1; } +GHOST_SO="$(ls target/*/*/bin/libghost.so 2>/dev/null | head -1)" +[[ -n "$GHOST_SO" ]] || { echo "FAIL: no libghost.so produced"; ls -R target | head -30; exit 1; } +# ABSOLUTE. The consumer below is built from a different directory, and a +# relative -L there resolves to nothing — which fails the build for the wrong +# reason and would read exactly like the failure this half is asserting. +GHOST_DIR="$(cd "$(dirname "$GHOST_SO")" && pwd)" + +mkdir -p "$TMP/needsghost/src" +cd "$TMP/needsghost" +cat > mcpp.toml < src/main.cpp + +if "$MCPP" build > build.log 2>&1; then + echo "FAIL: a DT_NEEDED that nothing can provide built successfully" + echo " The artifact cannot start; reporting that as a pass is the" + echo " defect this half exists for." + grep -i "ghost" build.log | head + exit 1 +fi +grep -qi "libmcpp_ghost_probe.so.1" build.log || { + echo "FAIL: the build failed, but not for the unresolvable DT_NEEDED —" + echo " a failure that does not name the cause is not this assertion." + cat build.log + exit 1 +} +echo "unresolvable DT_NEEDED failed the build and named itself" + +echo "PASS: farm-only libraries load, unsatisfiable ones fail the build" diff --git a/tests/unit/test_elf_runtime.cpp b/tests/unit/test_elf_runtime.cpp index 77c8728f..183f925b 100644 --- a/tests/unit/test_elf_runtime.cpp +++ b/tests/unit/test_elf_runtime.cpp @@ -166,6 +166,10 @@ runtime::RuntimeBinding binding_for(const std::filesystem::path& payload, b.providerId = "xlings"; b.platform = "linux"; b.arch = "x86_64"; + // A SubOS that described itself — which is what every fixture here means. + // Left at the default the two states would be indistinguishable in the + // fixtures, and "undeclared" carries its own verdict now. + b.declared = true; b.runtimeId = "glibc@" + version; b.libc = b.runtimeId; b.loader = payload / version / "lib64" / "ld-linux-x86-64.so.2"; @@ -363,22 +367,76 @@ TEST(RuntimePhysics, RuleAAcceptsEqualOrLowerFloor) { EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Pass); } -TEST(RuntimePhysics, MissingClosureDataIsInconclusiveNotGreen) { +// UNDER A HERMETIC BINDING, "not found" IS A MEASUREMENT. +// +// The artifact's PT_INTERP names a private loader whose entire search path mcpp +// computed — RPATH/RUNPATH, payloads, SubOS farm, and nothing else: no host +// defaults and no ld.so.cache. So an unresolved DT_NEEDED is the same answer +// the loader will give, and the program cannot start. Filing that under +// `inconclusive` reports a proven failure as an absence of one, and it is how a +// GL program that exited 127 was shipped as `validation: pass`. +TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { if constexpr (!mcpp::platform::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); + ASSERT_TRUE(b.hermetic()) << "this test's premise is a private loader"; elf::RuntimeResolution r; r.artifact = facts(t.path / "app"); r.artifact.interp = b.loader->string(); r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; r.unresolved = {"libgpu-driver.so"}; + auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); + EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Unresolvable); + EXPECT_TRUE(verdict.blocking()) + << "a proven-unstartable artifact must fail the build, not warn"; + EXPECT_NE(verdict.explain().find("libgpu-driver.so"), std::string::npos); +} + +// The other side of the same line. Without a private loader the artifact runs +// under the HOST's, which also consults `ld.so.cache` — something mcpp +// deliberately does not parse. There, unresolved really is unknown, and +// claiming otherwise would fail builds that work. +TEST(RuntimePhysics, UnresolvedNeededWithoutAPrivateLoaderStaysInconclusive) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + Tmp t; + auto b = binding_for(t.path / "store"); + b.loader.reset(); // host runtime: gcc@system and friends + ASSERT_FALSE(b.hermetic()); + elf::RuntimeResolution r; + r.artifact = facts(t.path / "app"); + r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; + r.unresolved = {"libgpu-driver.so"}; + auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Inconclusive); + EXPECT_FALSE(verdict.blocking()); EXPECT_NE(verdict.explain().find("libgpu-driver.so"), std::string::npos); } +// An undeclared SubOS on Linux is not "some other runtime" — it is a runtime +// nobody described, and the two must not print the same sentence. Reporting the +// second as the first sends the reader looking for a runtime they did not +// select, and quietly counts "unknown" as "fine". +TEST(RuntimePhysics, UndeclaredBindingIsInconclusiveNotNotApplicable) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + Tmp t; + auto b = binding_for(t.path / "store"); + b.declared = false; + b.runtimeId.clear(); + b.loader.reset(); + elf::RuntimeResolution r; + r.artifact = facts(t.path / "app"); + + auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); + EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Inconclusive); + EXPECT_NE(verdict.explain().find("does not describe its runtime"), + std::string::npos); +} + TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { if constexpr (mcpp::platform::is_linux) GTEST_SKIP() << "the non-Linux boundary is exercised on native runners"; diff --git a/tests/unit/test_runtime_search.cpp b/tests/unit/test_runtime_search.cpp new file mode 100644 index 00000000..9845f0fb --- /dev/null +++ b/tests/unit/test_runtime_search.cpp @@ -0,0 +1,127 @@ +// The run-time search-path contract: rank, provenance, machine-locality. +// +// This module is pure policy — it never touches the filesystem and knows +// nothing about ELF — so it is the one part of the closure that can be pinned +// exactly, on every platform, including the ones where none of it applies. +// Everything downstream (the DT_RPATH mcpp emits, what `mcpp pack` strips, +// what the closure resolver walks) is a consequence of these four answers. + +#include + +import std; +import mcpp.platform.runtime_search; + +namespace search = mcpp::platform::search; +using search::Origin; + +namespace { + +// THE invariant. Payload before farm is not a preference: `/lib` is a +// symlink view rewritten by every `xlings install`, a payload directory is +// written once, and an already-linked artifact must not have its libc changed +// out from under it by a later install. +TEST(RuntimeSearch, PayloadOutranksFarm) { + EXPECT_LT(search::rank(Origin::Payload), search::rank(Origin::SubosFarm)); + EXPECT_LT(search::rank(Origin::Package), search::rank(Origin::SubosFarm)); + EXPECT_LT(search::rank(Origin::SubosFarm), search::rank(Origin::HostDefault)); +} + +// `mcpp pack` asks this to decide what may not be baked into a distributable. +// The host's own /usr/lib is the only one that is NOT this machine's private +// state — relying on it is an ordinary host requirement, not a dependency on +// the build box. +TEST(RuntimeSearch, MachineLocalIsEverythingButTheHostDefaults) { + EXPECT_TRUE(search::is_machine_local(Origin::Payload)); + EXPECT_TRUE(search::is_machine_local(Origin::Package)); + EXPECT_TRUE(search::is_machine_local(Origin::SubosFarm)); + EXPECT_FALSE(search::is_machine_local(Origin::HostDefault)); +} + +TEST(RuntimeSearch, OriginNamesRoundTrip) { + for (auto origin : {Origin::Payload, Origin::Package, + Origin::SubosFarm, Origin::HostDefault}) { + auto parsed = search::parse_origin(search::to_string(origin)); + ASSERT_TRUE(parsed.has_value()) << search::to_string(origin); + EXPECT_EQ(*parsed, origin); + } + EXPECT_FALSE(search::parse_origin("something_else").has_value()); +} + +TEST(RuntimeSearch, OrderedSortsByRankNotByInsertion) { + std::vector input{ + {"/farm", Origin::SubosFarm}, + {"/pkg", Origin::Package}, + {"/pay", Origin::Payload}, + }; + auto out = search::ordered(input); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0].path, std::filesystem::path("/pay")); + EXPECT_EQ(out[1].path, std::filesystem::path("/pkg")); + EXPECT_EQ(out[2].path, std::filesystem::path("/farm")); +} + +// Stable within a rank, because insertion order still decides among peers — +// libglvnd resolves GL vendors by exactly that order, so reordering equals +// would change which driver a program gets. +TEST(RuntimeSearch, OrderedIsStableWithinARank) { + std::vector input{ + {"/pay/b", Origin::Payload}, + {"/pay/a", Origin::Payload}, + {"/pay/c", Origin::Payload}, + }; + auto out = search::ordered(input); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0].path, std::filesystem::path("/pay/b")); + EXPECT_EQ(out[1].path, std::filesystem::path("/pay/a")); + EXPECT_EQ(out[2].path, std::filesystem::path("/pay/c")); +} + +// The same directory reachable two ways is ONE entry, and it takes the +// stronger origin. A payload that is also visible through the farm view is a +// payload; ranking it as farm would push the real libc behind everything else +// the farm holds — the exact failure the ordering exists to prevent. +TEST(RuntimeSearch, DuplicatePathKeepsTheStrongestOrigin) { + std::vector input{ + {"/shared", Origin::SubosFarm}, + {"/shared", Origin::Payload}, + {"/other", Origin::SubosFarm}, + }; + auto out = search::ordered(input); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0].path, std::filesystem::path("/shared")); + EXPECT_EQ(out[0].origin, Origin::Payload); + EXPECT_EQ(out[1].origin, Origin::SubosFarm); +} + +TEST(RuntimeSearch, OrderedNormalizesAndDropsEmpty) { + std::vector input{ + {"", Origin::Payload}, + {"/a/./b/", Origin::Payload}, + {"/a/b", Origin::Payload}, + }; + auto out = search::ordered(input); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].path, std::filesystem::path("/a/b")); +} + +TEST(RuntimeSearch, PathsOfPreservesContractOrder) { + auto out = search::ordered({ + {"/farm", Origin::SubosFarm}, + {"/pay", Origin::Payload}, + }); + auto paths = search::paths_of(out); + ASSERT_EQ(paths.size(), 2u); + EXPECT_EQ(paths[0], std::filesystem::path("/pay")); + EXPECT_EQ(paths[1], std::filesystem::path("/farm")); +} + +// The opt-out is a CROSS-REPO name (openxlings/xlings#540). Pinning the +// spelling here means a rename shows up as a failed test rather than as a +// silently ineffective declaration — the failure mode of an env var nobody +// reads is that everything looks fine. +TEST(RuntimeSearch, LinkerOptOutIsSpelledOnce) { + EXPECT_EQ(search::kLinkerPathInjectionOptOut, "XLINGS_SUBOS_LD_PATHS"); + EXPECT_EQ(search::kLinkerPathInjectionOptOutValue, "0"); +} + +} // namespace From cf3894189dc4aeb2c70698cf448e7b3403acfee9 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:56:08 +0800 Subject: [PATCH 04/14] =?UTF-8?q?release:=202026.8.11.2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=96=87=E6=A1=A3=E3=80=81xlings=20pin=E3=80=81?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E4=B8=8E=E5=AE=9E=E6=96=BD=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版本两处同步(mcpp.toml + src/version.cppm),内带 xlings pin 升到 2026.8.11.2 并同步全部 workflow/action 引用点;check_version_pins.sh 通过。bootstrap pin (.xlings.json)不动 —— 它必须指向一个已发布并进入索引的版本,发布收尾时才前移。 文档: - 08-toolchain-internals(中英)新增 §2.3 运行期搜索闭包:四种 origin、次序与 理由、两条护栏、host_default 为什么只对非 hermetic 产物成立、退出声明。 - 同一份的 §2.1 改掉一句现在是假的话:「contract 缺失/不兼容会 hard error」。 改成矛盾报错、缺席降级,并写明 Linux 上降级确实会失去 payload-first。 - 02-pack-and-release 补一句所有档位都不携带构建机路径,并指出 system 档不是 「保留构建时的样子」而是「目标机提供一切」—— 后者无法用本机绝对路径表达。 设计与实施记录进 .agents/docs。两份文档都在顶部标了与实施的出入,每一处都是被 实测推翻的:system 档早就剥干净(所以那一项从「实现」变成「补测试」)、 farm 不能复用 linkIntent 字段(会流进 LD_LIBRARY_PATH)、载荷目录不能只读 linkRuntimeDirs(GCC 上是空的)。 --- ...-search-closure-and-binding-degradation.md | 500 ++++++++++++++++++ ...time-search-closure-implementation-plan.md | 333 ++++++++++++ .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 6 +- .github/workflows/release.yml | 14 +- CHANGELOG.md | 59 +++ docs/02-pack-and-release.md | 10 + docs/08-toolchain-internals.md | 69 ++- docs/zh/08-toolchain-internals.md | 58 +- mcpp.toml | 2 +- tests/unit/test_runtime_contract.cpp | 2 +- 14 files changed, 1040 insertions(+), 25 deletions(-) create mode 100644 .agents/docs/2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md create mode 100644 .agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md diff --git a/.agents/docs/2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md b/.agents/docs/2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md new file mode 100644 index 00000000..a16fe01a --- /dev/null +++ b/.agents/docs/2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md @@ -0,0 +1,500 @@ +# 图形栈剩下的那一半:链接期看得见、运行期看不见 + +> 日期:2026-08-11 +> 基线:mcpp `main` `1bc6074`(源码版本 `2026.8.11.1`,本机已装 `2026.8.10.3`)、 +> xlings `2026.8.11.2`、本机 x86_64-linux-gnu / gcc 16.1.0 / NVIDIA + X11 +> 来源:xlings [#532](https://github.com/openxlings/xlings/issues/532)(构建侧标签,mcpp 侧已完成一半)、 +> [#537](https://github.com/openxlings/xlings/issues/537)(subos info 低报)、 +> [#540](https://github.com/openxlings/xlings/issues/540)(E2b 落地前的接口谈判)、 +> [#543](https://github.com/openxlings/xlings/issues/543)(Windows 上 `mcpp build|test` 直接失败) +> 前置阅读(只需结论):`2026-08-10-graphics-closure-and-distribution-tiers-design.md` +> +> 本文所有"已验"结论都在本机跑过,命令与输出在正文里。凡未实测一律标 **未验**。 +> file:line 全部核于上述基线。 +> +> **实施状态(2026-08-11):全部已实施,版本 `2026.8.11.2`。** +> 实施计划见 `2026-08-11-graphics-runtime-search-closure-implementation-plan.md`。 +> **与本文的三处出入,以实施为准:** +> - **§2.6 的"`system` 档剥离"不需要写代码** —— 实测 `--mode system` 早就把 rpath +> 清空并把 `PT_INTERP` 改回 `/lib64/ld-linux`,产物里没有任何 `$MCPP_HOME` 路径。 +> 真正的缺口是 §2.6 那条**已有守卫抓不到它**,所以这一项从"实现"变成"补测试"。 +> - **§2.6 关于"从 farm 解析到的 NEEDED 升级为 host requirement"没有做**。 +> `HOST-REQUIREMENTS` 存在的理由是记录**产物本身看不出来**的东西(经 dlopen 链 +> 到达的驱动);而一个 `NEEDED` 本来就写在产物里,再抄一遍是冗余。 +> - **§2.1 说 farm 走 `linkIntent.runtimeSearchDirs`,实施改成了 `BuildPlan` 上的 +> 独立字段**。原因见实施计划 §4:那个字段还喂 `runtimeLibraryDirs`,而后者会变成 +> `mcpp run` 的 `LD_LIBRARY_PATH` —— **farm 绝不能进环境变量**,那正是本文 +> §6"不做什么"里禁掉的东西。差点自己踩进去。 + +--- + +## 0. 结论先行 + +这一轮要修的是**两个缺陷**,它们看起来一个属于图形、一个属于 Windows,底下是同一句话: + +> **一个决策在一处做了,在另一处没做 —— 而"没做"的那一处不报错。** + +| | 缺陷 A(图形) | 缺陷 B(xlings#543) | +|---|---|---| +| **一句话** | subos 在**编译/链接**期是 sysroot,在**运行**期不是任何东西 | subos **没有声明**,被当成 subos **有矛盾** | +| **症状** | `mcpp build` rc=0,`./bin/app` → `libGL.so.1: cannot open shared object file` | `mcpp build\|test` 直接 error 退出,文案在讲 GL 驱动 —— 在一台 Windows 上 | +| **判据行** | `flags.cppm:797`(运行期路径只有载荷目录) | `runtime_binding.cppm:217`(`!info.present` ⇒ 硬失败) | +| **谁掩盖了它** | `elf_runtime.cppm:282` 回落到**宿主**默认目录 ⇒ `validation: pass` | 无 —— 它很响,只是响错了地方 | +| **回归窗口** | 一直如此(不是回归) | **PR #400**,首次随 `2026.8.10.2` 发布 | + +两条修法也是同一句话的两面: + +> **A:运行期的搜索集合必须与链接期的视图同源(同一个 binding 推导一次)。** +> **B:缺声明必须降级为"未验证",不得升级为"构建失效"。** + +本轮**不新增任何用户可见语法**。用户侧的可见变化只有一条:原来链得上、跑不起来的图形程序,现在跑得起来。 + +--- + +## 1. 实测基线(先看现场,再谈设计) + +### 1.1 复现:零 flag 的 GL 程序 + +```toml +# mcpp.toml +[package] +name = "glprobe" +version = "0.1.0" +standard = "c++23" + +[build] +ldflags = ["-lGL", "-lX11"] +``` + +```console +$ mcpp build + Finished dev [unoptimized + debuginfo] in 0.05s # rc=0 ✅ + +$ readelf -d target/x86_64-linux-gnu/*/bin/glprobe + (NEEDED) [libGL.so.1] + (NEEDED) [libX11.so.6] + (NEEDED) [libm.so.6] [libgcc_s.so.1] [libc.so.6] + (RPATH) [/data/xpkgs/xim-x-glibc/2.39/lib64: + /data/xpkgs/xim-x-gcc/16.1.0/lib64] ← 没有 /lib + +$ ./target/.../bin/glprobe +error while loading shared libraries: libGL.so.1: cannot open shared object file # rc=127 ❌ + +$ mcpp run # rc=1,同样的错误 +$ mcpp why runtime +validation: pass (source post_link) # ⚠️ 假绿 + - …/bin/glprobe: pass +``` + +**顺带记一条**:`[build] libraries = ["GL"]` 不是合法键(`warning: unsupported key 'libraries' (ignored)`), +今天唯一的写法是 `ldflags = ["-lGL"]`。这属于用户语法层,**本轮范围外**(见 §6)。 + +### 1.2 为什么链接期通得过:subos 已经是 sysroot 了 + +`build.ninja` 头部(原文): + +```ninja +cxxflags = -std=c++23 -fmodules -O0 -g --sysroot=/subos/default -B<…>/xim-x-binutils/2.42/bin +ldflags = --sysroot=/subos/default \ + -Wl,--dynamic-linker=<…>/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2 \ + -L<…>/xim-x-glibc/2.39/lib64 -Wl,-rpath,<…>/xim-x-glibc/2.39/lib64 \ + -L<…>/xim-x-gcc/16.1.0/lib64 -Wl,-rpath,<…>/xim-x-gcc/16.1.0/lib64 \ + -B<…>/xim-x-binutils/2.42/bin -specs=<…>/mcpp-clean-link.specs -lGL -lX11 + +build bin/glprobe : cxx_link obj/main.o + unit_ldflags = -static-libstdc++ -Wl,--disable-new-dtags ← 标签契约,已生效 ✅ +``` + +`-lGL` 之所以解析得到,是因为 `--sysroot=` 让 ld 把 `/lib` 当默认库目录。 +**mcpp 今天已经把整个 subos 当作自己的根**,只是这个事实没有传到运行期。 + +| 阶段 | 对 subos 的视图 | 机制 | 状态 | +|---|---|---|---| +| 编译 | 整个 subos | `--sysroot=`(`linkmodel.cppm:83`) | ✅ | +| 链接 | 整个 subos | `--sysroot=`(`linkmodel.cppm:109`) | ✅ | +| **运行** | **两个载荷目录** | `flags.cppm:800-803` 只遍历 `plan.toolchain.linkRuntimeDirs` | ❌ | + +**这不是"缺一个 flag",是同一个决策在两处独立推导,而其中一处的输入集合小了一整个 farm。** + +### 1.3 假绿的机制:闭包模型用错了加载器 + +`elf_runtime.cppm:262-288` 的 `resolve_needed()` 搜索顺序: + +```cpp +for (auto const& raw : requester.runpaths) append_unique_path(dirs, expand_origin(raw, …)); +for (auto const& dir : additionalSearchDirs) append_unique_path(dirs, dir); +for (auto const& dir : binding.libraryDirs) append_unique_path(dirs, dir); +for (auto const& dir : host_library_dirs()) append_unique_path(dirs, dir); // :282 +``` + +`host_library_dirs()`(`:245`)返回 `/lib/x86_64-linux-gnu`、`/usr/lib64`、`/usr/lib`…… +本机实测宿主确实有 `/usr/lib/x86_64-linux-gnu/libGL.so.1` ⇒ `libGL.so.1` **在模型里解析成功**, +`resolution.unresolved` 为空,判决落到干净的 `Pass`。 + +**但产物跑在私有 loader 下**(`PT_INTERP` = 载荷 2.39 的 `ld-linux`),它的内建默认路径里没有宿主目录。 + +> **闭包模型模拟的是宿主加载器,产物用的是私有加载器。两者在"默认目录"这一项上不同, +> 而这一项恰好是唯一没被 binding 覆盖的输入。** + +即使 `unresolved` 非空,后果也只是 `inconclusive`(`elf_runtime.cppm:759-768`), +而 `has_proven_mismatch()`(`runtime_validation.cppm:41-47`)只对 `ProvenMismatch` 失败。 +**⇒ 今天没有任何一条路径能让"这个产物加载不了"变成红色。** + +这条要和 §2.1 一起修:**只修路径不修判据,等于把新行为建在一个不会响的报警器上面。** + +### 1.4 ⚠️ 为什么不能直接用 `$XLINGS_SUBOS_LIB` + +这是本轮最硬的一条实测,它直接否掉"加两个 flag 就完事"的写法: + +```console +$XLINGS_SUBOS_LIB = ~/.xlings/subos/current/lib + └─ libc.so.6 → ~/.xlings/data/xpkgs/xim-x-glibc/2.39/lib/libc.so.6 + +mcpp 的 RuntimeBinding = ~/.mcpp/registry/subos/default/lib + └─ libc.so.6 → ~/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib/libc.so.6 +``` + +**两个 home、两份物理 glibc 载荷、两个不同的 subos。** mcpp 有自己的 registry home, +它解析的 subos 与 shell 的 active subos **不是同一个**,并且没有任何机制保证它们同步。 + +把环境变量里的 farm 塞进 DT_RPATH,等于给产物挂上一个 mcpp 不管理、不 pin、随时会被 +`xlings use` 换掉的第二套 libc / libstdc++ —— 正是 rule B 存在的那句话: +*"one process cannot mix runtime payloads"*。 +本机两边版本号恰好都是 2.39,所以今天侥幸能跑;**版本一旦不同就是静默错**。 + +> **⇒ farm 路径只能从 mcpp 自己已解析的 `RuntimeBinding.subosDir` 推导。环境变量在这里是噪声,不是契约。** + +### 1.5 xlings#543:Windows 上直接失败 + +报告(xlings 2026.8.11.1 + mcpp 2026.8.10.3,Windows 11): + +``` +error: selected SubOS 'default' cannot provide a RuntimeBinding: + subos 'C:\Users\…\.mcpp\registry\subos\default' does not describe itself + (no `subos_info` block), so programs run from here get no environment it + declares — a GL application will not find its drivers. … +``` + +三处都错: + +1. **它是硬失败。** `runtime_binding.cppm:216-221`,`resolve_runtime_binding` 在 + `!info.present` 时返回 `unexpected`;`prepare.cppm:1292-1294` **无平台条件地**调用它并 + `return std::unexpected(...)`。⇒ Windows 上每一次 `mcpp build` / `mcpp test` 都停在这里。 +2. **它在 Windows 上讲 GL 驱动。** 这段文案是给 Linux 图形场景写的,被一个跨平台调用点复用了。 + Windows 既没有 ELF、没有 `PT_INTERP`、没有私有 libc,`validate_runtime_artifact` 自己第一句就是 + *"non-Linux platform; ELF/glibc rules are not applicable"*(`elf_runtime.cppm:629-633`)。 +3. **回归窗口已确认。** `git log -S "cannot provide a RuntimeBinding"` 只命中 `0006ce6`(PR #400), + 且 `git merge-base --is-ancestor 54d29fc 0006ce6` 为真 ⇒ #400 在 `2026.8.8.4` 之后落地, + **首次随 `2026.8.10.2` 发布**。与报告"降级到 2026.8.8.4 就能跑"完全吻合。 + +**同一位置还埋着第二颗雷**(尚未爆,爆了就是全平台): + +```cpp +// runtime_binding.cppm:222 +if (info.schema != mcpp::xlings::subos::kSupportedSchema) { /* hard error */ } +``` + +而它的**上游读取器**明写着相反的规矩(`subos_info.cppm:45-49`): + +> *"A HIGHER one on disk is still read — we take the fields we know and say so. +> Refusing outright would let a newer xlings break an older mcpp, which is the failure +> shape the index-floor incident already paid for once: publishing data must not +> invalidate the program that reads it."* + +**读取器按"上限"设计,消费者按"相等"检查。** xlings 写出 schema 2 的那一天, +所有平台上的所有 mcpp 构建同时停摆。这是 index-floor 事故的第二次转生,必须一起修。 + +--- + +## 2. 缺陷 A:运行期搜索闭包 + +### 2.1 A1 — farm 进 `runtimeSearchDirs`,从 binding 推导,排在末位 + +**做什么**:`RuntimeBinding` 增加一个字段 `searchDirs`(farm 目录,通常是 `/lib`), +由 `resolve_runtime_binding` 在**已有的那次探测**里顺手产出 —— 就是 +`runtime_binding.cppm:253-284` 里已经在走的 `{subosDir/"lib64", subosDir/"lib"}` 循环。 +**不新增一处布局知识**:今天那个循环是为了找 `libc.so.6`,顺路把命中的目录本身记下来即可。 + +然后经**已有的专用通道**下发: + +``` +RuntimeBinding.searchDirs + → BuildPlan.linkIntent.runtimeSearchDirs (plan.cppm,与包描述符的同一个字段汇合) + → render_link_intent_flags → "-Wl,-rpath," (flags.cppm:304-310) +``` + +选这个通道不是随意的,`flags.cppm:804-806` 的注释已经把语义写死了: + +> *"runtimeSearchDirs contributes RUNPATH only; it must never become a link-time `-L` path."* + +**正是我们要的**:链接期的解析已经由 `--sysroot` 覆盖(§1.2 实测),这里只补运行期。 +**不发 `-L`** 还额外省下链接行长度(128KiB 上限,真实 workspace 已用掉 43%)。 + +**排序不变式(硬要求)**: + +> **farm 目录必须排在所有载荷目录之后。** + +理由是**可变性**,不是风格: + +| 目录 | 可变性 | 谁在写 | +|---|---|---| +| `/xim-x-glibc/2.39/lib64` | **不可变** | 装一次就不再动 | +| `/lib` | **可变** | 每次 `xlings install` / 重解析都重写符号链接 | + +载荷目录在前 ⇒ `libc.so.6` / `libm.so.6` / `libstdc++.so.6` 永远从被 pin 的载荷解析, +farm **只补没人提供的那些**(libGL / libX11 / libEGL / libwayland / libvulkan…)。 +farm 若在前,一次 `xlings install` 就能在事后悄悄换掉一个**已经构建好**的产物的 libc。 + +**这一条要被断言,不能只被写下来**(§5.1)。 + +### 2.2 A2 — 与 2026-08-10 那份设计的分歧:不是推翻,是"不继承 ≠ 不使用" + +上一份设计明确写过两条,必须正面回应: + +> §1.3.1 表格:**`-rpath /lib`(路径)| mcpp 必须不继承** +> §不做什么:**不把 `/lib` 放进任何环境变量或全局搜索路径。……vendor 只能按对象可达(RPATH)。** + +逐条对齐: + +| 上一份的原话 | 本轮的位置 | +|---|---| +| "不把 `/lib` 放进**任何环境变量或全局搜索路径**" | **完全保留**。本轮不碰 `LD_LIBRARY_PATH`,不碰任何全局路径。§1.4 又给它加了一条独立证据 | +| "**vendor 只能按对象可达(RPATH)**" | **本轮正是在执行这一条** —— 把 farm 放进产物**自己的 DT_RPATH**,per-object,不外溢 | +| "`-rpath /lib` mcpp 必须**不继承**" | **保留,并加强**:mcpp 不接受**别人注入**的那条路径(§2.5 声明式退出),而是发**自己推导**的那条 | +| 理由:"一条**未经审查的**、带 libc 的目录" | 关键词是**未经审查**。而 `/lib` 恰恰是 mcpp **解析自己 libc 时穿过的那个目录**(`runtime_binding.cppm:257-262` 就是从 `/lib/libc.so.6` 跟到载荷的)。它不是第二个 libc,是**同一个 libc 的第二条路径**;再叠上排序不变式,连"第二条路径"都不会被走到 | + +还有一条更硬的、上一份没有的证据: + +> **链接期已经在信任 farm 了。** `--sysroot=` 意味着 `-lGL` 就是从 `/lib` 解析出来的。 +> 运行期拒绝同一个目录,不构成任何安全边界 —— 只构成"链得上、跑不了"。 + +**⇒ 分歧点只有一处、且是措辞层面的:"不继承"被读成了"不使用"。本轮把它读回"不继承"。** + +### 2.3 A3 — 闭包判据必须能变红 + +两处改动,缺一不可: + +**(a) 默认目录按 binding 分档。** `resolve_needed`(`elf_runtime.cppm:282`)的 +`host_library_dirs()` 回落**只在 binding 就是宿主运行时**(非 hermetic、无私有 `PT_INTERP`)时才加入。 +hermetic binding 的默认集合 = 产物自身 RPATH/RUNPATH + binding 载荷目录 + binding farm。 +今天这个回落把宿主的 `libGL.so.1` 冒充成答案,是 §1.1 那个 `pass` 的直接成因。 + +**(b) "解析不到"要有自己的名字。** 今天 hermetic 产物的一个无法解析的 `NEEDED` +是**可证的失败**(私有 loader 一定打不开),把它归到 `Inconclusive` 是**把可证的事说成没查过**。 +`RuntimeVerdict::Status` 增加 `UnresolvableNeeded`,并让 `runtime_validation` 的失败门收下它。 + +> 三值的用途是区分"没查"和"查了没事"。**"查了,而且证明它跑不起来"是第四种,不该被前两种吸收。** + +分档保留了 `gcc@system` 一类非 hermetic binding 的正确行为:那里宿主目录**确实**是加载器的默认值。 + +### 2.4 A4 — 两条护栏 + +| 护栏 | 规则 | 为什么 | +|---|---|---| +| **交叉目标** | 目标三元组与 binding 的 platform/arch 不一致时,**不发 farm** | farm 是宿主 subos 的产物。aarch64-musl / mingw / wasm 目标拿到一条 x86_64-glibc 的路径,轻则无效重则误解析 | +| **非 ELF** | 与标签契约同构:Mach-O / PE **无此概念**,不进任何分支 | `loader_contract` 已经用 `NotApplicable` 立过这个形状,照抄 | + +### 2.5 A5 — 对 xlings E2b 的声明式退出:mcpp 主动关掉,不是被动容忍 + +xlings#540 的 E2b 会让 `ld` 包装器追加 `-rpath "$XLINGS_SUBOS_LIB" --disable-new-dtags`。 +它落地之后,mcpp 链出来的产物会同时拿到**两条** farm 路径:mcpp 推导的那条,和包装器注入的那条 —— 而后者 +在 §1.4 已被实测证明**可能指向另一个 home**。 + +**mcpp 在它落地之前就把退出声明出来**:mcpp 驱动的每一次链接,环境里带上 +`XLINGS_SUBOS_LD_PATHS=0`(键名以 xlings#540 最终敲定的为准)。 + +- 今天设它 = 无操作(变量还没人读),**零风险**; +- E2b 落地当天自动生效,mcpp 的 DT_RPATH **仍然只包含 mcpp 决定的内容**; +- 与标签那一半的处理**同构**:mcpp 覆盖大量不经过那个 `ld` 的链路(交叉 musl / mingw、 + `-fuse-ld=lld`、`gcc@system` / `msvc@system`、host 工具子构建),**必须处处一个答案**。 + +> **这一条是本设计对 xlings 唯一的跨仓依赖,而且是"先声明后生效"式的 —— 不阻塞本轮实施。** +> 若 xlings 最终选择了别的退出形式,只改这一个常量。 + +### 2.6 A6 — `mcpp pack` 三档行为 + +`pack` 的依赖解析走的是同一个闭包(`elf_runtime`),所以 A1 落地后 +**pack 才第一次有能力看见 libGL / libX11** —— 今天它连这些依赖都找不到,更谈不上打包或申报。 + +| 档位 | farm 路径 | 从 farm 解析到的 `NEEDED` | +|---|---|---| +| `vendored` / `self-contained` | **天然消失**:`set_search_path`(`pack.cppm:408-426`)整体重写为 `$ORIGIN/../lib` | 随 bundle 走(vendor 驱动除外,见下) | +| `static` | 不适用 | — | +| **`system`** | **剥掉**(本轮新增) | **升级为 `HOST-REQUIREMENTS` 条目**(`host_requirements.cppm` 已有机制) | + +`system` 档的语义是"依赖由目标机器提供",而一条构建机绝对路径**不是**目标机器提供的东西 —— +它是一条会跟着 tarball 走、到别的机器上指向不存在或不同内容的目录的路径。剥掉之后信息不丢: +它变成 `HOST-REQUIREMENTS` 里可读的一行。 + +#### ⚠️ 已有的守卫抓不到它 —— 这一条是核验时发现的,必须一并修 + +`tests/e2e/215_pack_has_no_build_machine_paths.sh` 正是为"产物不得残留构建机路径"存在的,但它的 +机器本地前缀只取到 store: + +```bash +STORE="$(cd "$MCPP_HOME/registry/data/xpkgs" && pwd)" # 215:113 +… if [[ "$paths" == *"$STORE"* ]] ; then FAIL … # 215:124 +``` + +而 farm 在 **`$MCPP_HOME/registry/subos/default/lib`** —— **不在 `data/xpkgs` 之下**。 + +> **⇒ A1 落地后,一条泄漏的 farm 路径会让 215 保持绿色。** + +两处补齐,与 A6 同一个 PR: + +1. **前缀扩到整个 mcpp home**(至少加 `registry/subos`)。判据应当是"这台机器的私有状态", + 而 store 只是它的一个子集 —— 今天这条断言的**覆盖面窄于它自己的标题**。 +2. **补 `--mode system` 的用例**。215 只跑默认档(`vendored`);`system` 档从未被任何用例 + 检查过路径泄漏,而它恰恰是唯一不重写 rpath 的那一档。 + +**vendor 驱动无论哪一档都不进 bundle** —— 它与运行中的内核模块版本锁定,且专有栈禁止再分发。 +这是 `host_requirements` 模块开篇就写明的事,本轮不动。 + +--- + +## 3. 缺陷 B:缺声明必须降级,不得使构建失效(xlings#543) + +### 3.1 B1 — `!info.present` ⇒ 降级 + +`resolve_runtime_binding` 返回一个**总是成功**的 binding,携带 `present` 与 `note`: + +| 情况 | 今天 | 改为 | +|---|---|---| +| 无 `.xlings.json` / 无 `subos_info` 块 | **hard error**(全平台) | `present=false` + `note`,**构建继续**;rule A/B 报 `Inconclusive` 并说明是因为**没有声明** | +| `runtime` 字段为空 | hard error | 同上 | +| 用户 `[xlings] subos = "x"` 而 `x` 不存在 | hard error | **保留 hard error** | + +最后一行是这条规则的边界,值得单独说清: + +> **矛盾要报错,缺席要降级。** +> "你点名要 subos `x`,而 `x` 不在" 是矛盾 —— 用户的输入无法被满足。 +> "subos 存在但没有自我描述" 是缺席 —— 有一部分事实不可知,**其余全部照常可用**。 +> 今天这两者被同一条 `return unexpected` 处理。 + +这与 `subos_info.cppm` 自己的 *"A NOTE ON SILENCE"* 一节完全一致:那一节要求每一次降级都填 `note` +并由调用方打印 —— 它从设计上就假定了**降级是存在的**。今天没有降级路径,只有失败路径。 + +**Windows 上的具体后果**:binding 退化为 `platform="windows"` + `present=false`, +没有 loader、没有 libc、没有 farm;`validate_runtime_artifact` 走它第一句已有的 +*"ELF/glibc rules are not applicable"*;`mcpp build` / `mcpp test` 正常完成。 + +### 3.2 B2 — schema 检查改成上限,与读取器对齐 + +```cpp +- if (info.schema != kSupportedSchema) → hard error ++ 高于 kSupportedSchema → 读懂的字段照用,note 说明忽略了什么(读取器已经这么做了) ++ 低于/等于 → 照用 ++ 结构性无法使用 → present=false + note(走 B1 的降级) +``` + +**判据一句话**:**发布数据不得使读它的程序失效。** 这是 index-floor 事故的原话, +`subos_info.cppm:45-49` 也已经把它写进注释 —— 只是消费者没照做。 + +### 3.3 B3 — 文案按调用方分层 + +那句 *"a GL application will not find its drivers"* 是**图形场景**的解释,不是 binding 层的事实。 +binding 层只说事实(*"subos '' 没有 `subos_info` 块;运行期环境声明不可用"*), +GL 那一句留给真正与图形相关的调用点。 + +**判据**:一条诊断不该提到调用方**可能根本不存在**的概念。在 Windows 上讲 GL 驱动, +使读者去找一个不存在的问题 —— 与 xlings#537 里 `subos info` 低报 EGL 是同一种伤害。 + +### 3.4 B4 — `The system cannot find the path specified.` + +报告里这一行**在 2026.8.8.4 上同样出现**(只是当时不致命)⇒ **它是独立的、更早的缺陷,不是本次回归**。 +该文案是 `cmd.exe` 的,说明有一处**经 shell 调用了一个在 Windows 上不存在的路径**。 + +**本轮只做到"定位并单开 issue",不盲改**:没有 Windows 现场,任何修法都是猜。 +需要报告者补一条 `mcpp build --verbose`(或设 `MCPP_LOG=debug`)输出以定位是哪次调用。 +**B1 落地后这条会从"致命+噪声"降为"纯噪声"**,不再阻塞任何人。 + +--- + +## 4. 可观测性:让"为什么我的 GL 程序能跑"可回答 + +三处,都是既有载体的补齐,不新增机制: + +1. **`resolution.json`** —— 运行期搜索目录逐条记 provenance: + `payload`(不可变载荷)/ `subos_farm`(可变符号链接农场)/ `package`(描述符声明)。 + **顺序即语义**,所以记录必须保序。 +2. **`mcpp why runtime`** —— 打印 `NEEDED → 从哪个目录解析到`,并标出该目录的 provenance。 + §1.1 里那个 `pass` 之所以骗过人,正是因为它没说"从哪解析到的"。 +3. **`mcpp doctor`** —— binding 处于 `present=false` 时,把 `note` 作为一条 **info**(不是 warn)呈现, + 并说明后果范围(rule A/B 不可评估;运行期环境声明不可用),而不是让它只在构建输出里一闪而过。 + +--- + +## 5. 测试策略:怎么保证它不空转 + +这个仓库反复付学费的形状是**测试与被测对象共享同一个错误假设**。逐条设防: + +### 5.1 必须存在的四条断言 + +| # | 断言 | 为什么这一条不能省 | +|---|---|---| +| **T1** | 产物 `DT_RPATH` 的**最后一项**是 binding 的 farm 目录 | 直接钉死 §2.1 的排序不变式。**判据是生成物**,不是源码 | +| **T2** | `libc.so.6` 解析到**载荷目录**,不是 farm | 排序若被改反,T1 可能仍过而 T2 必红,并说明原因 | +| **T3** | 一个 `NEEDED` 只存在于 farm 的产物 **能真正运行**(rc=0) | **唯一能戳破 §1.3 假绿的断言**。宿主也有 `libGL.so.1`,所以只有"真的跑"能区分两个加载器 | +| **T4** | 一个 `NEEDED` 谁都提供不了的产物,构建**变红**并指名是哪个 so | 防止 A3 只改了状态枚举而没接到失败门上 | +| **T5** | `pack --mode system` 的产物里**不含**任何 `$MCPP_HOME` 下的路径 | 见 §2.6:现有的 215 前缀只取到 `data/xpkgs`,farm 从它下面漏过去 | +| **T6** | Windows job 上,一个 `subos_info` 缺失的 fixture 能 `mcpp build` 成功 | 缺陷 B 的直接判据,且**不需要任何图形能力** | + +### 5.2 ⚠️ 三个已知会让测试空转的坑 + +1. **不要断言 `readelf` 里"有 farm 这条路径"就收工。** 本机宿主同时有 `libGL.so.1`, + 路径正确但顺序错误、或私有 loader 打不开,`readelf` 都看不出来。**T3 必须真的 exec。** +2. **不要用 `# requires: gcc` 之类在 Windows/macOS 不授予的能力**,否则整条 case 静默跳过 —— + #272 / #412 已经各踩一次。缺陷 B 的用例**必须在 Windows job 里真的跑到**, + 而且它**不需要**任何图形能力:一个空 `subos_info` 的 fixture 加一次 `mcpp build` 就够。 +3. **不要依赖 CI 的 subos 里装了 GL。** T3 需要"一个只存在于 farm 的库", + **不必是 libGL** —— 从 binding 的 farm 与载荷目录做一次差集,取任意一个即可。 + 差集为空时用例应当 **skip 并打印原因**,不得静默通过。 + +### 5.3 对照组 + +沿用 `tests/e2e/214_executable_carries_dt_rpath.sh` 已经证明有效的手法:**用共享库当对照**。共享库不带 `--disable-new-dtags`, +它的标签就是链接器默认值 —— 默认值哪天变了,是**库那条**先红并说明原因, +而不是可执行那条为了别的理由静默地继续通过。farm 这一轮同理: +farm 对可执行与共享库都要发,但**排序断言只在可执行上做**,库那条负责钉住默认行为。 + +--- + +## 6. 不做什么 + +- **不新增 `[build] libraries` / capability 语法。** 用户今天用 `ldflags = ["-lGL"]` 能表达, + 本轮的目标是让这条已经能写的东西**跑得起来**。语法层是独立议题,值得单独一份设计 + (它要和 mcpp-index 描述符的 `linkIntent` 对齐,范围比本轮大)。 +- **不读 `$XLINGS_SUBOS_LIB`。** §1.4 已实测它在本机指向另一个 home。 +- **不碰 `LD_LIBRARY_PATH` 或任何全局搜索路径。** 沿用上一轮的结论,§1.4 只是又加了一条证据。 +- **不在 mcpp 里出现任何库名。** 不出现 `libGL`、不出现 `LIBGL_DRIVERS_PATH`、不判断"这是不是图形程序"。 + farm 是一个目录,mcpp 只知道它的**可变性**和**顺序**。图形只是第一个被它治好的病人。 +- **不改标签契约。** `loader_contract` 已经是对的(§1.2 实测 `unit_ldflags` 生效),本轮一行不动。 +- **不替 xlings 修 #537。** 那是 `subos info` 面板的判定语义(它以消费者标签为条件而没表达这个条件), + 归 xlings。mcpp 侧的对应事实已由 rule E 记录在 `resolution.json` 里。 +- **不盲改 B4 的 Windows 噪声。** 没有现场就没有判据。 + +--- + +## 7. 实施顺序与风险 + +| 步 | 内容 | 依赖 | 风险 | +|---|---|---|---| +| **1** | **B1 + B2 + B3**(binding 降级 / schema 上限 / 文案分层) | 无 | **低**。纯粹放松约束,且**立刻解掉 Windows 用户的阻塞**。应当先发 | +| **2** | **A3**(闭包分档 + `UnresolvableNeeded`) | 无 | 中。**必须先于 A1** —— 否则 A1 落地后没有任何断言能证明它对,§1.1 的 `pass` 会继续 `pass` | +| **3** | **A1 + A4**(farm 入 `runtimeSearchDirs`、末位、护栏) | 步 2 | 中。**改动 `resolution.json` 与产物 RPATH ⇒ 指纹与既有校验缓存需要一并考虑** | +| **4** | **A6**(pack `system` 档剥离 + 升 host requirement + 扩 `215` 的前缀与档位) | 步 3 | 低 | +| **5** | **A5**(声明 `XLINGS_SUBOS_LD_PATHS=0`) | 键名待 xlings#540 敲定 | 低。今天是无操作 | +| **6** | 观测(§4)+ 测试(§5) | 步 3 | 低 | + +**顺序上唯一不可交换的是 2 在 3 之前。** 理由在 §1.3:先加路径再补判据,等于在一个不会响的报警器上面加功能 —— 而这正是这一片区域已经付过两次学费的形状。 + +**步 1 可以独立成一个 PR 先发**:它与图形无关,解的是一个正在阻塞 Windows 用户的回归。 + +### 跨仓状态 + +| 事项 | 归属 | 本轮是否阻塞 | +|---|---|---| +| E2b 的声明式退出键名 | xlings#540 | **否**(A5 先声明,后生效) | +| `subos_info` 增 `library_dirs` 声明(E2a) | xlings#540 | **否**。A1 从 binding 推导,已足够;E2a 落地后把推导换成读声明,是**同一处**的替换,不是第二处推导 | +| `subos info` EGL 低报 | xlings#537 | 否,归 xlings | +| Windows `subos_info` 块是否该写 | xlings | 否 —— B1 之后写不写都不再影响 mcpp 能否构建 | diff --git a/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md new file mode 100644 index 00000000..8972fa3b --- /dev/null +++ b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md @@ -0,0 +1,333 @@ +# 实施计划:运行期搜索闭包 与 binding 降级 + +> 设计:`2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md` +> 分支:`feat/runtime-search-closure-and-binding-degradation` +> 基线:`main` `1bc6074`(`2026.8.11.1`)。**单 PR、一档全发,版本 `2026.8.11.2`。** +> +> **实施状态:已完成。** 实施过程中改动本计划的四处,均在对应小节以 +> **【实施修正】** 标出 —— 每一处都是被**实测**推翻的,不是改主意。 + +--- + +## 0. 模块划分:协议独占一个 `.cppm`,平台特化留在 `platform/` + +三个及以上的读写方共享同一条规则 ⇒ 独占一个模块。这是本仓库反复付学费后立下的形状 +(`loader_contract` / `graph_shape` / `host_requirements` 都是这么来的)。 + +| 新模块 | 协议内容 | 谁写 | 谁读 | +|---|---|---|---| +| **`src/platform/runtime_search.cppm`**
`mcpp.platform.runtime_search` | **运行期搜索路径契约**:一条目录的**来源**、**次序**、**是否机器本地** | `runtime_binding`(组装)、`plan`(链接期) | `elf_runtime`(闭包解析)、`pack`(剥离判定)、`runtime_validation`(记录) | + +**为什么放 `platform/` 而不是 `build/`**:它描述的是**加载器怎么搜**(物理), +不是 mcpp 怎么选(策略);而且 `elf_runtime`(platform)必须读它 —— +放 `build/` 会让 `platform → build` 反向依赖。 + +**它只 `import std`**,不认识 `RuntimeBinding`、不认识 ELF。纯策略,可单测,零耦合。 + +平台特化各归各位,不外溢: + +| 平台位置 | 本轮改动 | +|---|---| +| `src/platform/runtime_binding.cppm` | farm 目录发现(与既有 libc 探测同一次遍历);`declared`/`note` 降级字段 | +| `src/platform/elf_runtime.cppm` | 宿主默认目录按 binding 分档;`Status::Unresolvable` | +| `src/platform/linux/`、`macos`、`windows` | **不动** —— farm 是"有没有 DT_RPATH"的函数,已由 `platform::supports_rpath` 表达 | + +--- + +## 1. M1 — `src/platform/runtime_search.cppm`(新) + +```cpp +export module mcpp.platform.runtime_search; +import std; + +export namespace mcpp::platform::search { + +// 一条运行期搜索目录的来源。次序与"是否可分发"都由它决定, +// 调用方不再各自推导。 +enum class Origin { + Payload, // 不可变载荷目录(/xim-x-glibc/2.39/lib64) + Package, // 包描述符声明的 runtime 目录 + SubosFarm, // subos 符号链接农场(/lib)—— 可变 + HostDefault, // 宿主加载器的内建默认目录 —— 仅非 hermetic binding 适用 +}; + +// 次序 = 不可变性递减。 +int rank(Origin); + +// 这条目录是否是"这台机器的私有状态"⇒ 不得随产物分发。 +bool is_machine_local(Origin); // Payload / SubosFarm ⇒ true + +std::string_view to_string(Origin); + +struct Dir { std::filesystem::path path; Origin origin; }; + +// 唯一一次排序 + 去重。stable,同 rank 内保持插入序。 +std::vector ordered(std::vector dirs); +} +``` + +**`rank` 的理由写进注释,因为它是本轮唯一的不变式**: + +> 载荷目录不可变(装一次不再动),farm 每次 `xlings install` 都重写符号链接。 +> 载荷在前 ⇒ libc / libm / libstdc++ 永远从被 pin 的载荷解析,farm 只补没人提供的。 +> farm 在前 ⇒ 一次安装能在事后悄悄换掉一个**已经构建好**的产物的 libc。 + +--- + +## 2. M2 — `src/platform/runtime_binding.cppm` + +### 2.1 结构 + +```cpp +struct RuntimeBinding { + … + bool declared = false; // subos 是否自我描述(subos_info 块存在) + std::string note; // 降级原因;非空则调用方必须呈现 + std::vector searchDirs; // farm 视图目录(可变) +}; +``` + +`libraryDirs`(载荷,不可变)与 `searchDirs`(farm,可变)**必须是两个字段** —— +合成一个就把 rank 的信息丢了,而 rank 是本轮的全部。 + +### 2.2 `resolve_runtime_binding`:矛盾报错,缺席降级 + +| 情况 | 今天 | 改为 | +|---|---|---| +| 点名的 subos 目录不存在 | error | **error(保留)** —— 用户输入无法被满足 | +| 无 `.xlings.json` / 无 `subos_info` 块 | error | `declared=false` + `note`,**返回可用 binding** | +| `runtime` 字段为空 | error | 同上 | +| `schema > kSupportedSchema` | error | 读懂的字段照用 + `note`(与 `subos_info::read` 对齐) | +| `schema < kSupportedSchema` | error | 照用 | + +降级 binding 的内容:`platform`/`arch`/`providerId`/`subosDir`/`selection` 照填, +`runtimeId` 留空,`loader`/`libc` 无值 ⇒ rule A/B 自然落到 `Inconclusive` 并说明 +**是因为没有声明**,而不是因为查过了。 + +### 2.3 farm 发现:与既有 libc 探测同一次遍历 + +今天 `{subosDir/"lib64", subosDir/"lib"}` 那个循环找的是 `libc.so.6`(找到即 `break`)。 +farm 需要的是**目录本身是否存在**,两件事一次走完: + +``` +for candidate in {lib64, lib}: + if is_directory(candidate): searchDirs.push_back(candidate) // farm + if is_regular_file(candidate/libc.so.6) and libraryDirs.empty(): + …既有的 canonical → 载荷目录 → libraryDirs / loader… +``` + +**不新增第二处布局知识。** 只在 `if constexpr (is_linux)` 内。 + +### 2.4 序列化 + +- `search_dirs`、`declared`、`note` 进 JSON。 +- `searchDirs` + `declared` **进 `canonical_contract`** ⇒ contract hash 变 ⇒ + farm 变化会正确地让快路径与校验缓存失效。**这会让所有既有缓存失效一次,是预期的。** +- `deserialize` 的完整性检查放宽:`declared=false` 的 binding 允许 `runtimeId` 为空 + (今天 `schema==0 || runtimeId.empty()` 直接判 incomplete)。 + +--- + +## 3. M3 — `src/platform/elf_runtime.cppm` + +### 3.1 搜索顺序按契约,宿主默认目录分档 + +```cpp +// resolve_needed 内 +dirs = requester.runpaths (expand $ORIGIN) + + additionalSearchDirs + + binding.libraryDirs // Origin::Payload + + binding.searchDirs // Origin::SubosFarm + + (is_hermetic(binding) ? {} : host_library_dirs()); // ← 分档 +``` + +**`is_hermetic(binding)` = `binding.loader.has_value()`** —— 产物的 `PT_INTERP` 指向私有 +加载器时,宿主的内建默认目录**不在它的搜索路径里**。今天无条件加宿主目录,是 +`validation: pass` + `cannot open shared object file` 同时成立的直接成因。 + +非 hermetic(`gcc@system`、macOS、Windows)保持原状:那里宿主目录**确实**是默认值。 + +### 3.2 第四种判决 + +```cpp +enum class Status { Pass, ProvenMismatch, Unresolvable, Inconclusive }; +``` + +hermetic binding 下一个解析不到的 `NEEDED` 是**可证的失败**(私有 loader 一定打不开), +把它塞进 `Inconclusive` 是把可证的事说成没查过。 + +- `elf_runtime.cppm:759` 的 `inconclusive(...)` 在 hermetic 下改走 `unresolvable(...)`, + 非 hermetic 保持 `inconclusive`(宿主可能在 `ld.so.cache` 里有,mcpp 不读 cache)。 +- `runtime_validation`:`has_proven_mismatch()` → `has_blocking_failure()`, + 收下 `ProvenMismatch | Unresolvable`;`status_name`/`parse_status` 补 `"unresolvable"`。 +- `ninja_backend.cppm:1794` 的门同步。 +- `doctor.cppm:279` 的三分支补第四支。 + +--- + +## 4. M4 — `src/build/plan.cppm`:farm 进闭包,末位 + +### 【实施修正 ①】不能塞进 `linkIntent.runtimeSearchDirs` + +原计划让 farm 复用那个字段。**不行**:它有三个消费者,其中一个是 + +``` +plan.linkIntent.runtimeSearchDirs → plan.runtimeLibraryDirs → compute_run_env() + → LD_LIBRARY_PATH(`mcpp run` 的子进程环境) +``` + +而 farm 进 `LD_LIBRARY_PATH` 正是设计 §6「不做什么」第三条禁掉的东西 —— +它会污染 `mcpp run` 拉起的每一个子进程,包括宿主二进制(实测会让 `xdg-open` / +`notify-send` 死于 `__pointer_chk_guard`)。 + +**改为 `BuildPlan` 上的独立字段** `runtimeSearch`(`vector`, +全部四种 origin 的有序记录),farm 的**唯一**消费者是 `flags.cppm` 渲染的 +`-Wl,-rpath` 尾巴。**per-object 可达,绝不 per-process。** + +### 【实施修正 ②】载荷目录不能只读 `linkRuntimeDirs` + +`plan.toolchain.linkRuntimeDirs` **只有 clang 会填**(`clang.cppm:142`)。 +GCC 的载荷 `-rpath` 来自**链接模型**(`lm.libDirs`)。第一版记录出来只有一条 +farm,而产物 DT_RPATH 有三条 —— 记录与产物不一致,正是这份设计要消灭的形状。 + +改为向**发出它们的同一个函数**要:`resolve_link_model(plan.toolchain).libDirs` +(纯函数,可在 plan 层调用),再叠 `linkRuntimeDirs`,顺序与 `flags.cppm` 的拼接一致。 + +### 【实施修正 ③】装配点在 `merge_runtime_binding_contract`,不在 `build_plan` + +`plan.runtimeBinding` 在 `prepare.cppm:5313` 才被赋值,晚于 `make_plan` 返回。 +装配放进 `merge_runtime_binding_contract`(紧随其后调用),那里三个输入齐全。 + +**次序天然正确,不需要额外机制**(已核 `flags.cppm:975-978`): + +``` +f.ld = full_static + link_toolchain_flags + b_flag + runtime_dirs + + link_intent_ld + atomic_ld + payload_ld + user_ldflags + link_extra + ↑ 载荷 -L/-rpath ↑ linkIntent(farm 在其末尾) +``` + +且 `runtimeSearchDirs` 的既有语义正是我们要的(`flags.cppm:804-806` 原文): +*"contributes RUNPATH only; it must never become a link-time `-L` path"* —— +链接期已由 `--sysroot` 覆盖,这里只补运行期,还省下链接行长度。 + +### 4.1 两条护栏 + +| 护栏 | 判据 | +|---|---| +| **交叉目标** | `targetTriple` 非空且(`os != "linux"` 或 `arch != binding.arch`)⇒ 不发 | +| **非 ELF** | `elfTarget == false` ⇒ 不发(与 `loader_tag_flag` 同一个判据,复用) | + +--- + +## 5. M5 — pack:`215` 扩面(剥离不需要写代码) + +### 【实施修正 ④】5.1 的前提是错的 —— `system` 档早就剥干净了 + +原计划断言「`Mode::None` 是唯一不重写 rpath 的档」。**实测推翻**: + +```console +$ mcpp pack --mode system && tar -xzf …-system.tar.gz +$ readelf -d bin/glprobe | grep RPATH + (RPATH) Library rpath: [] ← 已清空 +$ readelf -p .interp bin/glprobe + /lib64/ld-linux-x86-64.so.2 ← 已改回平台标准解释器 +$ grep -rl "$HOME/.mcpp" / ← 无命中 +``` + +`pack.cppm:718` 对 `Mode::None` 把每个依赖都标 skip ⇒ `toBundle` 空 ⇒ +`rpath = ""` ⇒ `set_search_path` 整体清空。**这一项从"实现"变成"补测试"。** + +「从 farm 解析到的 `NEEDED` 升级为 host requirement」也**不做**: +`HOST-REQUIREMENTS` 存在的理由是记录**产物本身看不出来**的东西(经 dlopen 链到达 +的驱动),而 `NEEDED` 本来就写在产物里 —— 再抄一遍是冗余,还会污染 +`mcpp publish` 对 `[runtime].requirements` 的投影。 + +### 5.2 `215` 的两处扩面(设计 §2.6 核出来的) + +```bash +STORE="$MCPP_HOME/registry/data/xpkgs" # ← 今天只到这里 +MACHINE_LOCAL="$MCPP_HOME" # ← farm 在 registry/subos/…,不在 store 下 +``` + +并补 `--mode system` 的用例 —— 今天 215 只跑默认 `vendored` 档。 + +--- + +## 6. M6 — `XLINGS_SUBOS_LD_PATHS=0`:声明式退出 + +mcpp 在**驱动 ninja 之前**把它设进自己的进程环境(子进程继承 ⇒ 覆盖 ninja / 驱动 / ld), +**不进 ninja 命令行**(链接行有 128KiB 上限)。 + +- 今天 = 无操作(xlings 还没读它); +- xlings E2b 落地当天自动生效,mcpp 的 DT_RPATH 仍只含 mcpp 决定的内容; +- 键名与语义在 `runtime_search.cppm` 里以常量声明一次,**不散落**。 + +--- + +## 7. M7 — 可观测性 + +| 载体 | 补什么 | +|---|---| +| `resolution.json` | `runtime_search` 数组:`[{path, origin}]`,**保序** | +| `mcpp why runtime` | `search:` 行按 origin 展开;binding 未声明时打印 `note` | +| `mcpp doctor` | `declared=false` 作为 **info** 呈现并说明影响范围 | + +--- + +## 8. 测试 + +### 8.1 单测(`tests/unit/`) + +| 文件 | 断言 | +|---|---| +| `test_runtime_search.cpp`(新) | `rank` 次序;`ordered` 去重且 stable;`is_machine_local` 逐值 | +| `test_subos_info.cpp`(补) | schema 高于支持值时**不失败**,填 note | +| `test_runtime_contract.cpp`(补) | 降级 binding 的 serialize↔deserialize 往返;contract hash 含 searchDirs | + +### 8.2 e2e + +| # | 文件 | 断言 | 防空转 | +|---|---|---|---| +| T1 | `219_runtime_search_farm_is_last.sh` | 可执行文件 `DT_RPATH` **最后一项**是 binding 的 farm | 读**生成物** | +| T2 | 同上 | `libc.so.6` 解析到载荷目录,不是 farm | 次序反了它先红 | +| T3 | `220_farm_only_needed_runs.sh` | 一个只有 farm 提供的 `NEEDED` 的产物 **rc=0 真的跑起来** | **唯一能戳破假绿的断言**;库从 farm∖载荷 差集里取,差集空则 **skip 并打印原因** | +| T4 | 同上 | 谁都提供不了的 `NEEDED` ⇒ 构建**变红**并指名 | 防止状态枚举没接到失败门 | +| T5 | `215`(扩) | `--mode system` 产物不含任何 `$MCPP_HOME` 路径 | 前缀扩到整个 home | +| T6 | `221_subos_without_info_still_builds.sh` | `subos_info` 缺失的 fixture 能 `mcpp build` | **不要求任何图形能力**,Windows/macOS 都要真跑到 | + +**`# requires:` 只用 `run_all.sh` 真授予的能力**;T6 **不得**带 `elf`/`gcc`,否则它在 +Windows 上被跳过,而 Windows 正是它要防的回归。 + +--- + +## 9. 文档 + +| 文件 | 改什么 | +|---|---| +| `docs/08-toolchain-internals.md` | 新增"运行期搜索闭包"一节:四种 origin、次序与理由 | +| `docs/02-pack-and-release.md` | `system` 档会剥机器本地路径并升为 host requirement | +| `docs/11-machine-output.md` | `resolution.json` 的 `runtime_search` 字段 | +| `docs/zh/` 对应件 | 同步 | +| 设计文档 | 顶部标注实施状态与 PR 号 | + +--- + +## 10. 版本与 pin + +- `mcpp.toml` `[package].version` + `src/version.cppm` `MCPP_VERSION` → **`2026.8.11.2`**(同一 commit) +- `src/xlings.cppm` `kXlingsVersion` → **最新 xlings**(实施时以 `xlings --version` / 索引为准) +- `.xlings.json` 的 bootstrap pin **本 PR 不动**(发布并进索引后才前移) +- `bash .github/tools/check_version_pins.sh` 必须过 + +--- + +## 11. 实施顺序 + +**唯一不可交换:M3 在 M4 之前。** 先加路径再补判据 = 在不会响的报警器上加功能。 + +``` +M1 契约模块 → M2 binding(降级+farm) → M3 闭包判据 → M4 链接期 → M5 pack → M6 退出声明 → M7 观测 → 测试 → 文档 → 版本 +``` + +M2 的降级半边(§2.2)与图形无关,是正在阻塞 Windows 用户的回归 —— 它在同一个 PR 里, +但**提交上独立成一个 commit**,以便必要时单独 cherry-pick。 diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index d54ebe61..8e2ba0a2 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.11.1' + default: '2026.8.11.2' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 7c12ee1a..2ed36f70 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.11.1' + XLINGS_VERSION: '2026.8.11.2' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 25e7d228..82596446 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -364,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index cc0ed548..96529460 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -133,7 +133,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 28efad39..6ecaf210 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -102,7 +102,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - # Must equal `pinned::kXlingsVersion` (src/xlings.cppm) and the + # Must equal `pinned::kXlingsVersion` (src/platform/xlings/xlings.cppm) and the # xlings the release bundles — enforced by # .github/tools/check_version_pins.sh. # @@ -118,7 +118,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.11.1' + XLINGS_VERSION: '2026.8.11.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -255,7 +255,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.11.1' + XLINGS_VERSION: '2026.8.11.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1e5d96f..de8e79d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.11.1' + XLINGS_VERSION: '2026.8.11.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -288,7 +288,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.11.1' + XLINGS_VERSION: '2026.8.11.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -358,11 +358,11 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.11.1-linux-aarch64.tar.gz" + XLA="xlings-2026.8.11.2-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.11.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.11.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.11.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.11.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -440,7 +440,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.11.1' + XLINGS_VERSION: '2026.8.11.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -622,7 +622,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.11.1' + XLINGS_VERSION: '2026.8.11.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index d170cbfe..3f7ea737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,65 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.11.2] — 2026-08-11 + +### 修复 + +- **⚠️ 回归:SubOS 没有自我描述时,`mcpp build` / `mcpp test` 直接失败 + ([xlings#543](https://github.com/openxlings/xlings/issues/543))。** + + Windows 上 xlings 不写 `subos_info` 块,而 mcpp 把「缺声明」当成了错误,于是 + **每一次构建都停在一条讲 GL 驱动的消息上** —— 在一台没有 ELF、没有 `PT_INTERP`、 + 没有私有 libc 的机器上。`2026.8.10.2` 引入(PR #400),`2026.8.8.4` 正常。 + + 判据改为:**矛盾报错,缺席降级。** 点名的 SubOS 不存在仍是硬错误(该请求无法被 + 满足);SubOS 存在但没描述自己则记 `declared=false` + 一条必被打印的 note, + runtime 规则报 `inconclusive` 而不是给出判决,构建继续。 + + 同一位置还有第二颗雷:schema 检查是 `!=`,而它的**读取器**明写着「更高的 schema + 照读」。xlings 写出 schema 2 的那天,全平台所有构建会同时停摆。已改为上限语义 —— + **发布数据不得使读它的程序失效**。 + +- **图形/系统库链得上却跑不起来:运行期搜索路径补上 SubOS 库视图。** + + mcpp 在编译与链接两条线上都发 `--sysroot=`,所以 `-lGL` 零 flag 就解析得到; + 但运行期的搜索路径是**另一套独立推导**(只有工具链载荷目录)。结果是 + `mcpp build` rc=0、`./bin/app` 报 `libGL.so.1: cannot open shared object file`。 + + ``` + $ readelf -d bin/app | grep RPATH + before: [/glibc/2.39/lib64 : /gcc/16.1.0/lib64] + after: [/glibc/2.39/lib64 : /gcc/16.1.0/lib64 : /lib] + ``` + + 新增 `mcpp.platform.runtime_search` 契约模块:一条搜索目录有**来源** + (`payload` / `package` / `subos_farm` / `host_default`)、**次序**与**是否机器本地**。 + **次序 = 不可变性递减,farm 在最后** —— 载荷目录装一次不再动,`/lib` 每次 + `xlings install` 都重写;载荷在前,`libc`/`libstdc++` 永远从被 pin 的载荷解析。 + 交叉目标与非 ELF 格式不发 farm。 + +- **`validation: pass` 曾对一个跑不起来的产物成立。** + + 闭包解析在搜完 rpath 后**回落到宿主默认目录**,而宿主通常自带 `libGL.so.1` ⇒ + 模型认为解析到了。但产物跑在**私有加载器**下,它的默认路径里没有宿主目录。 + 现在宿主默认目录只在**非 hermetic** binding 下参与;hermetic 产物上一个谁都提供 + 不了的 `DT_NEEDED` 是**可证的**失败(新判决 `unresolvable`),会让构建变红并指名。 + +### 变更 + +- xlings 强相关模块归入 `src/platform/xlings/`:`mcpp.platform.xlings`、 + `mcpp.platform.xlings.subos_info`、`mcpp.platform.xlings.runtime_selection` + (命名空间不变)。`RuntimeBinding` 与 `runtime_search` 留在 `src/platform/` —— + 它们是 provider 中立的契约类型,不是 xlings 专属。 +- `resolution.json` 的 `runtime.search` 增加 `closure` 数组(路径 + origin + + machine_local,**保序**);`mcpp why runtime` 按加载器次序打印它。 +- mcpp 为自己启动的每个进程声明 `XLINGS_SUBOS_LD_PATHS=0` —— xlings 链接器包装器 + 路径注入的退出声明([xlings#540](https://github.com/openxlings/xlings/issues/540))。 + 今天是无操作;它落地后 mcpp 的 DT_RPATH 仍只含 mcpp 自己决定的内容。 + **不读 `$XLINGS_SUBOS_LIB`**:实测它指向当前 shell 的 subos,而 mcpp 有自己的 + registry home,两者通常由**不同物理 glibc 载荷**支撑。 +- 内带 xlings pin → `2026.8.11.2`。 + ## [2026.8.11.1] — 2026-08-11 ### 新增 diff --git a/docs/02-pack-and-release.md b/docs/02-pack-and-release.md index f1cce7e5..589785f5 100644 --- a/docs/02-pack-and-release.md +++ b/docs/02-pack-and-release.md @@ -61,6 +61,16 @@ How to choose: - Cross-distro / older glibc (legacy CentOS, Kylin) → `self-contained` - Single portable file, no host deps → `static` +**No mode ships a build-machine path.** A development build addresses this +machine on purpose: its `DT_RPATH` names the toolchain's payload directories and +the SubOS library view (`/lib`), and its `PT_INTERP` names a private +loader. Every mode rewrites both — `vendored`/`self-contained` to `$ORIGIN` +relative paths, `system` by clearing the search path entirely and restoring the +platform's standard interpreter. `system` is not "keep whatever the build had"; +it is "the target provides everything", which is a statement about the target +and cannot be spelled with this machine's absolute paths. e2e 215 sweeps every +ELF in the bundle for anything under `$MCPP_HOME` and fails on a hit. + ### A program that needs the HOST to provide something "Self-contained" has a floor. Some libraries can only come from the target diff --git a/docs/08-toolchain-internals.md b/docs/08-toolchain-internals.md index 3bc59d14..1c4fe52f 100644 --- a/docs/08-toolchain-internals.md +++ b/docs/08-toolchain-internals.md @@ -93,9 +93,23 @@ declaration applies only when that source is an independent root. Neither `XLINGS_ACTIVE_SUBOS`, `current`, the compiler's owner home, nor a CLI/env override is a third selection rung. -The selected SubOS must provide the supported `subos_info` contract. A missing -named environment or missing/incompatible contract is a hard error, never a -fallback to default/active/compiler-baked state. mcpp reads it once into a +A named environment that does not exist is a hard error, never a fallback to +default/active/compiler-baked state — the request cannot be satisfied, and +substituting a different one would make one `mcpp.toml` mean different ABIs on +different machines. + +A SubOS that exists but **does not describe itself** is a different case, and it +degrades. `declared = false` is recorded with a note the caller prints; runtime +rules report `inconclusive` instead of a verdict, and no payload-first binding is +available (mcpp declines rather than guessing a libc version, so the hermeticity +check will report the host fallback). The build is not stopped. Likewise a +`subos_info` schema **newer** than this mcpp understands is read for the fields it +knows, with a note — the same rule the reader itself documents: publishing data +must not invalidate the program that reads it. Refusing outright is what stopped +every `mcpp build` and `mcpp test` on Windows in 2026.8.10.2, where xlings writes +no such block and the facts it carries do not exist (openxlings/xlings#543). + +mcpp reads the SubOS once into a `RuntimeBinding` snapshot, feeds its libc identity into payload probing, and reuses the same snapshot for configure/link/run/test and the fast-path cache. On Linux the snapshot also records the canonical selected loader/libc directory @@ -175,6 +189,53 @@ intent, search mechanism, and post-link verdict are persisted in `resolution.json` schema 2. `mcpp why runtime` only interprets that stored file; re-diagnosis belongs to `xlings doctor`. +### 2.3 The run-time search closure (`src/platform/runtime_search.cppm`) + +mcpp passes `--sysroot=` on the compile **and** link lines, so a library +the SubOS provides — `-lGL`, `-lX11`, `-lwayland-client` — resolves with no +flags from the user. The run-time search path has to be derived from the same +decision, or a link succeeds and the artifact cannot start. + +The closure is one ordered list, each entry tagged with where it came from: + +| origin | example | mutable? | ships? | +|---|---|---|---| +| `payload` | `/xim-x-glibc/2.39/lib64` | no — written once at install | no | +| `package` | a dependency's `[runtime]` dir | no | no | +| `subos_farm` | `/lib` | **yes** — rewritten by every `xlings install` | no | +| `host_default` | `/usr/lib/x86_64-linux-gnu` | n/a — the target's own | n/a | + +**Order is decreasing mutability, and the farm is last.** That is the whole +invariant: payload-first keeps `libc` / `libm` / `libstdc++` resolving from the +pinned payload, leaving the farm to supply only what nothing else does. +Farm-first would let a later `xlings install` change which libc an *already +linked* artifact loads. + +Two guards decide whether the farm applies at all. The format must have a +search path (ELF; Mach-O and PE get nothing, matching the loader-tag contract), +and the target must be this host's runtime — a cross target gets no farm, since +it belongs to the host SubOS. + +`host_default` enters the *model* only when the artifact will really run under +the host loader. A hermetic artifact's `PT_INTERP` names a private loader with +different built-in defaults, so including `/usr/lib` there models the wrong +loader — and since a developer machine usually has its own `libGL.so.1`, doing +so reported "resolved" for binaries that exited 127. + +The closure is recorded in `resolution.json` under `runtime.search.closure` and +printed by `mcpp why runtime`. A `DT_NEEDED` that nothing on a hermetic +artifact's path can satisfy is a **proven** failure (`unresolvable`), not an +inconclusive one, and it fails the build. + +mcpp also declares `XLINGS_SUBOS_LD_PATHS=0` for every process it spawns. That +is the opt-out from xlings' linker-wrapper path injection +(openxlings/xlings#540): mcpp wants the wrapper's `--disable-new-dtags` and +must refuse its `-rpath "$XLINGS_SUBOS_LIB"`, because that variable names the +*active shell's* SubOS — mcpp keeps its own xlings home under +`/registry`, so it generally points at a different farm backed by a +different physical glibc payload. mcpp emits the farm entry it derived from the +binding it actually selected. + ## 3. The link model (`src/toolchain/linkmodel.cppm`) `ToolchainLinkModel` answers exactly one question — *how do we compile and @@ -489,7 +550,7 @@ everything §3–§4 does for ELF. | link model + loader resolution | `src/toolchain/linkmodel.cppm` | | unified fixup pipeline (patchelf/specs/cfg, marker) | `src/toolchain/post_install.cppm` | | install/lifecycle entry | `src/toolchain/lifecycle.cppm`; auto-install entries in `src/build/prepare.cppm` | -| root runtime selection/binding | `src/xlings/runtime_selection.cppm`, `src/platform/runtime_binding.cppm`, `src/xlings/subos_info.cppm` | +| root runtime selection/binding | `src/platform/xlings/runtime_selection.cppm`, `src/platform/runtime_binding.cppm`, `src/platform/xlings/subos_info.cppm` | | generic runtime contract + LinkIntent | `src/manifest/types.cppm`, `src/build/plan.cppm`, `src/build/flags.cppm` | | stored resolution explanation | `src/build/prepare.cppm`, `src/build/runtime_validation.cppm`, `src/doctor.cppm` | | flag assembly (main build) | `src/build/flags.cppm` | diff --git a/docs/zh/08-toolchain-internals.md b/docs/zh/08-toolchain-internals.md index 790776ec..a2e08938 100644 --- a/docs/zh/08-toolchain-internals.md +++ b/docs/zh/08-toolchain-internals.md @@ -74,8 +74,20 @@ payload-first 的构建会链接到某个具体的 glibc,而**是哪一个**是 workspace 构建由 workspace root 选择;member 与 dependency 声明不合并、不传递。 `XLINGS_ACTIVE_SUBOS`、current、编译器 owner home 以及 CLI/环境 override 都不是第三层。 -所选 SubOS 必须提供受支持的 `subos_info`:环境不存在或 contract 缺失/不兼容会 hard error, -绝不回退 default/active/编译器烙入状态。该 contract 只读取一次形成 `RuntimeBinding` snapshot, +**矛盾报错,缺席降级。** 点名的环境不存在是 hard error —— 该请求无法被满足, +换一个环境会让同一份 `mcpp.toml` 在不同机器上意味着不同 ABI,绝不回退 +default/active/编译器烙入状态。 + +而「环境存在但没有自我描述」是另一回事,它**降级**:记 `declared = false` 与一条 +调用方必须打印的 note;runtime 规则报 `inconclusive` 而不是给出判决;没有可用的 +payload-first binding(mcpp 拒绝猜一个 libc 版本,因而 hermeticity 检查会如实报告 +回退到宿主)。**构建不被中止。** 同理,`subos_info` 的 schema **高于**本 mcpp 所理解的 +版本时,读懂的字段照用并附 note —— 这正是读取器自己写下的规矩:**发布数据不得使 +读它的程序失效**。直接拒绝就是 2026.8.10.2 在 Windows 上停掉每一次 `mcpp build` / +`mcpp test` 的原因(那里 xlings 根本不写这个 block,它承载的事实也不存在, +openxlings/xlings#543)。 + +该 contract 只读取一次形成 `RuntimeBinding` snapshot, 由 configure/link/run/test 与 fast-path cache 共同使用。 Linux snapshot 还记录所选 loader/libc 目录的规范路径及可选的创建宿主 glibc floor; 它们是链接后校验的证据,不是新的选择入口。 @@ -142,6 +154,46 @@ provider-specific 分支,也拒绝把相关词汇与外部 probe 启动耦合。 搜索机制与链接后 verdict 写入 `resolution.json` schema 2。 `mcpp why runtime` 只解释该存储文件;重新诊断由 `xlings doctor` 负责。 +### 2.3 运行期搜索闭包(`src/platform/runtime_search.cppm`) + +mcpp 在**编译与链接**两条线上都发 `--sysroot=`,所以 subos 提供的库 +(`-lGL`、`-lX11`、`-lwayland-client`)零 flag 就能解析。运行期的搜索路径必须由 +**同一个决策**推导,否则就是「链得上、跑不起来」。 + +闭包是一张有序表,每条带来源: + +| origin | 例子 | 可变? | 可随产物分发? | +|---|---|---|---| +| `payload` | `/xim-x-glibc/2.39/lib64` | 否 —— 装一次不再动 | 否 | +| `package` | 依赖描述符的 `[runtime]` 目录 | 否 | 否 | +| `subos_farm` | `/lib` | **是** —— 每次 `xlings install` 重写 | 否 | +| `host_default` | `/usr/lib/x86_64-linux-gnu` | 目标机自己的 | 不适用 | + +**次序 = 不可变性递减,farm 在最后。** 这是本模块唯一的不变式:载荷在前, +`libc` / `libm` / `libstdc++` 永远从被 pin 的载荷解析,farm 只补没人提供的那些; +farm 在前的话,一次 `xlings install` 就能在事后悄悄换掉一个**已经链接完成**的产物 +所加载的 libc。 + +两条护栏决定 farm 是否适用:格式必须有搜索路径(ELF;Mach-O 与 PE 什么都不发, +与加载器标签契约同一形状),且目标必须是本宿主的运行时 —— 交叉目标不发 farm, +因为 farm 属于宿主 subos。 + +`host_default` 只在产物**确实**跑在宿主加载器下时才进模型。hermetic 产物的 +`PT_INTERP` 指向私有加载器,它的内建默认路径不同;把 `/usr/lib` 算进去就是在模拟 +另一个加载器 —— 而开发机通常自带 `libGL.so.1`,于是「解析到了」会被报给一个 +退出 127 的产物。 + +闭包记录在 `resolution.json` 的 `runtime.search.closure`,并由 `mcpp why runtime` +打印。hermetic 产物上一个谁都提供不了的 `DT_NEEDED` 是**可证的**失败 +(`unresolvable`),不是「没查过」,它会让构建变红。 + +mcpp 还会给它启动的每个进程声明 `XLINGS_SUBOS_LD_PATHS=0` —— 这是 xlings 链接器 +包装器路径注入的退出声明(openxlings/xlings#540):mcpp 要它的 +`--disable-new-dtags`,但必须拒绝它的 `-rpath "$XLINGS_SUBOS_LIB"`,因为那个变量 +指的是**当前 shell 的** subos,而 mcpp 有自己的 xlings home +(`/registry`),两者通常指向由**不同物理 glibc 载荷**支撑的不同 farm。 +mcpp 发的是自己从已选 binding 推导出来的那一条。 + ## 3. 链接模型(`src/toolchain/linkmodel.cppm`) `ToolchainLinkModel` 只回答一个问题——*如何对该工具链的 C 库编译与链接*—— @@ -400,7 +452,7 @@ mcpp 把运行时 DLL 部署到产物 exe 旁,这正是该平台对 §3–§4 | 链接模型 + loader 解析 | `src/toolchain/linkmodel.cppm` | | 统一 fixup 管线(patchelf/specs/cfg、marker)| `src/toolchain/post_install.cppm` | | install/lifecycle 入口 | `src/toolchain/lifecycle.cppm`;auto-install 入口在 `src/build/prepare.cppm` | -| root runtime 选择/binding | `src/xlings/runtime_selection.cppm`、`src/platform/runtime_binding.cppm`、`src/xlings/subos_info.cppm` | +| root runtime 选择/binding | `src/platform/xlings/runtime_selection.cppm`、`src/platform/runtime_binding.cppm`、`src/platform/xlings/subos_info.cppm` | | 通用 runtime contract + LinkIntent | `src/manifest/types.cppm`、`src/build/plan.cppm`、`src/build/flags.cppm` | | 存储 resolution 解释 | `src/build/prepare.cppm`、`src/build/runtime_validation.cppm`、`src/doctor.cppm` | | flag 组装(主构建)| `src/build/flags.cppm` | diff --git a/mcpp.toml b/mcpp.toml index fe50d791..731c84df 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.11.1" +version = "2026.8.11.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/tests/unit/test_runtime_contract.cpp b/tests/unit/test_runtime_contract.cpp index b626d60c..87c85683 100644 --- a/tests/unit/test_runtime_contract.cpp +++ b/tests/unit/test_runtime_contract.cpp @@ -9,7 +9,7 @@ import mcpp.modgraph.scanner; import mcpp.platform; import mcpp.platform.axis; import mcpp.platform.runtime_binding; -import mcpp.xlings.subos_info; +import mcpp.platform.xlings.subos_info; namespace build = mcpp::build; namespace mf = mcpp::manifest; From 81e0795debaba49886ae1675640eae7dac81ffda Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:21:36 +0800 Subject: [PATCH 05/14] =?UTF-8?q?fix(runtime):=20=E3=80=8C=E5=8F=AF?= =?UTF-8?q?=E8=AF=81=E3=80=8D=E8=A6=81=E7=9C=9F=E7=9A=84=E5=8F=AF=E8=AF=81?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E4=B8=89=E5=A4=84=E6=94=B6=E7=AA=84,?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E7=94=B1=20CI=20=E6=8A=93=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一 commit 把 hermetic 下解析不到的 DT_NEEDED 判为可证失败。方向对,范围过宽。 ① 产物的格式由产物决定,不由 binding 决定。 Linux→Windows 交叉构建用的是宿主 binding(Linux/glibc/私有加载器 ⇒ hermetic), 产物却是 PE。「不是 ELF」这条解析错误落进 unresolved,于是 crosswin.exe 被判成 「缺一个共享库」而构建失败 —— 一个连 DT_NEEDED 都没有的文件。 validate_runtime_artifact 现在先看 resolution.artifactIsElf。 ② unresolved 混装了三种东西,只有一种可证。 「找不到的 SONAME」「读不了的对象」「512 对象上限」此前同住一个 vector。后两种是 关于检查本身的陈述 —— 没能看的检查什么都没证明。拆出 unresolvedSonames,升级只看它。 ③ allow_host_libs 必须同时退出两个阶段,否则它意味着两件事。 它本就关掉链接期 hermeticity 检查。既然用户已声明「我有意伸到沙箱外」,mcpp 就不能 再断言产物起不来 —— 他们可能用 LD_LIBRARY_PATH 跑,或装在私有加载器确实会看的地方。 该档下未解析的 NEEDED 报 inconclusive 并指名,不阻断。谓词与 hermetic.cppm 逐字同源 (manifest 键 OR 环境变量),否则「链接放行、闭包判死」是两者中最坏的组合。 顺带把 e2e 206 的一条假绿改成真话。它的「安全宿主 DSO 对照」断言 status==pass, 而 LD_DEBUG=libs 实测:私有加载器的内建默认路径是 glibc 载荷自己的构建期前缀 (…/fromsource-x-glibc/2.39/lib),本机根本不存在,/usr/lib 从不被查,产物 exit 127。 它此前报 pass 只是因为闭包模型回落到宿主目录 —— 与那个被当成 pass 的 GL 程序同一形状。 而这份文件自己的注释「execution is not part of this link-physics control」正是让这条 假绿站住的理由:从来没人运行过它。改为断言 inconclusive + 诊断里指名 libtinfo 与 allow_host_libs。 219 的断言也放松到正确形状:farm 必须是最后一个「绝对路径」条目,而不是字面最后一个。 $ORIGIN 相对条目是另一类东西(它随产物走,不指本机),真实 GLFW 工程的 DT_RPATH 就以 … : /lib : $ORIGIN 结尾。 --- CHANGELOG.md | 16 ++++ docs/08-toolchain-internals.md | 19 +++- docs/zh/08-toolchain-internals.md | 14 ++- src/build/runtime_validation.cppm | 18 +++- src/platform/elf_runtime.cppm | 87 +++++++++++++++--- tests/e2e/206_runtime_binding_physics.sh | 43 +++++++-- tests/e2e/219_runtime_search_farm_is_last.sh | 24 +++-- tests/unit/test_elf_runtime.cpp | 92 ++++++++++++++++++++ 8 files changed, 287 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7ea737..fa9e8db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,22 @@ 现在宿主默认目录只在**非 hermetic** binding 下参与;hermetic 产物上一个谁都提供 不了的 `DT_NEEDED` 是**可证的**失败(新判决 `unresolvable`),会让构建变红并指名。 + 实测佐证(`LD_DEBUG=libs`):私有加载器的内建默认路径是 glibc 载荷**自己的构建期 + 前缀**(`…/fromsource-x-glibc/2.39/lib`),这台机器上根本不存在;`/usr/lib` **从不** + 被查。因此 e2e `206` 里那条「安全的宿主 DSO 对照」此前报 `pass` 也是假绿 —— 它 + 刻意不运行产物,而产物其实 127。已改为断言 `inconclusive` 并说明原因。 + + **`[build] allow_host_libs` 同时退出两个阶段。** 它本就关掉链接期 hermeticity 检查; + 既然用户已声明「我有意伸到沙箱外」,mcpp 就不能再断言产物起不来(他们可能用 + `LD_LIBRARY_PATH` 跑,或装在私有加载器会看的地方)。⇒ 该档下未解析的 `NEEDED` + 报 `inconclusive` 并指名,而不是变红。一条声明,一个含义。 + + 另两处精度修正(CI 抓到的):`unresolved` 此前混装三种东西 ——「找不到的 SONAME」 + 「读不了的对象」「512 上限」。只有第一种可证,故拆出 `unresolvedSonames`; + 且**产物的格式由产物决定,不由 binding 决定** —— Linux→Windows 交叉构建拿的是宿主 + binding(hermetic),产物却是 PE,「不是 ELF」落进 `unresolved` 后被判成「缺库」, + 让 `crosswin.exe` 构建失败。 + ### 变更 - xlings 强相关模块归入 `src/platform/xlings/`:`mcpp.platform.xlings`、 diff --git a/docs/08-toolchain-internals.md b/docs/08-toolchain-internals.md index 1c4fe52f..2638805d 100644 --- a/docs/08-toolchain-internals.md +++ b/docs/08-toolchain-internals.md @@ -225,7 +225,24 @@ so reported "resolved" for binaries that exited 127. The closure is recorded in `resolution.json` under `runtime.search.closure` and printed by `mcpp why runtime`. A `DT_NEEDED` that nothing on a hermetic artifact's path can satisfy is a **proven** failure (`unresolvable`), not an -inconclusive one, and it fails the build. +inconclusive one, and it fails the build. Measured with `LD_DEBUG=libs`: the +private loader's built-in default path is the glibc payload's own *build-time* +prefix, a directory that does not exist on the machine — `/usr/lib` is never +consulted. + +Three things narrow that proof, and each of them is a case where mcpp knows less +than the wording suggests: + +- **The artifact's format decides, not the binding's.** A cross build runs with + this host's binding while producing a PE or Mach-O. ELF rules do not apply to + the artifact whatever the binding says. +- **Only an unfindable SONAME proves anything.** "I could not read this object" + and "I stopped after 512 objects" are statements about the *check*; a check + that could not look has proven nothing, so those stay inconclusive. +- **`[build] allow_host_libs` opts out of both phases.** It already switches off + the link-time hermeticity check; once resolution is the user's responsibility + mcpp reports rather than blocks, because they may run under `LD_LIBRARY_PATH` + or on a machine where the library sits where the private loader looks. mcpp also declares `XLINGS_SUBOS_LD_PATHS=0` for every process it spawns. That is the opt-out from xlings' linker-wrapper path injection diff --git a/docs/zh/08-toolchain-internals.md b/docs/zh/08-toolchain-internals.md index a2e08938..867ffd67 100644 --- a/docs/zh/08-toolchain-internals.md +++ b/docs/zh/08-toolchain-internals.md @@ -185,7 +185,19 @@ farm 在前的话,一次 `xlings install` 就能在事后悄悄换掉一个**已 闭包记录在 `resolution.json` 的 `runtime.search.closure`,并由 `mcpp why runtime` 打印。hermetic 产物上一个谁都提供不了的 `DT_NEEDED` 是**可证的**失败 -(`unresolvable`),不是「没查过」,它会让构建变红。 +(`unresolvable`),不是「没查过」,它会让构建变红。`LD_DEBUG=libs` 实测:私有加载器的 +内建默认路径是 glibc 载荷**自己的构建期前缀**,该目录在本机并不存在 —— `/usr/lib` +从不被查。 + +有三件事收窄这个「可证」,每一件都是 mcpp 知道的比措辞暗示的少: + +- **产物的格式由产物决定,不由 binding 决定。** 交叉构建用的是本宿主的 binding, + 产物却是 PE / Mach-O;无论 binding 怎么说,ELF 规则都不适用于它。 +- **只有「找不到的 SONAME」能证明什么。**「这个对象读不了」「我在 512 个之后停了」 + 都是关于**检查**的陈述;没能看的检查什么都没证明,故仍报 inconclusive。 +- **`[build] allow_host_libs` 同时退出两个阶段。** 它本就关掉链接期 hermeticity 检查; + 既然解析责任已归用户,mcpp 就只报告不阻断 —— 他们可能用 `LD_LIBRARY_PATH` 跑, + 或装在私有加载器确实会看的地方。 mcpp 还会给它启动的每个进程声明 `XLINGS_SUBOS_LD_PATHS=0` —— 这是 xlings 链接器 包装器路径注入的退出声明(openxlings/xlings#540):mcpp 要它的 diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 4055c783..60803f37 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -159,6 +159,18 @@ std::string status_name(mcpp::platform::elf::RuntimeVerdict::Status status) { return "inconclusive"; } +// Did this build declare that it reaches outside the sandbox? +// +// Spelled here exactly as `mcpp.build.hermetic` spells it — manifest key OR +// environment variable — because the two checks must agree. A build whose link +// was allowed to resolve host libraries and whose closure was then judged as if +// it had not is the worst of both: it links, and mcpp calls it broken. +bool host_libs_allowed(const mcpp::build::BuildPlan& plan) { + if (plan.manifest.buildConfig.allowHostLibs) return true; + const char* e = std::getenv("MCPP_ALLOW_HOST_LIBS"); + return e && *e && *e != '0'; +} + // How bad each state is, for rolling many artifacts into one summary. // `Unresolvable` sits above `Inconclusive` (it is proven, not unknown) and // below `ProvenMismatch` (mixing payloads is the more fundamental error, and @@ -406,8 +418,12 @@ ValidationReport validate_changed_artifacts( validated.artifact = artifact; auto resolution = mcpp::platform::elf::resolve_runtime_closure( artifact, plan.runtimeBinding, searchDirs); + // The SAME opt-out the link-time hermeticity check honours, read the + // same way (manifest key or environment). A build that declared it is + // reaching outside the sandbox on purpose has taken responsibility for + // run-time resolution, so mcpp reports rather than blocks. validated.verdict = mcpp::platform::elf::validate_runtime_artifact( - artifact, plan.runtimeBinding, resolution); + artifact, plan.runtimeBinding, resolution, host_libs_allowed(plan)); store_artifact(doc, key, fp, validated); changedCache = true; report.artifacts.push_back(std::move(validated)); diff --git a/src/platform/elf_runtime.cppm b/src/platform/elf_runtime.cppm index 033a3d98..6e38af1e 100644 --- a/src/platform/elf_runtime.cppm +++ b/src/platform/elf_runtime.cppm @@ -68,7 +68,27 @@ struct RuntimeResolution { ElfRuntimeFacts artifact; std::vector objects; std::vector resolvedLibcs; + + // Everything that stopped the walk, as human-readable text. A mixed bag on + // purpose: an object that could not be parsed, a closure that hit the size + // cap, and a SONAME nothing provides all belong in the report. std::vector unresolved; + + // The strict subset that means "a DT_NEEDED nothing on the search path + // provides". SEPARATE because only this one is PROVABLE. + // + // `unresolved` also collects "I could not read this file" and "I stopped + // after 512 objects", which are statements about the CHECK, not about the + // artifact. Treating the whole bag as proof made a cross-built PE fail its + // build: `crosswin.exe` is not ELF, that fact landed in `unresolved`, and a + // "you are missing a library" verdict was issued for a file with no + // DT_NEEDED at all. Caught by CI, not by reading. + std::vector unresolvedSonames; + + // Did the artifact itself parse as ELF? False ⇒ the ELF rules do not apply + // to it, whatever the binding says. The binding describes the HOST; a cross + // build's artifact is a different format entirely. + bool artifactIsElf = false; }; struct RuntimeVerdict { @@ -113,10 +133,19 @@ RuntimeResolution resolve_runtime_closure( const mcpp::platform::runtime::RuntimeBinding& binding, std::span additionalSearchDirs = {}); +// `hostLibsAllowed` mirrors `[build] allow_host_libs` (and +// `MCPP_ALLOW_HOST_LIBS`). It is the user's explicit statement that this build +// reaches outside the sandbox on purpose, and it already switches off the +// link-time hermeticity check. It has to switch off the RUN-time proof for the +// same reason: once resolution is the user's responsibility, mcpp can no longer +// claim the artifact is unstartable — they may run it under LD_LIBRARY_PATH, or +// on a machine where the library is installed where the private loader looks. +// One declaration, one meaning, both phases. RuntimeVerdict validate_runtime_artifact( const std::filesystem::path& artifact, const mcpp::platform::runtime::RuntimeBinding& binding, - const RuntimeResolution& resolution); + const RuntimeResolution& resolution, + bool hostLibsAllowed = false); } // namespace mcpp::platform::elf @@ -579,6 +608,7 @@ RuntimeResolution resolve_runtime_closure( return resolution; } resolution.artifact = std::move(*root); + resolution.artifactIsElf = true; std::deque queue; queue.push_back(resolution.artifact); @@ -606,6 +636,7 @@ RuntimeResolution resolve_runtime_closure( soname, requester, binding, additionalSearchDirs); if (!path) { resolution.unresolved.push_back(soname); + resolution.unresolvedSonames.push_back(soname); continue; } loadedBySoname.emplace(soname, *path); @@ -644,6 +675,7 @@ RuntimeResolution resolve_runtime_closure( if (!queue.empty()) resolution.unresolved.push_back("runtime closure exceeds 512 ELF objects"); detail::sort_unique(resolution.unresolved); + detail::sort_unique(resolution.unresolvedSonames); std::sort(resolution.artifact.resolvedObjects.begin(), resolution.artifact.resolvedObjects.end()); resolution.artifact.resolvedObjects.erase( @@ -656,7 +688,8 @@ RuntimeResolution resolve_runtime_closure( RuntimeVerdict validate_runtime_artifact( const std::filesystem::path& artifact, const mcpp::platform::runtime::RuntimeBinding& binding, - const RuntimeResolution& resolution) { + const RuntimeResolution& resolution, + bool hostLibsAllowed) { RuntimeVerdict verdict; const bool isGlibc = binding.runtimeId.starts_with("glibc@"); if constexpr (!mcpp::platform::is_linux) { @@ -689,6 +722,22 @@ RuntimeVerdict validate_runtime_artifact( const auto artifactPath = detail::canonical_text(artifact); const auto& facts = resolution.artifact; + + // THE ARTIFACT'S FORMAT DECIDES, NOT THE BINDING'S. + // + // The binding describes this HOST — Linux, glibc, a private loader. A cross + // build's artifact is a different format entirely, and ELF rules say + // nothing about it. Without this, a Linux→Windows cross build reached the + // ELF validator with `crosswin.exe`, the "not an ELF file" parse error sat + // in `unresolved`, and the build was failed for a missing library on a file + // that has no DT_NEEDED at all. + if (!resolution.artifactIsElf) { + verdict.diagnostics.push_back(std::format( + "runtime physics: {} is not ELF; ELF/glibc rules are not applicable", + artifactPath)); + return verdict; + } + // ET_REL and static ET_EXEC/ET_DYN files carry no dynamic closure. if (facts.interp.empty() && facts.needed.empty() && resolution.unresolved.empty()) return verdict; @@ -807,11 +856,14 @@ RuntimeVerdict validate_runtime_artifact( if (verdict.status != RuntimeVerdict::Status::ProvenMismatch && !resolution.unresolved.empty()) { - std::string names; - for (auto const& name : resolution.unresolved) { - if (!names.empty()) names += ", "; - names += name; - } + auto join = [](std::span values) { + std::string out; + for (auto const& value : values) { + if (!out.empty()) out += ", "; + out += value; + } + return out; + }; // PROVEN under a hermetic binding, merely UNKNOWN otherwise. // // Hermetic means the artifact's PT_INTERP is a private loader whose @@ -825,7 +877,12 @@ RuntimeVerdict validate_runtime_artifact( // A non-hermetic artifact runs under the host loader, which also // consults `ld.so.cache` — something mcpp deliberately does not parse. // There, unresolved really is unknown. - if (binding.hermetic()) { + // + // And it must be an unfindable SONAME, not merely "something stopped + // the walk": an unreadable object or the 512-object cap are statements + // about the CHECK, and a check that could not look has proven nothing. + if (binding.hermetic() && !resolution.unresolvedSonames.empty() + && !hostLibsAllowed) { verdict.status = RuntimeVerdict::Status::Unresolvable; verdict.diagnostics.push_back(std::format( "runtime closure for {} cannot be satisfied: {} not found on the " @@ -836,11 +893,21 @@ RuntimeVerdict validate_runtime_artifact( " Fix: install the provider into the selected SubOS " "(`xlings install `), or declare the dependency so mcpp " "resolves it.", - artifactPath, names)); + artifactPath, join(resolution.unresolvedSonames))); + } else if (hostLibsAllowed && !resolution.unresolvedSonames.empty()) { + inconclusive(std::format( + "runtime closure for {} is inconclusive: {} is not on the search " + "path this artifact will use, but [build] allow_host_libs is set " + "— resolution at run time is yours to arrange (e.g. " + "LD_LIBRARY_PATH, or installing it where the private loader " + "looks).\n" + " Measured: this loader's built-in default path is the " + "glibc payload's own prefix, NOT /usr/lib.", + artifactPath, join(resolution.unresolvedSonames))); } else { inconclusive(std::format( "runtime closure for {} is inconclusive; unresolved objects: {}", - artifactPath, names)); + artifactPath, join(resolution.unresolved))); } } return verdict; diff --git a/tests/e2e/206_runtime_binding_physics.sh b/tests/e2e/206_runtime_binding_physics.sh index a17ba605..329013e7 100755 --- a/tests/e2e/206_runtime_binding_physics.sh +++ b/tests/e2e/206_runtime_binding_physics.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # requires: elf gcc # 206_runtime_binding_physics.sh — a selected RuntimeBinding must become the -# physical PT_INTERP/libc pair, and a host DSO whose GLIBC floor is satisfiable -# must remain a supported control. Proven mismatches fail at link completion; -# hot no-ops neither relink nor rewrite/re-probe the stored verdict. +# physical PT_INTERP/libc pair, and a host DSO linked under `allow_host_libs` +# must still BUILD (the declaration is honoured) while being reported honestly +# (the private loader does not consult /usr/lib). Proven mismatches fail at link +# completion; hot no-ops neither relink nor rewrite/re-probe the stored verdict. set -euo pipefail TMP=$(mktemp -d) @@ -45,8 +46,36 @@ verdict="$output_dir/.mcpp-runtime-verdicts.json" [[ -x "$artifact" && -f "$verdict" ]] || fail "artifact/verdict missing" readelf -d "$artifact" | grep -q 'libtinfo\.so' \ || fail "safe control did not retain host libtinfo in DT_NEEDED" -[[ $(jq -r '.artifacts["bin/runtime-physics-safe"].status' "$verdict") == pass ]] \ - || { cat "$verdict"; fail "safe host-DSO closure did not pass"; } + +# INCONCLUSIVE, not pass — and the change from `pass` is a correction, not a +# relaxation. +# +# This artifact links a HOST libtinfo while carrying a PRIVATE PT_INTERP, and +# measured with LD_DEBUG=libs the private loader's built-in default path is the +# glibc payload's own build-time prefix (`…/fromsource-x-glibc/2.39/lib`) — a +# directory that does not exist on this machine. /usr/lib is never consulted, +# and the binary exits 127 with "libtinfo.so.6: cannot open shared object file". +# +# It used to report `pass` because the closure model fell back to the HOST's +# default directories, where libtinfo of course is. The model was describing a +# loader the artifact does not use — the same defect that shipped a GL program +# as `validation: pass` while it exited 127. This file's own header note +# ("execution is not part of this link-physics control") is what let the false +# green stand: nothing ever ran it. +# +# `allow_host_libs` is still honoured, and that is the point of the state: the +# user declared they are reaching outside the sandbox, so mcpp REPORTS rather +# than blocks — resolution at run time (LD_LIBRARY_PATH, or installing where the +# private loader looks) is theirs to arrange. +status=$(jq -r '.artifacts["bin/runtime-physics-safe"].status' "$verdict") +[[ "$status" == inconclusive ]] \ + || { cat "$verdict"; fail "host-DSO closure status is '$status', expected inconclusive"; } +jq -r '.artifacts["bin/runtime-physics-safe"].diagnostics[]' "$verdict" \ + | grep -q 'libtinfo\.so\.6' \ + || { cat "$verdict"; fail "the verdict does not name the library it could not resolve"; } +jq -r '.artifacts["bin/runtime-physics-safe"].diagnostics[]' "$verdict" \ + | grep -q 'allow_host_libs' \ + || { cat "$verdict"; fail "the verdict does not say why it stopped short of a proof"; } artifact_before=$(stat -c '%y:%s' "$artifact") verdict_before=$(stat -c '%y:%s' "$verdict") @@ -59,8 +88,8 @@ noop_out=$($MCPP build 2>&1) || { echo "$noop_out"; fail "hot no-op build"; } doctor_out=$($MCPP self doctor 2>&1 || true) grep -q 'last runtime closure verdict' <<<"$doctor_out" \ || { echo "$doctor_out"; fail "doctor omitted stored runtime verdict"; } -grep -q 'runtime-physics-safe.*pass' <<<"$doctor_out" \ - || { echo "$doctor_out"; fail "doctor did not reuse passing verdict"; } +grep -q 'runtime-physics-safe.*inconclusive' <<<"$doctor_out" \ + || { echo "$doctor_out"; fail "doctor did not reuse the stored verdict"; } # Force form-X into a proven Rule-B mismatch: mcpp still supplies the selected # private libc RUNPATH, while the final user flag replaces PT_INTERP with the diff --git a/tests/e2e/219_runtime_search_farm_is_last.sh b/tests/e2e/219_runtime_search_farm_is_last.sh index 33d54d87..a6c1045d 100755 --- a/tests/e2e/219_runtime_search_farm_is_last.sh +++ b/tests/e2e/219_runtime_search_farm_is_last.sh @@ -148,12 +148,24 @@ PY echo "DT_RPATH: $DT_RPATH" [[ -n "$DT_RPATH" ]] || { echo "FAIL: executable carries no DT_RPATH"; exit 1; } -RPATH_LAST="${DT_RPATH##*:}" -[[ "$RPATH_LAST" == "$FARM" ]] || { - echo "FAIL: DT_RPATH does not end with the farm" - echo " recorded farm: $FARM" - echo " DT_RPATH last: $RPATH_LAST" - echo " full: $DT_RPATH" +# The farm must be the last ABSOLUTE entry — not literally the last entry. +# +# `$ORIGIN`-relative entries are a different kind: they address the artifact's +# own directory, not this machine, so they travel with it and their position +# says nothing about which machine-local directory wins. A project with a shared +# library dependency gets one appended after everything else, and an assertion +# of "literally last" would fail on every such project while the invariant it +# meant to check still held. (Measured on a real GLFW app, whose DT_RPATH ends +# `… : /lib : $ORIGIN`.) +RPATH_LAST_ABS="$(python3 -c " +p = [x for x in '''$DT_RPATH'''.split(':') if x.startswith('/')] +print(p[-1] if p else '') +")" +[[ "$RPATH_LAST_ABS" == "$FARM" ]] || { + echo "FAIL: the farm is not the last absolute entry of DT_RPATH" + echo " recorded farm: $FARM" + echo " last absolute entry: $RPATH_LAST_ABS" + echo " full: $DT_RPATH" exit 1 } diff --git a/tests/unit/test_elf_runtime.cpp b/tests/unit/test_elf_runtime.cpp index 183f925b..208e4525 100644 --- a/tests/unit/test_elf_runtime.cpp +++ b/tests/unit/test_elf_runtime.cpp @@ -288,6 +288,7 @@ TEST(RuntimePhysics, RuleBRejectsInterpreterAndLibcFromDifferentPayloads) { Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); r.artifact.interp = b.loader->string(); r.artifact.resolvedLibc = t.path / "store" / "2.39" / "lib64" / "libc.so.6"; @@ -304,6 +305,7 @@ TEST(RuntimePhysics, RuleBAcceptsSameSelectedPayload) { Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app", {"GLIBC_2.39"}); r.artifact.interp = b.loader->string(); r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; @@ -319,6 +321,7 @@ TEST(RuntimePhysics, RuleBRejectsTwoLibcsAcrossTheResolvedClosure) { Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); r.artifact.interp = b.loader->string(); r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; @@ -339,6 +342,7 @@ TEST(RuntimePhysics, RuleARejectsRequiredFloorAboveSelectedLibcExports) { Tmp t; auto b = binding_for(t.path / "store", "2.39"); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); r.artifact.interp = b.loader->string(); r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; @@ -357,6 +361,7 @@ TEST(RuntimePhysics, RuleAAcceptsEqualOrLowerFloor) { Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); r.artifact.interp = b.loader->string(); r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; @@ -382,10 +387,12 @@ TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { auto b = binding_for(t.path / "store"); ASSERT_TRUE(b.hermetic()) << "this test's premise is a private loader"; elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); r.artifact.interp = b.loader->string(); r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; r.unresolved = {"libgpu-driver.so"}; + r.unresolvedSonames = {"libgpu-driver.so"}; // a NEEDED nothing provides auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Unresolvable); @@ -394,6 +401,88 @@ TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { EXPECT_NE(verdict.explain().find("libgpu-driver.so"), std::string::npos); } +// `allow_host_libs` opts out of BOTH phases, or it means two different things. +// +// It already switches off the link-time hermeticity check. Once the user has +// declared that this build reaches outside the sandbox, mcpp cannot also claim +// the artifact is unstartable — they may run it under LD_LIBRARY_PATH, or where +// the library is installed somewhere the private loader does look. It reports; +// it does not block. (Without this, e2e 206's deliberate host-DSO control +// failed its own build.) +TEST(RuntimePhysics, AllowHostLibsDowngradesTheProofToAReport) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + Tmp t; + auto b = binding_for(t.path / "store"); + elf::RuntimeResolution r; + r.artifactIsElf = true; + r.artifact = facts(t.path / "app"); + r.artifact.interp = b.loader->string(); + r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; + r.unresolved = {"libtinfo.so.6"}; + r.unresolvedSonames = {"libtinfo.so.6"}; + + auto blocked = elf::validate_runtime_artifact(r.artifact.artifact, b, r, + /*hostLibsAllowed=*/false); + EXPECT_EQ(blocked.status, elf::RuntimeVerdict::Status::Unresolvable); + EXPECT_TRUE(blocked.blocking()); + + auto reported = elf::validate_runtime_artifact(r.artifact.artifact, b, r, + /*hostLibsAllowed=*/true); + EXPECT_EQ(reported.status, elf::RuntimeVerdict::Status::Inconclusive); + EXPECT_FALSE(reported.blocking()); + EXPECT_NE(reported.explain().find("libtinfo.so.6"), std::string::npos); + EXPECT_NE(reported.explain().find("allow_host_libs"), std::string::npos) + << "a downgraded verdict must say what downgraded it"; +} + +// THE ARTIFACT'S FORMAT DECIDES, NOT THE BINDING'S. +// +// A Linux→Windows cross build runs with the HOST's binding — Linux, glibc, a +// private loader, therefore hermetic — while producing a PE. The parse failure +// ("not an ELF file") landed in `unresolved`, and the artifact was failed for a +// missing shared library it could not possibly have. CI caught it; this pins it. +TEST(RuntimePhysics, CrossBuiltNonElfArtifactIsNotJudgedByElfRules) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + Tmp t; + auto b = binding_for(t.path / "store"); + ASSERT_TRUE(b.hermetic()); + elf::RuntimeResolution r; + r.artifactIsElf = true; + r.artifact = facts(t.path / "crosswin.exe"); + r.artifactIsElf = false; // what the PE walk produces + r.unresolved = {"artifact 'crosswin.exe' is not ELF"}; + // and crucially NO unresolvedSonames — nothing was ever looked for. + + auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); + EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Pass); + EXPECT_FALSE(verdict.blocking()); + EXPECT_NE(verdict.explain().find("not ELF"), std::string::npos); +} + +// The narrower half of the same conflation. Even on a genuine ELF, "I could not +// read one of the objects" or "I stopped after 512" are statements about the +// CHECK — a check that could not look has proven nothing, so it stays +// inconclusive no matter how hermetic the binding is. +TEST(RuntimePhysics, UnreadableObjectIsInconclusiveEvenWhenHermetic) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + Tmp t; + auto b = binding_for(t.path / "store"); + elf::RuntimeResolution r; + r.artifactIsElf = true; + r.artifact = facts(t.path / "app"); + r.artifact.interp = b.loader->string(); + r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; + r.unresolved = {"libweird.so.1 (truncated ELF header)"}; // read, not missing + // no unresolvedSonames: it WAS found, it just could not be parsed + + auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); + EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Inconclusive); + EXPECT_FALSE(verdict.blocking()); +} + // The other side of the same line. Without a private loader the artifact runs // under the HOST's, which also consults `ld.so.cache` — something mcpp // deliberately does not parse. There, unresolved really is unknown, and @@ -406,6 +495,7 @@ TEST(RuntimePhysics, UnresolvedNeededWithoutAPrivateLoaderStaysInconclusive) { b.loader.reset(); // host runtime: gcc@system and friends ASSERT_FALSE(b.hermetic()); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); r.artifact.resolvedLibc = b.libraryDirs.front() / "libc.so.6"; r.unresolved = {"libgpu-driver.so"}; @@ -429,6 +519,7 @@ TEST(RuntimePhysics, UndeclaredBindingIsInconclusiveNotNotApplicable) { b.runtimeId.clear(); b.loader.reset(); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); @@ -443,6 +534,7 @@ TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; + r.artifactIsElf = true; r.artifact = facts(t.path / "app"); r.artifact.interp = "/deliberately/mismatched/loader"; r.unresolved = {"libgpu-driver.so"}; From 1c58f22319edce37ef359d2f7113868a8df08f29 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:24:48 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix(runtime):=20=E9=97=AD=E5=8C=85?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8F=AA=E7=9C=8B=E4=BA=A7=E7=89=A9=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E4=BC=9A=E7=9C=8B=E7=9A=84=E5=9C=B0=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_needed 此前直接读 binding.searchDirs,而**护栏在 plan 上**:交叉目标不发 farm、非 ELF 格式不发。于是模型会拿本宿主的 x86_64 farm 去解析一个 aarch64 产物的 DT_NEEDED —— 那条路径根本不在它的 DT_RPATH 里 —— 然后报一个目标机不会兑现的 pass。 改为经 additionalSearchDirs 从 plan.runtimeSearch 里取 SubosFarm 条目,与发出去的 那份逐条同源。**模型必须看产物看的地方,不能更宽** —— 更宽正是这一轮在修的形状。 --- src/build/runtime_validation.cppm | 12 ++++++++++++ src/platform/elf_runtime.cppm | 14 ++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 60803f37..73ea40af 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -16,6 +16,7 @@ import mcpp.libs.json; import mcpp.platform; import mcpp.platform.elf_runtime; import mcpp.platform.runtime_binding; +import mcpp.platform.runtime_search; export namespace mcpp::build::runtime_validation { @@ -248,6 +249,17 @@ runtime_search_dirs(const mcpp::build::BuildPlan& plan) { append(plan.depRuntimeLibraryDirs); append(plan.toolchain.compilerRuntimeDirs); append(plan.runtimeBinding.libraryDirs); + // The SubOS farm comes from the PLAN's closure, not straight from the + // binding — because the plan is where the guards live. A cross target gets + // no farm entry in its DT_RPATH, so a model that consulted the binding + // directly would resolve an aarch64 DT_NEEDED out of this host's x86_64 + // farm and report a pass the target machine will not honour. The model has + // to look exactly where the artifact looks. + for (auto const& dir : plan.runtimeSearch) { + if (dir.origin != mcpp::platform::search::Origin::SubosFarm) continue; + if (dir.path.empty() || std::ranges::find(out, dir.path) != out.end()) continue; + out.push_back(dir.path); + } return out; } diff --git a/src/platform/elf_runtime.cppm b/src/platform/elf_runtime.cppm index 6e38af1e..1be390cb 100644 --- a/src/platform/elf_runtime.cppm +++ b/src/platform/elf_runtime.cppm @@ -327,10 +327,16 @@ std::optional resolve_needed( append_unique_path(dirs, expand_origin(raw, requester.artifact)); for (auto const& dir : additionalSearchDirs) append_unique_path(dirs, dir); for (auto const& dir : binding.libraryDirs) append_unique_path(dirs, dir); - // The SubOS farm. It is where `-lGL` resolved at link time (the SubOS is - // the sysroot), so a model that omits it reports libraries as missing - // that the artifact will in fact find. - for (auto const& dir : binding.searchDirs) append_unique_path(dirs, dir); + // NOTE: the SubOS farm is NOT read from the binding here. + // + // It reaches this function through `additionalSearchDirs`, which the caller + // builds from the PLAN — and the plan is where the guards live (no farm for + // a cross target, none for a non-ELF format). Reading `binding.searchDirs` + // directly would put this host's x86_64 farm on the search path of an + // aarch64 artifact that has no such entry in its DT_RPATH, and report a + // pass the target machine will not honour. The model must look exactly + // where the artifact looks, no wider. + // // The host loader's built-in defaults — ONLY when the artifact runs under // the host loader. // From fd330c2e2237538ded79773038711d6be1d8ab5f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:27:46 +0800 Subject: [PATCH 07/14] =?UTF-8?q?test(e2e):=20220=20=E8=87=AA=E5=B8=A6?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=20farm,=E4=B8=8D=E5=86=8D=E9=9D=A0=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E7=A2=B0=E5=B7=A7=E8=A3=85=E4=BA=86=E4=BB=80=E4=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的那一半只能测「这台机器恰好有、而载荷没有」的库。CI 的 SubOS 里没有这种库, 于是它 SKIP —— 最需要它的地方,那条决定性断言从来没跑过。 新增 half 0:在测试自己的临时目录里建一个工程级具名 SubOS (`/.mcpp/.xlings/subos/probe/lib`),把 mcpp 自己构建的探针 .so 放进去, 然后断言产物**真的跑起来**。不写任何共享状态 —— 往本机真实 SubOS 里塞探针库正是 「一个测试的失败源于上一次运行」那类缺陷,这套件已经为它付过一次学费。 `allow_host_libs` 是必需的,而且不是绕开:没有 subos_info 的 SubOS 说不出自己的 runtime,mcpp 拒绝猜 libc 版本,hermeticity 检查如实报告回落宿主。那条声明就是 「对,我是故意的」。 顺带把 219 与两份文档里那句「farm 是最后一条」精确成「最后一条**绝对路径**」。 --- tests/e2e/220_farm_only_library_runs.sh | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/e2e/220_farm_only_library_runs.sh b/tests/e2e/220_farm_only_library_runs.sh index 0299e970..605597a2 100755 --- a/tests/e2e/220_farm_only_library_runs.sh +++ b/tests/e2e/220_farm_only_library_runs.sh @@ -25,6 +25,87 @@ set -e TMP=$(mktemp -d) trap "rm -rf $TMP" EXIT +# ── half 0: a farm this test OWNS ─────────────────────────────────────────── +# +# The opportunistic half below can only measure what this machine happens to +# have installed, and on a CI SubOS that is nothing the payloads do not already +# provide — so it skips, and the decisive assertion never runs where it matters +# most. This half builds the condition instead of hoping for it. +# +# A project-local named SubOS (`/.mcpp/.xlings/subos/`) is a real +# SubOS as far as the binding is concerned, and it lives entirely inside this +# test's temp directory. Nothing shared is written — writing a probe library +# into the machine's actual SubOS would be the "one test's failure comes from +# the previous run" defect this suite has already paid for once. +# +# `allow_host_libs` is required and is not a workaround: a SubOS with no +# `subos_info` names no runtime, so mcpp declines payload-first rather than +# guessing a libc, and the hermeticity check correctly reports the host +# fallback. The declaration is what says "yes, I meant that". +mkdir -p "$TMP/ownfarm/src" "$TMP/ownfarm/.mcpp/.xlings/subos/probe/lib" +cd "$TMP/ownfarm" +FARM_OWN="$TMP/ownfarm/.mcpp/.xlings/subos/probe/lib" + +# The probe library, built by mcpp itself so it matches the toolchain exactly. +mkdir -p "$TMP/probelib/src" +cat > "$TMP/probelib/mcpp.toml" <<'EOF' +[package] +name = "probelib" +version = "0.1.0" + +[targets.mcppfarmprobe] +kind = "shared" +EOF +cat > "$TMP/probelib/src/probe.cppm" <<'EOF' +export module probelib.probe; +export int mcpp_farm_probe() { return 42; } +EOF +( cd "$TMP/probelib" && "$MCPP" build > build.log 2>&1 ) || { + echo "FAIL: probe library build"; cat "$TMP/probelib/build.log"; exit 1; } +PROBE_SO="$(ls "$TMP"/probelib/target/*/*/bin/libmcppfarmprobe.so | head -1)" +[[ -n "$PROBE_SO" ]] || { echo "FAIL: no probe .so"; ls -R "$TMP/probelib/target" | head -20; exit 1; } +cp "$PROBE_SO" "$FARM_OWN/libmcppfarmprobe.so" + +cat > mcpp.toml < src/main.cpp +"$MCPP" build > build.log 2>&1 || { + echo "FAIL: build against a SubOS-provided library"; cat build.log; exit 1; } + +OWN_BIN="$(ls target/*/*/bin/ownfarm | head -1)" +[[ -n "$OWN_BIN" ]] || { echo "FAIL: no artifact"; exit 1; } + +# The library exists ONLY under the SubOS view. `-L` got it linked; only the +# farm entry in DT_RPATH can get it loaded — nothing else on the search path +# has ever heard of it. +"$OWN_BIN" +rc=$? +[[ $rc -eq 0 ]] || { + echo "FAIL: an artifact could not load a library its own SubOS provides (exit $rc)" + echo " farm: $FARM_OWN" + echo " This is the run-time half of the closure: the SubOS is the" + echo " sysroot at link time and must be on the search path at load time." + exit 1 +} +echo "ran: exit 0 against a library only this SubOS provides" +# Measured against the previous release (2026.8.10.3) this half fails at the +# BINDING — an undeclared SubOS was a hard error there — so it exercises both +# halves of this change together rather than isolating the farm. The half below +# isolates the farm alone, when the machine has a populated SubOS to isolate it +# with; on the machine this was written on, `-lEGL` linked and exited 127 before +# the change and exits 0 after. +cd "$TMP" + # ── find a library only the farm provides ─────────────────────────────────── # # Derived from the recorded closure rather than hardcoded to libGL: CI SubOSes From 6f51d5e37579db94b93b78f653f26f19aa326ccf Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:31:09 +0800 Subject: [PATCH 08/14] =?UTF-8?q?refactor(runtime=5Fsearch):=20=E5=88=A0?= =?UTF-8?q?=E6=8E=89=E4=B8=A4=E4=B8=AA=E5=8F=AA=E6=9C=89=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=9C=A8=E7=94=A8=E7=9A=84=E5=AF=BC=E5=87=BA=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paths_of 与 parse_origin 在 src/ 下零消费者。导出面只有测试在撑,就是没有理由存在的 导出面。origin 名字的往返测试改成直接钉 to_string 的字面值 —— 那才是真正要紧的: 它们是 resolution.json 里 runtime.search.closure 的 origin 字段,改名等于改 wire 格式。 --- src/platform/runtime_search.cppm | 17 ----------------- tests/unit/test_runtime_search.cpp | 28 +++++++++------------------- 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/src/platform/runtime_search.cppm b/src/platform/runtime_search.cppm index 7736b1a1..9936f739 100644 --- a/src/platform/runtime_search.cppm +++ b/src/platform/runtime_search.cppm @@ -117,14 +117,6 @@ std::string_view to_string(Origin origin) { return "unknown"; } -std::optional parse_origin(std::string_view value) { - if (value == "payload") return Origin::Payload; - if (value == "package") return Origin::Package; - if (value == "subos_farm") return Origin::SubosFarm; - if (value == "host_default") return Origin::HostDefault; - return std::nullopt; -} - struct Dir { std::filesystem::path path; Origin origin = Origin::Payload; @@ -169,15 +161,6 @@ std::vector ordered(std::vector dirs) { return merged; } -// Just the paths, in contract order — for the callers that render a flag or -// walk a search path and have no use for the provenance. -std::vector paths_of(std::span dirs) { - std::vector out; - out.reserve(dirs.size()); - for (auto const& dir : dirs) out.push_back(dir.path); - return out; -} - // The declarative exit from xlings' linker wrapper (openxlings/xlings#540). // // That wrapper appends `-rpath "$XLINGS_SUBOS_LIB" --disable-new-dtags` to diff --git a/tests/unit/test_runtime_search.cpp b/tests/unit/test_runtime_search.cpp index 9845f0fb..459b3ddb 100644 --- a/tests/unit/test_runtime_search.cpp +++ b/tests/unit/test_runtime_search.cpp @@ -37,14 +37,15 @@ TEST(RuntimeSearch, MachineLocalIsEverythingButTheHostDefaults) { EXPECT_FALSE(search::is_machine_local(Origin::HostDefault)); } -TEST(RuntimeSearch, OriginNamesRoundTrip) { - for (auto origin : {Origin::Payload, Origin::Package, - Origin::SubosFarm, Origin::HostDefault}) { - auto parsed = search::parse_origin(search::to_string(origin)); - ASSERT_TRUE(parsed.has_value()) << search::to_string(origin); - EXPECT_EQ(*parsed, origin); - } - EXPECT_FALSE(search::parse_origin("something_else").has_value()); +// These strings are PUBLISHED — they are the `origin` field of every entry in +// `resolution.json`'s `runtime.search.closure`, which CI, `mcpp why runtime` and +// e2e 219 all read. Renaming one is a wire-format change, so it is pinned here +// rather than left to whatever `to_string` happens to say. +TEST(RuntimeSearch, OriginNamesArePublishedAndStable) { + EXPECT_EQ(search::to_string(Origin::Payload), "payload"); + EXPECT_EQ(search::to_string(Origin::Package), "package"); + EXPECT_EQ(search::to_string(Origin::SubosFarm), "subos_farm"); + EXPECT_EQ(search::to_string(Origin::HostDefault), "host_default"); } TEST(RuntimeSearch, OrderedSortsByRankNotByInsertion) { @@ -104,17 +105,6 @@ TEST(RuntimeSearch, OrderedNormalizesAndDropsEmpty) { EXPECT_EQ(out[0].path, std::filesystem::path("/a/b")); } -TEST(RuntimeSearch, PathsOfPreservesContractOrder) { - auto out = search::ordered({ - {"/farm", Origin::SubosFarm}, - {"/pay", Origin::Payload}, - }); - auto paths = search::paths_of(out); - ASSERT_EQ(paths.size(), 2u); - EXPECT_EQ(paths[0], std::filesystem::path("/pay")); - EXPECT_EQ(paths[1], std::filesystem::path("/farm")); -} - // The opt-out is a CROSS-REPO name (openxlings/xlings#540). Pinning the // spelling here means a rename shows up as a failed test rather than as a // silently ineffective declaration — the failure mode of an env var nobody From d0119faac3ab8003bc7d7515dde16e1f1e25938c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:37:30 +0800 Subject: [PATCH 09/14] =?UTF-8?q?fix(plan):=20farm=20=E7=9A=84=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F=E8=A1=A5=E4=B8=8A=20libc=20=E8=BD=B4,=E5=B9=B6?= =?UTF-8?q?=E4=B8=94=E3=80=8C=E4=B8=8D=E5=8C=B9=E9=85=8D=E3=80=8D=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E6=98=AF=E8=A2=AB=E8=AF=81=E6=98=8E=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原护栏只看 os + arch。x86_64-linux-musl 两者都对得上,于是一个 glibc farm 会落到 musl 程序的搜索路径上 —— 正是 rule B 在防的载荷混用。今天那个目标是 static (实测:根本没有 dynamic section,flag 是惰性的),所以这是为它不再是 static 的那天准备的。 ⚠️ 第一版把「未声明」当成了「不匹配」:没有 subos_info 的 SubOS 没有 runtimeId, 于是它自己的库视图被拒绝了 —— e2e 220 的 half 0 当场变红。这是本次改动在别处正在 消灭的那个错误的又一次现形:**缺席不是矛盾**。改为只有两边都说了话且说的不一样才算 不匹配;任一侧未知,就交给 os/arch 护栏决定。 补一张护栏矩阵单测(host / 换 arch / 换 libc / 换格式 / 未声明),因为逐条端到端 验证每一格都要一整套交叉工具链,而它防的失败是静默的。 --- src/build/plan.cppm | 28 +++++++++-- tests/unit/test_runtime_contract.cpp | 74 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 048aad72..7090b742 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -694,10 +694,16 @@ std::vector runtime_search_closure( // format DT_RPATH exists on ELF only. Mach-O and PE get nothing rather // than a branch in every consumer — the same shape // `loader_contract` uses for the tag half of this contract. - // host The farm belongs to THIS host's SubOS. A cross target - // (aarch64-musl, mingw, wasm) would receive a path that is inert - // at best and points at the wrong architecture's libraries at - // worst. + // host The farm belongs to THIS host's SubOS, and a SubOS is a + // (os, arch, libc) triple's worth of libraries. A cross target + // must match all three or the path is inert at best and points + // at the wrong architecture's — or the wrong C library's — + // objects at worst. `x86_64-linux-musl` is the case that makes + // the libc axis load-bearing: same OS, same arch, and a glibc + // farm on a musl program's search path is exactly the payload + // mixing rule B exists to prevent. (Today that target is also + // `linkage = "static"`, so the flag is inert — measured: no + // dynamic section at all. The guard is for the day it is not.) const auto triple = [&] { auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); return t ? *t : mcpp::toolchain::triple::Triple{}; @@ -705,7 +711,21 @@ std::vector runtime_search_closure( const bool elfTarget = triple.empty() ? bool(mcpp::platform::is_linux) : (triple.os != "macos" && triple.os != "windows"); + // The binding names its libc as `@`; the triple names it + // as an ABI env (`gnu` ⇒ glibc). A MISMATCH must be PROVEN, not assumed: + // an undeclared SubOS has no runtime identity at all, and refusing its own + // library view because it did not describe itself would be the same + // absence-read-as-contradiction this change exists to remove. Unknown on + // either side ⇒ no evidence of a mismatch ⇒ the os/arch guards decide. + const auto bindingLibcFamily = + binding.runtimeId.substr(0, binding.runtimeId.find('@')); + const auto targetLibcFamily = triple.env.empty() + ? std::string{} : (triple.env == "gnu" ? "glibc" : triple.env); + const bool libcMismatch = !bindingLibcFamily.empty() + && !targetLibcFamily.empty() + && targetLibcFamily != bindingLibcFamily; const bool hostTarget = binding.platform == "linux" + && !libcMismatch && (triple.empty() || (triple.os == "linux" && (triple.arch.empty() || triple.arch == binding.arch))); diff --git a/tests/unit/test_runtime_contract.cpp b/tests/unit/test_runtime_contract.cpp index 87c85683..16ed2e87 100644 --- a/tests/unit/test_runtime_contract.cpp +++ b/tests/unit/test_runtime_contract.cpp @@ -9,6 +9,7 @@ import mcpp.modgraph.scanner; import mcpp.platform; import mcpp.platform.axis; import mcpp.platform.runtime_binding; +import mcpp.platform.runtime_search; import mcpp.platform.xlings.subos_info; namespace build = mcpp::build; @@ -277,6 +278,8 @@ TEST(RuntimeContract, XlingsSelectedFactsPrecedeDescriptorFallbacks) { }); build::merge_runtime_binding_contract(plan, binding); + ASSERT_EQ(plan.runtimeSearch.size(), 0u) + << "a binding with no SubOS library view contributes no farm entry"; ASSERT_EQ(plan.runtimeProviders.size(), 2u); EXPECT_EQ(plan.runtimeProviders.front().provider.canonical(), "xim.selected@4.0.0"); @@ -287,3 +290,74 @@ TEST(RuntimeContract, XlingsSelectedFactsPrecedeDescriptorFallbacks) { } } // namespace + +// ── the farm's two guards, as a matrix ───────────────────────────────────── +// +// The farm belongs to THIS host's SubOS, so it may only reach an artifact that +// will run here under this runtime. The guards are asserted directly because +// each one costs a full cross toolchain to exercise end to end, and the failure +// they prevent is silent: a path that is inert at best, and points at another +// architecture's or another C library's objects at worst. +namespace { + +build::BuildPlan plan_for(std::string targetTriple) { + build::BuildPlan plan; + plan.toolchain.targetTriple = std::move(targetTriple); + return plan; +} + +mcpp::platform::runtime::RuntimeBinding host_binding_with_farm() { + mcpp::platform::runtime::RuntimeBinding b; + b.platform = "linux"; + b.arch = "x86_64"; + b.declared = true; + b.runtimeId = "glibc@2.39"; + b.searchDirs = {"/home/u/.mcpp/registry/subos/default/lib"}; + return b; +} + +std::size_t farm_entries(const std::vector& dirs) { + return static_cast(std::ranges::count_if(dirs, [](auto const& d) { + return d.origin == mcpp::platform::search::Origin::SubosFarm; + })); +} + +} // namespace + +TEST(RuntimeSearchClosure, HostTargetGetsTheFarm) { + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for(""), host_binding_with_farm())), 1u); + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for("x86_64-linux-gnu"), host_binding_with_farm())), 1u); +} + +TEST(RuntimeSearchClosure, CrossTargetGetsNoFarm) { + // Another architecture: the farm holds x86_64 objects. + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for("aarch64-linux-gnu"), host_binding_with_farm())), 0u); + // Another C library: a glibc farm on a musl program's search path is the + // payload mixing rule B exists to prevent. + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for("x86_64-linux-musl"), host_binding_with_farm())), 0u); + // Another format: DT_RPATH does not exist there at all. + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for("x86_64-windows-gnu"), host_binding_with_farm())), 0u); + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for("aarch64-macos"), host_binding_with_farm())), 0u); +} + +// A MISMATCH must be proven, not assumed. An undeclared SubOS has no runtime +// identity, and refusing its own library view on that basis would be exactly +// the absence-read-as-contradiction this release removes elsewhere. Caught by +// e2e 220, which builds against a SubOS it creates and which therefore has no +// `subos_info` to declare anything. +TEST(RuntimeSearchClosure, UndeclaredBindingStillGetsItsOwnFarm) { + auto b = host_binding_with_farm(); + b.declared = false; + b.runtimeId.clear(); + EXPECT_EQ(farm_entries(build::runtime_search_closure(plan_for(""), b)), 1u); + // …but the architecture guard still applies: unknown libc is not a licence + // to ignore everything else. + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for("aarch64-linux-gnu"), b)), 0u); +} From 9a0333e25ca1baf07486ab223f18ef22853f005e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:38:54 +0800 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E5=90=8E=E8=A2=AB=20CI=20=E4=B8=8E=E8=87=AA=E6=9F=A5?= =?UTF-8?q?=E6=8E=A8=E7=BF=BB=E7=9A=84=E4=B8=89=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每一条都是「可证」被用得太宽 —— 而这份设计本身就是在修同一种病。方向对不代表范围对。 §12.4 记下共同结构:unresolved 那种混装袋(三种不同强度的事实同住一个 vector, 消费者只能按最强的那种解读)是本轮所有误判的来源。 --- ...time-search-closure-implementation-plan.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md index 8972fa3b..5e443ec0 100644 --- a/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md +++ b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md @@ -6,6 +6,7 @@ > > **实施状态:已完成。** 实施过程中改动本计划的四处,均在对应小节以 > **【实施修正】** 标出 —— 每一处都是被**实测**推翻的,不是改主意。 +> 落地后 CI 与自查又推翻三处,见 §12。 --- @@ -331,3 +332,65 @@ M1 契约模块 → M2 binding(降级+farm) → M3 闭包判据 → M4 链接期 M2 的降级半边(§2.2)与图形无关,是正在阻塞 Windows 用户的回归 —— 它在同一个 PR 里, 但**提交上独立成一个 commit**,以便必要时单独 cherry-pick。 + +--- + +## 12. 落地后被推翻的三处(CI 与自查) + +这一节是这轮最有价值的部分:**每一条都是「可证」被用得太宽**,而这份设计本身 +就是在修同一种病。方向对不代表范围对。 + +### 12.1 ⚠️ CI 抓到:交叉产出的 PE 被 ELF 规则判死 + +`mingw-cross linux→windows` 变红: + +``` +error: runtime closure validation failed +runtime closure for …/crosswin.exe cannot be satisfied: + artifact '…/crosswin.exe' is not ELF not found on the search path … +``` + +三层错误叠在一起,每一层单独看都像对的: + +1. binding 是**宿主的**(Linux/glibc/私有加载器 ⇒ `hermetic()` 为真),产物却是 PE。 + **产物的格式由产物决定,不由 binding 决定。** +2. `resolution.unresolved` 是个**混装袋**:「找不到的 SONAME」「读不了的对象」 + 「512 上限」同住一个 vector。**只有第一种可证**;后两种是关于**检查本身**的陈述, + 而没能看的检查什么都没证明。⇒ 拆出 `unresolvedSonames`,升级只看它。 +3. 于是「这不是 ELF」被读成了「你缺一个库」,报给一个连 `DT_NEEDED` 都没有的文件。 + +### 12.2 ⚠️ CI 抓到:`206` 的「安全宿主 DSO 对照」本来就是假绿 + +`e2e 206` 断言一个 `allow_host_libs` + `-ltinfo` 的产物 `status == pass`。 +`LD_DEBUG=libs` 实测: + +``` +search path=…/xim-x-glibc/2.39/lib64 : …/xim-x-gcc/16.1.0/lib64 : /lib (RPATH) +search path=/home/xlings/.xlings_data/xim/xpkgs/fromsource-x-glibc/2.39/lib (system search path) + ↑ 载荷自己的构建期前缀,本机不存在 +./tinfoprobe → libtinfo.so.6: cannot open shared object file (127) +``` + +**私有加载器的内建默认路径是载荷自己的构建期前缀,`/usr/lib` 从不被查。** +它此前报 pass,只是因为闭包模型回落到宿主目录 —— 与那个被当成 pass 的 GL 程序 +**同一形状,就在 mcpp 自己的测试套件里**。而这份文件自己的注释 +*"execution is not part of this link-physics control"* 正是让这条假绿站住的理由: +**从来没人运行过它。** + +修法不是放回宿主目录,而是让 `[build] allow_host_libs` **同时退出两个阶段**: +它本就关掉链接期 hermeticity 检查,既然解析责任已归用户,mcpp 就不能再断言产物 +起不来。⇒ 该档下报 `inconclusive` 并指名,不阻断。**一条声明,一个含义。** + +### 12.3 自查:三处「模型比产物宽」 + +| 处 | 问题 | 修法 | +|---|---|---| +| `resolve_needed` | 直接读 `binding.searchDirs`,而**护栏在 plan 上** ⇒ 会拿宿主 x86_64 farm 解析 aarch64 产物的 `NEEDED`,报一个目标机不兑现的 pass | 经 `additionalSearchDirs` 从 `plan.runtimeSearch` 取,与发出去的逐条同源 | +| farm 护栏 | 只看 os + arch。`x86_64-linux-musl` 两者都对,glibc farm 会落到 musl 程序上 | 补 libc 轴。**但「未声明」不等于「不匹配」** —— 第一版这么写,当场让 e2e 220 变红 | +| `runtime_search.cppm` | `paths_of` / `parse_origin` 在 `src/` 下零消费者 | 删掉。导出面只有测试在撑,就是没有理由存在的导出面 | + +### 12.4 一条方法论 + +> **`unresolved` 这种「混装袋」是这轮所有误判的共同结构。** +> 一个 vector 里装了三种不同强度的事实,而消费者只能按最强的那种去解读它。 +> 拆开之后,每条判断的证据强度就写在类型上了。 From 7dacceb444eacdd8544be35c1d0df811b51b5187 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:52:12 +0800 Subject: [PATCH 11/14] =?UTF-8?q?fix(test):=20=E6=8A=A4=E6=A0=8F=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E4=B8=8D=E8=83=BD=E6=8A=8A=E3=80=8C=E6=9C=AC=E6=9C=BA?= =?UTF-8?q?=E6=98=AF=20Linux=E3=80=8D=E5=BD=93=E6=88=90=E5=89=8D=E6=8F=90;?= =?UTF-8?q?doctor=20=E5=91=88=E7=8E=B0=E9=99=8D=E7=BA=A7=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 macOS 与 Windows runner 上 RuntimeSearchClosure 两条红:它们用空 triple 断言 「拿得到 farm」,而空 triple 的意思是**本宿主的格式** —— 在 macOS/Windows 上那正是 「没有 ELF 搜索路径」,也就是这条护栏本身要表达的东西,不是它的例外。 改为:显式 Linux target 在任何 runner 上都断言拿得到(护栏读的是目标,不是 runner); 空 triple 那条按本机格式给期望值,并把理由写进断言消息。 顺带补上设计里承诺而没实现的一处:`mcpp why runtime` 现在呈现 binding 的 `declared` 与 `note`。只在发生那次构建里出现过一次的降级,是事后查不到的降级 —— 而「我的 verdict 为什么是 inconclusive」正是这个命令存在的理由。 --- src/doctor.cppm | 15 ++++++++++++--- tests/unit/test_runtime_contract.cpp | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/doctor.cppm b/src/doctor.cppm index 9bba4aca..7d99841d 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -592,10 +592,19 @@ int print_stored_runtime_resolution() { std::println("runtime resolution: {}", path.string()); if (auto binding = runtime->find("binding"); binding != runtime->end() && binding->is_object()) { - std::println("binding: {} via {} (contract {})", - binding->value("runtime_id", "?"), + const bool declared = binding->value("declared", true); + std::println("binding: {} via {} (contract {}){}", + declared ? binding->value("runtime_id", "?") : "(undeclared)", binding->value("provider_id", "?"), - binding->value("contract_hash", "?")); + binding->value("contract_hash", "?"), + declared ? "" : " — this SubOS did not describe itself"); + // The note, when there is one. A degradation that only ever appeared + // once during the build it happened in is a degradation nobody can look + // up afterwards, and "why is my verdict inconclusive" is exactly the + // question this command exists to answer. + if (auto note = binding->value("note", std::string{}); !note.empty()) + for (auto line : std::views::split(note, '\n')) + std::println(" {}", std::string_view(line)); } std::println("requirements:"); diff --git a/tests/unit/test_runtime_contract.cpp b/tests/unit/test_runtime_contract.cpp index 16ed2e87..5664652f 100644 --- a/tests/unit/test_runtime_contract.cpp +++ b/tests/unit/test_runtime_contract.cpp @@ -325,10 +325,18 @@ std::size_t farm_entries(const std::vector& dirs) { } // namespace TEST(RuntimeSearchClosure, HostTargetGetsTheFarm) { - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for(""), host_binding_with_farm())), 1u); + // An EXPLICIT Linux target is a farm target from anywhere, including a + // macOS or Windows runner: the guards read the target, not the runner. EXPECT_EQ(farm_entries(build::runtime_search_closure( plan_for("x86_64-linux-gnu"), host_binding_with_farm())), 1u); + + // An EMPTY triple means "this host", so the answer is the host's format — + // and that is the point of the ELF guard, not an exception to it. Asserting + // 1 unconditionally is what failed on the macOS and Windows runners. + const std::size_t expectedForHost = mcpp::platform::is_linux ? 1u : 0u; + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for(""), host_binding_with_farm())), expectedForHost) + << "an empty triple must resolve to this host's format"; } TEST(RuntimeSearchClosure, CrossTargetGetsNoFarm) { @@ -355,7 +363,8 @@ TEST(RuntimeSearchClosure, UndeclaredBindingStillGetsItsOwnFarm) { auto b = host_binding_with_farm(); b.declared = false; b.runtimeId.clear(); - EXPECT_EQ(farm_entries(build::runtime_search_closure(plan_for(""), b)), 1u); + EXPECT_EQ(farm_entries(build::runtime_search_closure( + plan_for("x86_64-linux-gnu"), b)), 1u); // …but the architecture guard still applies: unknown libc is not a licence // to ignore everything else. EXPECT_EQ(farm_entries(build::runtime_search_closure( From 59197956a64b516b0cb7bcaf3d62240e5823516b Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:54:12 +0800 Subject: [PATCH 12/14] =?UTF-8?q?docs:=20=E8=A7=82=E6=B5=8B=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E8=8A=82=E8=AE=B0=E4=B8=8B=E5=AE=9E=E9=99=85=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E4=BD=8D=E7=BD=AE(declared/note=20=E5=BD=92=20why=20r?= =?UTF-8?q?untime,=E4=B8=8D=E5=BD=92=20doctor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doctor 面向「这台机器健康吗」,而 binding 是某一次构建的决定 —— 它属于解释那次构建 的命令。写下差异,而不是让文档说一件没做的事。 --- ...phics-runtime-search-closure-implementation-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md index 5e443ec0..1782ce7f 100644 --- a/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md +++ b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md @@ -267,11 +267,11 @@ mcpp 在**驱动 ninja 之前**把它设进自己的进程环境(子进程继承 ## 7. M7 — 可观测性 -| 载体 | 补什么 | -|---|---| -| `resolution.json` | `runtime_search` 数组:`[{path, origin}]`,**保序** | -| `mcpp why runtime` | `search:` 行按 origin 展开;binding 未声明时打印 `note` | -| `mcpp doctor` | `declared=false` 作为 **info** 呈现并说明影响范围 | +| 载体 | 补什么 | 实际落地 | +|---|---|---| +| `resolution.json` | `runtime_search` 数组:`[{path, origin}]`,**保序** | ✅ 位置是 `runtime.search.closure`,并多带一列 `machine_local` | +| `mcpp why runtime` | `search:` 行按 origin 展开;binding 未声明时打印 `note` | ✅ 另加一行 `binding: (undeclared) … — this SubOS did not describe itself` | +| `mcpp doctor` | `declared=false` 作为 **info** 呈现 | **改到 `mcpp why runtime`**。`doctor` 面向「这台机器健康吗」,而 binding 是**某一次构建**的决定 —— 它属于解释那次构建的命令。`doctor` 本轮只补了 `unresolvable` 那一支判决 | --- From 93ab297826beb53be056000f4e218d5a0a756ece Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:09:32 +0800 Subject: [PATCH 13/14] =?UTF-8?q?test(e2e):=20202=20=E7=94=A8=20here-strin?= =?UTF-8?q?g,=E5=88=AB=E8=AE=A9=20pipefail=20=E6=8A=8A=E3=80=8C=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E6=88=90=E5=8A=9F=E3=80=8D=E8=AF=BB=E6=88=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set -o pipefail` 开着,而 `grep -q` 一匹配就退出 —— 于是 `echo "$out" | grep -q` 让 echo 往一个已关闭的管道写、吃到 SIGPIPE,整条管道报 141,**尽管匹配是成功的**。 它只在 `$out` 足够大、echo 还没写完时现形,对 `cache list` 来说就是「这台机器构建过 几个东西之后」。实测(开发机、热构建缓存,48808 字节): OLD(已发布 2026.8.10.3) 4/5 次失败 NEW(本分支) 3/5 次失败 同一个字符串 `grep -c kind` 数出 153 处匹配 ⇒ **与被测的 mcpp 无关,与本机缓存大小有关。** CI 缓存小,所以那边一直绿。 我差点把它当成本分支的回归报出去 —— 一个会随无关缓存大小失败的测试,必然会被读成 「当时手里那个改动」的回归。改成 here-string,两个二进制各跑 8 次全过。 --- tests/e2e/202_machine_output_contract.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/e2e/202_machine_output_contract.sh b/tests/e2e/202_machine_output_contract.sh index dc50563d..9e19c640 100755 --- a/tests/e2e/202_machine_output_contract.sh +++ b/tests/e2e/202_machine_output_contract.sh @@ -55,10 +55,20 @@ check "unsupported value (pack)" 2 "$MCPP" pack --format bogus # And the supported ones must still be JSON on stdout. Asserted here because # every check above is about what does NOT happen; without this, deleting the # feature entirely would leave the file green. +# +# HERE-STRINGS, NOT PIPES. `set -o pipefail` is on, and `grep -q` exits the +# moment it matches — so `echo "$out" | grep -q` leaves `echo` writing into a +# closed pipe, takes SIGPIPE, and the pipeline reports 141 even though the +# match SUCCEEDED. It only shows up once `$out` is big enough that echo has not +# finished writing, which for `cache list` means "once this machine has built a +# few things": measured 3-4 failures in 5 runs on a developer box with a warm +# build cache, and zero on CI, with `grep -c` on the same string finding 153 +# matches. A test that fails on the size of an unrelated cache is a test that +# will be read as a regression in whatever happens to be in flight. for cmd in "self env" "cache list"; do out=$($MCPP $cmd --format json 2>/dev/null) || { echo "FAIL: $cmd --format json exited non-zero"; fail=1; } - echo "$out" | grep -q '"schemaVersion"' || { echo "FAIL: $cmd --format json has no schemaVersion"; fail=1; } - echo "$out" | grep -q '"kind"' || { echo "FAIL: $cmd --format json has no kind"; fail=1; } + grep -q '"schemaVersion"' <<<"$out" || { echo "FAIL: $cmd --format json has no schemaVersion"; fail=1; } + grep -q '"kind"' <<<"$out" || { echo "FAIL: $cmd --format json has no kind"; fail=1; } done [[ "$fail" -eq 0 ]] || exit 1 From 8e0039810367c06d2133201d39736736e20b4fbe Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:15:02 +0800 Subject: [PATCH 14/14] =?UTF-8?q?refactor(runtime=5Fvalidation):=20?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E9=82=A3=E4=B8=AA=E4=BB=8E=E6=9D=A5=E6=B2=A1?= =?UTF-8?q?=E4=BA=BA=E8=B0=83=E7=94=A8=E7=9A=84=E6=B1=87=E6=80=BB=E8=B0=93?= =?UTF-8?q?=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `has_proven_mismatch()` 在本轮之前就是死的,我只是把它改名成 `has_blocking_failure()` —— 改名的死代码比死代码更糟。 真正的门在 `ninja_backend` 里逐个产物走,因为它要**指名是哪一个**并打印它的解释。 再放一个「有没有失败」的谓词读同一份数据,就是同一个决策两处推导,而没人调用的 那一处正是会悄悄不再一致的那一处。留一条注释说明为什么这里没有它。 --- src/build/runtime_validation.cppm | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 73ea40af..5a512e73 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -39,15 +39,14 @@ struct ValidatedArtifact { struct ValidationReport { std::vector artifacts; - // Any artifact PROVEN bad — payloads mixed, or a DT_NEEDED that the - // artifact's own loader will not find. Asks the verdict rather than - // enumerating states here, so a fifth state cannot be added without this - // gate deciding what it means. - bool has_blocking_failure() const { - return std::ranges::any_of(artifacts, [](auto const& artifact) { - return artifact.verdict.blocking(); - }); - } + // NOTE: there is deliberately no `has_blocking_failure()` here. + // + // There used to be a `has_proven_mismatch()`, and nothing ever called it — + // the real gate walks the artifacts in `ninja_backend` so it can name WHICH + // one failed and print its explanation. A second predicate that answers + // "did anything fail" from the same data is the same decision in two + // places, and the one with no callers is the one that silently stops + // agreeing. Ask `verdict.blocking()` per artifact. }; struct StoredRuntimeSummary {