Skip to content

Latest commit

 

History

History
840 lines (663 loc) · 32.2 KB

File metadata and controls

840 lines (663 loc) · 32.2 KB

xlings 运行时底座 —— 实施计划

For agentic workers: 逐 task 执行,每个 task 自带 red→green→commit 循环。

Goal: 让 mcpp 把 xlings 当作可查询的运行时底座:补上 libc 轴的分发契约(c_runtime)、消费 subos 的运行时身份与环境声明、修掉 self-contained 打包模式自产的 /proc/self/exe 陷阱。

Architecture: 三条缝。S2 在既有 mcpp.build.distribution 的三层模型(Role→Contract→Mechanism)上复用词汇、新增 libc 机制表,由 linkmodel 单点渲染;S3 新增 mcpp.xlings.subos_info 读取 xlings 的 subos_info 块,run/test 应用其 env;S1 让 runtime 身份成为显式轴。P2(视图寻址)不做。

Tech Stack: C++23 modules,gtest(tests/unit/),shell e2e(tests/e2e/),nlohmann::json(mcpp.libs.json)。

设计文档:.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md

Global Constraints

  • 词汇不新造:契约三值 self-contained | toolchain-coupled | host-coupled,与 [build] cxx_runtime 逐字相同。runtime binding 拼写 <name>@<version>,与 xlings subos_info.runtime 逐字相同。
  • 默认值不变:c_runtime 缺省 = toolchain-coupled = 今天的行为。引入这条轴本身不改变任何现有产物
  • Mechanism 必须是 total function:每格要么给 flags,要么 degraded=true + 非空 diagnostic。静默无操作是禁止的结果。
  • 不引入新的宿主探测:mcpp 里不得出现探测 GPU / 图形能力的代码。
  • schema 缺失/未知版本必须出声:一行 stderr,构建继续。
  • P2 不做:toolchain-coupled 保持载荷寻址,不切视图寻址。
  • 平台特殊处理下沉:平台差异放 src/platform/ 对应模块,不在 build/ 里写 #ifdef

Task 1: self-contained 打包模式的 /proc/self/exe 陷阱

Files:

  • Modify: src/pack/pack.cppm:431-446(write_bundle_all_wrappers)
  • Modify: docs/02-pack-and-release.md(§ Mode self-contained 之后)
  • Test: tests/e2e/30_pack_modes.sh

Interfaces:

  • Consumes: 无
  • Produces: 分发包契约新增环境变量 MCPP_BUNDLE_DIR(bundle 根目录绝对路径),由 run.sh / 顶层同名脚本导出。

背景:ELF 规范禁止 PT_INTERP$ORIGIN,所以 self-contained 只能经 ld.so --library-path 启动;代价是内核把 /proc/self/exe 指向 loader、/proc/self/cmdline 混入 --library-path。本 task 不消除这个约束(那需要安装期改写 PT_INTERP,见设计文档 Q6 选项 i),而是把它变成一个有声明、可编程的契约,并写进文档。

  • Step 1: 写失败的 e2e 断言

tests/e2e/30_pack_modes.sh 的 Mode B(bundle-all)段落后追加:

# self-contained: the wrapper must export MCPP_BUNDLE_DIR so an application
# can resolve resources despite /proc/self/exe pointing at the loader.
grep -q 'MCPP_BUNDLE_DIR' "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" || {
    echo "Mode B: run.sh does not export MCPP_BUNDLE_DIR"; exit 1; }
out=$("$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" 2>&1) || true
echo "$out" | grep -q 'Hello' || { echo "Mode B: wrapper broke execution: $out"; exit 1; }
  • Step 2: 跑,确认失败
bash tests/e2e/30_pack_modes.sh

Expected: FAIL,run.sh does not export MCPP_BUNDLE_DIR

  • Step 3: 改 wrapper

src/pack/pack.cppmwrite_bundle_all_wrappers body 改为:

    auto body = std::format(
        "#!/bin/sh\n"
        "# Auto-generated by `mcpp pack --mode self-contained`. Launches the\n"
        "# bundled binary through the bundled dynamic linker so the package\n"
        "# is portable across glibc versions.\n"
        "#\n"
        "# TRAP, and why this variable exists: launching through the loader\n"
        "# makes the kernel set /proc/self/exe to the LOADER, not to the\n"
        "# program, and /proc/self/cmdline carries --library-path. Any\n"
        "# \"find my resources next to the executable\" logic silently\n"
        "# resolves against the loader's directory instead. MCPP_BUNDLE_DIR\n"
        "# is the answer that survives: resolve against it first and fall\n"
        "# back to /proc/self/exe only when it is unset.\n"
        "here=$(cd \"$(dirname \"$0\")\" && pwd)\n"
        "MCPP_BUNDLE_DIR=\"$here\"; export MCPP_BUNDLE_DIR\n"
        "exec \"$here/lib/{}\" --library-path \"$here/lib\" \"$here/bin/{}\" \"$@\"\n",
        loaderName, binaryName);
  • Step 4: 跑,确认通过
bash tests/e2e/30_pack_modes.sh

Expected: PASS

  • Step 5: 文档

docs/02-pack-and-release.md 的 self-contained loader 说明之后追加一节:

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

Launching through the loader means 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 silently resolves against the wrong directory — GUI toolkits looking for
fonts or `assets/`, and helper binaries shipped alongside the program.

The wrapper exports `MCPP_BUNDLE_DIR` (the bundle root) for this. Resolve
against it first:

```c
const char *base = getenv("MCPP_BUNDLE_DIR");   /* set by run.sh */
/* fall back to /proc/self/exe only when unset */

If your application cannot be changed, use --mode vendored instead: it repoints PT_INTERP at the host loader, so /proc/self/exe is correct — at the cost of requiring the host's glibc to be at least as new as the one the artifact was built against.


同一节的中文版加到 `docs/zh/` 下对应文件(若存在)。

- [ ] **Step 6: Commit**

```bash
git add src/pack/pack.cppm docs/02-pack-and-release.md tests/e2e/30_pack_modes.sh
git commit -m "fix(pack): the self-contained wrapper broke /proc/self/exe and never said so"

Task 2–4: c_runtime 契约轴 —— 已撤销

做过,全绿,然后整条 reset。 设计文档 §3-S2 与 §5.2 记录了理由;摘要:

  • 它是照着 #375 提报者提出的解法做的,不是解他的问题(问题是「产物没法分发」)
  • 它唯一的新能力是 host-coupled = 把产物链到宿主 libc,而 hermetic 策略禁止穿越的第一条就是 /lib* 下的 libc。mcpp 能不用 host 就不用 host
  • 去掉那个值之后这条轴不剩任何能力(self-contained 已由 --target ...-musl 表达)
  • 它会造出「可不可以用宿主 libc」的第二个回答者(既有的是 [build] allow_host_libs)

代替它的是 Task 4'(下):#375 的真问题有三条 hermetic 答案,全都已存在;缺口在「其中一条自己是坏的」(Task 1 已修)加「三条都不可发现」。


Task 4': 让三条 hermetic 分发路径可发现

Files:

  • Modify: docs/02-pack-and-release.md + docs/zh/02-pack-and-release.md
  • Modify: docs/00-getting-started.md10-publishing-a-library.md(择一放「怎么分发」的入口)
  • Test: 文档改动,无自动化断言;判据是 §10 的 V1(三条路径各自在干净容器里跑通)

要写清的三条,并列、等价、都不用宿主 libc:

路径 命令 libc 从哪来 什么时候选它
A. 生态闭环 mcpp emit xpkgxlings install 目标机自己的 xlings 载荷,elfpatch 装机期重指 PT_INTERP/RUNPATH 目标机在生态内
B. 静态单文件 --target x86_64-linux-musl 自带,静态 任何 Linux,零运行期依赖
C. 自带运行时 mcpp pack --mode self-contained 自带这套工具链的 glibc + loader 任何 Linux,含比构建机更老的

A 要特别写明一件容易误读的事:产物里烙的 PT_INTERP 指向构建机路径不构成分发障碍 —— 走 A 时目标机的 xlings 会重写它。#375 观察到的「路径不存在所以起不来」,前提是绕开生态直接拷贝二进制。

不写:任何「让产物链到系统 libc」的做法。已有的那个决定只有一个入口([build] allow_host_libs),文档不给它第二个说法。

  • Step 1docs/02-pack-and-release.md 顶部「Two axes」之前加一节 "Three ways to ship, none of which use the host's libc",内容如上表
  • Step 2 中文版同步
  • Step 3 Commit

Task 5: 读 xlings 的 subos 自描述

Files:

  • Create: src/xlings/subos_info.cppm(模块 mcpp.xlings.subos_info)
  • Test: tests/unit/test_subos_info.cpp

Interfaces:

  • Consumes: mcpp.libs.json

  • Produces:

    namespace mcpp::xlings::subos {
      inline constexpr int kSupportedSchema = 1;
      struct EnvDecl   { std::string var, op, value; };
      struct Provider  { std::string binding; std::vector<EnvDecl> decls; };
      struct Info {
          int schema = 0;
          std::string runtime;              // "glibc@2.39"
          std::vector<Provider> providers;  // sorted by binding
          bool present = false;             // the block existed at all
          std::string note;                 // non-empty ⇒ caller MUST print it
      };
      Info read(const std::filesystem::path& subosDir);
      std::string family_of(std::string_view runtime, std::string_view arch = "x86_64");
      std::vector<std::pair<std::string,std::string>>
      resolve_env(const Info&, const std::filesystem::path& subosDir);
    }
  • Step 1: 写失败的单测

创建 tests/unit/test_subos_info.cpp:

#include <gtest/gtest.h>

import std;
import mcpp.xlings.subos_info;

namespace {

namespace su = mcpp::xlings::subos;

struct Tmp {
    std::filesystem::path dir;
    Tmp() {
        dir = std::filesystem::temp_directory_path()
            / ("mcpp_subos_" + std::to_string(std::random_device{}()));
        std::filesystem::create_directories(dir);
    }
    ~Tmp() { std::error_code ec; std::filesystem::remove_all(dir, ec); }
    void write(std::string_view body) {
        std::ofstream(dir / ".xlings.json") << body;
    }
};

TEST(SubosInfo, ReadsRuntimeAndEnvs) {
    Tmp t;
    t.write(R"({
      "workspace": {},
      "subos_info": {
        "schema_version": 1,
        "runtime": "glibc@2.39",
        "envs": [
          { "binding": "mesa@25.0.7.1", "decls": [
            { "var": "LIBGL_DRIVERS_PATH", "op": "prepend",
              "value": "${subosdir}/usr/lib/dri" }
          ]}
        ]
      }
    })");
    auto info = su::read(t.dir);
    EXPECT_TRUE(info.present);
    EXPECT_EQ(info.schema, 1);
    EXPECT_EQ(info.runtime, "glibc@2.39");
    ASSERT_EQ(info.providers.size(), 1u);
    ASSERT_EQ(info.providers[0].decls.size(), 1u);
    EXPECT_EQ(info.providers[0].decls[0].var, "LIBGL_DRIVERS_PATH");
    EXPECT_TRUE(info.note.empty());
}

// ${subosdir} is expanded against the subos this block was read from.
TEST(SubosInfo, ResolvesSubosdirPlaceholder) {
    Tmp t;
    t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39",
      "envs":[{"binding":"mesa@1","decls":[
        {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}]}})");
    auto env = su::resolve_env(su::read(t.dir), t.dir);
    ASSERT_EQ(env.size(), 1u);
    EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH");
    EXPECT_EQ(env[0].second, (t.dir / "usr/lib/dri").string());
}

// A subos made before xlings grew the block: degrade, and SAY SO. Silence
// here is what made mcpp#352 hard to find in the first place.
TEST(SubosInfo, MissingBlockDegradesWithANote) {
    Tmp t;
    t.write(R"({"workspace":{}})");
    auto info = su::read(t.dir);
    EXPECT_FALSE(info.present);
    EXPECT_TRUE(info.runtime.empty());
    EXPECT_FALSE(info.note.empty());
}

// A schema newer than we understand: use what we can, and say we are behind.
TEST(SubosInfo, NewerSchemaDegradesWithANote) {
    Tmp t;
    t.write(R"({"subos_info":{"schema_version":99,"runtime":"glibc@2.44","envs":[]}})");
    auto info = su::read(t.dir);
    EXPECT_TRUE(info.present);
    EXPECT_EQ(info.runtime, "glibc@2.44");
    EXPECT_FALSE(info.note.empty());
}

TEST(SubosInfo, NoFileAtAllIsNotACrash) {
    Tmp t;   // nothing written
    auto info = su::read(t.dir);
    EXPECT_FALSE(info.present);
    EXPECT_FALSE(info.note.empty());
}

// The family mapping must agree with xlings's own, verbatim.
TEST(SubosInfo, FamilyOfMirrorsXlings) {
    EXPECT_EQ(su::family_of("glibc@2.39"), "linux-x86_64-glibc");
    EXPECT_EQ(su::family_of("musl@1.2.5"), "linux-x86_64-musl");
    EXPECT_EQ(su::family_of("glibc@2.39", "aarch64"), "linux-aarch64-glibc");
    EXPECT_EQ(su::family_of("wasi-libc@1"), "wasm32-wasi");
    EXPECT_EQ(su::family_of("nonsense@1"), "unknown");
}

// Malformed JSON must not take the build down.
TEST(SubosInfo, MalformedJsonDegrades) {
    Tmp t;
    t.write("{ this is not json");
    auto info = su::read(t.dir);
    EXPECT_FALSE(info.present);
    EXPECT_FALSE(info.note.empty());
}

}  // namespace
  • Step 2: 跑,确认失败
mcpp test --filter SubosInfo

Expected: FAIL,模块不存在

  • Step 3: 实现

创建 src/xlings/subos_info.cppm:

// mcpp.xlings.subos_info — read the `subos_info` block xlings writes into a
// subos's own `.xlings.json`.
//
// WHAT THIS IS FOR
//
// A program needs three things: bootstrap (PT_INTERP + CRT + libc), discovery
// (PATH + RPATH), and configuration (env vars). xlings had the first two —
// glibc + elfpatch, xvm + shims — and until it grew this block, nothing for
// the third. Its own module comment names the consequence: 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. This module ONLY reads and resolves;
// it never writes the block and never manages subos lifecycle — that is
// xlings's layer, and mcpp reaching into it is the layering inversion the
// ecosystem design calls out by name.
//
// A note on silence: every degradation here fills `note`, and callers are
// required to print it. "It did not happen" and "it succeeded" producing the
// same output is the property that made #352 expensive to find.
//
// Design: .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md §3-S3

export module mcpp.xlings.subos_info;

import std;
import mcpp.libs.json;

export namespace mcpp::xlings::subos {

// The schema this build understands. A higher one on disk is usable — we read
// the fields we know — but the caller is told it is reading a newer format.
inline constexpr int kSupportedSchema = 1;

inline constexpr std::string_view kBlock = "subos_info";

struct EnvDecl {
    std::string var;
    std::string op;      // "set" | "prepend"
    std::string value;   // may contain ${subosdir}
};

struct Provider {
    std::string          binding;   // "<name>@<version>"
    std::vector<EnvDecl> decls;
};

struct Info {
    int                   schema = 0;
    std::string           runtime;    // "glibc@2.39"
    std::vector<Provider> providers;  // sorted by binding
    bool                  present = false;
    // Non-empty ⇒ the caller MUST surface it. Never a hard error: a missing
    // or newer block degrades the experience, it does not invalidate a build.
    std::string           note;
};

// The runtime string is self-describing: "glibc@2.39" says Linux/glibc
// without a second field that could disagree with it. Mirrors xlings's
// `subos::manifest::family_of` — the mapping is small, stable and part of the
// cross-repo contract, so it is asserted in tests rather than left implicit.
std::string family_of(std::string_view runtime,
                      std::string_view arch = "x86_64") {
    const auto at   = runtime.find('@');
    const auto name = runtime.substr(0, at == std::string_view::npos
                                            ? runtime.size() : at);
    if (name == "glibc")     return std::format("linux-{}-glibc", arch);
    if (name == "musl")      return std::format("linux-{}-musl", arch);
    if (name == "wasi-libc") return "wasm32-wasi";
    if (name == "macos_sdk") return std::format("darwin-{}", arch);
    if (name == "ucrt")      return std::format("windows-{}-ucrt", arch);
    return "unknown";
}

Info read(const std::filesystem::path& subosDir) {
    Info info;
    auto path = subosDir / ".xlings.json";
    std::error_code ec;
    if (!std::filesystem::exists(path, ec)) {
        info.note = std::format(
            "subos '{}' has no .xlings.json, so it cannot say which runtime it "
            "is or what environment its programs need", subosDir.string());
        return info;
    }
    std::ifstream is(path);
    auto doc = nlohmann::json::parse(is, nullptr, /*allow_exceptions=*/false);
    if (doc.is_discarded() || !doc.is_object()) {
        info.note = std::format("subos manifest {} is not readable JSON",
                                path.string());
        return info;
    }
    auto it = doc.find(std::string(kBlock));
    if (it == doc.end() || !it->is_object()) {
        info.note = std::format(
            "subos '{}' does not describe itself (no `{}` block): its runtime "
            "identity and environment declarations are unavailable. A newer "
            "xlings writes this block; `xlings self update` adds it",
            subosDir.string(), kBlock);
        return info;
    }
    info.present = true;
    if (auto v = it->find("schema_version");
        v != it->end() && v->is_number_integer())
        info.schema = v->get<int>();
    if (auto v = it->find("runtime"); v != it->end() && v->is_string())
        info.runtime = v->get<std::string>();

    if (auto envs = it->find("envs"); envs != it->end() && envs->is_array()) {
        for (auto& p : *envs) {
            if (!p.is_object()) continue;
            Provider prov;
            if (auto b = p.find("binding"); b != p.end() && b->is_string())
                prov.binding = b->get<std::string>();
            if (auto ds = p.find("decls"); ds != p.end() && ds->is_array()) {
                for (auto& d : *ds) {
                    if (!d.is_object()) continue;
                    EnvDecl e;
                    if (auto x = d.find("var");   x != d.end() && x->is_string())
                        e.var = x->get<std::string>();
                    if (auto x = d.find("op");    x != d.end() && x->is_string())
                        e.op = x->get<std::string>();
                    if (auto x = d.find("value"); x != d.end() && x->is_string())
                        e.value = x->get<std::string>();
                    if (!e.var.empty()) prov.decls.push_back(std::move(e));
                }
            }
            info.providers.push_back(std::move(prov));
        }
    }
    // Sorted by binding, matching xlings's own ordering, so two reads of the
    // same subos produce the same environment in the same order.
    std::ranges::sort(info.providers,
                      [](auto const& a, auto const& b) { return a.binding < b.binding; });

    if (info.schema > kSupportedSchema)
        info.note = std::format(
            "subos '{}' declares schema {}, newer than the {} this mcpp "
            "understands; reading the fields it knows and ignoring the rest",
            subosDir.string(), info.schema, kSupportedSchema);
    return info;
}

// Resolve the declarations into concrete (var, value) pairs, `${subosdir}`
// expanded. `prepend` entries for the same variable are joined with ':' in
// provider order; a `set` replaces whatever came before it, which is xlings's
// own precedence.
std::vector<std::pair<std::string, std::string>>
resolve_env(const Info& info, const std::filesystem::path& subosDir) {
    std::vector<std::pair<std::string, std::string>> out;
    auto expand = [&](std::string v) {
        constexpr std::string_view kPh = "${subosdir}";
        for (auto pos = v.find(kPh); pos != std::string::npos;
             pos = v.find(kPh, pos))
            v.replace(pos, kPh.size(), subosDir.string());
        return v;
    };
    for (auto const& p : info.providers) {
        for (auto const& d : p.decls) {
            auto value = expand(d.value);
            auto hit = std::ranges::find(out, d.var, &std::pair<std::string,std::string>::first);
            if (hit == out.end()) { out.emplace_back(d.var, value); continue; }
            if (d.op == "set") { hit->second = value; continue; }
            // prepend, de-duplicated: a doubled entry must not accumulate
            // across nested invocations.
            if (hit->second != value
                && !hit->second.starts_with(value + ":")
                && hit->second.find(":" + value) == std::string::npos)
                hit->second = value + ":" + hit->second;
        }
    }
    return out;
}

}  // namespace mcpp::xlings::subos
  • Step 4: 跑,确认通过
mcpp test --filter SubosInfo

Expected: PASS(7 组全绿)

  • Step 5: Commit
git add src/xlings/subos_info.cppm tests/unit/test_subos_info.cpp
git commit -m "feat(xlings): read the subos's own description instead of inferring it"

Task 6: mcpp run / mcpp test 应用 subos 环境

Files:

  • Modify: src/build/plan.cppm(把解析好的 env 放进 plan)
  • Modify: src/build/execute.cppm:279-300, 1210-1230(应用到子进程)
  • Test: tests/e2e/196_subos_env_reaches_program.sh(新建)

Interfaces:

  • Consumes: mcpp::xlings::subos::{read, resolve_env}
  • Produces: BuildPlan 新增 std::vector<std::pair<std::string,std::string>> subosEnv;

为什么不是自己拼图形变量:LIBGL_DRIVERS_PATH / __EGL_VENDOR_LIBRARY_DIRS / XDG_DATA_DIRS 的值由 xlings 的图形包声明,mcpp 只是把它们传下去。mcpp 里出现任何图形相关的字面量都是这条设计的违反。

  • Step 1: 写失败的 e2e

创建 tests/e2e/196_subos_env_reaches_program.sh:

#!/usr/bin/env bash
# requires: linux
# A variable a package declared into the subos must reach the program `mcpp
# run` launches. Until this landed, subos env declarations were applied only
# by `xlings subos use`, so a program started by mcpp saw none of them — the
# reason a GL binary could link fine and exit 255 (mcpp#352).
set -euo pipefail
. "$(dirname "$0")/_common.sh"

proj=$(mktemp -d)
trap 'rm -rf "$proj"' EXIT

# A subos that declares one variable, in xlings's own schema.
subos="$proj/subos"
mkdir -p "$subos/usr/lib/dri"
cat > "$subos/.xlings.json" <<'EOF'
{ "workspace": {},
  "subos_info": { "schema_version": 1, "runtime": "glibc@2.39",
    "envs": [ { "binding": "probe@1", "decls": [
      { "var": "MCPP_E2E_PROBE", "op": "prepend",
        "value": "${subosdir}/usr/lib/dri" } ] } ] } }
EOF

cd "$proj"
"$MCPP" new hello >/dev/null
cd hello
cat > src/main.cpp <<'EOF'
#include <cstdlib>
#include <cstdio>
int main() {
    const char* v = std::getenv("MCPP_E2E_PROBE");
    std::printf("PROBE=%s\n", v ? v : "(unset)");
}
EOF

out=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1)
echo "$out" | grep -q "PROBE=$subos/usr/lib/dri" || {
    echo "subos env did not reach the program:"; echo "$out"; exit 1; }
echo "PASS: subos env reaches the program"

chmod +x tests/e2e/196_subos_env_reaches_program.sh

MCPP_SUBOS_DIR 是本 task 引入的测试与覆盖用入口:默认解析仍是活动 subos。它让这条 e2e 不需要改动用户真实环境——记住 memory 里那条教训:破坏性脚本不在真机上试。

  • Step 2: 跑,确认失败
bash tests/e2e/196_subos_env_reaches_program.sh

Expected: FAIL,PROBE=(unset)

  • Step 3: 实现

src/build/plan.cppm,在 runtimeLibraryDirs 那段之后:

    // The subos's own environment declarations. mcpp reads them and passes
    // them on; it does not author them and does not know what they mean. That
    // is the whole point: when xlings adds a Vulkan loader or a new driver
    // bridge, this code does not change.
    {
        auto subosDir = mcpp::xlings::subos_dir_for_build(cfg);
        auto info     = mcpp::xlings::subos::read(subosDir);
        if (!info.note.empty()) mcpp::ui::warning(info.note);
        plan.subosEnv = mcpp::xlings::subos::resolve_env(info, subosDir);
    }

src/xlings.cppm 加一个解析器(单点,不让每个消费者各猜一次):

    // The subos whose environment a program built here should run under.
    //
    // MCPP_SUBOS_DIR overrides it outright — that exists so tests can exercise
    // this path without touching the developer's real environment, and so a
    // user can point a run at another subos without switching the active one.
    std::filesystem::path subos_dir_for_build(const config::GlobalConfig& cfg);

src/build/execute.cppm:在构造子进程环境的两处(run 的 279-300 与 ninja 侧 1210-1230)把 plan.subosEnv 合并进去,prepend 语义与既有 prepend_path_list 一致。

  • Step 4: 跑,确认通过
bash tests/e2e/196_subos_env_reaches_program.sh

Expected: PASS

  • Step 5: Commit
git add src/build src/xlings.cppm tests/e2e/196_subos_env_reaches_program.sh
git commit -m "feat(run): a program mcpp launches gets its subos's environment"

Task 7: 运行时身份成为显式轴

Files:

  • Modify: src/toolchain/abi.cppm(libc 维度带版本)
  • Modify: src/toolchain/model.cppm(ToolchainruntimeBinding)
  • Modify: src/build/prepare.cppm(解析优先级)
  • Test: tests/unit/test_abi.cpp

Interfaces:

  • Consumes: subos::read(...).runtimesubos::family_of
  • Produces: AbiProfile 新增 std::string libcVersion;(空 = 未知,不参与匹配),Toolchain::runtimeBinding

收窄的实现范围与理由:abi.cppmlibc 维度参与依赖解析,给它加一个参与匹配的版本轴会立刻改变全索引的解析结果(memory 里 index-floor-must-degrade 是同一族事故)。所以本 task 只做两件事:①把版本记录下来并在 mcpp doctor / --verbose 里可见;②runtimeBinding 的解析优先级落地。匹配语义不变,留给 platform manifest 那一轮。

  • Step 1: 写失败的单测

tests/unit/test_abi.cpp 追加:

// The libc dimension gains a version, and it is RECORDED, not matched. A
// dependency that says `abi:glibc` must keep matching a glibc@2.39 toolchain
// exactly as it did before — changing that would re-resolve the whole index.
TEST(Abi, LibcVersionIsRecordedButNotMatched) {
    mcpp::toolchain::Toolchain tc;
    tc.targetTriple   = "x86_64-linux-gnu";
    tc.runtimeBinding = "glibc@2.39";
    auto p = mcpp::toolchain::abi_profile(tc);
    EXPECT_EQ(p.libc, "glibc");
    EXPECT_EQ(p.libcVersion, "2.39");

    auto c = mcpp::toolchain::parse_abi_capability("abi:glibc");
    ASSERT_TRUE(c.has_value());
    EXPECT_TRUE(mcpp::toolchain::satisfies(p, *c));
}

// No binding resolved: the dimension still answers, without a version.
TEST(Abi, LibcVersionEmptyWithoutABinding) {
    mcpp::toolchain::Toolchain tc;
    tc.targetTriple = "x86_64-linux-gnu";
    auto p = mcpp::toolchain::abi_profile(tc);
    EXPECT_EQ(p.libc, "glibc");
    EXPECT_TRUE(p.libcVersion.empty());
}
  • Step 2: 跑,确认失败
mcpp test --filter Abi

Expected: FAIL,runtimeBinding / libcVersion 不存在

  • Step 3: 实现

model.cppmToolchain 加:

    // The runtime this toolchain's output is built against, in xlings's own
    // spelling ("glibc@2.39"). Resolved in prepare, in this order, every step
    // explicit — a "default is the convention" step is what grows a second
    // answerer (see the design doc §S1):
    //   1. --runtime
    //   2. [target.<triple>].runtime / [build].runtime
    //   3. the active subos's `subos_info.runtime`
    //   4. the payload actually resolved, with a note
    std::string runtimeBinding;

abi.cppmAbiProfilestd::string libcVersion;,并在 abi_profile() 的两条路径里从 tc.runtimeBinding@ 之后取值。satisfies() 不动。

prepare.cppm 按上述四级解析并写进 tc.runtimeBinding;第 4 级发一条 ui::info

  • Step 4: 跑,确认通过
mcpp test --filter Abi

Expected: PASS

  • Step 5: Commit
git add src/toolchain src/build/prepare.cppm tests/unit/test_abi.cpp
git commit -m "feat(toolchain): the runtime a build targets is read, not inferred"

Task 8: 文档

Files:

  • Modify: docs/05-mcpp-toml.md(c_runtime)

  • Modify: docs/02-pack-and-release.md(contract × mode 的关系)

  • Modify: docs/03-toolchains.md(runtime binding)

  • Modify: docs/zh/ 下的对应文件

  • Modify: .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md(标注已实施)

  • Step 1: 05-mcpp-toml.mdc_runtime

紧邻既有 cxx_runtime 一节,写明三值、默认值、以及与 mcpp pack --mode 的函数关系表(设计文档 §3-S2 那张)。必须写清 host-coupled 的语义是「宿主 glibc ≥ 构建时的那份」,不是「任何 Linux」。

  • Step 2: 02-pack-and-release.md 加一节 "Contract vs mode"

说明 mode 管「带多少东西」、contract 管「承诺什么」,并给出不可分发契约在 pack 时会被拒绝的行为。

  • Step 3: 03-toolchains.md 加 runtime binding

说明四级解析顺序与 --runtime

  • Step 4: 校验中文版同步
ls docs/zh/

对每个改过的英文文档,同步中文版。

  • Step 5: Commit
git add docs .agents/docs
git commit -m "docs: the two runtime contracts, and what each promises"

Task 9: 版本 + xlings pin + PR

Files:

  • Modify: src/version.cppm

  • Modify: src/xlings.cppm(kXlingsVersion)

  • Step 1: 查最新 xlings 版本

gh api repos/openxlings/xlings/releases/latest --jq .tag_name
  • Step 2: bump 两个常量

MCPP_VERSION2026.8.8.1;kXlingsVersion → 上一步的值。

只动这两个。 bootstrap pin(.xlings.json 那组)是自举起点,不随发布走 —— 一起 bump 会让全部 CI 去装一个还不存在的版本。

  • Step 3: 机器校验 pin 一致
.github/tools/check_version_pins.sh

Expected: 通过

  • Step 4: 全量本地验证
mcpp build && mcpp test
for t in 30_pack_modes 195_c_runtime_host_coupled 196_subos_env_reaches_program; do
    bash "tests/e2e/$t.sh" || echo "FAIL: $t"
done
  • Step 5: 开 PR
git checkout -b feat/xlings-runtime-substrate
git push -u origin feat/xlings-runtime-substrate
gh pr create --title "feat: xlings 作为运行时底座 —— libc 分发契约 + subos 环境 (#375, #352)" --body-file .agents/docs/pr-body.md

Task 10: mcpp-index 图形栈迁移(独立仓、独立 PR)

Files(/home/speak/workspace/github/mcpplibs/mcpp-index):

  • Modify: pkgs/c/compat.glfw.lua, pkgs/c/compat.glx-headers.lua, pkgs/c/compat.vulkan-runtime.lua

  • Deprecate: pkgs/c/compat.glx-runtime.lua

  • Step 1: 确认下游只有三个

cd /home/speak/workspace/github/mcpplibs/mcpp-index && grep -rln "glx-runtime" pkgs/

Expected: 恰好 4 个文件(含它自己)

  • Step 2: 三个消费者改为依赖 xim:graphics

compat.glx-runtime 保留为空壳 provider(仍声明 provides = {"opengl.glx.driver","x11.display"},但不再 symlink 宿主库),一个版本之后再删——下游包的 manifest 已发布,不能立刻失效(与 index-floor-must-degrade 同一条教训)。

  • Step 3: 验
bash tests/smoke.sh 2>/dev/null || ls tests/

按该仓既有验证入口跑。判据是渲染器身份,不是「窗口出现了」。

  • Step 4: PR

Self-Review

Spec coverage — 设计文档各节 → task 映射:

设计文档 Task
§1.6 / Q6 self-contained wrapper T1
§3-S2 c_runtime 契约 T2 + T3 + T4
§3-S3 subos 环境 T5 + T6
§3-S1 运行时身份 T7
§1.5 图形栈迁移 T10
§6 P0 文档 T1 Step5 + T8
§7 跨仓契约(xlings 落盘 exports) 不在本 PR —— 需 xlings 侧改动,设计文档已排 P1 尾;本 PR 的 §5.1「A 降级路径」足以工作
§5.3 / P2 视图寻址 不做(设计文档明确)

未覆盖且是有意的:§2.1 六个回答者里,本 PR 收敛的是 #2/#3/#5(loader 选择统一到 link_tokens + distro_loader_path)。#1/#4/#6 依赖 xlings 侧落盘 exports,留 P1。V5 的静态计数守的是「不再增加」,不是「已经归零」。

Type consistency — 跨 task 一致性已核:dist::Contract / dist::Role / dist::Format 三者在 T2/T4 同名;libcdist::Addressingtc::LibcAddressing有意的镜像对(层次不倒置),T3 的注释与 T2 的测试都点明了这一点,并由 T4 的 static_cast 单点转换。subos::Info / resolve_env 在 T5 定义、T6 消费,签名一致。