# xlings 生态闭环:一个进程只有一个 libc

> 2026-08-09 · 承接 `2026-08-08-three-open-items-analysis-and-plan.md` §1、
> `mcpp-community/mcpp#392`、`xim-pkgindex#578`(已回滚)
> 目标:把「我们的 loader / 我们的 libc / 宿主库」三者的关系一次性定清楚,
> 并给出**安装期就能判定**的闭环契约

---

## 0. 两个提法,都对,而且第二个更根本

> 「mcpp run 报 `GLIBC_2.42 not found`,是不是也是缺少库导致的?」

**报错文本的直接原因是版本地板**:宿主的 `/lib64/libtinfo.so.6` 一直在,宿主 glibc
2.43 也比它要的 2.42 新;是**我们的 libc 2.39 太旧**,给不出那个符号版本。

> 「难道不是 xlings 生态缺少这个库才走到 host 的吗?按理能不用 host 就不用 host」

**对,而且这是更根本的那一层。** 我第一版把它写成「不是缺库」,矫枉过正了:
如果闭包里根本没有宿主库,版本地板就永远不会被触发。实测:

```
xim 主索引:      没有 ncurses / libtinfo
子索引 scode:    有 ncurses.lua
本机 xim-x-xmake/3.0.8 的 xmake:  NEEDED libtinfo.so.6
```

所以两层要分开说:

| 层 | 问题 | 修法 |
|---|---|---|
| 表层 | 我们的 libc 满足不了宿主库 | 抬版本地板 |
| **根层** | **宿主库为什么会进闭包** | **补齐闭包,让它根本不进来** |

**只修表层是把 bug 换个版本号继续等着。** 下面的方案以根层为主。

### 0.1 而且这条契约仓库里已经有了

`xim-pkgindex/.github/scripts/dep-closure-check.sh` 的 **D2** 写的正是这件事:

```
用我们的 loader 的载荷  →  host-only soname 是 hard failure
还在宿主 loader 上的载荷 →  host-only 是 note,"the documented arrangement, not a leak"
```

理由和 §3 我测到的一致(ld.so cache 路径不存在,切过去就没有宿主回退)。
**契约是对的,不需要重新发明。** 真正的缺口是它管得到谁——见 §4.4。

---

## 0.1.5 更正:`mcpp#392` 是生态闭包问题,与用户构建无关

我在 §0.2 曾把它归到「用户链了宿主库,所以抬地板」。**这个归因是错的。**

复看链路:**用户用的是我们生态的 GL/GLX,是那条依赖链间接拉进了宿主 `libtinfo`。**
用户没有主动选择任何生态外的库。所以这是**索引包自己闭包不全**——规则 D 的违反,
**发生在我们这一侧**,和用户怎么构建没有关系。

它只是**通过**用户的构建暴露出来而已。区分这一点很要紧,因为两种归因指向的修法
完全相反:

| 归因 | 修法 | 对不对 |
|---|---|---|
| 用户链了宿主库 | 抬版本地板,让宿主库能共存 | **缓解,不是根治** |
| **我们的 GL 链闭包不全** | **补 ncurses,让宿主库根本不进来** | **根治** |

两件都要做,但**主次不能颠倒**:补闭包是根治,抬地板是让硬件例外那一小撮
(以及尚未补齐的缺口)不至于把人卡死。

> 本机没能复现这条链(mesa 未装,本机 llvm 不 NEEDED libtinfo);
> 报告者是 WSL2 + 系统 Mesa。**复现条件写在这里,别把「我没测到」当成「不存在」。**

## 0.2 划清一条线:哪些是物理,哪些是生态自律

**这条线我第一版划错了**,把生态政策套到了用户产物上。更正:

| | 适用范围 | 性质 | 违反的后果 |
|---|---|---|---|
| **规则 A**(版本地板)<br>**规则 B**(loader/libc 同源) | **一切二进制**,含用户产物 | **物理** | 跑不起来,没有商量余地 |
| **规则 D**(闭包完整) | **只有 xlings 索引包** | **生态自律** | 生态不自洽,但用户程序可能跑得好好的 |

**用户构建时用什么,是用户的事。** 默认是 mcpp 生态 + 已有的自定义库模块,
xlings 只提供**基础环境**(loader / libc / 工具链)。要求用户产物的每个 soname
都由 xlings 提供,是把我们的自律强加给别人 —— 不做。

于是有一条直接推论,而且它改变了 `mcpp#392` 的定性:

> **既然用户产物被允许链接宿主库,那么「我们提供的基础环境必须能和宿主库共处」
> 就不是可选项,而是基础环境的基本职责。**
>
> 所以 `mcpp#392` 的正解是 **C1(抬版本地板)**,不是把用户逼进完整闭包。

反过来说,**生态内部**必须闭环:索引包是我们发的,它们依赖什么由我们决定,
没有理由把宿主的不确定性写进自己的产物。规则 D 只管这一侧。

---

## 1. 物理约束(对一切二进制成立)

一个进程**只有一个动态加载器、一个 libc**。进程里每一个被载入的 .so 都必须与那一份
libc 兼容。所有故障都是这条约束的推论。

glibc 的兼容性是**单向**的:

```
新 libc 能满足旧库的符号要求      ✔  (向后兼容)
旧 libc 不能满足新库的符号要求    ✘  (不向前兼容)
```

于是有第一条硬规则:

> **规则 A(版本地板)**:若进程里会载入任何宿主库,则
> `our_glibc >= max(host_glibc, 所有被载入宿主库要求的最高 GLIBC_x.y)`

以及第二条:

> **规则 B(同源)**:`PT_INTERP` 指向的 ld.so 与实际载入的 `libc.so.6`
> **必须来自同一份 glibc 载荷**。

---

## 2. 四种组合,三种失败,而且症状各不指向病因

全部实测,不是推演:

| # | loader | libc | 载入的库 | 结果 | 实测症状 |
|---|---|---|---|---|---|
| 1 | 宿主 | 宿主 | 宿主 | ✔ | 今天的默认 |
| 2 | **宿主** | **我们的** | — | ✘ 段错误 | `__vdso_gettimeofday` + `Segmentation fault`(`xim-pkgindex#578`,已回滚) |
| 3 | 我们的 | 我们的 | **宿主(要求更新)** | ✘ 起不来 | `libc.so.6: version 'GLIBC_2.42' not found (required by /lib64/libtinfo.so.6)`(`mcpp#392`) |
| 4 | 我们的 | 我们的 | 宿主(SONAME 查找) | ✘ 找不到 | `UnsatisfiedLinkError: libawt_xawt.so: libX11.so.6`(2026.8.8.1) |

**没有一条症状指向病因**:2 报的是段错误、3 报的是「库缺符号」、4 报的是「缺库」。
三条都会让人去补包,而只有 4 是真的和「有没有那个包」有关。

组合 2 的存在尤其要记:它是**只声明依赖、不切 loader** 的直接产物——把我们的
glibc 写进 RPATH 而 `PT_INTERP` 仍是宿主的。**「安全的中间态」不存在。**

---

## 3. 一个根:载荷里带着打包机的绝对路径

三处独立故障,同一台机器:

```
gcc specs      /home/xlings/.xlings_data/subos/linux/lib/ld-linux-x86-64.so.2
ld.so cache    /home/xlings/.xlings_data/xim/xpkgs/fromsource-x-glibc/<ver>/etc/ld.so.cache
llvm clang.cfg …/xim-x-glibc/2.39/…            (mcpp#392 记录)
```

- 第一条造成 `xlings#509` 的 aarch64 ENOENT(16 轮 CI、4 个错误修复)
- 第二条造成组合 4:**我们的 ld.so 没有可用的 cache**,切过去之后宿主库
  **只能靠 RPATH/LD_LIBRARY_PATH 找到,SONAME 查找整体失效**
- 第三条把 2.39 焊死进工具链配置

> **规则 C**:载荷中任何指向绝对路径的东西(specs、cfg、ld.so cache、RPATH)
> 都必须在**安装机**上重新生成或验证,不能相信打包机写下的值。

---

## 4. 闭环契约

### 4.1 定义边界:core 与 leaf

```
core  = ld.so + libc + libstdc++/libgcc_s + 直接被它们约束的运行时
        → 必须整套来自我们,不可混用
leaf  = 一切其他库(X11、ALSA、freetype、fontconfig、mesa、ncurses…)
        → 可以来自我们,也可以来自宿主,但受规则 A 约束
```

判据不是「重不重要」,而是**「换掉它会不会改变 libc 契约」**。

### 4.2 两种合法形态,不存在第三种

**形态 H(host-anchored)**——今天的默认,也是最省事的:

```
PT_INTERP = 宿主 ld.so
libc      = 宿主
leaf      = 宿主为主;我们的 leaf 可用,但 RPATH 里绝不能出现我们的 glibc
```

**形态 X(xlings-anchored)**——目标形态:

```
PT_INTERP = 我们的 ld.so
libc      = 我们的(同一份载荷)
leaf      = 我们的优先;允许宿主 leaf,但必须满足规则 A
```

**任何二进制必须整体处于 H 或 X,不能一半一半。** 组合 2 就是「一半一半」。

**2026-08-09 定调(见 C5)**:**X 是目标形态,H 降级为过渡态。** 已有的 H 可以留着,
但新东西不得停在 H,且 X 下的宿主库只能经 `*-host-link` 进入。

### 4.3 形态 X 的三个前置条件

1. **版本地板**:`our_glibc >= host_glibc`。今天默认 runtime 是 `glibc@2.39`,
   而 Ubuntu 24.04 就是 2.39、更新的发行版已到 2.43 —— **默认值等于「在任何比
   打包机新的系统上,形态 X 都会踩规则 A」**。这不是个别用户的环境问题,是默认值选错了。
2. **cache 可达**:我们的 ld.so 必须能找到一份真实的 `ld.so.cache`,否则宿主 leaf
   只能靠 RPATH 逐个点名(组合 4)。
3. **闭包已知**:切之前必须知道这个二进制会载入哪些库——**包括 dlopen 的**。
   DT_NEEDED 不够:fontconfig 就是被 dlopen 的,任何只看 NEEDED 的清单都会漏掉它。

### 4.4 缺口:D2 管的是索引包,而 mcpp 把**用户二进制**放进了形态 X

这是 `mcpp#392` 真正的位置,也是 §0 那个问题问出来的东西。

D2 的判据是对的,但它只在 **xim-pkgindex 的 CI** 上、对**索引里的包**运行。
而 `mcpp#392` 里崩掉的不是索引包,是**用户自己的项目产物**:

```
llvm@22.1.8/bin/clang.cfg  硬编码
    -Wl,--dynamic-linker=…/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2
    -Wl,-rpath,…/xim-x-glibc/2.39/lib64
```

**默认工具链把每一个用户二进制都编进形态 X** —— 也就是契约最严格的那一侧 ——
而那一侧:

- 没有 D2 检查(它不是索引包,CI 看不到)
- 没有闭包保证(`xim` 主索引里根本没有 ncurses/libtinfo)
- 没有宿主回退(ld.so cache 路径不存在)

于是用户链上任何一个我们没打包的库,都会在**运行时**炸,报一个指向 glibc 版本的错误。

**这才是「能不用 host 就不用 host」这句话真正的落点**:不是「多打几个包」,
而是——

> **谁把二进制放进形态 X,谁就要为那个二进制的完整闭包负责。**

今天是 mcpp 的默认配置把它们放进去的,但负责闭包的机制只覆盖索引包。**责任和能力
不在同一处**,这是本文档全部四种故障里唯一的结构性问题,其余都是它的表现。

两个方向,必须选一个(见 §5 的 C5):

- **X-完整**:凡是工具链可能链进去的库,索引必须全有(ncurses 是当前已知缺口),
  并对用户产物也跑一次 D2
- **H-默认**:用户二进制默认编成形态 H(宿主 loader + 宿主 libc),
  形态 X 变成显式 opt-in,并只在闭包被证明完整时才允许

**倾向 H-默认**:形态 X 的价值是可复现和可移植,而它今天既不可复现(依赖宿主
有没有那个库)也不可移植(依赖宿主 glibc 够不够新)。在闭包补齐之前,
它拿到的是两者的缺点。

---

---

## 5. 方案

### C1 — `glibc` 的 `latest` 与默认 runtime 都停在 2.39,这是系统性错误

**当前状态**(`pkgs/g/glibc.lua:81`):

```lua
["latest"] = { ref = "2.39" },   -- 2.44 已存在于同一个表里
```

**它是故意的,而且注释写了理由**——但那段注释里同时写着反例:

> 「什么用 2.44:更新的 runtime 能承载更多外部预编译二进制——厂商的、发行版的、
> **nvidia-gl-host-link 链接的宿主 NVIDIA userspace**——它们在 2.44 下能载入,
> 在 2.39 下不能。」

`mcpp#392` 正是这一条。当时的决策模型只算了「我们自己的载荷都按 2.39 编,所以
2.39 全跑得动」,**没算进「宿主库一定会进闭包」**。而 host-link 是永久例外,
所以宿主库永远可能在闭包里 →

> **`latest = 2.39` 对任何 glibc 新于 2.39 的宿主都是系统性错误,不是个别环境问题。**

注释自己也承认「移到 2.44 同样安全」(向后兼容:2.39 编的载荷在 2.44 下照跑)。
剩下的唯一顾虑是「会静默改变每个已有 home 解析到的 glibc」——**那是迁移问题,
不是技术障碍**,而且现状的代价已经由用户在承担。

**两处要一起改,否则又是一个「改了一半」**:

```
pkgs/g/glibc.lua            ["latest"] = { ref = "2.39" }  → "2.44"
src/core/subos/manifest.cppm DEFAULT_RUNTIME = "glibc@2.39" → "glibc@2.44"
```

#### 口径已拍板(2026-08-09,已实现于 2026.8.9.1)

> **只影响新建 subos。绑定本身就是口径,不需要迁移。**

依据是三条既有机制,验证后全部成立:

1. **绑定已持久化**:`runtime` 是 subos 创建期属性,写死在各 subos 自己的
   `subos_info` 里(`manifest.cppm`),读取端无静默回退——改常量天然只影响新建。
2. **依赖解析不会拉偏**:resolver 的 `pin_target_to_active`
   (`resolver.cppm:38-62`)让**已激活的版本在满足约束时压过索引最新**。
   实测索引里对 glibc 的依赖全是裸名/范围(15 个裸 `xim:glibc`、25 个
   `@>=2.38/2.39`),旧 subos 的 2.39 被 pin 住,新 subos 无 active →
   解析到 2.44。llvm 的 clang.cfg 从**自己的 dep 解析**推导 loader
   (`llvm.lua:210`),因此同样自动分流——旧 subos 得 2.39 cfg,新 subos
   得 2.44 cfg,**llvm.lua 一行都不用改**。
3. **修复路径曾是唯一漏洞,已堵上**:`doctor --fix` 和三处块重铸原本无条件用
   `DEFAULT_RUNTIME` 重写坏块——默认值一变,修一个坏块就把存量 subos 悄悄改成
   2.44。新增 `preserved_runtime()`(单一谓词,六个重铸点共用):**块坏但
   runtime 合法时保留 runtime**,只修其余字段。e2e 断言了这一条。

另:「subos 创建时记录宿主 glibc」**复用 `subos_info` 机制**,不新建任何东西
——块里新增可选字段 `host_glibc`(创建/重铸时经 `/usr/bin/getconf` 探测;
绝对路径是因为 PATH 上的 `getconf`/`ldd` 可能是我们自己的 shim)。缺失 = 未知
= 不可证,规则 A 不判——不是判过。旧 manifest 无需迁移。

`mcpp#392` 的报告者手工改 `.xlings.json` + 删 2.39 + 全量重建才走通的那条路,
新建 subos 后不再需要;存量 subos 想上 2.44 的正路是
`xlings subos new <name> --runtime glibc@2.44`(新建绑定,不原地改)。

### C2 — 规则 D 的守卫:三个执行点,今天只有一个

#### C2.0 先把 D1 和 D2 分开,它们的可移植性完全不同

现有 `dep-closure-check.sh` 有两条断言,常被当成一条:

| | 判什么 | 需要的输入 |
|---|---|---|
| **D1** | 载荷 NEEDED 的、且**我们有包提供**的 soname,recipe 必须直接声明 | **recipe 的 `deps`** + 已安装载荷 |
| **D2** | INTERP 在我们这边时,**每个** external soname 都必须有提供者 | 只要 **ELF 本身 + 已安装载荷** |

**D1 是配方卫生,D2 是闭包完整性。** 而且——

> **provider map 是扫「已安装载荷」建的,不是读索引建的**
> (`dep-closure-check.sh` §"who provides what":遍历 `<xpkgs>/<ns>-x-<name>/<ver>/`,
> 且**符号链接算数**,因为 soname 通常就是那个链接)。

推论很关键:**D2 不需要 recipe 就能判**。而检查器今天在入口处写着

```
pkgs/*) ;;
*) [SKIP] recipe is not inside this index; cannot read its deps
```

—— 一个没有 recipe 的东西(用户产物)会被**整体跳过**,连能判的 D2 也一起跳过了。
**这就是「只覆盖索引包」的结构原因,不是没人想到。**

#### C2.1 三个执行点,判的东西不一样(见 §0.2 的分界)

| # | 执行点 | 对象 | 该判什么 | 今天 |
|---|---|---|---|---|
| 1 | **索引 CI** | 索引包 | **A + B + D**(生态自律,硬失败) | ✔ 已有 |
| 2 | **客户端安装期** | 索引包(装到用户机上) | **A + B + D** | ✘ 缺 |
| 3 | **用户构建期** | 用户产物 | **只有 A + B**(物理),**不判 D** | ✘ 缺 |

**执行点 3 不判 D,这是刻意的。** 用户链宿主库、链自己的模块,都是他的自由;
我们没有立场要求他的每个 soname 都由 xlings 提供。要判的只有「这个组合在物理上
能不能跑」:

```
规则 B:INTERP 的 glibc 载荷 == 实际会加载的 libc 载荷?
规则 A:our_glibc >= host_glibc?(只要闭包里有任何宿主库)
```

`mcpp#392` 恰好踩的是 A —— 而 A 的修法是 **C1**,不是逼用户闭包。

执行点 2 值得单独说:**客户端此刻手里是有 recipe 的**(它刚执行完 install/config),
所以对**索引包**它能判的和索引 CI 一样多,今天却一条都不判。同一份逻辑,在 CI 里
是硬失败,在用户机器上完全不存在——`mcpp#392` 的用户装 llvm 时没有任何东西拦过他,
而那一步装的是**我们的包**,本就该按生态标准判。

#### C2.2 host-link 例外不需要特判

规则 D 的豁免是「由已声明的 `*-host-link` 提供」。而 host-link 包**把宿主库
以符号链接接进我们的 sysroot**,于是它们在 provider map 里**本来就有提供者**。

所以 D2 原样跑就已经放行了它们,**不需要为例外写任何特殊分支**——这是这个机制
选得好的地方,也是不该另起炉灶的理由。

#### C2.3 dlopen:静态门 + 运行期验收,两层

D2 走 `DT_NEEDED`,**看不见 dlopen**。fontconfig 就是 dlopen 的(§4.3)。所以:

```
静态 D2      构建/安装期  →  快、能拦住绝大多数、可作为硬门
运行 provenance  测试/CI 期  →  LD_DEBUG,唯一完整,但要真跑起来
```

**不能只要一层**:只有静态门 → dlopen 的漏网(2026.8.8.1 的 AWT);
只有运行期 → 拦不住没被测试覆盖的路径,而且失败已经在用户机器上了。

#### C2.4 失败时给什么

拦下来不是目的,可修才是。三种情况三种话:

```
soname 有提供者、但没装   →  "libtinfo.so.6 由 scode:ncurses 提供,尚未安装:
                              xlings install scode:ncurses"
soname 索引里根本没有     →  "libtinfo.so.6 在任何索引里都没有提供者。
                              形态 X 的二进制不能依赖宿主库(硬件例外走 *-host-link)。"
形态不一致(规则 B)      →  "INTERP 来自 glibc@2.39,而 RPATH 提供 libc 的是
                              glibc@2.44——同一进程不能有两份 libc。"
```

第二种是真的要人去补包的那一种,**也只有它才是「缺库」**。前面两种都不是。

#### C2.5 上线顺序

因为用户侧只判 A/B(物理),不判 D(政策),**破坏性比我第一版估计的小得多**:
用户产物不会因为「链了宿主库」被拦,只会在「这个组合根本跑不起来」时被拦——
而那本来就是要炸的,只是提前到构建期、并且给出指向病因的话。

```
1. C1 抬版本地板              ← mcpp#392 的正解,先做
2. 执行点 2(安装期,索引包)  ← 先 warn-only,收集真实缺口
3. 执行点 3(用户产物,只 A/B) ← 可以直接硬失败,因为它拦的是必然崩溃
4. 生态内补齐缺口(ncurses 等),把执行点 2 转硬失败
```

第 3 步能直接硬失败,正是因为它**不含政策判断**:拦下来的每一个,今天跑起来
也一定是崩的。

#### C2.6 实现状态(2026-08-09,xlings 2026.8.9.1)

执行点 2 已上线,warn-only:`src/core/closure_check.cppm` +
`installer.cppm` 安装循环内(config 之后、mark_installed 之前,与 elfcheck
同为「新装载荷才扫」)。要点:

- **规则 D**:形态 X(INTERP 在 xpkgs 内)的每个 NEEDED soname 必须由载荷
  自身或任一已安装载荷提供;provider map 扫 `<xpkgs>` 全店,**符号链接算数**
  (soname 通常就是链接),被测载荷自身除外——与 CI 脚本同一组来之不易的
  性质,host-link 因此天然放行(C2.2)。
- **规则 A**:INTERP 载荷的 glibc 版本 < subos 记录的 `host_glibc` 时告警;
  `host_glibc` 缺失 = 未知 = 不判。
- **规则 B 不重复**:elfcheck 已在同一循环里硬失败。
- **防 predicate drift**:`tests/e2e/closure_guard_differential_test.sh`
  (E2E-68)在同一棵 rigged 树上跑客户端守卫与 CI 的
  `dep-closure-check.sh`,断言两边同时点名同一个缺口
  (libnothere.so.9)、同时放过已提供的 soname——两套实现,一份 fixture,
  不可能静默漂移。
- 客户端**不知道**「缺口该由索引里哪个包补」(需要 soname→包 的索引级映射,
  客户端没有)——C2.4 的第一种话术留给 CI 侧,客户端只报第二、三种。

执行点 3 在 mcpp 侧,本轮不实现,以 issue 形式带完整设计提给
mcpp-community/mcpp(判 A/B 两条物理规则,可直接硬失败)。

### C3 — 让我们的 ld.so 有一份真实的 cache

三个可选,按侵入性排序:

- **C3a** 安装 glibc 时用我们的 `ldconfig` 在**安装机**生成 cache 到载荷内,
  并确保 ld.so 编译时的 cache 路径指向该位置(即规则 C 应用到 cache)。
- **C3b** 不用 cache,改为在切形态 X 时把**宿主库目录**显式写进 RPATH 尾部。
  简单,但 RPATH 会变长,且宿主目录因发行版而异。
- **C3c** 完全闭包:形态 X 下不允许任何宿主 leaf。最干净,但要打的包最多,
  而且 §1 的 D5 测量已表明**并不需要**——我们的 freetype 只依赖 libc,
  brotli/bz2/png 会自动离开闭包。

**倾向 C3a**,因为它同时消掉规则 C 的这一处实例;C3b 作为过渡。

### C5 — 决定:**X-完整**,唯一例外是宿主闭源/硬件库(已拍板 2026-08-09)

> loader / libc / 库 **都该是我们的**,除了极少数宿主闭源硬件库。

这把形态 H 从「另一种合法形态」降级为**过渡态**:允许存在,但不是目标,
而且任何新东西不得停在那里。

**例外走且只走一个通道:`*-host-link` 哨兵包。** 这个机制已经存在,不需要发明:

```
pkgs/n/nvidia-gl-host-link.lua    NVIDIA 专有 GL/EGL
pkgs/w/wsl-gl-host-link.lua       WSL2 的 GL
pkgs/l/libcuda-host-link.lua      libcuda
```

它们**无载荷**,做的是「把宿主库以稳定符号链接接进我们的 sysroot 并声明为 sysroot
资产」,于是由**我们控制的 RPATH** 解析。

这带来一个重要简化:**宿主库不再需要 ld.so.cache 就能被找到**,因为它们是以
显式路径进入我们的 sysroot 的,不走 SONAME 全局查找。**C3 因此退出关键路径。**

判据也随之变成一条可机械判定的规则:

> **规则 D**:形态 X 的二进制,其闭包里的每一个 soname 必须由**索引里的包**提供;
> 唯一豁免是由**已声明的 `*-host-link` 依赖**提供的那些。
> 没有第三种来源。

#### 这个决定没有取消版本地板,反而让它永久化

`*-host-link` 接进来的是**宿主编译的**库(NVIDIA 的 `libGLX_nvidia.so` 等),
它们需要**宿主 glibc 的符号**。所以只要硬件例外存在:

> **`our_glibc >= host_glibc` 是永久约束,不是过渡期措施。**

这是本决定最重要的推论:**C1 留在关键路径上**,而且性质从「修一个 bug」变成
「我们的 glibc 必须持续跟进所支持发行版的最高 glibc」。默认值停在 2.39 就是
今天 `mcpp#392` 的原因,而它会随每个新发行版复发。

#### 已知要补的闭包缺口

```
ncurses / libtinfo    xim 主索引没有(scode 有);xmake、llvm 工具链会链到
```

这是**当前已知的一个**,不是全部。规则 D 落地后,缺口会由 CI 逐个报出来——
这正是它比人工清单可靠的地方。

### C4 — 闭包必须包含 dlopen

`DT_NEEDED` 不是闭包。fontconfig 由 libfontmanager `dlopen`,任何静态清单都看不到。

**做法**:形态 X 的验收统一用 **运行期 provenance**:

```
LD_DEBUG=libs <cmd> 2>&1 | grep "calling init:"
```

断言里面没有宿主路径(或只剩明确允许的 leaf)。这条**今天跑 JDK 会失败**
(15 个宿主对象),这正是它有价值的证据。

---

## 6. 落地顺序

```
C5  ✔ 已定:X-完整 + host-link 例外
C1  默认 runtime 版本地板     ← 现在是永久约束,不是一次性修复。最高优先
C2  规则 D 的守卫,且要覆盖用户产物,不只索引包
C6  补 ncurses(已知缺口),之后由 CI 报出其余
C4  provenance 验收固化       ← 唯一能看见 dlopen 的验收
C3  ld.so cache               ← 退出关键路径(host-link 用显式路径,不走 cache)
然后才谈 JDK 切 loader(D5-4)
```

顺序变了:C5 定了 X-完整之后,**C1 从「可选」变成「永久且最高优先」**——因为
硬件例外会一直把宿主库带进来,而它们要宿主 glibc 的符号。C3 相反,因为
host-link 机制不依赖 cache,降级为「以后再说」。

**顺序不能换。** 先切 loader 再补这些,就是 2026.8.8.1 和 `#578` 已经付过两次的学费。

---

## 7. 验收(全部可证伪,今天跑都会失败)

| 项 | 断言 | 写下时的结果 | 2026-08-09 落地后 |
|---|---|---|---|
| 规则 B | 任一 ELF 的 INTERP 载荷 == 其 libc 载荷 | `#578` 的产物会失败 | elfcheck 安装期硬失败(先前已有) |
| 规则 A | `our_glibc >= host_glibc` | 默认 2.39 在 2.43 宿主上失败 | C1 落地:默认 2.44,`host_glibc` 记录进 subos_info,客户端 warn(closure_check) |
| **规则 D** | **形态 X 的闭包里每个 soname 由索引包或已声明的 host-link 提供** | **xmake 的 libtinfo 无提供者,失败** | ncurses 入索引(xlings-res/ncurses 6.5);真实 xmake 3.0.8 在纯 xim 闭环下实测跑通;客户端 warn 上线,差分测试钉住两套实现 |
| C4 | `LD_DEBUG` provenance 除 host-link 外无宿主对象 | JDK 15 个宿主对象,失败 | 字体探针 = glibc 族 6 个精确断言(L4 测试);音频改判宿主服务例外(§8);L3/L4 首次接入索引 CI(closure-lifecycle job) |

组合 4(SONAME 找不到宿主库)不再列为验收项:X-完整下宿主库只经 host-link 的
显式符号链接进入,本就不走 SONAME 全局查找。

---

## 8. 把契约应用到 JDK:D5 重写

> **2026-08-09 暂缓(用户决定)**:jdk-zulu 的 D5-1 实现"有点复杂,目前可以先不做
> 生态闭环,就默认用上游默认的即可"——recipe 回退到上游默认形态(无 deps 声明、
> 无 RUNPATH 手术)。本节的测量、机制与分步**全部保留**:D5-1 曾在真引擎下完整
> 验证过一次(17 包全 checkout 解析、宿主残留恰好 glibc 族 6 个),恢复时按本节
> + `feat/ecosystem-closure-d5` 分支的 1a80dc0d(已回退)照做即可。

D5 原来的分步是「**先加 RUNPATH 改解析顺序,把 host 计数降到只剩 glibc,再切 INTERP**」。
**这个分步在第一步就是错的,`#578` 已经用一次线上事故证明了。**

### 8.1 原分步为什么不成立

「加 RUNPATH 让我们的库赢」这件事,一旦把**我们的 glibc** 也写进 RUNPATH,
产物就是 §2 的**组合 2**(宿主 INTERP + 我们的 libc)——段错误,`java` 根本不启动。

而声明依赖时几乎必然带上 glibc:索引 CI 的 D1 就要求「NEEDED 的 soname 必须直接声明」,
`libc.so.6` 赫然在列。**于是「只改解析顺序、先不切 loader」这个中间态,在现有机制下
是走不通的**——不是没做好,是自相矛盾。

### 8.2 可行的分步:leaf 与 core 分开对待

关键区别(实测):

```
宿主 INTERP + 我们的 libX11 / libXtst / alsa  →  安全
宿主 INTERP + 我们的 glibc                     →  段错误
```

我们的 leaf 库按 2.39 编,宿主 glibc ≥ 2.39,**向后兼容让它们在形态 H 下也能用**。
坏的只有 core。所以:

```
D5-1  leaf 入闭包:libXrender 等补齐;RPATH 只写 leaf 的 libdir,绝不写 glibc
      → 仍是形态 H,安全,可单独发布
D5-2  量:LD_DEBUG provenance,确认 leaf 全部解析到我们的
      → 闸门 = 宿主对象只剩 glibc 族(6 个)
D5-3  core 原子切换:INTERP + libc 同一次改完,不允许中间态
      → 这一步之前必须先满足 C1(our_glibc >= host_glibc),否则切完踩规则 A
D5-4  验收:LD_DEBUG 宿主对象为 0(host-link 除外)+ headless Toolkit + 字体 + 音频
```

**D5-3 是原子的,没有「切一半」。** 这是 `#578` 最贵的一课。

#### 2026-08-09 实测修正一:音频是宿主服务,不进 RPATH

强制我们的 alsa-lib 后实测:`AudioSystem.getMixerInfo()` 从 16 个降到 **0 个**。
宿主 libasound 会 `dlopen` 它的 pipewire 插件
(`alsa-lib/libasound_module_pcm_pipewire.so`),把整条宿主链拖进闭包
(libpipewire/libsystemd/libdbus/libgcrypt/… 实测 16+ 个对象,随发行版漂移);
而我们的 alsa 载荷没有插件桥,**纯净度换来的是桌面音频枚举归零**——功能回归,
不是进步。

所以口径修正:**音频与 GPU 同类,是宿主服务集成。** D5-1 的 RPATH
**排除 alsa-lib**(形态 H 下 SONAME 回落宿主 libasound,音频保持可用);
D5-2 的闸门相应拆成两半:

```
字体/Toolkit 探针(headless,不碰音频) →  宿主对象 == glibc 族 6 个,精确断言
音频探针                               →  只断言功能(exit 0),不断言纯净度
```

alsa 桥接(我们的 libasound + ALSA_PLUGIN_DIR 指宿主插件目录,或打包
alsa-plugins)是 **D5-3 的新增前置**——切形态 X 后 SONAME 无宿主回退,
届时必须二选一,现在不必。

#### 实测修正二:`#578` 的真正死因是 API,不只是清单

`elfpatch.set({ rpath = ... })` 的 `rpath` 参数 **`_apply` 根本不读**
(libxpkg `elfpatch.lua`:override 分支只消费
enable/interpreter/interp_from/shrink/extra_rpath;RPATH 恒为
`closure_lib_paths()` 全量)。`#578` 递进去的 leaf 清单被丢弃,实际写入的是
含 **glibc lib64 + subos farm** 的全量 closure——组合 2 由此而来。

两个连带的地雷,D5-1 的实现因此长这样:

- **farm 目录(`subos/<name>/lib`)里有 `libc.so.6` 和 `ld-linux`**,
  `closure_lib_paths()` 又无条件把它接在尾部——形态 H 下任何把它写进
  可执行文件 RPATH 的路径都是组合 2。库文件因 SONAME 去重(libc 已载入)
  而侥幸安全,可执行文件没有这层保护。
- 声明 `xim:glibc` 会触发 elfpatch 的 predicate(「恰好一个 dep 声明
  loader」)**自动切 PT_INTERP + 写全量 closure**。所以 jdk-zulu 的
  `install()` 必须 `elfpatch.skip()`,再在 `config()` 里用低层
  `patch_elf_loader_rpath`(唯一接受自定义 rpath 的 API)对 7 个 dlopen
  调用者逐个做 leaf-only RUNPATH 手术。

#### 实测修正三:leaf 自身必须 sealed,jdk 的 RPATH 救不了传递闭包

RUNPATH 只作用于**载体自己的查找**;我们的 fontconfig 载荷若无 RUNPATH,
它的 NEEDED(freetype/expat)照样从 ld.so cache 回落宿主——实测宿主
freetype + brotli×2 + bz2 + png 全从这道侧门回流。fontconfig **recipe**
早已声明 deps 并调用 `selfcontain.seal`,但**本机载荷装于 seal 存在之前**
——同版本键不重装,修复到不了存量用户。

解法是既有惯例:**同 artifact 换版本键**(fontconfig 2.15.0 → 2.15.0.1),
jdk 依赖写 `xim:fontconfig@>=2.15.0.1`——已激活的 2.15.0 不满足下界,
pin-to-active 失效,强制装一份 sealed 载荷。

sealed fontconfig 下重放:**宿主残留恰好 = glibc 族 6 个**
(ld-linux/libc/libdl/libm/libpthread/librt),字体链全归我们,
brotli/bz2/png 如 §8.3 预言整体消失。**D5-2 的闸门在仿真中已达成**;
产品化验收 = `tests/j/test_jdk_zulu.py::test_font_stack_provenance`(L4)。

### 8.3 JDK 的闭包账(实测,不是估算)

`LD_DEBUG` 下 headless Toolkit + 字体 + 音频,27 个对象:

| 类 | 数 | 现状 | D5 之后 |
|---|---|---|---|
| glibc 族 | 6 | 宿主 | 切 core 时整体变成我们的 |
| 我们已有、但解析输了 | 7 | 宿主 | D5-1 的 RPATH 让它们赢 |
| 宿主 freetype 拖进来的 | 3 | 宿主 | **自动消失**(我们的 freetype 只依赖 libc) |
| 其余(我们的) | 11+ | 我们的 | 不变 |

**需要新打的包:0。** `libXrender` 索引里一直有,只是本机没装;
brotli/bz2/png 是**宿主 freetype** 的依赖,不是我们的。

这条账值得单独强调:D5 一直被当成「缺包」问题,**实测下来一个包都不缺**,
缺的是解析顺序和切换原子性。

### 8.4 和 mcpp#392 是同一件事

JDK 的 15 个宿主对象,和 GL 链拉进 `libtinfo`,是**同一个形状**:
**我们的包,闭包不全,靠宿主补齐,然后在某个宿主上崩掉。**

区别只在于 JDK 今天还在形态 H(所以只是「用了宿主的」,不崩),
而 mcpp#392 的产物已经在形态 X(所以直接崩)。

> **形态 H 掩盖闭包缺口,形态 X 暴露它。** 切 loader 之所以危险,
> 不是因为切换本身,而是因为**它把一直存在的缺口一次性变成故障**。

所以顺序只能是:**先补闭包(规则 D),再切形态**。反过来做,就是
2026.8.8.1 和 `#578` 各付一次的学费。

---

## 9. 一条贯穿线

这份文档里四种失败、三处绝对路径、两条规则,底下是同一件事:

> **系统允许一个二进制处于「一半我们、一半宿主」的状态,不在任何环节拦截它,
> 并把随之而来的崩溃报成一个完全不相关的现象。**

和 `2026-08-08-declared-vs-effective-open-defects-design.md` §3 是同一个形状,
只是这次不出声的不是「声明」,而是**运行时契约**。

所以修法也是同一条:**把契约变成安装期就能判定的谓词,判不过就拒绝,而不是让它跑起来再炸。**
