Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
984 changes: 984 additions & 0 deletions .agents/docs/2026-08-28-issue519-dependency-linkage-form.md

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,108 @@
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。

## [2026.8.28.2] — 2026-08-28

依赖是静态并进来还是作为共享库放在旁边,以前由**包作者**定死,消费者没有任何
发言权;而两个包同时提供同一个库时,没有任何机制说这是错的。完整分析见
[`.agents/docs/2026-08-28-issue519-dependency-linkage-form.md`](.agents/docs/2026-08-28-issue519-dependency-linkage-form.md)。

> **一个库,一个提供者,一种形态。** 这条不变量在两个高度上执行:
> plan 期对 mcpp **决定**的东西,链接后对链接器**产出**的东西 ——
> 后者是唯一能看见 mcpp 从不知道其存在的那个库的高度。

### 新增

- **`[build] dependency_linkage`,以及依赖边上的 `linkage`。**(#519)

```toml
[build]
dependency_linkage = "shared" # 全图默认;缺省即 "static"
[dependencies]
"compat.zlib" = { version = "1.3.2", linkage = "shared" }
```

`static`(默认)与 mcpp 一直以来的行为**逐字节相同**,不写这个键的工程
构建结果不变。也可按 `[profile.*]` 覆盖。

形态是**每个链接映像解析一次**,不是每条依赖边一次 —— 同一个库在一个映像里
出现两种形态,正是本条要抓的缺陷。边上的 `linkage` 只在**根工程**生效:
依赖图深处的包无权决定最终程序的布局。

⚠️ **这不是 `[target.<triple>].linkage`**(那根是 C 库轴),而且两者**不独立**:
整链静态的映像没有解释器,装不下任何共享对象。C 库静态链接的目标 ——
**musl 的默认** —— 会拒绝 `shared` 并说明原因。

- **符号提供者检查。** 链接后核验映像里每个符号恰好有一个提供者。

⚠️ ELF 上可执行文件排在最前,被静态并进程序的库会在共享副本之上获胜:
共享的那份永远不会被调用,那个库里的代码跑在一份它并非针对其链接的构建上。
链接器与加载器都不报任何一句话。实测 `/usr/bin/git`(自己的 `error` 遮蔽
glibc 的 `error(3)`)与 `/usr/bin/ls`(gnulib obstack 遮蔽 glibc 的)都是
这个形状的野生实例。

⭐ 判据是**测量**不是声明,因此对 vendor 包里随附的库、`[system_deps]`
引入的宿主库同样有效 —— 引擎不需要认识任何具体的库。

⭐⭐ **两段式,而第二段不可省。** mcpp 自己的 `kind = "shared"` 机制会
**结构性地**产出「exe 导出、`.so` 绑过来」这个形状,而那是单份定义、
完全良性的。只看第一段会在正确的构建上刷警告,而用户对此无事可做。
真正的判据是「导出的东西**还有第二个提供者**」。

默认警告,`--strict` 下升级为错误。判定写进
`resolution.json` 的 `runtime.symbol_provision`,带计数与分母。

### 修复

- **⚠️ 依赖包的 `[targets.*] required_features` 从来没有生效过。**

目标门控只有一处,判据是**根**的活跃 feature 集,依赖包的 `targets`
一个都不过滤。一个描述符写下 `required_features`,得到的是它要求的**反面**:
该目标对每一个消费者都构建,不管 feature 开没开。

⭐ 对 `kind = "shared"` 的目标这不是外观问题:包里只要存在任何一个 shared
目标,它的**全部**对象就会从每个消费者的链接里被拿走。一个「可选」的目标
因此悄悄改变了整个包对所有人的链接方式。

- **⚠️ `-fPIC` 不在缓存键里。**

它是全图的(图里任何一个 shared 链接单元存在,所有对象都带 PIC),而键取的是
包**声明**的 flags,不是 flag 构造器算出来的。在形态由作者定死时可以幸存;
一旦消费者能请求 shared,同一条目就会把非 PIC 对象喂给共享链接,而报错指向
一个没人改过的文件。现在由 `make_plan` **决定一次**,编译标志与缓存键读同一位。

`dependency_linkage` 同样进了工程指纹 —— 否则切换开关会复用上一次配置的
构建目录(实测:两次构建落在同一个 `target/x86_64-linux-gnu/<fp>/`)。

- **⚠️⚠️ `mcpp pack` 不收合成的共享库,包解开就起不来。**

```console
$ ./app
error while loading shared libraries: libcore.so
```

闭包来自**运行产物**,而运行的是**暂存目录里的副本** —— 副本旁边的 `bin/`
是空的,`$ORIGIN` 解析不到,库于是从不出现在闭包里,也就从不被打包。
构建、打包、上传全程无话,失败发生在用户机器上。

⚠️ **不是本版引入的**:在 2026.8.26.1 上用作者声明的 `kind = "shared"`
依赖同样复现。但 `dependency_linkage` 把它从「12 个自称 shared 的包」
变成「任何一个包」都可达,所以在这里修。

- **⚠️ 在非 shared 目标上写 `soname` 会让整份 manifest 加载失败。**

`soname` 是一个库被**找到**时用的名字,也是 mcpp 构建的那份与第三方携带的
同一个库能解析到同一个文件的唯一途径。收窄为「非 **library** 目标才拒绝」,
可执行文件仍然不允许声明。

⚠️ 因此把 `soname` 写进索引描述符要等 `latest` 的 mcpp 下限跨过本版本 ——
旧客户端读到的是加载失败,不是忽略。

### 文档

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

## [2026.8.27.2] — 2026-08-27

一个文件名把整个 Windows 构建打断了,而报错说的是别的事。完整分析见
Expand Down
111 changes: 106 additions & 5 deletions docs/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,16 @@ downstream programs can load the library via its standard ABI name through
`DT_NEEDED` or `dlopen()`. This field only applies to `kind = "shared"`, and the
value must be a filename basename.

Shared-library targets are currently supported only for Linux/ELF targets. A
`kind = "shared"` target for macOS or Windows (including a cross build) is
rejected before planning because mcpp does not yet model Mach-O install names
or PE import libraries. Use `kind = "lib"` for a static library on those
targets, or build the shared library for Linux.
Shared-library targets work on all three binary formats. ELF gets a `.so` with
its `soname` and a `$ORIGIN` search path; Mach-O gets a `.dylib` whose install
name is `@rpath/<file>`, so it survives being moved; PE gets both the `.dll`
the loader opens and the import library the linker consumes, with the export
list generated from the objects on the MSVC ABI (which exports nothing without
`__declspec(dllexport)` or a `.def`). See `tests/e2e/08`, `257` and `259`.

A `soname` is meaningful on `kind = "lib"` too — see
[`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision)
below, where the form a library takes becomes the consumer's decision.

#### Per-target keys

Expand Down Expand Up @@ -186,11 +191,107 @@ cxx_runtime = "self-contained" # C++ runtime contract (§ below); static_stdl
target = "x86_64-linux-musl" # Default build target when no --target is passed
# (≙ cargo build.target; e.g. "ship fully-static")
macos_deployment_target = "14.0" # Minimum supported OS version for macOS artifacts (macOS only)
dependency_linkage = "static" # How dependencies arrive: static (default) | shared (§ below)
cache = "global" # Global dependency cache: global (default) | local | off (§2.10)
jobs = "auto" # Concurrent compiles: a positive number, or "auto" (§ below)
bmi_schedule = "auto" # Module-edge scheduling: auto (= off) | on | off (§ below)
```

#### `dependency_linkage` — static or shared is the consumer's decision

```toml
[build]
dependency_linkage = "shared" # whole-graph default; "static" is the default default

[profile.dev]
dependency_linkage = "shared" # per profile

[dependencies]
"compat.zlib" = { version = "1.3.2", linkage = "shared" } # one package
```

Until mcpp 2026.8.28.2 a dependency had exactly one shape and the *package
author* chose it: `kind = "lib"` merged its objects into every consumer's link,
`kind = "shared"` produced a real shared library. That is the wrong owner for
the decision. Whether a library should be a separate file at run time is a
property of the **program being built** — how it is shipped, how often it is
relinked, whether something else in the process already provides that library.

- **`static`** (default) — the dependency's objects are merged into the images
that use it. Byte-for-byte what mcpp has always done; a project that does not
write this key builds exactly as before.
- **`shared`** — mcpp builds the dependency as a shared library beside the
artifact and links against it, with `$ORIGIN` (ELF) / `@loader_path` (Mach-O)
/ the executable's own directory (PE) finding it again after the build
directory moves.

⚠️ **This is not `[target.<triple>].linkage`** (§2.7.1). That key answers the
same-sounding question about the **C library** (a musl `-static` link, MSVC's
`/MT`). The two are not independent, and the direction matters: a fully static
image has no interpreter, so it cannot load a shared object at all. On a target
whose C library is linked statically — which is the **default for musl** —
`dependency_linkage = "shared"` is refused, and says so.

**A package can say it must be one form**, and only for a real reason:

| The package writes | mcpp reads it as |
|---|---|
| `[targets.<n>] kind = "shared"` | *must* be shared — something else in the process will `dlopen` it, so there may only be one copy (X11, a Vulkan loader) |
| `ldflags` containing `-L` | *must* be static — the package ships prebuilt archives mcpp did not compile and cannot place inside a shared object it builds |
| a packaged library (`mcpp pack`) | whichever legs it actually ships, from `[[runtime.artifacts]] role` |
| anything else | either form |

`kind = "lib"` is **not** a constraint: it is the default value, and most
packages write it without choosing anything. Absence of a statement is not a
statement.

A per-dependency `linkage` is honoured **only in the root project's**
`[dependencies]`. A package deep in the graph does not get to decide how the
final program is laid out; one that genuinely must be a single shared copy says
so on its own target instead.

#### `soname` on a library target

A `soname` (§2.2) may be declared on `kind = "lib"` as well as
`kind = "shared"`. It is the name a library is *found* by, and it is the only
way mcpp's build of a package and a third party's copy of the same library can
resolve to **one file** instead of two — which a package cannot state if
declaring it forces the package to stop being consumable as a static library.

⚠️ A descriptor that writes `soname` on a non-shared target cannot be read by
mcpp releases before 2026.8.28.2 — the whole manifest fails to load, not just
the key. Publishing one to an index therefore waits for that floor to move.

#### The symbol-provision check

After a link, mcpp asks whether every symbol in the image has exactly **one**
provider. On ELF an executable is searched first, so a library statically
merged into the program wins for every symbol it shares with a shared library
loaded beside it — the shared copy is never called, and code inside that
library runs against a build it was not linked against. No linker or loader
diagnostic exists for this.

The check is a measurement, not a declaration: it reads the produced image's
dynamic symbol table, removes the entries that are copy relocations, and
reports only those a library in the artifact's own closure **also** defines.
An arrangement with one copy in the process is silent. The verdict is recorded
in `target/<triple>/<fp>/resolution.json` under `runtime.symbol_provision`,
with the count and its denominator, so CI can read it without `readelf`.

It is a warning by default and an error under `--strict`. The ways out are
ordered, and the order matters:

1. **Stop one side from providing it** — usually a package shipping a copy of a
library the graph already builds. Always correct.
2. **Make both resolve to one file** by declaring the library's real `soname`
on its target.
3. **`dependency_linkage`** changes which form mcpp builds. It removes *this*
finding, but on its own it can leave **two** copies loaded instead of one:
measured on a graph staging glib (whose `libgio` needs `libz.so.1`) beside a
statically built `compat.zlib`, switching the form dropped the executable's
88 exported symbols and then loaded both `libzlib.so` and `libz.so.1`. It
unifies the two providers only when (2) holds as well.

`private_include_dirs` names the entries **of `include_dirs`** that stop at this
package's own boundary: this package compiles with them, and a consumer never
receives them.
Expand Down
91 changes: 87 additions & 4 deletions docs/zh/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,14 @@ soname = "libmylib.so.1" # 可选: Linux/ELF ABI 名称,运行时会生成同
让下游程序可通过标准 ABI 名称 `DT_NEEDED` 或 `dlopen()` 加载该库。
该字段只对 `kind = "shared"` 有效,值必须是文件名 basename。

当前共享库目标只支持 Linux/ELF。面向 macOS 或 Windows 的
`kind = "shared"` 目标(包括交叉构建)会在规划阶段直接拒绝,因为 mcpp
尚未建模 Mach-O install name 或 PE import library。若目标是这些平台,请使用
`kind = "lib"` 构建静态库,或将共享库目标设为 Linux。
共享库目标在三种二进制格式上都可用。ELF 产出带 `soname` 的 `.so` 与 `$ORIGIN`
搜索路径;Mach-O 产出 install name 为 `@rpath/<file>` 的 `.dylib`,因此移动后
仍能被找到;PE 同时产出加载器打开的 `.dll` 和链接器消费的 import library,并在
MSVC ABI 上从对象生成导出表(该 ABI 没有 `__declspec(dllexport)` 或 `.def` 时
不导出任何符号)。参见 `tests/e2e/08`、`257`、`259`。

`soname` 对 `kind = "lib"` 同样有意义 —— 见下文的 `dependency_linkage`,
库以何种形态出现是**消费者**的决定。

#### 按目标的键(per-target keys)

Expand Down Expand Up @@ -174,11 +178,90 @@ ldflags = ["-lfoo"] # 额外链接参数
defines = ["BIZ=1", "QUX"] # 作用于每个 TU 的预处理宏(脱糖为 -D;会进入模块扫描)
cxx_runtime = "self-contained" # C++ 运行时契约(见下节);static_stdlib 是旧拼写
macos_deployment_target = "14.0" # macOS 产物的最低支持系统版本(仅 macOS 生效)
dependency_linkage = "static" # 依赖以何种形态进入:static(默认)| shared(见下文)
cache = "global" # 依赖的全局构建缓存:global(默认)| local | off(见 §2.10)
jobs = "auto" # 并发编译数:正整数,或 "auto"(见下节)
bmi_schedule = "auto" # 模块边调度:auto(= 关)| on | off(见下节)
```

#### `dependency_linkage` —— 静态还是动态由消费者决定

```toml
[build]
dependency_linkage = "shared" # 全图默认;缺省即 "static"

[profile.dev]
dependency_linkage = "shared" # 按 profile 覆盖

[dependencies]
"compat.zlib" = { version = "1.3.2", linkage = "shared" } # 单个包
```

在 mcpp 2026.8.28.2 之前,一个依赖只有一种形态,而且由**包作者**定死:
`kind = "lib"` 把它的对象并进每个消费者的链接,`kind = "shared"` 产出真正的
共享库。这个决定放错了位置。一个库在运行期该不该是独立文件,是**被构建的那个
程序**的性质 —— 它怎么分发、多久重链一次、进程里是不是已经有人提供了这个库。

- **`static`**(默认)—— 依赖的对象并进使用它的映像。与 mcpp 一直以来的行为
逐字节相同;不写这个键的工程构建结果不变。
- **`shared`** —— mcpp 把依赖构建成产物旁边的共享库并链接它,由 `$ORIGIN`
(ELF)/ `@loader_path`(Mach-O)/ 可执行文件自身目录(PE)保证构建目录
移动后仍能找到它。

⚠️ **这不是 `[target.<triple>].linkage`**(§2.7.1)。那个键回答的是听起来相同、
实则关于 **C 库**的问题(musl 的 `-static`、MSVC 的 `/MT`)。两者并不独立,而且
方向很重要:整链静态的映像没有解释器,根本装不下任何共享对象。因此在 C 库静态
链接的目标上 —— 这是 **musl 的默认** —— `dependency_linkage = "shared"` 会被
拒绝,并说明原因。

**包可以声明它必须是某一种形态**,而且只在确有理由时:

| 包写了 | mcpp 读作 |
|---|---|
| `[targets.<n>] kind = "shared"` | *必须* shared —— 进程里会有别人 `dlopen` 它,因此只能有一份(X11、Vulkan loader) |
| `ldflags` 里含 `-L` | *必须* static —— 包携带了 mcpp 没有编译的预构建归档,放不进 mcpp 自己构建的共享对象 |
| 分发包(`mcpp pack`) | 它实际随包的那些腿,取自 `[[runtime.artifacts]] role` |
| 其他 | 两种形态都可以 |

`kind = "lib"` **不是**约束:它是默认值,大多数包写下它并没有做任何选择。
**没有陈述不等于一条陈述。**

依赖边上的 `linkage` 只在**根工程**的 `[dependencies]` 里生效。依赖图深处的包
无权决定最终程序的布局;真正必须只有一份共享副本的包,应当在自己的 target 上
声明。

#### library 目标上的 `soname`

`soname`(§2.2)在 `kind = "lib"` 上同样可以声明。它是一个库被**找到**时用的
名字,也是 mcpp 构建的那份与第三方携带的同一个库能解析到**同一个文件**的唯一
途径 —— 而如果声明它就意味着这个包不能再作为静态库被消费,包就无法陈述这件事。

⚠️ 在非 shared 目标上写 `soname` 的描述符,**无法被 2026.8.28.2 之前的 mcpp 读取**
—— 失败的是整份 manifest,不只是这个键。因此把它发布进索引要等下限抬上去。

#### 符号提供者检查

链接之后,mcpp 会问:映像里的每个符号是不是**恰好有一个**提供者。在 ELF 上
可执行文件排在最前,因此被静态并进程序的库,会在它与旁边加载的共享库共有的
每个符号上获胜 —— 共享的那份永远不会被调用,而那个库里的代码跑在一份它并非
针对其链接的构建上。链接器和加载器都不会为此报任何一句话。

这项检查是**测量**而不是声明:读产物的动态符号表,去掉 copy relocation,只报告
产物自身闭包里**也**有定义的那些。进程里只有一份副本的安排保持静默。判定记录在
`target/<triple>/<fp>/resolution.json` 的 `runtime.symbol_provision` 下,带计数
与分母,CI 不需要 `readelf` 就能读。

默认是警告,`--strict` 下升级为错误。三条出路**有次序**,而次序是要紧的:

1. **让其中一方不再提供这个库** —— 通常是那个携带了依赖图已经在构建的库的副本
的包。永远正确。
2. **让两者解析到同一个文件**:在库的 target 上声明它真正的 `soname`。
3. **`dependency_linkage`** 改变 mcpp 构建的形态。它会消掉**这一条**报告,但单
独用可能把一份变成**两份**:实测在一个暂存了 glib(其 `libgio` 需要
`libz.so.1`)、同时静态构建 `compat.zlib` 的图上,切换形态让可执行文件的 88
个导出符号归零,然后 `libzlib.so` 与 `libz.so.1` **两个都被加载**。只有在
(2) 同时成立时它才真的把两个提供者合成一个。

`private_include_dirs` 指出 **`include_dirs` 中**在本包边界处停住的那些条目:
本包用它们编译,消费者永远收不到。

Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "2026.8.28.1"
version = "2026.8.28.2"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
Loading
Loading