diff --git a/.agents/docs/2026-08-10-issue-524-525-root-cause-and-fix-design.md b/.agents/docs/2026-08-10-issue-524-525-root-cause-and-fix-design.md new file mode 100644 index 00000000..86cefee1 --- /dev/null +++ b/.agents/docs/2026-08-10-issue-524-525-root-cause-and-fix-design.md @@ -0,0 +1,428 @@ +# #524 / #525 根因分析与修复设计 + +> 状态:**Root cause measured, fix designed, not implemented** —— 两条 issue 的根因都有可运行的复现或实测证据, +> 不是从日志推断的。#524 的修复方案已经在离线 harness 上验证过,#525 的修复方案还只有机制证据、没有端到端实测。 +> +> 日期:2026-08-10 +> +> 对象:`openxlings/xlings#524`(冷装 gcc 失败)、`openxlings/xlings#525`(GLX 拿不到 FBConfig) +> +> 复现工具:`.agents/tools/repro-dep-install-dir.sh`(离线,无需 xlings / 网络 / 安装,不碰任何 home) +> +> 关联:libxpkg `0.0.54 → 0.0.55 → 0.0.56`、xlings `2026.8.10.1`(#519 / #514)、 +> `mcpp-community/mcpp#400`、`mcpp-community/mcpp#401` + +--- + +## 0. 结论先行 + +| | #524 冷装 gcc 失败 | #525 GLX 无 FBConfig | +|---|---|---| +| **根因** | libxpkg 0.0.55 把 `dep_install_dir` 的**匹配条件从「答案是否唯一」换成了「问题是否够具体」**,同时让 explicit-roots 分支**无条件 `return`**、切断了 scan 兜底。index 里没有一条 recipe 写成它要求的形状。 | glvnd 的 **GLX 侧根本没有 `egl_vendor.d` 的对应物**(实测:`libGLX.so.0` 里只有 `libGLX_%s.so.0` 和 `__GLX_VENDOR_LIBRARY_NAME`,没有任何目录变量)。vendor 可达性只能由**执行 dlopen 的那个对象自己的 RPATH** 提供,而我们的 `libGLX.so.0` 的 RPATH 至今只有 `$ORIGIN`。 | +| **怎么引入的** | 收紧规则时**没有对照真实调用点**。7 个调用点里 6 个是「bare + 无版本」,而新规则要求「namespaced + 精确版本」。 | 机制被验证过,但**验证探针用了 `-Wl,--disable-new-dtags`**,把可达性挂在**消费者的链接参数**上。第一个真实消费者(mcpp,默认 `--enable-new-dtags`)一接触就塌。`glxprobe.c:15-28` 把这条前置条件写得清清楚楚,然后当成前提而不是缺陷。 | +| **影响面** | **6/7 调用点断**,比 issue 报的多两条硬失败(meson 冷装同样失败)和两条**静默降级**(godot 悄悄回落宿主 GL、graphics 横幅显示 unknown)。 | 任何用默认链接参数的 GL 消费者。生态里目前**全部**如此。 | +| **0.0.56 修了吗** | **没有**。它只把同一个 nil 变响了:6/7 依然断。 | — | +| **修复** | libxpkg:bare 查询按**答案唯一性**判定(唯一性守卫本来就在),版本半边按**区间**匹配。实测 6→2。剩下 2 条是真正不同的问题,单独决策。 | 让 `xim:libglvnd` 的 `libGLX.so.0` 自带指向 **GLX vendor 目录**的 RUNPATH。这是 issue 提的方向 B 的正确形态:不是环境变量(glvnd 没有),而是一个目录 + 我们自己载荷上的 RPATH。 | + +**两条 issue 是同一个形态的两次发作**,见 §3。 + +--- + +## 1. #524 —— 冷装 `xim:gcc@16.1.0` 失败 + +### 1.1 回归窗口:正好一个 commit + +``` +origin/main 65002df 2026.8.10.1 ← 失败 + f203b6b 2026.8.9.2 ← 成功 +``` + +`v2026.8.9.2..65002df` 之间只有这一个 commit。它同时做了两件事,**两件都是必要条件**: + +1. `src/core/xim/installer.cppm:895` 新增 `configure_dependency_store_roots_()`, + 在 `:2485` 和 `:3294` 两条安装路径上**无条件**填充 `ctx.dependency_store_roots` + (至少含 selectedStore + global xpkgs,所以**永远非空**)。 +2. `mcpp.lock`:`mcpplibs.xpkg` `0.0.54 → 0.0.55`。 + +单独任一方都不会出事:0.0.55 在没有 roots 字段时会走 scan 兜底;0.0.54 在有 roots 时忽略它。 +**是这两个一起发布,才让新分支第一次被执行到。** + +### 1.2 根因:两道闸门,外加一次早退 + +gcc 的 config hook 写的是(`xim-pkgindex/pkgs/g/gcc.lua:544`): + +```lua +local glibc_dir = pkginfo.dep_install_dir("glibc") -- bare name, 无版本 +``` + +而它声明的是 `deps = { "xim:glibc@>=2.39", ... }` —— **namespaced + 区间**。 + +0.0.55 的 `pkginfo.lua` 在这条路径上加了三样东西: + +```lua +-- :234 resolved_dep —— 闸门 1 +function M.resolved_dep(dep_name, dep_version) + local ns, bare = _parse_namespace(dep_name) + if not ns then + if not _is_exact_store_version(dep_version) then return nil end -- :239 ← dep_version 是 nil,直接 nil + ... +``` + +```lua +-- :178 _resolve_dep_via_explicit_roots —— 闸门 2 + if not _is_exact_store_version(dep_version) then return nil end -- :179 ← 同样 nil + local ns, bare = _parse_namespace(dep_name) + if not ns then return nil end -- ← bare 名字,再 nil +``` + +```lua +-- :296 dep_install_dir —— 早退 + local roots = _RUNTIME and _RUNTIME.dependency_store_roots + if type(roots) == "table" then + return _resolve_dep_via_explicit_roots(dep_name, dep_version) -- ← 无条件 return,scan 永远够不到 + end + local result = _resolve_dep_via_scan(...) -- ← 0.0.54 靠这行活着 +``` + +于是 `dep_install_dir("glibc")` 返回 **nil,且一句话都不说**。gcc 的 hook 只能报「glibc payload not found」—— +它说的是它知道的唯一一件事。 + +**闸门 1 是核心缺陷。** 注意它的循环体里**本来就有唯一性守卫**: + +```lua +if record_bare == bare and rec.version == dep_version then + if candidate then return nil end -- 两条记录同名 → 失败关闭 + candidate = rec +end +``` + +歧义已经由这一行判定了。`_is_exact_store_version(dep_version)` 那道前置**没有参与任何消歧**—— +它唯一的作用是把「不带版本的查询」整个拒掉,而那正是 index 里**每一条 recipe 实际写的形状**。 + +> 一句话:**0.0.55 把判定条件从「答案是否唯一」换成了「问题是否够具体」。** +> 这两者只在 recipe 恰好写得很啰嗦时重合,而没有一条 recipe 那么写。 + +### 1.3 复现:离线、可运行、不需要装任何东西 + +`.agents/tools/repro-dep-install-dir.sh` 把真实的 `pkginfo.lua`(直接从 libxpkg checkout 按 rev 取) +挂在 stub 过的 xmake stdlib + 假 xpkgs store 上,按**已发布 recipe 的原样调用形状**重放: + +``` +$ .agents/tools/repro-dep-install-dir.sh # 默认 0.0.54 / 0.0.55 / 0.0.56 +$ .agents/tools/repro-dep-install-dir.sh HEAD fix/xxx # 任意 rev +``` + +| 调用点(recipe 原样) | 0.0.54 | 0.0.55 | 0.0.56 | **fixA** | +|---|---|---|---|---| +| `gcc.lua:544` `dep_install_dir("glibc")` | ok | **nil** | **nil** | ok | +| `llvm.lua:210` `dep_install_dir("glibc")` | ok | **nil** | **nil** | ok | +| `graphics.lua:176` `dep_install_dir("nvidia-gl-host-link")` | ok | **nil** | **nil** | ok | +| `meson.lua:116` `dep_install_dir("python")` | ok | **nil** | **nil** | ok | +| `godot.lua:351/422` `dep_install_dir("mesa")` ¹ | ok | **nil** | **nil** | **nil** | +| `mcpp-vscode-clangd.lua:245` `dep_install_dir("llvm-tools", ver)` ² | ok | **nil** | **nil** | **nil** | +| (对照)`dep_install_dir("xim:glibc")` | ok | ok | ok | ok | +| | 0/7 断 | **6/7 断** | **6/7 断** | **2/7 断** | + +¹ mesa 是**传递**依赖(godot 声明的是 `xim:graphics@>=0.1`),`resolved_deps` 只记录 `node.runtime_deps` +(`installer.cppm:2472`),所以本来就没有它的记录。 +² `llvm-tools` 是 hook 里 `pkgmanager.install()` 装的,不是声明依赖,同样没有记录。 + +**「有 roots / 无 roots」两列都跑了**:无 roots 时三个版本全绿 —— 这直接证明触发条件是 +xlings 2026.8.10.1 开始填这个字段,而不是 recipe 或 home 的问题。 + +### 1.4 libxpkg 0.0.56 不是这条 issue 的修复 + +0.0.56(`fix(pkginfo): an unanswerable dependency query must not look like an absent one`) +已经**正确诊断**了这个问题——commit message 里点名了 `gcc.lua` 和 `llvm.lua` 的 bare 调用。 +但它的改动**只加诊断**: + +```lua +local hit = _resolve_dep_via_explicit_roots(dep_name, dep_version) +if hit then return hit end +... +log.error("dep_install_dir(%s): a bare name cannot be resolved against ...") +return nil -- ← 依然 nil +``` + +实测:**6/7 依然断**,只是现在会打印一条错误。 + +它的立场是「nil 在这里是**正确答案**,错的是**沉默**」。**这个立场对了一半。** +对的一半:explicit roots 确实只能回答精确的 namespaced 坐标,让它猜 `compat-x-zlib` 还是 `other-x-zlib` 是错的。 +错的一半:`resolved_deps` **不是** explicit roots。它是一张**封闭的小表**,就是这个包自己声明的那几条依赖, +「同一个 bare 名字有没有第二条记录」是**可判定的**,不需要猜。0.0.55 把 roots 分支的正确约束 +(必须精确、必须带命名空间)**误加到了 `resolved_deps` 分支上**,而后者恰恰是为了免去调用方重复说一遍而存在的。 + +### 1.5 issue 里的一条假线索 + +> 「安装的是 **2.44**,而 hook 的诊断建议的是 **2.39** —— 二者不一致本身就是线索。」 + +**不是线索。** `xlings install xim:glibc@2.39` 是 `gcc.lua:545` 里的**硬编码字面量**: + +```lua +log.error(" install it first: xlings install xim:glibc@2.39") +``` + +它跟解析结果没有任何关系,2.44 被解析出来是完全正确的(`>=2.39` 允许)。 +顺手改掉这个字面量(改成 hook 真正查的那个坐标),否则下一个人还会顺着它走一遍。 + +日志里**没有**出现 0.0.55 的另外两条消息(`resolver record points to missing payload`、 +`no explicit store roots, fell back to a scan`),这本身就是判别证据:它区分了 +「记录有但 payload 不在盘上」和「压根没匹配上记录」,落在后者。 + +### 1.6 为什么 CI 没拦住 + +- **热 home 天然免疫**:gcc 已装 → 整个 install 被跳过 → config hook 不跑。xlings 自己的 CI 走的是这条路。 +- 唯一会冷装 gcc 的是下游(mcpp)的 sandbox 缓存 miss —— 也就是 issue 里那次 A/B。 + 换句话说,**这个缺陷的第一个探测器在另一个仓库里**。 +- libxpkg 侧 0.0.55 带了 593 行新 `test_executor.cpp`,但测的是 executor 的 C++ 面; + `pkginfo.lua` 的 Lua 行为**没有任何测试**,而这次改的就是它。 + +### 1.7 修复方案 + +**Fix A(libxpkg,根因)——按答案唯一性判定,而不是按问题具体度** + +`M.resolved_dep` 的 bare 分支: + +1. 删掉 `_is_exact_store_version(dep_version)` 前置(它不消歧,只拒绝合法查询)。 +2. 版本半边按**区间**匹配,不按字面量相等 —— 这是 recipe 实际写的语法, + 解析器早就支持(同一形态的旧 bug:xlings#481,`>=2.38` 当字面量比导致匹配不到节点)。 +3. 唯一性守卫保留,并且**说出撞了谁**。 + +实测(`fixA` 列):6/7 → 2/7。歧义与区间行为单独验过: + +| 场景 | 结果 | +|---|---| +| 两个 provider 都叫 bare `zlib` | **nil + 命名双方**(失败关闭)✅ | +| 换成 `compat:zlib` | 正确解析 ✅ | +| `dep_install_dir("glibc", ">=2.39")` → 记录是 2.44 | 命中 ✅ | +| `dep_install_dir("glibc", "2.39")` → 记录是 2.44 | nil ✅ | + +**Fix B(xim-pkgindex)——recipe 按声明的坐标提问** + +`gcc/llvm/meson/graphics` 全部改成 namespaced(`"xim:glibc"`、`"xim:python"`、`"xim:nvidia-gl-host-link"`)。 +这**单独就能修好这四条**(对照行已证),但它是**卫生**不是根因修复:Fix A 不做, +下一条写 bare 的 recipe 还会再踩,而且 godot / clangd 那两条它修不了。**两个都要做。** + +**Fix C(剩下 2 条,需要你拍板)** + +这两条是**真正不同的问题**,不该被 Fix A 顺手糊过去: + +- `godot → mesa`:问的是**传递**依赖的 payload。选项: + (a) godot 直接声明 `xim:mesa@>=25`(它确实用 mesa 的 libdir,声明是诚实的); + (b) xlings 把传递依赖也记进 `resolved_deps`。 + **倾向 (a)** —— (b) 会把 `resolved_deps` 从「我声明了什么」变成「解析器装了什么」,是更大的语义变更。 + **注意这条现在是静默的**:`have_stack=false` → godot 悄悄回落宿主 GL 目录, + 正好是 recipe 注释里说它要防的 mcpp#352(`GLIBC_2.43 not found`)。 +- `clangd → llvm-tools`:hook 里 `pkgmanager.install()` 装的,本来就没有记录。 + 应当走 `tool_payload_dir`(它保留了独立的 scan 路径),而不是 `dep_install_dir`。 + +**Fix D(护栏)——这一类不能再靠下游发现** + +1. **libxpkg**:把本文的 harness 收成 `pkginfo.lua` 的真单测,覆盖上表 7 种调用形状 × 有/无 roots。 + 这是这次唯一能在改动当天就拦住的东西。 +2. **xim-pkgindex**:结构化 lint —— 每个 `pkginfo.dep_install_dir(<字面量>)` 的第一参数 + 必须带命名空间,且必须出现在该 recipe 的 `deps` 里。要**解析** Lua,不要正则 + (regex 改 recipe 「能解析、但意思变了」是本仓库的老账)。 +3. **xlings**:`fresh-install` CI 加一条真·冷装 `xim:gcc@16.1.0`。 + +**发布顺序**:libxpkg(Fix A + 单测)→ bump xlings `mcpp.lock` → xlings patch 版本 → +xim-pkgindex(Fix B/C)→ 通知 mcpp 把 pin 提回。 + +--- + +## 2. #525 —— graphics 栈装好后 GLX 仍无 FBConfig + +### 2.1 实测:GLX 侧没有 `egl_vendor.d` 的对应物,也不可能有 + +在本机宿主 libglvnd 上直接读符号表: + +``` +$ strings /lib/x86_64-linux-gnu/libGLX.so.0 | grep -iE 'vendor|libGLX_' +libGLX_%s.so.0 +__GLX_VENDOR_LIBRARY_NAME +__GLX_FORCE_VENDOR_LIBRARY_%d + +$ strings /lib/x86_64-linux-gnu/libEGL.so.1 | grep -iE 'vendor|library_path' +__EGL_VENDOR_LIBRARY_DIRS +__EGL_VENDOR_LIBRARY_FILENAMES +/etc/glvnd/egl_vendor.d:/usr/share/glvnd/egl_vendor.d +library_path +``` + +对比是彻底的: + +| | EGL | GLX | +|---|---|---| +| 发现机制 | JSON 目录(环境变量可改) | **无** | +| 定位方式 | JSON 里的 `library_path`,**可以是绝对路径** | `dlopen("libGLX_.so.0")`,**只有 SONAME** | +| 环境变量能给什么 | **目录** | **只有名字** | + +所以 issue 里那句「设 `__GLX_VENDOR_LIBRARY_NAME=nvidia` 也没用——名字对了,库仍然找不到」 +是**结构性的**,不是配置漏了:glvnd 从来没给过 GLX 一个说「去哪里找」的接口。 + +**这也顺带解释了 EGL 为什么一直是好的**,而它的好是**误导性**的: +`mesa.lua:161-166` 和 nvidia 的 vendor JSON 都把 SONAME **改写成了绝对路径**。 +绝对路径对「谁加载了 libEGL、它的 RUNPATH 是什么」完全免疫。 +**EGL 通过是因为它走了一条 GLX 没有的路,不是因为发现层配好了。** +`libs/graphics.lua:90-94` 的 `DISCOVERY` 表里三个变量(`LIBGL_DRIVERS_PATH`、 +`__EGL_VENDOR_LIBRARY_DIRS`、`XDG_DATA_DIRS`)—— 没有一个是给 GLX 的,因为没有可给的。 + +### 2.2 那条 dlopen 只有四条搜索路径,我们只能用一条 + +`libGLX.so.0` 内部 `dlopen("libGLX_nvidia.so.0")`,glibc 的顺序: + +| # | 路径 | 在我们这儿 | +|---|---|---| +| 1 | **调用方对象**的 DT_RPATH(且调用方无 DT_RUNPATH 时**沿加载链向上传递**) | ✅ **唯一可用的** | +| 2 | `LD_LIBRARY_PATH` | ❌ 进程全局,会毒死宿主二进制(issue 里的 `timeout: __pointer_chk_guard` 就是它;xim 自己的安装警告说的也是它) | +| 3 | **调用方对象**的 DT_RUNPATH(**不传递**) | ✅ 可用,但必须写在 `libGLX.so.0` 自己身上 | +| 4 | `ld.so.cache` / 默认目录 | ❌ 我们的 ld.so 的 cache 路径在任何地方都不存在(INTERP 切换后宿主兜底就没了) | + +**结论:可达性必须由执行 dlopen 的那个对象自己携带。** 那个对象是 `libGLX.so.0` —— 是我们的文件 +(`xim:libglvnd`),所以我们能改。这一点 2026-08-06 已经用 A/B/C 实测过: +调用方带 DT_RPATH 或 DT_RUNPATH 都能解析,两者都行;调用方什么都不带就失败。 + +### 2.3 被加载的 `libGLX.so.0` 是我们的,但它的 RPATH 只有 `$ORIGIN` + +`compat.glx-runtime` 不是宿主直通,而是**从 SubOS view 里挑选并 symlink 出来的桥** +(mcpp 自己的设计文档:「仍需要从 SubOS view 选择并 symlink GL 库到自己的 runtime 目录…… +这是过渡桥,而不是最终模型」)。所以程序加载到的 `libGLX.so.0` **确实是我们 libglvnd 的载荷**。 + +问题不在于加载到了谁,而在于: + +- `libGLX.so.0` 在 `/xim-x-libglvnd//lib/`,RPATH 是 `$ORIGIN`; +- `libGLX_nvidia.so.0` 在 `/xim-x-nvidia-gl-host-link/0.1.1/lib/`,**另一个载荷目录**; +- `/lib` 把两者并到一起,但**没有任何东西把这个目录写进 libGLX.so.0 的搜索路径**。 + +`$ORIGIN` 展开的是**符号链接解析之后**的真实目录,所以走 subos farm 的符号链接也救不了。 + +而消费者(app)的 RUNPATH 里**即使有** `glx_runtime/lib`(里面可能同时有 vendor 的符号链接), +也帮不上忙 —— **DT_RUNPATH 不传递**,服务不了下游对象发起的 dlopen。这正是陷阱所在。 + +### 2.4 探针为什么一直是绿的 —— 这就是引入点 + +`xim-pkgindex/.agents/tools/graphics/glxprobe.c:15-28`: + +```c +// BUILD REQUIREMENT -- DT_RPATH, not DT_RUNPATH: +// gcc -o glxprobe glxprobe.c -ldl \ +// -Wl,-rpath,/lib -Wl,--disable-new-dtags +// +// glvnd's dlopen of the vendor is served by the CALLING object's search +// path, and libGLX.so.0's own RPATH is `$ORIGIN` -- it cannot see the vendor +// package. What makes it resolve is that DT_RPATH is searched transitively up +// the load chain to the executable ... build the same probe with +// --enable-new-dtags (the default on many distros) and glvnd finds no vendor +// at all +``` + +`verify-stack.sh:166`、`verify-host-link.sh:110,133` 也都带 `-Wl,--disable-new-dtags`。 + +也就是说: + +- 缺陷被**准确地识别了**(注释把失败模式、原因、默认值风险全写对了); +- 然后被**当成对消费者的前置条件**接受,而不是当成我们载荷上的缺口去补; +- 于是**生态里每一条 GLX 绿灯,都是靠一个真实消费者不会传的链接参数换来的**。 + +mcpp 用默认 `--enable-new-dtags` 发 `-Wl,-rpath`(`flags.cppm:711-720`),得到 DT_RUNPATH, +不传递 —— 前置条件破了,vendor 找不到,glvnd 吞掉 dlopen 错误,用户看到的就是 +`GLX: No GLXFBConfigs returned`。 + +**mcpp 没做错任何事。** 把可达性挂在消费者链接参数上,这个契约本身就不成立。 + +### 2.5 修复方案 + +**Fix E(根因,xim-pkgindex)——`xim:libglvnd` 自带 GLX vendor 搜索路径** + +1. `libs/graphics.lua` 增加 `graphics.GLX_VENDOR_DIR`,一个**只放 `libGLX_*.so.0` 符号链接**的目录。 + **必须放在 `usr/` 下**(例如 `usr/lib/glx-vendor`):xlings 只允许 `usr`/`etc`/`share` 开头的 + file asset 目标,被拒绝**不报错、只是不发生** —— `dst = "lib/dri"` 那次就是这么变成 llvmpipe 的 + (`libs/graphics.lua:63-74` 已记录这条规则和那次事故)。 +2. vendor 包(`nvidia-gl-host-link`、`mesa`、`wsl-gl-host-link`)在 config 时把自己的 + GLX 入口点链进这个目录 —— 与它们已经在做的 `graphics.declare_egl_vendor()` 对称。 +3. `libglvnd` 的 config hook 给 `libGLX.so.0` 写上指向该目录的 **DT_RUNPATH**。 + + 为什么是 RUNPATH 而不是 RPATH:调用方自带时两者都能服务这条 dlopen(2026-08-06 A/B/C 实测), + 而 RUNPATH **不传递** —— 它不会渗进 vendor 库自己的依赖查找,那部分由 interposer 单独控制 + (`nvidia-gl-host-link.lua:418-455` 明确需要 DT_RPATH,是**另一个对象**上的另一条规则)。 + **不要**把 `/lib` farm 整个塞进去:farm 里有 `libc.so.6` / `ld-linux` 符号链接, + 进 RPATH 是已知地雷;专用目录从结构上避开了它。 + + 实现入口:`elfpatch.set({rpath=...})` 的 `rpath` 参数**从不被读取**, + 唯一接受 caller rpath 的是 `elfpatch.patch_elf_loader_rpath`。成功判定用 `r.patched > 0` + (缺 patchelf 时返回 `{0,0,0}`,按真值判会把「没发生」当成功)。 + +**Fix F(护栏)——探针必须按真实消费者的方式构建** + +`glxprobe` / `verify-stack.sh` / `verify-host-link.sh` **去掉 `--disable-new-dtags`**,改用默认。 +这一条现在就应该做,且**做完立刻会变红** —— 那就是它该有的样子: +现在的绿灯是假的,Fix E 落地后它才应该真绿。 +再加一条**显式**的 `--disable-new-dtags` 变体,用来区分「vendor 不可达」和「其它环节坏了」。 + +**Fix G(边界,与 mcpp 协同)** + +Fix E 落地后 `compat.glx-runtime` 的 symlink bridge 就不再是可达性的必要条件了 +(mcpp 自己的路线图里它本来就标着「过渡桥,最终应弃用」)。 +建议顺序:xlings 侧先修好并实测,**再**推动 mcpp 弃用桥 —— 反过来会在没有替代品的时候把 GL 彻底断掉。 + +### 2.6 需要在实测中确认的两点 + +以下两条来自 2026-08-06 的记录,写进方案前应当在装好栈的机器上复核(本机没装 graphics 栈,无法就地验证): + +1. `xim:libglvnd` 载荷里 `libGLX.so.0` 的 RPATH **当前确实只有 `$ORIGIN`**(`readelf -d`)。 +2. 给它加上 vendor 目录的 DT_RUNPATH 后,**默认 dtags** 构建的 imgui/GLFW 程序能拿到 FBConfig。 + 判据用 `/proc/self/maps` 里 GL 对象的实际路径,**不要**用 `glxinfo -B` 的 renderer 字符串 —— + renderer 字符串在「我们的栈生效」和「整条链都来自 `/usr/lib`」两种情况下**完全一样**。 + +--- + +## 3. 共同的形态 + +两条 issue 都不是「写错了一行」,而是同一个判断失误的两次发作: + +> **用一个比真实条件更强的前提去验证机制,然后把那个前提当成已经成立。** + +- **#524**:新规则要求「namespaced + 精确版本」。它在**作者手写的调用**下成立, + 在 index 里 7 个真实调用点中 6 个不成立。收紧规则时没有去枚举调用点。 +- **#525**:机制要求「消费者带 DT_RPATH」。它在**手工加 `--disable-new-dtags` 的探针**下成立, + 在任何用默认参数的真实构建系统下不成立。而这条前提被写进注释、当成了契约。 + +两者的失败方式也一致,都是本仓库反复出现的**沉默**: +`dep_install_dir` 返回 nil 不说话(0.0.56 才补上一句),glvnd 吞掉 dlopen 错误只报 `No GLXFBConfigs`。 +**「没找到」和「没发生」产生了相同的输出。** + +对应的通用护栏,两条都指向同一件事:**验证必须用消费者真实的形状**—— +真实的调用写法、真实的链接参数、真实的冷 home。 + +--- + +## 4. 验收判据(可证伪) + +| # | 命令 | 通过条件 | +|---|---|---| +| A1 | `lua5.4 repro_dep_install_dir.lua pkginfo-.lua` | 7 个调用点中 ≥5 个 ok;剩下的只能是 godot/clangd 且各带一条**命名了原因**的诊断 | +| A2 | 歧义用例(两个 provider 同 bare 名) | nil **且**日志点名双方 | +| A3 | 冷 home(全新 `XLINGS_HOME`,`--mirror CN`)`xlings install -y xim:gcc@16.1.0` | 退出 0,`gcc/g++/c++` 三个 shim 都注册 | +| A4 | 同一冷 home `xlings install -y meson` | 退出 0 | +| A5 | `readelf -d /lib/libGLX.so.0` | RUNPATH 含 GLX vendor 目录 | +| A6 | imgui 模板,**默认 dtags** 构建后 `mcpp run` | 出窗口;且 `/proc/self/maps` 里 GL 对象路径在我们的载荷下 | +| A7 | `verify-stack.sh`(去掉 `--disable-new-dtags`) | Fix E 之前**红**,之后**绿** | + +A7 是这次最重要的一条:**它现在必须先红**,否则说明改的不是这个缺陷。 + +--- + +## 5. 已定的决策(2026-08-10) + +| # | 决定 | 结论 | +|---|---|---| +| **D1** | godot → mesa | ✅ **godot 直接声明 `xim:mesa@>=25`**,并按 namespaced 坐标提问。它确实用 mesa 的 libdir,声明是诚实的;不走「把传递依赖记进 `resolved_deps`」那条路——那会把 `resolved_deps` 从「我声明了什么」变成「解析器装了什么」,是更大的语义变更 | +| **D2** | libxpkg 版本 | ✅ **仍叫 0.0.56**。实测 0.0.56 从未发布:mcpp-index 的 `xpkg.lua` 最高 `0.0.55`,git tag 最高 `0.0.55`,`71b9ed7` 只活在未合并的 `fix/dep-install-dir-fallback` 上。所以在该分支上追加 Fix A,一个 PR 发一个 0.0.56 —— 既保留它那批**诊断**(Fix A 之后它们只在真正无解的查询上响),又不制造「同号不同树」的幽灵版本 | +| **D3** | GLX vendor 目录形态 | ✅ **E1** —— 目录放 libglvnd **自己的 payload 内**,`$ORIGIN/glx-vendor`,home 级装齐所有 vendor,per-subos 由 `__GLX_VENDOR_LIBRARY_NAME` 选。理由:vendor 载荷本来就是 per-home 的;「哪个 subos 用哪个 vendor」glvnd 已经有 per-subos 选择器,不需要再用目录表达。E2(subos 绝对路径)会把共享 payload 钉死在一个 subos 上;E3(per-subos stub)不跨包写但多一层,且依赖真 libGLX 保持 DT_RPATH | +| **D4** | 发布方式 | ✅ **#524 与 #525 合并成一轮发布**。#524 是硬失败(任何冷 home 装不上 gcc 和 meson:新机器、CI 冷缓存、`fresh-install`、全部下游 mcpp 构建),必须尽快;#525 同属一个形态,一起发省一轮生态传播 | +| **D5** | 与 mcpp 的桥 | ✅ **串行**:xlings 侧先修好并实测,**再**推动 mcpp 弃用 `compat.glx-runtime` 的 symlink bridge。并行会出现「桥没了、RUNPATH 还没到」的窗口,GL 会完全不可用 | + +**E1 的已知代价**(记录在案,不粉饰):vendor 包要往 libglvnd 的 store 目录里写符号链接, +这是**跨包写**。落地时必须确认 integrity 清理不会把它当孤儿删掉——这条进验收(见 §4 A5)。 + +实施计划与任务依赖:`.agents/plans/2026-08-10-issue-524-525-implementation.md` diff --git a/.agents/docs/2026-08-10-release-2026.8.10.2-notes.md b/.agents/docs/2026-08-10-release-2026.8.10.2-notes.md new file mode 100644 index 00000000..08a83daa --- /dev/null +++ b/.agents/docs/2026-08-10-release-2026.8.10.2-notes.md @@ -0,0 +1,131 @@ +# Release notes — 2026.8.10.2 + +> 修复:#524(冷装失败)、#525(GLX 无 vendor) +> +> 根因与实测:`.agents/docs/2026-08-10-issue-524-525-root-cause-and-fix-design.md` +> +> 实施与跨仓库顺序:`.agents/plans/2026-08-10-issue-524-525-implementation.md` +> +> 同批:`openxlings/libxpkg#40`(0.0.56)、`mcpplibs/mcpp-index#199`、 +> `openxlings/xim-pkgindex#589` + +## 一句话 + +**2026.8.10.1 在任何冷 home 上都装不上 gcc 和 meson**,本版修好; +同时补上 GLX vendor 的可达性——它此前只在探针加了一个真实消费者不会传的链接参数时成立。 + +**建议所有 2026.8.10.1 用户升级。** + +## #524 —— 冷 home 装不上工具链 + +### 症状 + +新机器、CI 冷缓存、`fresh-install`、以及所有下游 mcpp 构建: + +``` +[error] [gcc] failed: config hook failed: config hook returned false +[error] [xim:xpkg]: [ERROR] glibc payload not found, but gcc needs it to rewrite + its ELF interpreter away from the build machine's path. +``` + +glibc 就装在旁边。热 home 不受影响——包已装,安装被跳过,config hook 根本不跑, +**这也正是所有 CI 通道一路绿灯的原因**。第一个探测器在另一个仓库(mcpp 的缓存 miss)。 + +### 根因 + +2026.8.10.1 开始无条件填 `dependency_store_roots`,并在**同一个版本**里带上了 libxpkg 0.0.55。 +单独任一方都不出事;合在一起才第一次执行到那条新分支——而它按「问题够不够具体」判定, +而不是按「答案唯不唯一」。 + +关键点:唯一性守卫**本来就在**,也一直在正确地失败关闭。那道额外的版本前置**没有参与任何消歧**, +唯一作用是拒掉「不带版本的查询」——而那正是索引里每一条 recipe 实际写的形状。 + +按已发布 recipe 的原样调用形状重放,7 个调用点断 6 个: + +| recipe | 后果 | +|---|---| +| gcc、meson | **安装失败**——冷 home 上装不上 | +| llvm | 同一条 helper | +| godot | **静默**回落宿主 GL,即 mcpp#352——它声明 `graphics` 就是为了防这个 | +| graphics | **静默**把横幅显示成 unknown,GPU 与 llvmpipe 再次无法区分 | +| clangd | 跳过 clangd 配置 | + +### 修了什么 + +- **libxpkg 0.0.56**:bare 名字按答案唯一性回答,撞名依旧失败关闭并**点名双方**; + 版本半边按**区间**匹配(`>=2.39` 对上解析器选的 `2.44`,这是 #481 同一形态)。 + 只放宽不收紧:0.0.55/0.0.56 能回答的一律照旧,精确 namespaced 坐标查不到依旧是确定的「没有」, + 记录指向不存在的 payload 依旧是 miss,没有 roots 字段的老客户端保持 scan。 +- **xim-pkgindex**:每条 recipe 按它声明的坐标提问;godot 直接声明 `xim:mesa@>=25`; + clangd 改用 `tool_payload_dir`;llvm 删掉一条**结构上只可能求值为 nil** 的 scode 回退分支。 +- **诊断**:「无解」不再和「没装」长得一样。撞名点名双方;未声明的坐标说明它不是本包的依赖, + 并列出本包实际声明了什么。 + +### 现在能看见它了 + +**E2E-79**:冷 home 上,consumer 声明 namespaced + 区间坐标,解析器落地依赖,config hook 必须找到它。 +同一个 xlings 二进制、两个 libxpkg 实测: + +``` +libxpkg 0.0.55 → FAIL: consumer install failed (exit 1) -- this is #524 +libxpkg 0.0.56 → PASS +``` + +**`.agents/tools/repro-dep-install-dir.sh`**:离线重放全部 7 种真实调用形状, +不联网、不安装、不碰任何 home。`worktree` 参数测未提交的工作树—— +其余形式都从 git 读文件,未提交的修复会「测起来像没生效」。 + +**索引侧结构化 lint**(`tests/test_dep_query_coordinates.py`):字面量实参必须 namespaced 且已声明。 +剥掉注释与字符串后再判——recipe 注释里就写着示例调用。 + +## #525 —— graphics 栈装好后 GLX 仍无 FBConfig + +### 根因:glvnd 的 GLX 侧根本没有 `egl_vendor.d` 的对应物 + +实测宿主 libglvnd 1.7 的符号表: + +``` +libGLX.so.0 libGLX_%s.so.0、__GLX_VENDOR_LIBRARY_NAME ← 一个名字,从来不是路径 +libEGL.so.1 __EGL_VENDOR_LIBRARY_DIRS + JSON library_path ← 我们的 recipe 把它改写成绝对路径 +``` + +**EGL 一直是好的,是因为它走了一条 GLX 没有的路。** 于是 vendor 发现是从 `libGLX.so.0` +内部发起的裸 SONAME `dlopen`,只能由**那个对象自己的搜索路径**服务—— +`LD_LIBRARY_PATH` 会毒死宿主二进制,我们的 loader 的 cache 路径在任何地方都不存在。 + +它此前由**消费者**的 DT_RPATH 沿加载链向上传递来服务。这条机制是真的, +而 `.agents/tools/graphics/` 里每一个探针都用 `-Wl,--disable-new-dtags` 买下了它。 +真实构建系统发的是 DT_RUNPATH,不传递。于是 mcpp 的 imgui 模板在一台宿主 `glxinfo` 完全正常的机器上 +拿到 `GLX: No GLXFBConfigs returned`,而这里每一条 GLX 检查当时都是绿的。 + +### 修了什么(E1) + +- `libglvnd` 给自己的 `libGLX.so.0` 加上 `$ORIGIN/glx-vendor` 的 RPATH—— + **追加**在 `selfcontain.seal()` 已经写入的闭包之后,并在写完后**断言**结果,而不是假定。 +- `graphics` 填充那个目录。不由各 vendor 自行注册:libglvnd 重装会 `os.tryrm` 掉自己的载荷, + 连带清空所有注册,而 vendor 不会因为依赖重装而重装。`graphics` 声明了全部 vendor 和 dispatch, + 所以它最后安装,并能整体重建。 +- `$ORIGIN` 而非 subos 绝对路径:一份载荷服务 home 里的每个 subos, + 而「哪个 subos 用哪个 vendor」由 glvnd 本来就有的 `__GLX_VENDOR_LIBRARY_NAME` 决定。 + 子目录而非 `lib/`:后者会被发布进 subos,而 vendor 是 dlopen 的插件不是链接目标—— + 这也把含 `libc.so.6` / `ld-linux` 符号链接的 `/lib` farm 挡在 RPATH 之外。 + +### 存量 home + +`libglvnd 1.7.0.1` 与 `graphics 0.1.1` 是**同一份产物的新版本键**。 +config hook 只在安装时跑,没有新键的话这个修复只会到达全新安装——而且是静默的。 + +### 探针现在按真实消费者的方式构建 + +`glxprobe.c`、`verify-stack.sh`、`verify-host-link.sh` 去掉了 `-Wl,--disable-new-dtags`。 +**它们在 E1 落地前应当是红的**——那个红是「修的就是这个缺陷」的唯一证据。 +`XLINGS_GFX_LEGACY_DTAGS=1` 保留旧标志用于诊断:legacy 过而默认不过,说明坏的是 vendor 目录。 + +## 升级 + +``` +xlings self update +``` + +索引侧的修复(#589)对**旧客户端同样生效**——namespaced 坐标在 0.0.54/0.0.55/0.0.56 上都能解析, +所以 2026.8.10.1 的用户在索引更新后就能装上 gcc,不必等本版。 diff --git a/.agents/plans/2026-08-10-issue-524-525-implementation.md b/.agents/plans/2026-08-10-issue-524-525-implementation.md new file mode 100644 index 00000000..f40bdfee --- /dev/null +++ b/.agents/plans/2026-08-10-issue-524-525-implementation.md @@ -0,0 +1,142 @@ +# #524 / #525 实施计划 —— 任务拆分、依赖关系、跨仓库协作 + +> 设计与根因:`.agents/docs/2026-08-10-issue-524-525-root-cause-and-fix-design.md` +> +> 日期:2026-08-10 | 决策见该文档 §5(D1–D5) +> +> 仓库:`openxlings/libxpkg`、`openxlings/xlings`、`openxlings/xim-pkgindex` +> (+ `mcpplibs/mcpp-index` 与 gitcode 镜像作为发布链的中转) + +--- + +## 1. 八个角度的取舍 + +这一轮改的是**两条被当成契约的错误前提**,所以每个角度的判据都是「契约现在由谁承担」。 + +**架构。** 两条 issue 的共同点是**把责任放在了拿不到信息的一侧**。 +`dep_install_dir` 要求调用方重述解析器已经知道的版本;GLX 可达性要求消费者传一个它不知道要传的链接参数。 +两处都改成**由掌握信息的一侧承担**:`resolved_deps` 自己判定唯一性;`libGLX.so.0` 自己带搜索路径。 +E1 进一步把 vendor 目录放进 libglvnd 的 payload,用 `$ORIGIN` 表达——**位置信息由文件自身携带,不由环境表达**。 + +**稳定性。** 三条防线,都在缺陷发生的那一层: +libxpkg 侧的 Lua 单测(今天是 0,本轮补齐;0.0.55 的 593 行新测试全在 C++ executor 面,改的却是 Lua); +index 侧的结构化 lint(调用形状与声明必须一致); +xlings 侧的**冷** home e2e(热 home 跳过安装、根本不跑 config hook,这正是 CI 看不见 #524 的原因)。 + +**优雅简洁。** Fix A 是**净删除**一个判据(`_is_exact_store_version` 前置),不是新增分支—— +唯一性守卫本来就在,它一直在做正确的事。 +Fix E1 与已有的 `declare_egl_vendor()` 完全对称,`GLX_VENDOR_DIR` 与 `EGL_VENDOR_DIR` 并列,不引入新概念。 + +**用户体验。** 目标是**没有失败可看**。退而求其次,失败必须指向可执行的下一步: +撞名时点名撞了谁(而不是「找不到」),GLX 不可达时说的是哪一个 vendor 目录空着。 +顺手删掉 `gcc.lua:545` 那条硬编码的 `xim:glibc@2.39` —— 它把 issue 报告人引到了一条假线索上。 + +**兼容性。** Fix A **只放宽不收紧**:0.0.55/0.0.56 能解析的,之后全部照旧能解析; +歧义依旧失败关闭。没有 roots 字段的老客户端走原来的 scan 路径,完全不变。 +E1 只往 libglvnd payload 里**加**一个目录、往 `libGLX.so.0` 上**加**一条 RPATH, +没装 graphics 栈的 home 里那个目录是空的,`dlopen` 照旧落到原来的路径上。 + +**跨平台。** `dep_install_dir` 是全平台的,Fix A 的改动不含任何平台分支。 +E1 是 **linux-only** 的(glvnd 只在 linux),必须用平台条件包住, +不能出现「macOS/Windows 上多出一个空目录 + 一次 patchelf 失败」。 +lint 与 e2e 要按平台枚举 recipe 的 `xpm.` 段,不能只看 linux 那一段。 + +**一致性。** 「一个问题一个回答者」:`resolved_deps` 是依赖坐标的唯一回答者, +`tool_payload_dir` 是 libxpkg 自身工具的唯一回答者(clangd 的 `llvm-tools` 归它,不归前者); +GLX vendor 目录是 GLX vendor 可达性的唯一回答者。 +godot 不再问一个它没声明的包——**要用就要声明**。 + +**无感升级。** 这是硬约束:**已经装好的 home 升级后不能变红、不能要求重装**。 +- Fix A 纯放宽,老 home 的任何已装包不受影响。 +- E1 的 RPATH 写在 libglvnd 的 config hook 里,只有**安装/重装 libglvnd 时**才执行。 + 存量 home 里 `libGLX.so.0` 还是老的 RPATH → GLX 依旧不可达 → **这就是要解决的那个问题本身**。 + 所以 libglvnd 必须**换版本键**(同 artifact、新版本),让消费者的 `>=` 下界把它拉起来重跑 config, + 这是 fontconfig 2.15.0.1 用过的模式。**不这样做,存量 home 拿不到修复,而且没有任何提示。** + → 落到 **T7 的验收里,必须在一个「已装老 graphics 栈」的 home 上验证升级路径。** + +--- + +## 2. 任务 DAG + +``` + ┌───────────────────────────────────────────┐ + │ T1 同步/分支/设计doc/计划 (done) │ + └───────────────┬───────────────────────────┘ + ┌───────────────┬───────┴────────┬──────────────┬─────────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌───────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐ + │T2 libxpkg │ │T5 index │ │T7 index E1 │ │T8 index │ │T10 xlings│ + │ Fix A │ │ Fix B │ │ GLX vendor │ │ Fix F │ │ e2e+tool│ + └───────┬───────┘ ├──────────┤ └──────┬───────┘ │ probes │ │ +docs │ + ┌───────▼───────┐ │T6 index │ │ └────┬─────┘ └────┬─────┘ + │T3 libxpkg │ │ Fix C │ │ │ │ + │ Lua tests │ ├──────────┤ └──────────────┘ │ + └───────┬───────┘ │T9 index │ F 必须先红、E1 后绿 │ + ┌───────▼───────┐ │ lint │ │ + │T4 libxpkg │ └──────────┘ │ + │ 0.0.56 PR │ │ + └───────┬───────┘ │ + │ │ + └──────────────────────┬────────────────────────────────────┘ + ▼ + ┌────────────────────────┐ + │T11 xlings bump+PR+CI │ + └────────────┬───────────┘ + ▼ + ┌────────────────────────┐ + │T12 发布链 │ + │ libxpkg tag → gtc → │ + │ mcpp-index → xlings rel │ + └────────────┬───────────┘ + ▼ + ┌────────────────────────┐ + │T13 subos --sandbox 实测 │ + └────────────────────────┘ +``` + +**可并行的**:T2/T3(libxpkg)与 T5/T6/T7/T8/T9(index)与 T10(xlings)三条线互不阻塞。 +**真串行的只有三处**:T4→T11(xlings 要消费已发布的 libxpkg)、T11→T12(发布链有严格顺序)、T12→T13(对着发布产物验)。 + +**index 侧的三条 PR 内顺序**(同一 PR,但提交顺序有意义): +T8(探针去掉 `--disable-new-dtags`)先落 → **它会红** → T7(E1)再落 → **它转绿**。 +这个红→绿是本轮唯一能证明「修的就是这个缺陷」的判据;顺序反了就只剩一句自我声明。 + +--- + +## 3. 跨仓库发布顺序(唯一可行的那个) + +``` +openxlings/libxpkg PR 合并 → git tag 0.0.56 → GitHub 自动 tarball + ↓ +gitcode 镜像 gtc release publish(字节相同的副本,只改名) + ↓ ⚠ 校验用 GET,不能用 HEAD(HEAD 401 / GET 302→200) +mcpplibs/mcpp-index pkgs/x/xpkg.lua —— GLOBAL + CN URL + sha256, + ↓ 三个平台段(linux/macosx/windows)都要写 + ↓ 等 publish-artifact.yml 跑完;客户端有 TTL, + ↓ `rm -rf ~/.mcpp/registry/data/mcpplibs` 强制刷新 +openxlings/xlings mcpp.toml/mcpp.lock 依赖 bump → 版本号 bump → PR → CI → release + ↓ ⚠ bump-index 与 mirror-binaries 都吞自己的失败, + ↓ 永远不要读成绿:直接查 latest.ref 和 gitcode 资源 +openxlings/xim-pkgindex recipes 合并(Fix B/C/E1/F/lint) + ↓ ⚠ index 是构建产物不是 git clone,合并后 CI 仍会取到旧 index +mcpp-community/mcpp 把 xlings pin 从 2026.8.9.2 提回(由 mcpp 侧执行) +``` + +**index 侧为什么放在 xlings 之后**:Fix B/C 依赖 Fix A 才生效;先合 index、后发 xlings, +中间窗口里 index 已经改成 namespaced 坐标而客户端还是老 libxpkg —— 那条路径本来就是通的(对照行已证), +所以**这个顺序是安全的**,但反过来(先发 xlings 后合 index)窗口更短,优先。 + +--- + +## 4. 验收(全部可证伪,判据见设计文档 §4) + +| # | 归属 | 判据 | +|---|---|---| +| A1/A2 | T3 | `repro-dep-install-dir.sh` ≥5/7 ok;撞名 nil **且**点名双方 | +| A3/A4 | T13 | 冷 home `xlings install -y xim:gcc@16.1.0` 退出 0 且注册 3 个 shim;`meson` 同样 | +| A5 | T7/T13 | `readelf -d libGLX.so.0` 含 vendor RPATH;**且** integrity 清理没删掉跨包写入的符号链接 | +| A6 | T13 | imgui 模板**默认 dtags** 构建后出窗口;判据是 `/proc/self/maps` 里 GL 对象的路径,**不是** `glxinfo` 的 renderer 字符串 | +| A7 | T8→T7 | `verify-stack.sh` 去掉 `--disable-new-dtags` 后:E1 之前**红**,之后**绿** | +| A8 | T7 | **存量 home 升级路径**:已装老 graphics 栈的 home,升级后 GLX 可达(靠 libglvnd 换版本键 + 消费者 `>=` 下界) | + +A7 与 A8 是这一轮最容易被跳过、也最能说明问题的两条。 diff --git a/.agents/tools/repro-dep-install-dir.lua b/.agents/tools/repro-dep-install-dir.lua new file mode 100644 index 00000000..55c85d07 --- /dev/null +++ b/.agents/tools/repro-dep-install-dir.lua @@ -0,0 +1,188 @@ +-- Reproduction harness for openxlings/xlings#524. +-- +-- Loads the REAL pkginfo.lua (path given as arg[1]) against a stubbed xmake +-- stdlib and a fake xpkgs store, then replays the exact call shapes the +-- published recipes use. No xlings, no network, no install. + +local PKGINFO = arg[1] or error("usage: lua repro.lua ") +local LABEL = arg[2] or PKGINFO + +-- ── fake filesystem ────────────────────────────────────────────────── +-- A cold home immediately after the resolver installed gcc's deps: +-- glibc 2.44 and binutils 2.42 are on disk, gcc's own dir exists. +local STORE = "/home/u/.xlings/data/xpkgs" +local DIRS = { + [STORE] = true, + [STORE .. "/xim-x-glibc"] = true, + [STORE .. "/xim-x-glibc/2.44"] = true, + [STORE .. "/xim-x-binutils"] = true, + [STORE .. "/xim-x-binutils/2.42"] = true, + [STORE .. "/xim-x-gcc"] = true, + [STORE .. "/xim-x-gcc/16.1.0"] = true, + [STORE .. "/xim-x-mesa"] = true, + [STORE .. "/xim-x-mesa/25.0.7.2"] = true, + [STORE .. "/xim-x-nvidia-gl-host-link"] = true, + [STORE .. "/xim-x-nvidia-gl-host-link/0.1.1"] = true, + [STORE .. "/xim-x-python"] = true, + [STORE .. "/xim-x-python/3.12.4"] = true, + [STORE .. "/xim-x-llvm-tools"] = true, + [STORE .. "/xim-x-llvm-tools/20.1.0"] = true, +} +local CHILDREN = { + [STORE] = { "xim-x-glibc", "xim-x-binutils", "xim-x-gcc", "xim-x-mesa", + "xim-x-nvidia-gl-host-link", "xim-x-python", "xim-x-llvm-tools" }, + [STORE .. "/xim-x-glibc"] = { "2.44" }, + [STORE .. "/xim-x-binutils"] = { "2.42" }, + [STORE .. "/xim-x-mesa"] = { "25.0.7.2" }, + [STORE .. "/xim-x-nvidia-gl-host-link"] = { "0.1.1" }, + [STORE .. "/xim-x-python"] = { "3.12.4" }, + [STORE .. "/xim-x-llvm-tools"] = { "20.1.0" }, +} + +-- ── xmake-ish stdlib stubs ─────────────────────────────────────────── +path = { + join = function(...) + local parts = {} + for _, p in ipairs({...}) do + if p and p ~= "" then parts[#parts + 1] = tostring(p) end + end + return (table.concat(parts, "/"):gsub("//+", "/")) + end, + filename = function(p) return (tostring(p):gsub(".*/", "")) end, + directory = function(p) return (tostring(p):gsub("/[^/]*$", "")) end, +} +local _os = os +os = setmetatable({ + isdir = function(p) return DIRS[tostring(p)] == true end, + isfile = function() return false end, + host = function() return "linux" end, + dirs = function(pattern) + local base = tostring(pattern):gsub("/%*$", "") + local out = {} + for _, c in ipairs(CHILDREN[base] or {}) do + out[#out + 1] = base .. "/" .. c + end + return out + end, + getenv = function() return nil end, +}, { __index = _os }) + +local MESSAGES = {} +local function record(level) + return function(fmt, ...) + local ok, msg = pcall(string.format, fmt, ...) + MESSAGES[#MESSAGES + 1] = level .. ": " .. (ok and msg or tostring(fmt)) + end +end +_LIBXPKG_MODULES = { + log = { debug = function() end, info = record("info"), + warn = record("warn"), error = record("error") }, +} + +-- ── the runtime the xlings installer hands a config hook ───────────── +-- resolved_deps is keyed by the DECLARED spec, exactly as the resolver +-- records it. gcc declares `xim:glibc@>=2.39`; the resolver picked 2.44. +local function runtime_for(pkg, deps_list, resolved, with_roots) + local rt = { + pkg_name = pkg, + version = "16.1.0", + install_dir = STORE .. "/xim-x-" .. pkg .. "/16.1.0", + xpkg_dir = "/home/u/.xlings/data/xim/pkgindex/pkgs/g", + project_data_dir = "", + deps_list = deps_list, + resolved_deps = resolved, + } + -- xlings >= 2026.8.10.1 always sets this; earlier clients never did. + if with_roots then rt.dependency_store_roots = { STORE } end + return rt +end + +local GCC_RESOLVED = { + ["xim:glibc@>=2.39"] = { + name = "xim:glibc", version = "2.44", + install_dir = STORE .. "/xim-x-glibc/2.44", + }, + ["xim:binutils@2.42"] = { + name = "xim:binutils", version = "2.42", + install_dir = STORE .. "/xim-x-binutils/2.42", + }, +} +local GODOT_RESOLVED = { + ["xim:graphics@>=0.1"] = { + name = "xim:graphics", version = "0.1.0", + install_dir = STORE .. "/xim-x-graphics/0.1.0", + }, +} +local GRAPHICS_RESOLVED = { + ["xim:nvidia-gl-host-link@>=0.1"] = { + name = "xim:nvidia-gl-host-link", version = "0.1.1", + install_dir = STORE .. "/xim-x-nvidia-gl-host-link/0.1.1", + }, +} + +-- ── the cases, as the published recipes actually write them ────────── +local CASES = { + { "gcc.lua:544 dep_install_dir('glibc')", + "gcc", {"xim:glibc@>=2.39", "xim:binutils@2.42"}, GCC_RESOLVED, + "glibc", nil, STORE .. "/xim-x-glibc/2.44" }, + { "llvm.lua:210 dep_install_dir('glibc')", + "llvm", {"xim:glibc@>=2.39"}, GCC_RESOLVED, + "glibc", nil, STORE .. "/xim-x-glibc/2.44" }, + { "graphics.lua:176 dep_install_dir('nvidia-gl-host-link')", + "graphics", {"xim:nvidia-gl-host-link@>=0.1"}, GRAPHICS_RESOLVED, + "nvidia-gl-host-link", nil, STORE .. "/xim-x-nvidia-gl-host-link/0.1.1" }, + { "godot.lua:351 dep_install_dir('mesa') [transitive, not declared]", + "godot", {"xim:graphics@>=0.1"}, GODOT_RESOLVED, + "mesa", nil, STORE .. "/xim-x-mesa/25.0.7.2" }, + { "meson.lua:116 dep_install_dir('python') [raise() on nil]", + "meson", {"xim:python@>=3.9"}, + { ["xim:python@>=3.9"] = { name="xim:python", version="3.12.4", + install_dir = STORE .. "/xim-x-python/3.12.4" } }, + "python", nil, STORE .. "/xim-x-python/3.12.4" }, + { "clangd:245 dep_install_dir('llvm-tools', ver) [pkgmanager-installed]", + "mcpp-vscode-clangd", {"xim:mcpp"}, {}, + "llvm-tools", "20.1.0", STORE .. "/xim-x-llvm-tools/20.1.0" }, + { "proposed fix: dep_install_dir('xim:glibc')", + "gcc", {"xim:glibc@>=2.39", "xim:binutils@2.42"}, GCC_RESOLVED, + "xim:glibc", nil, STORE .. "/xim-x-glibc/2.44" }, +} + +print(("═"):rep(78)) +print("pkginfo under test: " .. LABEL) +print(("═"):rep(78)) +print(string.format("%-52s %-11s %s", "call site", "no roots", "roots set")) +print(("─"):rep(78)) + +local fails = 0 +for _, c in ipairs(CASES) do + local desc, pkg, deps, resolved, name, ver, want = table.unpack(c) + local results = {} + for _, with_roots in ipairs({ false, true }) do + MESSAGES = {} + _RUNTIME = runtime_for(pkg, deps, resolved, with_roots) + package.loaded["pkginfo_under_test"] = nil + local chunk = assert(loadfile(PKGINFO)) + local M = chunk() + local ok, got = pcall(M.dep_install_dir, name, ver) + if not ok then + results[#results + 1] = "RAISED" + elseif got == want then + results[#results + 1] = "ok" + elseif got == nil then + results[#results + 1] = "nil" + else + results[#results + 1] = "WRONG" + end + if with_roots then + results.msgs = MESSAGES + if got ~= want then fails = fails + 1 end + end + end + print(string.format("%-52s %-11s %s", desc, results[1], results[2])) + for _, m in ipairs(results.msgs or {}) do + print(" └─ " .. m:sub(1, 120)) + end +end +print(("─"):rep(78)) +print(string.format("with dependency_store_roots set: %d/%d call sites broken", + fails, #CASES)) diff --git a/.agents/tools/repro-dep-install-dir.sh b/.agents/tools/repro-dep-install-dir.sh new file mode 100755 index 00000000..00ea526b --- /dev/null +++ b/.agents/tools/repro-dep-install-dir.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# openxlings/xlings#524 -- offline reproduction of the dep_install_dir regression. +# +# Loads the REAL pkginfo.lua from a libxpkg checkout, at each revision named on +# the command line, and replays the call shapes the published recipes actually +# use. No xlings, no network, no install, no home touched. +# +# ./repro-dep-install-dir.sh # 0.0.54 / 0.0.55 / 0.0.56 +# ./repro-dep-install-dir.sh HEAD my-fix-branch # any revs you like +# ./repro-dep-install-dir.sh worktree # UNCOMMITTED working tree +# +# `worktree` matters: every other form reads the file out of git, so an +# uncommitted fix silently tests as if it were not there -- which reads as +# "my fix does nothing" rather than "I forgot to commit". +# +# LIBXPKG env var overrides the checkout location. +set -euo pipefail + +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +libxpkg=${LIBXPKG:-$here/../../../libxpkg} +[[ -d $libxpkg/.git ]] || { echo "no libxpkg checkout at $libxpkg (set LIBXPKG=)" >&2; exit 2; } + +# Default revs are the two that bracket the regression plus the diagnostics-only +# follow-up, resolved by tag-in-subject rather than by hash so this keeps working. +if (( $# )); then revs=("$@"); else + revs=() + for tag in 0.0.54 0.0.55 0.0.56; do + r=$(git -C "$libxpkg" log --all --format='%H %s' \ + | grep -m1 -F "($tag)" | cut -d' ' -f1) || true + [[ -n ${r:-} ]] && revs+=("$r@$tag") + done +fi + +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +for spec in "${revs[@]}"; do + rev=${spec%%@*}; label=${spec#*@}; [[ $label == "$rev" ]] && label=$rev + if [[ $rev == worktree ]]; then + cp "$libxpkg/src/lua-stdlib/xim/libxpkg/pkginfo.lua" "$work/pkginfo.lua" + [[ $label == worktree ]] && label="worktree (uncommitted)" + elif ! git -C "$libxpkg" show "$rev:src/lua-stdlib/xim/libxpkg/pkginfo.lua" \ + > "$work/pkginfo.lua" 2>/dev/null; then + echo "!! cannot read pkginfo.lua at $rev -- skipped" >&2; continue + fi + lua5.4 "$here/repro-dep-install-dir.lua" "$work/pkginfo.lua" "libxpkg $label" + echo +done diff --git a/.github/workflows/xlings-ci-aarch64.yml b/.github/workflows/xlings-ci-aarch64.yml index 8caa3ff5..e44d4404 100644 --- a/.github/workflows/xlings-ci-aarch64.yml +++ b/.github/workflows/xlings-ci-aarch64.yml @@ -106,9 +106,22 @@ jobs: run: | xlings config --mirror GLOBAL xlings update -y 2>/dev/null || xlings update 2>/dev/null || true - xlings install mcpp -y - # This job deliberately takes the LATEST mcpp, not the pin -- it - # exists to catch cross-build breakage against the current client. + # TEMPORARY PIN -- remove when mcpp-community/mcpp#406 is fixed. + # + # This job deliberately takes the LATEST mcpp, not the `.xlings.json` + # pin: it exists to catch cross-build breakage against the current + # client. It did exactly that. mcpp 2026.8.10.1's host toolchain + # post-install fixup binds `glibc@2.39` while the resolver installs + # 2.44 (latest since xim-pkgindex#582) and refuses to fall back, so + # every cross-build lane fails -- on main as well as on PRs. Proven + # by re-running one unchanged run id across the index bump that made + # mcpp 2026.8.10.1 latest: 2026.8.8.4 passed, 2026.8.10.1 failed. + # + # Pinning here muzzles the canary this job is FOR, which is why the + # pin names its own removal condition instead of quietly existing. + # Until #406 lands, breakage against the newest mcpp is invisible + # here. + xlings install mcpp@2026.8.8.4 -y mcpp --version - name: Cross-build release candidate -> aarch64 static, run under qemu diff --git a/mcpp.lock b/mcpp.lock index 4723ac8c..e7a48352 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -17,6 +17,12 @@ version = "3.8.7" source = "index+compat@3.8.7" hash = "fnv1a:e412742c0533bef7" +[package."compat.lua"] +namespace = "compat" +version = "5.4.7" +source = "index+compat@5.4.7" +hash = "fnv1a:6bda787e2c850eac" + [package."compat.lz4"] namespace = "compat" version = "1.10.0" @@ -59,12 +65,6 @@ version = "6.1.9" source = "index+mcpplibs@6.1.9" hash = "fnv1a:b3ae3d5c6e5a7a91" -[package."lua"] -namespace = "mcpplibs" -version = "5.4.7" -source = "index+mcpplibs@5.4.7" -hash = "fnv1a:baac1cce64264c57" - [package."mcpplibs.tinyhttps"] namespace = "mcpplibs" version = "0.2.9" @@ -73,9 +73,9 @@ hash = "fnv1a:3465dd0bd5d7aa20" [package."mcpplibs.xpkg"] namespace = "mcpplibs" -version = "0.0.55" -source = "index+mcpplibs@0.0.55" -hash = "fnv1a:1f2ae5d41f7d9bb2" +version = "0.0.56" +source = "index+mcpplibs@0.0.56" +hash = "fnv1a:1f00ff8945ea9597" [package."mcpplibs.capi.lua"] namespace = "mcpplibs.capi" diff --git a/mcpp.toml b/mcpp.toml index c3001b59..e936d0e4 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "xlings" -version = "2026.8.10.1" +version = "2026.8.10.2" description = "Universal package management infrastructure tool with SubOS isolation" license = "Apache-2.0" repo = "https://github.com/openxlings/xlings" @@ -39,7 +39,7 @@ libarchive = "3.8.7" [dependencies.mcpplibs] cmdline = "0.0.2" -xpkg = "0.0.55" +xpkg = "0.0.56" tinyhttps = "0.2.9" capi.lua = "0.0.3" diff --git a/src/core/config.cppm b/src/core/config.cppm index 53a7810a..3a2b604c 100644 --- a/src/core/config.cppm +++ b/src/core/config.cppm @@ -13,7 +13,7 @@ import xlings.core.xvm.db; namespace xlings { export struct Info { - static constexpr std::string_view VERSION = "2026.8.10.1"; + static constexpr std::string_view VERSION = "2026.8.10.2"; static constexpr std::string_view REPO = "https://github.com/openxlings/xlings"; }; diff --git a/tests/e2e/config_hook_resolves_declared_dep_test.sh b/tests/e2e/config_hook_resolves_declared_dep_test.sh new file mode 100755 index 00000000..6739b132 --- /dev/null +++ b/tests/e2e/config_hook_resolves_declared_dep_test.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Regression for #524: a config hook must be able to locate the payload of a +# dependency THE RESOLVER JUST INSTALLED, on a home where neither was present. +# +# Why a cold home is the whole point. On a warm home the consumer is already +# installed, so the install is skipped and the config hook never runs -- which +# is exactly why every CI lane stayed green while `xlings install xim:gcc` was +# broken on every fresh machine. The first detector was a downstream repo's +# cache miss. This test is that detector, in-tree. +# +# The shape is gcc's, minimally: declare a namespaced, RANGED coordinate, let +# the resolver pick a concrete version, then ask for the payload from the +# config hook. Under xlings 2026.8.10.1 + libxpkg 0.0.55/0.0.56 the ask +# returned nil and the hook reported "payload not found" with the payload +# sitting next to it. + +set -euo pipefail + +# shellcheck source=./project_test_lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/config_hook_resolves_declared_dep" +HOME_DIR="$RUNTIME_DIR/xlings-home" +INDEX_DIR="$RUNTIME_DIR/index" +USER_HOME="$RUNTIME_DIR/user" + +cleanup() { rm -rf "$RUNTIME_DIR"; } +trap cleanup EXIT +cleanup + +XLINGS_BIN="$(find_xlings_bin)" + +mkdir -p "$HOME_DIR/subos/default/bin" "$INDEX_DIR/pkgs/d" "$INDEX_DIR/pkgs/c" "$USER_HOME" +printf 'xim_indexrepos = {}\n' > "$INDEX_DIR/xim-indexrepos.lua" + +cat > "$HOME_DIR/.xlings.json" < "$INDEX_DIR/pkgs/d/depender-payload.lua" <<'LUA' +package = { + spec = "1", + name = "depender-payload", + description = "Payload a consumer's config hook must be able to locate", + type = "package", + archs = {"x86_64", "aarch64"}, + status = "stable", + xpm = { + linux = { ["latest"] = { ref = "1.2.0" }, ["1.0.0"] = {}, ["1.2.0"] = {} }, + macosx = { ["latest"] = { ref = "1.2.0" }, ["1.0.0"] = {}, ["1.2.0"] = {} }, + windows = { ["latest"] = { ref = "1.2.0" }, ["1.0.0"] = {}, ["1.2.0"] = {} }, + }, +} +LUA + +# The consumer. Declares a RANGE, asks three ways in config(): +# +# namespaced the coordinate as declared -- must always work +# bare what every recipe in xim-pkgindex actually writes; the +# record set answers it uniquely, so it must work too +# +# The undeclared case gets its own consumer below, because a hook's output is +# only surfaced when the hook FAILS -- which is also the only moment a user +# reads it. +cat > "$INDEX_DIR/pkgs/c/dep-consumer.lua" <<'LUA' +package = { + spec = "1", + name = "dep-consumer", + description = "Resolves its declared dependency from a config hook", + type = "package", + archs = {"x86_64", "aarch64"}, + status = "stable", + xpm = { + linux = { deps = { "xim:depender-payload@>=1.0" }, + ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} }, + macosx = { deps = { "xim:depender-payload@>=1.0" }, + ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} }, + windows = { deps = { "xim:depender-payload@>=1.0" }, + ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} }, + }, +} + +import("xim.libxpkg.pkginfo") + +function config() + local ns = pkginfo.dep_install_dir("xim:depender-payload") + local bare = pkginfo.dep_install_dir("depender-payload") + local range = pkginfo.dep_install_dir("xim:depender-payload", ">=1.0") + + local f = io.open(path.join(pkginfo.install_dir(), "resolved.txt"), "w") + if not f then return false end + f:write("ns=" .. tostring(ns) .. "\n") + f:write("bare=" .. tostring(bare) .. "\n") + f:write("range=" .. tostring(range) .. "\n") + f:close() + + -- Fail the way gcc fails, so a regression shows up as a failed install and + -- not as a file nobody reads. + return ns ~= nil and bare ~= nil and range ~= nil +end +LUA + +RUN() { + ( cd /tmp && env -i \ + HOME="$USER_HOME" \ + USER=xlings-test \ + SHELL=/bin/sh \ + PATH=/usr/bin:/bin \ + NO_COLOR=1 \ + XLINGS_HOME="$HOME_DIR" \ + "$XLINGS_BIN" "$@" ) +} + +log "materialize the isolated index" +RUN update >/dev/null 2>&1 || fail "failed to materialize the isolated index" + +PAYLOAD_ROOT="$HOME_DIR/data/xpkgs/xim-x-depender-payload" +[[ ! -d "$PAYLOAD_ROOT" ]] \ + || fail "home is not cold: $PAYLOAD_ROOT already exists before the install" + +log "cold install: the resolver must place the dependency, then the hook must find it" +set +e +install_output="$(RUN install xim:dep-consumer@1.0.0 -y 2>&1)" +install_rc=$? +set -e +[[ "$install_rc" -eq 0 ]] \ + || fail "consumer install failed (exit $install_rc) -- this is #524 +$install_output" + +RESULT_FILE="$HOME_DIR/data/xpkgs/xim-x-dep-consumer/1.0.0/resolved.txt" +[[ -f "$RESULT_FILE" ]] || fail "config hook recorded nothing +$install_output" + +EXPECT="$PAYLOAD_ROOT/1.2.0" +ns="$(sed -n 's/^ns=//p' "$RESULT_FILE")" +bare="$(sed -n 's/^bare=//p' "$RESULT_FILE")" +range="$(sed -n 's/^range=//p' "$RESULT_FILE")" + +[[ "$ns" == "$EXPECT" ]] \ + || fail "namespaced query resolved '$ns', expected '$EXPECT'" +[[ "$bare" == "$EXPECT" ]] \ + || fail "bare query resolved '$bare', expected '$EXPECT' -- the record set +answers this uniquely, and every recipe in xim-pkgindex writes it this way" +[[ "$range" == "$EXPECT" ]] \ + || fail "ranged query resolved '$range', expected '$EXPECT' -- the caller +restates the RANGE it declared, never the concrete version the resolver picked" +log "an unanswerable coordinate must fail LOUDLY, naming itself" + +# A second consumer, because a hook's output reaches the user only when the +# hook fails. "Cannot answer" and "the dependency is not installed" produce the +# same nil, and #524 is what happens when they also produce the same message: +# the report chased a missing glibc that was sitting on disk. +cat > "$INDEX_DIR/pkgs/c/dep-stranger.lua" <<'LUA' +package = { + spec = "1", + name = "dep-stranger", + description = "Asks for a coordinate it never declared", + type = "package", + archs = {"x86_64", "aarch64"}, + status = "stable", + xpm = { + linux = { deps = { "xim:depender-payload@>=1.0" }, + ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} }, + macosx = { deps = { "xim:depender-payload@>=1.0" }, + ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} }, + windows = { deps = { "xim:depender-payload@>=1.0" }, + ["latest"] = { ref = "1.0.0" }, ["1.0.0"] = {} }, + }, +} + +import("xim.libxpkg.pkginfo") + +function config() + return pkginfo.dep_install_dir("xim:never-declared") ~= nil +end +LUA + +RUN update >/dev/null 2>&1 || fail "failed to refresh the isolated index" + +set +e +stranger_output="$(RUN install xim:dep-stranger@1.0.0 -y 2>&1)" +stranger_rc=$? +set -e +[[ "$stranger_rc" -ne 0 ]] \ + || fail "asking for an undeclared coordinate must not report success +$stranger_output" +grep -q "never-declared" <<<"$stranger_output" \ + || fail "the failure never named the coordinate that could not be answered +$stranger_output" +grep -qi "not a declared dependency" <<<"$stranger_output" \ + || fail "the failure must say WHY it cannot answer, not just that it did not +$stranger_output" + +log "PASS: a config hook resolves its declared dependency on a cold home" diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index a9263c50..2e528343 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -130,6 +130,7 @@ TESTS=( "E2E-76 |config_install_no_implicit_dir_test.sh||" "E2E-77 |subos_use_candidates_test.sh||" "E2E-78 |subos_runtime_declaration_upgrade_test.sh||" + "E2E-79 |config_hook_resolves_declared_dep_test.sh||" ) PASS=0; FAIL=0; SOFTFAIL=0