feat: 编译器是能力,不是配置 —— payload 版本权威 + specs 污染 + xlings pin (2026.8.8.2) - #378
Conversation
… 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.
三轮 CI 抓出的东西(全部已修)本地 66 个单测 + 定向 e2e 全绿的情况下,CI 仍抓出 5 个缺陷。它们有同一个形状:修补被放在控制流、或本机才有的状态,到不了的地方。记在这里,因为形状比缺陷本身更值得看。 1. 兼容回落读的是「我刚删掉的机制的产物」方案 §3.5 的兼容路径是「subos 不自述时,读工具链自身烙入的值 —— gcc 的 那些文件是旧 fixup 写出来的。本 PR 让 mcpp 不再写它们 ⇒ 全新安装的机器上根本没有: 每台开发机都还留着那些文件,所以本机永远绿。 这条该写成通用判据:删掉一个机制时,先列出谁在读它的产物。 2. 「没有权威就拒绝」收得过紧补了第三个来源(编译器自身 于是重新想清楚轴在哪:不是「mcpp 能不能去看 payload 目录」,而是「有没有得选」。
与被移除的旧规则的区别是决定性的:旧规则在有得选时按 验证方式是把三条来源全部打掉: 3. 归属谓词加在了一个到不了的函数里D5 把 本机实测:这个仓库的 gcc 报的 sysroot 指向 4.
|
…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.
一个 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符号却跑在没有这些符号的运行时上,报错还落在与那条依赖毫无关系的包上。现在按顺序解析:
[xlings] subos = "<name>"—— 该 subos 在自己.xlings.json的subos_info块里自述 runtime没有 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.xvsYYYY.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 —— 那个专为这套机制存在的测试 —— 只跑暖构建,而暖构建下文件从上一次留着,所以它看不见。现在它也跑冷构建。
验证
test_toolchain_probe6、test_xlings_version_pin5、test_sysroot_ownership7、test_runtime_binding_fallback8,test_subos_info扩充)178反而从基线里转绿。8 个 skip 全是平台能力缺失pin 停在 2026.8.7.1 而不是上游最新的 2026.8.8.1,因为 xim-pkgindex 还没收录后者 —— pin 到索引前面会让每个 CI job 去装一个不存在的版本。
runtime binding 四条来源,逐条验过
[xlings] subos = "<name>"自述runtime: glibc@2.39),项目指名它 ⇒ 产物拿 payload loader 并运行glibc@2.99⇒ 被跳过,落到实际装的 2.39specs挪开 且 把 gcc 自身PT_INTERP改指宿主 loader ⇒ 仍成立关于
-specs=在非原生路径上初版 PR 写着「只在原生 gcc 上验过,交叉/musl/MinGW 未验」。现在 CI 给了证据:
mingw-cross linux→windows(含 wine 真跑)、toolchain: musl + llvm、cross-build aarch64-linux-musl、windows→linux cross-build均通过。其中 musl 那条不是白过的 —— 全量 e2e 抓到我把 glibc payload 的 lib 目录塞进了 musl 链接(
undefined reference to _DYNAMIC),已加is_musl_target护栏。