Skip to content

feat: 编译器是能力,不是配置 —— payload 版本权威 + specs 污染 + xlings pin (2026.8.8.2) - #378

Merged
speak-agent merged 21 commits into
mainfrom
feat/compiler-as-capability
Aug 8, 2026
Merged

feat: 编译器是能力,不是配置 —— payload 版本权威 + specs 污染 + xlings pin (2026.8.8.2)#378
speak-agent merged 21 commits into
mainfrom
feat/compiler-as-capability

Conversation

@speak-agent

@speak-agent speak-agent commented Aug 8, 2026

Copy link
Copy Markdown
Member

一个 payload 交付两样可分离的东西:编译的能力,以及关于如何链接的主张。mcpp 要前者,后者自己给 —— 链接行才是构建决策该待的地方,因为它是逐次构建而变的那一个。这个 PR 把四个看起来无关的缺陷收敛到这一条上。

设计文档:

  • .agents/docs/2026-08-08-payload-version-and-contract-drift-design.md
  • .agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md(T1–T8)
  • .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md(前作,含 c_runtime 轴的撤销记录)

替代已关闭的 #377


1. 产物加载哪个 glibc,现在有权威,不再靠目录顺序

payload-first 的构建要链接到某个具体的 glibc。旧规则是向目录问「那个 glibc」、取 readdir 吐出的第一项。只装了一个时它永远正确,所以从来没有东西逼它正确

一条带 xim:glibc@>=2.38 的依赖就足以装进第二个。这在 mcpp-index 上真实发生过:编译侧取了 2.44,而产物的 interpreter(装机时冻结在 gcc specs 里)仍指向 2.39;二进制引用 GLIBC_2.42 符号却跑在没有这些符号的运行时上,报错还落在与那条依赖毫无关系的包上

现在按顺序解析:

  1. [xlings] subos = "<name>" —— 该 subos 在自己 .xlings.jsonsubos_info 块里自述 runtime
  2. 活动 subos,同一个块
  3. 兼容路径:更旧的 subos 无法作答,以工具链自身烙入的值(gcc 的 specs / clang 的 cfg)顶上 —— 这正是产物将会加载的那个值,故编译期与运行期仍然一致;一旦 subos 能作答,这条自动退场

没有 binding 是拒绝,不是取默认值:CLibMode::PayloadFirst 会被放弃,而不是去挑一个 libc。

因为 binding 决定产物加载什么,它计入工具链指纹(11 字段,不是 10),只在 runtime 上不同的两次构建不再共用缓存。

2. 不再改写 GCC 的 specs

旧的 specs 重写用单路径 needle 配双路径 replacement,每个跑过它的 home 都会漏下一条。一台开发机产出的每一个 gcc 产物里都带着 68 条陈旧 RUNPATH,全部指向已被删除的 mktemp 目录。没有任何东西发现它们,因为一条死 RUNPATH 只多花一点搜索时间。

改为 -specs= 一份逐构建生成的干净文件:-dumpspecs内建 *link:(不受磁盘上任何文件影响),去掉 loader/rpath 行,写进构建目录。不带前导 +-specs=替换,于是 payload 自己的主张被覆盖,而 payload 分毫未动。两条推论:逐构建,所以同机器上另一个项目不受影响;不需要写权限,所以继承来的、只读的 payload 都能用。

删掉 gcc 烙入的 *link:,也就删掉了它提供的东西。mcpp 因此在链接行上显式补齐 --dynamic-linker 与每一条 rpath(loader、glibc lib 目录、gcc 自己的 lib64 即 libgcc_s)—— 这三条都是把 specs 拿掉后、看什么先坏掉而逐一找出来的。

3. 落后于 pin 的 vendored xlings 会被替换 —— 但只在替换品确实更新时

acquire_xlings_binary 过去见到文件存在就返回,于是一个 home 永远留着它第一次获取的 xlings(本机实测 2026.8.2.1 对 pin 2026.8.6.3,而 mcpp self env 把两个数字并排打印却什么都不说)。subos_info 是 xlings 2026.8.5.1 才有的,所以那台机器上 subos 的自述一直被一个太旧的客户端丢弃 —— #352 的修复无论 mcpp 本身多新都无法生效。缺失的特性天然是静默的:块不存在也是一个合法状态。

修复的第一版直接删了重取,结果把 2026.8.2.1 换成了系统的 0.4.51 —— 更旧,且同样没有那个特性。落后于 pin 值得去找替换品,但不等于该接受找到的任何东西;现在先给替换品定价再动手。跨纪元比较(0.4.x vs YYYY.M.D.N)由测试钉住,因为两套编号同时存在于磁盘上。

4. --sysroot 的判据从「存在」改为「归属」

gcc 把 --sysroot=<...>/.xlings/subos/default字符串烙进去,而一台机器上有很多同名目录 —— 那条烙入的路径经常存在、却属于另一个 checkout。实测:mcpp 里的一次构建解析到了无关仓库下的 sysroot。


顺带修掉的一个自伤

冷构建(--no-cache)会删掉 target/,而生成的 spec 就住在里面 —— 于是每一次 --no-cache + gcc 的构建都以 g++: fatal error: cannot read spec file 失败。修法是把「ninja 跑之前 spec 必须存在」当作不变量在消费点持有,而不是调整两者的顺序(这样也扛得住用户手动 rm -rf target)。

这个缺陷是全量本地 e2e 扫出来的,不是定向子集。 e2e 201 —— 那个专为这套机制存在的测试 —— 只跑暖构建,而暖构建下文件从上一次留着,所以它看不见。现在它也跑冷构建。

验证

单测 66/66(新增 24 条断言:test_toolchain_probe 6、test_xlings_version_pin 5、test_sysroot_ownership 7、test_runtime_binding_fallback 8,test_subos_info 扩充)
本地全量 e2e 186 passed / 6 failed / 8 skipped。6 个失败全在已记录基线内(03/09/20/33/59/98),其中 03/09/20 已逐一用已发布的 2026.8.6.3 复现确认与本 PR 无关;178 反而从基线里转绿。8 个 skip 全是平台能力缺失
版本 2026.8.8.2;xlings pin 2026.8.6.3 → 2026.8.7.1

pin 停在 2026.8.7.1 而不是上游最新的 2026.8.8.1,因为 xim-pkgindex 还没收录后者 —— pin 到索引前面会让每个 CI job 去装一个不存在的版本。

runtime binding 四条来源,逐条验过

来源 怎么验的
[xlings] subos = "<name>" 自述 用 xlings 2026.8.8.1 新建 subos(runtime: glibc@2.39),项目指名它 ⇒ 产物拿 payload loader 并运行
活动 subos 自述 同机制
声明指向不存在的 payload 往自己的 subos 注入 glibc@2.99 ⇒ 被跳过,落到实际装的 2.39
兼容记录(specs / cfg / PT_INTERP) specs 挪开 把 gcc 自身 PT_INTERP 改指宿主 loader ⇒ 仍成立
单例 payload 三条来源全打掉后仍答得出

关于 -specs= 在非原生路径上

初版 PR 写着「只在原生 gcc 上验过,交叉/musl/MinGW 未验」。现在 CI 给了证据:mingw-cross linux→windows(含 wine 真跑)toolchain: musl + llvmcross-build aarch64-linux-muslwindows→linux cross-build 均通过。

其中 musl 那条不是白过的 —— 全量 e2e 抓到我把 glibc payload 的 lib 目录塞进了 musl 链接(undefined reference to _DYNAMIC),已加 is_musl_target 护栏。

… have

All three were found while investigating why a second glibc in the home broke
builds, and each one had sent a reader -- me -- reasoning from a false premise.

find_sibling_tool said "Return the first (highest) version dir". It sorts
nothing; it returns whatever readdir yields. With two versions installed the
answer varies by filesystem.

fixup_gcc_specs said "Idempotent". It is, for one home. Across homes it leaves
one stale rpath entry per run, because the needle is one path and the
replacement is two -- the extra gccLib never appears in a later needle. 68 on
this machine, all gccLib, zero glibc.

The third, in execute.cppm, claimed the run fast path treats a cache without
the subos field as a miss. No such check was ever written; it lands with the
change that implements it.

An attempt to pin the first one with a test is deliberately NOT included, and
the reason is recorded where the test would have been: the assertion passed on
this machine by directory order, which makes passing and failing equally
uninformative. The deterministic replacement is "given glibc@2.39, return
2.39 even with 2.44 present" -- which belongs to the resolver, not to a scan.
…nned

probe_payload_paths asked find_sibling_tool for "the glibc" and took what came
back -- whatever readdir yielded. With one glibc installed that is always
right, so nothing ever forced it to be correct. A dependency with a `>=` floor
installed a second one, and the compile side took 2.44 while the artifact's
interpreter, written into gcc's specs at install time, still named 2.39.
Binaries referenced GLIBC_2.42 symbols against a 2.39 runtime, and the
failures landed on packages with nothing to do with graphics.

The version is now named: `--runtime`, else `[xlings] subos`'s
subos_info.runtime, else the active subos's. The tests assert determinism
rather than preference -- given glibc@2.39 return 2.39 with 2.44 also present,
and a missing version does not fall back to an installed one, because falling
back reintroduces the split silently.

A fourth step was added after measuring what "no authority = refuse" does on
this machine: it refuses. mcpp's own sandbox subos has no subos_info block --
the vendored xlings is 2026.8.2.1 and the block arrived in 2026.8.5.1, and
that binary is never upgraded -- so every existing user's build would break.
The compatibility step reads the binding this toolchain was installed against,
out of gcc's specs or clang's cfg.

That is not the guess this change removes. The guess picked a version by
directory order, unrelated to what the artifact would load; this reads the
version the artifact WILL load, so compile side and run side still agree,
which is the invariant. It is a migration path and it retires itself: once a
subos describes itself, the earlier steps answer first.
The target triple says x86_64-linux-gnu whether the build targets glibc 2.39
or 2.44, so without this the two share `target/<fp>/`. Switching subos would
leave the directory untouched, ninja would replay a graph whose objects were
compiled against the other libc, the link would succeed, and the result would
reference symbols its interpreter cannot provide -- with nothing along the way
saying so.

This is the prerequisite for building one project under two subos, not a
refinement of it, which is why it lands before that feature rather than with
it.

Also fixes a dead reference: the header pointed at
docs/06-toolchain-and-fingerprint.md, which does not exist. The fingerprint is
described in 08-toolchain-internals.md.
mcpp told gcc nothing about the C runtime at link time and let the specs file
it had written at install time answer instead. That made the run side a
per-toolchain-install decision while the compile side stayed per-build, and
the two named different glibc versions as soon as a second one was installed.
mcpp already refuses to depend on clang's install-time cfg for precisely this
reason -- `--no-default-config`, "reproducible builds, no dependence on the
install-time-generated cfg". The reasoning had simply never reached gcc.

Three changes, and each one was forced by the failure the previous one
exposed:

Emitting -Wl,--dynamic-linker and -Wl,-rpath for gcc did not remove what the
specs already inject, because rpath accumulates. So a clean `*link:` is
generated into the build directory from `g++ -dumpspecs` -- which prints the
spec compiled into the binary and is unaffected by the file on disk, so the
original is always recoverable and the payload is never touched.

Replacing `*link:` then removed the addressing the specs used to supply, and
the hermeticity check caught the result: an artifact pointing at the host's
/lib64/ld-linux. Sysroot mode now names the interpreter too. That fixed the
interpreter and left libm unfound, because the same spec carried the rpath;
then libgcc_s, because it carried two entries. The pair mcpp emits is exactly
what the deleted code baked in.

fixup_gcc_specs is gone. Deleting it restores what R6 asks of a payload --
immutable, independent of any home -- and removes the accumulation at its
source: the substitution had a one-path needle and a two-path replacement, so
every home that patched the shared payload left one entry behind. 68 on this
machine, all pointing at deleted mktemp directories, in every gcc artifact it
produced. The e2e asserts on the artifact rather than the specs file, because
what a user ships is the thing that matters.

patchelf_walk stays: making the compiler runnable is a different question from
what the compiler produces, and putting both in one function with one
glibcLibDir is why they looked like one.

One test changed meaning rather than being fixed:
GccPayloadUsesIdirafterAndNoLoader asserted the behaviour this commit removes.
Headers still differ by driver; addressing no longer does.
…pgrade cache

Carried over from the PR this work supersedes; the two defects and their
evidence are unchanged.

2026.8.8.1 shipped a reader for a format that does not exist. `envs` is an
object keyed by binding, the reader expected an array of {binding, decls}, so
is_array() was false, the loop never ran, and against a real subos every
variable came back unset -- LIBGL_DRIVERS_PATH, __EGL_VENDOR_LIBRARY_DIRS,
XDG_DATA_DIRS still the host's. The whole #352 fix was inert.

Ten unit tests and an e2e passed throughout, because every fixture was
hand-written in the same invented shape the parser expected. A fixture
composed from the same understanding as the parser cannot catch a
misunderstanding of the wire format; only one taken from the writer can.
RealXlingsCapture is that -- verbatim output from a real `xlings install
graphics`, reformatted for width and nothing else. Transcribing the parser
from xlings's own reader also caught a second divergence: xlings drops a
declaration whose op is neither "set" nor "prepend", and this accepted any.

Second defect: `mcpp run`'s fast path replayed a cache written before the
subos field existed, so the fix did not survive an upgrade -- and not just for
one run. The fast path's identity is the profile, cache mode and resource
list, and its fingerprint check compares an entry against itself; nothing
notices that a different mcpp wrote it. Measured on a real 2026.8.7.1 ->
2026.8.8.1 upgrade. An empty subosDir cannot stand in for "predates the
field", since a system toolchain legitimately has no subos, so presence is
tracked separately.
…lsewhere

D4. acquire_xlings_binary returned on mere existence, so a home kept the
first xlings it ever acquired. Measured here: 2026.8.2.1 against a pin of
2026.8.6.3, with `mcpp self env` printing both numbers beside each other and
saying nothing about the gap. Not cosmetic -- the subos_info block arrived in
xlings 2026.8.5.1, so on that machine every subos declaration was discarded by
a client too old to have the API, and the mcpp#352 fix could not take effect
however current mcpp itself was. Missing features are silent by construction:
an absent block is also a legitimate state.

The first cut of this deleted the vendored binary and re-acquired. That
replaced 2026.8.2.1 with the system's 0.4.51 -- older still, and missing the
very feature the replacement existed to restore. Being behind the pin
justifies looking for a replacement; it does not justify taking whatever
turns up. candidate_source_version() now prices the replacement before the
old one is removed, and the epoch-crossing comparison (0.4.x vs YYYY.M.D.N)
is pinned by a test, since both schemes live on the same disk.

D5. remap_xlings_baked_sysroot asked whether gcc's baked sysroot exists. Wrong
axis: gcc records `--sysroot=<...>/.xlings/subos/default` as a string when it
is built and keeps reporting it in every project it later serves, and a
machine with several checkouts has many directories by that name. The baked
path therefore routinely exists and belongs to someone else -- a build inside
mcpp resolved a sysroot under an unrelated repo. The predicate is now
ownership, not existence.

doctor reports both. The sysroot finding is anchored on the mcpp home and the
project rather than on the compiler's own tree: written the obvious way it
consulted xpkgs_from_compiler(binaryPath), which returns nothing when the
compiler is reached through an xvm shim -- the default project-local case, and
this machine's. It would have shipped as a check that never checked.

  tests/unit/test_xlings_version_pin.cpp     +1 (epoch crossing)
  tests/unit/test_sysroot_ownership.cpp      +7 (new; no filesystem, on purpose)
….8.8.2)

08-toolchain-internals had two sections that stopped being true when the
specs rewrite was removed. §4's gcc row still promised a specs rewrite; §5
described the clang cfg as if bypassing an install-time configuration were a
clang-specific accommodation rather than the rule both compilers now follow.
Rewritten as one policy with two mechanisms (`--no-default-config` for clang,
a generated `-specs=` for gcc), including what removing gcc's baked `*link:`
takes away and therefore what mcpp has to put back.

New §2.1 documents where the runtime binding comes from, since it is now a
resolution with an authority rather than a directory scan, and since "no
binding declines payload-first" is surprising unless the reason is written
down. 05-mcpp-toml notes that `[xlings] subos` picks the runtime too -- it
was already a key, but it did not previously decide this.

CHANGELOG gains 2026.8.8.2 and, separately, 2026.8.8.1, which shipped without
an entry.

xlings pin 2026.8.6.3 -> 2026.8.7.1: upstream is at 2026.8.8.1, but
xim-pkgindex does not carry it yet and pinning ahead of the index makes every
CI job install a version that is not there.
The generated `*link:` spec is written to the output directory, and
run_build_plan deletes that directory on a cold build. prepare writes the
file before that point, so every `--no-cache` build with gcc reached ninja
with a link command naming a file that had just been removed:

  g++: fatal error: cannot read spec file '.../mcpp-clean-link.specs'

Regenerating after the wipe rather than reordering the two. The property
worth holding is "the spec exists when ninja runs"; stated as an invariant it
also survives a user's `rm -rf target`, which reordering would not. The write
is idempotent, so the warm path pays one stat.

e2e 201 only ever built warm, where the file survives from the previous run --
so the test that exists precisely to guard this mechanism could not see it.
It now builds cold as well. Found by the full local e2e sweep (100_cppfly_*),
not by the targeted subset.
…leted

Three defects, all found by CI in configurations this developer machine does
not have. Each is the same shape: a repair placed where the control flow, or
the machine state, does not reach it.

1. baked_runtime_binding read gcc's `specs` and clang's `.cfg` -- files mcpp
   used to write and no longer does. On a machine whose toolchain was
   installed after that change they are simply absent, so no binding resolved,
   so no payload paths, so no `--dynamic-linker`, so the artifact took the
   HOST loader and the hermetic check rejected it:

     /lib64/ld-linux-x86-64.so.2 (outside the sandbox)

   Every existing machine still has those files from before, which is exactly
   why local verification was green and CI was not. The binding now also comes
   from the compiler's own PT_INTERP -- written by the patchelf walk, which
   still runs on every install, and naming the same glibc payload the specs
   used to name. Read with a small ELF header reader rather than
   `patchelf --print-interpreter`: this runs during prepare, where patchelf is
   not guaranteed to be resolved.

   Verified by moving the specs file aside and rebuilding: binding still
   resolves, artifact still takes the payload loader.

2. probe_sysroot accepted gcc's reported sysroot as soon as it existed and
   carried headers, and only consulted remap_xlings_baked_sysroot when it did
   not exist. So the ownership predicate added for that function -- the whole
   point of which is a sysroot that EXISTS and belongs to someone else -- was
   never reached in the case it was written for. Measured on this machine: gcc
   reported a sysroot under an unrelated repo and every build took its headers.
   Ownership is now asked first; a foreign-but-usable sysroot remains the last
   resort, since taking nothing would break machines with no registry subos.

3. `rel.native().rfind("..", 0)` does not compile on Windows, where native()
   is a wstring -- every Windows job failed to build. It was also subtly wrong
   where it did compile: a directory named `..cache` is not an escape.
   Containment is a question about path components, so path_is_under asks it
   of components.

Also removes a duplicated flag group: the C-runtime flags were emitted both as
link_toolchain_flags and again as payload_ld for gcc. Correct but wasteful,
and the link line has a hard 128KiB ceiling that real workspaces already spend
43% of.

  gcc with specs / gcc without specs / llvm: payload interpreter, exactly one
  --dynamic-linker, program runs.
  65 unit tests, e2e 201/86/200/65/100/28/30.
…rected

The doc lived only on the branch of the closed PR #377, so every reference to
it from code comments, docs, and the implementation plan pointed at a file the
repository did not contain.

New §9 records what landing it changed about the design itself. §9.1 is the
one that matters: the compatibility fallback described in §3.5 reads gcc's
specs and clang's cfg -- files the same change stops writing. On an existing
machine they are still there; on a fresh install they are not, and nothing
downstream says so. That asymmetry is why local verification was green while
CI was not, and it generalises past this change: when removing a mechanism,
enumerate who reads its output.
CI still had no runtime binding, and therefore no loader on the link line:

  -o bin/mcpp --sysroot=<...>/subos/default -B<...>/binutils/bin -static-libstdc++

Every compatibility source is something an earlier mechanism wrote -- gcc's
specs, clang's cfg, and now the compiler's PT_INTERP -- and a machine can
legitimately have none of them. Recorded loader paths can also name a subos
VIEW rather than the payload; those carry no version at all, so they are
canonicalised first (R6: artifacts bind the payload, never the view).

The last resort is the installed payload set, and only when it is a singleton.
This is not the rule the design removed: that one asked a directory for "the
glibc" and took whatever readdir yielded first -- a choice, made by something
unrelated to what the artifact loads, silently wrong as soon as a dependency's
`>=` floor installed a second payload. Where exactly one glibc exists there is
no choice to make; it is the only runtime any artifact from this toolchain
could bind. Two or more and this stays silent, which is precisely the case the
incident was and the case the subos has to settle.

Verified under conditions this machine had to be forced into: gcc's specs file
moved aside AND gcc's own PT_INTERP repointed at the host loader. The binding
still resolves and the artifact still takes the payload loader.

  tests/unit/test_runtime_binding_fallback.cpp  +5
§9.6. The design's rule -- no authority, no payload-first -- assumed a machine
always has at least one of the compatibility sources. All three are artifacts
some earlier mechanism wrote, and a machine can legitimately have none, at
which point refusing hands the artifact the HOST loader: worse than guessing a
version, because it leaves the sandbox entirely.

The axis is whether there is anything to choose between, not whether mcpp may
look. One payload is an answer; two are the incident.
@speak-agent

Copy link
Copy Markdown
Member Author

三轮 CI 抓出的东西(全部已修)

本地 66 个单测 + 定向 e2e 全绿的情况下,CI 仍抓出 5 个缺陷。它们有同一个形状:修补被放在控制流、或本机才有的状态,到不了的地方。记在这里,因为形状比缺陷本身更值得看。

1. 兼容回落读的是「我刚删掉的机制的产物」

方案 §3.5 的兼容路径是「subos 不自述时,读工具链自身烙入的值 —— gcc 的 specs、clang 的 .cfg」。

那些文件是旧 fixup 写出来的。本 PR 让 mcpp 不再写它们 ⇒ 全新安装的机器上根本没有:

无 binding → 无 payloadPaths → 无 --dynamic-linker → 产物拿宿主 loader
error: hermetic link check failed
       /lib64/ld-linux-x86-64.so.2 (outside the sandbox)

每台开发机都还留着那些文件,所以本机永远绿。 这条该写成通用判据:删掉一个机制时,先列出谁在读它的产物。

2. 「没有权威就拒绝」收得过紧

补了第三个来源(编译器自身 PT_INTERP,由仍在运行的 patchelf 走查写入)后 CI 依然红 —— 三条来源可以同时都不存在。

于是重新想清楚轴在哪:不是「mcpp 能不能去看 payload 目录」,而是「有没有得选」

  • 恰好一个 glibc payload ⇒ 没有选择可言,它是唯一可能被绑定的运行时,拒答等于拒答一个只有一个答案的问题
  • 两个或以上 ⇒ 沉默。这正是事故的形状,必须由 subos 定

与被移除的旧规则的区别是决定性的:旧规则在有得选时按 readdir 顺序选。

验证方式是把三条来源全部打掉:specs 挪开 gcc 自身 PT_INTERP 改指宿主 loader。binding 仍解析得出,产物仍拿 payload loader。

3. 归属谓词加在了一个到不了的函数里

D5 把 remap_xlings_baked_sysroot 的判据从「存在」改成「归属」。但调用方 probe_sysroot 第一步就是「存在且可用 ⇒ 直接 return」,只有路径不存在时才往下走到 remap —— 而该函数存在的全部意义,正是处理「存在、可用、却属于别人」。

本机实测:这个仓库的 gcc 报的 sysroot 指向 xim-pkgindex-fromsource 下的 .xlings/subos/default,每次构建都在吃另一个仓库的头文件

4. rel.native().rfind("..", 0) 在 Windows 编译不过

native()wstring,所有 Windows job 直接挂。而且它在能编译的地方语义也错:名叫 ..cache 的目录不是逃逸。包含关系是路径分量的问题。

5. 冷构建删掉了自己即将传给 gcc 的 spec

--no-cacheremove_all(outputDir),而 spec 是 prepare 期写进那个目录的 ⇒ 每次冷构建 + gcc 必挂。修法是把「ninja 跑之前该文件必须存在」当不变量在消费点持有,而不是调顺序(也扛得住用户 rm -rf target)。

值得记的是它怎么被发现的:e2e 201 是专为这套机制写的测试,但它只跑暖构建,而暖构建下文件从上一次留着。是全量本地 e2e 扫出来的,不是定向子集。 现在它也跑冷构建。

附带

本地基线

03_multi_module.sh / 09_path_dependency.sh已发布的 2026.8.6.3 上一样失败,与本 PR 无关。

…ority

CI's log gave the answer directly:

  probe: runtime 'glibc@2.39' is not installed in this home

The machine had one glibc payload, 2.44, and a compatibility record naming
2.39. specs, cfg and PT_INTERP are all RECORDS of a past state -- written at
some install, some fixup -- and nothing revisits them when the payload
underneath is replaced. The probe matches exactly and never falls back, quite
deliberately, so it refused; no payload paths resolved; no --dynamic-linker
reached the link line; the artifact took the host loader and the hermetic
check rejected it.

Each record is now checked against the payloads actually installed, and a
record naming an absent one is skipped rather than trusted. Where two payloads
exist and a record names one of them, that still wins -- it is the value the
artifact would load. Where nothing present is named, silence.

Two more, both from the full local e2e rather than the targeted subset:

- musl targets were getting the glibc payload's lib dir and loader. The
  sysroot branch pushed them unconditionally, but a musl sysroot is
  self-contained and the glibc payload there belongs to the HOST toolchain,
  merely probed alongside. ld then pulled glibc's static libc.a into a musl
  link: `undefined reference to _DYNAMIC` out of dl-reloc-static-pie.o
  (e2e 103). The musl guard the header branch already had now covers
  libraries and the loader too.

- The new "vendored xlings is behind the pin" note went to stdout, and
  `mcpp test --json` promises every stdout line is NDJSON (e2e 155). It is a
  remark about the environment, not output of the command; stderr.

  tests/unit/test_runtime_binding_fallback.cpp  +3 (8 total)
  66 unit tests; e2e 103/155/184/201/86/28/30 pass.
§9.7-9.8. Two more corrections to the design's refusal rule, both from CI.

The compatibility sources are records of a past state, and nothing revisits
them when the payload underneath is replaced -- so 'exact match, never fall
back' correctly refused a record naming a glibc that had been upgraded away,
and the artifact took the host loader. §9.6 said 'no choice is not a guess';
§9.7 says 'a fossil is not an authority'. Both narrow where refusing applies:
it belongs to genuine ambiguity, nothing else.

§9.8 records the musl guard the sysroot branch was missing.
The stale value was not a fossil after all -- it was the subos's own
`subos_info`, saying `glibc@2.39` on a machine whose only payload is 2.44. The
subos description is written once; the payloads beneath it are replaced
independently. Same staleness, a layer up, and the previous commit only
guarded the layer below.

Two things kept it alive:

  1. The declaration was returned unchecked. Honouring it is not the
     conservative choice -- the exact-match probe finds nothing, no loader
     reaches the link line, and the artifact lands on the HOST loader, outside
     the sandbox entirely. Worse than any substitution.

  2. The second resolution pass was gated on `runtimeBinding.empty()`. A stale
     declaration is not empty, so the pass that has the compiler in hand --
     the only one that can check whether the payload exists -- never ran.
     The gate is now `!tc->payloadPaths`, which is what both failures
     (undiscoverable binding, uninstallable binding) actually look like.

Reproduced locally by injecting `runtime: glibc@2.99` into this machine's
subos: the declaration is passed over, resolution carries on to the 2.39 that
is installed, and the artifact takes the payload loader and runs.

  66 unit tests; e2e 201/86/200/103/155.
…oject

`cannot read spec file '/tmp/tmp.XXX/my'` for a project in `my project`
(e2e 179, on CI). escape_path adds ninja's `$` escapes and nothing else, but
ninja hands the command to `sh -c`, so a space still splits the word.

Quoted the way include_token quotes: join the prefix, then escape and quote
the whole token -- quoting only the path would put the opening quote in the
wrong place and re-split exactly what the join was for.

Every other payload flag survives a spaced path by luck rather than design:
they name directories under MCPP_HOME, and this is the only one that lives
under the PROJECT. (A spaced MCPP_HOME would break `--sysroot=` and `-B` the
same way, uniformly, and did before this branch.)

Locally 179 passes either way -- this machine resolves llvm for it, which
emits no specs at all. Verified by building gcc explicitly in a directory with
a space: artifact takes the payload loader and runs, and the ninja token comes
out `'-specs=/tmp/.../my$ project/...'`.

Also gates RuntimeBindingFallback.RecordNamingAPresentPayloadWins to POSIX.
detect_baked_loader parses GCC specs, whose loader paths are absolute and
POSIX, and its character whitelist deliberately excludes `\` and `:` so a
`%{...}` spec body is never swallowed -- so a `C:\Users\...` fixture cannot be
spelled at all. The other cases exercise the payload set, not the parser, and
keep running everywhere.
`echo "first-run binary did not run"; exit 1` reports the one thing already
known and none of the things that would explain it. An ELF fails to start for
a short, enumerable set of reasons -- a missing interpreter, an unresolvable
RUNPATH entry, a symbol the runtime does not have -- and which one it is IS
the answer.

29 fails on CI and passes on every local run here, and after three rounds of
reading code I still cannot say why, because the assertion discards the
process's own account of itself. It now prints the exit status and output, the
PT_INTERP and whether it exists, each RUNPATH entry and whether it exists, and
ldd.

No behaviour change; the test passes locally exactly as before.
The diagnostic added in the previous commit answered it on the first run:

  error while loading shared libraries: libgcc_s.so.1
  RUNPATH: <home>/data/xpkgs/xim-x-glibc/2.44/lib64      <- one entry
  ldd:     libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1

The compiler's own runtime lives beside the compiler, not in the C library,
and a produced binary links it whether or not the build mentions it. gcc's
patched specs used to put that directory on the artifact; removing the specs
rewrite has to put it back, and only the Sysroot path did. One helper now
serves both modes.

Invisible where it was written: on any machine with a host toolchain the
artifact resolves libgcc_s.so.1 from /lib and runs. It failed only in a
throw-away home on CI.

The guard is a unit test, not an e2e. Which link mode an e2e reaches depends
on whether the machine happens to have a usable sysroot -- this one does, so a
PayloadFirst leg written for exactly this defect quietly ran Sysroot twice and
passed with the bug in place. Verified by removing the fix: the unit test goes
red, the e2e leg does not. The link model takes its mode from its input, so a
test can name the mode instead of hoping for it.

e2e 201 also now asserts that no artifact loads a library from /lib or
/usr/lib at all. "Does it run" was the wrong question -- it is answerable yes
on every developer machine while the artifact is quietly reaching outside the
sandbox.

  tests/unit/test_link_model_runtime_dirs.cpp  +4 (67 unit tests total)
… installed

mcpp.toml's `[toolchain] default` went from gcc@16.1.0 to llvm@22.1.8 in the
previous commit. Not deliberate -- a local experiment wrote it and `git add -A`
carried it along. The whole repository then built with llvm, and five CI jobs
failed on assertions that name gcc:

  toolchain: gcc (cold self-host)     grep -q "Resolved gcc@16.1.0"
  toolchain: musl + llvm              sed expects `default = "gcc@16.1.0"`
  integration / e2e 1-2 (linux)       `gcc@16.1.0 is not installed`

Verified back to exactly one difference from main: the version.

The e2e job's failure had a second cause worth fixing on its own. It runs
`toolchain default gcc@16.1.0` without ever installing gcc, so it depends on
the sandbox cache happening to carry one from an earlier run. That is not a
property any job should rely on, and this branch's xlings pin bump moved the
whole cache-key prefix, so every shard started cold and hit it. Now installed
explicitly; warm runs pay a lookup.

  67 unit tests; e2e 201/86/29/103/179/200 under the restored gcc default.
The host-library check I added a commit ago failed on CI for a dependency the
artifact does not have:

  /home/runner/.mcpp/.../xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2
      => /lib64/ld-linux-x86-64.so.2

ldd resolves the program interpreter by running the host's loader, so it
always reports the payload interpreter as resolving to the host's. That says
nothing about what the artifact loads at runtime -- the PT_INTERP assertion
above already covers the interpreter, and it passed.

A real dependency has a bare soname on the left; the interpreter line has an
absolute path. The predicate now requires the former, and is self-checked
against all three line shapes before use.

Worth stating plainly: this is a check that would have blocked correct work
while claiming a defect. That is the opposite failure from the ones this
branch has been chasing, and no less expensive.
@speak-agent
speak-agent merged commit fdad165 into main Aug 8, 2026
18 checks passed
speak-agent added a commit that referenced this pull request Aug 8, 2026
35 项全部未勾选,而 T1–T7 随 #378 合入并发布 2026.8.8.2、T8 随 mcpp-index #181 合入。一份 committed 的文档读起来像什么都没做,比没有文档更坏。

同时在文首写明两件没随本计划完成的:#352 的 GLX vendor 选择(根因已定位、修法已实验验证,待在 xim:nvidia-gl-host-link 实施),以及约定为独立 PR 的 `glibc>=X` 下限语义。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant