From 31fa04362c09e9cab5f2c849ab6ade50f5731e2c Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 09:35:01 +0800 Subject: [PATCH 01/20] =?UTF-8?q?2026.8.27.1=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E4=BE=A7=E8=A2=AB=E8=A7=A3=E6=9E=90=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=E4=BA=86,=E5=8F=AA=E5=8F=91=E7=BB=99=E4=BA=86?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=BC=96=E8=AF=91=E5=8D=95=E5=85=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⭐ 与 2026.8.25.x / 2026.8.26.1 是同一族的下一层。那两批修的是「谓词问错了」 与「答案没接到决定上」;这一批里,答案接上了一个消费者,而它有五个。 ## ⭐⭐ 编译侧的谓词是 2026.8.26.1 在链接侧修掉的那条的孪生兄弟 `hostflags.cppm` 问 `!crossTargetFlag.empty()`——「命令行上有没有 `--target=`」—— 而它自己的注释写的是「目标侧来自图」。同机、同编译器、同目标,只差写不写 `--target`,编译线少六个 token: --no-default-config -nostdinc++ -isystem /include/c++/v1 -isystem /include//c++/v1 -isystem /include -isystem /include ⇒ 头文件来自一个库,目标文件链自另一个库。两侧现在读同一个 `plan.targetSide.cAbi.prebuilt()`,由 prepare 记在 `Toolchain` 上供三个生产者共用。 ⚠️ `--no-default-config` 从该条件里拆出来无条件发:它不是载荷头文件集合的一部分, 而 cfg 按 post_install.cppm 自己的说法是「per-machine, per-install-path artifact」。 这也解释了报告者那个手写 `-clang++.cfg` 的 workaround 为什么有效。 ⚠️ e2e 295 写的就是这条恒等式,而它只比对 `^ldflags` ⇒ 恒等式在下一行不成立而测试 看不见。现在两条都比,并断言两侧非空。 ## ⭐⭐ 载荷目录名是 LLVM 词汇,查找用的是 mcpp 词汇,且失配静默 `include//c++/v1` 由 LLVM 的构建写下(`x86_64-unknown-linux-gnu`), `tc.targetTriple` 是 mcpp 的(`x86_64-linux-gnu`)。两者只在三元组是探测来的时候 恰好相同。查找是 `if (exists) push_back`,找不到就什么也不发生 —— 而那个目录里 只有 `__config_site`,它的缺席产生的报错读起来像载荷坏了。两种拼法都试。 ## ⭐⭐ 由图供给的目标侧只到达一个编译单元(mcpp#514 §A) 提供 `mcpp:` 层的包发布的是整个目标编译时所依据的头文件集合,而它以 publicUsage 的形态沿依赖边传播 ⇒ 根与 provider 自己的单元拿得到,兄弟依赖包拿不到。结果是 一次构建里两种口味的 BMI(`reference to 'space' is ambiguous`)。 目标侧解析后,`fromGraph()` 的层的 publicUsage 并入每一个包的 privateBuild; std 模块的命令行改读同一个集合,不再自己推一遍。 ## ⭐⭐ 缓存键描述了编译器,没描述它被指向的头文件集合(mcpp#514 §B) `driverIdentity` 按设计覆盖不了它 —— `normalize_driver_output` 故意抹掉路径, 好让一个条目能被两个 home 共享。新增 `targetHeaderSet` 轴,取自已解析的 linkmodel,store-相对化。不 bump kCacheEpoch:旧条目是 miss 不是不可用。 ## ⭐ home 发现有第四份拷贝,而且会伸到别的 home 去 `active_home_xpkgs()` 自己重推一遍 home(漏掉自包含安装那一档); `find_sibling_package` 无条件回落 `~/.xlings/data/xpkgs` ⇒ 密闭构建可以从另一棵树 取载荷,结果直接进每条编译命令的 `-isystem`。前者改读 `mcpp::home::root()`, 后者删除 —— 找不到会说话,找错了不会。 ## 新增 `[build] private_include_dirs` 指出 include_dirs 中在本包边界处停住的条目。publicUsage 此前整份接过 privateBuild,于是内嵌带内部头覆盖层的库(musl 的 src/include 定义 hidden/weak/weak_alias)会把那些宏发给每个消费者。openkal-musl 的 port/include/features.h 自己写下了这个缺口和它的第一优解。 ⚠️ 它是 include_dirs 的子集而不是第二个列表:两类目录的相对顺序是承重的, 而两个 TOML 数组表达不了一个顺序。 ## 判据 - 单元 93 passed / 0 failed(新增 3 条,均确认真的在跑,非 SKIP) - e2e 295 扩到 cxxflags:修复后两工具链四条全绿;未修二进制上退出码 1 并列出六个丢失 token - e2e 304(新):目标侧到达兄弟依赖包 —— 未修二进制上变红 - e2e 305(新):编译用/发布用分开 —— 未修二进制上变红 - 三条 e2e 均带分母(CDB 行数),空 CDB 不会假绿 Closes #514 --- ...026-08-27-openkal-ecosystem-design-plan.md | 1842 +++++++++++++++++ ...-08-27-openkal-native-path-three-issues.md | 1426 +++++++++++++ CHANGELOG.md | 74 + docs/05-mcpp-toml.md | 29 + docs/zh/05-mcpp-toml.md | 23 + mcpp.toml | 2 +- src/build/build_program.cppm | 9 + src/build/cache_key.cppm | 69 +- src/build/flags.cppm | 5 + src/build/prepare.cppm | 231 ++- src/manifest/toml.cppm | 7 + src/manifest/types.cppm | 35 + src/manifest/xpkg.cppm | 18 + src/platform/xlings/xlings.cppm | 57 +- src/toolchain/hostflags.cppm | 68 +- src/toolchain/linkmodel.cppm | 57 +- src/toolchain/model.cppm | 23 + src/toolchain/stdmod.cppm | 6 + src/version.cppm | 2 +- ..._naming_the_host_target_changes_nothing.sh | 77 +- .../304_the_target_side_reaches_every_unit.sh | 128 ++ tests/e2e/305_built_from_is_not_published.sh | 111 + tests/unit/test_cache_key.cpp | 42 + tests/unit/test_hostflags.cpp | 67 + 24 files changed, 4354 insertions(+), 54 deletions(-) create mode 100644 .agents/docs/2026-08-27-openkal-ecosystem-design-plan.md create mode 100644 .agents/docs/2026-08-27-openkal-native-path-three-issues.md create mode 100755 tests/e2e/304_the_target_side_reaches_every_unit.sh create mode 100755 tests/e2e/305_built_from_is_not_published.sh diff --git a/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md b/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md new file mode 100644 index 00000000..ff4a2055 --- /dev/null +++ b/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md @@ -0,0 +1,1842 @@ +# openkal 生态:接口判据、面的补全,与端口层的组合 + +2026-08-27 · 设计方案 + 分批清单(**待 review,尚未实施**) + +配套文档:[`2026-08-27-openkal-native-path-three-issues.md`](2026-08-27-openkal-native-path-three-issues.md) +—— 那份是**三个 issue 的缺陷分析与 mcpp 引擎侧的修复**;本份是 **openkal 及其生态 +仓库的设计/优化升级**,两份不重复。 + +--- + +## 0. 一句话 + +> **openkal 提供面向内核的通用原子能力,组合由端口层做一次,既有生态继承它。** +> +> 于是「openkal 该不该有 X」这个问题,永远先问「X 能不能由已有的原子能力组合出来」, +> 而不是问「有没有程序需要 X」。 + +本文按这条把 27 条运行期失败重排,得到:**四个接口该加**(两小、一中、一大)、 +**七条落在端口层**(其中可复用的那几条归 §2.7 的 `openkal-kit`)、**两条是应用自己的事**。 + +⚠️ 第四个(`openkal.space`,§2.4)是**复查掉两条错误理由之后**才进来的 —— 见 §1.3, +那两条错误本身比结论更值得读。 + +--- + +## 1. 判据:三次替换,最后一条是规范自带的 + +⚠️ **前两条都判错过,记录在此,因为犯错的方式值得记。** + +| # | 判据 | 判错了什么 | +|---|---|---| +| 1 | 「是不是通用内核能力」 | ⚠️ 要人判断。**漏掉了就绪与终端**;把 `net` 判成「等第二个消费者」 | +| 2 | 「传统体系下已可移植的程序用到的,必须有」 | ⚠️ 好用的**筛**,但把 `poll` 误判进来 —— 可移植程序用它是因为 POSIX 给了它,不是因为它不可约 | +| 3 | **clause 10 基数 + 原子性 + 端口层组合** | ⭐⭐ **规范自带,且裁决了前两条的冲突** | + +### 1.1 最终判据(三问,按顺序) + +**问一:一个程序里有几个?**(SPEC clause 10 的基数表) + +| | 一个程序里几个实现 | 语法上表现为 | 契约形式 | +|---|---|---|---| +| openarch | 一个,即处理器 | —— | concepts | +| **openkal** | **每个接口一个** | **「我的 X」**:我的标准输出、我的进程、我的时钟、我的网络栈 | **C ABI** | +| openhal | 多个,即那些设备 | **「哪一个 X」**:哪块声卡、哪个摄像头、哪个 UART | concepts | + +⭐ **判别一个能力归谁,只需问它的名字里是「我的」还是「哪一个」。** +这条与 backend 是 MMIO 驱动还是 ALSA 封装**完全无关** —— 这正是「不关心具体 +backend 实现」的意思。 + +**问二:能不能由已经命名的原子能力组合出来?** +能 ⇒ 它是**端口层**的事,不是 openkal 的。 +判据在仓库里已有一句话形态(`okm_syscall.c:836`,解释 `openkal.random` 为何必须存在): + +> **Entropy is not derivable from the other interfaces** … Neither bypassing the +> layer nor inventing entropy was acceptable, so the layer gained an interface. + +**问三:它的形状是不是某个 OS 的形状?** +是 ⇒ 拆到原子为止。`socket`+`setsockopt`+`sockaddr` 是 POSIX 的形状; +「连到一个端点,得到一个流」才是原子。 + +### 1.2 ⚠️ 一处我说过头的,必须更正:`fork` 不是「硬天花板」 + +前几轮我写过「`fork` 永远不可能,是通用性的硬天花板」。**按判据二,这话是错的。** + +clause 7.1 禁止的是 **`kal_fork` 这个操作**(复制地址空间与执行状态不能在每个环境上 +忠实完成)。它**没有**禁止**地址空间的原子能力**: + +``` +创建一个地址空间 · 复制一个地址空间 · 在其中启动一个上下文 +``` + +有了这三样,端口层**可以**把 `fork` 组合出来 —— Fuchsia 没有 `fork` 却有 +`zx_process_create`/`zx_vmar_map`/`zx_thread_start`,seL4 亦然。 + +⚠️ **本节初稿把第三样写成「以给定寄存器状态启动」,那是错的** —— 那会逼 openkal +认识 `arch::context`,而 clause 10 不允许(C ABI ↔ concepts)。正确的形状与解法 +见 §2.4。 + +按三问过一遍: + +| 问 | 答 | +|---|---| +| 几个? | 一个地址空间管理器 ⇒ **openkal** ✔ | +| 可导出? | ❌ 只有环境能造地址空间 ✔ | +| OS 的形状? | ❌ Fuchsia/seL4/Linux 三种内核都有这个形状,**而 `fork` 才是被绑定的那一个** ✔ | + +⇒ ⭐ **它通过,而且它比 `openkal.net` 更「内核」。⇒ 列入本方案,见 §2.4。** + +### 1.3 ⚠️ 我为「不做」给过三条理由,两条是错的 + +初稿把它记成「候选但不在本批」,理由三条。逐条复查: + +| # | 理由 | 复查 | +|---|---|---| +| 1 | 「面上最大的一次扩张」 | ❌ **算错了** —— 分解对了是 **5 个操作**,比 `openkal.net` 的 6 个还少(§2.4) | +| 2 | 「无 MMU 目标必然缺席」 | ❌ **这根本不是反对理由,它就是 openkal 的设计** —— clause 6.1:实现不提供的接口以**链接期缺席**表达。`openkal.fs` 在 opensbi 上就是缺席的。**由后端选择与实现,正是可组合设计的意思** | +| 3 | 「今天没有消费者」 | ⚠️ 这是**排期**理由,不是设计理由。⭐ 而且它反过来成立:见下 | + +⭐⭐ **理由 3 反过来是「现在就要设计」的论据:`kal_process_spawn` 与地址空间原子能力 +在语义上重叠。** 前者接一个**路径**,由环境完成加载;后者让程序自己造进程。 +两者的关系 —— spawn 是原子之上的复合,还是一个独立接口 —— **必须一次决定**, +否则将来会出现「造一个进程有两条路」,而 clause 8 不允许事后改。 + +⇒ **设计现在做,实现按排期。** 这与「简洁」不冲突:简洁是**面的大小**,不是 +**决定的推迟**。 + +--- + +## 2. openkal(规范包)—— 四个接口,一条记录 + +⚠️ **§2.8 是对本章的深度 review**(推翻了 §2.1/§2.3/§2.4 的部分内容); +**§2.9 是完整性验收** —— 面够不够用,以及唯一一个真实的原子缺口; +**§2.10 用四问回扫**,推翻了 §2.8 ⓹ 的结论并删掉了 §2.8 ⓸。 +**§2.11 是第二次 review**,采纳截止时间方案并补上它的真实代价。 +**§2.12 是六个接口的头文件草案**,**§2.13 是规范/ABI 级 review(六处发现)**, +**§2.14 是 clause 6.3 / clause 11 的增补文本**。 +⭐ **只读一节的话读 §2.13** —— 写草案时撞出来的问题都在那里。 + +⚠️ **每一条的形状都不是它在 POSIX 里的形状。这是本章的要点。** + +### 2.1 `openkal.terminal`(小)—— 独立接口,不是往 stream 里加 + +**依据**:「raw 模式」在不同资源上表现不同(终端 vs 普通文件),正是 clause 6.4 的 +形状 —— 它当时裁定 `seek` 因此属于 `openkal.fs`(资源是 descriptor)而**不**属于 +`openkal.stream`。同理,终端控制属于一个资源为「交互式流」的接口。 + +**入口已经存在**:`KAL_STREAM_PROP_INTERACTIVE`。 + +**面(约 2 操作 + 1 询问)**: + +``` +kal_terminal_set_raw(stream, int on) 不要解释我的输入(行编辑) +kal_terminal_set_echo(stream, int on) 不要回显 +kal_terminal_size(stream, &cols, &rows) 显示多大;不知道就说不知道 +kal_terminal_props +``` + +**不是 `termios`** —— 那是一个带六十个标志位的 POSIX 结构。 + +**各环境**:Linux `tcsetattr`;Windows Console API;裸机 UART **没有行编辑** ⇒ +「关掉它」是**无事可做即完成**(`okm_opt.h` 已有这类先例:释放一个不可能取得的句柄); +尺寸报「不知道」。 + +⚠️ **未决**:`set_raw` 与 `set_echo` 是两个操作还是一个「输入处理模式」? +POSIX 把它们放在一个结构里是历史,不是必然。**倾向两个** —— 它们可以独立变化。 + +### 2.2 `kal_process_channel`(小)—— `openkal.process` 的一次加法 + +**缺口**:`kal_spawn_streams` 已经收流句柄(`process.h:17`),缺的是**取得一个跨得过 +spawn 边界的流**。 + +``` +kal_process_channel(struct kal_stream* parent_end, + struct kal_stream* child_end) +``` + +⭐ **比「给我子进程的 stdout」更原子**:调用方自己决定把 `child_end` 装到子进程的 +哪一个流上,于是 `popen("r")`、`popen("w")`、双向捕获、`2>&1` 都由端口层组合出来。 + +⚠️ **进程内的**流对**不属于这里** —— 可由缓冲 + `kal_task_wait/wake` 导出 ⇒ 是库。 +这条存在的理由**只有**「跨地址空间」。 + +**依据**:clause 8 允许 *"A revision may add declarations"*。 + +### 2.3 `openkal.net`(大)—— 与 `openkal.fs` 同构 + +**依据三条,全部现成**: + +- **clause 10**:NIC(openhal,多个)→ 网络栈(openkal,一个),与 + 块设备 → `openkal.fs` **同构**。fs 已经在规范里,net 的理由与它逐字相同。 +- **clause 3.4**:已经点名 `openkal.net`,并指出它与 fs 的差别正是 **half-closure**。 +- **clause 3.4**:拒绝「解析无边界的名字方案」⇒ **DNS 不在里面**。 + +**面(约六个操作)**: + +``` +kal_net_connect(endpoint) → stream 连到一个端点 +kal_net_listen(endpoint) → listener 接受入站 +kal_net_accept(listener) → stream +kal_net_shutdown(stream, dir) 半关闭 —— clause 3.4 点名的那件事 +kal_net_close_listener(listener) +kal_net_props +``` + +**不在里面**(每一条都是 POSIX 的形状而非原子能力): +地址族与 `sockaddr` 家族 · `setsockopt` 的选项空间 · 非阻塞标志(就绪归端口层) · +`sendmsg/recvmsg` · **名字解析**。 +⇒ endpoint 是**结构化的地址 + 端口**,不是字符串;`getaddrinfo` 是它之上的库。 + +⚠️⚠️ **两处必须动笔前定,不能边写边定(clause 8:接口发布后不可更改):** + +1. **数据报要不要?** 流与数据报是两种资源(clause 6.4 的形状)。 + ⭐ **连锁后果是真的**:若 v1 只做流,DNS 只能走 TCP。这不是细节。 +2. **谁来定形?** 按 [[second-instance-exposes-the-interface]],需要**两个形态不同的 + 实现**才知道分解对不对 —— 一个宿主内核(openkal-linux over sockets)+ 一个 + BSP over openhal(lwIP)。⚠️ **同一个作者的两份实现等于零证据** + ([[openkal-portable-program-findings]])。 + +### 2.4 `openkal.space`(中)—— 地址空间的原子能力 + +**为什么它是原子的而不是 `fork`**:clause 7.1 拒绝 `fork` 的理由是「复制地址空间**与 +执行状态**不能在每个环境上忠实完成」。⭐ 拆开看,不能忠实完成的是**执行状态**那一半 +(任意寄存器状态的恢复),而**地址空间**那一半在任何有 MMU 的环境上都是一次操作。 + +#### 面(七个操作) + +``` +kal_space_create(struct kal_space* out) 一个空的空间 +kal_space_clone(struct kal_space src, struct kal_space* out) 复制内存与句柄 +kal_space_grant_dir(struct kal_space, struct kal_dir, + const char* name, kal_uintptr len) ← kal_fs_preopen 的逆 +kal_space_grant_stream(struct kal_space, kal_uintptr stream, int slot) +kal_space_start(struct kal_space, void (*entry)(void*), void* arg, + void* stack_top, struct kal_task* out) 在其中启动一个上下文 +kal_space_destroy(struct kal_space) +kal_space_props +``` + +⇒ **7 个操作**(含两个 grant),与 `openkal.net` 的 6 个同量级。 +§1.3 那条「最大的一次扩张」仍然是算错了。 + +#### ⭐⭐ 关键:`start` 只收 (entry, arg, stack),**不收寄存器状态** + +这是让它能被一份**可移植 C ABI**表达的那一步。 + +「以任意寄存器状态启动」需要 openkal 认识 `arch::context` —— 而 clause 10 规定 +openkal 是 **C ABI**、openarch 是 **concepts**,二者不能互相引用。 +Fuchsia 撞的是同一堵墙:`zx_thread_start(thread, entry, stack, arg1, arg2)` 只有 +入口和栈,**所以 Fuchsia 上没有真 `fork`**。 + +⭐ **出路在端口层,不在接口**:`fork` 前 `setjmp`,子空间里从 `entry` 起来之后 +`longjmp` 回去。⇒ 子进程只需要从**一个已知函数**起步,而 `jmp_buf` 指向的那段栈 +在**被复制的空间里地址相同**,所以跳得回去。UML 一类的实现就是这么做的。 + +⇒ **接口保持原子且与架构无关,复合发生在端口层。** 这正是「原子能力 + port 组合」 +这条原则第一次真正吃重的地方。 + +#### 各后端的答案(clause 6.1:不提供 = 不导出符号)——**初步网络调研** + +| backend | 形状 | 可行性 | +|---|---|---| +| openkal-linux | `clone(CLONE_VM=0)` / `mmap` | ✅ | +| **openkal-windows** | ⭐ `RtlCloneUserProcess`(ntdll)→ `NtCreateUserProcess`;内核侧 `MmInitializeProcessAddressSpace` 带 **COW 标志**,子进程**地址空间完全相同且从同一位置继续** | ⚠️ **形状完全对上**,但**未文档化**;不复制全部句柄,CSRSS 连接需另行处理 | +| **openkal-macos** | `fork()`(BSD)可用;Mach 的 `task_create(parent, inherit_memory, &child)` **形状正是本接口** | ⚠️ `fork` 可用但 Apple 强烈不建议(框架在 fork 后不安全);`task_create` 在现代 XNU 上受 SIP/entitlement 限制 ⇒ **需实测** | +| openkal-opensbi / uefi | — | ❌ 不提供 —— **缺席即答案**,与 `openkal.fs` 在 opensbi 上一样 | + +⭐⭐ **两条结论:** + +1. **Windows 那条形状比预期准得多** —— `RtlCloneUserProcess` 正是「克隆地址空间 + + 从同一处继续」,连 COW 都在内核里做。⇒ 「Windows 没有 fork 原语」这句常识 + **在 NT 层面是错的**;错的是 Win32 API 面没有暴露它。 +2. ⚠️ 但两者都带**未文档化 / 受限**的性质。⭐ 而这**不阻塞定形** —— + clause 6.1 让「不提供」成为一个合法答案。**缺席即答案这条机制,正是让规范可以 + 先定形、实现按各平台可行性推进的原因。** + +⚠️ 以上为**初步网络调研,未实测**。`RtlCloneUserProcess` 的未文档化状态对一个必须 +长期可用的包是真实风险,落地前要评估。 + +来源:[huntandhackett/process-cloning](https://github.com/huntandhackett/process-cloning) · +[Abusing Windows' Implementation of Fork()](https://billdemirkapi.me/abusing-windows-implementation-of-fork-for-stealthy-memory-operations/) · +[GNU Mach: Task Creation](https://www.gnu.org/software/hurd/gnumach-doc/Task-Creation.html) + +#### 三处待定,现给出裁决与理由 + +**① 继承什么 —— ⭐⭐ 不需要开关,两个入口天然分开** + +初稿把它当成「一个开关」,又改成「显式传句柄」,**两次都不对**。正确的形状是: +**`create` 与 `clone` 是两个不同的入口,各自的名字已经说出了它给什么。** + +| 需求 | 入口 | 得到什么 | +|---|---|---| +| `fork`(要继承全部) | `kal_space_clone(src, &dst)` | ⭐ 复制**内存与句柄** —— 一个空间**持有**它的句柄,克隆自然带上 | +| 沙箱(要一无所有) | `kal_space_create(&s)` + 显式 `grant` | 只有明确交过去的 | + +⇒ **零开关、零歧义,而且每个入口的名字就是它的语义。** + +#### 这条缝规范里已经有另一半 —— clause 7.11 的形状 + +`process.h` 自己写下了这个缺口: + +> A stream handle of zero denotes that the program inherits … **which is what an +> environment without a general mechanism for passing handles can always provide.** + +而且它已经为此留了属性位:**`KAL_PROCESS_PROP_STREAM_PASSING`**。 +⇒ **openkal 早就把「跨 spawn 边界传句柄」建模成一个随实现变化的能力,只是把它限定在了流上。** + +⭐⭐ **更准确地说,缺的那一半是 `kal_fs_preopen` 的逆。** 把接收侧与供给侧并排看: + +| 接收侧(**今天已有**) | 供给侧 | +|---|---| +| `kal_env_arg` / `arg_count` | spawn 的 `argv` ✔ | +| `kal_env_var` / `var_at` | spawn 的 `envp` ✔ | +| 三个标准流 | `kal_spawn_streams` ✔ | +| **`kal_fs_preopen`** | ⭐ **缺失** | + +**恰好一行。** 而 SPEC clause 7.11 正是为这个形状写的: + +> An interface that **reports** a property … and offers **no way to set it** is +> incomplete … The absence was not visible in the specification text; it became +> visible when such programs were compiled above an implementation. + +⇒ **这不是一个新机制,是一个已有机制缺了逆向的那一半** —— 与 `kal_fs_set_modified` +在 0.5 被补上的理由逐字相同。 + +#### 形状:grant 属于 `openkal.space`,而不是 `openkal.process` + +⚠️ clause 8 规定「可以增加声明,不得改动已有的」,而 `kal_process_spawn` 有十个参数、 +`kal_spawn_streams` 的布局按 clause 5.3 不可变 ⇒ **不能往 spawn 上加参数。** + +⭐⭐ 而 `openkal.space` **本来就是两段式的**(`create` 然后 `start`),grant 天然落在中间: + +``` +kal_space_create(&space) +kal_space_grant_dir(space, dir, name, len) ← kal_fs_preopen 的逆 +kal_space_grant_stream(space, stream, slot) ← kal_spawn_streams 的逐个形式 +kal_space_start(space, entry, arg, stack_top, &task) +``` + +**子进程通过已有的 `kal_fs_preopen` 收到它们 —— 接收侧一行不改。** + +⇒ 这一处同时满足了五件事: + +| | | +|---|---| +| clause 8 | ✔ 不动任何已有声明,`kal_process_spawn` 原样保留 | +| **spawn 与 space 独立** | ✔ spawn =「环境按名字加载,普通继承」;space =「我自己搭子进程的世界」 | +| 一致性 | ✔ 与 `kal_fs_preopen` 逐字对称,名字都一样 | +| 能力式 | ✔ 无环境权威,交出去的都是明写的 | +| 不提供的实现 | ✔ 整个 `openkal.space` 缺席,clause 6.1 | + +#### ⭐ 「受限 fork」这个限制随之消失 + +初稿说「`kal_file` 传不过去 ⇒ 端口层只能给一个受限的 fork」。 +⭐ 用 `clone` 就没有这个问题:**一个空间持有它的句柄,克隆复制它们**, +已打开文件的偏移因此是共享的 —— 正是 POSIX `fork` 的语义。 +Linux 的 `fork` 复制 fd 表、能力系统克隆 CSpace,两边都自然。 + +⚠️ **但这要成为规范的一句话**:「空间持有它的句柄;`clone` 复制它们」。 +⭐ 而 Windows 那条调研正好给出了它必须是**属性**的理由 —— +`RtlCloneUserProcess` *"does not necessarily copy all … file handles"* +⇒ 加一位 **`KAL_SPACE_PROP_CLONE_HANDLES`**。 + +⚠️ **仍未决**:`grant_stream` 的 `slot` 与 `kal_spawn_streams` 的三个字段是否统一编号。 +**倾向统一**,否则同一件事有两套编号。 + +**② 与 `spawn` 的关系 —— 独立,而且理由很硬** + +「若是复合,加载器就得从环境搬到端口层」的意思是:今天 `kal_process_spawn` 收一个 +**路径**,由**环境**完成打开文件、解析 ELF/PE/Mach-O、映射段、重定位、 +构造 argv/env/auxv 栈、跳入口 —— 这一整套就是「加载器」。 +若把 spawn 定义成 `space` 原子之上的复合,程序就得自己做这一套。 + +⭐⭐ **决定性理由:可执行格式是环境的属性。** +openkal-windows 上是 PE,openkal-linux 上是 ELF,macos 上是 Mach-O。 +加载器若在端口层,端口层就得认识**所有格式** —— **这正是 openkal 存在要消除的 +那个 N×M**。 + +⇒ **两者独立。`spawn` 保持「环境完成加载」,`space` 只管地址空间。** +这句话要写进规范文本,否则将来两条造进程的路会各长一半。 + +**③ COW —— 判据是「用户可不可感知」,而它恰好可感知一次** + +性能不是语义。⭐ **真正可感知的只有一处:失败在什么时候发生。** + +| | clone 时 | 后续写入时 | +|---|---|---| +| 立即复制 | 可能失败(ENOMEM) | 不会 | +| COW + overcommit | 成功 | ⚠️ **可能失败**(Linux OOM) | + +⇒ 这是**语义差别**,不是性能差别,而且它正是 clause 3.1 所说的「模拟让调用者 +静默地错」那一类。 + +**裁决**:语义上要求 **「成功即完成」**;`kal_space_props` 出一位说明 +**本实现是否可能把失败推迟到后续写入**。⭐ 这正是 clause 6.2 属性字的用法 —— +程序**适应**一个属性,而不是调用它。 + +⚠️ **诚实**:Linux 默认 overcommit 下保证不了 ⇒ 那一位在 openkal-linux 上会是 +「可能推迟」。**这不是缺陷,这正是属性字该说的话。** + +#### 它解锁的不只是 `fork` + +- 沙箱:在一个新空间里跑不受信任的代码 +- ⭐ **一个以 openkal 为 ABI 的操作系统的用户态自己造进程** —— 今天 `spawn` 假定 + 环境里有加载器,而一个 OS 的 userland 需要自己加载 + +### 2.5 SPEC clause 11 增补(零代码) + +六条,每条注明是**边界**还是**未决**: + +| 条目 | 类别 | 理由 | +|---|---|---| +| 进程复制(`fork`) | **边界(操作)** | `kal_fork` 永不(clause 7.1)。⭐ 但 `openkal.space`(§2.4)让端口层把它组合出来 —— **这一行必须同时说出这一点**,否则一句「不会有 fork」会把地址空间那组能力一起埋掉 | +| 网络 | **未决** | 本版不定义;形状草案见 §2.3 | +| 就绪 / 多路复用 | **边界** | 可由 task + stream 导出 ⇒ 属于端口层 | +| 终端控制 | **未决** | 本版不定义;形状草案见 §2.1 | +| 权限 / 属主 | **边界** | 预设身份模型;Windows 只有只读位 ⇒ 不通用 | +| 符号链接的创建/读取 | **边界** | 文件系统**格式**的属性;`std::filesystem` 本就设计成允许失败 | + +⚠️ **「边界」不等于「永远」**:第一行同时是边界与候选,写清楚这一点是本节存在的 +理由 —— 一句「不会有 fork」会把地址空间那组能力一起埋掉。 + +### 2.6 规范包的配套 + +- `SURFACE.txt` 增新接口的名字(⚠️ 它是 clause 9 的**唯一**依据) +- `tools/check-surface.sh` —— ⚠️ [[link-error-is-the-mechanism-not-the-defect]] 记着: + 不带 `--complete` 时它只查**多出**的名字,所以一个非规范实现能通过。 + **新增接口必须同时让 `--complete` 那一侧变红**才算写完。 +- conformance 套件增对应用例 + +--- + +### 2.7 ⭐ `openkal-kit` —— 规范之外的组合库(同仓库,独立包) + +**动机**:判据二把一批东西判给了「端口层」—— 就绪、进程内流对、`fork` 助手、DNS。 +⚠️ 但「端口层」今天只有一个,而且它组合出的是 **POSIX**。 +⇒ **一个原生 openkal 应用想同时等两个流,今天没有答案** —— +要么自己写一遍 thread-per-source,要么把整个 musl 拉进来。 + +#### 定位 + +| | | +|---|---| +| 位置 | ⭐ **openkal 仓库内,与 `include/`、`src/` 同级的 `kit/`** —— 发现性最好 | +| 形态 | **独立的 `mcpp.toml`**,包名 `mcpplibs/openkal-kit`,模块 `openkal.kit.*` | +| 规范地位 | ⭐ **不进 SPEC.md,非规范** —— 它的价值恰恰在于**可以演进**,而 clause 8 让规范不能 | +| 测试 | ✔ 有自己的测试集,进 openkal 仓库的 CI | +| 启用 | `openkal` 包的一个 feature(`[feature-deps.kit]`),不用的不链接 | + +#### ⭐⭐ 它结构上不可能被误认成规范的一部分 + +clause 10 规定 **openkal 的契约形式是 C ABI**。 +⇒ **kit 刻意不是 C ABI** —— 它是 C++ 模块 + `namespace kal::kit`,不导出任何 +`kal_` 开头的 C 符号。 + +这一条同时解决了 `SURFACE.txt` 的约束:*"exports no other name beginning with +`kal_`"* —— C++ 修饰名是 `_ZN3kal3kit…`,**不以 `kal_` 开头** ⇒ +`tools/check-surface.sh --complete` 不会把它读成一个不合规的实现。 + +⭐ **契约形式本身成了「是不是规范」的判据**,而不是靠一句声明。 + +#### 内容(全部由原子组合,零新接口) + +| 模块 | 由什么组合出来 | +|---|---| +| `openkal.kit.wait` | 等 N 个流之一就绪 = `kal_task_wait/wake` + 阻塞读 + 预读缓冲 | +| `openkal.kit.pipe` | 进程内流对 = 环形缓冲 + `wait/wake` | +| `openkal.kit.spawn` | `fork` 助手 = `openkal.space` 的 `clone` + `setjmp/longjmp` | +| `openkal.kit.name` | 端点名字解析(DNS)= `openkal.net` 之上;clause 3.4 明确把它推到接口之外 | + +#### 消费者两类 + +**原生 openkal 应用** · **openkal-musl**(§3.7 的就绪实现直接用 `openkal.kit.wait`) + +⭐ **它顺带回答了一个此前没答的问题**:判据二说「可导出的归端口层」, +但没说**归哪个**端口层。答案是:**归一个所有端口层共用的库** —— +否则「同一个组合写第二遍」会在第二个端口层出现时发生。 + +⚠️ **未决:它与 openkal-musl 的边界。** musl 的 fd 表在 musl 里,而就绪的预读缓冲要挂 +在 fd 上 ⇒ **kit 给的是「等一组流」,fd 那一层的适配仍在 musl。** 这条边界要在写之前画清。 + +### 2.8 ⭐⭐ 深度 review:七处问题,三处是设计错误 + +判据五条:**对上**(原子 · 可组合 · 最小 · 一致)、**对下**(backend 容易实现)。 +逐个接口压测的结果如下。⚠️ 前三条推翻了 §2.1–§2.4 的部分内容。 + +#### ⓵ ⭐⭐⭐ `kal_space_create` 要删掉 —— 两条独立的理由 + +**对下:Linux 上几乎无法实现。** `fork` 给的是**克隆**;要造一个**空**的用户地址空间, +只能 spawn 一个 stub 程序再往里写 —— 那需要一个 stub 二进制,是不能接受的形状。 + +**对上:它逻辑上也没有意义。** `kal_space_start(space, entry, …)` 的 `entry` 是一个 +**本空间的**函数指针。在一个**空**空间里,那段代码根本不在里面。 +⭐ **两条独立的理由指向同一个裁决 —— 这通常说明裁决是对的。** + +⇒ **`openkal.space` 只剩 `clone` / `start` / `destroy` / `props`。** +而沙箱(要一无所有)由 **spawn + grant** 满足,不由 space 满足。 + +⚠️ **连带结论:`space` 实际上只服务 `fork` 一个用例。** 它值不值四个操作, +应当在定形时重新问一次 —— 本文倾向值得,因为 `fork` 之外还有「OS 用户态自己造进程」。 + +#### ⓶ ⭐⭐⭐ 三个接口都产生**拥有的流**,而 core 的流是**借来的** + +`openkal.stream` 的模型是借用(clause 6.7 / clause 11:*"Every handle in the core +interfaces is borrowed"*),所以它**没有 close**。而: + +| 接口 | 产生 | 需要 close 吗 | +|---|---|---| +| `openkal.fs` | `kal_file` | ✔ 已有 `kal_fs_close_file` | +| `kal_process_channel` | 两个流 | ⚠️ **缺** | +| `kal_net_connect` / `accept` | 流 | ⚠️ **缺** | + +⚠️⚠️ **channel 缺 close 不是小事**:父进程 spawn 之后不关掉 `child_end`, +读端**永远等不到 EOF** —— 这是管道的经典陷阱,而接口不提供关闭就无法避免。 + +⭐ **同一个问题第三次出现,说明该收口的是规则而不是补丁。** +clause 11 自己写着:*"Owned handles arrive with `openkal.fs`, and the rules for +their release … are deferred to that interface."* —— **那笔延期到期了。** + +**裁决**:沿用 `openkal.fs` 已确立的形状 ——**谁给的谁收**,每个产生拥有句柄的接口 +自带它的 close(`kal_process_channel_close` / `kal_net_close`)。 +❌ **不**给 core 的 `kal_stream` 加通用 close:那会改变 core 接口的语义, +而 clause 3.2 的理由(core 一旦扩张就要求每个实现)同样适用于给 core 加操作。 + +⇒ channel 变 2 个操作,net 变 7 个。 + +#### ⓷ ⭐⭐ `terminal` 的两个 set 无法恢复原状 + +`set_raw(s, 0)` 恢复到**什么**?一个 TUI 退出时必须把终端还原成**它进来时的样子**, +否则用户的 shell 坏掉。我给的两个 setter **没有保存原状的地方**。 + +⇒ 改成 clause 7.11 要求的**成对**形状,而且顺带从 2 个操作变回 2 个: + +``` +kal_terminal_get_mode(stream, kal_uintptr* mode) +kal_terminal_set_mode(stream, kal_uintptr mode) +kal_terminal_size(stream, &cols, &rows) +kal_terminal_props +/* 位: KAL_TERM_LINE_EDIT KAL_TERM_ECHO */ +``` + +⭐ 与 `kal_fs_file_info` / `kal_fs_set_modified` **同构**,而且 save/restore 是 +`get` 一次、退出时 `set` 回去 —— 一个 TUI 本来就这么写。 + +⚠️ **裸机上仍然成立**:UART 没有行编辑 ⇒ `get_mode` 报「都关着」,`set_mode` 是 +无事可做即完成。 + +#### ⓸ ⭐⭐ 「诞生时收到的东西」缺一个名字 —— `kal_grant` + +`argv` / `envp` / 三个流 / preopen 是**同一类东西**(openkal.env 的定义就是 +*"the parameters a program receives at inception"*),而它们今天在**四个地方各自表达**。 + +把它具体化成一个对象,§2.4 的 grant 问题与沙箱问题一起解决: + +``` +kal_grant_create(&g) +kal_grant_add_arg / add_var / add_stream / add_dir +kal_grant_spawn(g, base, path, path_len, &proc) 按名字启动一个程序 +kal_grant_start(g, space, entry, arg, stack, &task) 在一个克隆的空间里启动 +kal_grant_close(g) +``` + +⭐⭐ **它同时做到三件事**: +1. `kal_process_spawn` 的**十个参数塌成三个**(base/path/grant) +2. **grant 同时服务 spawn 与 space** —— 沙箱走 `grant_spawn`,fork 走 `clone`+`grant_start` +3. 与 `kal_fs_preopen` / `kal_env_*` 的接收侧**逐字对称**(clause 7.11 的逆) + +⚠️ **代价**:`kal_process_spawn` 保留不动(clause 8),于是**有两条 spawn 路**。 +⭐ 这次是**有意且一次性**的,规范可以明说哪条是正道 —— 但**这是一个大决定, +必须单独确认**,它触及 `openkal.process` 的地位。 + +⚠️ **对下检查**:实现要能「暂存一组句柄再一次性交付」—— +Linux 用 `posix_spawn_file_actions` 或 fork+dup2 ✔;Windows 用 +`PROC_THREAD_ATTRIBUTE_HANDLE_LIST` ✔;裸机不提供 process ⇒ 不适用 ✔。 + +#### ⓹ ⭐⭐ net 的数据报问题,实质是「DNS 能不能用」 + +`openkal.kit.name`(DNS)需要 UDP。若 v1 只做流,DNS 只能走 TCP —— +而不少 resolver 不接受 TCP-only。⇒ **「要不要数据报」实际上等于「net 落地之后 +有没有人能用域名」。** + +⚠️ 加上数据报,net 从 7 个操作变成 ~11 个 —— **这就不小了**。 +⇒ ⭐ **这是 net 最大的设计张力,必须专门决策,不能顺带。** 本文不预设答案。 + +#### ⓺ ⭐ 窗口尺寸变化只能轮询 —— 可接受,但必须写下来 + +openkal 没有信号 ⇒ 没有 SIGWINCH。TUI 只能在事件循环里轮询 `kal_terminal_size`。 +⭐ 在一个本来就有事件循环的程序里这是自然的,**但 TUI 作者会先去找通知机制** +⇒ README 必须直说。 + +#### ⓻ ⭐ listener 不是流,而 `kit.wait` 只等流 —— **组合路径成立** + +一个服务器要同时等 listener 与若干连接。listener 不是 `kal_stream`, +`kit.wait` 表面上等不了它。 +⇒ 但 kit 可以为 listener 起一个辅助 task 做阻塞 `accept` ✔。 + +⭐⭐ **这条值得单记:它是「组合发生在库里」这条路第一次被真实用例压测,而它通过了。** +接口不必为组合的方便而变形。 + +#### 修正后的规模 + +| 接口 | 操作数 | 变化 | +|---|---|---| +| `openkal.terminal` | 4 | 形状改(成对),数量不变 | +| `openkal.process` 加法 | **2**(channel + close) | +1 | +| `openkal.space` | **4** | −3(删 `create` 与两个 grant) | +| `openkal.grant` | **7** | ⭐ 新增,但吸收了 space 的 grant 与 spawn 的参数表 | +| `openkal.net` | **7 或 ~11** | +1(close);数据报未决 | + +--- + +### 2.9 ⭐⭐ 完整性验收:面够不够用 + +**方法**:每条需求走一遍「需求 → 组合路径 → 缺哪个原子」。不看接口漂不漂亮, +只看**组合得出来还是组合不出来**。 + +#### 结论 + +> **面基本够用。全部需求里只有一个组合不出来,而它是一个真实的原子缺口:** +> **⭐⭐⭐ 一个阻塞中的读,如何结束。** + +#### 缺口的证据链(实测两处头文件) + +| 实测 | 结果 | +|---|---| +| `task.h:51` `kal_task_wait(word, expected, **timeout_ns**)` + 属性位 `KAL_TASK_PROP_WAIT_TIMEOUT` | ✅ **主循环可以带超时醒来** | +| `stream.h:26` `kal_stream_read(s, buf, len)` | ⚠️ **没有截止时间,阻塞不可打断** | + +⇒ `openkal.kit.wait` 的实现(每源一个辅助 task 做阻塞读)**对短命程序成立** +(TUI 退出时 `kal_exit` 收走一切),**对长跑程序漏 task** —— +每关一个连接漏一个,服务器跑一天漏一万个。 + +⚠️ 同一个缺口还挡住另外两处:`select`/`poll` 的 **timeout 参数**, +以及「**等多个子进程之一退出**」(`kal_process_wait` 也是不可打断的阻塞)。 + +#### 按三问判它 + +| 问 | 答 | +|---|---| +| 可导出? | ❌ **不可** —— 一个已经阻塞在环境里的调用,只有环境能让它返回 | +| 几个? | 一个 ⇒ openkal | +| OS 的形状? | ❌ POSIX 用信号/`pthread_cancel`、Windows 用 `CancelIoEx`、Fuchsia 用 handle close —— **三种形状**,而「等待要有尽头」是它们共同的那个概念 | + +⇒ **它通过,而且是本轮唯一一个通过的新原子。** + +#### 两个候选形状 —— ⚠️ 必须裁决,本文不预设 + +**(a) 带截止时间的等待** + +``` +kal_stream_read_until(stream, buf, len, deadline_ns) → kal_io_result +kal_net_accept_until (listener, deadline_ns, &stream) +``` + +| | | +|---|---| +| ✅ 一次解决三件事 | 超时读 · 辅助 task 可退出 · `select` 的 timeout | +| ✅ 一致性 | 与 `kal_task_wait` 的 `timeout_ns` **形状逐字相同**(ns,零=无超时) | +| ✅ 对下容易 | Linux `poll`+`read` · Windows overlapped/`WaitForSingleObject` · 裸机 UART 轮询+timer —— **每个环境都给得出** | +| ⚠️ 代价 | 不是「立刻取消」,辅助 task 最多再等一个 deadline | +| ⚠️ 位置 | `openkal.stream` 在 **core** 里,而 clause 3.2 的理由(core 一旦扩张就要求**每个**实现)同样适用于加操作 ⇒ ⭐ 应当**自成一个可选接口**(clause 6.2:「实现可能没有的 operation 自成接口」),资源是流/listener | + +**(b) 关闭唤醒**:在 ⓶ 新增的 `kal_net_close` / `kal_process_channel_close` 的**语义**里 +规定「关闭使阻塞在该流上的读返回」。 + +| | | +|---|---| +| ✅ 不加任何新操作 | 只写一句语义 | +| ⚠️ 只覆盖**拥有的**流 | 借来的 stdin 仍然打不断(可接受:`kal_exit` 收尾) | +| ⚠️ 解决不了 timeout | `select(timeout)` 仍然没有答案 | +| ⚠️⚠️ **对下更难** | Linux 上 `close(fd)` **不保证**唤醒 blocked reader ⇒ 实现得自己配一个内部 eventfd + poll。**接口简单了,实现复杂了** | + +⭐ **本文倾向 (a)**:它对下更容易(实现本来就要 poll),而且多解决一件事(timeout)。 +但这是一个需要拍板的决定。 + +#### 其余需求:全部可组合 ✔ + +| 需求 | 组合路径 | | +|---|---|---| +| `fork` | `space.clone` + `setjmp/longjmp` + `grant` | ✔ | +| 子进程 + 捕获输出 | `channel` + `grant_spawn` | ✔ | +| 进程内 pipe | kit:环形缓冲 + `task.wait/wake` | ✔ | +| socket 服务器 | `net.listen/accept` + `kit.wait` | ✔(需缺口) | +| 终端 UI | `terminal.get/set_mode` + `size` 轮询 | ✔ | +| `std::thread` / `atomic::wait` | `task.start` / `task.wait/wake` | ✔ **正好对上** | +| `std::chrono` / `sleep_for` | `time` | ✔ | +| `std::random_device` | `random` | ✔ | +| `` | `fs`(部分操作允许失败,标准如此设计) | ✔ | +| 时区数据库 | 读 tzdata 文件 ⇒ `fs` | ✔ | +| `dlopen` / 动态链接 | 读文件 `fs` + `openkal.exec` 的可执行内存 + 重定位(库) | ✔ **exec 够用** | +| 等多个子进程之一 | kit + 辅助 task | ✔(需缺口) | +| cwd / `chdir` | 端口层在 preopen 之上模拟(`okm_getcwd.c` 已有) | ✔ | + +#### 两条已识别、本轮不必,但其中一条会影响定形 + +| | 三问 | 处置 | +|---|---|---| +| **共享内存**(两个空间共用一段) | 通过(不可导出 · 一个 · 通用:shm/section/VMO) | ⚠️⚠️ **本轮不做,但 `space` 定形时必须留一句话** —— 将来它一出现,`kal_space_clone` 就要回答「共享的那段怎么办」 | +| **文件锁**(多进程协调) | 通过,但裸机没有 | 记入 clause 11,今天无消费者 | + +⚠️ **内存映射文件不是缺口,是性能**:`mmap(fd)` 的用途(动态链接、大文件) +都能用 `read` + `openkal.exec` 组合出来,只是慢。 + +--- + +### 2.10 ⭐⭐ 用四问回扫:数据报的结论是反的,而 grant 是我过度设计 + +**判据(四问,顺序即优先级)**: + +1. **是不是最小的原子能力?** +2. **是不是所有内核都有?** +3. **是不是只能由 openkal 给,而不能由已有原子组合出来?** +4. **是不是通用(不与某个 OS 的形状绑定)?** + +> 能由原子组合出来的,一律去 `openkal-kit`。只有**必须**的才入规范。 +> 且**对上对下双向**检查,并与 openkal 已有的语义/风格一致。 + +#### ⓵ 数据报:⭐⭐⭐ 我的结论是反的 + +初稿说「加上数据报 net 从 7 变 11,就不小了」。**用四问过一遍,这个反对理由不成立。** + +| 问 | 答 | +|---|---| +| 最小原子? | ✔ `open` / `send_to` / `recv_from` / `close` / `props` —— **5 个** | +| 所有内核都有? | ✔ 而且 ⭐ **比流更普遍** —— 一个 IP 栈实现 UDP 只要几百行,TCP 要几千行 | +| 只能 openkal 给? | ✔ **消息边界、无连接、可能乱序** —— 这些**不可能**由字节流组合出来 | +| 通用? | ✔ | + +⭐⭐ **而「变成 11 个操作」这个担心,是因为我把它们当成了一个接口。** +按 clause 6.4(我自己在 §2.3 就引用过它):**流与数据报是两种资源** —— +「定位适用于文件而不适用于连接;半关闭适用于连接而不适用于文件」是同一条推理。 + +⇒ **它们应当是两个接口:** + +``` +openkal.net 连接(流) connect / listen / accept / shutdown / close / props 6 +openkal.datagram 数据报 open / send_to / recv_from / close / props 5 +``` + +⭐ **拆开之后,对上对下都更好:** + +| | | +|---|---| +| **对下** | 一个裸机 BSP 可以**只提供 datagram**(UDP 好写得多)⇒ clause 6.1 的缺席机制正好用上 | +| **对上** | ⭐ **DNS 不再被「TCP-only」卡住** —— `kit.name` 只依赖 `openkal.datagram` | +| **风格** | 与 `fs` / `process` / `task` 一样**按资源分**,不是按主题分 | + +⇒ **§2.8 ⓹ 那条「必须专门决策」的张力消失了。答案是拆,不是选。** + +⚠️ **DNS 的 resolver 地址从哪来?** POSIX 读 `/etc/resolv.conf`(ambient 配置)。 +⭐ openkal 上应当**由调用方显式传入** —— 这与能力式模型一致(程序不该有 ambient 的 +resolver),而且它可组合(从 `kal_env_var` 或一个 preopen 的文件读,由**程序**决定)。 +⇒ **不需要新原子。** + +#### ⓶ ⭐⭐ 同一把尺子回扫,`kal_grant` 是我过度设计 + +§2.8 ⓸ 引入 `kal_grant` 对象(7 个操作),理由两条: +**(1)** 把 preopen 交给一个新空间 · **(2)** 让 `kal_process_spawn` 的十个参数塌成三个。 + +用四问过: + +| 部分 | 只能 openkal 给? | 判定 | +|---|---|---| +| `add_dir`(交出一个目录句柄) | ✔ 不可组合 | **必须** | +| `add_stream` | ✔ | **必须**(spawn 已有,形式不同) | +| `add_arg` / `add_var` | ❌ **argv/env 只是字节,spawn 已经收它们** | **便利,不是原子** | +| 「塌掉参数表」 | ❌ 纯便利 | **不是理由** | + +⇒ ⭐ **grant 对象把「没办法传的」和「已经有办法传的」放进了同一个对象。** +按「只有必须的才入规范」,它的正当理由只剩 `add_dir` 一条。 + +⚠️ 而**与 openkal 风格一致**这条也指向同一边:`kal_process_spawn` 本来就是十个参数 +的长参数表,**再加一个参数与既有风格一致**;引入一个 grant 对象是**一个新概念**。 + +⇒ **退回最小形状 —— 一个新声明,一个新参数:** + +```c +struct kal_preopen { struct kal_dir dir; const char* name; kal_uintptr len; }; + +int kal_process_spawn_with(struct kal_dir base, const char* path, kal_uintptr path_len, + const char** argv, const kal_uintptr* argv_lens, kal_uintptr argc, + const char** envp, const kal_uintptr* envp_lens, kal_uintptr envc, + const struct kal_spawn_streams* streams, + const struct kal_preopen* grants, kal_uintptr grant_count, + struct kal_process* out); +``` + +⚠️ 重复十个参数**是丑的**,但它是**最小的**:一个声明、零新对象、零新概念, +且 `kal_process_spawn` 原样保留(clause 8)。 +⭐ 这是「最小 + 一致」压过「好看 + 便利」的一次,而那正是这四问的排序。 + +#### ⓷ ⭐⭐⭐ 连带:`openkal.space` 的两个 grant 也不需要了 + +§2.8 ⓵ 删掉 `kal_space_create` 之后,`space` 只剩 `clone` —— 而 +**`clone` 已经复制了全部句柄**(§2.4)。于是: + +| 用例 | 走哪条 | 还需要 space 的 grant 吗 | +|---|---|---| +| `fork`(继承全部) | `kal_space_clone` | ❌ clone 全带 | +| 沙箱(只给指定的) | `kal_process_spawn_with` | ❌ 不走 space | + +⇒ ⭐ **`kal_space_grant_dir` / `kal_space_grant_stream` 整个删掉。** + +#### 修正后的规模(与 §2.8 对照) + +| 接口 | §2.8 | **§2.10** | 变化 | +|---|---|---|---| +| `openkal.terminal` | 4 | 4 | — | +| `openkal.process` 加法 | 2 | **3** | +1(`spawn_with`) | +| `openkal.space` | 4 | **4** | 形状变(去 grant,回到 clone/start/destroy/props) | +| `openkal.grant` | 7 | **0** | ⭐ **删除** —— 便利不是原子 | +| `openkal.net`(连接) | 7 或 ~11 | **6** | ⭐ 张力消失 | +| `openkal.datagram` | — | **5** | ⭐ 新增,独立接口 | +| 截止时间(§2.9) | — | **2** | 待裁决 | + +⇒ **总计 24 个操作,分在六个可选接口里**,而 §2.8 的版本是 24 个分在五个里 —— +**数量持平,而每个接口都更小、更可独立提供、且没有一个是「便利」。** + +⭐⭐ **这一节的价值不在数字,在于它证明了那四问是可执行的**:同一把尺子, +一次推翻了我的一个反对理由(数据报),一次扫掉了我的一处过度设计(grant)。 +**判据要能否定作者自己,才算判据。** + +--- + +### 2.11 ⭐⭐ 第二次深度 review:采纳 (a),但理由和代价都要重写 + +**(a) 带截止时间的等待,已采纳。** 但本轮 review 找到一个**更好的候选**, +算清之后它仍然输 —— 输在哪里,比结论更值得记。 + +#### ⓵ ⭐⭐⭐ 考虑过而未采纳:就绪通知(`notify`) + +与其「让阻塞的读能超时退出」,不如**让它根本不必阻塞**: + +``` +kal_stream_notify(stream, kal_u32* word) 该流可读时,递增 *word 并 wake 它 +``` + +主循环于是 `kal_task_wait(word, seen, timeout)` —— ⭐ **一个 task 等 N 个源, +辅助 task 整个不需要。** + +| | `read_until`(a) | `notify` | +|---|---|---| +| 操作数 | ⚠️ 每个可无限阻塞的操作 +1(见 ⓶) | ⭐ **1 个覆盖全部可等待的东西** | +| 辅助 task | 仍需 N 个(只是能退出了) | ⭐ **不需要** | +| ⭐ 预读缓冲(§3.7) | ⚠️ **仍然需要** | ⭐ **整个消失**(通知不消耗数据) | +| 与既有原子咬合 | 平行的第二套超时 | ⭐⭐ **正好接上 `kal_task_wait/wake`** | +| §2.8 ⓻ 的 listener | 靠 kit 变通 | ⭐ 接口层面解决 | +| 对下:裸机 | 轮询 + timer | ⭐ **中断 → wake,更自然** | +| **对下:Linux** | ⭐ `poll(fd, ms)` + `read`,**十行、无状态、无线程** | ⚠️⚠️ **要一个进程级 epoll + 一个后台线程** | + +⇒ ⭐⭐ **它在五项上更好,输在最后一项 —— 而最后一项是 clause 7.1。** + +> **clause 7.1(Naturalness)**:实现不应被迫构建一个兼容层。 + +一个**隐藏的后台轮询线程**,正是「重建一套事件机制」。而 `read_until` 在 Linux 上 +就是驱动本来就要做的那件事(`poll` 然后 `read`)。 + +⇒ **按 clause 7.1 采纳 (a)。** 并按规范自己的体例(**clause 6.3 +「Mechanisms considered and not adopted」**),把 `notify` 连同这张表写进规范 —— +⭐ **否则下一个人会重新想到它,并重新算一遍。** + +#### ⓶ ⚠️ (a) 的代价比上一版说的大 + +「哪些操作会无限阻塞」穷举一遍,每个都要一个带截止时间的版本: + +| 会无限阻塞 | 需要 | +|---|---| +| `kal_stream_read` | ✔ | +| `kal_stream_write` | ⚠️⚠️ **我上一版漏了** —— 管道满 / TCP 窗口满时会阻塞 | +| `kal_net_accept` | ✔ | +| `kal_datagram_recv_from` | ✔ | +| `kal_process_wait` | ✔ | +| `kal_task_join` | ❌ 可由 `wait/wake` 协调,不需要 | + +⇒ **`openkal.deadline` 是 5 个操作 + props = 6**,与 `openkal.net` 同样大。 +**这是 (a) 的结构性代价:每新增一个可无限阻塞的操作,这个接口就 +1。** + +⚠️ 而「同一件事两个版本」在规范里**有先例**:clause 7.8 让 +`kal_fs_open_file`(两个 flag)与 `kal_fs_open`(整个意图一个字)并存,并给了理由。 +⇒ 不是新问题,但**这个先例必须被引用**,否则它看起来像随手加的。 + +#### ⓷ ⭐⭐ `kal_stream_write` 会阻塞 ⇒ `popen` 的经典死锁,§3.3 漏了 + +kit 的 wait 只处理了**读侧**。而一个子进程往满的管道写、父进程又在等它退出 ⇒ **死锁**。 +这是 `popen` 教科书级的陷阱,而 §3.3 的两阶段实现没有提。 + +⇒ **§3.3 必须补一条**:临时文件阶段天然没有这个问题(文件不会满); +换到 `kal_process_channel` 之后**就有了** ⇒ 那一步必须同时用上 +`kal_deadline_write`。⭐ **一个「后来才出现的死锁」比一开始就有的更难查。** + +#### ⓸ ⭐⭐ `kal_space_start` 应当返回 `kal_process`,不是 `kal_task` + +一个跑在**另一个地址空间**里的执行上下文,`kal_task_join` 能 join 它吗? +跨空间的 task 句柄语义没人定过。 + +⭐ 而它本来就有名字:**一个在自己地址空间里执行的东西,就是一个进程。** + +⇒ `kal_space_start(space, entry, arg, stack_top, struct kal_process* out)`, +之后 `kal_process_wait` / `terminate` / `close` **全部复用**。 +**一致性 +1,新概念 −1,操作数不变。** + +#### ⓹ 四处小项(写下来免得落掉) + +| | 裁决 | +|---|---| +| `struct kal_endpoint` 定义在哪 | `types.h`(或共享头)—— net 与 datagram 共用,且**一个缺席时另一个仍要能用** | +| `kal_net_shutdown` 的方向 | 三个常量:read / write / both | +| `kal_terminal_size` 在非终端流上 | `kal_err_not_supported` —— 与今天 `ioctl` 的 `ENOTTY` 对应 | +| 跨接口引用类型 | ✔ **已有先例**:`kal_fs_stream(kal_file) → kal_uintptr` 已经引用了 stream。**声明在规范包、符号在实现包**,类型可见而符号缺席,正是 openkal 的模型 | + +#### 定稿规模 + +| 接口 | 操作数 | | +|---|---|---| +| `openkal.terminal` | 4 | `get_mode` / `set_mode` / `size` / `props` | +| `openkal.process` 加法 | 3 | `channel` / `channel_close` / `spawn_with` | +| `openkal.space` | 4 | `clone` / `start`(→`kal_process`) / `destroy` / `props` | +| `openkal.net` | 6 | `connect` / `listen` / `accept` / `shutdown` / `close` / `props` | +| `openkal.datagram` | 5 | `open` / `send_to` / `recv_from` / `close` / `props` | +| **`openkal.deadline`** | **6** | `read` / `write` / `accept` / `recv_from` / `wait_process` / `props` | + +⇒ **28 个操作,六个可选接口,没有一个是「便利」。** +⚠️ 比 §2.10 多 4 个,全部来自 ⓶ 的穷举 —— **穷举比估计贵,但估计会漏。** + +--- + +### 2.12 头文件草案(六个接口) + +⚠️ **草案,不是定稿。** 每份都按现有头文件的约定写:句柄是**单字 struct 传值**、 +字符串**永远带长度**、传输返回 `struct kal_io_result`、释放返回 `void`、 +只 include 兄弟头。 + +#### `openkal/timeout.h` —— ⚠️ 名字从 `deadline` 改了,理由见 §2.13 ⓷ + +```c +/* openkal.timeout --- a bound upon operations that would otherwise wait + * without end. + * + * Every operation here is the operation of the same name in another interface, + * with one argument added. Clause 7.8 already establishes that a second form of + * one operation is admissible when the first cannot state the whole of an + * intent: `kal_fs_open_file' and `kal_fs_open' stand beside each other for that + * reason, and these stand beside their originals for the same one. + * + * ⚠️ THE ARGUMENT IS A DURATION, NOT AN INSTANT, and the name of this interface + * was changed to say so. `kal_task_wait' already takes `timeout_ns' and already + * defines zero as no timeout; a second spelling of the same idea would be the + * one thing this specification most consistently refuses. + * + * An expired bound is reported as `kal_err_again' --- "the operation would + * block" --- which is what an expiry is. The error set is closed (clause 5.2) + * and required no addition. */ +#ifndef OPENKAL_TIMEOUT_H +#define OPENKAL_TIMEOUT_H +#include "types.h" +#include "stream.h" +#include "net.h" +#include "datagram.h" +#include "process.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct kal_io_result kal_timeout_read (struct kal_stream, void*, kal_uintptr len, kal_u64 timeout_ns); +struct kal_io_result kal_timeout_write(struct kal_stream, const void*, kal_uintptr len, kal_u64 timeout_ns); + +int kal_timeout_accept(struct kal_net_listener, kal_u64 timeout_ns, struct kal_stream* out); + +struct kal_io_result kal_timeout_recv_from(struct kal_datagram, void*, kal_uintptr len, + struct kal_endpoint* from, kal_u64 timeout_ns); + +int kal_timeout_wait_process(struct kal_process, kal_u64 timeout_ns, + int* status, int* terminated); + +/* The smallest bound this implementation distinguishes, in nanoseconds. A + * board whose only clock ticks at a millisecond reports 1000000; a caller that + * asks for less is not refused and does not get less. Per implementation, so a + * word rather than an enquiry (clause 6.2). */ +extern const kal_uintptr kal_timeout_granularity_ns; + +#ifdef __cplusplus +} +#endif +#endif +``` + +#### `openkal/terminal.h` + +```c +/* openkal.terminal --- what an interactive stream does with what is typed at + * it. The resource is a stream for which `kal_stream_props' reports + * KAL_STREAM_PROP_INTERACTIVE; every operation here reports + * kal_err_not_supported for any other. + * + * A separate interface rather than operations upon `openkal.stream', for the + * reason clause 6.4 gives when it places positioning in `openkal.fs': the + * behaviour varies between the RESOURCES of the stream interface, and an + * implementation could neither claim these honestly nor withhold them usefully. + * + * ⚠️ THE PAIR IS get/set AND NOT two setters. A program that turns line editing + * off must be able to put back what was there, and a setter alone gives it + * nothing to put back --- it would restore a default, and the terminal a user + * returns to is then not the one they had. Clause 7.11 states the general rule; + * this is an instance of it. */ +#ifndef OPENKAL_TERMINAL_H +#define OPENKAL_TERMINAL_H +#include "types.h" +#include "stream.h" + +/* Positions in the mode word. A position that has not been assigned reads as + * zero, so a program compiled against a later specification behaves correctly + * against an earlier implementation (clause 6.2). */ +#define KAL_TERM_LINE_EDIT ((kal_uintptr)1u << 0) /* the environment assembles lines */ +#define KAL_TERM_ECHO ((kal_uintptr)1u << 1) /* the environment shows what is typed */ + +#ifdef __cplusplus +extern "C" { +#endif + +int kal_terminal_get_mode(struct kal_stream, kal_uintptr* mode); +int kal_terminal_set_mode(struct kal_stream, kal_uintptr mode); + +/* The size of the display, in character cells. An environment that does not + * know --- a serial line has no way to ask --- reports kal_err_not_supported + * and leaves both outputs untouched. + * + * ⚠️ THERE IS NO NOTIFICATION. openkal has no signals, so a program learns of a + * change by asking again. A program with an event loop already has somewhere to + * ask from; one without does not need to know. */ +int kal_terminal_size(struct kal_stream, kal_uintptr* cols, kal_uintptr* rows); + +kal_uintptr kal_terminal_props(struct kal_stream); /* enquiry: varies per resource */ + +#ifdef __cplusplus +} +#endif +#endif +``` + +#### `openkal/net.h` —— 连接 + +```c +/* openkal.net --- a connection, which is a stream with a peer and a way to be + * half-closed. Clause 3.4 records why this is not merged with `openkal.fs': + * positioning applies to a file and not to a connection, half-closure to a + * connection and not to a file. + * + * ⚠️ NAME RESOLUTION IS NOT HERE. Clause 3.4 excludes it in terms: an + * implementation shall not be required to parse an unbounded set of name + * schemes. An endpoint is an address and a port; turning "example.com" into one + * is a library above `openkal.datagram'. */ +#ifndef OPENKAL_NET_H +#define OPENKAL_NET_H +#include "types.h" +#include "stream.h" + +struct kal_net_listener { kal_uintptr h; }; + +/* Directions for kal_net_shutdown. */ +#define KAL_SHUT_READ 1 +#define KAL_SHUT_WRITE 2 +#define KAL_SHUT_BOTH 3 + +/* Positions in kal_net_props. */ +#define KAL_NET_PROP_IPV6 ((kal_uintptr)1u << 0) +#define KAL_NET_PROP_HALFCLOSE ((kal_uintptr)1u << 1) + +#ifdef __cplusplus +extern "C" { +#endif + +int kal_net_connect(const struct kal_endpoint*, struct kal_stream* out); +int kal_net_listen (const struct kal_endpoint*, struct kal_net_listener* out); +int kal_net_accept (struct kal_net_listener, struct kal_stream* out); + +/* Ends transfer in one direction while the other continues. This is the + * operation that distinguishes a connection from a file, and it is why the two + * are separate interfaces. */ +int kal_net_shutdown(struct kal_stream, int direction); + +/* ⚠️ AN OWNED STREAM, UNLIKE THE THREE `openkal.stream' provides. A connection + * is obtained and must be released; the standard streams are borrowed and are + * not. Same division `openkal.fs' already draws with kal_fs_close_file. */ +void kal_net_close (struct kal_stream); +void kal_net_close_listener(struct kal_net_listener); + +extern const kal_uintptr kal_net_props; + +#ifdef __cplusplus +} +#endif +#endif +``` + +#### `openkal/datagram.h` + +```c +/* openkal.datagram --- messages with boundaries, sent without a connection. + * + * A SEPARATE INTERFACE FROM `openkal.net', for the reason clause 6.4 gives: a + * datagram and a connection are two resources, and an operation that some + * resources of an interface can never satisfy does not belong in it. A message + * boundary is not a property a byte stream has; ordering is not a property a + * datagram has. + * + * ⭐ AND IT IS THE EASIER HALF TO PROVIDE. A board that carries an IP stack + * reaches datagrams in a few hundred lines and connections in a few thousand, + * so an implementation that supplies only this one is ordinary rather than + * deficient --- clause 6.1 already expresses that by absence. */ +#ifndef OPENKAL_DATAGRAM_H +#define OPENKAL_DATAGRAM_H +#include "types.h" + +struct kal_datagram { kal_uintptr h; }; + +#define KAL_DGRAM_PROP_IPV6 ((kal_uintptr)1u << 0) +#define KAL_DGRAM_PROP_BROADCAST ((kal_uintptr)1u << 1) + +#ifdef __cplusplus +extern "C" { +#endif + +/* A local endpoint of zero port asks the environment to choose one. */ +int kal_datagram_open(const struct kal_endpoint* local, struct kal_datagram* out); + +/* A message is sent whole or not at all; a partial send is not a result this + * interface produces. */ +struct kal_io_result kal_datagram_send_to(struct kal_datagram, const void*, kal_uintptr len, + const struct kal_endpoint* to); + +/* Reports one message and who sent it. A message longer than the buffer is + * truncated and the excess is lost, which is what the medium does. */ +struct kal_io_result kal_datagram_recv_from(struct kal_datagram, void*, kal_uintptr len, + struct kal_endpoint* from); + +void kal_datagram_close(struct kal_datagram); + +extern const kal_uintptr kal_datagram_props; + +#ifdef __cplusplus +} +#endif +#endif +``` + +#### `openkal/space.h` + +```c +/* openkal.space --- an address space, and a context executing in one. + * + * ⚠️ THIS IS NOT `fork'. Clause 7.1 refuses to require the duplication of an + * address space AND ITS EXECUTION STATE; what is here is the first half alone. + * A context started in a cloned space begins at a function the caller names, + * not at the instruction the caller was executing --- which is what lets this + * be stated in a C application binary interface at all. A library above this + * interface reaches `fork' by saving its own execution state before the clone + * and restoring it in the new context; that is composition, and it belongs + * above this line rather than in it. + * + * ⚠️ THERE IS NO `create'. An empty address space contains no code, so the + * entry function a caller would name is not in it. A program that wants a + * child with only what it grants uses kal_process_spawn_with. */ +#ifndef OPENKAL_SPACE_H +#define OPENKAL_SPACE_H +#include "types.h" +#include "process.h" + +struct kal_space { kal_uintptr h; }; + +/* Positions in kal_space_props. */ +/* Whether a clone carries the handles the original holds. An environment whose + * cloning primitive copies memory and not handles reports zero here, and a + * library above it cannot reach POSIX fork semantics. Measured: this is the + * case on at least one environment. */ +#define KAL_SPACE_PROP_CLONE_HANDLES ((kal_uintptr)1u << 0) +/* Whether the copy may be completed lazily, so that a store to cloned memory + * may fail after the clone reported success. A program that cannot tolerate a + * deferred failure adapts; it cannot ask for the other behaviour. */ +#define KAL_SPACE_PROP_DEFERRED_COPY ((kal_uintptr)1u << 1) + +#ifdef __cplusplus +extern "C" { +#endif + +int kal_space_clone(struct kal_space* out); /* clones the CALLER's space */ + +/* Starts a context in the given space. The result is a process: a context with + * an address space of its own is what that word means, and `kal_process_wait', + * `kal_process_terminate' and `kal_process_close' therefore apply unchanged. */ +int kal_space_start(struct kal_space, void (*entry)(void*), void* arg, + void* stack_top, struct kal_process* out); + +/* Releases the space. A space whose context is running is not released until + * that process ends; the two handles are independent and either may be closed + * first. */ +void kal_space_destroy(struct kal_space); + +extern const kal_uintptr kal_space_props; + +#ifdef __cplusplus +} +#endif +#endif +``` + +#### `openkal/process.h` 的三处增补 + +```c +/* --- added --- */ + +/* One directory a started program shall receive among its preopens. The layout + * is frozen (clause 5.3). */ +struct kal_preopen { + struct kal_dir dir; + const char* name; + kal_uintptr len; +}; + +/* Starts a program that receives exactly the directories named. The program + * reads them back through kal_fs_preopen, which is the operation this one is + * the inverse of --- clause 7.11. + * + * ⚠️ A SECOND DECLARATION RATHER THAN AN ARGUMENT ADDED TO THE FIRST, because + * clause 8 forbids altering an existing one. kal_process_spawn remains, and a + * program that does not grant directories keeps using it. */ +int kal_process_spawn_with(struct kal_dir base, + const char* path, kal_uintptr path_len, + const char** argv, const kal_uintptr* argv_lens, kal_uintptr argc, + const char** envp, const kal_uintptr* envp_lens, kal_uintptr envc, + const struct kal_spawn_streams* streams, + const struct kal_preopen* grants, kal_uintptr grant_count, + struct kal_process* out); + +/* A pair of streams of which one end crosses a spawn boundary. The parent + * holds `mine'; `theirs' is what it places in a kal_spawn_streams. + * + * ⚠️ BOTH ENDS ARE OWNED. A parent that does not release `theirs' after the + * spawn never observes the end of input on `mine' --- the classic deadlock of + * this arrangement, and the reason an interface that hands out these streams + * must also take them back. */ +int kal_process_channel(struct kal_stream* mine, struct kal_stream* theirs); +void kal_process_channel_close(struct kal_stream); + +/* --- added positions in kal_process_props --- */ +#define KAL_PROCESS_PROP_CHANNEL ((kal_uintptr)1u << 3) +#define KAL_PROCESS_PROP_GRANT_DIR ((kal_uintptr)1u << 4) +``` + +#### `openkal/types.h` 的两处增补 + +```c +/* A byte. The three fixed widths above are the ones openkal's OPERATIONS use; + * an address is the first datum openkal must state byte by byte. */ +#if defined(__UINT8_TYPE__) +typedef __UINT8_TYPE__ kal_u8; +#elif defined(_MSC_VER) +typedef unsigned char kal_u8; +#endif + +/* Where a connection or a message goes. The layout is frozen (clause 5.3). + * + * ⚠️ HERE AND NOT IN net.h, because `openkal.datagram' uses it and either + * interface may be provided without the other. + * + * ⚠️ BYTES AND A LENGTH, not a tagged union of families. The length is what + * distinguishes them, and it is a VALUE rather than a layout --- so the set of + * lengths this specification defines may grow while clause 5.3 holds the + * structure fixed. That is the same evolution rule clause 6.2 gives a property + * word, applied to a value instead of to a bit. + * + * An implementation refuses a length it does not know (kal_err_invalid) rather + * than reading it as one it does. Twenty-four bytes is chosen so that an + * address with a scope identifier fits without a second type. */ +struct kal_endpoint { + kal_u8 addr[24]; /* network order --- the bytes of the address itself */ + kal_uintptr addr_len; /* 4 = IPv4 16 = IPv6 20 = IPv6 with a scope + * identifier; further values are defined by later + * revisions and refused, not misread, by an + * implementation that does not know them. */ + kal_u32 port; /* a number, in host order: this interface does not + * ask a caller to perform a protocol's byte order + * conversion. */ +}; +``` + +--- + +### 2.13 ⭐⭐ 规范 / ABI 级整体 review:六处发现 + +写草案的过程本身是一次 review。逐条: + +#### ⓵ ⭐⭐ 错误集是**封闭**的,而超时需要一个错误 —— 而它已经有了 + +`types.h` 写着:*"The set is closed: an implementation maps its environment's +error values onto these and **does not extend them**."* ⇒ **不能加 `kal_err_timeout`。** + +⭐ 而 `kal_err_again` 的定义正是 *"the operation would block"* —— **一次超时的到期, +说的就是这件事。** ⇒ **复用它,零改动。** + +⚠️ 但必须在规范文本里写死:在带超时的操作上,`kal_err_again` **只**表示到期。 +否则「到期」与「本来就会阻塞」在一个不阻塞的调用上会同读数。 + +#### ⓶ ⭐⭐ `types.h` 里**没有字节类型** + +现有四个类型是 `kal_uintptr` / `kal_u32` / `kal_u64` / `kal_i64` —— +它们是**操作的**类型。而一个地址必须**逐字节**陈述。 +⇒ **加 `kal_u8`**,并在注释里说明它与那三个的区别(见 §2.12)。 + +⚠️ 用 `unsigned char` 而不加类型也可以,但 types.h 的注释说得很清楚: +「一个决定写在八个地方」正是它存在的理由。**加类型与既有理由一致。** + +#### ⓷ ⭐⭐⭐ `deadline` 这个名字与 `kal_task_wait` 不一致 ⇒ 改名 `timeout` + +`kal_task_wait(word, expected, **timeout_ns**)` —— **相对时长**,且 +**零表示不设超时**。 + +⚠️ 我原来叫它 `deadline` 并想用**绝对时刻**。⭐ **两处都要改**: +名字改成 `openkal.timeout`,参数改成相对的 `timeout_ns`,零的含义与 `task_wait` 相同。 + +> **同一个概念在一份 ABI 里必须只有一种拼法。** 这是本规范最一贯的一条, +> 而我差点在它上面留下第二种。 + +⚠️⚠️ **代价必须写下来**:零被「不设超时」占用了 ⇒ **无法表达「立即返回,不等」** +(即 `poll(timeout=0)` 的非阻塞探测)。 +⭐ 这是「逐字一致」与「表达力」的真实冲突,而一致性优先。 +调用方要非阻塞探测,传 `1`(纳秒)—— 丑,但**不引入第二种拼法**。 +⇒ 这一条应当写进 clause 6.3「考虑过而未采纳」。 + +#### ⓸ ⭐ `props` 有两种形式,而我差点全用错 + +现有头文件的分法是**严格的**: + +| 形式 | 用在 | 例子 | +|---|---|---| +| **函数**,取资源 | 属性**随资源**变化 | `kal_stream_props(s)` —— 终端与文件答案不同 | +| **`extern const kal_uintptr`** | 属性**随实现**变化 | `kal_task_props` / `kal_fs_props` / `kal_process_props` | + +⇒ 六个新接口的归属: + +| 接口 | 形式 | 理由 | +|---|---|---| +| `terminal` | **函数** | 一个 pty 能报尺寸,一条串口不能 —— **同一实现两种答案** | +| `net` / `datagram` / `space` | **`extern const`** | IPv6 支持、克隆是否带句柄 —— 是实现的性质 | +| `timeout` | **`extern const`** | 粒度是时钟的性质。⭐ 而且它不是位字段而是一个**数值**(`kal_timeout_granularity_ns`)—— 与其他 props 形状不同,要在规范里说明 | + +#### ⓹ ⚠️ `kal_endpoint` 布局冻结 ⇒ 三个字段永远加不进去 + +clause 5.3:结构体布局不可变。⇒ IPv6 的 **scope id**(链路本地地址需要)、 +**flow label**、以及任何非 IP 的地址族,**定稿之后永远加不进去**。 + +⭐ **我的裁决是不带**,理由写在草案注释里:携带一个协议族的每个字段, +就变成了那个协议族的接口而不是一个地址。 +⚠️ **但这是一条不可逆的决定,必须由你确认** —— 链路本地 IPv6(`fe80::/10`) +没有 scope id 是**用不了**的,而嵌入式网络里它并不罕见。 + +#### ⓺ ⭐ 三处小的但会咬人的 + +| | | +|---|---| +| `kal_space_clone` 克隆的是**调用者自己的**空间 | 没有第二个空间可克隆(要有就得先有 `create`,而 §2.8 ⓵ 删掉了它)⇒ **签名里没有 src 参数**,草案已改 | +| `space` 与 `process` **两个句柄同时存在** | 谁先释放?⇒ 草案定为**独立,任一先释放皆可**,并写进注释 | +| 32 位目标上 `kal_u64` 参数占两个寄存器 | `kal_timeout_recv_from` 有 5 个参数 ⇒ riscv32 上会溢出到栈。**性能,不是正确性** ⇒ 记录,不改 | + +#### 符号规模 + +SURFACE.txt 从 **~40** 增到 **~68**。`check-surface.sh` 的成本不变(它是逐行比对)。 +⚠️ **但每个后端都要重新回答一次「提供还是不提供」** —— 而那正是 clause 6.1 +要求的、且今天没有任何机制强制的事。⭐ **`--complete` 那一侧必须在新接口落地时 +一起被使用**,否则一个后端「忘了不提供」会以链接期缺席的形式混过去。 + +--- + +### 2.13' ⭐⭐⭐ `kal_endpoint` 的可扩展性:四个方案,第四个不需要保留字节 + +§2.13 ⓹ 把它记成「不可逆,要确认」。**再想一轮,它不必是不可逆的。** + +#### ❌ 方案一:保留字节 + +```c +struct kal_endpoint { kal_u8 addr[16]; kal_uintptr addr_len; kal_u32 port; + kal_u32 reserved[N]; /* must be zero */ }; +``` + +| ⚠️ | | +|---|---| +| **「必须为零」没有人保证** | C 不自动清零。一个忘了初始化的调用方,会在 reserved 被启用的**那一天**突然改变行为 | +| **N 取多少是一个猜** | 取少不够,取多每个 endpoint 都在浪费 | +| **它与 openkal 每一条规则相反** | clause 7.8 要求「说出整个意图」;保留字段是「我还没想好」的具体化 | +| 先例是反面的 | `sockaddr_storage` / `OVERLAPPED` 的保留字段被创造性地使用过 | + +⇒ **保留字节能工作,但它把一个「以后再说」冻进了 ABI。** + +#### ❌ 方案二:不透明字节 + 长度 + +调用方无法构造一个 endpoint ⇒ 需要构造函数 ⇒ 又回到接口里;且丢掉类型检查。 + +#### ⚠️ 方案三:版本化的类型 + +clause 5.3 冻结的是**已有类型的布局**,新增一个类型是允许的(clause 8)。 +⇒ 将来出 `kal_endpoint2` + `kal_net_connect2`。 +**诚实且有先例**(clause 7.8 让 `kal_fs_open_file` 与 `kal_fs_open` 并存), +⚠️ 但每次演进要把操作复制一遍。**留作最后手段。** + +#### ⭐⭐⭐ 方案四:变长地址 —— **可扩展的是取值,不是布局** + +关键在于把 scope id 看成**地址的一部分**,而不是 endpoint 的**另一个字段**。 + +```c +struct kal_endpoint { + kal_u8 addr[24]; /* 网络序,就是地址本身的字节 */ + kal_uintptr addr_len; /* 4=IPv4 16=IPv6 20=IPv6+scope 其余由 props 定义 */ + kal_u32 port; /* ⭐ 主机序数值,见下 */ +}; +``` + +⭐⭐ **它不是「保留字节」,区别是决定性的:** + +| | 保留字节 | 变长地址 | +|---|---|---| +| 那些字节今天的含义 | ❌ **未定** —— 谁也不知道该填什么 | ✔ **地址的长度就是它的长度**,超出 `addr_len` 的字节不被读 | +| 扩展时改的是 | ⚠️ **布局的解释**(clause 5.3 的边缘) | ✔ **`addr_len` 的取值集合** —— 一个值,不受 clause 5.3 约束 | +| 旧调用方 + 新实现 | ⚠️ 取决于它有没有清零 | ✔ `addr_len=16` 照常工作 | +| 新调用方 + 旧实现 | ⚠️ 静默地错 | ⭐ `addr_len=20` 被拒(`kal_err_invalid`)—— **一个诚实的失败** | + +⭐ **而这个形状 openkal 到处都在用**:每一个字符串都是 `const char* + kal_uintptr len`。 +**地址是同一形状**,不是一个新概念。 + +⭐ 并且它与 clause 6.2 的属性字**同构**:位可以增长,未分配的读作零; +这里是**取值**可以增长,不认识的被拒绝。⇒ **同一条演进规则的第二个实例。** + +#### 数组开多大 + +| 用途 | 字节 | +|---|---| +| IPv4 | 4 | +| IPv6 | 16 | +| IPv6 + scope id | **20** | +| 非 IP(Bluetooth BD_ADDR 6 / CAN 4 / mesh 8) | ≤ 8 | + +⇒ **24**。`24 + 8 + 4` 对齐后 40 字节 —— 传指针,不进寄存器,代价可忽略。 + +⚠️ **地址族怎么区分?** 一个 6 字节地址是 Bluetooth 还是别的? +⭐ **今天不需要区分**:clause 10 规定**每个接口一个实现** ⇒ 一个程序里只有一个网络栈, +而**那个栈知道它的地址是什么**;`kal_net_props` 说明它支持哪些长度。 +⇒ 一个同时支持 IP 与非 IP 的栈是将来的问题,**而 24 字节的余量让将来有得选** +(例如用 `addr_len` 的高位编码族)。**这正是不必现在就不可逆的意思。** + +#### ⭐ 顺带一处必须定死的:字节序 + +| 字段 | 序 | 理由 | +|---|---|---| +| `addr[]` | **网络序** | 它就是地址本身的字节,不是一个数值 | +| `port` | ⭐ **主机序数值** | 它是一个数,而 openkal 不让调用方做协议的字节序转换 —— 那是实现的事 | + +⚠️ **不定死的话每个实现会各猜一次**,而两边猜反了的症状是「连到了错的端口」, +在一个能连通的网络里可能很久才被发现。 + +#### 裁决 + +**采纳方案四。** ⇒ §2.13 ⓹ 那条「不可逆,要确认」**降级为一条普通的定形决定** —— +不带 scope id 的是**今天的取值集合**,不是这个类型的能力上限。 + +⚠️ 仍然不可逆的只剩一条:**`addr[24]` 这个数字**。24 是从上表推的, +若将来出现更长的地址族,只能走方案三。⭐ 但那时的代价是**一个新类型**, +而不是**一个已经发布的类型说不出话**。 + +--- + +### 2.14 SPEC 增补文本(clause 6.3 / clause 11) + +#### clause 6.3「Mechanisms considered and not adopted」增两条 + +> **Readiness notification.** An interface reporting that a stream may be read, +> by waking a word as `kal_task_wake` does, was considered as the remedy for a +> context that would otherwise wait without end. It composes better than the +> bound this specification adopted: one operation covers every waitable thing, +> a single context may await many sources, and a library above it needs no +> read-ahead buffer because a notification consumes nothing. +> +> It was not adopted because of what it asks of an implementation. On an +> environment whose readiness is discovered by polling a set of descriptors, an +> implementation would have to maintain that set and a context of its own to +> watch it — a mechanism reconstructed rather than a facility conveyed, which +> clause 7.1 excludes. The bound this specification adopts asks the same +> environment only for what it already does at the point of the call. +> +> **An instant rather than a duration.** `openkal.timeout` states a duration +> because `kal_task_wait` does. An instant would not accumulate drift when a +> caller retries in a loop, and was considered for that reason; it was not +> adopted because it would give one specification two spellings of one idea. +> A caller that requires an instant computes the remaining duration from +> `kal_time_monotonic`, and the cost of doing so falls on the caller that has +> the requirement rather than on every implementation. + +#### clause 11「Matters this version does not settle」增六条 + +> 4. **Networking.** Not defined by this version. `openkal.net` and +> `openkal.datagram` are anticipated by clause 3.4 and are separate for the +> reason clause 6.4 gives. +> 5. **Readiness.** Awaiting one of several sources is not an operation of this +> specification. It is reached above the interface, from `openkal.task` and a +> bound upon each wait; clause 6.3 records the alternative that was weighed. +> 6. **Terminal control.** Not defined by this version. `KAL_STREAM_PROP_INTERACTIVE` +> reports that a stream is one; no operation acts upon that fact. +> 7. **Permission and ownership.** Not defined, and not a deferral: a permission +> presupposes an identity, and the environments this specification targets do +> not agree that one exists. A C library above openkal reports the absence as +> the error its own surface defines. +> 8. **Creation and reading of links.** Not defined, and not a deferral, for the +> reason clause 6.4 gives: whether a filesystem has links is a property of the +> format rather than of the environment. `KAL_FS_PROP_LINKS` reports it; +> resolution follows one where the property is claimed. +> 9. **Duplication of the calling image.** `fork` is refused by clause 7.1 and +> that refusal stands. ⚠️ It is refused as an OPERATION: the atomic +> capabilities from which a library may compose it — cloning an address space, +> starting a context in one — are a candidate for a later version and are not +> excluded here. A sentence reading "openkal will not have fork" would bury +> them, and this clause exists so that it is not written. + +⚠️ **第 9 条的最后一句是本节最重要的一句** —— 它防的是我自己犯过两次的那个错 +(§1.2 / §1.3)。 + +--- + +## 3. openkal-musl(端口层)—— 七条,是本轮工作量的主体 + +### 3.1 ⓪ 定位 PC=0(**先做,而且是定位不是修**) + +⭐ **排第一不是因为最严重,是因为它让其余每一条的读数都不可信** —— 一个在早期 +初始化就 SIGSEGV 的程序,后面 26 条失败都是它的下游。 + +**已由源码收窄的两条**(不必再查): + +- `okm_syscall.c` 的 `default:` 返回 `-ENOSYS`(71 个 `case` + 一个 default) + ⇒ **不是未知系统调用走空指针** +- 端口层一共只有**两处**弱引用(`grep '__attribute__((weak))' port/src` = 2 行: + `okm_phdr.c:39` 的 `__ehdr_start`、`okm_syscall.c:31` 的 `kal_random_fill`), + **两处都判空了** ⇒ 不是 [[link-error-is-the-mechanism-not-the-defect]] 第二形态的复发 + +**三个仍开着的候选,各配一条决定性检查**: + +| 候选 | 检查 | +|---|---| +| `.init_array` 走过头或含 0 项(`okm_start.c:179` 的循环**逐项不判空**) | `readelf -x .init_array `;临时打印循环的 `a` 范围 | +| 程序自己 `dlsym` 得 0 就调用(静态 musl 的 `dlsym` 恒返回 0) | `nm \| grep dlsym`;`gdb` 在 `dlsym` 下断 | +| ⭐ **终端初始化**:`ioctl` 只答 `TCGETS`,`TCSETS`/`TIOCGWINSZ` 都 `-ENOTTY` ⇒ TUI 拿到失败后的路径 | **先排除这一条** —— 崩溃位置与它重合 | + +⚠️ **判据的单位是一整行输出**:`bt` 空的时候 `info registers rip rsp` 与 +`x/8gx $rsp` 不空。⚠️ **不要凭报错跳到修法** —— +[[reasons-written-from-memory-kill-good-fixes]];这里连报错都没有,只有一个空栈。 + +### 3.2 ① `copy_file_range` —— **只需这一个** + +⭐ 实测 libc++ 源码(`libcxx/src/filesystem/operations.cpp`): +`copy_file` 对 `copy_file_range` 的回落名单**含 ENOSYS**(`:314`), +对 `sendfile` 的**只认 `EINVAL`** ⇒ ENOSYS 走不到 `fstream` 回落。 + +⇒ **实现 `copy_file_range` 一个就够,`sendfile` 是多余的**(实现了 range 就永远到不了 +sendfile)。实现成 `kal_stream_read`→`kal_stream_write` 循环。 +⚠️ 短写不是成功结果([[openkal-spec-and-linux-impl]]),循环必须写全或报错。 + +**量级**:~40 行。 + +### 3.3 ② 替换 `popen.c` —— 替换源码,不重建机制 + +`okm_spawn.c` 的开篇已经论证过这个动作: + +> openkal has neither operation … What openkal has instead is **the composite** … +> so the replacement is a **translation of arguments rather than a reconstruction +> of a mechanism** — and it is shorter than the code it replaces. + +`posix_spawn.c` 已经在排除表里(`mcpp.toml:118`),`popen.c` **不在** ⇒ 它还在用 +`pipe2` ⇒ ENOSYS。 + +**两阶段**: + +| 阶段 | 实现 | 语义差 | +|---|---|---| +| 现在(`kal_process_channel` 落地前) | `kal_fs_open`(`CREATE\|EXCLUSIVE\|TRUNCATE`)开临时文件当子进程 stdout,`kal_process_wait` 后 seek 回 0 | ⚠️ 不能并发交错;需要可写文件系统 | +| `channel` 落地后 | 直接用 `kal_process_channel` | 语义补齐 | + +⚠️⚠️ **这不是 `pipe2`,不要假装是。** 三处语义差必须写进 README。 +⭐ **正因为差,才必须实现成 `popen` 而不是实现成 `pipe2`** —— 一个假的 `pipe2` 会让 +self-pipe 的调用方**静默地永远等下去**,这正是 +[[c-library-configured-by-what-is-beneath]] 里 futex 那条踩过的形状。 + +ⓘ **musl 的 `system()` 走 `posix_spawn`,所以它今天就能用** —— 「不能 shell out」的 +范围比它看起来窄,值得在回复里说清楚。 + +**量级**:~120 行。 + +### 3.4 ③ `last_write_time(dir)` —— clause 7.12 的保留名 + +clause 7.12 的 `"."` 就是为这件事存在的(*"a program holding a directory has no way +to ask an operation about that directory"*)。⇒ `stat`/`statx`/`newfstatat` 对目录路径 +应走 `kal_fs_open_dir` + `kal_fs_info(dir, ".")`,而不是按文件路径走 `kal_fs_open`。 + +**量级**:~20 行。 + +### 3.5 ④ `umask` 去静默 —— 本轮最硬的一条正确性缺陷 + +**实测**:`g_umask` 只被 `SYS_umask` 自己读写(`okm_syscall.c:388` 定义、`:937` 使用), +**从不作用于任何创建**;而 `kal_fs_open` 本来就没有 mode 参数。 + +⇒ `umask(077)` 返回旧值、报成功、**下一次创建不受影响**。这正是 `okm_opt.h` 开篇 +明令禁止的那一种: + +> ⚠️ THE ONE WAY THIS COULD GO WRONG IS NOT PRESENT: **nothing below reports +> SUCCESS having done nothing.** + +⭐ **它比「0600 落成 0666」那条更硬** —— 后者要论证具体值,这条不需要: +**一个有返回值的调用,它的效果不存在。** + +**处置**:删掉 `g_umask`,让 `SYS_umask` 落进 `default:` 的 `-ENOSYS`。 +⚠️ musl 的 `umask()` 会因此返回 -1/ENOSYS,调用方多数不检查 —— **这是对的**: +它此前得到的成功是假的。README 记一条。 + +**量级**:~10 行(删)。 + +### 3.6 ⑤ ⭐⭐ 「永远没有」变链接错误 —— 零设计成本,回报最大 + +**问题**:今天 `okm_syscall.c` 用同一个 `-ENOSYS` 回答了两件性质不同的事。 + +| | 例子 | 正确的回答时机 | +|---|---|---| +| **这个后端不提供** | core-only 后端上的 `open` | **运行期 ENOSYS** ✔ `okm_opt.h` 那道缝做对了 | +| **openkal 根本没有这个接口** | `fork` / `socket` / `poll` / `chmod` | ⭐ **链接期** —— clause 6.2 的表就是这么规定的 | + +第二类是**永久的、与后端无关的**事实。ENOSYS 把它伪装成了运行期条件,于是 +`mcpplibs/tinyhttps`(**mcpp 自己索引里的包**)在 openkal 上**构建成功**, +跑到第 27 个测试才失败。 + +**做法**:`mcpp.toml` 的 `sources` 增排除项。**机制全部现成**: + +- `!` 排除语法已在用(今天已排除 10 个 musl 源) +- cflags 已有 `-ffunction-sections -fdata-sections`,ldflags 已有 `-Wl,--gc-sections` + (`mcpp.toml:240`)⇒ ⭐ **没被引用的不会失败,被引用的才失败** —— 粒度正好 + +**候选排除集合**(⚠️ 需一轮闭包实测): + +``` +!musl/src/network/** socket 全族 + getaddrinfo +!musl/src/select/** poll / select / epoll / eventfd / signalfd +!musl/src/process/fork.c 以及 vfork / _Fork +!musl/src/unistd/pipe*.c pipe / pipe2 +!musl/src/stat/chmod.c chmod / fchmod / fchmodat +!musl/src/unistd/symlink*.c symlink / symlinkat +``` + +⚠️ **闭包是这条的唯一风险**:musl 的 `network/` 内部互相引用 +(`getaddrinfo` → `socket`),排一个可能牵出一串。**这是实现问题,不是设计问题** —— +判据是「排完之后 `mcpp build` 全绿」。 + +⚠️ **`timerfd`/`eventfd` 要不要一起排?** 它们在 `select/` 之外。 +⭐ 建议**一起排**:一个「创建了却永远不会就绪」的 fd 比 ENOSYS 更坏。 + +**判据(两向,缺一不可)**: + +| 方向 | 断言 | +|---|---| +| 正 | 一个引用 `socket` 的程序**链接失败并指名 `socket`** | +| 反 | 一个**不**引用它的程序**正常链接** —— 否则就是把所有人都打死了 | + +### 3.7 ⑥ 就绪 `poll`/`select` —— 端口层设计,不动 spec + +**为什么在这里而不在 openkal**(⭐ 更准确地说是在 §2.7 的 `openkal.kit.wait` 里,由所有端口层共用):可由 `openkal.task`(`kal_task_wait/wake`, +`okm_opt.h:135`,端口的 `SYS_futex` 就走它)+ `openkal.stream` 的阻塞读组合出来 —— +每源一个辅助 task 做阻塞读,读到就唤醒主 task。**可导出 ⇒ 是库。** + +⚠️ **代价是真的,必须写下来**:「就绪」不等于「已读」,而 `kal_stream` 读了退不回去 +⇒ **端口层必须为每个可 poll 的 fd 做预读缓冲**。 + +- 它已经拥有 fd 表(`okm_desc`,`okm.h`)⇒ 缓冲有地方放 +- stdio 本来就在做预读 ⇒ 不外来 +- ⚠️ 但 `read` 必须改为**先取缓冲**,这动的是热路径 + +⚠️ **未测的两条**: +(a) 预读缓冲对 `poll` 语义的覆盖度 —— 能不能撑住 ftxui 的用法; +(b) 一个 `poll` 过、随后被传给别人(dup / 交给子进程)的 fd 怎么办。 + +**量级**:大,**需要一次设计**,是本批唯一需要设计而非实现的一条。 + +### 3.8 ⑦ `private_include_dirs`(等 mcpp) + +`port/include/features.h:70` 自己写下了第一优解和它为什么做不到: + +> ⓘ **THIS IS THE SECOND-BEST REMEDY.** The first would be for a package to +> distinguish the directories it is **built from** from the directories it +> **publishes**. Measured 2026-08-22: **mcpp cannot express it.** + +mcpp 侧改动见配套文档 §4。此处只需在 `mcpp.toml` 把三个内部目录移过去: + +```toml +include_dirs = ["port/include", "musl/include"] +private_include_dirs = ["musl/src/include", "musl/src/internal", + "musl-generated/internal"] +``` + +⚠️ **顺序必须与 `include_dirs` 交错保持声明序** —— `features.h` 那段实测说得很清楚: +挪到后面,musl 自己的构建会先找到公共 `` 而失败。 + +⭐ **判据不是「`hidden` 不再泄漏」,是「那三个目录不出现在消费者的命令行上」** —— +前者会随着包侧再打一个补丁而变绿,而缺陷还在。 + +### 3.9 ⑧ POSIX 验收套件 —— **长期回报最高的一条** + +**缺口**:openkal 的 conformance 验的是 **openkal 自己的 `kal_*` 面**; +**没有任何东西验 openkal-musl 重建出来的 POSIX 面。** + +⇒ 本轮五条缺陷它抓五条(⓪ / copy_file / popen / last_write_time / umask), +**在用户之前**。 + +**设计要点**: + +| | 要求 | +|---|---| +| 分母 | ⭐ 判据是 `67 passed / 94`,不是「套件跑过了」([[criterion-whose-no-is-also-silence]]) | +| 三态 | 每个用例声明**应当成功** / **应当失败且 errno 是 X** / **不适用于本后端**。⚠️ 只有两态会让「没测成」和「不支持」同读数 | +| 矩阵 | 每 backend × 每 arch,**真跑**(qemu),不是只链接 —— 本轮全部三类问题都在链接之后 | +| 种子 | ⭐ **报告者的 94 个用例**,他已经在 issue 里提出愿意提供 | + +⚠️ **它必须能变红。** 一个新增的、故意不被实现的用例必须让套件失败,否则这个套件 +就是 [[openkal-spec-and-linux-impl]] 记的那个「第一版 surface 检查器是空转的」。 + +--- + +## 4. openkal-llvm-runtime —— `__config_site` 与实际能力对账 + +### 4.1 实测的三行(§配套文档 §6.7 表 C) + +| 开关 | generic | 实际 | 处置 | +|---|---|---|---| +| `_LIBCPP_HAS_RANDOM_DEVICE` | 1 | ✅ 符合 | **0.1.3 已修**(报告者测的是 0.1.1) | +| `_LIBCPP_HAS_FILESYSTEM` | 1 | ⚠️ 部分 | **保持 1** —— `` 每个操作都有 `error_code` 重载,标准把它设计成允许失败;Windows FAT 上 `create_symlink` 同样失败 | +| `_LIBCPP_HAS_TERMINAL` | 1 | ❌ **不符** | ⭐ **改成 0**,直到 `openkal.terminal` 落地 | + +⭐ **为什么 FILESYSTEM 留 1 而 TERMINAL 改 0** —— 这不是尺度不一,是标准的态度不同: + +> **标准把这件事设计成「可以失败」的 ⇒ 声明有,失败时报错是对的。** +> **标准假定它「一定成功」/ 用开关表达有没有 ⇒ 没有就必须写 0。** + +### 4.2 ⭐ 对账要成为一条 CI 判据,而不是一次检查 + +`__config_site` 是**一份对下层能力的声明**,而今天**没有任何东西核对它**。 +三行里有两行曾与实际不符,而它们都是**编译期**写死的 —— 程序因此把 `` +和终端支持整个编进去,再在运行期一条一条撞 ENOSYS。 + +⇒ 每个开关配一个**最小探针**,与 §3.9 的验收套件同批跑: +`_LIBCPP_HAS_TERMINAL` ⇒ 一个 `tcgetattr`+`tcsetattr` 的探针; +`_LIBCPP_HAS_RANDOM_DEVICE` ⇒ 构造一个 `std::random_device` 并取一个值。 + +⚠️ 未测:`_LIBCPP_HAS_LOCALIZATION` / `UNICODE` / `WIDE_CHARACTERS` / +`TIME_ZONE_DATABASE` 四行,本轮没有验过。 + +--- + +## 5. openkal-linux 与其它 backend + +### 5.1 实测:openkal-linux 是**满的** + +| backend | abort | stream | memory | env | time | fs | process | task | random | exec | +|---|---|---|---|---|---|---|---|---|---|---| +| **openkal-linux 0.5.4** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| openkal-macos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| openkal-windows | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| openkal-opensbi | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | +| openkal-uefi | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | + +⭐⭐ **十个接口一个不缺 ⇒ 本轮的缺口不在 backend,在接口集合本身。** +这条读数把「让 openkal-linux 更完整」整条路排除掉了,值得单独记。 + +### 5.2 新接口落地时,每个 backend 的答案 + +⚠️ **一个接口新增,五个 backend 都要**明确回答**「提供还是不提供」** —— +clause 6.1 的缺席是靠**不导出符号**表达的,不是靠不写。 + +| 接口 | linux | macos | windows | opensbi | uefi | +|---|---|---|---|---|---| +| `openkal.terminal` | ✅ termios | ✅ termios | ✅ Console API | ⚠️ UART:关行编辑=无事可做即完成;尺寸=不知道 | 同左 | +| `kal_process_channel` | ✅ pipe2 | ✅ | ✅ CreatePipe | ❌ 无 process ⇒ 整个 `openkal.process` 本就不提供 | ❌ | +| `openkal.net` | ✅ sockets | ✅ | ✅ Winsock | ⚠️ 需 BSP 带 lwIP ⇒ **默认不提供** | ⚠️ UEFI 有 TCP protocol,**未测** | + +### 5.3 openkal-linux 的一条待议:`openat(..., 0666)` 硬编码 + +`src/fs.cpp:127` 硬编码 `0666`,`:236` 硬编码 `0777`。 + +⚠️ **在 openkal 没有权限模型的前提下,这不是缺陷** —— 实现必须选一个值。 +⭐ 真正的缺陷是端口层的 `umask` 静默(§3.5)。 + +⚠️ **未测**:报告者报的「文件 0600 落成 0777」我复现不出机制 —— +`openat(...,0666)` 给不出 0777。**权限被放宽这件事成立;放宽到哪个值未定。** +⇒ §3.9 的验收套件里应当有一条用例把它量出来。 + +--- + +## 6. 分批与依赖 + +| 批 | 内容 | 仓库 | 前置 | +|---|---|---|---| +| **P0** | ⓪ 定位 PC=0 | musl / linux | — | +| **P0** | ⑤ **「永远没有」变链接错误** | musl(manifest) | — | +| **P0** | ④ `umask` 去静默 | musl | — | +| **P1** | ① `copy_file_range` · ③ `last_write_time(dir)` | musl | ⓪ | +| **P1** | ② `popen`(临时文件阶段) | musl | ⓪ | +| **P1** | `_LIBCPP_HAS_TERMINAL` → 0 | llvm-runtime | — | +| **P1** | clause 11 增补六条 | openkal | — | +| **P2** | ⑧ **POSIX 验收套件** | musl + CI | P0/P1(否则读数全是 ⓪ 的下游) | +| **P2** | ⑥ 就绪 `poll`/`select` | musl | 需设计 | +| **P2** | ⑦ `private_include_dirs` | musl | **mcpp 侧 C** | +| **P3** | `openkal.terminal` + 五个 backend | openkal + 5 | 设计定形 | +| **P3** | `kal_process_channel` + backend | openkal + 3 | 设计定形 | +| **P4** | `openkal.net` + 两个形态不同的实现 | openkal + 2 | ⚠️ 数据报与定形者两处未决 | +| **P4** | `openkal.space` + linux 实现 | openkal + linux | ⭐ **设计与 P3 同批做**,因为它决定 `spawn` 怎么被理解 | +| **P4** | ⭐ **句柄的跨空间传递**(独立立项) | openkal | ⚠️ `space` 只是第一个撞上它的;在它有答案前 `kal_space_start` 只传流 | +| **P2–P3** | ⭐ `openkal-kit`(非规范,openkal 仓库内的独立包,§2.7) | openkal 仓库 | 就绪/流对随 §3.7 一起落;`fork` 助手随 `space` 落 | + +⭐ **P0 三条零设计成本,而 ⑤ 的回报最大** —— 它把发现从运行期移到链接期, +让「范围」这件事对每一个用户可见,而不只是对报告者。 + +⚠️ **P2 的验收套件必须排在 P0/P1 之后** —— 在 ⓪ 修好之前跑它,读数全是 ⓪ 的下游 +([[openkal-portable-program-findings]]:一条流水线按顺序失败时,后面每一条缺陷都被 +藏住,而且第一条会被当成唯一一条)。 + +--- + +## 7. mcpp 侧的配合点 + +完整清单见配套文档 §6.9/§6.10.3。与本文直接耦合的只有两条: + +| # | 内容 | 本文哪里用到 | +|---|---|---| +| **C** | `[build] private_include_dirs` | §3.8 | +| **D** | 目标侧报告在「由图供给的层收窄了上层的面」时说出来 | §3.6 的运行期一侧 —— ⚠️ 引擎不得硬编码 POSIX 名 ⇒ 由包说 | + +--- + +## 8. 未决与我可能错的地方 + +1. **§2.3 `openkal.net` 的数据报问题未决**,而它有连锁后果(DNS)。**不能边写边定。** +2. **§3.7 的预读缓冲未测** —— 能否撑住 ftxui 的用法,以及 dup/传递后的 fd 语义。 +3. **§3.6 的排除闭包未测** —— musl `network/` 内部互相引用,可能牵出一串。 +4. **§5.3 权限放宽到哪个值未定** —— 我复现不出 0777。 +5. **§4.2 四行 libc++ 开关未验**(LOCALIZATION / UNICODE / WIDE_CHARACTERS / + TIME_ZONE_DATABASE)。 +6. ⚠️⚠️ **§1.2/§1.3 是我连续说错两次后的更正。** 第一次把 `fork` 写成「永远不可能 + 的硬天花板」—— 判据二(能不能由原子能力组合出来)本来就该问到这一步,我没问。 + 第二次把它降为「候选但不做」,给的三条理由里**两条是错的**:面的大小算错了 + (5 个操作,比 net 少),而「无 MMU 目标必然缺席」**根本不是反对理由 —— + 它就是 clause 6.1 的机制本身**。 + ⭐ 教训:**「这个目标做不到」在一个可组合的规范里不是排除理由,是缺席的答案。** +7. **§2.4 的三处未决(继承语义 / 与 spawn 的关系 / COW 是否承诺)必须动笔前定** —— + clause 8 不允许事后改,而其中第一条的两个需求方向相反。 +8. **我没有真的跑过一次报告者那样规模的工作区** —— 本文的实测都在等价的小工程上做的。 + +--- + +## 9. 相关记忆 + +[[link-error-is-the-mechanism-not-the-defect]] · [[c-library-configured-by-what-is-beneath]] · +[[openkal-spec-and-linux-impl]] · [[openkal-portable-program-findings]] · +[[second-instance-exposes-the-interface]] · [[criterion-whose-no-is-also-silence]] · +[[reasons-written-from-memory-kill-good-fixes]] · [[build-program-advisory-channel]] diff --git a/.agents/docs/2026-08-27-openkal-native-path-three-issues.md b/.agents/docs/2026-08-27-openkal-native-path-three-issues.md new file mode 100644 index 00000000..ae0a65ad --- /dev/null +++ b/.agents/docs/2026-08-27-openkal-native-path-three-issues.md @@ -0,0 +1,1426 @@ +# 目标侧被解析出来了,只发给了一个编译单元 + +2026-08-27 · 三个 issue 的深度分析 + 优化方案(**待 review,尚未实施**) + +三个 issue: +[mcpp#514](https://github.com/mcpp-community/mcpp/issues/514) · +[openkal-linux#13](https://github.com/mcpplibs/openkal-linux/issues/13) · +[openkal-musl#13](https://github.com/mcpplibs/openkal-musl/issues/13) + +前置:[`2026-08-25-the-two-layer-predicate-family.md`](2026-08-25-the-two-layer-predicate-family.md) · +[`2026-08-26-cross-target-implies-graph.md`](2026-08-26-cross-target-implies-graph.md) · +[`2026-08-24-target-side-architecture.md`](2026-08-24-target-side-architecture.md) + +--- + +## 0. 一句话 + +> **mcpp 已经算出了「这个目标的 C++ ABI 头文件集合是哪 18 个目录」,并且把它准确地 +> 交给了 `std` 模块那一个编译单元。图里其余每一个单元,只有恰好站在一条依赖边上的 +> 才拿得到。** + +`prepare.cppm:7207`: + +```cpp +// ⚠️ AND THE HEADERS THIS PACKAGE ITSELF IS BUILT AGAINST. +for (auto& d : pkg.publicUsage.includeDirs) + flags += " -isystem " + mcpp::xlings::shq(d.string()); +``` + +这段注释把道理写得完全正确 —— 「std 模块源码在任何重要的意义上都是这个包的一个翻译 +单元,它到达 C 库头文件的方式和其余单元一样」。**它只差一句:其余单元并不都能到达。** + +于是 `std` 是 openkal 味的(对),依赖包的 BMI 是载荷味的(错),任何同时导入两者的 +TU 在第一个碰到双方都声明了的名字的模板实例化处炸掉 —— 这正是报告者贴的 +`reference to 'space' is ambiguous`。 + +⭐ 这与 2026.8.25.1/.2 修的七条、2026.8.26.1 修的那条是**同一个形状**:一个已经被 +正确回答的问题,答案没有送到所有该读它的地方。区别只在这次不是「谓词问错了」,是 +**「答案只发给了一个人」**。 + +⚠️ **但这句话只覆盖 mcpp#514。** 三个 issue 里有一半不是缺陷,而是报告者被路由到了 +一条回答另一个问题的路径上 —— **§1.5 先回答「他要什么、为什么会走到这里、是谁的 +缺陷」,§6.5 给通用视角的完整性补充。** 只读修复清单会修错。 + +> ⭐ **只想看结论的读者直接去 §6.10** ——「三侧各做什么」,含判据的最终形态 +> (clause 10 基数 + 原子性)、三个接口的形状草案、mcpp 的七条、以及给报告者的 +> 三类答复。中间各章是它的依据。 +> +> ⭐ **openkal 及其生态仓库的设计/优化方案已独立成篇** —— +> [`2026-08-27-openkal-ecosystem-design-plan.md`](2026-08-27-openkal-ecosystem-design-plan.md)。 +> 本篇是三个 issue 的缺陷分析 + **mcpp 引擎侧**的修复;那篇是 **openkal / openkal-musl / +> openkal-llvm-runtime / 各 backend** 的接口判据与分批清单。两篇不重复。 + +--- + +## 1. 五条结论 + +| # | 结论 | 判据强度 | +|---|---|---| +| **A1** | 提供目标侧层的包,其 `publicUsage` 到不了兄弟依赖包的单元 | ⭐ **本机实测**(§2.2) | +| **A2** | **新发现**:编译侧仍在用 `!crossTargetFlag.empty()` 当「系统来自图」,是 #511 修的那条在**下一行**的孪生兄弟;`e2e 295` 只断言 `ldflags`,看不见它 | ⭐⭐ **本机实测**(§2.3) | +| **A3** | 跨 home 发现:`find_sibling_package` 无条件回落 `~/.xlings`;`active_home_xpkgs` 是 `mcpp::home::root()` 的第二份拷贝 | 读源码 + 本机存在性实测(§2.4) | +| **B** | 依赖缓存键描述了**编译器**,没有描述**它被指向的头文件集合**;同一台机器上 `std` 缓存把这件事做对了 | ⭐ **磁盘上的两个 JSON 实测**(§3.1) | +| **C** | openkal-musl 的 `hidden` 泄漏是**引擎缺口**,不是包的缺陷 —— 那份头文件自己写下了第一优解和它为什么做不到 | 读源码,一行(§4) | + +openkal-linux#13 不在这张表里:它**不是一条缺陷**,是三类东西被列在了一张表上。见 §5。 + +--- + +## 1.1 判据来源标注 + +本文严格区分四种依据,凡未实测的推断都标了「**未测**」: + +| 记号 | 含义 | +|---|---| +| ⭐ 实测 | 我在本机跑出来的,命令与输出都在文中 | +| 读源码 | 从 `origin/main@078631d` 的源码直接读出的事实 | +| 报告方所测 | issue 里贴的、我没有复现的 | +| **未测** | 我的推断 | + +实测环境:`origin/main@078631d`(`2026.8.26.2`)自举构建 · +x86_64-linux-gnu · llvm@22.1.8 · gcc@16.1.0 · `MCPP_HOME=~/.mcpp`。 + +--- + +## 1.5 诉求是什么,以及这条路为什么会长成这样 + +⚠️ **本章先于逐条修复,因为三个 issue 里有一半不是缺陷,而按缺陷去修会修错。** + +### 1.5.1 诉求(报告者自己的话,#492 评论) + +> 「我这边要同时出 x86_64 和 aarch64 两份静态二进制」 +> 「当时满脑子能出静态产物就行」 +> 「we're switching our workspace to llvm + musl static builds via this path and +> retiring our `build-static.sh` workaround」 + +⇒ **诉求是一个链接属性**:双架构、全静态、无 `PT_INTERP` 的 Linux 可执行文件。 + +应用本身是一个**完全普通的 POSIX C++23 CLI**:7 成员 workspace、~2k 编译单元、 +ftxui 终端 UI、tinyhttps 本地回调服务器、mbedtls、要 shell out、94 个用例的测试套。 + +⚠️ **它不是一个可移植性诉求。** 他没有要求这份源码跑到裸机、UEFI 或 supervisor 上。 + +### 1.5.2 他被路由到哪一格,以及为什么 + +`x86_64-linux-musl` 这**一个目标**在实测矩阵里是四格 +([`2026-08-26-the-support-matrix-measured.md`](2026-08-26-the-support-matrix-measured.md) §2/§3): + +| | **gcc** | **llvm** | +|---|---|---| +| **payload**(kernel-abi = Linux 直连) | ✅ `xim:musl-gcc` 自带 sysroot,libstdc++,**完整 POSIX** | ❌ ① 密闭性拒绝 —— clang 载荷不带 musl | +| **graph**(kernel-abi = openkal) | ❌ ⑤ 没有 `openkal-gcc-runtime` | ✅ libc++,**POSIX 子集** | + +⭐⭐ **两个可用格在反对角线上,而编译器那一维他没得选。** +GCC 16 的 modules 有未修的 ICE(#491 正文:BMI 读回期段错误,cc1plus 堆内 tree +指针损坏),clang 22 编同一代码库全绿。选定 clang 之后,`linux-musl` 只剩右下那格。 + +⇒ **他没有"选择"openkal —— 矩阵里只有那一格是亮的。** + +而右下那格的第三维是 `kernel-abi = openkal`,openkal SPEC clause 7.1 明写这一层 +**故意不具备 POSIX 的形状**(没有地址空间复制、没有全局路径命名空间、没有描述符), +因为它要能被实现在裸机、UEFI、supervisor 上。 + +⭐ **一句话:他要的是一个链接属性,拿到的是一层可移植性抽象。** +在 Linux 上,静态 musl 本来自带 fork/pipe/socket/chmod/symlink —— 因为 musl 直接 +对 Linux 说话。经由 openkal 之后这些被那一层的设计目标筛掉了,**而这笔交易他没有 +参与,也没有任何一处告诉过他。** + +### 1.5.3 构建过程从头到尾没有说过这件事 + +目标侧报告打的是三行,**全对**: + +``` +kernel-abi openkal (openkal-linux@0.5.4, graph) +c-abi musl (openkal-musl@0.3.5, graph) +c++-abi libc++ (openkal-llvm-runtime@0.1.3, graph) +``` + +**没有一行说「因此 fork / socket / chmod / symlink 会在运行期报 ENOSYS」。** +他是在 94 个用例跑出 27 个失败的时候知道的 —— 也就是**依赖解析期已知的事实, +被推迟到运行期才显形**。这与 [[recorded-field-with-no-decision-reader]] 是同一族: +问题问对了、答案也对了,只是没送到需要它的那一侧。 + +⚠️ **也是为什么 #491/#492 的撤回值得回看一眼。** 那个 PR 提的正是「llvm 家族的 +payload musl 路线」,即上表左下 → 右上那一格。它被 openkal 路线取代了,而两者 +**回答的不是同一个问题**: + +| | 回答的问题 | +|---|---| +| openkal 路线 | 一份源码怎么到达**多个环境** | +| #492 路线 | clang 怎么产出**静态 musl 产物** | + +第二个问题至今没有第二个答案。 + +### 1.5.4 那么,是谁的缺陷?—— 四类 + +| 条目 | 类别 | 归属 | +|---|---|---| +| §2 目标侧 include 集合到不了兄弟依赖包(A1) | **缺陷** | mcpp 引擎。与 openkal 无关,任何 graph 供给的目标侧都中 | +| §2.3 编译侧谓词(A2) | **缺陷** | mcpp 引擎。`mcpp build --target <宿主自己>` 就中,连依赖都不用 | +| §2.4 跨 home 发现(A3) | **缺陷** | mcpp 引擎(密闭性) | +| §3 缓存键缺头文件集合(B) | **缺陷** | mcpp 引擎(正确性:静默错产物) | +| §4 `hidden` 泄漏(C) | **缺陷,但根因在引擎** | 表现在 openkal-musl,成因是 mcpp 不能表达「编译用/发布用」 | +| §5.2 PC=0 跳转 | **缺陷** | openkal-musl 或 openkal-linux(位置未定) | +| §5.3 `copy_file_range`/`popen`/`last_write_time(dir)` | **缺陷** | openkal-musl 端口层:**可表达而未表达** | +| §5.5 权限静默放宽 | **缺陷** | openkal-linux + 端口层;根在 openkal 无权限模型 | +| §5.4/§5.6 `fork`/`pipe2`/socket/symlink | **不是缺陷** | openkal 在做它声明要做的事(clause 7.1) | +| §1.5.2 反对角线为空 | **不是缺陷,也不是边界** | **矩阵空缺** —— 没人做,而不是不能做 | + +⭐ **最后一行是本文最重要的一行。** 前面九行加起来修完,报告者仍然在 openkal 上, +仍然没有 `fork`。让他拿到他真正要的东西的,是最后一行。 + +--- + +## 2. mcpp#514 §A —— 目标侧的头文件集合只到达一个单元 + +### 2.1 现状:三条互不相交的路径 + +一个编译单元的 include 集合今天由三处拼出来,**互不知情**: + +| 来源 | 代码 | 到达谁 | +|---|---|---| +| 工具链载荷的头文件 | `hostflags.cppm:199-217` → 全局 `cxxflags` 规则 | 每一个单元 | +| 根清单的 `[build] include_dirs` | `flags.cppm:482` → 全局 `cxxflags` 规则 | 每一个单元(**含依赖包**,见 §2.2 的旁证) | +| 依赖包发布的 `publicUsage` | `prepare.cppm:3490` 定点 → `privateBuild` → `scanner.cppm:1100` → **逐单元** | **只有这条边的消费者** | + +openkal 那 18 个目录走第三条。而 `nlohmann.json` 不依赖 `openkal-llvm-runtime` —— +它是图里的兄弟,不是下游。所以它一个都拿不到。 + +⭐ **这不是传播漏了一条边,是这个事实放错了模型。** +`publicUsage` 描述的是「一个库对它的使用者的要求」,而目标的 C++ ABI 头文件集合是 +**整张图的属性** —— `mcpp.targetside` 那个模块的开篇写的就是这句话: + +> The fix is not a better guess. It is to resolve once, after the graph is known, +> and to have every consumer read that one value. + +目标侧已经 resolve 了(`prepare.cppm:6713`),`std` 模块读了它(间接地,通过 +provider 的 `publicUsage`),**编译边没有读**。 + +### 2.2 ⭐ 实测 ①:20 行复现,不需要 openkal + +``` +work/ + mcpp.toml [dependencies] abiprov = { path = "abi" } + mcpplibs.cmdline = "0.0.1" + src/main.cpp import abiprov; import mcpplibs.cmdline; + abi/mcpp.toml provides = ["mcpp:c++-abi=libc++"] + [build] include_dirs = ["abi-include"] +``` + +`mcpp build` 后读 `compile_commands.json`,问每一行「命令里有没有 `abi-include`」: + +``` +DEP cmdline.cppm abi-include on line: False ← 兄弟依赖包 +DEP options.cppm abi-include on line: False +DEP parse.cppm abi-include on line: False +ABIPKG abiprov.cppm abi-include on line: True ← provider 自己 +ROOT main.cpp abi-include on line: True ← 消费者 +``` + +⭐ **一个声明了 `mcpp:c++-abi` 的包,它发布的头文件目录到不了兄弟依赖包。** +报告里的现象在没有 openkal、没有 musl、没有交叉的情况下就成立了。 + +⚠️ 旁证:同一次实测里,根清单的 `[build] include_dirs = ["hdrs"]` **确实**出现在了 +`cmdline.cppm` 的命令行上(它走全局规则)。所以「依赖包看不到根的 include」这个直觉 +是错的 —— 看不到的恰恰是走 `publicUsage` 那条路的,也就是目标侧走的那条。 + +### 2.3 ⭐⭐ 实测 ②(新发现):编译侧的谓词还没修 + +`hostflags.cppm:199`: + +```cpp +const bool graphSuppliesTarget = !tc.crossTargetFlag.empty(); + +if (bypassCfg && !graphSuppliesTarget) { ...载荷 libc++ 头... } +if (!trustCfg && !graphSuppliesTarget && ...) { ...载荷 glibc / linux-headers 头... } +``` + +这**正是** [`2026-08-26-cross-target-implies-graph.md`](2026-08-26-cross-target-implies-graph.md) +分析的、`2026.8.26.1`(#511)在链接侧改成 `plan.targetSide.cAbi.prebuilt()` 的那个 +谓词。`git log -- src/toolchain/hostflags.cppm` 的最后一次改动是 #486 —— +**编译侧从未跟上**。 + +同一台机器、同一个编译器、同一个目标,只差写不写 `--target`: + +``` +$ mcpp build +$ mcpp build --target x86_64-unknown-linux-gnu +``` + +`build.ninja` 的 `ldflags` 行(#511 修过的那条):**逐 token 相同 ✔** +`cxxflags` 行:显式那条**少了六个 token**: + +``` +< --no-default-config +< -nostdinc++ +< -isystem /xim-x-llvm/22.1.8/include/c++/v1 +< -isystem /xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1 +< -isystem /xim-x-glibc/2.44/include +< -isystem /xim-x-linux-headers/5.11.1/include +``` + +⭐⭐ **三个后果,一个比一个重:** + +1. **头文件来自一个库,目标文件链自另一个库。** 链接线保留了载荷的 + libc++/glibc(#511 修对了),编译线把它们全丢了 —— clang 回落到自己的默认搜索。 + 本机有系统头就悄悄编过去,没有就报一个指向载荷的错。 +2. ⚠️ **`--no-default-config` 一起消失了。** 于是任何带 `--target` 的构建都从 + 「一切显式」切换成「这台机器的 cfg 说了算」,而 `post_install.cppm:266` 自己写着 + 那个 cfg 是「per-machine, per-install-path artifact」。 + ⭐ **这解释了报告者的 workaround 为什么有效** —— + 他手写的 `x86_64-unknown-linux-musl-clang++.cfg` 之所以被读到,正是因为 + `--target` 路径把 `--no-default-config` 丢掉了。报告里那句 + 「必须叫 `-clang++.cfg` 这个名字」也随之解释:那是 clang 自己的 + `-.cfg` 默认查找规则,而不是 mcpp 的机制。 +3. **`e2e 295` 看不见它。** 那个测试的不变式写的是「命名宿主自己的目标什么都不改变」, + 而它只取 `grep -m1 '^ldflags'`。同一个不变式在下面一行不成立,而测试不看那一行。 + +### 2.4 第二个皱褶:跨 home 发现 + +报告者说「机器上存在别的 mcpp home 时,依赖单元会拿到**它们的**宿主工具链 include +集合」。两处机制: + +**(a) `xlings.cppm:935`** —— `find_sibling_package` 找不到时无条件回落 +`$HOME/.xlings/data/xpkgs`: + +```cpp +// Also check ~/.xlings/data/xpkgs/ (xlings global home) as fallback. +const char* home = std::getenv("HOME"); +if (home) { auto xlingsXpkgs = path(home)/".xlings"/"data"/"xpkgs"; ... } +``` + +`probe.cppm:448` 用它找 `linux-headers`,结果直接进每一条编译命令的 `-isystem`。 +⭐ 本机 `~/.xlings/data/xpkgs` **存在**(实测,含 `fromsource-x-*` 等 30+ 包)—— +这条回落在这台机器上是活的。 + +**(b) `xlings.cppm:836`** —— `active_home_xpkgs()` 自己重新推导了一遍 home: + +```cpp +if (const char* h = getenv("MCPP_HOME")) home = h; +else if (const char* u = getenv("HOME")) home = path(u)/".mcpp"; +``` + +而 `mcpp.home::root()` 的答案有**三档**(`$MCPP_HOME` → 自包含安装 +`/..` → `$HOME/.mcpp`)。自包含安装下两者给出不同的 home。 +⚠️ `home.cppm` 的开篇写的正是这件事:「Every path under the mcpp home must be +derived from here. Before #311 this logic existed in three places … the copies +drifted」。**这是第四份拷贝。** + +⚠️ **未测**:报告里「`--target musl` 时那些行仍然出现」这一点,与 §2.3 的实测冲突 +(`--target` 非空 ⇒ 载荷行被抑制)。最可能的解释是那次构建走了 **fast path 重放** +—— `.build_cache` 不记录 home,整项目指纹也不记录(`fingerprint.cppm` 11 个字段里没有 +任何一个是 home 或载荷路径,且 `normalize_driver_output` 会**故意**把 `/home/` 路径 +抹掉),于是换了 home 也命中同一个 `target///`,ninja 重放的是上一次生成 +的、写着另一个 home 绝对路径的 `build.ninja`。这也解释了「选中的味道会在 +`resolution.json` 里跨次留存」。**这条要一次实测才能定案**,命令在 §7。 + +### 2.5 修复方案 + +#### A1 —— 目标侧的使用要求是全图的,不是一条边的 + +在 `prepare.cppm` 目标侧解析完成之后(`resolvedTargetSide` 已定,`~6713`),收集 +**从图里供给任一 `mcpp:` 层的包**的 `publicUsage`,并入**每一个**包的 +`privateBuild`: + +```cpp +// 目标侧的头文件集合是这次构建的属性,不是某条依赖边的属性。 +// 它已经被算出来了 —— prepare.cppm:7207 把同一个集合交给了 std 模块。 +UsageRequirements targetSideUsage; // 四个字段: +for (idx : layerProviderPackageIndices) // includeDirs + merge(targetSideUsage, packages[idx].publicUsage); // includeDirsAfter +for (auto& p : packages) // cflags / cxxflags + appendUnique(p.privateBuild, targetSideUsage); // 已在的会被去重 +``` + +四条设计判断: + +1. **只并入 `privateBuild`,不并入 `publicUsage`。** 它对全图可见,不需要再传播; + 写进 `publicUsage` 会让它进入被打包的库的使用要求,那是另一个层次的承诺。 +2. **追加在末尾。** 包自己的头文件仍然先被搜到;目标侧只需要排在**驱动默认目录之前**, + 而驱动的默认目录永远在最后。 +3. **provider 自己是 `layerProviderPackageIndices` 的成员**,所以它不需要特例 —— + `appendUnique` 让「它本来就有」成为无操作。 +4. ⭐ **`prepare.cppm:7207` 那一段随之变成这条规则的一个实例。** 不要保留两份: + std 模块的 flags 应当从同一个 `targetSideUsage` 取,否则「哪些目录是目标侧的」 + 这个决定又有了两处推导 —— 这个仓库为这个形状付过四次代价(#233/#240/#242/#344)。 + +⚠️ **一个必须同时回答的问题**:`layerProviderPackageIndices` 里要不要含 +`Origin::Graph` 以外的层?**不要**。载荷/xpkg 供给的层由 `hostflags` 那条路负责, +两条路同时发会让顺序变成两处决定。判据就是 `Layer::fromGraph()`。 + +#### A2 —— 编译侧读 `targetSide`,和链接侧读的是同一个值 + +`hostflags.cppm` 拿不到 `plan.targetSide`(它是 `mcpp.toolchain` 层,不能 import +`mcpp.build.plan`)。⇒ 通过 `HostFlagOptions` 传一个 `bool cAbiPrebuilt`, +在 `flags.cppm` 那一处从 `plan.targetSide.cAbi.prebuilt()` 填,和链接侧**同一个表达式**: + +```cpp +hopt.cAbiPrebuilt = plan.targetSide.cAbi.prebuilt(); // flags.cppm,与 1245/1484/1547 同源 +... +const bool graphSuppliesTarget = !opt.cAbiPrebuilt; // hostflags.cppm:199 +``` + +⚠️ **不要在 hostflags 里重新推导。** 那正是 #486 的开篇列出的三处分歧的成因。 +`hostflags` 的其它调用方(std 模块预编译、build.mcpp 宿主编译)必须一并给出这个值, +否则 `std.pcm` 和导入它的单元会用两套头文件 —— 这就是 e2e 181 抓过的形状。 + +⚠️ **`--no-default-config` 要单独拉出来。** 它今天被 `bypassCfg && !graphSuppliesTarget` +连坐,而它与「目标侧从哪来」无关:cfg 是 per-machine 的不可复现产物,**任何** +mcpp 构建都不该读它。改成无条件发(clang 且有 cfg 时)。 + +#### A3 —— home 发现收敛到一个答案 + +1. `active_home_xpkgs()` 改为 `mcpp::home::root() / "registry" / "data" / "xpkgs"`。 +2. `find_sibling_package` 的 `~/.xlings` 回落:**去掉**,或改为只在 + `mcpp::home::root()` 与该路径同源时才走。 + ⚠️ 这条回落写于跨 home 还不是问题的时候;删掉它可能让某些机器上的 + `linux-headers` 探测从「找到别人的」变成「找不到」—— + 而 `probe.cppm:453` 的 verbose 分支已经为「找不到」准备好了诊断。 + **这个方向是对的:找不到会说话,找错了不会。** + +#### 判据(A) + +| # | 判据 | 形态 | +|---|---|---| +| A-1 | §2.2 那个 20 行工程里,`cmdline.cppm` 的命令行含 `abi-include` | 新 e2e,断言 CDB 的行而不是 grep 全文 | +| A-2 | `e2e 295` 同时比对 `^cxxflags` 与 `^ldflags`,两条都是恒等式 | **改现有测试**,一行 | +| A-3 | `--no-default-config` 在两种拼写下都在 `cxxflags` 里 | 并入 A-2 | +| A-4 | 单测:`targetSideUsage` 只收 `fromGraph()` 的层 | `test_targetside.cpp` 的表可以直接扩 | + +⚠️ **A-2 是这批里最重要的一条**,因为它是**恒等式**:不需要期望值表,在每台宿主上 +都成立,而且它会在下一次有人只修一层时立刻变红。 + +--- + +## 3. mcpp#514 §B —— 键描述了编译器,没描述它被指向的头文件 + +### 3.1 ⭐ 磁盘上的两个 JSON + +同一台机器,同一个 `build-cache/v1`: + +``` +~/.mcpp/build-cache/v1/pkg/mcpplibs/cmdline@0.0.1//entry.json + inputs.toolchain = { compiler, compiler_version, driver_identity, + target_triple, target_implied_flags, + stdlib, stdlib_version } ← 七个字段,没有一个是头文件集合 + +~/.mcpp/build-cache/v1/std//std-module.json + std_build_commands = [ "... -isystem ... -isystem ... " ] ← 整条命令行,含头文件集合 +``` + +⭐⭐ **两个缓存,一台机器,两种「输入相同」的定义。** `std` 那个是对的 —— +`stdmod.cppm` 的注释说它「一直就是正确的身份」。依赖那个少一根轴。 + +而且这根轴是**故意**被抹掉的一半:`normalize_driver_output`(`probe.cppm:151`) +会把 `/home/`、`/tmp/`、`/var/` 开头的路径整段替换掉,**这是对的** —— 它让缓存 +跨 home 可共享。错的是**没有任何别的东西**接着说出「跨的这两个 home 里,glibc 是 +2.39 还是 2.44、linux-headers 是哪一版、cfg 文件写了什么」。 + +### 3.2 报告的三个方向,机制各不相同 + +| 方向 | 报告的现象 | 机制 | +|---|---|---| +| 1 | 装了 cfg workaround 之后,被污染的 BMI 仍然被复用 | ⭐ **成立且已定位**:cfg 文件是 `--target` 路径下每条编译命令的输入(§2.3),而键里没有它。加了 cfg 键不动 ⇒ 命中 ⇒ 从不重编 | +| 3 | 两次**宿主**构建,glibc 头来自不同 home(2.39 vs 2.44),命中同一条目 | ⭐ **成立且已定位**:`driver_identity` 抹掉路径 + 键无 sysroot 轴 ⇒ 同键。混合 BMI 让 clang 前端 SIGSEGV 在 `ASTReader::FindExternalVisibleDeclsByName` —— **反序列化崩溃而不是可读诊断**,因为 BMI 之间没有互校验 | +| 2 | musl 构建填了缓存后,宿主 `mcpp build` 复用了 musl 味的 BMI | ⚠️ **按报告的字面意思不成立**:`target_triple` **在**键里(上面的 JSON 实测),`--target x86_64-linux-musl` 会改写 `tc.targetTriple`(`prepare.cppm:2480`,clang 分支)。⇒ 两次构建的 dep 键必然不同。**要么是另一条路径把它重新调味了,要么这条是从症状推出来的。§7 给了定案命令** | + +⚠️ 我把方向 2 单独拎出来说,是因为 **[[recorded-field-with-no-decision-reader]] 的 +教训是双向的**:一条被推出来而没被测的因果,会让修复落在一个已经为真的谓词上。 +`dep-bmi-cache-cross-version-poisoning` 那次我把根因写错过两次,第二次靠 grep 生成物 +才定死。 + +### 3.3 ⭐ A 修好之后,B 自己消失一半 + +`cache_key::fill_package_config` 已经把 `pkg.privateBuild.includeDirs` 折进 +`includes` 轴(前缀 `priv:`,相对 `` 归一化)。 + +⇒ **A1 把目标侧目录并进每个包的 `privateBuild` 的那一刻,每个依赖包的键就自动带上了 +目标侧的身份。** 不需要为 openkal 这条路加任何新轴。 + +这也是判断 A1 那个形状对不对的一个独立证据:**一个正确的建模会让下游的键自己变对。** + +### 3.4 剩下的一半:载荷侧的头文件集合 + +A1 覆盖不到「同一个 triple、同一个 clang、不同的 glibc/linux-headers 载荷」。这需要 +一根显式的轴。最小形状: + +```cpp +// BuildAxes 新增一个字段,A 轴(toolchain)内: +std::string targetHeaderSet; // 头文件搜索集合的摘要 +``` + +内容取**已经解析好的**那三处,不要重新推导: + +- `resolve_clang_driver(tc).compile_tokens(...)` +- `resolve_link_model(tc).compile_tokens(...)` +- clang cfg 文件的**内容摘要**(存在时),因为它是命令的一部分而不在命令里 + +⭐ **取 token 而不是取路径**:`stdmod` 已经证明了这个取法是对的(它折进整条命令行), +而且 token 里的 `` 路径可以用 `fill_package_config` 已有的归一化函数处理, +保持跨 home 可共享 —— 这正是 §3.1 说「抹路径是对的」的那个性质。 + +⚠️ **不要 bump `kCacheEpoch`。** 那个常量的注释规定了它的判据:「Bump ONLY when a +change makes previously written entries **unusable**」。加一根轴让旧条目**miss**, +不让它们不可用;而且 `inputs_match`(`bmi_cache.cppm:211`)对 +`inputs.toolchain` 是整对象比较,旧条目自然不匹配。`cache gc` 的大小统计也仍然有效。 + +⚠️ **`std` 缓存不需要改** —— 它已经对了。但它和 dep 缓存现在会因为**不同的理由** +失效,`mcpp cache list` 的输出会短暂地看起来不一致。这是正确的,值得在 +`docs/` 里写一句。 + +#### 判据(B) + +| # | 判据 | 形态 | +|---|---|---| +| B-1 | 单测:两个只在 `targetHeaderSet` 上不同的 `BuildAxes` 产生不同的 `key_hex` | `test_cache_key.cpp` 已有表 | +| B-2 | ⭐ e2e:`--cache global` 下,**写一个 cfg 文件**再构建,`build-cache/v1/pkg/...` 下多出一个目录 | ⚠️ **不能写进真载荷**;测试要在临时 `MCPP_HOME` 里做 | +| B-3 | A1 落地后,含 `mcpp:c++-abi` provider 的图与不含的图,同一个依赖包落在两个键上 | e2e,判据是**目录名**不是日志行 | + +⚠️ **B-2/B-3 的判据必须是磁盘上的条目,不是 CLI 打的 `Cached` 那一行。** +`dep-build-cache-scoping` 那次,「假状态行骗了三个月」。 + +--- + +## 4. openkal-musl#13 —— 包不能区分「我从哪儿编」和「我发布什么」 + +### 4.1 那份头文件自己写下了答案 + +`openkal-musl/port/include/features.h:70`: + +> ⓘ **THIS IS THE SECOND-BEST REMEDY.** The first would be for a package to +> distinguish the directories it is **built from** from the directories it +> **publishes**. Measured 2026-08-22: **mcpp cannot express it** — publicUsage +> takes privateBuild's include directories entire. Moving the two directories +> into per-glob flags places them AFTER include_dirs on the command line, and +> musl's own build then finds the public `` before the internal one +> and fails with `unknown type name hidden`. + +引擎侧就是一行,`prepare.cppm:4162`: + +```cpp +pkg.publicUsage.includeDirs = pkg.privateBuild.includeDirs; +pkg.publicUsage.includeDirsAfter = pkg.privateBuild.includeDirsAfter; +``` + +⇒ **issue 报的是 openkal-musl 的行为,缺陷在 mcpp。** 包侧现在的做法(`hidden` 在 +C++ 下给 `extern "C"`、C 下清空;`weak` 清空;`weak_alias` 删除)已经是第二优解, +而且被三位不同的消费者各校准过一次(`restrict` / 链接性 / compiler-rt 的 `weak`)。 +**不要动它。** + +### 4.2 方案:`[build] private_include_dirs` + +```toml +[build] +include_dirs = ["port/include", "musl/include"] # 编译 + 发布 +private_include_dirs = ["musl/src/include", "musl/src/internal", + "musl-generated/internal"] # 只编译,不发布 +``` + +- 语义:进 `privateBuild.includeDirs`,**不**进 `publicUsage.includeDirs`。 +- 顺序:与 `include_dirs` **交错保持声明顺序**,因为 features.h 那段实测说得很清楚 + —— 把这两个目录挪到后面,musl 自己的构建就先找到公共 `` 而失败。 + ⭐ 所以这不能实现成「private 的都排在后面」,必须是**同一个有序列表,带一个 + 发布位**。 +- ⚠️ 这是 `[build]` 的一个新键。按 + [[new-capability-key-floor-measured]]:`mcpp.toml` 里不认识的键会让整份 manifest + 加载失败还是被忽略?**发布前必须实测索引 latest 的行为**,并且 openkal-musl 的 + `min_mcpp` 下限要写下界。 + +### 4.3 为什么这值得做,而不是「包自己绕过去」 + +`OKM_MUSL_INTERNAL` 那条(见 [[c-library-configured-by-what-is-beneath]])解决的是 +**宏**的泄漏,它成立是因为 `defines` 不传播。**目录**没有对应的机制,所以三个 +musl-internal 目录仍然出现在每一个消费者的命令行上 —— `hidden` 只是**已经被发现的 +那一个**名字。musl 的 `src/include` 覆盖层里还有 `__syscall`、`__libc`、`a_cas` 等 +一批名字,下一个消费者会撞上下一个。 + +⭐ **判据不是「`hidden` 不再泄漏」,是「那三个目录不出现在消费者的命令行上」。** +前者会随着包侧再打一个补丁而变绿,而缺陷还在。 + +#### 判据(C) + +| # | 判据 | +|---|---| +| C-1 | 单测:声明了 `private_include_dirs` 的包,其 `publicUsage.includeDirs` 不含这些目录,`privateBuild.includeDirs` 含且**顺序与声明一致** | +| C-2 | e2e:消费者的 CDB 行里没有 provider 的私有目录,provider 自己的行里有 | +| C-3 | 生态:openkal-musl 改用新键后,一个把 `hidden` 用作普通标识符的消费者能编过 —— **这条要等 mcpp 发布并进索引** | + +--- + +## 5. openkal-linux#13 —— 三类东西,一张表 + +report 里那张 ENOSYS 表把三种性质完全不同的东西列在了一起。**逐条实现会做错至少三条。** + +### 5.1 分类 + +| POSIX 面 | 类别 | 依据 | +|---|---|---| +| `copy_file_range` / `sendfile` | **① 端口层可解,不动 spec** | 语义就是「从一个流读、往另一个流写」,`kal_stream_read/write` 齐全 | +| `popen` / 子进程输出捕获 | **① 端口层可解,不动 spec** | 见 §5.3 | +| `last_write_time(dir)` 报 "Is a directory" | **① 端口层缺陷** | `kal_fs_open_dir` + `kal_fs_info` 都在;`.` 是保留名(clause 7.12),目录自己可以被问 | +| `fork` | **② 设计边界,不是缺陷** | SPEC clause 7.1 明写:复制地址空间不能在每个环境上忠实完成 | +| 文件权限位 / `chmod` | **③ 真缺口,而且当前行为是静默放宽** | 见 §5.5 | +| `symlink` / `symlinkat` | **③ 真缺口** | `KAL_FS_PROP_LINKS` 与 `kal_node_link` 都在,**没有任何操作能用它们** | +| socket / bind / listen | **③ 真缺口** | SPEC clause 3.4 已经点名了 `openkal.net`,见 §5.6 | +| **PC=0 的跳转** | **⓪ 唯一无歧义的缺陷** | 见 §5.2 | + +### 5.2 ⓪ NULL stub —— 先修这个,因为跳到 0 的程序说不出任何话 + +⭐ **这条排第一不是因为最严重,是因为它让其余每一条都测不准。** +一个在早期初始化就 SIGSEGV 的程序,后面 26 个失败用例的读数都是它的下游。 + +我能从源码确定的**收窄**: + +- `okm_syscall.c` 的 `default:` 分支返回 `-ENOSYS`(实测 `grep`,68 个 + `case SYS_*` + 一个 default)。⇒ **不是未知系统调用走空指针。** +- 端口层一共只有**两处**弱引用(`grep '__attribute__((weak))' port/src` = 2 行): + `okm_phdr.c:39` 的 `__ehdr_start` 与 `okm_syscall.c:31` 的 `kal_random_fill`, + **两处都判空了**。⇒ 不是 [[link-error-is-the-mechanism-not-the-defect]] 第二形态 + 的复发。 + +⇒ 三个仍然开着的候选,每个配一条**决定性**检查: + +| 候选 | 决定性检查 | +|---|---| +| `.init_array` 走过了头 / 含 0 项(`okm_start.c:179` 的循环**逐项不判空**) | `readelf -x .init_array `;并在循环里临时打印 `a` 的范围 | +| 程序自己 `dlsym` 得到 0 就调用(静态 musl 的 `dlsym` 恒返回 0) | `nm | grep dlsym`;`gdb` 在 `dlsym` 下断 | +| 某个 `kal_*` 被以函数指针形式取址后置零(不是弱引用,是数据) | `gdb`:`x/4gx $sp` 取返回地址 → `info symbol` | + +⚠️ **不要凭报错信息跳到修法。** [[reasons-written-from-memory-kill-good-fixes]]、 +[[link-error-is-the-mechanism-not-the-defect]] 都是同一天里在同一个仓库上踩的: +「报错信息不是规范」。这里连报错信息都没有 —— 只有一个空栈。 + +⚠️ **判据的单位是一整行输出。** 空栈 + PC=0 时,`bt` 是空的,但 +`info registers rip rsp` 与 `x/8gx $rsp` 不是。 + +### 5.3 ① 端口层可解的四条 —— 一个都不用动 spec + +**(a) `copy_file_range` / `sendfile`。** 在 `okm_syscall.c` 里加两个 case,实现成 +`kal_stream_read` → `kal_stream_write` 的循环。⚠️ 短写不是成功结果 +([[openkal-spec-and-linux-impl]]),循环必须写全或报错。 + +**(b) `popen` —— ⭐ 替换源码,不要重建机制。** + +这正是 `okm_spawn.c` 开篇已经论证过的动作: + +> musl starts a program by duplicating itself … openkal has neither operation … +> What openkal has instead is **the composite** … so the replacement is a +> **translation of arguments rather than a reconstruction of a mechanism** —— +> and it is shorter than the code it replaces. + +`posix_spawn.c` 已经在 `mcpp.toml:118` 的排除表里。`popen.c` **不在**,所以它还在 +用 `pipe2` ⇒ ENOSYS。而 `kal_process_spawn` 的 `struct kal_spawn_streams` +(`process.h:17`)允许调用方指定三个流 —— **子进程的 stdout 可以是父进程给的任意流**。 + +⇒ 把 `!musl/src/stdio/popen.c` 加进排除表,写 `port/src/okm_popen.c`:用 +`kal_fs_open`(`CREATE|EXCLUSIVE|TRUNCATE`)开一个临时文件当子进程的 stdout, +`kal_process_wait` 之后把它 seek 回 0 交给 `FILE*`。 + +⚠️⚠️ **这不是 `pipe2`,不要假装是。** 语义差三处,而且都必须写进包的 README: +不能与子进程并发交错;不能用于 self-pipe;`popen(..., "w")` 方向要单独处理。 +⭐ **正因为差,才必须实现成 `popen` 而不是实现成 `pipe2`** —— +一个假的 `pipe2` 会让 self-pipe 的调用方**静默地永远等下去**,而这正是 +[[c-library-configured-by-what-is-beneath]] 里 futex 那条踩过的: +「⚠️ 答案由被重建的那张面决定」。 + +ⓘ 顺带:musl 的 `system()` 走的是 `posix_spawn`,所以 **`std::system` 今天就能用**。 +报告里「不能 shell out」的范围比它看起来窄,值得在回复里说清楚。 + +**(c) `last_write_time(dir)`。** clause 7.12 的保留名 `"."` 就是为这件事存在的 +(「a program holding a directory has no way to ask an operation about that +directory」)。端口层的 `stat`/`statx` 对目录路径应当走 +`kal_fs_open_dir` + `kal_fs_info(dir, ".")`,而不是 `kal_fs_open` 文件路径。 + +**(d) symlink 的**报告**方式。** 端口层已经在 +`okm_syscall.c:688/699` 对 `kal_node_link` 返回 `-ENOSYS`。这是对的,保留; +真正缺的是**创建/读取**,那属于 ③。 + +### 5.4 ② `fork` 是边界不是缺陷 —— 诊断而不是实现 + +SPEC clause 7.1 与 `okm_spawn.c` 的开篇都把 `kal_fork` 这个**操作**写死了。 + +⚠️⚠️ **本节初稿写的是「openkal 不会长出 `fork`,这是通用性的硬天花板」——那句话错了。** +clause 7.1 禁止的是**这个操作**,没有禁止**地址空间的原子能力**(创建地址空间 / +映射内存 / 以给定寄存器状态在其中启动上下文)。有了那三样,端口层**可以**把 `fork` +组合出来 —— Fuchsia 没有 `fork` 却有 `zx_process_create`/`zx_vmar_map`/`zx_thread_start`。 + +⇒ 正确的说法是:**`fork` 需要一组 openkal 目前没有的原子能力,而那组能力是一个 +将来的候选,不是一条永久的拒绝。** 为什么不在本批(三条理由)见 +[`2026-08-27-openkal-ecosystem-design-plan.md`](2026-08-27-openkal-ecosystem-design-plan.md) §1.2。 + +**本节其余结论不变**:今天没有那组能力,而报告者那一处应当改用 `posix_spawn` —— +因为**他那份代码在传统体系里也不可移植**(Windows 没有 `fork`)。 + +⚠️ 但报告者读到的是 `"fork failed: Function not implemented"` —— 一个不区分 +「本实现没有」和「本模型没有」的答案。⇒ 值得做的是**让这条边界可读**: +openkal-musl 的 README 增一节「这个 C 库不提供什么,以及改用什么」,把 +`fork`→`posix_spawn`、`pipe2`→`popen`、`socket`→(无)列成一张表。 + +⭐ **一条不能实现的调用,唯一能改善的是它被理解的速度。** + +### 5.5 ③ 权限位 —— 当前行为是**静默放宽**,这是唯一被明令禁止的失败模态 + +读源码: + +- `openkal/include/openkal/fs.h` —— `kal_fs_open` 的 flags 只有 6 位 + (READ/WRITE/CREATE/EXCLUSIVE/TRUNCATE/APPEND),**没有任何权限参数**; + `kal_node_info` 只有一个 `writable` 布尔。 +- `openkal-linux/src/fs.cpp:127` —— `openat(..., f, 0666)` 硬编码; + `:236` —— `mkdirat(..., 0777)` 硬编码。 + +⇒ 程序请求 `0600` 拿到 `0666 & ~umask`。⚠️ **这不是「缺一个特性」,这是 +`okm.h` 自己划的唯一禁区**: + +> ⚠️ THE ONE WAY THIS COULD GO WRONG IS NOT PRESENT: **nothing below reports +> SUCCESS having done nothing.** + +一个要求私有的文件被创建成组内/全局可读,而调用方**收到了成功**。密钥、token、 +会话文件都走这条路。⭐ **这条的严重性高于表里其它任何一条,而它在报告里排第四。** + +**最小的、加性的、clause 允许的一步**(如果一定要动 spec): + +- `kal_fs_props` 加一个位 `KAL_FS_PROP_PERMISSIONS`。clause 6.2 明写属性字就是 + 为这类问题准备的,而且「a position that has not been assigned reads as zero, so + that a program compiled against a later specification behaves correctly against + an earlier implementation」—— **向后兼容是这个机制自带的**,clause 8 也允许 + 「A revision may add declarations」。 +- **不加操作。** `chmod` 是一个 operation,clause 6.2 说 operation 的缺席应当由 + **独立接口**表达(链接期缺席),而不是塞进 `openkal.fs` —— 因为一个 FAT 分区上的 + 实现永远满足不了它,那正是 clause 6.4 禁止的。 + +⭐ 而在 spec 动之前,**端口层就能把静默去掉**:`O_CREAT|O_EXCL` 且请求模式比 +环境能给的更严格时,openkal-musl 至少要能被问出真相。具体形态需要一次设计讨论, +本文不预设。 + +### 5.6 ③ socket —— spec 已经预留了名字,但现在不做 + +SPEC clause 3.4 里 `openkal.net` **已经被点名**: + +> An earlier draft replaced `openkal.fs` and **`openkal.net`** with a single +> interface … Positioning applies to a file and not to a connection; +> **half-closure applies to a connection and not to a file.** + +⇒ 加一个 `openkal.net` 是 spec **预期内**的演进(clause 3.2:新接口一律放在 core +之外;clause 6.5:`openkal.exec` 已经给了「可选接口在依赖解析期决定」的先例)。 + +**但现在不该做**,三条理由,都来自这个生态自己的记录: + +1. [[openkal-portable-program-findings]]:**两个实现意见一致等于零证据** —— + 同一个作者、同一次阅读。一个从**一个 CLI 的需求**设计出来的网络接口,会把那个 + CLI 的形状(一个本地回调服务器)当成接口的形状。 +2. [[second-instance-exposes-the-interface]]:第二个实例才暴露接口是否完整。 + `openkal.net` 需要至少两个环境(比如 Linux 与 一个非 POSIX 环境)各写一份, + 才知道 half-closure、地址族、非阻塞该怎么切。 +3. clause 3.2 的不对称性:**放错位置的代价不对称**。接口一旦定下就不能改 + (clause 8:「shall not alter existing ones」;clause 5.3:结构布局不可变)。 + +⇒ 回复 issue 的正确形状是:**说明范围,而不是承诺路线图**。 +openkal 的 README/SPEC 值得增一节「本版不提供什么」,把 socket、pipe、fork、 +权限位、符号链接创建列进去,并说明每条是「边界」还是「未决」。 +⚠️ SPEC clause 11 已经有「Matters this version does not settle」这一节 —— +**这五条就应该进那一节**,那是它们的既有位置,不需要新机制。 + +--- + +## 6. 批次与优先级 + +| 批 | 内容 | 面 | 依赖 | +|---|---|---|---| +| **P0** | **A2** 编译侧谓词 + `--no-default-config` 无条件化 + `e2e 295` 扩到 `cxxflags` | mcpp | 无 | +| **P0** | **⓪** openkal-musl 的 NULL 跳转定位(不是修,是定位) | openkal-musl | 无 | +| **P1** | **A1** 目标侧使用要求全图化 + `prepare.cppm:7207` 收敛为它的实例 | mcpp | A2(否则两处顺序互相干扰) | +| **P1** | **A3** home 发现收敛到 `mcpp::home::root()` | mcpp | 无 | +| **P2** | **B** `targetHeaderSet` 轴(A1 之后剩下的那一半) | mcpp | A1 | +| **P2** | **C** `[build] private_include_dirs` | mcpp | 无 | +| **P2** | **①** copy_file_range/sendfile、popen 替换、`last_write_time(dir)` | openkal-musl | ⓪ | +| **P3** | **②** README 的「不提供什么」表 | openkal-musl / openkal-linux | ① | +| **P3** | **③** 权限位的静默去掉 + SPEC clause 11 增补五条 | openkal | 需要单独讨论 | +| **不做** | `openkal.net`、`fork`、`pipe2` | — | 见 §5.4 / §5.6 | + +⚠️ **A2 排在 A1 前面是有理由的**:A1 往命令行上加 `-I`,而 A2 决定载荷的 +`-isystem` 在不在。先加 `-I` 会让「载荷的行还在不在」这个问题被新加的目录遮住, +到时候读不出是哪一层在起作用 —— [[second-copy-of-a-decision-written-without-reading-the-first]]: +**一层盖住一层,修完要看下一层读数。** + +⚠️ **P0 那条 mcpp 改动会改变每一个带 `--target` 的构建的编译线。** 按 +[[everything-except-docs-goes-through-pr]],七个 openkal 仓库的 CI 都要在 +`MCPP_SOURCE_REF` 下现场构建验一遍,判据是 `under review:` 那一行,不是「七个全绿」。 + +--- + +## 6.5 通用完整性补充 —— 六条,都不专为这个场景 + +⚠️ 本章刻意**不**从「让报告者的程序跑起来」出发。判据是:**换一个用户、换一个应用, +这六条是否仍然成立。** 逐条给了这个反问的答案。 + +### G1 —— 一个层只有一个实现,它就还不是层 + +`mcpp.targetside` 的架构规则写着「mcpp 硬编码层名,永不硬编码实现」,理由是 +「生态的组合是 2×N×M 而包是 2+N+M」。可 `kernel-abi` 这一层今天在 Linux 上 +**N = 1**:所有 graph 路线都是 openkal。 + +两个空缺,都在矩阵里已被记录: + +1. **`openkal-gcc-runtime`**(填 ⑤)—— 让 gcc 也能走 graph。矩阵文档已记为生态空缺。 +2. **`linux-musl` 的 llvm 实现**(填 ①)—— 一个包,`provides` 只写两层: + + ```toml + provides = ["mcpp:c-abi=musl", "mcpp:c++-abi=libc++"] + # kernel-abi 不由它供给 —— 留空即"传统栈上这层没有名字" + ``` + + ⭐ **引擎已经能表达这个组合,不需要 #492 那种 `isLlvmMusl` 分支。** + `TargetSide` 的注释为这个形状写好了(*"kernelAbi HAS NO NAME ON A TRADITIONAL + STACK … 这个字段在 picolibc 裸机构建里读 `—`"*),而 `2026.8.25.1` 把 + `system_from_graph()` 拆成可以单独问 `cAbi.prebuilt()`,正是为了让 + 「一层来自图、另一层来自载荷」成为可表达的排布。 + ⇒ **这是包的工作,不是引擎的工作。** + + ⚠️ **未测**:需要真跑一次才能确认那个组合的 flag 路径通(尤其 `-B`/startup + objects 与 `--gcc-toolchain` 的来源)。#492 撤回时留下的设计文档 + `.agents/docs/2026-08-23-llvm-musl-target-design.md` 里有一半答案。 + +**换个用户还成立吗?** 成立。任何「要 clang + 静态 musl」的项目今天都只有 openkal +一条路,而这个组合与可移植性无关 —— 它是绝大多数「发一个能拷到任何 Linux 上跑的 +二进制」的诉求的形状。 + +### G2 —— 一个层收窄了它上面那层的面,这件事必须在构建期可读 + +今天:openkal 的缺席在 **链接期**表达(clause 6.1),这对直接调 `kal_*` 的消费者是 +对的。但 C 库**转发**它时,链接期缺席被翻译成了**运行期 ENOSYS** —— +这在分层上是正确的([[c-library-configured-by-what-is-beneath]]:一层用"不存在" +表达的缺席,到上一层变成"一个有定义的错误"),**代价是程序在运行期才发现依赖解析期 +就已知的事。** + +三个形态,按代价排序: + +| 形态 | 改动 | 局限 | +|---|---|---| +| openkal-musl 的 README 增「本 C 库在什么配置下不提供什么」表 | 零 | 要人去读 | +| build.mcpp 的 advisory 通道打一行 | 零引擎、零 spec | ⚠️ **缓存命中不重跑** ⇒ 提示会出现一次就消失([[build-program-advisory-channel]]),不能是唯一载体 | +| C 库声明它重建出来的 POSIX 面,mcpp 在目标侧报告里多打一行 | 新词汇 | **建议先不做** —— 等第二个消费者,理由同 §5.6 | + +⚠️ **引擎不得硬编码 POSIX 设施名**(架构规则四),所以这条无论如何都必须由包来说。 + +**换个用户还成立吗?** 成立,而且更普遍:任何 `Origin::Graph` 的层都可能收窄上层的面, +openkal 只是第一个。 + +### G3 —— 「能构建」不是「能用」,缺的是一整层验收 + +- openkal 的 conformance 套件验的是 **openkal 自己的 `kal_*` 面**。 +- **没有任何东西验 openkal-musl 重建出来的 POSIX 面。** + +⇒ 一套「POSIX 验收」套件,跑在 **每个 backend × 每个 arch** 上,会在用户之前抓住本轮 +的 ⓪(NULL 跳转)、①(`copy_file_range`/`popen`/`last_write_time`)、 +§5.5(权限静默放宽)—— **五条里的五条**。 + +⭐ **这是六条里回报最高的一条**:它把「67/94」从**用户的读数**变成 **CI 的读数**。 + +⚠️ 判据要带分母([[criterion-whose-no-is-also-silence]]): +`67 passed / 94` 是读数,「套件跑过了」不是 —— 一个什么都没发现的套件也会报成功。 +⚠️ 且必须**真跑**(qemu),不能只链接:本轮全部三类问题都在链接之后。 + +**换个用户还成立吗?** 成立。这是「第二个实例才暴露接口是否完整」 +([[second-instance-exposes-the-interface]])的验收版本。 + +### G4 —— 引擎表达力的三个缺口 + +前两个是本文 §2/§4 的 A1 与 C。第三个是新的,而且最深: + +**c. 包的构建不能条件于"已解析的目标侧"。** + +`openkal-musl/port/src/okm.h` 自己写下了这条: + +> ⚠️ THE TARGET IS A PROXY FOR THE IMPLEMENTATION, AND AN IMPERFECT ONE. … +> **mcpp conditions on the target and not on which package satisfies a +> capability; when it can, this block is where that would be read instead.** + +于是 `OKM_HAS_FS/PROCESS/TASK` 只能用 `cfg(os = "none")` 当代理 —— 而代理是错的: +一块板**可以**带真正的文件系统。 + +⭐ **这条可以做,而且比看上去便宜。已核三点:** + +1. 目标侧扫描只读 **manifest 的 `provides` + 已激活的 feature + 已解析的 tc**; + feature 激活在 `prepare.cppm:5603`,工具链在 `:5218` —— 都早于依赖 build.mcpp(`:6308`)。 +2. `directives.cppm` 的 **16 行指令表里没有任何一行能改 `provides`** + ⇒ build.mcpp 不可能改变扫描的输入。 +3. ⇒ **把目标侧解析从 `:6713` 前移到 `:6308` 之前是安全的**,之后每个包的 + build.mcpp 都能被告知"我下面是谁"。 + +⚠️ **未测**:前移会改变 `tsd::Inputs` 里 `tc` 的成熟度(`tc` 在 `:5218`–`:6713` 之间 +被读写过 39 处)。要先枚举哪些字段进了 `Inputs`,再判断它们在 `:6308` 时是否已定。 + +**换个用户还成立吗?** 成立。「C 库要被它下面那层配置」是分层系统的通例,不是 openkal 的特例。 + +### G5 —— 头文件集合必须进缓存键与指纹 + +本文 §3。通用形式:**任何"头文件集合不能由 (编译器, 三元组) 推出"的目标** —— +交叉、显式 sysroot、graph 供给 —— 都暴露在这条上。今天只有 `std` 缓存把它做对了。 + +**换个用户还成立吗?** 成立,且今天就在生效:§3.2 方向 3 是**两次宿主构建**,连交叉都不是。 + +### G6 —— 「换编译器」可能同时换掉平台接口,诊断要按两根轴说话 + +矩阵文档的建议 ③ 已经写了一半:「① 拒绝时指出可用的替代编译器,两向判据」。 + +⚠️ **另一半没写**:在 `linux-musl` 上,从 llvm 换到 gcc 不只换了编译器,还把 +`kernel-abi` 从 openkal 换成了 Linux 直连、`c++-abi` 从 libc++ 换成了 libstdc++ —— +**三样东西一起动了**。一条只说「用 gcc 也能构建这个目标」的建议,会让下一个人 +在不知情的情况下跨过同一条缝(方向相反)。 + +判据:该建议必须同时说出**换过去之后那三行目标侧报告长什么样**。 + +**换个用户还成立吗?** 成立 —— `x86_64-windows-gnu` 在两张表里 c-abi 一个是 `gnu` +一个是 `musl`,矩阵文档已经把那一格标成「一个目标名,两个 C 库」。 + +### 优先级:哪些解阻塞,哪些是完整性 + +| | 解报告者的阻塞 | 通用完整性 | +|---|---|---| +| **G1.2**(llvm-musl 包) | ⭐⭐ 直接解掉 openkal-linux#13 的**全部 27 条** —— 因为不再经过 openkal | ⭐⭐ | +| **G3**(POSIX 验收) | 间接:让剩下的缺陷在 CI 而不是在他那里显形 | ⭐⭐⭐ | +| A1/A2/A3/B(§2/§3) | ⭐ 解 mcpp#514 | ⭐⭐⭐ | +| C(§4) | ⭐ 解 openkal-musl#13 | ⭐⭐ | +| ⓪/①(§5.2/§5.3) | ⭐ 若他继续留在 openkal 上 | ⭐⭐ | +| G2/G4/G6 | — | ⭐⭐ | + +⚠️ **G1.2 与 §5 不是二选一。** 即使他换到 llvm-musl 包,openkal 路线仍然要修 —— +它服务的是**另一批用户**(裸机、UEFI、跨平台一份源码),而那批用户不会撞到 `fork`。 +⭐ 反过来说:**§5 那些条目之所以被当成"openkal 的缺陷",恰恰是因为一个不需要 +可移植性的用户被放到了可移植性抽象上。** 把他放回去之后,那些条目回到它们本来的 +优先级 —— 除了 ⓪ 和 §5.5,那两条在任何用户身上都是缺陷。 + +--- + +## 6.6 什么可以进 openkal —— 四问 + +判据不是我提的,`okm_syscall.c:836` 解释 `openkal.random` 为什么必须存在时已经写下了: + +> ⚠️ **AND IT IS WHY `openkal.random` HAD TO EXIST. Entropy is not derivable from +> the other interfaces** … Neither bypassing the layer nor inventing entropy was +> acceptable, so the layer gained an interface. + +把它和 clause 6.4 / 7.1 合起来,是四问: + +1. **只有环境能给吗?** 能由已命名的东西(流 / 任务 / 内存 / 进程)组合出来的,是**库**。 +2. **在形态不同的环境里都以可辨认的形式存在吗?** 预设了某个 OS 的模型(用户身份、 + 全局路径命名空间、地址空间复制)的,是**那个 OS 的形状**。 +3. **是不是「某些资源永远满足不了」的操作?**(clause 6.4) +4. **实现它会不会逼某个环境去构建兼容层?**(clause 7.1) + +### 按四问过一遍 + +| 候选 | 判定 | 理由 | +|---|---|---| +| **symlink** | ❌ 不进 | 文件系统**格式**的属性(FAT 没有,NTFS 语义还不同),不是内核能力。`KAL_FS_PROP_LINKS` 那个属性位是对的;加操作正撞 clause 6.4 | +| **permission / chmod** | ❌ 不进 | 预设**身份模型**。裸机/UEFI 没有用户,Windows 是 ACL,WASI 是能力模型 ⇒ 不通用。⭐ **缺陷是「静默」,不是「缺 chmod」**(见 §6.8 ②) | +| **pipe(进程内)** | ❌ 不进 | = ring buffer + `okm_task_wait/wake`,而这两个已经有(`okm_opt.h:135`,端口的 `SYS_futex` 就走它)⇒ **可导出 ⇒ 是库** | +| **pipe(跨到 spawn 的程序)** | ⚠️ 候选,但**不是现在** | 只有环境能做。⭐ 但它不是 `pipe2`,而是 `kal_process_spawn` 缺「给我一个连到子进程 stdout 的流」—— **已有接口的一次加法**(clause 8 允许)。而临时文件已能覆盖绝大多数用法(§5.3b),⇒ 等一个临时文件解决不了的用例 | +| **net** | ✅ 通过,**不是现在** | 网络栈确实不可由已有接口导出;clause 3.4 已点名 `openkal.net`。但 clause 8 定死后不能改 ⇒ 不该由一个程序的需求定形(§5.6) | +| **就绪/多路复用(poll)** | ⚠️ **新候选,见 §6.8 ①** | 这一条比上面任何一条都更接近「只有环境能给」,而且它今天一个都没有 | + +⭐ **结论:上一版列的四个可选接口里,三个不该进,第四个不该现在进。** + +### 6.6.1 ⭐⭐ 一条更硬的判据,它替换上面四问的**范围结论** + +上面四问要人去判断「是不是通用内核能力」—— 而那是一次判断,判断会错。把判断交给 +**已经存在的可移植程序**: + +> **传统体系下已经可移植的程序用到的东西,openkal 体系必须有。** +> +> 否则 openkal 让这些程序**更不可移植**,而不是更可移植 —— 它们在 Windows/macOS/Linux +> 上靠三条 `#ifdef` 分支就能走,到了 openkal 上没有第四条可写。 + +这条判据是从 openkal 自己的目的推出来的,不是外加的:openkal 存在是为了让一份源码 +到达多台机器。**一个已经到达三台机器的程序,不该在第四台上失去能力。** + +| 设施 | Windows | macOS | Linux | 可移植程序已经在用? | ⇒ openkal | +|---|---|---|---|---|---| +| 网络 socket | ✅ Winsock | ✅ | ✅ | ✅ **`mcpplibs/tinyhttps@0.2.8` 就是**(`#ifdef _WIN32` 两支) | ✅ **该有** | +| 就绪等待 | ✅ `WSAPoll` | ✅ | ✅ | ✅ 同上(它用 `poll`) | ✅ **该有** | +| 管道 + 子进程输出 | ✅ `CreatePipe`/`CreateProcess` | ✅ | ✅ | ✅ | ✅ **该有**(spawn 那半个已经有) | +| 终端控制(raw / 尺寸) | ✅ Console API | ✅ termios | ✅ | ✅ **ftxui 就是** | ✅ **该有** | +| **`fork`** | ❌ **没有** | ✅ | ✅ | ❌ 用了它的程序**本来就不可移植** | ❌ **不该有** | +| **mode 位 / `chmod`** | ❌ 只有只读位 | ✅ | ✅ | ❌ | ❌ **不该有** | +| **symlink** | ⚠️ 要权限 | ✅ | ✅ | ⚠️ 标准库把它设计成**允许失败** | ❌ **不该有** | +| `copy_file` | 标准库层面已可移植 | | | ✅ | ❌ 端口层做 | + +⚠️ **这张表与 §6.6 四问的结论不同,差在两处,而且是这条判据赢:** + +1. 四问把 `net` 判成「通过但等第二个消费者」。这条判据说**它已经有第二个消费者了** + —— mcpp 自己索引里的 `tinyhttps`,它的两支 `#ifdef` 就是两个独立实现, + 而且不是同一个作者读同一份规范写出来的 + ([[openkal-portable-program-findings]] 那条「两个实现一致等于零证据」在这里**不适用**)。 +2. 四问完全没看到**就绪原语**与**终端控制**。这条判据把它们提到与 net 同级, + 而它们比 net 小得多。 + +⭐ **推论:`__config_site` 那份开关清单本身就是一份现成的核对表** +(`_LIBCPP_HAS_TERMINAL` / `_LIBCPP_HAS_FILESYSTEM` / `_LIBCPP_HAS_THREADS` / …)—— +libc++ 已经替我们列出了「一个标准库需要下层提供什么」,而 §6.7 表 C 显示今天有两行 +与现实不符。**核对它,比逐条讨论接口更该先做。** + +### 6.6.2 ⭐⭐ 「永远不会有」与「这个后端没有」是两件事,今天用同一个答案回答 + +`okm.h` 论证 ENOSYS 正当性的那段是对的 —— 但它论证的是**第二件事**: + +> A C library may [answer unsupported], because POSIX's surface HAS one — ENOSYS +> — and every caller of `open` already handles a failure. + +`open` 在一个 core-only 后端上失败,是**接口存在而这个后端不提供** ⇒ ENOSYS 正确, +`okm_opt.h` 那道缝处理得也正确。 + +但 `fork` / `socket` / `poll` / `chmod` 是**另一件事:openkal 根本没有这个接口, +因此没有任何后端会提供它**。对这一类,ENOSYS 把一个**永久的、与后端无关的**事实 +伪装成了一个运行期条件。 + +⭐ **正确的答案是链接错误**,而这正是 clause 6.2 的表已经规定的: + +| 时机 | 机制 | 回答什么 | +|---|---|---| +| 链接 | 未定义符号 | 是否用了它**不提供**的接口 | + +⇒ **openkal-musl 应当把这些 POSIX 面从构建里排除掉**,让引用它们的程序链接失败并 +被指名。 + +**机制全部现成,是一次 manifest 编辑,没有新机器:** + +- `mcpp.toml` 的 `sources` 已经有 `!` 排除语法,**今天已经排除了 10 个 musl 源** +- cflags 已有 `-ffunction-sections -fdata-sections`,ldflags 已有 `-Wl,--gc-sections` + (`mcpp.toml:240`)⇒ **没被引用的不会失败,被引用的才失败**,粒度正好 + +**效果**:`tinyhttps` 在 openkal 上**链接失败并指名 `socket`**,而不是构建成功、 +测试跑到第 27 个才知道。⭐ 这把发现从**运行期**移到**链接期**,零设计成本。 + +⚠️ **未测**:排除集合的**闭包**要跑一轮才能定 —— musl 的 `network/` 内部互相引用 +(`getaddrinfo` → `socket`),排一个可能牵出一串。这是实现问题,不是设计问题。 + +--- + +## 6.7 能力支持表(实测) + +⚠️ 判据全部来自源码与磁盘,不是推的。每张表标了取数方式。 + +### 表 A —— openkal 接口 × 实现(取自各仓库 `src/` 的文件集合 + `provides`) + +| backend | abort | stream | memory | env | time | fs | process | task | random | exec | +|---|---|---|---|---|---|---|---|---|---|---| +| openkal-linux 0.5.4 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| openkal-macos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| openkal-windows | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| openkal-opensbi | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | +| openkal-uefi | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | + +⭐ **openkal-linux 是完整的 —— 十个接口一个不缺。** 所以本轮的缺口**不在 backend**, +在**接口集合本身**。这条读数很重要:它把「让 openkal-linux 更完整」这条路排除掉了。 + +### 表 B —— openkal-musl 重建出来的 POSIX 面 + +判据:`port/src/okm_syscall.c` 的 `case SYS_*` 集合(**71** 个)对 +`musl/src/**/*.c` 引用到的 syscall 集合(**289** 个)。⭐ 分母在这里。 + +| 设施 | 状态 | 判据 | +|---|---|---| +| 文件 I/O、目录、重命名、删除 | ✅ | `open/openat/read/write/lseek/getdents64/mkdirat/renameat2/unlinkat` 均在表 | +| stat / 时间戳 | ✅ | `stat/fstat/newfstatat/statx/utimensat` 在表 | +| 线程、futex、TLS | ✅ | `okm_thread.c` 直接走 `kal_task_*`;`SYS_futex → __okm_futex` | +| 匿名内存 | ✅(受限) | `mmap` 仅 `MAP_ANON\|MAP_PRIVATE` 且 `addr==0 && fd<0`,否则 `-ENOSYS` | +| 时间、睡眠 | ✅ | `clock_gettime/nanosleep/clock_nanosleep` 在表 | +| 起子进程 + 等待 | ✅ | `okm_spawn.c` 替换了 `posix_spawn.c`;`wait4` 在表。⇒ **`system()` 可用** | +| 熵 | ✅ | `SYS_getrandom → kal_random_fill`(弱引用 + 判空) | +| **`fork` / `clone` / `vfork`** | ❌ ENOSYS | 不在表 —— clause 7.1 的**设计边界** | +| **pipe / pipe2 / mkfifo** | ❌ ENOSYS | 不在表 | +| **poll / ppoll / select / pselect6** | ❌ ENOSYS | 不在表 —— 见 §6.7 ① | +| **epoll / eventfd / signalfd / timerfd** | ❌ ENOSYS | 不在表 | +| **socket 全族** | ❌ ENOSYS | 不在表 | +| **chmod / fchmodat / chown** | ❌ ENOSYS | 不在表 | +| **symlink / symlinkat** | ❌ ENOSYS | 不在表;`readlinkat` 在表但对 `kal_node_link` 返回 `-ENOSYS` | +| **copy_file_range / sendfile** | ❌ ENOSYS | 不在表 | +| **`umask`** | ⚠️ **成功且无效** | `g_umask` 只被 `SYS_umask` 自己读写,**从不作用于任何创建**(`okm_syscall.c:388/937`) | +| **终端控制** | ⚠️ 只答"是不是终端" | `ioctl` 仅 `TCGETS` 返回 0;`TCSETS`/`TIOCGWINSZ` → `-ENOTTY` | +| **信号 handler** | ❌ ENOSYS(**诚实**) | `rt_sigaction` 对 `SIG_DFL/SIG_IGN` 返 0,真实 handler 返 `-ENOSYS`(`:969-970`) | +| **uid/gid** | ⚠️ 虚构 | `getuid/geteuid/getgid/getegid` 一律返回 **1000**(`:935`) | + +### 表 C —— libc++ 的特性声明 vs 实际(读 `openkal-llvm-runtime/llvm-generated/*/__config_site`) + +| 开关 | generic | freestanding | 实际 | +|---|---|---|---| +| `_LIBCPP_HAS_THREADS` | 1 | 1 | ✅ 真的有 | +| `_LIBCPP_HAS_MONOTONIC_CLOCK` | 1 | 1 | ✅ | +| `_LIBCPP_HAS_RANDOM_DEVICE` | **1** | 0 | ✅ **0.1.3 已修**(报告者测的是 0.1.1) | +| `_LIBCPP_HAS_FILESYSTEM` | 1 | 0 | ⚠️ **部分** —— symlink / copy_file / permissions 运行期 ENOSYS | +| `_LIBCPP_HAS_TERMINAL` | 1 | 0 | ⚠️ **不符** —— 见表 B 终端控制 | +| `_LIBCPP_PSTL_BACKEND_STD_THREAD` | 是 | 是 | ⚠️ 线程有,**就绪原语一个没有** | +| `_LIBCPP_HAS_LOCALIZATION` / `UNICODE` / `WIDE_CHARACTERS` / `TIME_ZONE_DATABASE` | 1 | 1 | **未测** | + +⭐⭐ **这张表本身就是一个缺口的证据:`__config_site` 是一份对下层能力的声明,而 +今天没有任何东西核对它。** 三行里有两行与实际不符,而它们都是**编译期**就写死的 +—— 程序因此把 `` 和终端支持整个编了进去,再在运行期一条一条撞 ENOSYS。 + +⇒ **回答「能力是不是不完整」:是,而且不完整的位置不在 backend(表 A 满), +在接口集合(表 B)与声明和现实的落差(表 C)。** + +--- + +## 6.8 其他问题(报告者未列,本轮实测发现) + +**① ⭐⭐ 就绪 / 多路复用原语一个都没有。** +`poll`/`ppoll`/`select`/`pselect6`/`epoll_*`/`eventfd2`/`signalfd4`/`timerfd_*`/`pipe2` +**全部** ENOSYS(逐个查过 `case SYS_*`)。⇒ **任何事件循环都建不起来。** +这比"没有 socket"更根本 —— 一个不联网的 TUI 也要等"输入就绪或超时"。 +⭐ 按 §6.6 的四问,这一条最接近「只有环境能给」:等待多个来源之一变为就绪, +不能由 `kal_stream_read`(阻塞、单源)组合出来。**如果 openkal 只增一个接口, +候选应该是它,而不是 net。** +⚠️ **未测**:是否有环境无法提供它(裸机上"就绪"是否有意义)。这决定它是不是通用能力。 + +**② ⭐⭐ `umask()` 成功且无效。** `g_umask` 只被自己读写,从不作用于任何创建 +(`kal_fs_open` 也没有 mode 参数)。⇒ `umask(077)` 返回旧值、报成功、**下一次创建 +不受影响**。这正是 `okm_opt.h` 开篇明令禁止的那一种: +*"nothing below reports SUCCESS having done nothing"*。 +⭐ **它比 §5.5 那条更硬** —— §5.5 要论证"0600 被放宽"的具体值,这条不需要: +**一个有返回值的调用,它的效果不存在。** + +**③ ⭐ 终端控制不存在,而 `KAL_STREAM_PROP_INTERACTIVE` 这个位存在。** +`ioctl` 的注释自己写着:*"Everything a terminal can be asked to do beyond that is +not an operation openkal has."* ⇒ TUI 进不了 raw mode、拿不到窗口尺寸。 +⚠️ **未测**:这与 §5.2 的 PC=0 是否相关。但"早期终端 UI 初始化"这个位置与它重合, +**定位 PC=0 时应当先排除这一条**。 + +**④ `sigaction` 是诚实的**(我一度以为它静默吞掉,读源码后是我错了):真实 handler +返回 `-ENOSYS`。⇒ SIGWINCH / SIGINT 不可用,但程序会被告知。 + +**⑤ `getuid` 族返回硬编码 1000。** 一个被虚构的身份。⚠️ 与 ② 合起来,任何做 +"按权限/属主判断"的代码都在一个虚构的世界里运行,而且**全部报成功**。 + +--- + +## 6.9 综合改动清单:改动量、影响面、通用性、风险 + +⚠️ 改动量是**量级估计**,不是实测行数。影响面按"会不会改变现有构建的命令行/产物"分。 + +### mcpp 引擎侧 + +| # | 改动 | 文件 | 量级 | 影响面 | 通用性 | 风险 | 判据 | +|---|---|---|---|---|---|---|---| +| **A2** | 编译侧读 `targetSide`,`--no-default-config` 无条件化 | `hostflags.cppm` + `flags.cppm` + 3 处调用方 | ~40 行 | ⚠️ **改变每一个带 `--target` 的构建的编译线** | ⭐⭐⭐ 与 openkal 无关 | ⚠️ **中** —— 三个调用方必须同批给值,否则 `std.pcm` 与导入者头文件不一致 | e2e 295 扩到 `cxxflags`(恒等式) | +| **A1** | 目标侧使用要求全图化 + `:7207` 收敛 | `prepare.cppm` | ~60 行 | 只影响 graph 供给目标侧的构建 | ⭐⭐⭐ | 低 —— 追加+去重,现有边不动 | §2.2 的 20 行工程 | +| **A3** | home 发现收敛到 `mcpp::home::root()` | `xlings.cppm` | ~15 行 | ⚠️ 可能让某些机器上 `linux-headers` 从"找到别人的"变"找不到" | ⭐⭐⭐ 密闭性 | 低(失败会说话) | §7 ① | +| **B** | `targetHeaderSet` 轴 | `cache_key.cppm` + `prepare.cppm` | ~50 行 | **全部缓存条目 miss 一次**(不失效) | ⭐⭐⭐ | 低 —— 不 bump epoch,`inputs_match` 天然拒旧 | `test_cache_key.cpp` | +| **C** | `[build] private_include_dirs` | `types/toml/prepare` | ~80 行 | 新键,老 mcpp 的行为待测 | ⭐⭐ | ⚠️ **中** —— 见 §4.2 的 floor 问题 | 单测 + e2e | +| **G4c** | 目标侧解析前移到依赖 build.mcpp 之前 | `prepare.cppm` | ~30 行(移动) | 依赖 build.mcpp 的环境多几个变量 | ⭐⭐⭐ | ⚠️ **中高** —— `tc` 在 `:5218–:6713` 间被读写 39 处,前移要逐字段核 | 未定 | + +**引擎侧合计**:6 处、~275 行、4 个文件为主。⭐ **其中 A1/A2/A3/B 四条与 openkal +无关**,是任何交叉/graph 目标都吃的正确性问题。 + +### openkal 生态侧 + +| # | 改动 | 仓库 | 量级 | 影响面 | 通用性 | 风险 | +|---|---|---|---|---|---|---| +| **⓪** | 定位并修 PC=0 | musl 或 linux | 未知 | 全部程序 | ⭐⭐⭐ | 先定位,**不猜** | +| **①a** | `copy_file_range`/`sendfile` | musl port | ~40 行 | `std::filesystem::copy_file` | ⭐⭐⭐ | 低 | +| **①b** | 替换 `popen.c`(临时文件流) | musl port | ~120 行 | shell out | ⭐⭐ | ⚠️ 语义差三处,必须写进 README | +| **①c** | 进程内 `pipe2`(ring + task_wait) | musl port | ~150 行 | 自管道/线程唤醒 | ⭐⭐ | ⚠️ **与 spawn 的会合是本轮唯一要设计的地方** | +| **①d** | `last_write_time(dir)` 走 `"."` | musl port | ~20 行 | `std::filesystem` | ⭐⭐⭐ | 低 | +| **②** | 去掉 `umask` 的静默 | musl port | ~10 行 | 权限语义 | ⭐⭐⭐ | 低 —— 最小是删掉 `g_umask`,让它也 ENOSYS | +| **C'** | 用 `private_include_dirs` 收掉 3 个内部目录 | musl | ~5 行 | 所有消费者 | ⭐⭐ | 依赖引擎 C | +| **③** | clause 11 增补:net / pipe / 权限 / symlink / 终端 / 就绪 | openkal SPEC | 文档 | — | ⭐⭐⭐ | 零 | +| **④** | POSIX 验收套件 | musl + CI | 大 | — | ⭐⭐⭐ | 零(只增读数) | + +⭐ **注意:生态侧没有一条是「加 openkal 接口」。** 按 §6.6 的四问过完之后, +27 条里除 socket 外全部落在端口层。 + +### 架构与稳定性评估 + +| 维度 | 评估 | +|---|---| +| **架构方向** | ⭐ 全部改动都在**收敛**方向:A1/A2 把"同一个决定的第二处推导"删掉,A3 把 home 的第四份拷贝删掉,C 让包能说出它一直想说的话。**没有一条是加机制。** | +| **对既有生态的冲击** | A2 最大(改每个 `--target` 构建的编译线)。⚠️ 七个 openkal 仓库必须在 `MCPP_SOURCE_REF` 下现场构建验一遍 | +| **可回退性** | A1/A3/B/C 都是加法或收敛,回退=revert。⚠️ **A2 不是** —— 它会让一些今天"靠 cfg 文件恰好能编"的构建变成显式,**这些构建在 A2 之后可能需要真的声明它们的依赖** | +| **稳定性净收益** | ⭐⭐ B 消除的是**静默错产物**(混合 BMI 让 clang 前端 SIGSEGV);② 消除的是**静默错权限**。这两条的当前状态都是"报成功而结果是错的",属于最坏的一类 | +| **通用性** | 引擎六条里五条与 openkal 无关;生态九条里七条与本报告者的具体应用无关 | + +### 能不能解决报告者的问题,以及怎么解决 + +| 他的 27 条失败 | 修完之后 | 靠哪一条 | +|---|---|---| +| `fork` | ❌ 仍然不行 | **应用改用 `posix_spawn`**;`system()` 今天就能用 | +| 子进程输出捕获 | ✅ | ①b | +| `pipe2`(自管道) | ✅ | ①c | +| socket / 本地 HTTP 回调服务 | ❌ 仍然不行 | 需要 `openkal.net` —— **明确列为不解决** | +| 事件循环(poll) | ❌ 仍然不行 | §6.8 ①,**新候选接口** | +| `chmod` / 权限 | ⚠️ 从"静默错"变成"明确失败" | ② | +| `symlink` | ⚠️ 保持 ENOSYS,但被文档化 | ③ | +| `copy_file` | ✅ | ①a | +| `last_write_time(dir)` | ✅ | ①d | +| 终端 UI 崩溃 | ⚠️ **取决于 ⓪ 的定位结果** | ⓪(+ §6.8 ③) | +| `std::random_device` | ✅ **已解决** | 0.1.3 | + +⭐⭐ **结论:走 openkal 路线,他的应用仍然跑不起来 —— 因为 socket 与 poll 两条不在 +可修范围内,而它们是那个应用的核心(本地回调服务 + 终端事件循环)。** + +⇒ **能解决他问题的只有两条路,而且都不是"修缺陷":** + +1. **openkal 增 `net` + 就绪原语两个接口** —— 按 §6.6 的四问,`net` 通过但需要 + 第二个消费者定形;就绪原语是**本文新提的候选**,未测其通用性。 +2. **§1.5.2 那张矩阵的反对角线** —— 给 llvm 一条 payload musl 路线。他的应用不改一行。 + +⚠️ **两条都不该由这一个应用的需求来推动决定。** 而本文列的 15 条改动,无论选哪条路 +都要做 —— 它们修的是**引擎的正确性**与**端口层已经能表达却没表达的东西**, +与路线选择无关。 + +--- + +## 6.10 结论:三侧各做什么 + +### 6.10.0 判据的最终形态 + +三条判据在讨论中依次替换,**最后一条是规范自带的**,前两条只作为它的快速筛: + +| | 判据 | 地位 | +|---|---|---| +| 四问 | 「是不是通用内核能力」 | ⚠️ 要人判断,**判错过两处**(漏掉就绪与终端;把 net 判成"等第二个消费者") | +| 传统体系已可移植 | 「三个平台上可移植程序已经在用的,openkal 必须有」 | ⭐ 好用的筛,但**会把 `poll` 误判进来**(可移植程序用它是因为 POSIX 给了,不是因为不可约) | +| **clause 10 基数 + 原子性** | 「一个程序里几个实现?」+「能不能由已命名的东西组合出来?」 | ⭐⭐ **规范自带,且两处冲突都由它裁决** | + +加上一条形状约束,它决定接口**长什么样**而不只是**有没有**: + +> ⭐⭐ **openkal 的接口是面向内核的通用原子能力,不与任何一个内核的形状绑定。 +> 组合由上层做。** + +这条不是新增的原则 —— clause 3.4 已经在用它:*"The stream is therefore the shared +currency of the specification and **not its common entrance**"*,以及它拒绝那个 +「把名字映射到资源」的统一接口时给的三条理由。 + +### 6.10.1 openkal(spec)—— 三个接口,而且都要按原子性重新定形 + +⚠️ **每一条的形状都不是它在 POSIX 里的形状。** 这是本节的要点。 + +#### ① `openkal.net`(大)—— 与 `openkal.fs` 同构,但**不是 BSD socket** + +规范里的依据全部现成: +- clause 10:NIC(openhal,多个)→ 网络栈(openkal,一个),与 块设备 → `openkal.fs` **同构** +- clause 3.4:已经点名 `openkal.net`,并指出它与 fs 的差别正是 **half-closure** +- clause 3.4:拒绝解析无边界的名字方案(⇒ **DNS 不在里面**) + +⇒ 原子面大约是**六个操作**,而不是 BSD 的三十个: + +``` +kal_net_connect(endpoint) → stream 建立到一个端点的连接 +kal_net_listen(endpoint) → listener 接受入站 +kal_net_accept(listener) → stream +kal_net_shutdown(stream, dir) 半关闭 —— clause 3.4 点名的那件事 +kal_net_close_listener(listener) +kal_net_props 能力字 +``` + +**不在里面**(每一条都是 POSIX 的形状而不是内核的原子能力): +地址族与 `sockaddr` 家族、`setsockopt` 的选项空间、非阻塞标志(就绪归端口层)、 +`sendmsg/recvmsg`、**名字解析**。 +⇒ 端点是**结构化的地址+端口**,不是字符串;`getaddrinfo` 是端点之上的库。 + +⚠️ **两处未决,必须在动笔前定,不能边写边定:** +1. **数据报要不要?** 流与数据报是两种东西(clause 6.4 的形状)。若 v1 只做流, + 则 DNS 只能走 TCP —— 这是一个真实的连锁后果,不是细节。 +2. **谁来定形?** clause 8 规定接口一旦发布不可更改。⭐ 按 + [[second-instance-exposes-the-interface]],**需要两个形态不同的实现** + (一个宿主内核 + 一个 BSP over openhal)才知道分解对不对。 + +#### ② `openkal.terminal`(小)—— 按 clause 6.4 独立成接口 + +「raw 模式」**在不同资源上表现不同**(终端 vs 普通文件),这正是 clause 6.4 裁定 +`seek` 属于 `openkal.fs` 而非 `openkal.stream` 的那个形状。⇒ 独立接口,资源是一个 +交互式流,入口是已有的 `KAL_STREAM_PROP_INTERACTIVE`。 + +原子面约 **2 操作 + 1 询问**:「不要解释我的输入」(raw)、「不要回显」、「显示多大」。 +**不是 `termios`** —— 那是一个带六十个标志位的 POSIX 结构。 + +裸机上:没有行编辑 ⇒ 关掉它是**无事可做即完成**(`okm_opt.h` 已有这类先例); +尺寸报「不知道」。 + +#### ③ `openkal.process` 的一次加法(小)—— 跨 spawn 边界的通道 + +`kal_spawn_streams` 已经收流句柄,缺的是**取得一个跨得过 spawn 边界的流**。 + +``` +kal_process_channel(void) → (parent_stream, child_stream) +``` + +⭐ **比「给我子进程的 stdout」更原子**:调用方自己决定把 `child_stream` 装到子进程 +的哪一个流上,于是 `popen("r")`、`popen("w")`、双向捕获都由上层组合出来 —— 正是 +「上层自己组合」。 + +⚠️ **进程内的**流对不属于这里(可由缓冲 + `kal_task_wait/wake` 导出 ⇒ 是库)。 +这条的存在理由**只有**「跨地址空间」。 + +#### ④ 不进 openkal 的,以及为什么(记录下来,免得再讨论一次) + +| | 为什么不进 | +|---|---| +| `fork` | clause 7.1;且 Windows 没有 ⇒ 用它的程序本来就不可移植 | +| `pipe`(进程内) | 可由 `kal_task_wait/wake` + 缓冲导出 ⇒ 库 | +| **就绪 `poll`/`select`** | ⭐ 同上,可由 task+stream 导出。**「传统体系已可移植」那条判据在这里判错了** | +| `chmod` / mode 位 | 预设身份模型;Windows 只有只读位 ⇒ 不通用 | +| `symlink` | 文件系统**格式**的属性;`std::filesystem` 本来就设计成允许失败 | +| DNS / `getaddrinfo` | clause 3.4 明确拒绝「解析无边界的名字方案」⇒ 端点之上的库 | + +#### ⑤ 文档(零代码) + +clause 11「本版不解决」增补:网络、就绪、终端、权限、符号链接、进程复制 —— 六条, +每条说明是**边界**(fork/权限/symlink)还是**未决**(net/terminal/channel)。 + +### 6.10.2 openkal 生态(实现层) + +| # | 内容 | 仓库 | 量级 | +|---|---|---|---| +| ⓪ | **定位并修 PC=0** | musl / linux | 未知,**先定位** | +| ① | `copy_file_range`(**只需这一个**,见下) | musl port | ~40 行 | +| ② | 替换 `popen.c`(临时文件流;`channel` 落地后改用它) | musl port | ~120 行 | +| ③ | `last_write_time(dir)` 走 clause 7.12 的 `"."` | musl port | ~20 行 | +| ④ | **`umask` 去静默** —— `g_umask` 只被自己读写,删掉它让它也 ENOSYS | musl port | ~10 行 | +| ⑤ | ⭐ **「永远没有」变链接错误**:排除 `net/`、`fork.c`、`pipe*.c` 等 | musl **manifest** | 编辑 + 一轮闭包实测 | +| ⑥ | **就绪 `poll`/`select`**(含预读缓冲)—— 端口层设计,不动 spec | musl port | 大,**需设计** | +| ⑦ | 用 `private_include_dirs` 收掉三个 musl 内部目录 | musl | ~5 行,等 mcpp | +| ⑧ | ⭐ **POSIX 验收套件**(每 backend × 每 arch,真跑) | musl + CI | 大,**回报最高** | +| ⑨ | `__config_site` 与实际能力**对账** | llvm-runtime | 见下 | + +**关于 ①**:libc++ 的 `copy_file` 对 `copy_file_range` 的回落名单**含 ENOSYS** +(`operations.cpp:314`),对 `sendfile` 的**只认 EINVAL** ⇒ 实现 +`copy_file_range` 一个就够,两个都做是多余的。 + +**关于 ⑨**:`_LIBCPP_HAS_TERMINAL 1` 与 `_LIBCPP_HAS_FILESYSTEM 1` 今天与实际不符 +(§6.7 表 C)。⭐ **在 `openkal.terminal` 落地前,`HAS_TERMINAL` 应当是 0** —— +声称有然后运行期失败,比声称没有更糟。 + +### 6.10.3 mcpp(引擎) + +与 openkal 的路线选择**完全无关**,无论 openkal 怎么长都要做: + +| # | 内容 | 量级 | 关键风险 | +|---|---|---|---| +| **A2** | 编译侧读 `targetSide`;`--no-default-config` 无条件化 | ~40 行 | ⚠️ 改每个 `--target` 构建的编译线;三个调用方同批 | +| **A1** | 目标侧使用要求全图化;`:7207` 收敛为它的实例 | ~60 行 | 低 | +| **A3** | home 发现收敛到 `mcpp::home::root()`;删 `~/.xlings` 回落 | ~15 行 | 低 | +| **B** | 缓存键增 `targetHeaderSet` 轴 | ~50 行 | 低,不 bump epoch | +| **C** | `[build] private_include_dirs` | ~80 行 | ⚠️ 新键的 floor 待测 | +| **G4c** | 目标侧解析前移到依赖 build.mcpp 之前 | ~30 行 | ⚠️ `tc` 39 处读写,逐字段核 | +| **D**(新) | ⭐ 目标侧报告增一行:**由图供给的层收窄了上层的面时说出来** | 未定 | 引擎不得硬编码 POSIX 名 ⇒ 由包说 | + +### 6.10.4 应当提示报告者做什么 + +⭐ **给可判定的答复,不是"我们在看"。** 他的 27 条分三类,三类的处置不同: + +**第一类 —— 我们修,他不用改代码(5 条)** +`copy_file` · `last_write_time(dir)` · 子进程输出捕获 · `umask` 语义 · `random_device`(**0.1.3 已修**,他测的是 0.1.1) + +**第二类 —— 他要改,而且在传统体系里也该改(3 条)** + +| 他现在写的 | 改成 | 为什么这不是将就 | +|---|---|---| +| `fork` | `posix_spawn` / `std::system` | ⭐ **Windows 没有 `fork`** —— 这份代码在传统体系里也不可移植 | +| `std::filesystem::create_symlink` 假定成功 | 失败则降级 | Windows 上没有权限时同样失败;标准把它设计成可失败 | +| `permissions()` 假定成功 | 同上 | Windows 只有只读位 | + +**第三类 —— 今天没有绕法,要等接口(2 条)** +终端 UI(等 `openkal.terminal` + 端口层就绪)· 本地 HTTP 服务(等 `openkal.net`) + +⇒ **在这两条落地前,他的程序在 openkal 上跑不完整。这不是缺陷,是范围。** +他有三个知情选择,而**今天他一个都看不见**: + +1. 等第三类接口(时间由 spec 定形决定,不由缺陷修复决定) +2. 把这两处换成 openkal 面上能表达的(不现实) +3. 走 §1.5.2 矩阵的另一格(payload musl),放弃跨平台换取立即可用 + +⭐ **让这三个选择可见,是 6.10.2 ⑤ 那一条(链接错误)零成本做到的** —— +`tinyhttps` 会在链接期被指名,而不是跑到第 27 个测试。 + +**他能贡献的两样,他自己已经提过:** +- ⭐ **94 个 POSIX 用例** —— 正是 6.10.2 ⑧ 缺的那一层的现成种子 +- 双架构 musl libc++ 制品 —— 若走 §1.5.2 那一格,这是缺的那一块 + +--- + +## 7. 定案用的四条命令 + +三条待测项,每条给出**能给出否定读数**的形态。 + +**① §2.4 的 fast-path 重放假说**(未测): + +```sh +# 同一个工程,两个 home,只看它是否重新 prepare +MCPP_HOME=$A mcpp build --target x86_64-linux-musl +grep -c "$A" target/*/*/build.ninja # 期望 >0 +MCPP_HOME=$B MCPP_VERBOSE=1 mcpp build --target x86_64-linux-musl 2>&1 | grep "scanning module sources" +grep -c "$A" target/*/*/build.ninja # 若仍 >0 且上一行为空 ⇒ 重放了 $A 的图 +``` + +⚠️ 判据是**两条同时成立**:没有 `scanning module sources`(说明没重新 prepare) +**且** `build.ninja` 里仍是 `$A` 的路径。只看其中一条,两种不同的机制会同读数。 + +**② §3.2 方向 2**(dep 缓存跨目标复用): + +```sh +mcpp build --target x86_64-linux-musl --cache global +mcpp build --cache global +ls -d ~/.mcpp/build-cache/v1/pkg//@/*/ | wc -l # 期望 2;若为 1 ⇒ 报告成立 +python3 -c "import json,glob;[print(f, json.load(open(f))['inputs']['toolchain']['target_triple']) for f in glob.glob('...entry.json')]" +``` + +⚠️ 判据是**条目目录数 + 每个条目里的 `target_triple`**,不是 CLI 的 `Cached` 行。 + +**③ ⓪ NULL 跳转**: + +```sh +gdb --args ./prog +(gdb) run +(gdb) info registers rip rsp +(gdb) x/8gx $rsp # 返回地址在这里,bt 空的时候它不空 +(gdb) info symbol <上面读到的地址> +readelf -x .init_array ./prog | grep -c ' 00000000 00000000' +``` + +**④ A-2 的恒等式**(应当加进 `e2e 295`): + +```sh +diff <(tr ' ' '\n' host_base_flags(const mcpp::toolchain::Toolchain& tc, // undefined __cxa_* / __gxx_personality_v0. opt.cfgBypass = mcpp::toolchain::HostFlagOptions::CfgBypass::LinuxOnly; opt.clangStdlibSelect = true; + // ⭐ `cAbiPrebuilt` is left at its default (true), and that is a statement + // rather than an omission: `tc` here is always HOST-targeting (see this + // function's header), so the helper's C library is the payload's whatever + // the project's target side turns out to be. + // + // ⚠️ It also corrects a latent defect. The predicate this replaced was + // `!tc.crossTargetFlag.empty()`, and a host toolchain resolved for a cross + // build could carry one — in which case the helper lost the payload's own + // headers for a reason that had nothing to do with it. // binutils -B so the driver finds ld/as (GCC; musl and MinGW ship their own). opt.binutilsPrefix = !mcpp::toolchain::is_musl_target(tc) && !mcpp::toolchain::is_mingw_target(tc); diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 8de9119f..34430971 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -56,6 +56,7 @@ import mcpp.manifest; import mcpp.modgraph.scanner; import mcpp.toolchain.detect; import mcpp.toolchain.fingerprint; +import mcpp.toolchain.linkmodel; import mcpp.toolchain.triple; import mcpp.freestanding.target; // the flags a bare-metal triple implies @@ -97,6 +98,36 @@ struct BuildAxes { // Measured on exactly that upgrade. Empty for hosted targets, so nothing // else's key moves. std::vector targetImpliedFlags; + // ⭐⭐ THE HEADER SET THE DRIVER IS POINTED AT — not the driver. + // + // Everything else on this axis describes the COMPILER. Nothing described + // the LIBRARY it compiles against, and the two are separately installed: + // one clang payload sits above whichever `xim:glibc` and + // `xim:linux-headers` the home happens to carry. + // + // ⚠️ AND `driverIdentity` CANNOT COVER IT, BY DESIGN. + // `normalize_driver_output` deliberately strips `/home/`, `/tmp/` and + // `/var/` paths out of `clang --version`, which is what lets one entry be + // shared between two homes. Correct — and it means two homes carrying the + // same clang above DIFFERENT glibc payloads produce the same + // `driver_identity`, the same key, and one entry serving two header sets. + // + // Measured (mcpp#514 §B, third direction): two host builds resolving glibc + // 2.39 and 2.44 hit one entry, and the mixed BMIs then crash the clang + // frontend outright — SIGSEGV in + // `ASTReader::FindExternalVisibleDeclsByName` during deserialization — + // rather than producing a readable diagnostic. BMIs carry no cross-check + // against each other. + // + // ⭐ THE `std` CACHE ON THE SAME MACHINE HAS ALWAYS HAD THIS. Its identity + // folds in `std_build_commands`, the whole command line, `-isystem` rows + // included. Two caches, one machine, two notions of "the same inputs"; the + // dependency one was the short one. + // + // Store-relative, for the same reason the include dirs below are: the key + // must survive a different MCPP_HOME, and what matters is WHICH payloads, + // not where the home is. + std::vector targetHeaderSet; std::string stdlibId; std::string stdlibVersion; // B @@ -163,7 +194,8 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, const mcpp::manifest::Manifest& rootManifest, std::string_view cppStandardFlag, const std::vector& dialectFlags, - std::string_view macosDeploymentTarget); + std::string_view macosDeploymentTarget, + const std::filesystem::path& storeRoot = {}); // Axes E from one PackageRoot. `storeRoot` is stripped off absolute include // dirs so the key survives a different MCPP_HOME (the payload paths are @@ -217,6 +249,7 @@ nlohmann::json to_json(const BuildAxes& b, const PackageAxes& p) { {"driver_identity", b.driverIdentity}, {"target_triple", b.targetTriple}, {"target_implied_flags", b.targetImpliedFlags}, + {"target_header_set", b.targetHeaderSet}, {"stdlib", b.stdlibId}, {"stdlib_version", b.stdlibVersion}, }; @@ -264,6 +297,7 @@ std::string key_hex(const BuildAxes& b, const PackageAxes& p) { put(s, "driver", b.driverIdentity); put(s, "triple", b.targetTriple); put_list(s, "targetflags", b.targetImpliedFlags); + put_list(s, "hdrset", b.targetHeaderSet); put(s, "stdlib", b.stdlibId); put(s, "stdlibv", b.stdlibVersion); // B @@ -302,7 +336,8 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, const mcpp::manifest::Manifest& rootManifest, std::string_view cppStandardFlag, const std::vector& dialectFlags, - std::string_view macosDeploymentTarget) + std::string_view macosDeploymentTarget, + const std::filesystem::path& storeRoot) { BuildAxes b; b.compilerId = std::string(tc.compiler_name()); @@ -349,6 +384,36 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, b.stdlibId = tc.stdlibId; b.stdlibVersion = tc.stdlibVersion; + // ⭐ THE HEADER SET, TAKEN FROM THE RESOLVERS THAT PRODUCE THE COMMAND + // LINE — not derived a second time. + // + // `mcpp.toolchain.linkmodel` is already the single answer to "where does + // this toolchain's C library and libc++ come from"; every consumer of a + // compile line reads it. Asking it here means the key describes the header + // set that will actually be used, and cannot drift from it. + // + // ⚠️ Store-relative. The absolute form names this machine's home, and the + // whole point of `normalize_driver_output` stripping paths is that one + // entry can serve two homes carrying the same payloads. Leaving these + // absolute would undo that; folding them in relative distinguishes + // DIFFERENT payloads while still sharing between homes. + { + const auto storeStr = storeRoot.generic_string(); + const mcpp::toolchain::PathEscape relativize = + [&](const std::filesystem::path& p) -> std::string { + auto str = p.generic_string(); + if (!storeStr.empty() && str.starts_with(storeStr)) + return "" + str.substr(storeStr.size()); + return str; + }; + for (auto& t : mcpp::toolchain::resolve_clang_driver(tc) + .compile_tokens(relativize)) + b.targetHeaderSet.push_back(t); + for (auto& t : mcpp::toolchain::resolve_link_model(tc) + .compile_tokens(relativize)) + b.targetHeaderSet.push_back(t); + } + b.cppStandard = rootManifest.package.standard; b.cppStandardFlag = std::string(cppStandardFlag); b.dialectFlags = dialectFlags; diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 53921d38..f0651a98 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -593,6 +593,11 @@ CompileFlags compute_flags(const BuildPlan& plan) { mcpp::toolchain::HostFlagOptions hopt; hopt.cfgBypass = mcpp::toolchain::HostFlagOptions::CfgBypass::Always; hopt.macosDeploymentTarget = macosDeploymentTarget; + // ⭐ THE SAME EXPRESSION THE LINK SIDE ASKS, twenty lines further down + // (`plan.targetSide.cAbi.prebuilt()`). Reading one value at both sites + // is what makes it impossible for them to disagree — which they did, + // from #511 until now, because only the link side was corrected. + hopt.cAbiPrebuilt = plan.targetSide.cAbi.prebuilt(); compile_toolchain_flags = mcpp::toolchain::render_tokens( mcpp::toolchain::host_compile_tokens(plan.toolchain, hopt, ninjaEsc)); } else { diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index b7e60d9c..12091de1 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -4146,6 +4146,28 @@ prepare_build(bool print_fingerprint, return dirs; }; + // The same expansion for `private_include_dirs`, so a private entry may be + // a glob and still name exactly the directories it expands to. + auto expandPrivateIncludeDirs = + [&](const std::filesystem::path& packageRoot, + const mcpp::manifest::Manifest& manifest) + { + std::vector dirs; + for (auto const& inc : manifest.buildConfig.privateIncludeDirs) { + if (inc.is_absolute()) { + auto n = inc; + n.make_preferred(); + appendUniquePath(dirs, std::move(n)); + continue; + } + for (auto& dir : mcpp::modgraph::expand_dir_glob( + packageRoot, inc.generic_string())) { + appendUniquePath(dirs, dir); + } + } + return dirs; + }; + auto makePackageRoot = [&](const std::filesystem::path& packageRoot, const mcpp::manifest::Manifest& manifest) @@ -4159,7 +4181,53 @@ prepare_build(bool print_fingerprint, pkg.privateBuild.includeDirsAfter = expandIncludeDirsAfter(packageRoot, manifest); pkg.privateBuild.cflags = manifest.buildConfig.cflags; pkg.privateBuild.cxxflags = manifest.buildConfig.cxxflags; - pkg.publicUsage.includeDirs = pkg.privateBuild.includeDirs; + // ⭐⭐ NOT `= privateBuild` ANY MORE — a package may now say which of + // its include directories stop at its own boundary. + // + // This line took the whole set for as long as the two were the same + // set, which they are for almost every package. The one shape where + // they are not is a package that vendors a library with an internal + // header overlay: musl's `src/include` adds `hidden`, `weak` and + // `weak_alias` for musl's own sources, and publishing it hands those + // names to every consumer. See BuildInputs::privateIncludeDirs. + // + // ⚠️ THE FILTER IS APPLIED AFTER GLOB EXPANSION, so a private entry may + // itself be a glob and still name exactly the directories it expands + // to. Comparing the unexpanded spellings would let `musl/src/*` be + // published because it is not literally equal to `musl/src/include`. + { + const auto privateExpanded = + expandPrivateIncludeDirs(packageRoot, manifest); + for (auto const& d : pkg.privateBuild.includeDirs) + if (std::ranges::find(privateExpanded, d) == privateExpanded.end()) + pkg.publicUsage.includeDirs.push_back(d); + + // ⚠️ AN ENTRY THAT WITHHOLDS NOTHING IS REPORTED, because the way + // it fails is the very defect this key exists to prevent: a + // directory the author believes is private stays published, and + // nothing about the build looks different until a consumer trips + // over a name months later. + // + // A WARNING AND NOT AN ERROR, for consistency with `include_dirs` + // itself: that key silently ignores a glob matching nothing, and a + // conditional manifest can legitimately name a directory that + // exists on one platform only. Refusing here would be stricter + // than the list this one filters. + for (auto const& want : privateExpanded) { + if (std::ranges::find(pkg.privateBuild.includeDirs, want) + != pkg.privateBuild.includeDirs.end()) + continue; + mcpp::diag::warning("manifest", std::format( + "package '{}': `private_include_dirs` names '{}', which is " + "not among this package's `include_dirs`.\n" + " It withholds nothing — `private_include_dirs` says " + "which entries OF `include_dirs`\n" + " stop at this package's boundary, and an entry that " + "is not one of them is published\n" + " exactly as before.", + manifest.package.name, want.generic_string())); + } + } pkg.publicUsage.includeDirsAfter = pkg.privateBuild.includeDirsAfter; pkg.linkUsage.ldflags = manifest.buildConfig.ldflags; return pkg; @@ -6453,6 +6521,17 @@ prepare_build(bool print_fingerprint, mcpp::targetside::TargetSide resolvedTargetSide; + // What the packages supplying the target side's layers publish: the header + // directories and interface flags the whole build is compiled against. + // + // ⭐ ONE SET, TWO READERS, and that is deliberate: it is merged into every + // package's `privateBuild` (so every compile edge sees it) and handed to + // the `std` module's own command line (which is one more translation unit + // of the same build). Before this existed, only the second reader was + // written, and it derived the set itself — which is how the two could + // describe different worlds. + mcpp::modgraph::UsageRequirements targetSideUsage; + // ── THE TARGET SIDE, RESOLVED ONCE ─────────────────────────────────────── // // HERE AND NOT EARLIER, AND THAT IS THE WHOLE POINT. @@ -6494,7 +6573,13 @@ prepare_build(bool print_fingerprint, // keeping the first acceptable one, two suppliers resolved by graph // traversal order — an order the author neither writes nor can predict — // and the loser's `[build]` section still reached the command line. - struct Candidate { tsd::Provider p; bool direct; }; + // ⭐ `index` — WHICH PACKAGE this candidate is, not just its name. + // + // Needed once resolution is done: a layer supplied from the graph + // publishes an include set the WHOLE build must see (see + // `targetSideUsage` below), and reaching that package by name would be + // a second lookup of something already in hand. + struct Candidate { tsd::Provider p; bool direct; std::size_t index = 0; }; std::map> byLayer; std::vector requirements; @@ -6511,7 +6596,8 @@ prepare_build(bool print_fingerprint, return false; }; - for (auto const& pkg : packages) { + for (std::size_t pkgIndex = 0; pkgIndex < packages.size(); ++pkgIndex) { + auto const& pkg = packages[pkgIndex]; const auto pkgId = pkg.manifest.package.version.empty() ? pkg.manifest.package.name : std::format("{}@{}", pkg.manifest.package.name, @@ -6576,8 +6662,9 @@ prepare_build(bool print_fingerprint, if (same != slot.end()) { if (same->p.interfaceName.empty() && !p.interfaceName.empty()) same->p = p; + same->index = pkgIndex; } else { - slot.push_back({ p, is_direct(p.name) }); + slot.push_back({ p, is_direct(p.name), pkgIndex }); } } @@ -6711,6 +6798,114 @@ prepare_build(bool print_fingerprint, } resolvedTargetSide = tsd::resolve(in); + + // ⭐⭐ RECORDED ON THE TOOLCHAIN THE MOMENT IT IS KNOWN, because three + // producers of a compile line need it and only one of them can see + // `resolvedTargetSide`. + // + // `flags.cppm` reads `plan.targetSide` directly; the std module build + // (`mcpp.toolchain.stdmod`) and the build.mcpp host helper cannot — + // they are in the toolchain layer and take a `Toolchain`. Giving them a + // second way to derive the answer is exactly the shape this release + // exists to remove, so the answer travels on the value they already + // share. + // + // ⚠️ HERE AND NOT LATER: `ensure_built` runs at :7368 and every compile + // line is assembled after it. A std BMI built against a different C + // library than its importers is what e2e 181 catches. + if (tc) tc->cAbiPrebuilt = resolvedTargetSide.cAbi.prebuilt(); + + // ── The target side's include set is a property of the BUILD ───────── + // + // ⭐⭐ IT WAS ALREADY COMPUTED, AND IT REACHED EXACTLY ONE TRANSLATION + // UNIT. + // + // A package that supplies a target-side layer publishes the headers the + // whole target is built against — libc++'s, the C library's, the + // architecture's. Those travel today as an ordinary `publicUsage`, + // which propagates ALONG DEPENDENCY EDGES. So a workspace member that + // depends on the provider receives them and a SIBLING DEPENDENCY + // PACKAGE does not: `nlohmann.json` is not downstream of + // `openkal-llvm-runtime`, it is beside it. + // + // The result is two flavours of BMI in one build — `std` compiled over + // the target's libc++ (correct: the block at :7232 hands it exactly + // this set) and the dependency packages compiled over the payload's. + // Any unit importing both fails at the first template instantiation + // that touches a declaration present in both header sets: + // + // istream:1245: error: reference to 'space' is ambiguous + // note: candidate … xim-x-llvm/…/__locale:321 + // note: candidate … openkal-llvm-runtime/…/__locale:302 + // + // mcpp#514. Reproduced in twenty lines with no openkal at all: a path + // package declaring `provides = ["mcpp:c++-abi=libc++"]` and one + // `include_dirs` entry reaches the root and its own units, and reaches + // no sibling dependency package. + // + // ⭐ THE FIX IS THE ONE `mcpp.targetside` OPENS WITH: resolve once, + // after the graph is known, and have every consumer read that one + // value. A `publicUsage` describes what a library asks of ITS USERS; a + // target side is beneath everything. Modelling the second as the first + // is what made it edge-scoped. + // + // ⚠️ ONLY LAYERS THE GRAPH SUPPLIES. `Layer::fromGraph()` is the whole + // condition. A payload-supplied layer already reaches every unit + // through `mcpp.toolchain.hostflags`, and emitting it twice would put + // the ordering of one decision in two places. + { + std::set layerProviderIndices; + auto note_layer = [&](tsd::CapLayer which, const tsd::Layer& resolved) { + if (!resolved.fromGraph()) return; + auto it = byLayer.find(static_cast(which)); + if (it != byLayer.end() && !it->second.empty()) + layerProviderIndices.insert(it->second.front().index); + }; + note_layer(tsd::CapLayer::CompilerRuntime, resolvedTargetSide.compilerRuntime); + note_layer(tsd::CapLayer::KernelAbi, resolvedTargetSide.kernelAbi); + note_layer(tsd::CapLayer::CAbi, resolvedTargetSide.cAbi); + note_layer(tsd::CapLayer::CxxAbi, resolvedTargetSide.cxx); + + for (auto idx : layerProviderIndices) { + if (idx >= packages.size()) continue; + auto const& provider = packages[idx]; + appendUniquePaths(targetSideUsage.includeDirs, + provider.publicUsage.includeDirs); + appendUniquePaths(targetSideUsage.includeDirsAfter, + provider.publicUsage.includeDirsAfter); + appendUniqueFlags(targetSideUsage.cflags, + provider.publicUsage.cflags); + appendUniqueFlags(targetSideUsage.cxxflags, + provider.publicUsage.cxxflags); + } + + // Into `privateBuild` and NOT into `publicUsage`. + // + // It is visible to the whole graph already, so it needs no further + // propagation; and writing it into `publicUsage` would fold the + // target side into the usage requirements of any library this + // build packages — a promise about a different machine. + // + // ⚠️ APPENDED, so a package's own directories keep coming first. + // The target side only has to precede the DRIVER's own defaults, + // and those are always searched last. + if (!targetSideUsage.includeDirs.empty() + || !targetSideUsage.includeDirsAfter.empty() + || !targetSideUsage.cflags.empty() + || !targetSideUsage.cxxflags.empty()) { + for (auto& p : packages) { + appendUniquePaths(p.privateBuild.includeDirs, + targetSideUsage.includeDirs); + appendUniquePaths(p.privateBuild.includeDirsAfter, + targetSideUsage.includeDirsAfter); + appendUniqueFlags(p.privateBuild.cflags, + targetSideUsage.cflags); + appendUniqueFlags(p.privateBuild.cxxflags, + targetSideUsage.cxxflags); + } + } + } + if (auto why = tsd::check_layering(resolvedTargetSide)) { refusal::record(refusal::Code::LayerOrdering); return std::unexpected(*why); @@ -7204,9 +7399,25 @@ prepare_build(bool print_fingerprint, // The two differ, and the difference is not cosmetic --- a package's own // build path carries directories that exist for its .cpp files and that // shadow the library's headers when a module is compiled against them. - for (auto& d : pkg.publicUsage.includeDirs) + // + // ⭐⭐ AND IT IS `targetSideUsage`, NOT THIS PACKAGE'S `publicUsage`. + // + // The two are the same set whenever one package supplies every layer, + // which is the arrangement this block was written for — so reading the + // package directly was correct and stayed correct until a second + // provider appeared. `openkal-llvm-runtime` supplies the C++ runtime + // while `openkal-musl` supplies the C library, and the std module needs + // both: libc++'s own headers reach ``, which is the C + // library's. + // + // ⭐ Reading the assembled set also makes this site and every compile + // edge read ONE value. Deriving it here a second time is the shape + // #233/#240/#242/#344 each cost a release, and the same set has to + // reach both or the `std` BMI describes a different world than the + // units importing it — which is mcpp#514 exactly. + for (auto& d : targetSideUsage.includeDirs) flags += " -isystem " + mcpp::xlings::shq(d.string()); - for (auto& d : pkg.publicUsage.includeDirsAfter) + for (auto& d : targetSideUsage.includeDirsAfter) flags += " -idirafter " + mcpp::xlings::shq(d.string()); // And the definitions, for the same reason as the directories: a C // library's headers show a different library depending on which feature @@ -7214,7 +7425,7 @@ prepare_build(bool print_fingerprint, // its own translation units see. Measured: without them the module // reaches musl's and stops on `clockid_t', a name that header // declares only under the macro the package carries. - for (auto& f : pkg.publicUsage.cxxflags) + for (auto& f : targetSideUsage.cxxflags) flags += " " + mcpp::xlings::shq(f); tc->stdModuleFlags = flags; break; @@ -8067,7 +8278,11 @@ prepare_build(bool print_fingerprint, *tc, m->cppStandard.experimental, mcpp::manifest::dialect_flags(m->buildConfig)), mcpp::platform::macos::deployment_target( - m->buildConfig.macosDeploymentTarget)); + m->buildConfig.macosDeploymentTarget), + // The GLOBAL registry root — the same one `fill_package_config` + // relativizes against below, so both halves of the key describe + // payload paths the same way. + storeRoots.empty() ? std::filesystem::path{} : storeRoots.front()); // Sources belonging to each package, package-root-relative and sorted. std::vector> pkgSources(packages.size()); diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 64206b97..81a8822f 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -350,6 +350,12 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string_array("build.include_dirs_after")) { for (auto& s : *v) m.buildConfig.includeDirsAfter.emplace_back(s); } + // [build].private_include_dirs — of `include_dirs`, the ones a consumer + // must NOT receive. See BuildInputs::privateIncludeDirs for why it is a + // subset of that list rather than a second ordered list. + if (auto v = doc->get_string_array("build.private_include_dirs")) { + for (auto& s : *v) m.buildConfig.privateIncludeDirs.emplace_back(s); + } // [targets.*] — M5.0: now optional. If absent, defer to auto-inference (in load()). // [profile.] — bundled build settings. @@ -1241,6 +1247,7 @@ std::expected parse_string(std::string_view content, "allow_host_libs", "bmi_schedule", "build_program_timeout", "c_standard", "cache", "cflags", "cxxflags", "cxx_runtime", "default-profile", "defines", "dialect_cxxflags", "flags", "include_dirs", "include_dirs_after", + "private_include_dirs", "jobs", "ldflags", "macos_deployment_target", "module_extensions", "profile", "sources", "static_stdlib", "target", }; diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 02809261..031d35a7 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -192,6 +192,38 @@ struct BuildInputs { std::vector includeDirs; // relative to package root // #249: emitted as -idirafter (searched after the toolchain's system dirs) std::vector includeDirsAfter; + // ⭐⭐ WHICH OF `includeDirs` A CONSUMER MUST NOT RECEIVE. + // + // `publicUsage` has always taken `privateBuild`'s include directories + // ENTIRE, so a package is built from exactly the set it publishes. For + // almost every package those are the same set. For one that vendors a + // library with an internal header overlay they are not, and the difference + // reaches every consumer. + // + // `mcpplibs/openkal-musl` states the case in its own source + // (`port/include/features.h`), having found it three times: + // + // ⓘ THIS IS THE SECOND-BEST REMEDY. The first would be for a package to + // distinguish the directories it is BUILT FROM from the directories it + // PUBLISHES. Measured 2026-08-22: mcpp cannot express it. + // + // musl's build reaches its own declarations through `src/include`, whose + // headers define `hidden`, `weak` and `weak_alias` — names that mean + // something only to musl's own sources. Publishing that directory hands + // those macros to every consumer, and which consumer breaks on which name + // was discovered one at a time: a C++ one on `restrict`, then on linkage; + // a C one (compiler-rt) on `weak`, which it writes itself. + // + // ⚠️ A SUBSET OF `includeDirs`, NOT A SECOND LIST, AND THE REASON IS ORDER. + // The relative order of the two kinds is load-bearing: moving musl's + // internal directories after the public ones makes musl's OWN build find + // the public `` first and fail with `unknown type name hidden` + // (measured, same file). Two arrays in TOML cannot express one order, so + // `includeDirs` stays the single ordered list and this one says which of + // its entries stop at the package boundary. An entry here that is not in + // `includeDirs` withholds nothing, and is reported as such rather than + // passing in silence. + std::vector privateIncludeDirs; // What the `std` module source of a package that IS a standard library // needs on its command line. // @@ -220,6 +252,9 @@ inline void append(BuildInputs& dst, const BuildInputs& src) { dst.includeDirsAfter.insert(dst.includeDirsAfter.end(), src.includeDirsAfter.begin(), src.includeDirsAfter.end()); + dst.privateIncludeDirs.insert(dst.privateIncludeDirs.end(), + src.privateIncludeDirs.begin(), + src.privateIncludeDirs.end()); dst.stdModuleFlags.insert(dst.stdModuleFlags.end(), src.stdModuleFlags.begin(), src.stdModuleFlags.end()); diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index ca70844b..8b6069be 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -228,6 +228,7 @@ namespace mcpp::manifest { inline constexpr std::string_view kKnownXpkgKeys[] = { "cflags", "c_standard", "cxxflags", "defines", "deps", "features", "flags", "generated_files", "import_std", "include_dirs", "include_dirs_after", + "private_include_dirs", "language", "ldflags", "linux", "macosx", "modules", "provides", "requires", "runtime", "scan_overrides", @@ -1258,6 +1259,22 @@ synthesize_from_xpkg_lua(std::string_view luaContent, } cur.consume('}'); } + else if (key == "private_include_dirs") { + // Of `include_dirs`, the entries that stop at the package + // boundary. See BuildInputs::privateIncludeDirs. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `private_include_dirs =`", + m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) m.buildConfig.privateIncludeDirs.emplace_back(s); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else if (key == "include_dirs_after") { // #249: header dirs searched AFTER the toolchain's system dirs // (-idirafter). Use for extracted-tarball roots that contain @@ -1387,6 +1404,7 @@ synthesize_from_xpkg_lua(std::string_view luaContent, // unknown-key policy is a separate question — #263). std::vector* pathDst = sub == "include_dirs" ? &cc.inputs.includeDirs + : sub == "private_include_dirs" ? &cc.inputs.privateIncludeDirs : sub == "include_dirs_after" ? &cc.inputs.includeDirsAfter : nullptr; if (!dst && !pathDst) { diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index e6164a46..fbd83185 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -19,6 +19,7 @@ import mcpp.pm.index_contract; import mcpp.pm.index_snapshot; import mcpp.platform; import mcpp.log; +import mcpp.home; export namespace mcpp::xlings { @@ -834,15 +835,22 @@ find_sibling_tool(const std::filesystem::path& compilerBin, } std::optional active_home_xpkgs() { - std::filesystem::path home; - if (const char* h = std::getenv("MCPP_HOME"); h && *h) { - home = h; - } else if (const char* u = std::getenv("HOME"); u && *u) { - home = std::filesystem::path(u) / ".mcpp"; - } else { - return std::nullopt; - } - auto xpkgs = home / "registry" / "data" / "xpkgs"; + // ⭐⭐ `mcpp::home::root()`, NOT A FOURTH DERIVATION OF IT. + // + // This function used to resolve the home itself — `$MCPP_HOME`, else + // `$HOME/.mcpp` — which is two of the three answers `mcpp.home` gives. The + // one it left out is SELF-CONTAINED MODE: a release tarball or + // `xlings install mcpp` puts the binary at `/bin/mcpp` and the + // unpacked tree IS the home. On such an install this function answered + // `$HOME/.mcpp`, so payload discovery reached a DIFFERENT home than + // everything else in the process — and what payload discovery produces is + // `-isystem` rows on every compile command. + // + // ⚠️ `mcpp.home`'s own header opens with this: "Every path under the mcpp + // home must be derived from here. Before #311 this logic existed in three + // places … the copies drifted." This was the fourth copy, and it drifted + // the same way. + auto xpkgs = mcpp::home::root() / "registry" / "data" / "xpkgs"; std::error_code ec; if (!std::filesystem::exists(xpkgs, ec)) return std::nullopt; return xpkgs; @@ -932,15 +940,28 @@ find_sibling_package(const std::filesystem::path& compilerBin, if (auto found = find_package_in_xpkgs(*xpkgs, packageName, requiredRelPath)) return found; - // Also check ~/.xlings/data/xpkgs/ (xlings global home) as fallback. - std::error_code ec; - const char* home = std::getenv("HOME"); - if (home) { - auto xlingsXpkgs = std::filesystem::path(home) / ".xlings" / "data" / "xpkgs"; - if (xlingsXpkgs != *xpkgs && std::filesystem::exists(xlingsXpkgs, ec)) - return find_package_in_xpkgs(xlingsXpkgs, packageName, requiredRelPath); - } - + // ⚠️⚠️ THE `~/.xlings` FALLBACK IS GONE, AND ITS REMOVAL IS THE POINT. + // + // It used to read: "Also check ~/.xlings/data/xpkgs/ (xlings global home) + // as fallback." That was written when one machine had one home. It means + // that a build whose `MCPP_HOME` names one tree can take a payload out of + // ANOTHER, and what this function's callers do with the result is put it on + // every compile command: + // + // probe.cppm:448 linux-headers → -isystem /…/include + // + // ⇒ a hermetic build reaching outside its own sandbox for headers, with no + // diagnostic, and with the resulting objects looking exactly like objects + // built from the payload that was actually pinned. Reported as the second + // half of mcpp#514, where a project's dependency units carried `-isystem` + // rows naming a home the build was not using. + // + // ⭐ THE DIRECTION IS THE SAFE ONE. Not finding a payload is reported — + // `probe.cppm` already has the verbose branch for it, and a glibc build + // that then fails at `` says so at the first compile. + // Finding the WRONG one says nothing at all, and this codebase has paid + // for that shape before (`e2e-inherit-toolchain-corrupts-real-payloads`, + // `dev-overlay-poisons-a-released-version`). return std::nullopt; } diff --git a/src/toolchain/hostflags.cppm b/src/toolchain/hostflags.cppm index 594c90eb..fabbe241 100644 --- a/src/toolchain/hostflags.cppm +++ b/src/toolchain/hostflags.cppm @@ -76,6 +76,44 @@ struct HostFlagOptions { // Must agree across the std BMI and everything that imports it — clang // rejects a module built for a different deployment target outright. std::string macosDeploymentTarget; + + // ⭐⭐ DOES THE TARGET'S C LIBRARY COME FROM A DIRECTORY THAT EXISTED + // BEFORE DEPENDENCY RESOLUTION? — `plan.targetSide.cAbi.prebuilt()`, READ + // rather than derived. + // + // This function used to ask `!tc.crossTargetFlag.empty()`: is there a + // `--target=` on the command line. Its own comment said what it meant to + // ask — "AND NOT WHEN THE TARGET SIDE COMES FROM THE GRAPH" — and those + // are different questions. A project that names its host's own target + // while depending on nothing answers yes to the first and no to the + // second. + // + // ⚠️ THE LINK SIDE OF THIS DEFECT WAS FIXED IN 2026.8.26.1 (#511) AND THIS + // SIDE WAS NOT. Measured on 2026.8.26.2, same machine, same compiler, same + // target, differing only in whether it was spelled out: + // + // $ mcpp build ldflags: identical ✔ + // $ mcpp build --target x86_64-unknown-linux-gnu cxxflags: SIX tokens gone + // + // --no-default-config -nostdinc++ + // -isystem /include/c++/v1 + // -isystem /include//c++/v1 + // -isystem /include + // -isystem /include + // + // ⇒ headers from one library, objects linked from another. On a machine + // with system headers it compiles against /usr/include and links the + // payload — the silent ABI mix; on one without, it fails naming the + // payload. + // + // ⭐ e2e 295 states the invariant ("naming the host's own target changes + // nothing") and compared only `^ldflags`, so the identity held one line + // above the line where it did not. It now compares both. + // + // Default true = "prebuilt", which is the zero-dependency case and what + // every caller that has no graph (the std module build, the build.mcpp + // host helper) means. + bool cAbiPrebuilt = true; }; // Host-compile flags as argv tokens, in the order the string channels have @@ -196,11 +234,39 @@ std::vector host_compile_tokens(const Toolchain& tc, // // mingw's own header asked for ``, and the payload's libc++ was // still ahead of the sysroot that had just been pointed at the right place. - const bool graphSuppliesTarget = !tc.crossTargetFlag.empty(); + // + // ⭐ READ, NOT DERIVED — see HostFlagOptions::cAbiPrebuilt for the + // measurement that replaced `!tc.crossTargetFlag.empty()` here. This site + // and `flags.cppm`'s link side now ask one question of one value, so they + // cannot disagree. + const bool graphSuppliesTarget = !opt.cAbiPrebuilt; if (bypassCfg && !graphSuppliesTarget) { for (auto& t : dm.compile_tokens(esc, opt.clangStdlibSelect)) out.push_back(t); + } else if (bypassCfg) { + // ⭐⭐ THE BYPASS IS NOT PART OF THE PAYLOAD'S HEADER SET, AND IT WAS + // BEING SUPPRESSED WITH IT. + // + // The payload's `-isystem` rows describe a C library this target does + // not use, so the branch above is right to withhold them. The cfg + // bypass is a different statement: `post_install.cppm` calls that file + // "a per-machine, per-install-path artifact", and reading it makes the + // command line depend on what happened to be installed when the + // payload landed. + // + // ⚠️ Measured on 2026.8.26.2: `mcpp build --target ` + // dropped `--no-default-config`, so clang read `bin/clang++.cfg` and + // the build silently inherited that machine's install. It is also what + // made a hand-written `-clang++.cfg` a working workaround for + // mcpp#514 — a workaround that only exists because this token went + // missing. + // + // Emitted here rather than moved into `compile_tokens`: that vector's + // rendering is part of the std module's cache identity, and reordering + // it would invalidate every user's std BMI for no behavioural gain. + // Nothing that used to be emitted moves; this path emitted nothing. + out.push_back("--no-default-config"); } // Unconditional on macOS, cfg or no cfg. clang refuses to load a module diff --git a/src/toolchain/linkmodel.cppm b/src/toolchain/linkmodel.cppm index 95ccde5b..d3eb7f29 100644 --- a/src/toolchain/linkmodel.cppm +++ b/src/toolchain/linkmodel.cppm @@ -20,6 +20,7 @@ export module mcpp.toolchain.linkmodel; import std; import mcpp.platform; import mcpp.toolchain.model; +import mcpp.toolchain.triple; export namespace mcpp::toolchain { @@ -304,13 +305,57 @@ ClangDriverModel resolve_clang_driver(const Toolchain& tc) { dm.llvmRoot = tc.binaryPath.parent_path().parent_path(); auto libcxxInclude = dm.llvmRoot / "include" / "c++" / "v1"; dm.cxxIncludes.push_back(libcxxInclude); + + // ⭐⭐ THE PAYLOAD'S DIRECTORIES ARE NAMED IN LLVM'S VOCABULARY, AND THIS + // LOOKUP USED mcpp'S. + // + // `include//c++/v1` and `lib/` are written by the LLVM + // build, so they carry LLVM's spelling — `x86_64-unknown-linux-gnu`. + // `tc.targetTriple` is mcpp's — `x86_64-linux-gnu`. The two coincide only + // when the triple was PROBED (`-dumpmachine` answers in LLVM's form), and + // they come apart the moment a target is named: prepare rewrites + // `targetTriple` to mcpp's canonical spelling for a retargetable driver. + // + // ⚠️ AND THE MISS IS SILENT. Both lookups are `if (exists) push_back`, so + // a directory that is not found simply does not appear. What is in the one + // that goes missing is a single file — `__config_site` — which is why the + // failure this produces reads `'__config_site' file not found` from inside + // libc++'s own `__config`, names no decision mcpp made, and has been + // mistaken for a broken payload more than once in this codebase. + // + // Measured 2026-08-27, same machine, same compiler, same target: + // + // mcpp build include/x86_64-unknown-linux-gnu/c++/v1 ✔ + // mcpp build --target x86_64-linux-gnu (absent) + // + // ⭐ BOTH SPELLINGS ARE TRIED, and that is not a heuristic: they are two + // vocabularies for one fact, and which one a given payload used is a + // property of how it was built rather than of anything mcpp decides. The + // LLVM spelling is tried first because it is the one an LLVM payload + // writes. if (!tc.targetTriple.empty()) { - auto targetInclude = dm.llvmRoot / "include" / tc.targetTriple / "c++" / "v1"; - if (std::filesystem::exists(targetInclude)) - dm.cxxIncludes.push_back(targetInclude); - auto targetLib = dm.llvmRoot / "lib" / tc.targetTriple; - if (std::filesystem::exists(targetLib)) - dm.libDirs.push_back(targetLib); + std::vector spellings; + if (auto t = mcpp::toolchain::triple::parse(tc.targetTriple)) { + auto llvmForm = t->llvm_triple(); + if (!llvmForm.empty()) spellings.push_back(std::move(llvmForm)); + } + if (std::ranges::find(spellings, tc.targetTriple) == spellings.end()) + spellings.push_back(tc.targetTriple); + + for (auto const& spelling : spellings) { + auto targetInclude = dm.llvmRoot / "include" / spelling / "c++" / "v1"; + if (std::filesystem::exists(targetInclude)) { + dm.cxxIncludes.push_back(targetInclude); + break; + } + } + for (auto const& spelling : spellings) { + auto targetLib = dm.llvmRoot / "lib" / spelling; + if (std::filesystem::exists(targetLib)) { + dm.libDirs.push_back(targetLib); + break; + } + } } return dm; } diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index 2111db2b..99f59248 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -121,6 +121,29 @@ struct Toolchain { // throw, and wrong when something can. bool targetCxxRuntime = false; + // ⭐⭐ DOES THE TARGET'S C LIBRARY COME FROM A DIRECTORY THAT EXISTED + // BEFORE DEPENDENCY RESOLUTION? — `TargetSide::cAbi.prebuilt()`, recorded + // here so the three producers of a compile line read one value. + // + // ⚠️ THIS IS NOT `targetCxxRuntime` AND THE DIFFERENCE IS THE ONE + // 2026.8.25.1 WAS ABOUT. That field says a package supplies a C++ RUNTIME; + // this one says where the C LIBRARY comes from. A pure C program over + // openkal has no C++ runtime and its C library still comes from the graph, + // and a backend that implements openkal ON TOP OF Linux takes its kernel + // interface from the graph while its C library stays the payload's. The + // two come apart in both directions. + // + // ⚠️ RECORDED, NOT DERIVED. `mcpp.targetside` answers it once, after the + // dependency graph exists; every consumer reads this. The predicate it + // replaced on the compile side was `!crossTargetFlag.empty()` — "is there a + // `--target=` on the command line" — which is true for a project that names + // its host's own target and depends on nothing. See + // `HostFlagOptions::cAbiPrebuilt` for the measurement. + // + // Defaults to true (prebuilt): a build whose graph supplies nothing, and + // every host-targeting toolchain, means exactly that. + bool cAbiPrebuilt = true; + // ⭐⭐ THE `--target=` A RETARGETABLE DRIVER HAS TO BE GIVEN, OR EMPTY. // // Non-empty only when the user asked for a cross AND the resolved compiler diff --git a/src/toolchain/stdmod.cppm b/src/toolchain/stdmod.cppm index 9b9c85ef..c60cfd71 100644 --- a/src/toolchain/stdmod.cppm +++ b/src/toolchain/stdmod.cppm @@ -249,6 +249,12 @@ std::expected ensure_built( HostFlagOptions hopt; hopt.cfgBypass = HostFlagOptions::CfgBypass::Always; hopt.clangStdlibSelect = true; + // ⚠️ THE STD MODULE IS A TRANSLATION UNIT OF THIS BUILD, so it must see the + // same header set every unit importing it sees. Taken from the toolchain + // rather than re-derived: prepare records the resolved answer there once, + // and a std BMI built against a different C library than its importers is + // the failure e2e 181 exists for. + hopt.cAbiPrebuilt = tc.cAbiPrebuilt; std::string sysroot_flag = render_tokens(host_compile_tokens(tc, hopt, shellEsc)); diff --git a/src/version.cppm b/src/version.cppm index 46a2ab27..bc5da918 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.26.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.27.1"; } // namespace mcpp diff --git a/tests/e2e/295_naming_the_host_target_changes_nothing.sh b/tests/e2e/295_naming_the_host_target_changes_nothing.sh index d15bc1f2..34f8c4e4 100755 --- a/tests/e2e/295_naming_the_host_target_changes_nothing.sh +++ b/tests/e2e/295_naming_the_host_target_changes_nothing.sh @@ -59,7 +59,26 @@ implicit_target() { # toolchain spec → the triple it would use anyway | jq -r '.data.triple.toolchain // empty' | tr -d '\r' } -ldflags_of() { # extra args… → the ldflags line, or nothing +# ⚠️⚠️ TWO LINES, AND FOR A LONG TIME THIS TEST READ ONLY ONE. +# +# The identity is about THE BUILD, and a build has a compile line as well as a +# link line. `2026.8.26.1` corrected the link side; the compile side kept +# asking `!crossTarget.empty()` and kept getting it wrong, and this test could +# not see that because it compared `^ldflags` alone. +# +# Measured on 2026.8.26.2 — same machine, same compiler, same target, differing +# only in whether it was spelled out — the compile line lost SIX tokens: +# +# --no-default-config -nostdinc++ +# -isystem /include/c++/v1 +# -isystem /include//c++/v1 +# -isystem /include +# -isystem /include +# +# ⇒ headers from one library and objects linked from another, silently, on any +# machine that happens to have system headers. +line_of() { # channel, extra args… → that line of build.ninja, or nothing + local channel="$1"; shift rm -rf target "$MCPP" build "$@" >/dev/null 2>&1 || true local f; f="$(find target -name build.ninja 2>/dev/null | head -1)" @@ -69,11 +88,21 @@ ldflags_of() { # extra args… → the ldflags line, or nothing # examine, a build that produced no link line, killed the script before it # could say so. It exited 1 with no output at all. if [ -n "$f" ]; then - grep -m1 '^ldflags' "$f" || true + grep -m1 "^$channel" "$f" || true fi return 0 } +# `--target=` is expected on the explicit side and only there — it is the one +# token that names which machine. `-fprebuilt-module-path=` names the build +# directory, which differs because the fingerprint does; that is the mechanism +# working, not a difference in what is compiled. +normalise() { + printf '%s\n' "$1" | tr ' ' '\n' \ + | grep -v '^--target=' | grep -v '^-fprebuilt-module-path=' \ + | grep -v '^$' | sort +} + fail=0 checked=0 @@ -99,8 +128,10 @@ for tc in gcc llvm; do continue fi - implicit="$(ldflags_of)" - explicit="$(ldflags_of --target "$ht")" + implicit="$(line_of ldflags)" + explicit="$(line_of ldflags --target "$ht")" + implicit_cxx="$(line_of cxxflags)" + explicit_cxx="$(line_of cxxflags --target "$ht")" if [ -z "$implicit" ] || [ -z "$explicit" ]; then # ⚠️ EARNED, NOT ASSUMED. One of the two produced no build.ninja at all, @@ -119,20 +150,32 @@ for tc in gcc llvm; do fi checked=$((checked+1)) - # `--target=` itself is expected on the explicit side and only - # there: it is the one token that names which machine, and the identity is - # about everything else. - a="$(printf '%s\n' "$implicit" | tr ' ' '\n' | grep -v '^--target=' | sort)" - b="$(printf '%s\n' "$explicit" | tr ' ' '\n' | grep -v '^--target=' | sort)" - if [ "$a" = "$b" ]; then - echo " ok $tc@$ver: naming $ht changes nothing" - else - echo "FAIL: $tc@$ver: naming $ht changed the link line" - diff <(printf '%s\n' "$a") <(printf '%s\n' "$b") \ - | grep -E '^[<>]' | head -8 | sed 's/^/ /' - fail=1 - fi + for channel in ldflags cxxflags; do + if [ "$channel" = ldflags ]; then + lhs="$implicit"; rhs="$explicit" + else + lhs="$implicit_cxx"; rhs="$explicit_cxx" + fi + # ⚠️ BOTH SIDES MUST HAVE CONTENT. Two empty strings compare equal, and + # a comparison that passes on nothing is the false green this file's + # other guard already exists for. + if [ -z "$lhs" ] || [ -z "$rhs" ]; then + echo "FAIL: $tc@$ver: no $channel line to compare" + fail=1 + continue + fi + a="$(normalise "$lhs")" + b="$(normalise "$rhs")" + if [ "$a" = "$b" ]; then + echo " ok $tc@$ver: naming $ht changes nothing ($channel)" + else + echo "FAIL: $tc@$ver: naming $ht changed the $channel line" + diff <(printf '%s\n' "$a") <(printf '%s\n' "$b") \ + | grep -E '^[<>]' | head -8 | sed 's/^/ /' + fail=1 + fi + done done if [ "$checked" = 0 ]; then diff --git a/tests/e2e/304_the_target_side_reaches_every_unit.sh b/tests/e2e/304_the_target_side_reaches_every_unit.sh new file mode 100755 index 00000000..359d3d7d --- /dev/null +++ b/tests/e2e/304_the_target_side_reaches_every_unit.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# requires: unix-shell jq +# A layer the GRAPH supplies is beneath every unit, including a sibling +# dependency package that has never heard of it. +# +# ⭐⭐ THE SET WAS ALREADY COMPUTED AND REACHED ONE TRANSLATION UNIT. +# +# A package that supplies a target-side layer publishes the headers the whole +# target is built against. Those travelled as an ordinary `publicUsage`, which +# propagates ALONG DEPENDENCY EDGES — so the root and the provider's own units +# received them and a SIBLING dependency package did not. `nlohmann.json` is +# not downstream of `openkal-llvm-runtime`; it is beside it. +# +# The result is two flavours of BMI in one build: `std` compiled over the +# target's libc++ (correct — prepare hands it exactly this set) and the +# dependency packages compiled over the payload's. Any unit importing both +# fails at the first template instantiation touching a declaration present in +# both header sets: +# +# istream:1245: error: reference to 'space' is ambiguous +# +# mcpp#514 §A. +# +# ⚠️ NO openkal, NO CROSS, NO NETWORK. A path package declaring one capability +# and one `include_dirs` entry reproduces it, which is the point: this is not a +# property of openkal but of how a target side was modelled. +# +# ⭐ THE CRITERION IS THE CDB ROW, NOT A GREP OVER THE TREE. `compile_commands.json` +# says what each unit is actually compiled with; a grep over `build.ninja` would +# also match the global rule and pass for the wrong reason. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +mkdir -p src abi/src abi/abi-include dep/src dep/dep-include + +# The provider: supplies a target-side layer and publishes one directory. +cat > abi/mcpp.toml <<'EOF' +[package] +name = "abiprov" +version = "0.1.0" +provides = ["mcpp:c++-abi=libc++"] + +[build] +include_dirs = ["abi-include"] +EOF +printf 'export module abiprov;\nexport int abiprov_v() { return 1; }\n' > abi/src/abiprov.cppm +printf '#pragma once\n' > abi/abi-include/marker.h + +# The sibling: a dependency package that does NOT depend on the provider. +cat > dep/mcpp.toml <<'EOF' +[package] +name = "sibling" +version = "0.1.0" + +[build] +include_dirs = ["dep-include"] +EOF +printf 'export module sibling;\nexport int sibling_v() { return 2; }\n' > dep/src/sibling.cppm +printf '#pragma once\n' > dep/dep-include/own.h + +cat > mcpp.toml <<'EOF' +[package] +name = "reachprobe" +version = "0.1.0" + +[dependencies] +abiprov = { path = "abi" } +sibling = { path = "dep" } +EOF +cat > src/main.cpp <<'EOF' +#include +import abiprov; +import sibling; +int main() { std::printf("%d\n", abiprov_v() + sibling_v()); } +EOF + +"$MCPP" build >/dev/null 2>&1 || { + echo "FAIL: the probe project did not build" + "$MCPP" build 2>&1 | tail -20 | sed 's/^/ /' + exit 1 +} + +[ -s compile_commands.json ] || { echo "FAIL: no compile_commands.json"; exit 1; } + +# ⚠️ A DENOMINATOR. A CDB with no sibling row would make every assertion below +# vacuously true, which is the false green this criterion has to rule out. +siblings="$(jq -r '[.[] | select(.file | test("/dep/src/"))] | length' compile_commands.json)" +if [ "$siblings" -lt 1 ]; then + echo "FAIL: the CDB has no sibling-package row — nothing was checked" + exit 1 +fi + +missing="$(jq -r ' + [ .[] + | select(.file | test("/dep/src/")) + | { f: .file, has: ((.arguments // (.command | split(" "))) | map(test("abi-include")) | any) } + | select(.has | not) + | .f + ] | .[]' compile_commands.json)" + +if [ -n "$missing" ]; then + echo "FAIL: the target side did not reach $siblings sibling unit(s):" + printf '%s\n' "$missing" | sed 's/^/ /' + exit 1 +fi + +# And the reverse direction: a package that supplies NO layer must not have its +# own directory pushed onto everyone. Otherwise this test would pass for a +# build that simply gives every include dir to every unit. +leaked="$(jq -r ' + [ .[] + | select(.file | test("/abi/src/")) + | { f: .file, has: ((.arguments // (.command | split(" "))) | map(test("dep-include")) | any) } + | select(.has) + | .f + ] | .[]' compile_commands.json)" + +if [ -n "$leaked" ]; then + echo "FAIL: a non-layer package's directory reached a sibling — the fix is too wide:" + printf '%s\n' "$leaked" | sed 's/^/ /' + exit 1 +fi + +echo "OK: the graph-supplied target side reaches every unit ($siblings sibling units), and nothing else does" diff --git a/tests/e2e/305_built_from_is_not_published.sh b/tests/e2e/305_built_from_is_not_published.sh new file mode 100755 index 00000000..1d5db9af --- /dev/null +++ b/tests/e2e/305_built_from_is_not_published.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# requires: unix-shell jq +# A package can say which of its include directories stop at its own boundary. +# +# ⭐⭐ `publicUsage` TOOK `privateBuild`'s DIRECTORIES ENTIRE, so a package was +# built from exactly the set it published. For almost every package those are +# the same set; for one that vendors a library with an internal header overlay +# they are not, and the difference reaches every consumer. +# +# `mcpplibs/openkal-musl` wrote the case down in its own source, having found +# it three times (port/include/features.h): +# +# ⓘ THIS IS THE SECOND-BEST REMEDY. The first would be for a package to +# distinguish the directories it is BUILT FROM from the directories it +# PUBLISHES. Measured 2026-08-22: mcpp cannot express it. +# +# musl reaches its own declarations through `src/include`, whose headers define +# `hidden`, `weak` and `weak_alias` — names meaningful only to musl's sources. +# Publishing that directory hands those macros to every consumer; which +# consumer breaks on which name was discovered one at a time. +# +# ⭐ THE CRITERION IS THE DIRECTORY, NOT THE SYMPTOM. Asserting that `hidden` +# no longer collides would go green again the moment the package patched that +# one macro, while the leak stayed. This asserts that the directory is not on +# the consumer's command line — and, separately, that a consumer using the name +# as an ordinary identifier compiles, which is what the user actually reported. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +mkdir -p src lib/src lib/pub lib/internal + +cat > lib/mcpp.toml <<'EOF' +[package] +name = "vendored" +version = "0.1.0" + +[build] +# ⚠️ ORDER IS LOAD-BEARING and is why `private_include_dirs` is a SUBSET of +# this list rather than a second list: the internal overlay must precede the +# public headers for the package's OWN build, and two TOML arrays cannot +# express one order. +include_dirs = ["internal", "pub"] +private_include_dirs = ["internal"] +EOF +printf 'export module vendored;\nexport int vendored_v() { return 7; }\n' > lib/src/vendored.cppm +printf '#pragma once\n#define VENDORED_PUBLIC 1\n' > lib/pub/pub.h +# The internal overlay: a macro that is meaningful to the package and a +# perfectly ordinary identifier to everyone else. +printf '#pragma once\n#define hidden __attribute__((visibility("hidden")))\n' > lib/internal/overlay.h + +cat > mcpp.toml <<'EOF' +[package] +name = "boundaryprobe" +version = "0.1.0" + +[dependencies] +vendored = { path = "lib" } +EOF +# `hidden` as a local variable — the exact shape openkal-musl#13 reported. +cat > src/main.cpp <<'EOF' +#include +#include +import vendored; +int main() { + int hidden = 1; + std::printf("%d %d\n", vendored_v() + hidden, VENDORED_PUBLIC); +} +EOF + +if ! "$MCPP" build >/dev/null 2>&1; then + echo "FAIL: the consumer did not build — the private overlay is still reaching it" + "$MCPP" build 2>&1 | tail -20 | sed 's/^/ /' + exit 1 +fi + +[ -s compile_commands.json ] || { echo "FAIL: no compile_commands.json"; exit 1; } + +# ⚠️ DENOMINATORS ON BOTH SIDES. With no provider row or no consumer row the +# assertions below are vacuously true. +prov="$(jq -r '[.[] | select(.file | test("/lib/src/"))] | length' compile_commands.json)" +cons="$(jq -r '[.[] | select(.file | test("/src/main"))] | length' compile_commands.json)" +if [ "$prov" -lt 1 ] || [ "$cons" -lt 1 ]; then + echo "FAIL: CDB has provider=$prov consumer=$cons rows — nothing was checked" + exit 1 +fi + +has_dir() { # file-pattern, dir-pattern → "true"/"false" + jq -r --arg f "$1" --arg d "$2" ' + [ .[] | select(.file | test($f)) + | ((.arguments // (.command | split(" "))) | map(test($d)) | any) ] + | any' compile_commands.json +} + +fail=0 +# The provider is built from BOTH — the private directory is private, not unused. +[ "$(has_dir '/lib/src/' '/lib/internal')" = true ] || { + echo "FAIL: the provider lost its own private directory"; fail=1; } +[ "$(has_dir '/lib/src/' '/lib/pub')" = true ] || { + echo "FAIL: the provider lost its own public directory"; fail=1; } +# The consumer gets the public one and NOT the private one. +[ "$(has_dir '/src/main' '/lib/pub')" = true ] || { + echo "FAIL: the public directory did not reach the consumer"; fail=1; } +[ "$(has_dir '/src/main' '/lib/internal')" = false ] || { + echo "FAIL: the private directory leaked to the consumer"; fail=1; } + +[ "$fail" = 0 ] || exit 1 +echo "OK: built-from and published are separable (provider=$prov consumer=$cons rows)" diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index e579bf83..c61b100d 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -367,3 +367,45 @@ TEST(CacheKey, AHostedTargetHasNoTargetImpliedFlagsEitherWay) { EXPECT_TRUE(a.targetImpliedFlags.empty()) << a.targetImpliedFlags.size(); EXPECT_EQ(a.targetImpliedFlags, b.targetImpliedFlags); } + +// ⭐⭐ THE HEADER SET THE DRIVER IS POINTED AT IS PART OF THE IDENTITY. +// +// Everything else on axis A describes the COMPILER. Nothing described the +// LIBRARY it compiles against — and the two are separately installed: one clang +// payload sits above whichever `xim:glibc` and `xim:linux-headers` the home +// carries. `driverIdentity` cannot cover it by design, because +// `normalize_driver_output` strips paths so that one entry CAN be shared +// between two homes. +// +// Measured (mcpp#514 §B): two host builds resolving glibc 2.39 and 2.44 hit one +// entry, and the mixed BMIs crashed the clang frontend during deserialization +// rather than producing a diagnostic. +TEST(CacheKey, TheHeaderSetIsPartOfTheKey) { + mcpp::manifest::Manifest m; + m.package.standard = "c++23"; + + auto a = ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""); + auto b = a; + // The axis is filled from the resolvers in a real build; here it is set + // directly, because what this asserts is that the KEY reads it — not how + // linkmodel discovers a payload, which is that module's own test. + b.targetHeaderSet = { "-isystem/xim-x-glibc/2.44/include" }; + a.targetHeaderSet = { "-isystem/xim-x-glibc/2.39/include" }; + + EXPECT_NE(ck::key_hex(a, pkg()), ck::key_hex(b, pkg())); +} + +// ...and it travels in entry.json, so a suspected wrong hit can be read field +// by field rather than guessed at. `is_cached` compares `inputs.toolchain` as +// one object, which is also why an entry written before this axis existed is a +// miss without needing `kCacheEpoch` to move. +TEST(CacheKey, TheHeaderSetIsRecordedInTheEntry) { + mcpp::manifest::Manifest m; + m.package.standard = "c++23"; + auto a = ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""); + a.targetHeaderSet = { "-isystem/xim-x-glibc/2.44/include" }; + auto j = ck::to_json(a, pkg()); + ASSERT_TRUE(j["toolchain"].contains("target_header_set")); + EXPECT_EQ(j["toolchain"]["target_header_set"][0], + "-isystem/xim-x-glibc/2.44/include"); +} diff --git a/tests/unit/test_hostflags.cpp b/tests/unit/test_hostflags.cpp index e5ac8d54..b7591b28 100644 --- a/tests/unit/test_hostflags.cpp +++ b/tests/unit/test_hostflags.cpp @@ -1,4 +1,6 @@ #include +#include +#include import std; import mcpp.platform; @@ -295,3 +297,68 @@ TEST(GraphRuntimeFlags, UnparseableTripleTakesNone) { EXPECT_TRUE(mcpp::toolchain::graph_runtime_compile_flags( graph_tc("not-a-triple-at-all")).empty()); } + +// ⭐⭐ `--no-default-config` IS NOT PART OF THE PAYLOAD'S HEADER SET, AND WAS +// BEING SUPPRESSED WITH IT. +// +// The payload's `-isystem` rows describe a C library a graph-supplied target +// does not use, so withholding them is right. The cfg bypass is a different +// statement: `post_install.cppm` calls that file "a per-machine, +// per-install-path artifact", and reading it makes the command line depend on +// what happened to be installed when the payload landed. +// +// Measured on 2026.8.26.2: `mcpp build --target ` dropped the +// token, so clang read `bin/clang++.cfg`. That is also what made a hand-written +// `-clang++.cfg` a working workaround for mcpp#514 — a workaround that +// only existed because this token went missing. +TEST(HostFlags, TheCfgBypassSurvivesAGraphSuppliedTargetSide) { + // ⚠️ A FIXTURE, NOT THE MACHINE'S OWN TOOLCHAIN. The first draft used a + // synthetic `Toolchain` with no `binaryPath`, so `resolve_clang_driver` + // reported no cfg and the whole test SKIPPED — a check that asserts + // nothing while reporting success, which is the one failure mode a test + // must not have. `resolve_clang_driver` only asks whether a sibling + // `.cfg` EXISTS, so two empty files are a complete fixture. + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() + / ("mcpp_hostflags_" + std::to_string(::getpid())); + fs::remove_all(root); + fs::create_directories(root / "bin"); + fs::create_directories(root / "include" / "c++" / "v1"); + { std::ofstream(root / "bin" / "clang++"); } + { std::ofstream(root / "bin" / "clang++.cfg"); } + struct Cleanup { + fs::path p; + ~Cleanup() { std::error_code ec; fs::remove_all(p, ec); } + } cleanup{root}; + + auto tc = tc_for(CompilerId::Clang); + tc.binaryPath = root / "bin" / "clang++"; + ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg) + << "the fixture did not produce a cfg — the assertions below would be vacuous"; + + const auto has = [](const std::vector& v, std::string_view f) { + return std::ranges::find(v, f) != v.end(); + }; + + HostFlagOptions prebuilt; + prebuilt.cfgBypass = HostFlagOptions::CfgBypass::Always; + prebuilt.cAbiPrebuilt = true; + + HostFlagOptions fromGraph = prebuilt; + fromGraph.cAbiPrebuilt = false; + + const auto a = mcpp::toolchain::host_compile_tokens( + tc, prebuilt, mcpp::toolchain::no_escape); + const auto b = mcpp::toolchain::host_compile_tokens( + tc, fromGraph, mcpp::toolchain::no_escape); + + // The bypass is emitted on BOTH sides: the cfg is a per-machine, + // per-install-path artifact, and reading it makes the command line depend + // on what happened to be installed when the payload landed. + EXPECT_TRUE(has(a, "--no-default-config")); + EXPECT_TRUE(has(b, "--no-default-config")); + // ...while the payload's own C++ headers stay withheld from the graph side, + // which is the distinction this splits apart. + EXPECT_TRUE(has(a, "-nostdinc++")); + EXPECT_FALSE(has(b, "-nostdinc++")); +} From 9adc5ad7e058375e29d32e495cf2296045498883 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 09:58:17 +0800 Subject: [PATCH 02/20] =?UTF-8?q?fix(test):=20=E6=96=B0=E7=9A=84=20hostfla?= =?UTF-8?q?gs=20fixture=20=E7=94=A8=E4=BA=86=20MSVC=20=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E7=9A=84=20getpid/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 它在写下它的那台机器上编过了,在 Windows CI 上编不过 —— 而 Windows 是这个项目 一半的面唯一可见的地方。改成固定名 + 先 remove_all:gtest 在一个二进制里串行 跑测试,一个名字就够。 --- tests/unit/test_hostflags.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_hostflags.cpp b/tests/unit/test_hostflags.cpp index b7591b28..36e49fdf 100644 --- a/tests/unit/test_hostflags.cpp +++ b/tests/unit/test_hostflags.cpp @@ -1,6 +1,5 @@ #include #include -#include import std; import mcpp.platform; @@ -319,8 +318,12 @@ TEST(HostFlags, TheCfgBypassSurvivesAGraphSuppliedTargetSide) { // must not have. `resolve_clang_driver` only asks whether a sibling // `.cfg` EXISTS, so two empty files are a complete fixture. namespace fs = std::filesystem; - const auto root = fs::temp_directory_path() - / ("mcpp_hostflags_" + std::to_string(::getpid())); + // ⚠️ A FIXED NAME AND `remove_all` FIRST, NOT A PROCESS ID. The first + // draft reached for `::getpid()` and ``, which do not exist under + // MSVC — it built on the machine it was written on and failed on Windows + // CI, which is the only place that half of this project is visible. + // gtest runs a binary's tests serially, so one name is enough. + const auto root = fs::temp_directory_path() / "mcpp_hostflags_cfg_fixture"; fs::remove_all(root); fs::create_directories(root / "bin"); fs::create_directories(root / "include" / "c++" / "v1"); From dd2b278c350f7602a01526806641b23ce73a43d5 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 10:05:44 +0800 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20=E8=AF=B7=E6=B1=82=E7=9A=84=20glib?= =?UTF-8?q?c=20=E7=89=88=E6=9C=AC=E4=B8=8E=E8=A3=85=E5=A5=BD=E7=9A=84=20gl?= =?UTF-8?q?ibc=20=E7=89=88=E6=9C=AC,=E6=98=AF=E4=B8=A4=E7=A7=8D=E8=AF=8D?= =?UTF-8?q?=E6=B1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ 这条不是本 PR 引入的 —— `mcpp_ref=main` 的对照 run 报同一处错。 它是索引把 `xim:glibc` 在同一系列内移动之后,**任何**从源码构建 mcpp 的 全新 CI 机器都会中的一条,今天开始出现。修在这里,因为它卡住了本 PR 的门。 ## 机制 RuntimeBinding 带的是**声明的**身份;xlings 把载荷目录按请求**解析成**的版本命名。 索引把 `xim:glibc@2.44` 解析到 `2.44.2` 的那一刻,两者分开: error: selected RuntimeBinding glibc@2.44 requires payload '…/xim-x-glibc/2.44', but it is not installed $ ls …/xim-x-glibc/ → 2.44.2 ⭐ `runtime_binding.cppm` 早一个版本就记下了同一形状(「observed in CI as runtime=glibc@2.39 with libc.so.6 resolving to the managed 2.44 payload」) 并做了归一 —— 但只在 SubOS 视图存在之后。而这一处跑在**第一次**装工具链时, 那时还没有视图可读。 ## ⭐ 是「精化」不是「按目录顺序挑」,而这个区别是那条拒绝还站得住的全部理由 `2.44.2` 是请求 `2.44` 解析出来的东西:它的版本**分量**以请求的分量开头。 ⚠️ 逐**分量**比,不是逐字符:`2.4` 不是 `2.44` 回答的那个请求。按字符串前缀比 会说它是,然后把一个错的 C 库交给构建而什么也不说。 ⚠️ 若有两个载荷都精化了这个请求,这里**不做选择** —— 原样报拒绝。 「这个请求的解析结果」必须是**一个**载荷才谈得上是个答案。 ## 判据(5 条,四个方向) - 精确版本在场时优先(不会被精化抢走) - 唯一精化被采纳 - `2.4` 不匹配 `2.44`(逐分量) - 两个精化 → 仍然拒绝,且消息说明为什么 - 什么都没装 → 仍然拒绝 --- src/toolchain/post_install.cppm | 79 ++++++++++++++++++++++++++-- tests/unit/test_post_install.cpp | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index 6004c87b..401a07ff 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -413,10 +413,81 @@ select_glibc_payload_lib(const std::filesystem::path& glibcRoot, auto payload = glibcRoot / std::string(version); std::error_code ec; if (!std::filesystem::is_directory(payload, ec)) { - return std::unexpected(std::format( - "selected RuntimeBinding {} requires payload '{}', but it is not " - "installed; mcpp will not fall back to another directory entry", - runtimeId, payload.string())); + // ⭐⭐ THE VERSION THAT WAS ASKED FOR AND THE VERSION THAT WAS + // INSTALLED ARE TWO VOCABULARIES FOR ONE FACT. + // + // A RuntimeBinding carries the DECLARED identity — what the subos state + // says the provider is. xlings names the payload directory after what + // the request RESOLVED to, and the two differ the moment the index + // moves a package within a series: `xim:glibc@2.44` resolving to + // `2.44.2` puts the payload in `xim-x-glibc/2.44.2` while the + // declaration still reads `glibc@2.44`. + // + // ⚠️ Measured 2026-08-27, on every CI machine with a cold cache, on + // `main` as readily as on any branch: + // + // error: selected RuntimeBinding glibc@2.44 requires payload + // '…/xim-x-glibc/2.44', but it is not installed + // + // $ ls …/xim-x-glibc/ → 2.44.2 + // + // `runtime_binding.cppm` already names this exact shape one version + // earlier ("observed in CI as runtime=glibc@2.39 with libc.so.6 + // resolving to the managed 2.44 payload") and canonicalizes it — + // but only once the SubOS view exists. This runs during the FIRST + // toolchain install, before there is a view to read. + // + // ⭐ A REFINEMENT, NOT A DIRECTORY-ORDER PICK — and that distinction is + // the whole of why the refusal below still stands. `2.44.2` is what the + // request `2.44` resolved to: its version components BEGIN with the + // requested ones. `2.4` does not match `2.44`, because the comparison + // is per component and not per character. And if two payloads both + // refine the request, nothing here chooses between them: the refusal is + // reported exactly as before, because "the resolution of this request" + // has to be one payload to be an answer at all. + auto components = [](std::string_view v) { + std::vector parts; + std::size_t start = 0; + while (start <= v.size()) { + auto dot = v.find('.', start); + if (dot == std::string_view::npos) { + parts.emplace_back(v.substr(start)); + break; + } + parts.emplace_back(v.substr(start, dot - start)); + start = dot + 1; + } + return parts; + }; + const auto wanted = components(version); + + std::vector refinements; + std::error_code dec; + for (auto it = std::filesystem::directory_iterator(glibcRoot, dec); + !dec && it != std::filesystem::directory_iterator{}; + it.increment(dec)) { + if (!it->is_directory(dec)) continue; + const auto have = components(it->path().filename().string()); + if (have.size() <= wanted.size()) continue; + if (!std::equal(wanted.begin(), wanted.end(), have.begin())) continue; + refinements.push_back(it->path()); + } + + if (refinements.size() != 1) { + return std::unexpected(std::format( + "selected RuntimeBinding {} requires payload '{}', but it is not " + "installed{}; mcpp will not fall back to another directory entry", + runtimeId, payload.string(), + refinements.empty() + ? std::string{} + : std::format(" and {} installed payloads refine that " + "version, so none of them is its resolution", + refinements.size()))); + } + payload = refinements.front(); + mcpp::log::verbose("toolchain", std::format( + "RuntimeBinding {} resolved to installed payload '{}'", + runtimeId, payload.filename().string())); } auto lib = payload_lib_dir_with_loader(payload); if (lib.empty()) { diff --git a/tests/unit/test_post_install.cpp b/tests/unit/test_post_install.cpp index 5f084730..318ff8b9 100644 --- a/tests/unit/test_post_install.cpp +++ b/tests/unit/test_post_install.cpp @@ -1,4 +1,5 @@ #include +#include import std; import mcpp.toolchain.post_install; @@ -190,3 +191,91 @@ TEST(PostInstallFixup, APackageWithNoFixupReportsNothingToReport) { << "a toolchain with no fixup reported a degradation: " << result->skippedReason; } + +// ⭐⭐ THE VERSION THAT WAS ASKED FOR AND THE VERSION THAT WAS INSTALLED ARE +// TWO VOCABULARIES FOR ONE FACT. +// +// A RuntimeBinding carries the DECLARED identity; xlings names the payload +// directory after what the request RESOLVED to. The two come apart the moment +// the index moves a package within a series — `xim:glibc@2.44` resolving to +// `2.44.2`. +// +// Measured 2026-08-27 on every CI machine with a cold cache, on `main` as +// readily as on any branch: +// +// error: selected RuntimeBinding glibc@2.44 requires payload +// '…/xim-x-glibc/2.44', but it is not installed +// $ ls …/xim-x-glibc/ → 2.44.2 +namespace { + +// A payload is "installed" for this purpose when it has a lib dir with a +// loader in it — that is what select_glibc_payload_lib returns. +std::filesystem::path make_glibc_payload(const std::filesystem::path& root, + std::string_view version) { + auto lib = root / std::string(version) / "lib64"; + std::filesystem::create_directories(lib); + std::ofstream(lib / "ld-linux-x86-64.so.2"); + return lib; +} + +struct GlibcRootFixture { + std::filesystem::path root; + explicit GlibcRootFixture(std::string_view name) + : root(std::filesystem::temp_directory_path() / name) { + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root); + } + ~GlibcRootFixture() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } +}; + +} // namespace + +TEST(GlibcPayload, TheExactVersionIsPreferred) { + GlibcRootFixture fx{"mcpp_glibc_exact"}; + auto want = make_glibc_payload(fx.root, "2.44"); + make_glibc_payload(fx.root, "2.44.2"); + auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); + ASSERT_TRUE(got.has_value()) << got.error(); + EXPECT_EQ(*got, want); +} + +TEST(GlibcPayload, ARequestResolvesToItsOneRefinement) { + GlibcRootFixture fx{"mcpp_glibc_refine"}; + auto only = make_glibc_payload(fx.root, "2.44.2"); + auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); + ASSERT_TRUE(got.has_value()) << got.error(); + EXPECT_EQ(*got, only); +} + +// ⚠️ PER COMPONENT, NOT PER CHARACTER. `2.4` is not a request that `2.44` +// answers — a prefix match on the string would say it is, and would then hand +// a build the wrong C library without saying anything. +TEST(GlibcPayload, AStringPrefixIsNotARefinement) { + GlibcRootFixture fx{"mcpp_glibc_strprefix"}; + make_glibc_payload(fx.root, "2.44"); + auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.4"); + EXPECT_FALSE(got.has_value()); +} + +// ⭐ AND THE REFUSAL STILL STANDS WHEN THERE IS NO ONE ANSWER. "The resolution +// of this request" has to be a single payload to be an answer at all; two +// refinements are not a menu to pick from. +TEST(GlibcPayload, TwoRefinementsAreRefusedRatherThanChosenBetween) { + GlibcRootFixture fx{"mcpp_glibc_ambiguous"}; + make_glibc_payload(fx.root, "2.44.1"); + make_glibc_payload(fx.root, "2.44.2"); + auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); + ASSERT_FALSE(got.has_value()); + EXPECT_NE(got.error().find("none of them is its resolution"), std::string::npos) + << got.error(); +} + +TEST(GlibcPayload, NothingInstalledIsStillRefused) { + GlibcRootFixture fx{"mcpp_glibc_empty"}; + auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); + EXPECT_FALSE(got.has_value()); +} From 1b6f527e16dd891da78567d57c79f5e9e8ba15fe Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 10:09:53 +0800 Subject: [PATCH 04/20] =?UTF-8?q?chore(xlings):=20pin=202026.8.17.2=20?= =?UTF-8?q?=E2=86=92=202026.8.27.1,=E7=B4=A2=E5=BC=95=E5=B7=B2=E7=BB=8F?= =?UTF-8?q?=E5=9C=A8=E7=AD=89=E8=BF=99=E4=B8=AA=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ 这是那条 `glibc@2.44 ... not installed` 的**主因**,而它在索引里被逐字 预告过。`xim-pkgindex` 的 `pkgs/g/glibc.lua` 记着一对提交: fb289b8 glibc: withdraw 2.44.2 until the client that can resolve it ships 557e862 glibc: restore 2.44.2 now that the client resolving it has shipped 以及那次失败的原文: -- released xlings pinned `glibc@2.44`, payload directories are named -- after the version, and so a clean environment installed 2.44.2 and -- then found nothing answering to the binding -- error: selected RuntimeBinding glibc@2.44 requires payload -- '/.../xpkgs/xim-x-glibc/2.44', but it is not installed -- on every NEW environment and on none that already existed. -- The index is DATA and the client is a PROGRAM: the consumer ships first. ⭐ 那个消费者是 **xlings 2026.8.27.1**(openxlings/xlings#567):它改为从索引文件 读默认 runtime binding,而不是读一个编译进客户端的常量。mcpp 钉的还是 8.17.2, 所以每一台冷缓存的 CI 机器都停在上面 —— **在 main 上和在任何分支上一样**。 ## ⭐ 与前一条提交的关系(两条,不是一条的两半) | | 覆盖谁 | |---|---| | 本条(pin) | **新环境**:xlings 从索引读绑定,直接绑到 2.44.2 | | 前一条(`select_glibc_payload_lib`) | **已存在的环境升级**:subos 记下的绑定还是 `2.44`,而载荷已经是 `2.44.2`,且 SubOS 视图尚未建立 —— `runtime_binding.cppm:224` 早一个版本就记过这个形状 | 两者在不同的层,谁也替代不了谁;去掉后者,一台已经跑过一次的机器在升级后仍会停住。 ## 判据 `.github/tools/check_version_pins.sh` 是这件事的唯一真源检查器,它一次列出了 全部 7 个不同步的点(3 个 workflow + 2 个 composite action + 2 个 release 段), 同步后退出码 0。**这正是它存在的理由** —— 注释里说过,手工维护的清单曾漏掉 两个 composite action,让 CI 的沙箱在 0.4.30 上停了很久没人发现。 --- .github/actions/bootstrap-mcpp/action.yml | 8 ++++---- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +++--- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 ++-- .github/workflows/release.yml | 14 +++++++------- src/platform/xlings/xlings.cppm | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 6b50301b..fb9f3fe8 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.17.2' + default: '2026.8.27.1' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false @@ -49,7 +49,7 @@ runs: # `ubuntu-24.04-arm` restored what `ubuntu-24.04` had stored. Measured # the first time this repo scheduled an arm Linux runner: # - # Cache hit for: xlings-Linux-v2-xl2026.8.17.2-… + # Cache hit for: xlings-Linux-v2-xl2026.8.27.1-… # …/bin/xlings: cannot execute binary file: Exec format error # # ⭐ AND IT SURVIVED THE FIX ONE LAYER DOWN. Reading `uname -m` to pick @@ -93,7 +93,7 @@ runs: # invisible until a Linux runner is not x86_64. Measured on # `ubuntu-24.04-arm`, the first time this repo scheduled one: # - # .../xlings-2026.8.17.2-linux-x86_64/subos/default/bin/xlings: + # .../xlings-2026.8.27.1-linux-x86_64/subos/default/bin/xlings: # cannot execute binary file: Exec format error # Process completed with exit code 126 # @@ -118,7 +118,7 @@ runs: "${WORK}/${tarball}" tar -xzf "${WORK}/${tarball}" -C "${WORK}" # ⚠️⚠️ THE TWO LINUX TARBALLS DO NOT HAVE THE SAME SHAPE, and this line - # named one of them. Measured on the 2026.8.17.2 assets: + # named one of them. Measured on the 2026.8.27.1 assets: # # linux-x86_64 → subos/default/bin/xlings (513 entries) # linux-aarch64 → bin/xlings (494 entries) diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 3cfdd438..fdbb33b9 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.17.2' + default: '2026.8.27.1' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index ecbfb38e..52dd2283 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.1' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 2995291c..f8fb73bf 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -364,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 6b443920..039de18f 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -237,7 +237,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 8c2e1517..ed4fc2c5 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -122,7 +122,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -263,7 +263,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 780c1ccb..5b479930 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -360,7 +360,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.17.2-linux-aarch64.tar.gz" + XLA="xlings-2026.8.27.1-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -369,9 +369,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.17.2/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.27.1/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.17.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.27.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -449,7 +449,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -632,7 +632,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.1' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index fbd83185..f0edaf27 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -45,7 +45,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.17.2"; + inline constexpr std::string_view kXlingsVersion = "2026.8.27.1"; inline constexpr std::string_view kNasmVersion = "3.02"; } From af126a3611057543ec204da9537437ab5db2dbf2 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 10:29:07 +0800 Subject: [PATCH 05/20] =?UTF-8?q?fix(e2e295):=20=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=E7=94=A8=E4=BA=86=20`^`=20=E9=94=9A=E5=AE=9A,?= =?UTF-8?q?=E8=80=8C=20Windows=20=E4=B8=8A=E9=82=A3=E4=B8=AA=20token=20?= =?UTF-8?q?=E6=98=AF=E5=B8=A6=E5=BC=95=E5=8F=B7=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ninja 通道会给需要引号的路径加引号,于是同一个 flag 到达时是 `"-fprebuilt-module-path=C$:\Users\..."` —— 锚定的模式匹配不到它, 比对随后就栽在这个过滤器本来要去掉的那个 token 上。 在 windows-x86_64 上实测:它把一条本该绿的恒等式变成红的,而原因与被编译的 东西毫无关系。改成子串匹配。⚠️ Linux 侧仍然能在未修二进制上变红(已复验)。 --- tests/e2e/295_naming_the_host_target_changes_nothing.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/e2e/295_naming_the_host_target_changes_nothing.sh b/tests/e2e/295_naming_the_host_target_changes_nothing.sh index 34f8c4e4..acdcb0c6 100755 --- a/tests/e2e/295_naming_the_host_target_changes_nothing.sh +++ b/tests/e2e/295_naming_the_host_target_changes_nothing.sh @@ -97,9 +97,16 @@ line_of() { # channel, extra args… → that line of build.ninja, or nothing # token that names which machine. `-fprebuilt-module-path=` names the build # directory, which differs because the fingerprint does; that is the mechanism # working, not a difference in what is compiled. +# +# ⚠️ NOT ANCHORED WITH `^`, AND WINDOWS IS WHY. The ninja channel QUOTES a token +# whose path needs it, so the same flag arrives as +# `"-fprebuilt-module-path=C$:\Users\..."` — an anchored pattern misses it and +# the comparison then fails on the one token this filter exists to remove. +# Measured on windows-x86_64, where it turned a green invariant into a red one +# for a reason that had nothing to do with what was being compiled. normalise() { printf '%s\n' "$1" | tr ' ' '\n' \ - | grep -v '^--target=' | grep -v '^-fprebuilt-module-path=' \ + | grep -v -- '--target=' | grep -v -- '-fprebuilt-module-path=' \ | grep -v '^$' | sort } From 4fd1fa2330c730e9a0270facb1c5b158ccedfdad Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 10:41:32 +0800 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20=E3=80=8C=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E7=9A=84=E7=89=88=E6=9C=AC=20=E2=86=92=20=E8=A3=85=E5=A5=BD?= =?UTF-8?q?=E7=9A=84=E7=9B=AE=E5=BD=95=E3=80=8D=E6=9C=89=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B9,=E8=80=8C=E6=88=91=E5=85=88?= =?UTF-8?q?=E5=8F=AA=E4=BF=AE=E4=BA=86=E4=B8=80=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一版把解析写在了工具链 post-install 里。工具链于是装得上了,而**编译线**的载荷 发现(`probe.cppm` 的 `payload_root_for_binding`)自己拼了一遍同样的查找,继续找不到 —— 而它的失败**连版本都不提**: bits/os_defines.h:39: fatal error: features.h: No such file or directory 因为 glibc 的 include 目录压根没被加上。在 openkal-musl 的 CI 上实测,就在 post-install 那一处修好之后。 ⭐ ⇒ 抽成 `mcpp::xlings::paths::payload_dir_for_version`,两个调用方都读它。 这正是这个仓库反复付过代价的那个形状(#233/#240/#242/#344):**同一个决定推导两遍, 修好一处会让另一处更难被发现**,因为症状换了一副样子。 ## 判据 单测从断言「那个包装函数」改为断言**共享的解析器**本身,四个方向: 精确优先 · 唯一精化被采纳 · 两个精化不是答案 · `2.4` 不匹配 `2.44`(逐分量)。 包装函数那五条保留,它们覆盖的是「拿不到 lib 目录时仍然拒绝」这一层。 --- src/platform/xlings/xlings.cppm | 67 +++++++++++++++++++++ src/toolchain/post_install.cppm | 99 ++++++-------------------------- src/toolchain/probe.cppm | 22 +++++-- tests/unit/test_post_install.cpp | 46 ++++++++++++++- 4 files changed, 146 insertions(+), 88 deletions(-) diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index f0edaf27..3ca3c8f1 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -170,6 +170,35 @@ namespace paths { find_home_tool(std::string_view tool, std::string_view requiredRelPath = {}); + // ⭐⭐ WHICH INSTALLED PAYLOAD DIRECTORY ANSWERS A REQUESTED VERSION. + // + // A request and a resolution are two vocabularies for one fact. A + // RuntimeBinding carries the DECLARED version (`glibc@2.44`); xlings names + // the payload directory after what that request RESOLVED to (`2.44.2`). + // They coincide until the index moves a package within a series, and then + // every lookup that spells the directory by the declared version stops + // finding it. + // + // ⚠️ STATED ONCE HERE BECAUSE IT HAS TWO CALLERS AND THEY FAILED + // SEPARATELY. `post_install`'s toolchain fixup and `probe`'s compile-side + // payload discovery each spelled it themselves; fixing one left the other, + // and the second failure did not name a version at all — it read + // + // bits/os_defines.h:39: fatal error: features.h: No such file + // + // because the glibc include directory had simply not been added. + // + // ⭐ A REFINEMENT, NOT A DIRECTORY-ORDER PICK. `2.44.2` is what the request + // `2.44` resolved to: its version COMPONENTS begin with the requested ones. + // `2.4` does not answer `2.44`, because the comparison is per component and + // not per character. And when two payloads both refine the request, this + // returns nothing — "the resolution of this request" has to be one payload + // to be an answer at all, and picking by directory order is the guess every + // caller here refuses to make. + std::optional + payload_dir_for_version(const std::filesystem::path& packageRoot, + std::string_view version); + // index data root: env.home / "data" std::filesystem::path index_data(const Env& env); @@ -965,6 +994,44 @@ find_sibling_package(const std::filesystem::path& compilerBin, return std::nullopt; } +std::optional +payload_dir_for_version(const std::filesystem::path& packageRoot, + std::string_view version) { + std::error_code ec; + auto exact = packageRoot / std::string(version); + if (std::filesystem::is_directory(exact, ec)) return exact; + + auto components = [](std::string_view v) { + std::vector parts; + std::size_t start = 0; + while (start <= v.size()) { + auto dot = v.find('.', start); + if (dot == std::string_view::npos) { + parts.emplace_back(v.substr(start)); + break; + } + parts.emplace_back(v.substr(start, dot - start)); + start = dot + 1; + } + return parts; + }; + const auto wanted = components(version); + + std::optional only; + std::error_code dec; + for (auto it = std::filesystem::directory_iterator(packageRoot, dec); + !dec && it != std::filesystem::directory_iterator{}; + it.increment(dec)) { + if (!it->is_directory(dec)) continue; + const auto have = components(it->path().filename().string()); + if (have.size() <= wanted.size()) continue; + if (!std::equal(wanted.begin(), wanted.end(), have.begin())) continue; + if (only) return std::nullopt; // two refinements are not an answer + only = it->path(); + } + return only; +} + std::filesystem::path index_data(const Env& env) { return env.home / "data"; } diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index 401a07ff..5b3fda0b 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -410,91 +410,30 @@ select_glibc_payload_lib(const std::filesystem::path& glibcRoot, runtimeId)); } - auto payload = glibcRoot / std::string(version); - std::error_code ec; - if (!std::filesystem::is_directory(payload, ec)) { - // ⭐⭐ THE VERSION THAT WAS ASKED FOR AND THE VERSION THAT WAS - // INSTALLED ARE TWO VOCABULARIES FOR ONE FACT. - // - // A RuntimeBinding carries the DECLARED identity — what the subos state - // says the provider is. xlings names the payload directory after what - // the request RESOLVED to, and the two differ the moment the index - // moves a package within a series: `xim:glibc@2.44` resolving to - // `2.44.2` puts the payload in `xim-x-glibc/2.44.2` while the - // declaration still reads `glibc@2.44`. - // - // ⚠️ Measured 2026-08-27, on every CI machine with a cold cache, on - // `main` as readily as on any branch: - // - // error: selected RuntimeBinding glibc@2.44 requires payload - // '…/xim-x-glibc/2.44', but it is not installed - // - // $ ls …/xim-x-glibc/ → 2.44.2 - // - // `runtime_binding.cppm` already names this exact shape one version - // earlier ("observed in CI as runtime=glibc@2.39 with libc.so.6 - // resolving to the managed 2.44 payload") and canonicalizes it — - // but only once the SubOS view exists. This runs during the FIRST - // toolchain install, before there is a view to read. - // - // ⭐ A REFINEMENT, NOT A DIRECTORY-ORDER PICK — and that distinction is - // the whole of why the refusal below still stands. `2.44.2` is what the - // request `2.44` resolved to: its version components BEGIN with the - // requested ones. `2.4` does not match `2.44`, because the comparison - // is per component and not per character. And if two payloads both - // refine the request, nothing here chooses between them: the refusal is - // reported exactly as before, because "the resolution of this request" - // has to be one payload to be an answer at all. - auto components = [](std::string_view v) { - std::vector parts; - std::size_t start = 0; - while (start <= v.size()) { - auto dot = v.find('.', start); - if (dot == std::string_view::npos) { - parts.emplace_back(v.substr(start)); - break; - } - parts.emplace_back(v.substr(start, dot - start)); - start = dot + 1; - } - return parts; - }; - const auto wanted = components(version); - - std::vector refinements; - std::error_code dec; - for (auto it = std::filesystem::directory_iterator(glibcRoot, dec); - !dec && it != std::filesystem::directory_iterator{}; - it.increment(dec)) { - if (!it->is_directory(dec)) continue; - const auto have = components(it->path().filename().string()); - if (have.size() <= wanted.size()) continue; - if (!std::equal(wanted.begin(), wanted.end(), have.begin())) continue; - refinements.push_back(it->path()); - } - - if (refinements.size() != 1) { - return std::unexpected(std::format( - "selected RuntimeBinding {} requires payload '{}', but it is not " - "installed{}; mcpp will not fall back to another directory entry", - runtimeId, payload.string(), - refinements.empty() - ? std::string{} - : std::format(" and {} installed payloads refine that " - "version, so none of them is its resolution", - refinements.size()))); - } - payload = refinements.front(); - mcpp::log::verbose("toolchain", std::format( - "RuntimeBinding {} resolved to installed payload '{}'", - runtimeId, payload.filename().string())); + // ⭐ ONE RESOLVER, TWO CALLERS. `payload_dir_for_version` also answers + // `probe`'s compile-side discovery; see its own header for why a request + // and a resolution are two vocabularies, and why a unique component-wise + // refinement is an answer while a directory-order pick is not. + // + // ⚠️ THE FIRST VERSION OF THIS FIX SPELLED IT HERE, and that left the other + // caller: the toolchain then installed and the compile line came out + // without the glibc include directory, which reads as + // `features.h: No such file` from inside libstdc++'s own headers. + auto payload = mcpp::xlings::paths::payload_dir_for_version( + glibcRoot, version); + if (!payload) { + return std::unexpected(std::format( + "selected RuntimeBinding {} requires payload '{}', but no installed " + "payload is its resolution; mcpp will not fall back to another " + "directory entry", + runtimeId, (glibcRoot / std::string(version)).string())); } - auto lib = payload_lib_dir_with_loader(payload); + auto lib = payload_lib_dir_with_loader(*payload); if (lib.empty()) { return std::unexpected(std::format( "selected RuntimeBinding {} payload '{}' is stale/incomplete: no " "dynamic loader was found under lib64/ or lib/", - runtimeId, payload.string())); + runtimeId, payload->string())); } return lib; } diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm index 3fe41a57..e2496113 100644 --- a/src/toolchain/probe.cppm +++ b/src/toolchain/probe.cppm @@ -374,16 +374,28 @@ payload_root_for_binding(const std::filesystem::path& compilerBin, const auto name = binding.substr(0, at); const auto version = std::string(binding.substr(at + 1)); - std::error_code ec; + // ⭐ THE DIRECTORY IS NAMED AFTER WHAT THE REQUEST RESOLVED TO, NOT AFTER + // THE REQUEST. `payload_dir_for_version` is the one answer to that, shared + // with the toolchain post-install fixup — see its header. + // + // ⚠️ THIS SITE'S FAILURE DOES NOT NAME A VERSION. The fixup at least says + // which payload it wanted; here the include directory is simply never + // added, and what the user reads comes from inside libstdc++: + // + // bits/os_defines.h:39: fatal error: features.h: No such file + // + // Measured 2026-08-27 on openkal-musl's CI after the fixup alone was fixed. // Compiler siblings: <...>/xpkgs/xim-x-/ if (auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(compilerBin)) { - auto root = *xpkgs / std::format("xim-x-{}", name) / version; - if (std::filesystem::exists(root, ec)) return root; + if (auto root = mcpp::xlings::paths::payload_dir_for_version( + *xpkgs / std::format("xim-x-{}", name), version)) + return *root; } // Active home. if (auto xpkgs = mcpp::xlings::paths::active_home_xpkgs()) { - auto root = *xpkgs / std::format("xim-x-{}", name) / version; - if (std::filesystem::exists(root, ec)) return root; + if (auto root = mcpp::xlings::paths::payload_dir_for_version( + *xpkgs / std::format("xim-x-{}", name), version)) + return *root; } return std::nullopt; } diff --git a/tests/unit/test_post_install.cpp b/tests/unit/test_post_install.cpp index 318ff8b9..da0a62a7 100644 --- a/tests/unit/test_post_install.cpp +++ b/tests/unit/test_post_install.cpp @@ -3,6 +3,7 @@ import std; import mcpp.toolchain.post_install; +import mcpp.platform.xlings; import mcpp.config; import mcpp.toolchain.registry; import mcpp.platform; @@ -269,9 +270,48 @@ TEST(GlibcPayload, TwoRefinementsAreRefusedRatherThanChosenBetween) { make_glibc_payload(fx.root, "2.44.1"); make_glibc_payload(fx.root, "2.44.2"); auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); - ASSERT_FALSE(got.has_value()); - EXPECT_NE(got.error().find("none of them is its resolution"), std::string::npos) - << got.error(); + EXPECT_FALSE(got.has_value()); +} + +// ⭐⭐ THE RESOLVER ITSELF, because it has TWO callers and they failed +// separately. The first version of this fix lived inside the toolchain fixup; +// `probe`'s compile-side payload discovery spelled the same lookup its own way +// and kept missing — and ITS failure names no version at all: +// +// bits/os_defines.h:39: fatal error: features.h: No such file +// +// because the glibc include directory is simply never added. Asserting on the +// shared function is what makes both call sites covered by one test. +TEST(PayloadDirForVersion, ExactWinsOverRefinement) { + GlibcRootFixture fx{"mcpp_pdfv_exact"}; + std::filesystem::create_directories(fx.root / "2.44"); + std::filesystem::create_directories(fx.root / "2.44.2"); + auto got = mcpp::xlings::paths::payload_dir_for_version(fx.root, "2.44"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->filename(), "2.44"); +} + +TEST(PayloadDirForVersion, OneRefinementIsTheAnswer) { + GlibcRootFixture fx{"mcpp_pdfv_one"}; + std::filesystem::create_directories(fx.root / "2.44.2"); + auto got = mcpp::xlings::paths::payload_dir_for_version(fx.root, "2.44"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->filename(), "2.44.2"); +} + +TEST(PayloadDirForVersion, TwoRefinementsAreNotAnAnswer) { + GlibcRootFixture fx{"mcpp_pdfv_two"}; + std::filesystem::create_directories(fx.root / "2.44.1"); + std::filesystem::create_directories(fx.root / "2.44.2"); + EXPECT_FALSE(mcpp::xlings::paths::payload_dir_for_version(fx.root, "2.44")); +} + +// ⚠️ PER COMPONENT, NOT PER CHARACTER — a string prefix would hand a build the +// wrong C library and say nothing. +TEST(PayloadDirForVersion, AStringPrefixIsNotARefinement) { + GlibcRootFixture fx{"mcpp_pdfv_strprefix"}; + std::filesystem::create_directories(fx.root / "2.44"); + EXPECT_FALSE(mcpp::xlings::paths::payload_dir_for_version(fx.root, "2.4")); } TEST(GlibcPayload, NothingInstalledIsStillRefused) { From 2533735218928b8ddff65b9a3962b3a1e01101d5 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 10:56:57 +0800 Subject: [PATCH 07/20] =?UTF-8?q?fix(prepare):=20build.mcpp=20=E7=9A=84?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E5=B7=A5=E5=85=B7=E9=93=BE=E4=B8=8D=E8=AF=A5?= =?UTF-8?q?=E7=BB=A7=E6=89=BF=E7=9B=AE=E6=A0=87=E4=BE=A7=E7=9A=84=20C=20?= =?UTF-8?q?=E5=BA=93=E5=87=BA=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_program.cppm` 的开篇写着这条不变量 ——「`tc` 永远是一个宿主目标的工具链」 —— 而原生分支直接返回主 `tc`。对除此之外的每个字段它都成立(原生构建里编译器 就是宿主编译器);`cAbiPrebuilt` 是「同一个编译器」与「同一个目标侧」第一次分开的 那个字段。 ⚠️ **这是一条不变量,不是任何已测失败的修复。** 它是在追 openkal-musl CI 上那条 `features.h: No such file` 时写下的,而**它不是那条的成因** —— 在 origin/main 和 本分支上实测,gcc 的 std 模块两边都是零个 `-isystem`/`-idirafter`:那条工具链是 通过 post-install 重写的 specs 到达它的 C 库的,真正的缺陷在于**那些 specs 指名的是 哪一个 glibc 载荷**。 保留它,是因为这条不变量值得为真:一个在构建机上编译并运行的助手程序不该继承目标的 C 库出处,而下一个分开的字段将在这里找不到规则。 --- src/build/prepare.cppm | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 12091de1..8768e0ce 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -2781,9 +2781,44 @@ prepare_build(bool print_fingerprint, std::optional> hostTcCache; auto host_tc_for_build_program = [&]() -> std::expected< std::pair, std::string> { + // ⭐⭐ A HOST TOOLCHAIN'S C LIBRARY IS THE PAYLOAD'S, WHATEVER THE + // PROJECT'S TARGET SIDE IS. + // + // `build.mcpp` is compiled AND RUN on the machine doing the build. Its + // C library therefore comes from the compiler payload — even for a + // project whose TARGET takes its C library from the dependency graph. + // The two are different machines and this function's whole job is to + // keep them apart. + // + // ⚠️ AND THE NATIVE BRANCH BELOW RETURNS THE MAIN `tc`, WHICH CARRIES + // THE OTHER ANSWER. `build_program.cppm`'s own header states the + // invariant — "`tc` is always a HOST-targeting toolchain" — and for + // every field but this one the native branch satisfied it, because on a + // native build the compiler IS the host compiler. `cAbiPrebuilt` is the + // first field where "same compiler" and "same target side" come apart. + // + // ⚠️ AN INVARIANT, NOT A BUG FIX FOR ANY MEASURED FAILURE. It was + // written while chasing a `features.h: No such file` on openkal-musl's + // CI and it is NOT that failure's cause: measured on `origin/main` and + // on this branch, the gcc std module carries zero `-isystem`/ + // `-idirafter` rows either way — that toolchain reaches its C library + // through the specs the post-install fixup rewrites, and the real + // defect was in resolving WHICH glibc payload those specs name. + // + // Kept because the invariant is worth being true: a helper compiled and + // run on the build machine must not inherit the target's C-library + // origin, and the next field that comes apart would find no rule here. + // + // ⇒ Stated once, so every consumer (the std module build, + // `host_base_flags`) gets it without asking. + auto as_host = [](mcpp::toolchain::Toolchain t) { + t.cAbiPrebuilt = true; + return t; + }; if (overrides.target_triple.empty()) - return std::pair{explicit_compiler, *tc}; - if (hostTcCache) return *hostTcCache; + return std::pair{explicit_compiler, as_host(*tc)}; + if (hostTcCache) + return std::pair{hostTcCache->first, as_host(hostTcCache->second)}; if (!tcSpec || *tcSpec == "system" || tcSpecIsMsvc) { // ⭐ A READABLE REFUSAL THAT HAD NO CODE, so the target matrix // recorded four identical `other` cells for it. The sentence was @@ -2828,7 +2863,7 @@ prepare_build(bool print_fingerprint, mcpp::ui::info("Resolved", std::format( "host toolchain for build.mcpp: {}", htc->label())); hostTcCache = std::pair{frontend, *htc}; - return *hostTcCache; + return std::pair{hostTcCache->first, as_host(hostTcCache->second)}; }; // Resolve dependencies: walk the **transitive** graph from the main From b3735fcf4fcdf4d6c63ab2567a05d4338140c968 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 11:12:58 +0800 Subject: [PATCH 08/20] =?UTF-8?q?Revert=20"chore(xlings):=20pin=202026.8.1?= =?UTF-8?q?7.2=20->=202026.8.27.1"=20=E2=80=94=E2=80=94=20=E5=AE=83?= =?UTF-8?q?=E6=8A=8A=E7=BB=91=E5=AE=9A=E7=A7=BB=E5=88=B0=E4=BA=86=E5=B7=B2?= =?UTF-8?q?=E8=A3=85=E8=BD=BD=E8=8D=B7=E7=9A=84=E5=89=8D=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ **bump 是对的方向,但在这次过渡里它制造了一个新的失败模态,而且比原来那个更糟。** ## 实测出来的因果 `acquire_xlings_binary` 会在 pin 前移时**更新沙箱里那份 xlings** (`fallback/xlings_binary.cppm:55`)。而沙箱那份才是解析 runtime binding 的那个 —— `.github/actions/bootstrap-mcpp/action.yml` 自己的注释就写着这件事: The SANDBOX xlings is a separate copy: mcpp vendors it into ~/.mcpp/registry/bin at `self init` and never revisits it … and it — not the system binary — is what resolves dependencies. 于是在一次 CI 里发生的是: 1. 引导用的已发布 mcpp(沙箱 xlings 8.17.2)绑 `glibc@2.44`,装出 `2.44.2` 2. 我的构建把沙箱 xlings 升到 2026.8.27.1,绑定**前移**到 `glibc@2.44.2` 3. 而已经装好的是 `2.44` ⇒ 请求比已装的**更新** ⭐ 第 3 步正是 `payload_dir_for_version` **正确地不覆盖**的方向:接受一个更旧的载荷 去回答一个更新的请求,等于静默地用上索引明说「IT IS NOT THE ONE TO INSTALL」的那份 —— 它带着 `mcpp#484` 的 preload 缺陷。**拒绝是对的。** ## 保留的是哪一半 `payload_dir_for_version` 保留:它覆盖的是**请求比已装的更旧**那一侧 (绑定说 `2.44`,装好的是 `2.44.2`),那是安全的方向,也是这次事件最初的形状。 pin 的前移留给一次**独立的**变更:它需要与「谁来装那个新绑定指名的载荷」一起做, 而那不是本 PR 的题目。 --- .github/actions/bootstrap-mcpp/action.yml | 8 ++++---- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +++--- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 ++-- .github/workflows/release.yml | 14 +++++++------- src/platform/xlings/xlings.cppm | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index fb9f3fe8..6b50301b 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.27.1' + default: '2026.8.17.2' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false @@ -49,7 +49,7 @@ runs: # `ubuntu-24.04-arm` restored what `ubuntu-24.04` had stored. Measured # the first time this repo scheduled an arm Linux runner: # - # Cache hit for: xlings-Linux-v2-xl2026.8.27.1-… + # Cache hit for: xlings-Linux-v2-xl2026.8.17.2-… # …/bin/xlings: cannot execute binary file: Exec format error # # ⭐ AND IT SURVIVED THE FIX ONE LAYER DOWN. Reading `uname -m` to pick @@ -93,7 +93,7 @@ runs: # invisible until a Linux runner is not x86_64. Measured on # `ubuntu-24.04-arm`, the first time this repo scheduled one: # - # .../xlings-2026.8.27.1-linux-x86_64/subos/default/bin/xlings: + # .../xlings-2026.8.17.2-linux-x86_64/subos/default/bin/xlings: # cannot execute binary file: Exec format error # Process completed with exit code 126 # @@ -118,7 +118,7 @@ runs: "${WORK}/${tarball}" tar -xzf "${WORK}/${tarball}" -C "${WORK}" # ⚠️⚠️ THE TWO LINUX TARBALLS DO NOT HAVE THE SAME SHAPE, and this line - # named one of them. Measured on the 2026.8.27.1 assets: + # named one of them. Measured on the 2026.8.17.2 assets: # # linux-x86_64 → subos/default/bin/xlings (513 entries) # linux-aarch64 → bin/xlings (494 entries) diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index fdbb33b9..3cfdd438 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.27.1' + default: '2026.8.17.2' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 52dd2283..ecbfb38e 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.27.1' + XLINGS_VERSION: '2026.8.17.2' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index f8fb73bf..2995291c 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -364,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 039de18f..6b443920 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -237,7 +237,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index ed4fc2c5..8c2e1517 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -122,7 +122,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.27.1' + XLINGS_VERSION: '2026.8.17.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -263,7 +263,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.1' + XLINGS_VERSION: '2026.8.17.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b479930..780c1ccb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.27.1' + XLINGS_VERSION: '2026.8.17.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.1' + XLINGS_VERSION: '2026.8.17.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -360,7 +360,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.27.1-linux-aarch64.tar.gz" + XLA="xlings-2026.8.17.2-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -369,9 +369,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.27.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.17.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.27.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.17.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -449,7 +449,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.1' + XLINGS_VERSION: '2026.8.17.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -632,7 +632,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.1' + XLINGS_VERSION: '2026.8.17.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index 3ca3c8f1..88de3cb5 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -45,7 +45,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.27.1"; + inline constexpr std::string_view kXlingsVersion = "2026.8.17.2"; inline constexpr std::string_view kNasmVersion = "3.02"; } From 6e310aa39792f920950c0a0ed4fcdd8e6a2963ef Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 11:14:55 +0800 Subject: [PATCH 09/20] =?UTF-8?q?ci:=20=E6=8A=8A=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E7=BB=91=E5=AE=9A=E6=8C=87=E5=90=8D=E7=9A=84?= =?UTF-8?q?=20glibc=20=E8=BD=BD=E8=8D=B7=E8=A3=85=E7=BB=99=E5=AE=83(?= =?UTF-8?q?=E8=BF=87=E6=B8=A1=E6=AD=A5=E9=AA=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xim:glibc` 的 `latest` 从 `2.44` 移到了 `2.44.2`。载荷目录按请求**解析成**的 版本命名,而 RuntimeBinding 带的是**声明的**版本 —— 而一个已发布的 mcpp vendor 进 自己沙箱的那份 xlings 仍然声明 `2.44`。于是干净机器装出 `2.44.2`,工具链 fixup 要 `2.44`,构建在编译任何东西之前就停住。 ⚠️ 在每一台**新**机器上出现,在任何已存在的机器上都不出现 —— 所以开发机看不见。 在 `main` 上和在任何分支上一样(已用 `mcpp_ref=main` 的对照 run 证实)。 ⭐ 待一个能解析它的 mcpp 发布之后移除:2026.8.27.1 接受「版本精化了请求」的已装载荷 (`payload_dir_for_version`),从它引导就不需要这一步了。在那之前,缺的那个载荷 直接装上。 共享的 `bootstrap-mcpp` action 覆盖 6 个 workflow;另外两个自己引导的 (`ci-fresh-install`、`ci-aarch64-fresh-install`)单独插入。 --- .github/actions/bootstrap-mcpp/action.yml | 56 +++++++++++++++++++ .../workflows/ci-aarch64-fresh-install.yml | 28 ++++++++++ .github/workflows/ci-fresh-install.yml | 28 ++++++++++ 3 files changed, 112 insertions(+) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 6b50301b..5cdbff2f 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -168,6 +168,34 @@ runs: else echo "sandbox xlings: (not initialised yet)" fi + # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. + # + # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload + # directory is named after the version a request RESOLVED to, while a + # RuntimeBinding carries the version that was DECLARED — and the xlings + # a released mcpp vendors into its own sandbox still declares `2.44`. + # So a clean machine installs `2.44.2`, the toolchain fixup asks for + # `2.44`, and the build stops before anything is compiled: + # + # error: selected RuntimeBinding glibc@2.44 requires payload + # '…/xpkgs/xim-x-glibc/2.44', but it is not installed + # + # ⚠️ On every NEW machine and on none that already existed, which is why + # it is invisible from a developer's own. Measured on `main` as readily + # as on any branch — the index records the same failure verbatim in + # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA + # and the client is a PROGRAM: the consumer ships first." + # + # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` + # accepts an installed payload whose version REFINES the requested one + # (`payload_dir_for_version`), so a bootstrap from it needs nothing + # here. Until then the missing payload is simply installed. + if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then + XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ + "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ + >/dev/null 2>&1 || true + echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" + fi echo "MCPP=$MCPP" >> "$GITHUB_ENV" echo "XLINGS_BIN=$HOME/.xlings/subos/default/bin/xlings" >> "$GITHUB_ENV" @@ -201,6 +229,34 @@ runs: else echo "sandbox xlings: (not initialised yet)" fi + # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. + # + # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload + # directory is named after the version a request RESOLVED to, while a + # RuntimeBinding carries the version that was DECLARED — and the xlings + # a released mcpp vendors into its own sandbox still declares `2.44`. + # So a clean machine installs `2.44.2`, the toolchain fixup asks for + # `2.44`, and the build stops before anything is compiled: + # + # error: selected RuntimeBinding glibc@2.44 requires payload + # '…/xpkgs/xim-x-glibc/2.44', but it is not installed + # + # ⚠️ On every NEW machine and on none that already existed, which is why + # it is invisible from a developer's own. Measured on `main` as readily + # as on any branch — the index records the same failure verbatim in + # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA + # and the client is a PROGRAM: the consumer ships first." + # + # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` + # accepts an installed payload whose version REFINES the requested one + # (`payload_dir_for_version`), so a bootstrap from it needs nothing + # here. Until then the missing payload is simply installed. + if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then + XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ + "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ + >/dev/null 2>&1 || true + echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" + fi echo "MCPP=$MCPP" >> "$GITHUB_ENV" echo "XLINGS_BIN=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe")" >> "$GITHUB_ENV" diff --git a/.github/workflows/ci-aarch64-fresh-install.yml b/.github/workflows/ci-aarch64-fresh-install.yml index b451203e..df276c10 100644 --- a/.github/workflows/ci-aarch64-fresh-install.yml +++ b/.github/workflows/ci-aarch64-fresh-install.yml @@ -68,6 +68,34 @@ jobs: - name: Fresh-install mcpp via xlings run: | xlings install mcpp -y + # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. + # + # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload + # directory is named after the version a request RESOLVED to, while a + # RuntimeBinding carries the version that was DECLARED — and the xlings + # a released mcpp vendors into its own sandbox still declares `2.44`. + # So a clean machine installs `2.44.2`, the toolchain fixup asks for + # `2.44`, and the build stops before anything is compiled: + # + # error: selected RuntimeBinding glibc@2.44 requires payload + # '…/xpkgs/xim-x-glibc/2.44', but it is not installed + # + # ⚠️ On every NEW machine and on none that already existed, which is why + # it is invisible from a developer's own. Measured on `main` as readily + # as on any branch — the index records the same failure verbatim in + # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA + # and the client is a PROGRAM: the consumer ships first." + # + # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` + # accepts an installed payload whose version REFINES the requested one + # (`payload_dir_for_version`), so a bootstrap from it needs nothing + # here. Until then the missing payload is simply installed. + if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then + XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ + "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ + >/dev/null 2>&1 || true + echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" + fi mcpp --version mcpp self config --mirror GLOBAL 2>/dev/null || true diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 2995291c..7c1fc7ed 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -466,6 +466,34 @@ jobs: formula_version="$(brew info --json=v2 mcpp-community/mcpp/mcpp-m \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["formulae"][0]["versions"]["stable"])')" echo "formula declares: $formula_version" + # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. + # + # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload + # directory is named after the version a request RESOLVED to, while a + # RuntimeBinding carries the version that was DECLARED — and the xlings + # a released mcpp vendors into its own sandbox still declares `2.44`. + # So a clean machine installs `2.44.2`, the toolchain fixup asks for + # `2.44`, and the build stops before anything is compiled: + # + # error: selected RuntimeBinding glibc@2.44 requires payload + # '…/xpkgs/xim-x-glibc/2.44', but it is not installed + # + # ⚠️ On every NEW machine and on none that already existed, which is why + # it is invisible from a developer's own. Measured on `main` as readily + # as on any branch — the index records the same failure verbatim in + # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA + # and the client is a PROGRAM: the consumer ships first." + # + # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` + # accepts an installed payload whose version REFINES the requested one + # (`payload_dir_for_version`), so a bootstrap from it needs nothing + # here. Until then the missing payload is simply installed. + if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then + XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ + "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ + >/dev/null 2>&1 || true + echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" + fi mcpp --version mcpp --version | grep -Fq "$formula_version" From d335ff33e5a87c46b2eec9fa86670fb5e68e03a1 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 11:30:50 +0800 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20=E5=A4=B4=E9=9B=86=E8=BD=B4?= =?UTF-8?q?=E6=8A=8A=E8=BF=99=E5=8F=B0=E6=9C=BA=E5=99=A8=E7=9A=84=20home?= =?UTF-8?q?=20=E5=86=99=E8=BF=9B=E4=BA=86=E9=94=AE(=E8=87=AA=E6=88=91=20re?= =?UTF-8?q?view=20=E5=AE=9E=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新轴的注释承诺「键要能跨 MCPP_HOME 存活」,而只做 `` 一档的规则兑现不了它。 本机自己的工具链走 `CLibMode::Sysroot`,它唯一的编译 token 是 --sysroot=/home//.mcpp/registry/subos/default —— 在 HOME 底下而不在 `` 底下。⇒ 每个条目都带上了这台机器的 home,两台装着 同样载荷的机器互相命不中。⚠️ 不是错的答案,是**不可共享**的答案 —— 而 `normalize_driver_output` 正是为了保住这条性质才故意丢掉路径信息的。 补第二档 ``,和 `fill_package_config` 已有的 ``/`` 同形。 从 storeRoot 推导而不再传一个参数:两者必须是同一个 home,多一个参数就多一种 它们描述两台机器的写法。 ⭐ 顺带把这个轴分不分得开什么写进注释,免得下一个读者重新推一遍: 载荷带版本号 ⇒ 两个载荷两个键(#514 §B,本轴存在的理由);一个 home 下的两个 subos ⇒ 两个键(此前没有);两个 home 下同名的 subos ⇒ 仍是一个键(与此前一致,由整工程 指纹的第 11 项区分)。 ⚠️ 已有的两个单测**直接给轴赋值**,所以都没跑到相对化那段 —— 只做 `` 也全绿。 新增 `TheHeaderSetCarriesNoAbsoluteHome`,判据是**home 不出现**而不是「有 `` 前缀」(后者在前缀后面还拖着绝对路径时照样通过)。A/B 实测:移掉这一档后它失败, 逐字打印上面那行。 另:候选去重时 `index` 改为**跟着 `p` 一起动**。两者描述同一个包,今天只有同一个 `pkgIndex` 能走到那个分支,所以还差不出来;绑在一起是为了将来也差不出来。 --- src/build/cache_key.cppm | 41 ++++++++++++++++++++++++++---- src/build/prepare.cppm | 12 ++++++--- tests/unit/test_cache_key.cpp | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 34430971..e9b0ea32 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -392,18 +392,49 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, // compile line reads it. Asking it here means the key describes the header // set that will actually be used, and cannot drift from it. // - // ⚠️ Store-relative. The absolute form names this machine's home, and the - // whole point of `normalize_driver_output` stripping paths is that one - // entry can serve two homes carrying the same payloads. Leaving these - // absolute would undo that; folding them in relative distinguishes - // DIFFERENT payloads while still sharing between homes. + // ⚠️ RELATIVE, IN TWO TIERS — and `` ALONE IS NOT ENOUGH. + // + // The absolute form names this machine's home, and the whole point of + // `normalize_driver_output` stripping paths is that one entry can serve + // two homes carrying the same payloads. Leaving these absolute undoes it. + // + // ⚠️ MEASURED, AND `` MISSED THE COMMON CASE. This developer's own + // toolchain resolves `CLibMode::Sysroot`, whose only compile token is + // + // --sysroot=/home//.mcpp/registry/subos/default + // + // — under the HOME and not under ``, so a store-only rule left the + // home in the key and every entry stopped being shareable. `` is the + // second tier for exactly this, mirroring the ``/`` pair + // `fill_package_config` already uses for the same reason. + // + // ⭐ WHAT THIS AXIS DOES AND DOES NOT SEPARATE, stated so the next reader + // does not have to re-derive it: + // * payload-supplied headers — `/xim-x-glibc/2.44/include` — carry + // the VERSION in the path, so two payloads are two keys. This is the + // measured mcpp#514 §B case and the one this axis exists for. + // * two subos on one home — `/subos/default` vs `/subos/foo` + // — are two keys, which they were not before this axis existed. + // * the SAME subos name in two homes stays one key even if the two hold + // different C libraries. Unchanged from before, and the same trade + // `normalize_driver_output` already makes; the whole-project + // fingerprint's field 11 is what separates those. { const auto storeStr = storeRoot.generic_string(); + // `/registry/data/xpkgs` → `/registry`. Derived rather than + // passed because the two must be the same home by construction: a + // second parameter could be given a different one, and then the tiers + // would describe two machines. + const auto homeStr = storeRoot.empty() + ? std::string{} + : storeRoot.parent_path().parent_path().generic_string(); const mcpp::toolchain::PathEscape relativize = [&](const std::filesystem::path& p) -> std::string { auto str = p.generic_string(); if (!storeStr.empty() && str.starts_with(storeStr)) return "" + str.substr(storeStr.size()); + if (!homeStr.empty() && str.starts_with(homeStr)) + return "" + str.substr(homeStr.size()); return str; }; for (auto& t : mcpp::toolchain::resolve_clang_driver(tc) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 8768e0ce..7c385064 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -6695,9 +6695,15 @@ prepare_build(bool print_fingerprint, auto same = std::find_if(slot.begin(), slot.end(), [&](const Candidate& c){ return c.p.name == p.name; }); if (same != slot.end()) { - if (same->p.interfaceName.empty() && !p.interfaceName.empty()) - same->p = p; - same->index = pkgIndex; + // ⚠️ `index` MOVES WITH `p` AND NOT ON ITS OWN. The two + // describe one package, and this branch is reached only + // from the same `pkgIndex` today — a package carrying both + // spellings — so they cannot differ yet. Tying them keeps + // it that way if a second package ever reaches here. + if (same->p.interfaceName.empty() && !p.interfaceName.empty()) { + same->p = p; + same->index = pkgIndex; + } } else { slot.push_back({ p, is_direct(p.name), pkgIndex }); } diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index c61b100d..20051e55 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -409,3 +409,50 @@ TEST(CacheKey, TheHeaderSetIsRecordedInTheEntry) { EXPECT_EQ(j["toolchain"]["target_header_set"][0], "-isystem/xim-x-glibc/2.44/include"); } + +// ⭐⭐ AND THE PATHS IN IT ARE RELATIVE — INCLUDING THE ONES OUTSIDE ``. +// +// ⚠️ THE TWO TESTS ABOVE SET THE AXIS DIRECTLY, so neither of them runs the +// relativization, and a store-only rule passed both while leaving this +// developer's own home in every key. The toolchain that showed it is the +// ordinary one: `CLibMode::Sysroot`, whose single compile token is +// +// --sysroot=/registry/subos/default +// +// — under the HOME and not under ``. An absolute key there is not +// WRONG, it is unshareable: two homes holding the same payloads stop hitting +// each other's entries, which is the property `normalize_driver_output` gives +// up path information to preserve. +// +// ⭐ THE CRITERION IS THE ABSENCE OF THE HOME, not the presence of a tag. +// Asserting `starts_with("")` would still pass if the rest of the string +// carried the absolute path behind it. +TEST(CacheKey, TheHeaderSetCarriesNoAbsoluteHome) { + const std::filesystem::path home = "/home/somebody/.mcpp/registry"; + const std::filesystem::path store = home / "data" / "xpkgs"; + + mcpp::toolchain::Toolchain tc; + tc.compiler = mcpp::toolchain::CompilerId::GCC; // no cfg ⇒ Sysroot mode + tc.version = "16.1.0"; + tc.driverIdent = "gcc-16.1.0"; + tc.targetTriple = "x86_64-linux-gnu"; + tc.sysroot = home / "subos" / "default"; + + mcpp::manifest::Manifest m; + m.package.standard = "c++23"; + const auto a = ck::build_axes(tc, m, "-std=c++23", {}, "", store); + + // A denominator: with an empty axis every assertion below is vacuous, and + // an empty axis is itself the bug in the other direction. + ASSERT_FALSE(a.targetHeaderSet.empty()); + for (auto const& t : a.targetHeaderSet) + EXPECT_EQ(t.find(home.generic_string()), std::string::npos) << t; + + // Two subos on ONE home are still two keys — the relativization must not + // flatten the distinction it exists to preserve. + auto tc2 = tc; + tc2.sysroot = home / "subos" / "musl"; + const auto b = ck::build_axes(tc2, m, "-std=c++23", {}, "", store); + EXPECT_NE(a.targetHeaderSet, b.targetHeaderSet); + EXPECT_NE(ck::key_hex(a, pkg()), ck::key_hex(b, pkg())); +} From 4ed751144dd11a73e14254ebc7d15f5732b12874 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 11:40:50 +0800 Subject: [PATCH 11/20] =?UTF-8?q?fix:=20=E7=A9=BA=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=BC=9A=E6=8A=8A=E5=8C=85=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=9C=AC=E8=BA=AB=E5=BD=93=E6=88=90=E8=BD=BD=E8=8D=B7=E8=BF=94?= =?UTF-8?q?=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `payload_dir_for_version(root, "")` 里 `root / ""` 就是 `root`,而它**是**一个 目录 ⇒ 精确匹配那一支把**容器**交了出去,调用者会把它当载荷用。 ⚠️ 今天两个调用者都在到达这里之前拒了空版本(`probe` 靠 `at + 1 >= size()`, `post_install` 有显式的 `version.empty()` 判断)—— 这恰恰是没有任何东西会撞上它的 原因。函数是导出的,第三个调用者不会知道这条约束写在别处。⇒ 闸放在性质成立的地方, 而不是放在每个调用者那里。 A/B 实测:移掉这一行后 `AnEmptyRequestIsNotTheContainer` 失败;测试自带分母 (同一棵树上真实的请求仍要解析得出来),空结果才不会和「目录读不了」同读数。 --- src/platform/xlings/xlings.cppm | 8 ++++++++ tests/unit/test_post_install.cpp | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index 88de3cb5..0f8a5e84 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -997,6 +997,14 @@ find_sibling_package(const std::filesystem::path& compilerBin, std::optional payload_dir_for_version(const std::filesystem::path& packageRoot, std::string_view version) { + // ⚠️ AN EMPTY REQUEST IS NOT A REQUEST FOR EVERYTHING. `packageRoot / ""` + // is `packageRoot` itself, which IS a directory — so without this the + // exact-match branch below would hand back the package root and every + // caller would treat that container as a payload. Both callers today + // reject an empty version before arriving here; this is the invariant + // stated where it holds rather than at each of them. + if (version.empty()) return std::nullopt; + std::error_code ec; auto exact = packageRoot / std::string(version); if (std::filesystem::is_directory(exact, ec)) return exact; diff --git a/tests/unit/test_post_install.cpp b/tests/unit/test_post_install.cpp index da0a62a7..61d56483 100644 --- a/tests/unit/test_post_install.cpp +++ b/tests/unit/test_post_install.cpp @@ -319,3 +319,23 @@ TEST(GlibcPayload, NothingInstalledIsStillRefused) { auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); EXPECT_FALSE(got.has_value()); } + +// ⚠️ AN EMPTY REQUEST IS NOT A REQUEST FOR EVERYTHING. +// +// `packageRoot / ""` is `packageRoot`, and that IS a directory — so the +// exact-match branch would hand the CONTAINER back and every caller would +// treat it as a payload. Both callers reject an empty version before arriving, +// which is exactly why nothing would have caught it here. +TEST(PayloadDirForVersion, AnEmptyRequestIsNotTheContainer) { + auto root = std::filesystem::temp_directory_path() + / "mcpp_payload_empty_request"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root / "2.44.2"); + + EXPECT_FALSE(mcpp::xlings::paths::payload_dir_for_version(root, "")); + // The denominator: a real request against the same tree still resolves, + // so an empty result above is the guard and not an unreadable directory. + EXPECT_TRUE(mcpp::xlings::paths::payload_dir_for_version(root, "2.44")); + + std::filesystem::remove_all(root); +} From 5087d3a29a7dccf026aa86d1d9d76e002863d817 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 11:43:58 +0800 Subject: [PATCH 12/20] =?UTF-8?q?docs:=20=E6=96=B0=E9=94=AE=E5=9C=A8?= =?UTF-8?q?=E6=97=A7=E5=BC=95=E6=93=8E=E4=B8=8A=E7=9A=84=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?(=E5=AE=9E=E6=B5=8B),=E5=B9=B6=E8=A1=A5=E4=B8=8A=E8=A2=AB?= =?UTF-8?q?=E6=88=91=E6=94=B9=E5=BE=97=E4=B8=8D=E5=87=86=E7=9A=84=E9=82=A3?= =?UTF-8?q?=E6=9D=A1=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 旧引擎怎么读这个键 —— 实测,不是推断 在 **2026.8.26.2** 上跑: * 出现在**依赖**清单里 ⇒ 静默接受,退 0 * 出现在**根**清单里 ⇒ `warning: [build] has unsupported key 'private_include_dirs' (ignored)`,退 0 ⇒ 包可以先用上它,不必等消费者升级。这条写进中英两份文档,因为它决定了 openkal-musl 现在能不能发 —— 能。 ⚠️ **唯一不成立的地方**:已发布 `xim` 描述符的 `target_cfg` 块里,不认识的子键是 **硬错误**(那段代码自己的注释就写着 "Unknown sub-keys stay a HARD ERROR here"), 会让整份清单加载失败。文档明说:索引下限指向认识它的引擎之前别写进那里。 ## 消息是承诺,而我把它改得不准了 `target_cfg` 的未知键报错列出「期望哪些键」,我加了 `private_include_dirs` 却没有 把它加进那份列表 —— 于是错误信息会把一个**已经被接受**的键说成不存在。补上。 --- docs/05-mcpp-toml.md | 10 ++++++++++ docs/zh/05-mcpp-toml.md | 7 +++++++ src/manifest/xpkg.cppm | 3 ++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 9ba45f73..ff02455a 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -219,6 +219,16 @@ after expansion — so a glob may name exactly the directories it expands to. An entry that is not among this package's `include_dirs` withholds nothing and is reported as such rather than passing in silence. +**On an older engine the key is ignored, never fatal.** Measured on 2026.8.26.2: +in a dependency's manifest it is accepted silently, and in a root manifest it +warns — `[build] has unsupported key 'private_include_dirs' (ignored)` — and the +build continues. So a package may adopt the key without waiting for its +consumers to upgrade; those on an older engine simply keep receiving the +directory as they did before. The one place this does **not** hold is a +published `xim` descriptor's `target_cfg` block, where an unrecognised sub-key +is a hard error that fails the whole manifest — do not put this key there until +the index floor names an engine that knows it. + `include_dirs_after` (#249) lists header directories that are searched **after** the toolchain's system directories (emitted as `-idirafter` on GCC/Clang, as trailing `/I` under the MSVC dialect, and as plain `-I` for NASM diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 4914bbed..cbf38566 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -201,6 +201,13 @@ private_include_dirs = ["musl/src/include"] 所以一个 glob 可以恰好指名它展开出的那些目录。若某条目不在本包的 `include_dirs` 里,它什么也没扣下,mcpp 会把这件事说出来而不是让它悄悄通过。 +**旧引擎会忽略这个键,而不会因此失败。** 在 2026.8.26.2 上实测:出现在依赖的清单里 +时被静默接受;出现在根清单里时给一条警告 —— `[build] has unsupported key +'private_include_dirs' (ignored)` —— 构建照常继续。所以一个包可以先用上这个键, +不必等消费者升级;还在旧引擎上的消费者只是像以前一样继续收到那个目录。**唯一不成立 +的地方**是已发布的 `xim` 描述符的 `target_cfg` 块:那里不认识的子键是硬错误,会让 +整份清单加载失败 —— 在索引下限指向认识它的引擎之前,不要把这个键写进那里。 + `include_dirs_after`(#249)列出**排在工具链系统目录之后**搜索的头文件目录 (GCC/Clang 发射为 `-idirafter`;MSVC 方言退化为排在末尾的 `/I`,NASM 汇编 单元退化为普通 `-I`——两者都没有对应 flag,也都没有需要保护的系统头搜索链)。当目录是解压后的源码 tarball 根目录、且其中的文件名会与标准头冲突时, diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index 8b6069be..eb526114 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -1411,7 +1411,8 @@ synthesize_from_xpkg_lua(std::string_view luaContent, return std::unexpected(ManifestError{ std::format("unknown target_cfg key '{}' (expected " "cflags/cxxflags/ldflags/sources/defines/" - "flags/include_dirs/include_dirs_after)", sub), + "flags/include_dirs/private_include_dirs/" + "include_dirs_after)", sub), m.sourcePath, 0, 0}); } if (!cur.consume('=') || !cur.consume('{')) { From ff24a884ec36f4d336797258ffcbc0127c212fe3 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 11:47:28 +0800 Subject: [PATCH 13/20] =?UTF-8?q?docs(changelog):=20=E8=A1=A5=E5=9B=9E?= =?UTF-8?q?=E6=BC=8F=E6=8E=89=E7=9A=84=E9=82=A3=E6=9D=A1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?,=E5=B9=B6=E8=AE=A2=E6=AD=A3=E4=B8=80=E5=8F=A5=E5=B7=B2?= =?UTF-8?q?=E7=BB=8F=E8=BF=87=E6=97=B6=E7=9A=84=E8=AF=B4=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **载荷版本解析整条没写进去** —— 而它是这一版里用户最先撞上的一条(干净机器 上引导直接停住)。补上,含判据:精化是逐**分量**比而不是逐字符,两个都精化时返回 空而不是按目录序挑,反方向不接受。 订正:`targetHeaderSet` 写的是「store-相对化」,自我 review 时实测发现只做这一档 盖不住最常见的那台机器(`--sysroot=/registry/subos/default` 在 HOME 底下), 已改成 ``/`` 两档。顺带把这个轴分得开什么写清楚。 --- CHANGELOG.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee330d10..4819456e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,9 +59,29 @@ A 轴上的每一项都在描述**编译器**,没有一项描述它编译时所依据的**库** —— 而两者是分开安装的。`driverIdentity` 按设计也覆盖不了它: `normalize_driver_output` **故意**抹掉路径,好让一个条目能被两个 home 共享。 - 新增 `targetHeaderSet` 轴,取自已经解析好的 `linkmodel`,并 store-相对化。 + 新增 `targetHeaderSet` 轴,取自已经解析好的 `linkmodel`,并**分两档相对化**: + `` 与 ``。⚠️ 只做 `` 一档不够 —— 最常见的那台机器走 + `CLibMode::Sysroot`,它唯一的编译 token 是 `--sysroot=/registry/subos/default`, + 在 HOME 底下而不在 store 底下,于是每个条目都会带上这台机器的 home。分得开什么: + 两个载荷(路径里带版本号)⇒ 两个键;一个 home 下的两个 subos ⇒ 两个键; + 两个 home 下同名的 subos ⇒ 仍是一个键(由整工程指纹的第 11 项区分)。 ⚠️ 不 bump `kCacheEpoch` —— 旧条目是 miss 而不是不可用。 +- **⭐⭐ 请求的版本和载荷目录的版本是两套词汇,而每个查找都按请求那套拼。** + + RuntimeBinding 带的是**声明的**版本(`glibc@2.44`),而 xlings 把载荷目录按这个 + 请求**解析成**的版本命名(`2.44.2`)。索引在同一序列内挪动一次包,所有按声明版本 + 拼目录名的查找就同时失效 —— 在每一台**新**机器上,在任何已存在的机器上都不出现。 + + 两个查找点各自拼过一遍,所以修好一个还剩另一个,而第二处的失败**根本不提版本**: + glibc 的 include 目录只是没被加上,用户读到的是 libstdc++ 头文件里的 + `features.h: No such file`。⇒ 收敛到一个 `payload_dir_for_version`。 + + ⭐ **判据是「精化」,不是「按目录序挑一个」**:`2.44.2` 的版本**分量**以请求的分量 + 开头。`2.4` 回答不了 `2.44`(逐分量比,不是逐字符)。两个载荷都精化同一个请求时 + 返回**空** —— 「这个请求的解析结果」得是唯一一个才配叫答案,而按目录序挑正是这里 + 每个调用者都拒绝做的猜测。反方向(拿更旧的载荷回答更新的请求)不接受。 + - **⭐ home 发现有第四份拷贝,而且会伸到别的 home 里去。** `active_home_xpkgs()` 自己重推了一遍 home(漏掉自包含安装那一档); From 32fc5745276d67fcaf306354fb2bd436afab9288 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 11:51:24 +0800 Subject: [PATCH 14/20] =?UTF-8?q?test:=20=E4=B8=A4=E4=B8=AA=E9=94=AE?= =?UTF-8?q?=E4=BC=9A=E5=9C=A8=E5=90=8C=E4=B8=80=E4=B8=AA=E5=8C=85=E9=87=8C?= =?UTF-8?q?=E7=9B=B8=E9=81=87,=E8=80=8C=E6=B2=A1=E6=9C=89=E4=BB=BB?= =?UTF-8?q?=E4=BD=95=E5=88=A4=E6=8D=AE=E8=A6=86=E7=9B=96=E8=BF=99=E4=BB=B6?= =?UTF-8?q?=E4=BA=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## e2e 304:提供层的包同时带私有目录 `openkal-musl` 就是这个形状 —— `provides = ["mcpp:c-abi=musl"]` **加上**内部头 覆盖层。层的目录到达**每一个**单元,所以在这里误发布覆盖层比在别处都宽:musl 的 `hidden`/`weak` 宏不只落到它的消费者,而是落到整个构建的每一个翻译单元。 两者按构造是相容的(目标侧取的是提供者的 `publicUsage`,而那已经是过滤后的集合) —— 「按构造」正是需要一条断言的地方。反向对照实测:把 fixture 里的 `private_include_dirs` 拿掉,判据当场检出 /tmp/.../dep/src/sibling.cppm ← 兄弟依赖包 /tmp/.../src/main.cpp ← 根 也就是把那句「爆炸半径」从注释变成了读数。⭐ 另加控制:提供者自己的单元必须仍然 带着它,否则「没泄漏」和「整个丢掉了」同读数。 ## e2e 305:glob 在展开之后比对 文档承诺「条目支持 `*` glob 并在展开之后比对」,而没有判据。加 `gen/*` 两条边: 提供者两个目录都要有,消费者一个都不能有。⭐ 判据落到 `gen/one`/`gen/two` **按名** 而不是 `gen`—— 只查 `gen` 的话,一个拿未展开拼写去比的实现照样通过。 反向对照实测:把 glob 从 private 列表里拿掉,两条都变红并各自点名。 --- .../304_the_target_side_reaches_every_unit.sh | 55 +++++++++++++++++-- tests/e2e/305_built_from_is_not_published.sh | 26 +++++++-- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/tests/e2e/304_the_target_side_reaches_every_unit.sh b/tests/e2e/304_the_target_side_reaches_every_unit.sh index 359d3d7d..de1b95f3 100755 --- a/tests/e2e/304_the_target_side_reaches_every_unit.sh +++ b/tests/e2e/304_the_target_side_reaches_every_unit.sh @@ -35,9 +35,22 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT cd "$work" -mkdir -p src abi/src abi/abi-include dep/src dep/dep-include +mkdir -p src abi/src abi/abi-include abi/abi-internal dep/src dep/dep-include -# The provider: supplies a target-side layer and publishes one directory. +# The provider: supplies a target-side layer, publishes one directory, and +# keeps one to itself. +# +# ⭐⭐ THE TWO KEYS OF THIS RELEASE MEET IN THIS ONE PACKAGE, AND THAT IS THE +# REAL-WORLD SHAPE. `openkal-musl` declares `provides = ["mcpp:c-abi=musl"]` +# AND carries an internal header overlay. A layer's directories reach EVERY +# unit, so publishing the overlay by mistake would put musl's `hidden`/`weak` +# macros not merely on its consumers but on every translation unit of the +# build — a strictly wider blast radius than the leak `private_include_dirs` +# was written for. +# +# The two compose correctly by construction (the target side is assembled from +# the provider's `publicUsage`, which is the filtered set), and "by +# construction" is what an assertion is for. cat > abi/mcpp.toml <<'EOF' [package] name = "abiprov" @@ -45,10 +58,12 @@ version = "0.1.0" provides = ["mcpp:c++-abi=libc++"] [build] -include_dirs = ["abi-include"] +include_dirs = ["abi-internal", "abi-include"] +private_include_dirs = ["abi-internal"] EOF printf 'export module abiprov;\nexport int abiprov_v() { return 1; }\n' > abi/src/abiprov.cppm printf '#pragma once\n' > abi/abi-include/marker.h +printf '#pragma once\n' > abi/abi-internal/private.h # The sibling: a dependency package that does NOT depend on the provider. cat > dep/mcpp.toml <<'EOF' @@ -125,4 +140,36 @@ if [ -n "$leaked" ]; then exit 1 fi -echo "OK: the graph-supplied target side reaches every unit ($siblings sibling units), and nothing else does" +# ⭐⭐ AND THE LAYER'S *PRIVATE* DIRECTORY REACHES NOBODY BUT ITSELF. +# +# A layer reaches every unit, so publishing an internal overlay by mistake is +# wider here than anywhere else: it would land on every translation unit of the +# build rather than on the provider's consumers. +private_leak="$(jq -r ' + [ .[] + | select((.file | test("/abi/src/")) | not) + | { f: .file, has: ((.arguments // (.command | split(" "))) | map(test("abi-internal")) | any) } + | select(.has) + | .f + ] | .[]' compile_commands.json)" + +if [ -n "$private_leak" ]; then + echo "FAIL: the layer's PRIVATE directory reached units outside the package:" + printf '%s\n' "$private_leak" | sed 's/^/ /' + exit 1 +fi + +# The control: private is not the same as dropped. The provider's own units +# must still be built with it, or the assertion above passes for a build that +# simply lost the directory. +own_private="$(jq -r ' + [ .[] + | select(.file | test("/abi/src/")) + | select((.arguments // (.command | split(" "))) | map(test("abi-internal")) | any) + ] | length' compile_commands.json)" +if [ "$own_private" -lt 1 ]; then + echo "FAIL: the provider lost its own private directory — the check above proves nothing" + exit 1 +fi + +echo "OK: the graph-supplied target side reaches every unit ($siblings sibling units), its private directory reaches only its own $own_private unit(s), and nothing else spreads" diff --git a/tests/e2e/305_built_from_is_not_published.sh b/tests/e2e/305_built_from_is_not_published.sh index 1d5db9af..427e5cbe 100755 --- a/tests/e2e/305_built_from_is_not_published.sh +++ b/tests/e2e/305_built_from_is_not_published.sh @@ -31,7 +31,7 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT cd "$work" -mkdir -p src lib/src lib/pub lib/internal +mkdir -p src lib/src lib/pub lib/internal lib/gen/one lib/gen/two cat > lib/mcpp.toml <<'EOF' [package] @@ -43,14 +43,21 @@ version = "0.1.0" # this list rather than a second list: the internal overlay must precede the # public headers for the package's OWN build, and two TOML arrays cannot # express one order. -include_dirs = ["internal", "pub"] -private_include_dirs = ["internal"] +# +# ⭐ `gen/*` IS A GLOB ON BOTH LINES. The filter is applied AFTER expansion, so +# the glob withholds exactly the directories it expands to. Comparing the +# unexpanded spellings would be the obvious implementation and would publish +# every one of them, because `gen/*` is not literally equal to `gen/one`. +include_dirs = ["internal", "gen/*", "pub"] +private_include_dirs = ["internal", "gen/*"] EOF printf 'export module vendored;\nexport int vendored_v() { return 7; }\n' > lib/src/vendored.cppm printf '#pragma once\n#define VENDORED_PUBLIC 1\n' > lib/pub/pub.h # The internal overlay: a macro that is meaningful to the package and a # perfectly ordinary identifier to everyone else. printf '#pragma once\n#define hidden __attribute__((visibility("hidden")))\n' > lib/internal/overlay.h +printf '#pragma once\n' > lib/gen/one/one.h +printf '#pragma once\n' > lib/gen/two/two.h cat > mcpp.toml <<'EOF' [package] @@ -107,5 +114,16 @@ fail=0 [ "$(has_dir '/src/main' '/lib/internal')" = false ] || { echo "FAIL: the private directory leaked to the consumer"; fail=1; } +# ⭐ AND THE GLOB WITHHOLDS WHAT IT EXPANDS TO — both directories, by name. +# Checking `gen` alone would pass for an implementation that matched the +# unexpanded spelling and published `gen/one` and `gen/two` anyway. +for g in one two; do + [ "$(has_dir '/lib/src/' "/lib/gen/$g")" = true ] || { + echo "FAIL: the provider lost 'gen/$g', which its own glob names"; fail=1; } + [ "$(has_dir '/src/main' "/lib/gen/$g")" = false ] || { + echo "FAIL: 'gen/$g' leaked to the consumer — the glob was compared unexpanded" + fail=1; } +done + [ "$fail" = 0 ] || exit 1 -echo "OK: built-from and published are separable (provider=$prov consumer=$cons rows)" +echo "OK: built-from and published are separable, globs included (provider=$prov consumer=$cons rows)" From 7325184892df16d7c9230383ee5a3ebf6c5dce99 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 12:07:46 +0800 Subject: [PATCH 15/20] =?UTF-8?q?ci:=20=E6=8C=91=E3=80=8C=E5=88=9A?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=87=BA=E6=9D=A5=E7=9A=84=E9=82=A3=E4=B8=AA?= =?UTF-8?q?=20mcpp=E3=80=8D,=E8=80=8C=E5=AE=83=E6=8C=91=E7=9A=84=E6=98=AF?= =?UTF-8?q?=E8=B5=B0=E5=88=B0=E7=9A=84=E7=AC=AC=E4=B8=80=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `target/` 是**恢复出来的缓存**,键里含 `src/**` 的哈希。源码一改就 miss, `restore-keys` 照样把最近的旧树递回来(分层恢复本来就是为此),构建再把产物写进 一个**新的** `//` 目录,和已有的并排。于是 `find … | head -1` 返回的是遍历先到的那个 —— 不是最新的,也不是这次构建的。 ⚠️ 本机与 CI 双侧实测。本机这棵树上该命令挑到 `007bc0f2b78f7fa5`,而构建刚写的是 `148448dec6c4a7a7`。CI 上挑到的陈旧二进制,其 ELF 解释器指向 runner 上已经不存在的 glibc 载荷: …/bin/mcpp: cannot execute: required file not found (exit 127) —— 读起来像**这个 commit 构建坏了**,而完全不是。连着七次绿之后两次红、同一个陈旧 指纹:触发条件只是「源码改动大到让恢复出来的树里留下两个目录」。 ⭐ **mtime 不能当判据。** 缓存恢复会把所有文件的时间戳写成解压时刻,所以恰恰在出问题 的那棵树上「最新」是没有意义的 —— `ls -t | head -1` 在本仓库挑到过陈旧指纹。 **缺席伪造不了**:先删掉,构建之后还在的就是刚链出来的。对象和 BMI 仍在缓存里, 代价是一次链接。 ⭐ 判据是**恰好一个**而不是「至少一个」:两个就说明删漏了一棵树,这一步要消除的 歧义又回来了。⚠️ 不用 `mapfile` —— 这个 job 也在 macos 上跑,那里的 /bin/bash 是 3.2,没有这个 builtin。 本机实测:删前 4 个候选 → 删后 0 → 重建后 COUNT=1,且路径正是新指纹 `31ec1ef4ce2afcd5`,`--version` 正常。 ⚠️ 同型写法在别的 workflow 里还有(ci-macos / ci-macos-e2e / ci-target-matrix 的 `| head -1`,以及若干 `-printf|sort -rn` 的 mtime 形态)。它们今天是绿的,这次改动 只碰变红的这一处;规则记在这里。 --- .github/workflows/openkal-cross.yml | 47 +++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index 3c0916f7..a9737419 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -128,9 +128,52 @@ jobs: # decisions about compile and link flags; an optimisation level # changes none of them and a release self-build is most of the budget # of a job on a two-core runner. + # ⚠️⚠️ REMOVE ANY mcpp ALREADY UNDER `target/` FIRST, so that after the + # build there is exactly ONE and it is the one this step produced. + # + # `target/` is a RESTORED CACHE whose key hashes `src/**`. A source + # change misses the key, `restore-keys` hands back the nearest older + # tree anyway — that is the point of the layered restore — and the + # build then writes its output into a NEW `//` + # directory beside the ones already there. `find … | head -1` returns + # whichever directory the walk reaches first, which is not the newest + # and is not this build's. + # + # ⚠️ MEASURED, LOCALLY AND ON CI. On this developer's own tree the + # command picks `007bc0f2b78f7fa5` while the build just wrote + # `148448dec6c4a7a7`. On CI it picked a stale binary whose ELF + # interpreter names a glibc payload the runner no longer has: + # + # …/bin/mcpp: cannot execute: required file not found (exit 127) + # + # — which reads as a broken build of THIS commit and is nothing of the + # kind. Two runs, same stale fingerprint, after seven green ones: the + # trigger is simply a source change large enough to leave two + # directories in the restored tree. + # + # ⭐ mtime CANNOT BE THE CRITERION HERE. A cache restore writes every + # file at extraction time, so "newest" is meaningless on exactly the + # tree where this goes wrong — `ls -t | head -1` has picked a stale + # fingerprint in this repository before. Absence can't be faked: + # delete them, and whatever exists afterwards was linked just now. + # + # The objects and BMIs stay cached — this costs one link. + find target -type f \( -name 'mcpp' -o -name 'mcpp.exe' \) -delete 2>/dev/null || true "$MCPP" build --dev - BUILT=$(find target -type f \( -name 'mcpp' -o -name 'mcpp.exe' \) | head -1) - [ -n "$BUILT" ] || { echo "::error::mcpp did not build"; exit 1; } + # ⚠️ EXACTLY ONE, not "at least one". Two would mean the delete above + # missed a tree and the ambiguity this step exists to remove is back. + # + # ⚠️ NO `mapfile`. This job also runs on macos, whose `/bin/bash` is + # 3.2 and has no such builtin — a bashism here would fail on one row + # of the matrix for a reason unrelated to what the row tests. + find target -type f \( -name 'mcpp' -o -name 'mcpp.exe' \) > /tmp/mcpp-built.txt + COUNT=$(wc -l < /tmp/mcpp-built.txt | tr -d ' ') + if [ "$COUNT" -ne 1 ]; then + echo "::error::expected exactly one built mcpp, found $COUNT" + sed 's/^/ /' /tmp/mcpp-built.txt + exit 1 + fi + BUILT=$(cat /tmp/mcpp-built.txt) BUILT=$(cd "$(dirname "$BUILT")" && pwd)/$(basename "$BUILT") echo "MCPP_UNDER_TEST=$BUILT" >> "$GITHUB_ENV" "$BUILT" --version From 464a16a9c5ad56b356a861c69a4efc958cce3e6e Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 12:33:31 +0800 Subject: [PATCH 16/20] =?UTF-8?q?Revert=20"ci:=20=E6=8A=8A=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E9=98=B6=E6=AE=B5=E7=BB=91=E5=AE=9A=E6=8C=87=E5=90=8D?= =?UTF-8?q?=E7=9A=84=20glibc=20=E8=BD=BD=E8=8D=B7=E8=A3=85=E7=BB=99?= =?UTF-8?q?=E5=AE=83(=E8=BF=87=E6=B8=A1=E6=AD=A5=E9=AA=A4)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ **它一次都没有执行过,而且它的前提本身做不到。** ## 没执行 我先前把日志里的 `glibc payloads present: ` 读成了它的输出。那是**回显的脚本正文** —— 展开后的输出一次都没出现过。守卫 `[ -x "$HOME/.mcpp/registry/bin/xlings" ]` 在 那个位置为假:mcpp 的 registry 要到第一次运行 mcpp 才建出来。 ⚠️ 同一个坑今天第三次:**判据是子串匹配,而被匹配到的是脚本自己。** ## 前提做不到 `xlings install glibc@2.44` 会把请求**解析成** `2.44.2`,建出来的目录就叫 `2.44.2` —— 它**不可能**造出旧绑定要找的那个 `2.44` 目录。这一步的整个想法 (「把绑定指名的那个载荷装上」)对这个包管理器无法成立。 ## 真正修好它的是上一个 commit `openkal cross-build` 在桥**之前**就已经绿了:02:10 / 02:41 / 02:57 三次,而桥是 03:15 的 6e310aa3。⇒ 止血的是 `b3735fc`(撤回 xlings pin 前移),不是这座桥。 ⇒ 留着它 = 留一段永不执行的 CI 代码,配一条声称自己承重、并让下一个人去「等发布后 移除」的注释。删掉。 ⚠️ 生态那七个仓库里也有同一段,同样是空转;它们下一轮改用正式版时一并删。 --- .github/actions/bootstrap-mcpp/action.yml | 56 ------------------- .../workflows/ci-aarch64-fresh-install.yml | 28 ---------- .github/workflows/ci-fresh-install.yml | 28 ---------- 3 files changed, 112 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 5cdbff2f..6b50301b 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -168,34 +168,6 @@ runs: else echo "sandbox xlings: (not initialised yet)" fi - # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. - # - # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload - # directory is named after the version a request RESOLVED to, while a - # RuntimeBinding carries the version that was DECLARED — and the xlings - # a released mcpp vendors into its own sandbox still declares `2.44`. - # So a clean machine installs `2.44.2`, the toolchain fixup asks for - # `2.44`, and the build stops before anything is compiled: - # - # error: selected RuntimeBinding glibc@2.44 requires payload - # '…/xpkgs/xim-x-glibc/2.44', but it is not installed - # - # ⚠️ On every NEW machine and on none that already existed, which is why - # it is invisible from a developer's own. Measured on `main` as readily - # as on any branch — the index records the same failure verbatim in - # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA - # and the client is a PROGRAM: the consumer ships first." - # - # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` - # accepts an installed payload whose version REFINES the requested one - # (`payload_dir_for_version`), so a bootstrap from it needs nothing - # here. Until then the missing payload is simply installed. - if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then - XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ - "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ - >/dev/null 2>&1 || true - echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" - fi echo "MCPP=$MCPP" >> "$GITHUB_ENV" echo "XLINGS_BIN=$HOME/.xlings/subos/default/bin/xlings" >> "$GITHUB_ENV" @@ -229,34 +201,6 @@ runs: else echo "sandbox xlings: (not initialised yet)" fi - # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. - # - # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload - # directory is named after the version a request RESOLVED to, while a - # RuntimeBinding carries the version that was DECLARED — and the xlings - # a released mcpp vendors into its own sandbox still declares `2.44`. - # So a clean machine installs `2.44.2`, the toolchain fixup asks for - # `2.44`, and the build stops before anything is compiled: - # - # error: selected RuntimeBinding glibc@2.44 requires payload - # '…/xpkgs/xim-x-glibc/2.44', but it is not installed - # - # ⚠️ On every NEW machine and on none that already existed, which is why - # it is invisible from a developer's own. Measured on `main` as readily - # as on any branch — the index records the same failure verbatim in - # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA - # and the client is a PROGRAM: the consumer ships first." - # - # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` - # accepts an installed payload whose version REFINES the requested one - # (`payload_dir_for_version`), so a bootstrap from it needs nothing - # here. Until then the missing payload is simply installed. - if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then - XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ - "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ - >/dev/null 2>&1 || true - echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" - fi echo "MCPP=$MCPP" >> "$GITHUB_ENV" echo "XLINGS_BIN=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe")" >> "$GITHUB_ENV" diff --git a/.github/workflows/ci-aarch64-fresh-install.yml b/.github/workflows/ci-aarch64-fresh-install.yml index df276c10..b451203e 100644 --- a/.github/workflows/ci-aarch64-fresh-install.yml +++ b/.github/workflows/ci-aarch64-fresh-install.yml @@ -68,34 +68,6 @@ jobs: - name: Fresh-install mcpp via xlings run: | xlings install mcpp -y - # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. - # - # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload - # directory is named after the version a request RESOLVED to, while a - # RuntimeBinding carries the version that was DECLARED — and the xlings - # a released mcpp vendors into its own sandbox still declares `2.44`. - # So a clean machine installs `2.44.2`, the toolchain fixup asks for - # `2.44`, and the build stops before anything is compiled: - # - # error: selected RuntimeBinding glibc@2.44 requires payload - # '…/xpkgs/xim-x-glibc/2.44', but it is not installed - # - # ⚠️ On every NEW machine and on none that already existed, which is why - # it is invisible from a developer's own. Measured on `main` as readily - # as on any branch — the index records the same failure verbatim in - # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA - # and the client is a PROGRAM: the consumer ships first." - # - # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` - # accepts an installed payload whose version REFINES the requested one - # (`payload_dir_for_version`), so a bootstrap from it needs nothing - # here. Until then the missing payload is simply installed. - if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then - XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ - "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ - >/dev/null 2>&1 || true - echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" - fi mcpp --version mcpp self config --mirror GLOBAL 2>/dev/null || true diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 7c1fc7ed..2995291c 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -466,34 +466,6 @@ jobs: formula_version="$(brew info --json=v2 mcpp-community/mcpp/mcpp-m \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["formulae"][0]["versions"]["stable"])')" echo "formula declares: $formula_version" - # ⚠️⚠️ TRANSITION: GIVE THE BOOTSTRAP THE glibc ITS BINDING NAMES. - # - # `xim:glibc`'s `latest` moved from `2.44` to `2.44.2`. A payload - # directory is named after the version a request RESOLVED to, while a - # RuntimeBinding carries the version that was DECLARED — and the xlings - # a released mcpp vendors into its own sandbox still declares `2.44`. - # So a clean machine installs `2.44.2`, the toolchain fixup asks for - # `2.44`, and the build stops before anything is compiled: - # - # error: selected RuntimeBinding glibc@2.44 requires payload - # '…/xpkgs/xim-x-glibc/2.44', but it is not installed - # - # ⚠️ On every NEW machine and on none that already existed, which is why - # it is invisible from a developer's own. Measured on `main` as readily - # as on any branch — the index records the same failure verbatim in - # `pkgs/g/glibc.lua` and states the rule it broke: "The index is DATA - # and the client is a PROGRAM: the consumer ships first." - # - # ⭐ REMOVE THIS once a released mcpp resolves it. `mcpp 2026.8.27.1` - # accepts an installed payload whose version REFINES the requested one - # (`payload_dir_for_version`), so a bootstrap from it needs nothing - # here. Until then the missing payload is simply installed. - if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then - XLINGS_HOME="$HOME/.mcpp/registry" XLINGS_NON_INTERACTIVE=1 \ - "$HOME/.mcpp/registry/bin/xlings" install glibc@2.44 -y -g \ - >/dev/null 2>&1 || true - echo "glibc payloads present: $(ls "$HOME/.mcpp/registry/data/xpkgs/xim-x-glibc" 2>/dev/null | tr '\n' ' ')" - fi mcpp --version mcpp --version | grep -Fq "$formula_version" From d4cbd398532513257f79386e61334d8fa2ac8380 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 13:39:05 +0800 Subject: [PATCH 17/20] chore(xlings): pin 2026.8.17.2 -> 2026.8.27.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The released 2026.8.27.2 fixes, upstream, the defect this release works around from the client side. Its own commit message states it: a stale bootstrap pin is a claim about the ecosystem, not neutral setup. The aarch64 job bootstrapped v2026.8.10.1. That client has `glibc@2.44` compiled in as the default runtime binding, so once the index's `latest` moved past it the job installed the newer payload and then refused to use it. It also fixes a defect in 2026.8.27.1, which is why that version is skipped: the subos code asked the index for `glibc` by bare name, which is ambiguous on an ordinary home, and the resolver's error was read as "the index cannot answer" and turned into the pinned fallback. The mechanism shipped dead and the binding still came out right, because the pinned value and the index agreed that day. Two constants now answer the two questions: `DEFAULT_RUNTIME_PACKAGE` names the runtime and cannot carry a namespace because it is the payload directory's name; `DEFAULT_RUNTIME_QUERY` asks the index and must carry one. The two halves are complementary rather than redundant. A binding that follows the index stops going stale; `payload_dir_for_version` in this release accepts an installed payload whose version refines a request an OLDER client already compiled in, and older clients continue to exist. ⚠️ I attempted this bump earlier and reverted it, correctly at the time: the version then available moved the binding FORWARD, to `glibc@2.44.2` against an installed `2.44`, which is the direction `payload_dir_for_version` refuses --- and must refuse, since accepting it would silently use the payload the index states is not the one to install. The comments in `bootstrap-mcpp/action.yml` that record measurements taken on the 2026.8.17.2 assets keep that version, because that is the version they were taken on. A comment stating a measurement states which build it came from. check_version_pins.sh OK: xlings pins all at 2026.8.27.2 unit test_xlings_version_pin 5 passed --- .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +++--- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 ++-- .github/workflows/release.yml | 14 +++++++------- src/platform/xlings/xlings.cppm | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 6b50301b..6d68c04e 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.17.2' + default: '2026.8.27.2' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 3cfdd438..665dc81b 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.17.2' + default: '2026.8.27.2' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index ecbfb38e..321b6ebf 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.2' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 2995291c..2325f924 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -364,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 6b443920..6a572f6c 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -237,7 +237,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 8c2e1517..a213733e 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -122,7 +122,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -263,7 +263,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 780c1ccb..552c851d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -360,7 +360,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.17.2-linux-aarch64.tar.gz" + XLA="xlings-2026.8.27.2-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -369,9 +369,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.17.2/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.27.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.17.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.27.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -449,7 +449,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -632,7 +632,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index 0f8a5e84..c82fb4a5 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -45,7 +45,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.17.2"; + inline constexpr std::string_view kXlingsVersion = "2026.8.27.2"; inline constexpr std::string_view kNasmVersion = "3.02"; } From 5603bc51ff2c27f3cfe6c7a5fa5c3309c1920049 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 13:44:48 +0800 Subject: [PATCH 18/20] Revert "chore(xlings): pin 2026.8.17.2 -> 2026.8.27.2" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bump is correct and this release cannot carry it. Measured on CI rather than predicted: Downloading xim:glibc@2.44 error: toolchain post-install fixup: selected RuntimeBinding glibc@2.44.2 requires payload '…/xpkgs/xim-x-glibc/2.44.2', but it is not installed Two versions of one decision meet inside a single run. The mcpp that BUILDS this one is the released 2026.8.25.2, whose compiled-in pin is 2026.8.17.2, and it installs the payload its own binding names: 2.44. The vendored xlings that COMPUTES the binding is the one this branch pins, which asks the index and gets 2.44.2. The request is then newer than what is installed. That is the direction `payload_dir_for_version` refuses, and it must: accepting a payload older than the request would silently use the one the index states is not the one to install --- the preload defect of mcpp#484. ⚠️ THIS IS NOT A STALE CACHE. The sandbox cache key includes the xlings version for exactly this reason, so the bump is a cache miss and the sandbox was fresh. The mismatch is between the engine doing the building and the xlings it was told to vendor. ⭐ IT IS SELF-HEALING, AND ONLY IN ONE ORDER. Once 2026.8.27.1 is released, a bootstrap from it carries the new pin, computes 2.44.2, and installs 2.44.2. The bump therefore belongs in the release AFTER this one, which is the rule the index states in its own source: "The index is DATA and the client is a PROGRAM: the consumer ships first." The engine-side half of the same problem stays in this release: `payload_dir_for_version` accepts an installed payload whose version REFINES a request an older client already compiled in, so older clients stop being stranded by an index move in the safe direction. --- .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +++--- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 ++-- .github/workflows/release.yml | 14 +++++++------- src/platform/xlings/xlings.cppm | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 6d68c04e..6b50301b 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.27.2' + default: '2026.8.17.2' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 665dc81b..3cfdd438 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.27.2' + default: '2026.8.17.2' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 321b6ebf..ecbfb38e 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.27.2' + XLINGS_VERSION: '2026.8.17.2' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 2325f924..2995291c 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -364,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 6a572f6c..6b443920 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -237,7 +237,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index a213733e..8c2e1517 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -122,7 +122,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.27.2' + XLINGS_VERSION: '2026.8.17.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -263,7 +263,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.2' + XLINGS_VERSION: '2026.8.17.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 552c851d..780c1ccb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.27.2' + XLINGS_VERSION: '2026.8.17.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.2' + XLINGS_VERSION: '2026.8.17.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -360,7 +360,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.27.2-linux-aarch64.tar.gz" + XLA="xlings-2026.8.17.2-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -369,9 +369,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.27.2/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.17.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.27.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.17.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -449,7 +449,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.2' + XLINGS_VERSION: '2026.8.17.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -632,7 +632,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.27.2' + XLINGS_VERSION: '2026.8.17.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index c82fb4a5..0f8a5e84 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -45,7 +45,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.27.2"; + inline constexpr std::string_view kXlingsVersion = "2026.8.17.2"; inline constexpr std::string_view kNasmVersion = "3.02"; } From 557b6e9c21186dcbc2291bb0bf87ddde9bc92edb Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 13:54:44 +0800 Subject: [PATCH 19/20] =?UTF-8?q?docs(plan):=20=E5=9B=9E=E5=A1=AB=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E7=BB=93=E6=9E=9C=20=E2=80=94=E2=80=94=20=E5=86=99?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=94=B9=E6=8E=89=E4=BA=86=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §7.5 是落地之后回填的。§2.12 的头文件草案里有两处,读的时候没看出问题,写第一份实现 时第一次运行就暴露了,而且两处都是**改规范而不是改实现**。 ⓵ `openkal.space` 从两个操作收敛成一个。本规范面向的环境里没有一个把 「克隆地址空间」和「在其中启动上下文」当作一对原语:Linux 的 clone 是一个动作。要求 实现分开,它只能启动一个上下文、把它停住、再自建一条信令通道 —— clause 7.1 的判词是 **形状有错,不是实现有错**。分开的形式还有一个答不上来的问题:clone 之后 start 之前 改了内存,子上下文看见哪一份。 ⓶ `openkal.net` 交出的是被拥有的连接,流从它借。草案直接交出 `kal_stream` 并称其 被拥有,而那在 clause 7.2 下无法实现:流句柄就是环境传输操作接受的东西,在描述符系统 上是一个关掉就被复用的数字,没有地方放代际。`openkal.fs` 早就回答过同一个问题。 ⓷ 顺带更正 §4.1:`_LIBCPP_HAS_TERMINAL` 的结论与那里相反。设成 0 只是「对一个坏掉 的 port 的正确描述」;它门控的是 isatty,而 isatty 不工作是因为端口层用 TCGETS 回答 musl 用 TIOCGWINSZ 问的问题。修端口层才是修法。 §2.12 的两份草案加了取代说明而不是改写 —— 草案是「当时提出了什么」的记录。 --- ...026-08-27-openkal-ecosystem-design-plan.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md b/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md index ff4a2055..d20eff0d 100644 --- a/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md +++ b/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md @@ -1019,6 +1019,9 @@ kal_uintptr kal_terminal_props(struct kal_stream); /* enquiry: varies per reso #### `openkal/net.h` —— 连接 +> ⚠️ **本草案已被 §7.5 ⓶ 取代**:交出的是被拥有的 `kal_net_conn`,流经 `kal_net_stream` 从它借。下面保留的是当时提出的形状。 + + ```c /* openkal.net --- a connection, which is a stream with a peer and a way to be * half-closed. Clause 3.4 records why this is not merged with `openkal.fs': @@ -1125,6 +1128,9 @@ extern const kal_uintptr kal_datagram_props; #### `openkal/space.h` +> ⚠️ **本草案已被 §7.5 ⓵ 取代**:收敛成 `kal_space_start` 一个操作,`kal_space` 类型不存在。下面保留的是当时提出的形状。 + + ```c /* openkal.space --- an address space, and a context executing in one. * @@ -1814,6 +1820,60 @@ clause 6.1 的缺席是靠**不导出符号**表达的,不是靠不写。 --- +## 7.5 ⭐⭐ 实施回填:写实现改掉了两处接口设计 + +本节是**落地之后**回填的。§2.12 的头文件草案里有两处,**读的时候没看出问题,写第一份 +实现时第一次运行就暴露了**。两处都是**改规范而不是改实现**。 + +### ⓵ `openkal.space` 从两个操作收敛成一个 + +草案是 `kal_space_clone` + `kal_space_start`,调用者手里拿着一个 space 句柄。 + +⚠️ **本规范面向的环境里没有一个把这对当原语。** Linux 的 `clone` 是**一个动作**: +复制地址空间**并且**在副本里开始执行,没有「只做前一半」的形式。要求实现把两者分开, +它只能照样启动一个上下文、把它停在某个等待原语上、再自建一条信令通道告诉它跑什么 —— +clause 7.1 对此的判词是:**是规范的形状有错,不是实现有错。** + +⭐ 分开的形式还有一个它答不上来的问题:调用者 clone 之后、start 之前改了自己的内存, +子上下文看见的是哪一份?一个操作没有这个问题。 + +⇒ `kal_space_start(entry, arg, stack_top, out)`,`kal_space` 类型与 +`kal_space_clone` / `kal_space_destroy` 一并删除。被否掉的形式记进 clause 6.3。 + +### ⓶ `openkal.net` 交出的是**被拥有的连接**,流是从它借的 + +草案里 `kal_net_connect` 直接交出一个 `kal_stream` 并声明它是被拥有的。 + +⚠️ **那在 clause 7.2 下无法实现。** 规范要求「实现不得把已释放的句柄当作有效」,而 +流句柄就是环境的传输操作所接受的东西 —— 在描述符系统上是一个**关掉就会被复用的数字**, +没有地方放代际。被拥有的句柄放得下,借来的流放不下。 + +⭐ `openkal.fs` 早就回答过同一个问题:文件被拥有,流经 `kal_fs_stream` 从它借,并随 +文件一起释放。`openkal.net` 现在就是这个形状,措辞也照抄 —— 因为是同一个安排。 + +更直白的症状是第一次跑就出现的: + + FAIL: the bytes read are the bytes written + FAIL: the peer observes end of input after a half-closure + +Linux 实现把连接打进了句柄方案,而 `kal_stream_read` 把那个打包过的字当描述符用。 + +⇒ 新增 `struct kal_net_conn` 与 `kal_uintptr kal_net_stream(struct kal_net_conn)`; +connect / accept / peer / local / shutdown / close 全部改收连接; +`kal_timeout_accept` 交出的也是连接。 + +### ⓷ `_LIBCPP_HAS_TERMINAL` 的结论与 §4.1 相反 + +§4.1 说把它设成 `0`。⭐ **那只是「对一个坏掉的 port 的正确描述」** —— 它门控的是 +`isatty`,而 `isatty` 之所以不工作,是端口层用 TCGETS 回答了 musl 用 TIOCGWINSZ 问的 +问题。修端口层才是修法,声明本来就该是真的。 + +⇒ 端口层已修;`__config_site` 的声明保持 `1`,并在 openkal-llvm-runtime 的 CI 里加了 +一条**对账**判据:判据是**关系**(pty 与 pipe 必须不同,且与系统 C 库同向), +两个方向都会红 —— 声明 1 而端口答不出、声明 0 而端口其实答得出,都判失败。 + +--- + ## 8. 未决与我可能错的地方 1. **§2.3 `openkal.net` 的数据报问题未决**,而它有连锁后果(DNS)。**不能边写边定。** From 79777443c272222925dbb7ef90f130bddffce4a6 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 15:03:15 +0800 Subject: [PATCH 20/20] =?UTF-8?q?docs(plan):=20=E5=9B=9E=E5=A1=AB=20?= =?UTF-8?q?=E2=91=A4=20=E7=9A=84=E5=AE=9E=E6=96=BD=E7=BB=93=E6=9E=9C=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=8E=92=E9=99=A4=E8=A1=A8=E4=BB=8E?= =?UTF-8?q?=E5=8D=81=E5=85=AB=E6=9D=A1=E7=BC=A9=E5=88=B0=E4=BA=94=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §3.6 说这条「零设计成本,回报最大」。实施之后两句都要更正。 **前提只在四个 target 里的一个成立。** 机制靠「没被引用的定义会被丢掉」,那要 `-ffunction-sections` + `-Wl,--gc-sections`,而只有 `cfg(os = "linux")` 设了; macOS 与 Windows 那两节根本没有 ldflags。⚠️ 我当时只跑了宿主一行,并据此在清单 注释里写下「这两个 flag 已经在位」—— 对我测的那个 target 为真,对其余三个为假。 **闭包应当一次问清,而不是跟着失败一条条加。** 逐族查目录之外的引用者:五个 fd 族 为 0;`network` 经 syslog/nscd_query/mq_notify 通到 `getpwnam`;`fork` 经 forkpty/wordexp/daemon/clone.c 通到 **`pthread_create`**。⇒ fork 的闭包终点是线程 实现,network 的终点是口令函数,两个都不能排。 ⭐ **不是拒绝,是还不可能**:排 network 族要等每个 target 都收集未引用 section, 那是三条链接线的改动,可以单独测。代价照直说 —— 今天用 socket 的程序仍然构建成功、 仍然运行期失败。 另加一条:`pipe` 落到 `kal_process_channel` 上之后,裸机后端上每个程序都链接失败 (不管要不要 pipe)。同一条规则四行之外就写在 `kal_random_fill` 上 ⇒ 判据改成 符号类别 `w` 而不是 `U`。 §3.6 加了取代说明而不是改写 —— 它是「当时是怎么想的」的记录。 --- ...026-08-27-openkal-ecosystem-design-plan.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md b/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md index d20eff0d..d6bbe3ef 100644 --- a/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md +++ b/.agents/docs/2026-08-27-openkal-ecosystem-design-plan.md @@ -1605,6 +1605,12 @@ to ask an operation about that directory"*)。⇒ `stat`/`statx`/`newfstatat` ### 3.6 ⑤ ⭐⭐ 「永远没有」变链接错误 —— 零设计成本,回报最大 +> ⚠️⚠️ **本节的「零设计成本」是错的,实施后已由 §7.6 更正。** 机制有一个前提, +> 而这份文档把它当成了已经满足:`-ffunction-sections` + `-Wl,--gc-sections` +> **只有 `cfg(os = "linux")` 一个 target 设了**。另外三个 target 不收集未引用的 +> section,于是同一份排除表在宿主上绿、在 mingw 上把构建打死。 +> 下面保留的是当时的推理。 + **问题**:今天 `okm_syscall.c` 用同一个 `-ENOSYS` 回答了两件性质不同的事。 | | 例子 | 正确的回答时机 | @@ -1874,6 +1880,56 @@ connect / accept / peer / local / shutdown / close 全部改收连接; --- +## 7.6 ⭐⭐ 实施回填:⑤ 的排除表从十八条缩到五条 + +§3.6 说这条「零设计成本,回报最大」。实施之后两条都要更正。 + +### 前提只在四个 target 里的一个成立 + +机制靠的是「没被引用的定义会被丢掉」,而那要 `-ffunction-sections` + +`-Wl,--gc-sections`。⚠️ **只有 `cfg(os = "linux")` 的 ldflags 里有**,macOS 与 +Windows 那两节**根本没设 ldflags**。于是宿主上排除是隐形的,mingw 上对象被保留: + + musl/src/legacy/daemon.c:18: undefined reference to `fork' + +⚠️ 我当时的实测只跑了宿主一行,并据此在清单注释里写下「这两个 flag 已经在位」—— +对我测的那个 target 为真,对其余三个为假。 + +### 闭包:一次问清,而不是跟着失败一条条加 + +排除一族只有在 musl 自己不从别处引用它时才成立。逐族查了目录之外的引用者: + +| 族 | 目录外引用者 | 闭包终点 | +|---|---|---| +| `timerfd` `eventfd` `signalfd` `inotify` `epoll` | **0** | — ⇒ 排除 | +| `select` | `network/res_msend.c` | 随 network | +| `network` | `syslog.c` `nscd_query.c` `mq_notify.c` | → `getpwnam` | +| `fork` | `forkpty.c` `wordexp.c` `daemon.c` `linux/clone.c` | → **`pthread_create`** | + +⭐ **fork 的闭包终点是线程实现,network 的终点是口令函数。** 排任一个都会带走一个 +必需的或普通的设施 ⇒ 两个都不排。 + +⚠️ 先前是跟着失败一条条加(lchmod→fchmodat、sem_open→link、daemon/wordexp/ +forkpty→fork),那是错的方法:这个问题在源码上一遍就能问清。 + +### 结论:不是拒绝,是还不可能 + +排除 network 族要等**每个 target 都收集未引用的 section**(ELF/PE 用 +`--gc-sections`,macOS 用 `-dead_strip`)—— 那是三条链接线的改动,可以单独测, +不该挂在这一批里。 + +⭐ **代价照直说**:今天一个用 socket 的程序仍然能构建、仍然在运行期失败,也就是 +`tinyhttps` 在第 27 个测试上遇到的那件事。留下的五条自带理由 —— 一个创建出来却 +永远不会就绪的 fd,比一个创建不出来的更坏 —— 且它们的闭包在每个 target 上都是空的。 + +### 另一条:可选接口的转发者必须弱引用 + +`pipe` 落到 `kal_process_channel` 上之后,裸机后端(不提供 `openkal.process`)上 +**每一个**程序都链接失败,不管它要不要 pipe。同一条规则四行之外就写在 +`kal_random_fill` 上。⇒ 判据改成符号类别:`w` 而不是 `U`。 + +--- + ## 8. 未决与我可能错的地方 1. **§2.3 `openkal.net` 的数据报问题未决**,而它有连锁后果(DNS)。**不能边写边定。**