设计:
2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md分支:feat/runtime-search-closure-and-binding-degradation基线:main1bc6074(2026.8.11.1)。单 PR、一档全发,版本2026.8.11.2。实施状态:已完成。 实施过程中改动本计划的四处,均在对应小节以 【实施修正】 标出 —— 每一处都是被实测推翻的,不是改主意。 落地后 CI 与自查又推翻三处,见 §12。
三个及以上的读写方共享同一条规则 ⇒ 独占一个模块。这是本仓库反复付学费后立下的形状
(loader_contract / graph_shape / host_requirements 都是这么来的)。
| 新模块 | 协议内容 | 谁写 | 谁读 |
|---|---|---|---|
src/platform/runtime_search.cppmmcpp.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 表达 |
export module mcpp.platform.runtime_search;
import std;
export namespace mcpp::platform::search {
// 一条运行期搜索目录的来源。次序与"是否可分发"都由它决定,
// 调用方不再各自推导。
enum class Origin {
Payload, // 不可变载荷目录(<store>/xim-x-glibc/2.39/lib64)
Package, // 包描述符声明的 runtime 目录
SubosFarm, // subos 符号链接农场(<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<Dir> ordered(std::vector<Dir> dirs);
}rank 的理由写进注释,因为它是本轮唯一的不变式:
载荷目录不可变(装一次不再动),farm 每次
xlings install都重写符号链接。 载荷在前 ⇒ libc / libm / libstdc++ 永远从被 pin 的载荷解析,farm 只补没人提供的。 farm 在前 ⇒ 一次安装能在事后悄悄换掉一个已经构建好的产物的 libc。
struct RuntimeBinding {
…
bool declared = false; // subos 是否自我描述(subos_info 块存在)
std::string note; // 降级原因;非空则调用方必须呈现
std::vector<std::filesystem::path> searchDirs; // farm 视图目录(可变)
};libraryDirs(载荷,不可变)与 searchDirs(farm,可变)必须是两个字段 ——
合成一个就把 rank 的信息丢了,而 rank 是本轮的全部。
| 情况 | 今天 | 改为 |
|---|---|---|
| 点名的 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 并说明
是因为没有声明,而不是因为查过了。
今天 {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) 内。
search_dirs、declared、note进 JSON。searchDirs+declared进canonical_contract⇒ contract hash 变 ⇒ farm 变化会正确地让快路径与校验缓存失效。这会让所有既有缓存失效一次,是预期的。deserialize的完整性检查放宽:declared=false的 binding 允许runtimeId为空 (今天schema==0 || runtimeId.empty()直接判 incomplete)。
// 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)保持原状:那里宿主目录确实是默认值。
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的三分支补第四支。
原计划让 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<search::Dir>,
全部四种 origin 的有序记录),farm 的唯一消费者是 flags.cppm 渲染的
-Wl,-rpath 尾巴。per-object 可达,绝不 per-process。
plan.toolchain.linkRuntimeDirs 只有 clang 会填(clang.cppm:142)。
GCC 的载荷 -rpath 来自链接模型(lm.libDirs)。第一版记录出来只有一条
farm,而产物 DT_RPATH 有三条 —— 记录与产物不一致,正是这份设计要消灭的形状。
改为向发出它们的同一个函数要:resolve_link_model(plan.toolchain).libDirs
(纯函数,可在 plan 层调用),再叠 linkRuntimeDirs,顺序与 flags.cppm 的拼接一致。
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 覆盖,这里只补运行期,还省下链接行长度。
| 护栏 | 判据 |
|---|---|
| 交叉目标 | targetTriple 非空且(os != "linux" 或 arch != binding.arch)⇒ 不发 |
| 非 ELF | elfTarget == false ⇒ 不发(与 loader_tag_flag 同一个判据,复用) |
原计划断言「Mode::None 是唯一不重写 rpath 的档」。实测推翻:
$ 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" <bundle>/ ← 无命中pack.cppm:718 对 Mode::None 把每个依赖都标 skip ⇒ toBundle 空 ⇒
rpath = "" ⇒ set_search_path 整体清空。这一项从"实现"变成"补测试"。
「从 farm 解析到的 NEEDED 升级为 host requirement」也不做:
HOST-REQUIREMENTS 存在的理由是记录产物本身看不出来的东西(经 dlopen 链到达
的驱动),而 NEEDED 本来就写在产物里 —— 再抄一遍是冗余,还会污染
mcpp publish 对 [runtime].requirements 的投影。
STORE="$MCPP_HOME/registry/data/xpkgs" # ← 今天只到这里
MACHINE_LOCAL="$MCPP_HOME" # ← farm 在 registry/subos/…,不在 store 下并补 --mode system 的用例 —— 今天 215 只跑默认 vendored 档。
mcpp 在驱动 ninja 之前把它设进自己的进程环境(子进程继承 ⇒ 覆盖 ninja / 驱动 / ld), 不进 ninja 命令行(链接行有 128KiB 上限)。
- 今天 = 无操作(xlings 还没读它);
- xlings E2b 落地当天自动生效,mcpp 的 DT_RPATH 仍只含 mcpp 决定的内容;
- 键名与语义在
runtime_search.cppm里以常量声明一次,不散落。
| 载体 | 补什么 | 实际落地 |
|---|---|---|
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 那一支判决 |
| 文件 | 断言 |
|---|---|
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 |
| # | 文件 | 断言 | 防空转 |
|---|---|---|---|
| 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 正是它要防的回归。
| 文件 | 改什么 |
|---|---|
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 号 |
mcpp.toml[package].version+src/version.cppmMCPP_VERSION→2026.8.11.2(同一 commit)src/xlings.cppmkXlingsVersion→ 最新 xlings(实施时以xlings --version/ 索引为准).xlings.json的 bootstrap pin 本 PR 不动(发布并进索引后才前移)bash .github/tools/check_version_pins.sh必须过
唯一不可交换:M3 在 M4 之前。 先加路径再补判据 = 在不会响的报警器上加功能。
M1 契约模块 → M2 binding(降级+farm) → M3 闭包判据 → M4 链接期 → M5 pack → M6 退出声明 → M7 观测 → 测试 → 文档 → 版本
M2 的降级半边(§2.2)与图形无关,是正在阻塞 Windows 用户的回归 —— 它在同一个 PR 里, 但提交上独立成一个 commit,以便必要时单独 cherry-pick。
这一节是这轮最有价值的部分:每一条都是「可证」被用得太宽,而这份设计本身 就是在修同一种病。方向对不代表范围对。
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 …
三层错误叠在一起,每一层单独看都像对的:
- binding 是宿主的(Linux/glibc/私有加载器 ⇒
hermetic()为真),产物却是 PE。 产物的格式由产物决定,不由 binding 决定。 resolution.unresolved是个混装袋:「找不到的 SONAME」「读不了的对象」 「512 上限」同住一个 vector。只有第一种可证;后两种是关于检查本身的陈述, 而没能看的检查什么都没证明。⇒ 拆出unresolvedSonames,升级只看它。- 于是「这不是 ELF」被读成了「你缺一个库」,报给一个连
DT_NEEDED都没有的文件。
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 : <subos>/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 并指名,不阻断。一条声明,一个含义。
| 处 | 问题 | 修法 |
|---|---|---|
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/ 下零消费者 |
删掉。导出面只有测试在撑,就是没有理由存在的导出面 |
unresolved这种「混装袋」是这轮所有误判的共同结构。 一个 vector 里装了三种不同强度的事实,而消费者只能按最强的那种去解读它。 拆开之后,每条判断的证据强度就写在类型上了。