Skip to content

feat: 依赖的链接形态是一根可选择的轴,而混形态会被抓住 (#519) - #521

Merged
Sunrisepeak merged 8 commits into
mainfrom
feat/dependency-linkage-form
Aug 28, 2026
Merged

feat: 依赖的链接形态是一根可选择的轴,而混形态会被抓住 (#519)#521
Sunrisepeak merged 8 commits into
mainfrom
feat/dependency-linkage-form

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Closes #519.

一个库,一个提供者,一种形态。

issue 提的两件事是这条不变量的两半:诊断执行它,形态轴是当映像里确实出现
两个提供者时唯一能让用户满足它的杠杆。一个没有杠杆的不变量只是一堵墙。

不变量在两个高度上执行,因为 mcpp 有两种知识:

知道什么 何时 覆盖 看得见 issue §2 的复现吗
声明层 mcpp 自己决定的东西 plan 期 全平台、精确 看不见(vendor 包里的 libz.so.1 不是 mcpp 的概念)
测量层 链接器实际产出的东西 链接后 仅 ELF 看得见,而且是唯一看得见的

完整设计、实测与两轮自我 review:
.agents/docs/2026-08-28-issue519-dependency-linkage-form.md

⚠️ 范围:mcpp 侧只做通用底座。mcpplibs/mcpp-index#245(eui-neo 的 Linux SNI
托盘)是这条 issue 在真实世界里的实例,用来验证,不驱动设计 —— 引擎里没有
任何 glib / gio / zlib / 托盘 的知识,也不依赖宿主。


新增

[build] dependency_linkage + 依赖边上的 linkage

[build]
dependency_linkage = "shared"        # 全图默认;缺省即 "static"
[profile.dev]
dependency_linkage = "shared"        # 按 profile 覆盖
[dependencies]
"compat.zlib" = { version = "1.3.2", linkage = "shared" }
  • 默认 static ⇒ 既有工程逐字节不变(e2e 306 断言两种配置落在不同
    指纹目录,且默认那次不产出任何 .so)。
  • 形态每个链接映像解析一次,不是每条边一次 —— 同一个库在一个映像里出现
    两种形态,正是本 PR 要抓的缺陷。
  • 边上的 linkage 只在根工程生效。依赖图深处的包无权决定最终程序的布局
    (与 reexport 不搭 visibility 便车是同一条理由);真正必须只有一份共享
    副本的包在自己的 target 上声明。

能力是推导出来的,不是问出来的 —— 零新描述符键:

包写了 读作
kind = "shared" 必须 shared(索引里 12 个包,全是 dlopen 单例)
ldflags-L 必须 static(携带了 mcpp 没编译的预构建归档)
分发包 它随包的那些 [[runtime.artifacts]] role
其他 两种都行

⚠️ kind = "lib" 不是约束,它是解析器的默认值 —— 索引 130 个包里 84 个
把它当样板写下,并没有选择过什么。按字面读会把整个生态锁死在这根轴之外。

⚠️ 两根 linkage 轴不独立:整链静态的映像没有解释器,装不下任何共享对象。
C 库静态链接的目标(musl 的默认)会拒绝 shared 并说明原因。

符号提供者检查

EXPORTED = 已定义 dynsym − copy relocation        # 便宜,总是跑
CONFLICT = EXPORTED ∩ ⋃ defines(closure)          # 仅当 EXPORTED 非空

⭐⭐ 第二段不可省,而这是实测出来的。 mcpp 自己的 kind = "shared" 机制
结构性地产出「exe 导出、.so 绑过来」这个形状(shared 依赖的链接单元只拿
它自己的对象,它的 static 依赖落进消费者的 exe),而那是单份定义、完全良性的。
只看第一段会在正确的构建上刷警告,而用户无事可做。e2e 307 的两半只差一个包:
良性安排必须静默,真冲突必须报告并指名两个提供者

⚠️ 判据是 PT_INTERP 不是 ELF 类型:PIE 可执行文件是 ET_DYN,与共享库
一样,而是否 PIE 取决于载荷工具链的默认值(全仓无 -pie/-no-pie)。按类型判
会在 PIE 世界里恒读「没有可执行文件要查」。

⚠️ copy relocation 按地址判不按名字:environ__environ 的 WEAK 别名,
同址但重定位表里只有后者 —— 按名字判会在每一个 mcpp 产物上误报。

实测基线(四个产物,回归集写进了单测):

产物 ELF 类型 已定义/总 EXPORTED
bin/mcpp EXEC 5 / 217 0
/usr/bin/git DYN(PIE) 1 / 257 1 —— 自己的 error 遮蔽 glibc 的
/usr/bin/ls DYN(PIE) 7 / 127 7 —— gnulib obstack 遮蔽 glibc 的
/usr/bin/bash DYN(PIE) 2339 / 2578 用了 -rdynamic,前提不成立,正确排除

默认警告,--strict 升级为错误(复用 mcpp.diag 的既有策略点,零新严重度机制)。
判定记进 resolution.jsonruntime.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>/

  • ⚠️ 在非 shared 目标上写 soname 会让整份 manifest 加载失败。 收窄为
    「非 library 目标才拒绝」。⚠️ 因此把 soname 写进索引描述符要等 latest
    的 mcpp 下限跨过本版本 —— 旧客户端读到的是加载失败,不是忽略。

文档

docs/05 §2.2「共享库目标只支持 Linux/ELF」已过时(e2e 257/259 证明 PE 与
Mach-O 早已支持),中英双份更正。

判据

  • 单测 95 个二进制全绿,其中新增 test_linkage_form(16)、
    test_symbol_provision(12),并扩了 test_elf_runtime(读真实产物)、
    test_cache_keytest_manifest
  • e2e 306 / 307 / 308 新增;07 / 08 / 222 回归通过。
  • check_docs_style.shcheck_version_pins.sh 通过。

> 一个库,一个提供者,一种形态。

这条不变量在两个高度上执行: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 是同一份字节。
@Sunrisepeak

Copy link
Copy Markdown
Member Author

开出去之后又改的四处,以及每一处是被什么推翻的

⚠️ 全部是实测推翻了写下来的推理,记在这里因为犯错的方式比结论值得读。

1. mcpp pack 不收合成的共享库 —— 包解开就起不来

设计文档 §11.1 写的是「大概率经 $ORIGIN 的闭包被捞到 —— 但这是推理不是测量」,
并为它留了判据 12。判据一跑,推理就错了:

$ mcpp pack && tar xzf … && ./app
error while loading shared libraries: libcore.so

ldd_parse 追的是暂存目录里的副本,而库靠 $ORIGIN 找 —— 副本旁边的
bin/ 是空的。⚠️2026.8.26.1 上用作者声明的 kind = "shared" 依赖同样
复现,所以不是这根轴引入的;但轴把它从「12 个自称 shared 的包」变成
「任何一个包」都可达,与 PIC 进缓存键同一条理由,因此在这里修。

2. 三条出路的次序 —— 第一条会把 1 份变 2 份

用生态自己的 glib 复现 issue §2 之后按第一条出路走了一遍:导出符号 88 → 0、
诊断静默,而进程里加载的 zlib 从一份变成两份(libzlib.so 没有 SONAME,
libgio 仍然去找 libz.so.1)。次序倒过来,单测断言的是次序不是三个子串。

3. 依赖目标的 feature 门抹掉了主机工具 —— 187_dep_host_tool

上一版的注释写着「主机工具那条路会以依赖为根重进 prepare_build,所以到不了
这里」。那句是凭印象写的:工具查找在这段代码下面几百行,对着的正是这份
manifest。收窄到库目标 —— 依赖的 bin 目标在本次构建里不产生任何链接单元。

4. 「零 diff」的对照物是错的

第一次对照用了 2026.8.26.1(差两代)且两个工程在不同路径下 ——
三个指纹互不相同,什么都证明不了。重做:同一个目录、版本字符串held住、
只换二进制 ⇒ 指纹逐字节相同


另外:e2e 2/2 (linux) 那次红是我自己的推送节奏造成的

main 上 3.98s 通过、本 PR 上 0.30s 失败,看起来就是回归。判据在缓存行里:

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 会留下它的缓存;
在同一分支上快速连推会给自己埋一个只在特定判据上现形的地雷。

@Sunrisepeak
Sunrisepeak merged commit 1a49eca into main Aug 28, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat/RFC: 依赖的链接形态(static/shared)应是一根可选择的轴,而不是由包作者定死 —— 现状会静默符号劫持

2 participants