Skip to content

feat: xlings 作为运行时底座 —— subos 环境到达程序 + self-contained 的 /proc/self/exe 陷阱 (#352, #375) (2026.8.8.1) - #376

Merged
speak-agent merged 10 commits into
mainfrom
feat/xlings-runtime-substrate
Aug 7, 2026
Merged

feat: xlings 作为运行时底座 —— subos 环境到达程序 + self-contained 的 /proc/self/exe 陷阱 (#352, #375) (2026.8.8.1)#376
speak-agent merged 10 commits into
mainfrom
feat/xlings-runtime-substrate

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

起点与一次方向修正

调研起点是 #375(产物不可分发)与 #352(GL 程序 exit 255)。

这个 PR 的第一版做错了,值得先说。 #375 的标题是「让要分发的应用链到系统 libc」——那是提报者已经想好的解法,他的问题是「产物没法分发」。第一版照着标题做,加了一条 [build] c_runtime 轴,其中 host-coupled = 把产物链到宿主 libc。它实现完毕、12+7 个单测与一条五断言 e2e 全绿,然后被整条撤销:

  • xlings 是用户态发行版,其 hermetic 策略禁止穿越的第一条就是 /usr/lib* /lib* 下的 .so,libc 在内。mcpp 能不用 host 就不用 host。
  • 去掉那个值后这条轴不剩任何能力(self-contained 已由 --target …-musl 表达,toolchain-coupled 是现状)
  • 它会造出「可不可以用宿主 libc」的第二个回答者——既有的是 [build] allow_host_libs

保留在设计文档 §5.2 作为被否方案而不是删掉:它通过了全部测试,而测试全绿不能告诉你一个功能不该存在;下一个读 #375 的人会有同样的想法。

实际改了什么

1. pack --mode self-contained 打坏 /proc/self/exe,而且从没说过

这是调研里最出乎意料的一条。#375 描述的第三条症状读起来像用户自己的绕法,但 mcpp 自己的 self-contained 模式生产的就是那个 wrapper:

exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myapp" "$@"

文档解释了 ELF 规范为何强制这种启动方式,却从没写它的后果:内核把 /proc/self/exe 指向 loader,所有「在 exe 旁边找资源」的逻辑静默失效——字体、assets/、随包分发的辅助二进制。全仓 grep 该陷阱零处提及,而 mcpp 自己(platform/fs.cppm)就靠它定位自身。

ELF 的约束去不掉,能去掉的是沉默:wrapper 现在导出 MCPP_BUNDLE_DIR,两个入口都带(它们被文档为可互换)。改不了的应用被指向 --mode vendored

2. mcpp 程序拿不到 subos 声明的环境(#352)

一个程序需要三样:链接(bootstrap)、找到库(RPATH)、被告知运行期数据在哪(env)。mcpp 供了前两样,第三样什么都没有。xlings 的图形包把 LIBGL_DRIVERS_PATH 之类声明进 subos,xlings subos use 会应用,mcpp run 不会——所以一个 GLFW 二进制链接干净、exit 255、零输出。

xlings 侧 subos/manifest.cppm 的注释直接点名了这件事:「That is the gap behind #352」。而实测 mcpp 自己的 sandbox subos:356 个 workspace 条目,subos_info

新增 mcpp.xlings.subos_info 读取并解析该块,mcpp run / mcpp test 应用其结果。

mcpp 不知道这些变量是什么意思,这是设计而非疏漏——e2e 特意用一个与图形无关的探针变量,因为一旦 mcpp 里出现 LIBGL_DRIVERS_PATH 这个字符串,图形栈就有了两个主人,xlings 下次加 Vulkan loader 就会变成一次 mcpp 改代码。

3. 文档:三条分发路径,没有一条用宿主 libc

方式 C 运行时来自
A mcpp emit xpkgxlings install 目标机自己的 xlings 载荷(装机期重指 ELF)
B --target x86_64-linux-musl 不来自任何地方,已链进去
C mcpp pack --mode self-contained 随 bundle 分发

缺的从来不是机制,是这一页开头只写「用 mcpp pack」、从不说还有什么。A 那一条专门写明了 #375 的直接答案:烙进去的 PT_INTERP 指向构建机路径不是产物的缺陷,是「手工拷贝」这个动作的性质——经 xlings 安装时 ELF 会被重指。不说清这点,下一个读者就会认定产物坏了,然后伸手去拿宿主 loader「修」它。

验证

  • 单测 62 passed / 0 failed,含新增 test_subos_info(10 个断言组:wire format、${subosdir} 展开、prepend/set 语义与去重、缺块/新 schema/坏 JSON 三种降级、family 映射逐行)
  • e2e:30_pack_modes(加了 MCPP_BUNDLE_DIR 两个入口断言)、新增 195_subos_env_reaches_program、以及 86_llvm_hermetic_link 74_run_no_loader_env_leak 166_run_env_no_private_glibc 06_emit_xpkg 28_target_static —— 7/7 PASS
  • 新 e2e 对改动前的二进制实测为(PROBE=(unset)),断言有效

尚未做(有意)

设计:.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md
计划:.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md

Refs #375, #352

…said so

Launching through the bundled loader makes the kernel set /proc/self/exe to
the loader. Every "find my resources next to the executable" path then
resolves against lib/ -- silently. mcpp#375 reported this as a user's own
workaround; it is in fact what mcpp's own self-contained mode produces, and
the docs explained the ELF constraint that forces the launch without ever
stating its consequence.

The ELF spec forbids $ORIGIN in PT_INTERP, so the launch itself cannot go
away. What can is the silence: the wrapper now exports MCPP_BUNDLE_DIR, and
both entry points carry it because they are documented as interchangeable.
Applications that cannot be changed are pointed at --mode vendored, where
PT_INTERP is the host loader and /proc/self/exe is correct.
… not their problem

#375 is titled "let the app link against the system libc". That is the
reporter's SOLUTION. Their problem is that the artifact cannot be distributed.
The design took the title at face value and proposed a [build] c_runtime axis
whose one new capability was host-coupled -- linking the artifact against the
host's libc.

That crosses the boundary this ecosystem exists to hold. xlings is a userspace
distribution and its hermetic policy names, first on the forbidden list, any
.so under /usr/lib* or /lib* -- libc included. mcpp should not use the host
when it does not have to.

Removing that one value leaves the axis with nothing: self-contained is
already spelled --target x86_64-linux-musl, and toolchain-coupled is today's
behaviour. It would also have created a second answerer for "may I use the
host's libc", which [build] allow_host_libs already owns.

The problem has three hermetic answers and all three already exist: emit xpkg
into the ecosystem (xlings repoints the binary at install time), a static musl
target, or pack --mode self-contained. Two of them work today; the third was
broken, and that is what the previous commit fixed. What was missing was never
a mechanism -- it was that none of the three is discoverable.

The axis is kept in the document as a rejected option rather than deleted. It
had passed 12 contract tests, 7 renderer tests and a five-assertion e2e; green
tests cannot tell you a feature should not exist, and the next person reading
#375 will have the same idea.
A program needs bootstrap, discovery and configuration. xlings had the first
two and, until it grew the subos_info block, nothing for the third -- its own
module comment names the consequence by number: mcpp#352, a GLFW binary that
links fine and exits 255 because nothing told it where the GL drivers are.

mcpp is the consumer of the third one, and it reads rather than knows. That is
the property that matters: mcpp must never contain the string
LIBGL_DRIVERS_PATH, because the moment it does the graphics stack has two
owners and the next capability xlings adds becomes an mcpp code change instead
of a declaration.

Every degradation fills the `note` field and callers must print it. A subos
with no block is not exotic -- mcpp's own sandbox subos was measured in
exactly that state, 356 workspace entries and no self-description -- and
silence there is what made #352 expensive to find.

family_of duplicates a five-row mapping that xlings also has. The alternative
was asking the xlings binary, which costs a subprocess in the hot path and
fails precisely where it matters: a sandbox xlings that has not been updated.
Every row is pinned in tests, so a drift is a test failure rather than a
silent ABI disagreement.
A program needs three things: it links, it finds its libraries, and it is told
where its runtime data lives. mcpp supplied the first two and nothing for the
third. xlings's graphics packages declare LIBGL_DRIVERS_PATH and friends into
the subos; `xlings subos use` applied them and `mcpp run` did not. That gap is
mcpp#352 -- a GLFW binary that links cleanly and exits 255 with no output,
because nothing ever told it where the GL drivers are.

mcpp carries whatever the subos declares and knows what none of it means. The
e2e probes a variable with no graphics meaning on purpose: naming a real one
would suggest mcpp has an opinion about it, and the moment it does the
graphics stack has two owners.

Resolved at run time rather than cached with the build. These values belong to
the subos, not to the build, so a user who switches subos between `mcpp build`
and `mcpp run` has to get the new ones; it is one file read.

The subos is derived from the toolchain binary rather than from a global, so a
toolchain inherited from another home resolves to that home's subos -- the one
whose payloads the binary was actually linked against. MCPP_SUBOS_DIR
overrides it, which is how the e2e exercises this without going near a real
subos: an earlier test wrote through a symlink and permanently broke a real
toolchain.
mcpp#375 asked for a supported way to make a distributable artifact and
proposed one: link against the system libc. Three routes already produce a
distributable artifact and none of them does that -- through the ecosystem
(xlings repoints the ELF at install time), a static musl target, or a bundle
carrying the toolchain's own glibc. The gap was never a mechanism. It was that
the page opened with "use mcpp pack" and never said what the alternatives
were, so the reader who wanted something else had to invent it.

The note about route A is the one that answers #375 directly: a freshly built
binary's PT_INTERP names the build machine's payload, and hand-copying that
file elsewhere fails. That is a property of hand-copying, not of the artifact
-- installed through xlings the ELF is repointed at the target's own payloads.
Saying so is what stops the next reader concluding the artifact is broken and
reaching for the host's loader to "fix" it.
The xlings pin moves because this release consumes something new from it: the
subos_info block a subos writes to describe itself. Reading it against an
older xlings degrades quietly by design, but CI should exercise the version
that actually has it.

The bootstrap pin stays at 2026.8.6.2. It is where the self-host starts, not
what the release ships -- moving it would send every CI job looking for a
version that does not exist yet.
`mcpp run` has two paths and only one of them had been taught about subos
declarations. The full path resolves the toolchain and applies them; the fast
path skips prepare_build entirely and built its own child environment. So a
program got its environment on the run right after a build and lost it on
every run after that -- for a GL application, "it worked once and now the
window is black", with nothing in between to attribute it to.

WHICH subos is a build property and is now recorded in the build cache. WHAT
it declares is the subos's own and is re-read on every run, so installing a
graphics stack between two runs takes effect without a rebuild. That split is
also why MCPP_SUBOS_DIR moved out of the derivation: an override means "for
this invocation", and caching one would let a single `MCPP_SUBOS_DIR=… mcpp
run` silently redirect every later run.

Found by an assertion that the test did not originally have. The first version
ran `mcpp run` once, passed, and proved nothing about the path it was written
for. The fast-path check now runs first and fails loudly if the second run did
NOT take the fast path -- without it the assertion below it is vacuous and
would keep passing after the coverage silently went away. It caught a second
defect immediately: the new cache line was written before `profile=` and
parsed after `cacheMode=`, so every entry read back as stale and the fast path
was disabled for everyone.

Two dead capability tokens, and a guard so there is no third. `# requires:
linux` and `# requires: llvm` are not capabilities run_all.sh ever sets, so
65_toolchain_runtime_dirs_for_run.sh had never executed in CI -- and it
passes. The skip line for a token that cannot exist reads exactly like the
skip line for one that legitimately does not, which is what let it sit. The
runner now refuses to start when a test declares a token outside the known
universe.

The xlings pin goes back to 2026.8.6.3. It was moved to 2026.8.7.1 for tidiness
rather than need -- subos_info degrades quietly on an older xlings by design --
and CI's fresh-sandbox jobs failed with the toolchain's own g++ exiting 127,
the signature of an interpreter that is not where the binary says it is.
Whether that is a real incompatibility is worth knowing, but not on the back of
a change that does not need it.
Windows CI caught this; no amount of reading would have. resolve_env joined
and split its lists on ':', which on Windows is both the wrong separator (it
is ';') and a character that appears INSIDE every absolute path. So
de-duplication split "C:\x" into "C" and "\x", matched nothing, and the
joined value came back as "C:\...\x:C:\...\x" -- a list that grows on every
nested invocation and that no consumer can parse.

This repository has made the same mistake before, in the other direction:
find_first_of(";:") over a Windows PATH cuts at the drive-letter colon.

The three test expectations that failed were also wrong, but differently, and
the difference matters: they compared against a path JOIN while the code does
a literal substitution. The literal one is correct -- the separator inside a
declaration belongs to the subos manifest, and rewriting it to the host's
spelling would be editing a value we do not own. The assertions now say so,
and the dedup test additionally asserts the result contains NO separator at
all, which is the property that actually failed.
Windows CI: `clang++: error: clang frontend command failed due to signal`,
with the diagnostic file named subos_info-*.cppm. No message beyond the
signal, so the offending construct is identified by removal rather than by a
compiler telling us.

The one exotic thing in the file was `std::ranges::find` with a member-pointer
projection into std::pair; `std::ranges::sort` went with it for the same
reason. Both are replaced by plain loops, which nothing here needed to be
fancier than.

Ruled out first: importing mcpp.platform, added in the previous commit and the
only other change to this file between the run that failed a test assertion
and the run that crashed the compiler. Four modules already import
mcpp.libs.json and mcpp.platform together (bmi_cache, stdmod, post_install,
prepare), so that combination is not it.

Stated plainly because it matters for the next person: this is a hypothesis
confirmed only by CI going green, not by a local reproduction. The crash needs
clang 20.1.7 targeting MSVC and I have no such host.
@speak-agent
speak-agent merged commit 463dedf into main Aug 7, 2026
18 checks passed
@speak-agent
speak-agent deleted the feat/xlings-runtime-substrate branch August 7, 2026 18:18
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.

2 participants