feat: 依赖的链接形态是一根可选择的轴,而混形态会被抓住 (#519) - #521
Conversation
> 一个库,一个提供者,一种形态。 这条不变量在两个高度上执行:plan 期对 mcpp **决定**的东西,链接后对链接器 **产出**的东西 —— 后者是唯一能看见 mcpp 从不知道其存在的那个库的高度 (vendor 包里随附的、`[system_deps]` 引入的宿主的)。 设计与实测:`.agents/docs/2026-08-28-issue519-dependency-linkage-form.md` ## 新增 - `[build] dependency_linkage`(可按 `[profile.*]` 覆盖)与依赖边上的 `linkage`。`static` 是默认值,与既有行为逐字节相同。形态**每个链接映像 解析一次**,不是每条边一次;边上的键只在**根工程**生效。 - 符号提供者检查。两段式,而**第二段不可省**:mcpp 自己的 `kind = "shared"` 机制会结构性地产出「exe 导出、`.so` 绑过来」这个形状,而那是单份定义、 完全良性的。真正的判据是「导出的东西还有第二个提供者」。默认警告, `--strict` 升级为错误,判定记进 `resolution.json`(带分母)。 ## 修复 -⚠️ 依赖包的 `[targets.*] required_features` 从来没有生效过 —— 门控只读根的 活跃集。一个「可选」的 shared 目标因此悄悄改变了整个包对所有消费者的链接方式。 -⚠️ `-fPIC` 不在缓存键里。形态由作者定死时可以幸存;一旦消费者能请求 shared, 同一条目就会把非 PIC 对象喂给共享链接。现由 `make_plan` 决定一次, 编译标志与缓存键读同一位;`dependency_linkage` 同时进工程指纹。 -⚠️ 在非 shared 目标上写 `soname` 会让整份 manifest 加载失败,收窄为 「非 **library** 目标才拒绝」。⚠️ 因此写进索引描述符要等 `latest` 下限跨过本版本。 ## 文档 `docs/05` §2.2「共享库只支持 Linux/ELF」已过时(e2e 257/259),中英双份更正。
用生态自己的 glib 复现 issue §2(xim:glib + 整条闭包,零宿主依赖): exe 导出 88 个 zlib 符号,`LD_DEBUG` 显示 libgio 全部绑到 exe, 含 `inflateGetHeader' [ZLIB_1.2.2]` —— 带版本的引用绑到无版本的定义。 诊断指名了两个提供者。⚠️ 但按第一条出路(`linkage = "shared"`)走一遍之后:导出符号 88 → 0、 诊断静默,而进程里加载的 zlib 从 **1 份变成 2 份**(`libzlib.so` 没有 SONAME,libgio 仍然去找 `libz.so.1`)。 ⇒ 这根轴单独用会把一个可检测的缺陷换成一个不可检测的缺陷。三条出路重排: 永远正确的那条(让一方不再提供)排第一,形态切换排最后并写清它的前提。 单测断言的是次序,不是三个子串都在。中英文档同步。
##⚠️ ⚠️ `mcpp pack` 不收合成的共享库,包解开就起不来 判据 12 是为「这是推理不是测量」写的,而推理错了。实测: $ ./app error while loading shared libraries: libcore.so `ldd_parse` 追的是**暂存目录里的副本**,而那个库是靠 `$ORIGIN` 找到的 —— 副本旁边的 `bin/` 是空的,于是它从不出现在闭包里,也就从不被打包。 构建、打包、上传全程无话,失败发生在用户机器上。⚠️ **不是本 PR 引入的**:在 mcpp 2026.8.26.1 上用作者声明的 `kind = "shared"` 依赖复现,同样起不来。但这根轴把它从「12 个自称 shared 的包」 变成「任何一个包」都可达 —— 与 PIC 进缓存键同一条理由,所以在这里修。 改为追**构建出来的**二进制:暂存文件是它的逐字节副本,此刻两者都没被改过 (patchelf 在更后面),变的只是 `$ORIGIN` 展开到哪个目录。 ##⚠️ 未变更产物的判定会从记录里消失 跳过 stat 未动的产物是对的(loader-tag 那条实测过 158.7s/190s),但不能连 判定一起丢:workspace 一次只重链一个成员,`mcpp test` 在已构建的树上一测一驱动。 照 loader-tag 的既有做法从 `resolution.json` 读回,而「没有条目」恰好与 「检查过且干净」读数相同 —— 这正是要避免的。 ##⚠️ 链接单元的匹配拼法与快照不一致 `snapshot_link_artifacts` 用 `outputDir / output`,我这边多了一次 `lexically_normal()`。不匹配时静默丢掉该单元自己的 ldflags。改为逐字同拼。 判据:e2e 306 增加第四段(打包 → 解开 → **跑起来**);14 个适用的 pack e2e 全绿。
`187_dep_host_tool.sh` 红。上一版的门抹掉了依赖包里**任何**种类的 feature-gated 目标,并附了一句「主机工具那条路会以依赖为根重进 prepare_build,所以到不了这里」—— 那句是**凭印象写的,不是读出来的**: 工具的查找在这段代码**下面几百行**,对着的正是这份 manifest,于是它读到 一个空的目标表。 被请求为主机工具的目标是**被要求的东西**,它的 `required_features` 是那次 子构建的**输入**而不是门(docs/05 §2.2 原文如此)。 收窄到库目标不是绕过,而是规则本身:依赖包的 `bin` 目标在本次构建里不产生 任何链接单元(make_plan 只走根的目标),留着它零成本;门要管的是「一个目标 仅仅存在就改变整个包对所有消费者的链接方式」,而那恰好就是 `shared` / `lib`。 本机:187 与 308 同时绿。
消费者用 FQN 或裸名寻址依赖,而每条消息都想要版本号。原先的做法是把
label 换成命中的那个拼法 —— 于是每条拒绝消息都丢掉了版本。改成让请求
也认得描述性的 label。
复验:306/307/308/187 全绿;`"compat.zlib" = { linkage = "shared" }`
仍然产出 libzlib.so。
§14.6 记下 mcpp#522(feature 带不了 ldflags,所以 eui-neo 的托盘做不成 feature)与 mcpp-index#270(compat.zlib 的 soname)。两条是同一个形状: 一个键能不能发,判据是索引 latest 的下限,不是引擎支持了没有。 §14.7 记下 CN 镜像,并推翻一条照抄的理由 —— gitcode 的 tarball 保留 wrap 层, 与 GitHub 是同一份字节。
开出去之后又改的四处,以及每一处是被什么推翻的1.
|
| sandbox 缓存 | 判据 29 | |
|---|---|---|
| main | Cache not found → 冷装,glibc 载荷齐全 |
PASS |
| 本 PR | Cache hit on aaefa297… |
FAIL |
那条缓存的写入时间是 00:55:48,ref 就是本 PR —— 我第一次推送启动的 run,
被我下一次推送取消,而被取消的 job 仍然会跑 cache-save,于是存进了一个
装了一半的 sandbox。之后每次 run 都把它恢复回来。
删掉那条缓存、同一个 commit 重跑:绿。
⭐ 教训不是「CI 有时会抖」,而是取消一个 run 会留下它的缓存;
在同一分支上快速连推会给自己埋一个只在特定判据上现形的地雷。
Closes #519.
issue 提的两件事是这条不变量的两半:诊断执行它,形态轴是当映像里确实出现
两个提供者时唯一能让用户满足它的杠杆。一个没有杠杆的不变量只是一堵墙。
不变量在两个高度上执行,因为 mcpp 有两种知识:
libz.so.1不是 mcpp 的概念)完整设计、实测与两轮自我 review:
.agents/docs/2026-08-28-issue519-dependency-linkage-form.mdmcpplibs/mcpp-index#245(eui-neo 的 Linux SNI托盘)是这条 issue 在真实世界里的实例,用来验证,不驱动设计 —— 引擎里没有
任何 glib / gio / zlib / 托盘 的知识,也不依赖宿主。
新增
[build] dependency_linkage+ 依赖边上的linkagestatic⇒ 既有工程逐字节不变(e2e 306 断言两种配置落在不同的指纹目录,且默认那次不产出任何
.so)。两种形态,正是本 PR 要抓的缺陷。
linkage只在根工程生效。依赖图深处的包无权决定最终程序的布局(与
reexport不搭visibility便车是同一条理由);真正必须只有一份共享副本的包在自己的 target 上声明。
能力是推导出来的,不是问出来的 —— 零新描述符键:
kind = "shared"ldflags含-L[[runtime.artifacts]] rolekind = "lib"不是约束,它是解析器的默认值 —— 索引 130 个包里 84 个把它当样板写下,并没有选择过什么。按字面读会把整个生态锁死在这根轴之外。
linkage轴不独立:整链静态的映像没有解释器,装不下任何共享对象。C 库静态链接的目标(musl 的默认)会拒绝
shared并说明原因。符号提供者检查
⭐⭐ 第二段不可省,而这是实测出来的。 mcpp 自己的
kind = "shared"机制结构性地产出「exe 导出、
.so绑过来」这个形状(shared 依赖的链接单元只拿它自己的对象,它的 static 依赖落进消费者的 exe),而那是单份定义、完全良性的。
只看第一段会在正确的构建上刷警告,而用户无事可做。e2e 307 的两半只差一个包:
良性安排必须静默,真冲突必须报告并指名两个提供者。
PT_INTERP不是 ELF 类型:PIE 可执行文件是ET_DYN,与共享库一样,而是否 PIE 取决于载荷工具链的默认值(全仓无
-pie/-no-pie)。按类型判会在 PIE 世界里恒读「没有可执行文件要查」。
environ是__environ的 WEAK 别名,同址但重定位表里只有后者 —— 按名字判会在每一个 mcpp 产物上误报。
实测基线(四个产物,回归集写进了单测):
EXPORTEDbin/mcpp/usr/bin/giterror遮蔽 glibc 的/usr/bin/ls/usr/bin/bash-rdynamic,前提不成立,正确排除默认警告,
--strict升级为错误(复用mcpp.diag的既有策略点,零新严重度机制)。判定记进
resolution.json的runtime.symbol_provision,带分母 —— e2e 断言的是 JSON 字段,不是 stdout 子串。
修复
[targets.*] required_features从来没有生效过。 门控只有一处,判据是根的活跃 feature 集。一个描述符写下它,得到的是它要求的反面。
对
kind = "shared"的目标这不是外观问题:包里只要有任何一个 shared 目标,它的全部对象就从每个消费者的链接里被拿走。(e2e 308)
-fPIC不在缓存键里。 它是全图的,而键取包声明的 flags。形态由作者定死时可以幸存;一旦消费者能请求 shared,同一条目就会把非 PIC 对象喂给共享
链接,报错指向一个没人改过的文件。现由
make_plan决定一次,编译标志与缓存键读同一位。
dependency_linkage同时进工程指纹 —— 实测两次构建曾落在同一个target/x86_64-linux-gnu/<fp>/。soname会让整份 manifest 加载失败。 收窄为「非 library 目标才拒绝」。
soname写进索引描述符要等latest的 mcpp 下限跨过本版本 —— 旧客户端读到的是加载失败,不是忽略。
文档
docs/05§2.2「共享库目标只支持 Linux/ELF」已过时(e2e 257/259 证明 PE 与Mach-O 早已支持),中英双份更正。
判据
test_linkage_form(16)、test_symbol_provision(12),并扩了test_elf_runtime(读真实产物)、test_cache_key、test_manifest。check_docs_style.sh、check_version_pins.sh通过。