Skip to content

Commit 463dedf

Browse files
feat: xlings 作为运行时底座 —— subos 环境到达程序 + self-contained 的 /proc/self/exe 陷阱 (#352, #375) (2026.8.8.1) (#376)
* docs: xlings as mcpp's runtime substrate — design and implementation plan * fix(pack): the self-contained wrapper broke /proc/self/exe and never 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. * docs: retract the c_runtime axis — it answered the reporter's method, 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. * feat(xlings): read the subos's own description instead of inferring it 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. * feat(run): a program mcpp launches gets its subos's environment 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. * docs: three ways to ship, none of which uses the host's libc 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. * chore: 2026.8.8.1, and track xlings 2026.8.7.1 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. * fix(run): the cached fast path dropped the subos environment `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. * fix(xlings): the env list separator is the platform's, not a literal ':' 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. * fix(xlings): drop a ranges spelling that crashed the clang 20 frontend 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. --------- Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
1 parent 8783350 commit 463dedf

15 files changed

Lines changed: 2314 additions & 14 deletions

.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md

Lines changed: 597 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md

Lines changed: 840 additions & 0 deletions
Large diffs are not rendered by default.

docs/02-pack-and-release.md

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,42 @@
11
# 02 — Packaging for Release
22

3-
> A default dynamically linked binary produced by `mcpp build` normally has a
4-
> loader and RUNPATH tied to the build sandbox. To distribute it to other
5-
> machines or deploy it to a server, use `mcpp pack` to produce a release
6-
> tarball or directory with the appropriate runtime closure.
3+
> A default dynamically linked binary produced by `mcpp build` has a loader and
4+
> RUNPATH tied to the build sandbox. It is a development artifact, not a
5+
> deliverable. Three routes turn it into one — and none of them uses the host's
6+
> C library.
7+
8+
## Three ways to ship
9+
10+
Every route below produces an artifact whose C runtime comes from the
11+
ecosystem, never from `/lib64`. That is deliberate: mcpp builds against a
12+
private glibc precisely so a binary's behaviour does not depend on which
13+
distribution happens to be underneath it, and reaching back out to the host's
14+
libc to distribute would give that away at the last step.
15+
16+
| | Route | Command | Where its C runtime comes from | Choose it when |
17+
|---|---|---|---|---|
18+
| **A** | Through the ecosystem | `mcpp emit xpkg``xlings install <pkg>` | the target machine's own xlings payloads | the target has xlings |
19+
| **B** | One static file | `mcpp build --target x86_64-linux-musl` | nowhere — it is linked in | you want a single file with no runtime at all |
20+
| **C** | Carry the runtime | `mcpp pack --mode self-contained` | shipped inside the bundle | any Linux, including older than the build machine |
21+
22+
**On route A, and the thing that surprises people.** The `PT_INTERP` baked into
23+
a freshly built binary points at *your* machine's payload, so copying that file
24+
to another machine by hand does not work — the path is not there. That is not a
25+
property of the artifact so much as of the copy: installed through `xlings`, the
26+
package's ELF files are repointed at the target machine's own payloads at
27+
install time. The baked path is a build-machine detail, not a distribution
28+
format. If you are hand-copying binaries between machines, you want B or C.
29+
30+
**On route B.** `--target …-musl` implies a static link, so there is no loader,
31+
no RUNPATH and nothing to find at run time. It is the smallest and most
32+
portable result, and the one to reach for first when the program does not need
33+
glibc-specific behaviour (NSS lookups, `dlopen` of host plugins).
34+
35+
**On route C.** The bundle carries this toolchain's glibc and its loader, so it
36+
runs on distributions older than the build machine — the case B cannot cover
37+
when glibc is actually required. Read the `/proc/self/exe` note below before
38+
choosing it: launching through a bundled loader changes what the program sees
39+
about itself.
740

841
## Two axes: target (libc) × mode (bundling depth)
942

@@ -128,6 +161,37 @@ exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myap
128161
The layout and wrapper above use an x86_64 example. The packer derives the
129162
loader name from the target; for aarch64 it is `ld-linux-aarch64.so.1`.
130163

164+
#### Trap: `/proc/self/exe` under the bundled loader
165+
166+
Being started *by* the loader has a consequence the layout above does not
167+
show: the kernel sets `/proc/self/exe` to the **loader**, not to your program,
168+
and `/proc/self/cmdline` carries the `--library-path` argument. Every "find my
169+
resources next to the executable" path therefore resolves against `lib/`
170+
instead of the bundle root — and it does so silently. In practice that means
171+
a GUI toolkit rendering blank text because it cannot find its fonts, an
172+
`assets/` directory that appears to be missing, and helper binaries shipped
173+
alongside the program that cannot be located. Code that parses `argv` from
174+
`/proc/self/cmdline` sees the loader's arguments mixed in.
175+
176+
This affects `self-contained` only. `vendored`, `system` and `static` all
177+
carry a `PT_INTERP` that the kernel can use directly, so `/proc/self/exe` is
178+
correct there.
179+
180+
The wrapper exports **`MCPP_BUNDLE_DIR`** (the bundle root) for this. Resolve
181+
against it first and fall back only when it is unset:
182+
183+
```c
184+
const char *base = getenv("MCPP_BUNDLE_DIR"); /* set by run.sh */
185+
if (!base) {
186+
/* not launched through the wrapper — /proc/self/exe is trustworthy */
187+
}
188+
```
189+
190+
If the application cannot be changed — a third-party GUI framework doing its
191+
own resolution, say — use `--mode vendored` instead. It repoints `PT_INTERP`
192+
at the host loader, at the cost of requiring the host's glibc to be at least
193+
as new as the one the artifact was built against.
194+
131195
## Configuration
132196

133197
Packaging behavior is configured via the `[pack]` section in `mcpp.toml`. The

docs/zh/02-pack-and-release.md

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,34 @@
11
# 02 — 发布打包
22

3-
> 默认的动态链接 `mcpp build` 产物通常会把 loader 与 RUNPATH 指向构建沙盒。
4-
> 如需分发至其他机器或部署至服务器,应使用 `mcpp pack` 生成带有适当运行时闭包的
5-
> 发布 tarball 或目录。
3+
> 默认的动态链接 `mcpp build` 产物会把 loader 与 RUNPATH 指向构建沙盒。它是
4+
> 开发产物,不是交付物。有三条路把它变成交付物 —— **没有一条使用宿主的 C 库**
5+
6+
## 三种分发方式
7+
8+
下面每一条产出的产物,其 C 运行时都来自生态,而不是 `/lib64`。这是有意的:
9+
mcpp 之所以针对私有 glibc 构建,正是为了让产物的行为不取决于底下是哪个发行版;
10+
如果最后一步又伸手去拿宿主的 libc,前面这件事就白做了。
11+
12+
| | 方式 | 命令 | C 运行时来自哪里 | 何时选它 |
13+
|---|---|---|---|---|
14+
| **A** | 走生态 | `mcpp emit xpkg``xlings install <pkg>` | 目标机自己的 xlings 载荷 | 目标机装了 xlings |
15+
| **B** | 静态单文件 | `mcpp build --target x86_64-linux-musl` | 不来自任何地方 —— 已链进去 | 想要一个无任何运行时依赖的单文件 |
16+
| **C** | 自带运行时 | `mcpp pack --mode self-contained` | 随 bundle 一起分发 | 任何 Linux,含比构建机更老的 |
17+
18+
**关于 A,以及那个让人意外的地方。** 刚构建出的二进制里烙的 `PT_INTERP` 指向
19+
**你这台机器**的载荷,所以手工把这个文件拷到另一台机器上跑不起来——那个路径
20+
在那边不存在。与其说这是产物的性质,不如说是「手工拷贝」这个动作的性质:经
21+
`xlings` 安装时,包里的 ELF 会在**装机期被重指到目标机自己的载荷**。烙进去的
22+
路径是构建机的细节,不是分发格式。如果你就是要在机器之间手工拷二进制,那你要
23+
的是 B 或 C。
24+
25+
**关于 B。** `--target …-musl` 隐含静态链接,所以没有 loader、没有 RUNPATH、
26+
运行期不需要找任何东西。它的结果最小也最可移植,在程序不需要 glibc 专有行为
27+
(NSS 查询、`dlopen` 宿主插件)时应当首选。
28+
29+
**关于 C。** bundle 里带着这套工具链的 glibc 与 loader,因此能在比构建机更老的
30+
发行版上跑 —— 这是 B 覆盖不了、而又确实需要 glibc 时的那一格。选它之前先读下面
31+
`/proc/self/exe` 一节:经 bundled loader 启动会改变程序对「自己在哪」的认知。
632

733
## 两条轴:target(libc) × mode(打包深度)
834

@@ -120,6 +146,32 @@ exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myap
120146
上面的布局与 wrapper 以 x86_64 为例。打包器会按 target 推导 loader 名称;aarch64
121147
对应 `ld-linux-aarch64.so.1`
122148

149+
#### 陷阱:经 bundled loader 启动后的 `/proc/self/exe`
150+
151+
「由 loader 启动」有一个上面的布局看不出来的后果:内核会把 `/proc/self/exe`
152+
指向 **loader**,而不是你的程序;`/proc/self/cmdline` 里也混进了
153+
`--library-path`。于是所有「在可执行文件旁边找资源」的逻辑都会解析到 `lib/`
154+
而不是 bundle 根目录——而且是**静默**的。实际表现是:GUI 框架找不到字体因而
155+
文字渲染空白、`assets/` 目录看起来不存在、随包分发的辅助二进制定位失败。
156+
`/proc/self/cmdline` 解析 argv 的代码则会拿到混入 loader 参数的结果。
157+
158+
这只影响 `self-contained``vendored``system``static``PT_INTERP`
159+
都能被内核直接使用,`/proc/self/exe` 是正确的。
160+
161+
wrapper 为此导出 **`MCPP_BUNDLE_DIR`**(bundle 根目录)。优先用它,只在未设置
162+
时回退:
163+
164+
```c
165+
const char *base = getenv("MCPP_BUNDLE_DIR"); /* 由 run.sh 设置 */
166+
if (!base) {
167+
/* 不是经 wrapper 启动的 —— 此时 /proc/self/exe 可信 */
168+
}
169+
```
170+
171+
如果应用本身改不了(比如第三方 GUI 框架自己做解析),改用 `--mode vendored`:
172+
它把 `PT_INTERP` 重指到宿主 loader,`/proc/self/exe` 正常,代价是要求宿主
173+
glibc 不低于构建时所用的那份。
174+
123175
## 配置项
124176

125177
打包行为通过 `mcpp.toml` 中的 `[pack]` 节配置,常用字段如下:

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.8.7.1"
3+
version = "2026.8.8.1"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/execute.cppm

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import mcpp.manifest;
2020
import mcpp.modgraph.scanner;
2121
import mcpp.toolchain.stdmod;
2222
import mcpp.xlings;
23+
import mcpp.xlings.subos_info;
24+
import mcpp.log;
2325
import mcpp.platform;
2426
import mcpp.fetcher.progress;
2527
import mcpp.project;
@@ -60,6 +62,12 @@ struct BuildCacheEntry {
6062
// plan.runtimeLibraryDirs is empty.
6163
std::string runEnvKey;
6264
std::string runEnvValue;
65+
// The subos this build's toolchain belongs to (mcpp#352). The DIRECTORY,
66+
// never the resolved variables: the environment is the subos's property
67+
// and must be re-read on every run, while WHICH subos is the build's
68+
// property and would otherwise be unknowable on the fast path -- which
69+
// has no toolchain to derive it from.
70+
std::string subosDir;
6371
// The resolved profile this entry was built for. Entries used to be keyed
6472
// by target triple alone, and the fast paths only refuse to run when an
6573
// EXPLICIT --profile/--dev/--release is passed — so a bare `mcpp build`
@@ -139,6 +147,16 @@ std::vector<BuildCacheEntry> read_build_cache(const std::filesystem::path& proje
139147
std::getline(f, e.runEnvValue);
140148
haveNextLine = static_cast<bool>(std::getline(f, line));
141149
}
150+
// Optional subos line. Same back-compat contract: absent ⇒ empty ⇒
151+
// the run fast path treats the entry as a miss, exactly as it already
152+
// does for a cache written before runtimeEnvKey existed. Running with
153+
// a DIFFERENT environment than the full path would be worse than not
154+
// using the cache at all -- the program would work once and then
155+
// silently stop finding its runtime data.
156+
if (haveNextLine && line.starts_with("subos=")) {
157+
e.subosDir = line.substr(6);
158+
haveNextLine = static_cast<bool>(std::getline(f, line));
159+
}
142160
// Optional profile line. Same back-compat contract as the two blocks
143161
// above: absent ⇒ e.profile stays empty ⇒ every fast path treats the
144162
// entry as a miss and falls through to prepare_build.
@@ -167,7 +185,8 @@ void write_build_cache(const std::filesystem::path& projectRoot,
167185
const std::string& runEnvKey = "",
168186
const std::string& runEnvValue = "",
169187
const std::string& profile = "",
170-
const std::string& cacheMode = "") {
188+
const std::string& cacheMode = "",
189+
const std::string& subosDir = "") {
171190
auto path = projectRoot / kBuildCacheFile;
172191
auto entries = read_build_cache(projectRoot);
173192

@@ -182,7 +201,7 @@ void write_build_cache(const std::filesystem::path& projectRoot,
182201
// Insert at front (MRU).
183202
BuildCacheEntry newEntry{targetTriple, outputDir.string(), ninjaProgram, fingerprintHex,
184203
runtimeEnvKey, runtimeEnvValue, std::move(runTargets),
185-
runEnvKey, runEnvValue, profile, cacheMode};
204+
runEnvKey, runEnvValue, subosDir, profile, cacheMode};
186205
entries.insert(entries.begin(), std::move(newEntry));
187206

188207
// Trim to LRU capacity.
@@ -210,6 +229,7 @@ void write_build_cache(const std::filesystem::path& projectRoot,
210229
for (auto& [name, exe] : e.runTargets) f << name << '\t' << exe << '\n';
211230
f << "runEnv=" << e.runEnvKey << '\n';
212231
f << e.runEnvValue << '\n';
232+
f << "subos=" << e.subosDir << '\n';
213233
f << "profile=" << e.profile << '\n';
214234
f << "cacheMode=" << e.cacheMode << '\n';
215235
}
@@ -287,6 +307,55 @@ compute_run_env(const mcpp::build::BuildPlan& plan) {
287307
return {key, value};
288308
}
289309

310+
// The environment the active subos declares for the programs it hosts
311+
// (mcpp#352).
312+
//
313+
// A GL application needs three things and mcpp only ever supplied two: the
314+
// binary links (bootstrap), it finds its libraries (RPATH), and then it has to
315+
// be told which driver module to load and which GL vendors exist. That third
316+
// one is a set of environment variables, xlings's graphics packages declare
317+
// them into the subos, and until now nothing carried them to a program mcpp
318+
// launched — `xlings subos use` applied them, `mcpp run` did not. Hence a
319+
// binary that links fine and exits 255 with no output.
320+
//
321+
// Resolved at RUN time, deliberately not cached with the build: these values
322+
// belong to the subos, not to the build, and a user who switches subos between
323+
// `mcpp build` and `mcpp run` must get the new one. It is a file read.
324+
//
325+
// mcpp does not know what any of these variables MEAN, and that is the design:
326+
// when the ecosystem gains a Vulkan loader or a new driver bridge, the
327+
// declaration changes and this code does not.
328+
// The subos a RUN should use: an explicit override if the caller set one,
329+
// otherwise the subos this build belongs to.
330+
//
331+
// The override lives HERE and not in the derivation, because the derivation's
332+
// answer is cached and this one must not be: MCPP_SUBOS_DIR says "for this
333+
// invocation". It exists so tests can exercise this path without touching a
334+
// developer's real subos — an earlier e2e wrote through a symlink and
335+
// permanently broke a real toolchain — and so a user can point one run at
336+
// another subos without switching the active one.
337+
std::filesystem::path subos_dir_for_run(const std::filesystem::path& buildSubos) {
338+
if (const char* e = std::getenv("MCPP_SUBOS_DIR"); e && *e)
339+
return std::filesystem::path(e);
340+
return buildSubos;
341+
}
342+
343+
std::vector<std::pair<std::string, std::string>>
344+
compute_subos_env(const mcpp::build::BuildPlan& plan) {
345+
auto built = mcpp::xlings::paths::subos_dir_of(plan.toolchain.binaryPath);
346+
auto dir = subos_dir_for_run(built ? *built : std::filesystem::path{});
347+
if (dir.empty()) return {};
348+
auto info = mcpp::xlings::subos::read(dir);
349+
// The note is a `verbose` line rather than a warning: a subos with no
350+
// self-description is the normal state of every machine whose subos
351+
// predates the block, and a warning on every run would train people to
352+
// ignore it. It becomes loud only where it explains a failure — the GL
353+
// diagnostic path in doctor.
354+
if (!info.note.empty())
355+
mcpp::log::verbose("subos", info.note);
356+
return mcpp::xlings::subos::resolve_env(info, dir);
357+
}
358+
290359
// Compile a prepared BuildContext. Shared between `mcpp build` and `mcpp run`
291360
// so the latter doesn't call prepare_build twice (and re-print the toolchain
292361
// resolution banner).
@@ -398,12 +467,14 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache,
398467
auto fpHex = ctx.outputDir.filename().string();
399468
auto runTargets = compute_run_targets(ctx.plan);
400469
auto [runEnvKey, runEnvValue] = compute_run_env(ctx.plan);
470+
auto subosDir = mcpp::xlings::paths::subos_dir_of(ctx.plan.toolchain.binaryPath);
401471
write_build_cache(ctx.projectRoot, ctx.outputDir, r->ninjaProgram,
402472
std::string(targetOverride), fpHex,
403473
r->runtimeEnvKey.empty() ? "-" : r->runtimeEnvKey,
404474
r->runtimeEnvValue,
405475
std::move(runTargets), runEnvKey, runEnvValue,
406-
ctx.profile, std::string(cache_mode_name(ctx.cacheMode)));
476+
ctx.profile, std::string(cache_mode_name(ctx.cacheMode)),
477+
subosDir ? subosDir->string() : std::string{});
407478
}
408479

409480
// The one place the --strict policy is settled. Degradations reported by
@@ -754,6 +825,25 @@ std::optional<int> try_fast_run(const std::filesystem::path& projectRoot,
754825
std::vector<std::pair<std::string, std::string>> childEnv;
755826
if (!match->runEnvKey.empty() && !match->runEnvValue.empty())
756827
childEnv.emplace_back(match->runEnvKey, match->runEnvValue);
828+
// ...and the subos's declared environment, re-READ here rather than taken
829+
// from the cache. Which subos is a build property (cached above); what it
830+
// declares is the subos's own, and a user who installs a graphics stack
831+
// between two runs must get it without rebuilding.
832+
//
833+
// This is the half that a fast path is most likely to lose, and losing it
834+
// would be invisible in the worst way: the first `mcpp run` after a build
835+
// takes the full path and works, every later one takes this path and does
836+
// not. A GL program would run once and then stop finding its driver.
837+
{
838+
// Same rule as the full path, through the same helper: an override
839+
// for this invocation, else the subos this build was recorded against.
840+
auto subosDir = subos_dir_for_run(std::filesystem::path(match->subosDir));
841+
if (!subosDir.empty()) {
842+
auto info = mcpp::xlings::subos::read(subosDir);
843+
for (auto& kv : mcpp::xlings::subos::resolve_env(info, subosDir))
844+
childEnv.push_back(std::move(kv));
845+
}
846+
}
757847

758848
return mcpp::platform::process::run_exec(argv, childEnv) == 0 ? 0 : 1;
759849
}
@@ -827,6 +917,8 @@ export int build_run_target(const std::optional<std::string>& targetName,
827917
auto [runEnvKey, runEnvValue] = compute_run_env(ctx->plan);
828918
if (!runEnvKey.empty() && !runEnvValue.empty())
829919
childEnv.emplace_back(runEnvKey, runEnvValue);
920+
// ...plus whatever the subos declares for the programs it hosts (#352).
921+
for (auto& kv : compute_subos_env(ctx->plan)) childEnv.push_back(std::move(kv));
830922

831923
// Direct exec (no /bin/sh): the loader env reaches ONLY the target child,
832924
// never mcpp or a host shell. Fixes the bundled-glibc-vs-host-libtinfo
@@ -1213,6 +1305,9 @@ export int run_tests(std::span<const std::string> passthrough,
12131305
auto runtimeEnvKey = mcpp::platform::env::runtime_library_path_key();
12141306
auto runtimeEnvValue = mcpp::platform::env::prepend_path_list(
12151307
runtimeEnvKey, ctx->plan.runtimeLibraryDirs);
1308+
// Read once for the whole run rather than per test: it is one file, and
1309+
// every test in a run belongs to the same subos.
1310+
const auto subosEnv = compute_subos_env(ctx->plan);
12161311

12171312
// macOS deliberately has no runtime-library-path key (env.cppm): injecting
12181313
// DYLD_LIBRARY_PATH would reach every executable ninja launches and can
@@ -1283,6 +1378,10 @@ export int run_tests(std::span<const std::string> passthrough,
12831378
std::vector<std::pair<std::string, std::string>> childEnv;
12841379
if (!runtimeEnvKey.empty() && !runtimeEnvValue.empty())
12851380
childEnv.emplace_back(runtimeEnvKey, runtimeEnvValue);
1381+
// ...and the subos's declared environment, same as `mcpp run` (#352).
1382+
// A GL test that cannot find a driver fails the same way a GL program
1383+
// does, so it must be told the same things.
1384+
for (auto& kv : subosEnv) childEnv.push_back(kv);
12861385

12871386
// Prepend the sandbox's subos/default/bin to the CHILD PATH so test
12881387
// binaries that shell out to bootstrapped tools (patchelf, ninja) find

0 commit comments

Comments
 (0)