Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
597 changes: 597 additions & 0 deletions .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

72 changes: 68 additions & 4 deletions docs/02-pack-and-release.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,42 @@
# 02 — Packaging for Release

> A default dynamically linked binary produced by `mcpp build` normally has a
> loader and RUNPATH tied to the build sandbox. To distribute it to other
> machines or deploy it to a server, use `mcpp pack` to produce a release
> tarball or directory with the appropriate runtime closure.
> A default dynamically linked binary produced by `mcpp build` has a loader and
> RUNPATH tied to the build sandbox. It is a development artifact, not a
> deliverable. Three routes turn it into one — and none of them uses the host's
> C library.

## Three ways to ship

Every route below produces an artifact whose C runtime comes from the
ecosystem, never from `/lib64`. That is deliberate: mcpp builds against a
private glibc precisely so a binary's behaviour does not depend on which
distribution happens to be underneath it, and reaching back out to the host's
libc to distribute would give that away at the last step.

| | Route | Command | Where its C runtime comes from | Choose it when |
|---|---|---|---|---|
| **A** | Through the ecosystem | `mcpp emit xpkg` → `xlings install <pkg>` | the target machine's own xlings payloads | the target has xlings |
| **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 |
| **C** | Carry the runtime | `mcpp pack --mode self-contained` | shipped inside the bundle | any Linux, including older than the build machine |

**On route A, and the thing that surprises people.** The `PT_INTERP` baked into
a freshly built binary points at *your* machine's payload, so copying that file
to another machine by hand does not work — the path is not there. That is not a
property of the artifact so much as of the copy: installed through `xlings`, the
package's ELF files are repointed at the target machine's own payloads at
install time. The baked path is a build-machine detail, not a distribution
format. If you are hand-copying binaries between machines, you want B or C.

**On route B.** `--target …-musl` implies a static link, so there is no loader,
no RUNPATH and nothing to find at run time. It is the smallest and most
portable result, and the one to reach for first when the program does not need
glibc-specific behaviour (NSS lookups, `dlopen` of host plugins).

**On route C.** The bundle carries this toolchain's glibc and its loader, so it
runs on distributions older than the build machine — the case B cannot cover
when glibc is actually required. Read the `/proc/self/exe` note below before
choosing it: launching through a bundled loader changes what the program sees
about itself.

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

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

#### Trap: `/proc/self/exe` under the bundled loader

Being started *by* the loader has a consequence the layout above does not
show: the kernel sets `/proc/self/exe` to the **loader**, not to your program,
and `/proc/self/cmdline` carries the `--library-path` argument. Every "find my
resources next to the executable" path therefore resolves against `lib/`
instead of the bundle root — and it does so silently. In practice that means
a GUI toolkit rendering blank text because it cannot find its fonts, an
`assets/` directory that appears to be missing, and helper binaries shipped
alongside the program that cannot be located. Code that parses `argv` from
`/proc/self/cmdline` sees the loader's arguments mixed in.

This affects `self-contained` only. `vendored`, `system` and `static` all
carry a `PT_INTERP` that the kernel can use directly, so `/proc/self/exe` is
correct there.

The wrapper exports **`MCPP_BUNDLE_DIR`** (the bundle root) for this. Resolve
against it first and fall back only when it is unset:

```c
const char *base = getenv("MCPP_BUNDLE_DIR"); /* set by run.sh */
if (!base) {
/* not launched through the wrapper — /proc/self/exe is trustworthy */
}
```

If the application cannot be changed — a third-party GUI framework doing its
own resolution, say — use `--mode vendored` instead. It repoints `PT_INTERP`
at the host loader, at the cost of requiring the host's glibc to be at least
as new as the one the artifact was built against.

## Configuration

Packaging behavior is configured via the `[pack]` section in `mcpp.toml`. The
Expand Down
58 changes: 55 additions & 3 deletions docs/zh/02-pack-and-release.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
# 02 — 发布打包

> 默认的动态链接 `mcpp build` 产物通常会把 loader 与 RUNPATH 指向构建沙盒。
> 如需分发至其他机器或部署至服务器,应使用 `mcpp pack` 生成带有适当运行时闭包的
> 发布 tarball 或目录。
> 默认的动态链接 `mcpp build` 产物会把 loader 与 RUNPATH 指向构建沙盒。它是
> 开发产物,不是交付物。有三条路把它变成交付物 —— **没有一条使用宿主的 C 库**。

## 三种分发方式

下面每一条产出的产物,其 C 运行时都来自生态,而不是 `/lib64`。这是有意的:
mcpp 之所以针对私有 glibc 构建,正是为了让产物的行为不取决于底下是哪个发行版;
如果最后一步又伸手去拿宿主的 libc,前面这件事就白做了。

| | 方式 | 命令 | C 运行时来自哪里 | 何时选它 |
|---|---|---|---|---|
| **A** | 走生态 | `mcpp emit xpkg` → `xlings install <pkg>` | 目标机自己的 xlings 载荷 | 目标机装了 xlings |
| **B** | 静态单文件 | `mcpp build --target x86_64-linux-musl` | 不来自任何地方 —— 已链进去 | 想要一个无任何运行时依赖的单文件 |
| **C** | 自带运行时 | `mcpp pack --mode self-contained` | 随 bundle 一起分发 | 任何 Linux,含比构建机更老的 |

**关于 A,以及那个让人意外的地方。** 刚构建出的二进制里烙的 `PT_INTERP` 指向
**你这台机器**的载荷,所以手工把这个文件拷到另一台机器上跑不起来——那个路径
在那边不存在。与其说这是产物的性质,不如说是「手工拷贝」这个动作的性质:经
`xlings` 安装时,包里的 ELF 会在**装机期被重指到目标机自己的载荷**。烙进去的
路径是构建机的细节,不是分发格式。如果你就是要在机器之间手工拷二进制,那你要
的是 B 或 C。

**关于 B。** `--target …-musl` 隐含静态链接,所以没有 loader、没有 RUNPATH、
运行期不需要找任何东西。它的结果最小也最可移植,在程序不需要 glibc 专有行为
(NSS 查询、`dlopen` 宿主插件)时应当首选。

**关于 C。** bundle 里带着这套工具链的 glibc 与 loader,因此能在比构建机更老的
发行版上跑 —— 这是 B 覆盖不了、而又确实需要 glibc 时的那一格。选它之前先读下面
的 `/proc/self/exe` 一节:经 bundled loader 启动会改变程序对「自己在哪」的认知。

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

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

#### 陷阱:经 bundled loader 启动后的 `/proc/self/exe`

「由 loader 启动」有一个上面的布局看不出来的后果:内核会把 `/proc/self/exe`
指向 **loader**,而不是你的程序;`/proc/self/cmdline` 里也混进了
`--library-path`。于是所有「在可执行文件旁边找资源」的逻辑都会解析到 `lib/`
而不是 bundle 根目录——而且是**静默**的。实际表现是:GUI 框架找不到字体因而
文字渲染空白、`assets/` 目录看起来不存在、随包分发的辅助二进制定位失败。
按 `/proc/self/cmdline` 解析 argv 的代码则会拿到混入 loader 参数的结果。

这只影响 `self-contained`。`vendored`、`system`、`static` 的 `PT_INTERP`
都能被内核直接使用,`/proc/self/exe` 是正确的。

wrapper 为此导出 **`MCPP_BUNDLE_DIR`**(bundle 根目录)。优先用它,只在未设置
时回退:

```c
const char *base = getenv("MCPP_BUNDLE_DIR"); /* 由 run.sh 设置 */
if (!base) {
/* 不是经 wrapper 启动的 —— 此时 /proc/self/exe 可信 */
}
```

如果应用本身改不了(比如第三方 GUI 框架自己做解析),改用 `--mode vendored`:
它把 `PT_INTERP` 重指到宿主 loader,`/proc/self/exe` 正常,代价是要求宿主
glibc 不低于构建时所用的那份。

## 配置项

打包行为通过 `mcpp.toml` 中的 `[pack]` 节配置,常用字段如下:
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "2026.8.7.1"
version = "2026.8.8.1"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
105 changes: 102 additions & 3 deletions src/build/execute.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import mcpp.manifest;
import mcpp.modgraph.scanner;
import mcpp.toolchain.stdmod;
import mcpp.xlings;
import mcpp.xlings.subos_info;
import mcpp.log;
import mcpp.platform;
import mcpp.fetcher.progress;
import mcpp.project;
Expand Down Expand Up @@ -60,6 +62,12 @@ struct BuildCacheEntry {
// plan.runtimeLibraryDirs is empty.
std::string runEnvKey;
std::string runEnvValue;
// The subos this build's toolchain belongs to (mcpp#352). The DIRECTORY,
// never the resolved variables: the environment is the subos's property
// and must be re-read on every run, while WHICH subos is the build's
// property and would otherwise be unknowable on the fast path -- which
// has no toolchain to derive it from.
std::string subosDir;
// The resolved profile this entry was built for. Entries used to be keyed
// by target triple alone, and the fast paths only refuse to run when an
// EXPLICIT --profile/--dev/--release is passed — so a bare `mcpp build`
Expand Down Expand Up @@ -139,6 +147,16 @@ std::vector<BuildCacheEntry> read_build_cache(const std::filesystem::path& proje
std::getline(f, e.runEnvValue);
haveNextLine = static_cast<bool>(std::getline(f, line));
}
// Optional subos line. Same back-compat contract: absent ⇒ empty ⇒
// the run fast path treats the entry as a miss, exactly as it already
// does for a cache written before runtimeEnvKey existed. Running with
// a DIFFERENT environment than the full path would be worse than not
// using the cache at all -- the program would work once and then
// silently stop finding its runtime data.
if (haveNextLine && line.starts_with("subos=")) {
e.subosDir = line.substr(6);
haveNextLine = static_cast<bool>(std::getline(f, line));
}
// Optional profile line. Same back-compat contract as the two blocks
// above: absent ⇒ e.profile stays empty ⇒ every fast path treats the
// entry as a miss and falls through to prepare_build.
Expand Down Expand Up @@ -167,7 +185,8 @@ void write_build_cache(const std::filesystem::path& projectRoot,
const std::string& runEnvKey = "",
const std::string& runEnvValue = "",
const std::string& profile = "",
const std::string& cacheMode = "") {
const std::string& cacheMode = "",
const std::string& subosDir = "") {
auto path = projectRoot / kBuildCacheFile;
auto entries = read_build_cache(projectRoot);

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

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

// The environment the active subos declares for the programs it hosts
// (mcpp#352).
//
// A GL application needs three things and mcpp only ever supplied two: the
// binary links (bootstrap), it finds its libraries (RPATH), and then it has to
// be told which driver module to load and which GL vendors exist. That third
// one is a set of environment variables, xlings's graphics packages declare
// them into the subos, and until now nothing carried them to a program mcpp
// launched — `xlings subos use` applied them, `mcpp run` did not. Hence a
// binary that links fine and exits 255 with no output.
//
// Resolved at RUN time, deliberately not cached with the build: these values
// belong to the subos, not to the build, and a user who switches subos between
// `mcpp build` and `mcpp run` must get the new one. It is a file read.
//
// mcpp does not know what any of these variables MEAN, and that is the design:
// when the ecosystem gains a Vulkan loader or a new driver bridge, the
// declaration changes and this code does not.
// The subos a RUN should use: an explicit override if the caller set one,
// otherwise the subos this build belongs to.
//
// The override lives HERE and not in the derivation, because the derivation's
// answer is cached and this one must not be: MCPP_SUBOS_DIR says "for this
// invocation". It exists so tests can exercise this path without touching a
// developer's real subos — an earlier e2e wrote through a symlink and
// permanently broke a real toolchain — and so a user can point one run at
// another subos without switching the active one.
std::filesystem::path subos_dir_for_run(const std::filesystem::path& buildSubos) {
if (const char* e = std::getenv("MCPP_SUBOS_DIR"); e && *e)
return std::filesystem::path(e);
return buildSubos;
}

std::vector<std::pair<std::string, std::string>>
compute_subos_env(const mcpp::build::BuildPlan& plan) {
auto built = mcpp::xlings::paths::subos_dir_of(plan.toolchain.binaryPath);
auto dir = subos_dir_for_run(built ? *built : std::filesystem::path{});
if (dir.empty()) return {};
auto info = mcpp::xlings::subos::read(dir);
// The note is a `verbose` line rather than a warning: a subos with no
// self-description is the normal state of every machine whose subos
// predates the block, and a warning on every run would train people to
// ignore it. It becomes loud only where it explains a failure — the GL
// diagnostic path in doctor.
if (!info.note.empty())
mcpp::log::verbose("subos", info.note);
return mcpp::xlings::subos::resolve_env(info, dir);
}

// Compile a prepared BuildContext. Shared between `mcpp build` and `mcpp run`
// so the latter doesn't call prepare_build twice (and re-print the toolchain
// resolution banner).
Expand Down Expand Up @@ -398,12 +467,14 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache,
auto fpHex = ctx.outputDir.filename().string();
auto runTargets = compute_run_targets(ctx.plan);
auto [runEnvKey, runEnvValue] = compute_run_env(ctx.plan);
auto subosDir = mcpp::xlings::paths::subos_dir_of(ctx.plan.toolchain.binaryPath);
write_build_cache(ctx.projectRoot, ctx.outputDir, r->ninjaProgram,
std::string(targetOverride), fpHex,
r->runtimeEnvKey.empty() ? "-" : r->runtimeEnvKey,
r->runtimeEnvValue,
std::move(runTargets), runEnvKey, runEnvValue,
ctx.profile, std::string(cache_mode_name(ctx.cacheMode)));
ctx.profile, std::string(cache_mode_name(ctx.cacheMode)),
subosDir ? subosDir->string() : std::string{});
}

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

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

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

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

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