From bfa9b8fe697101987e5411e793759901c3843c0b Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 13:15:24 +0900 Subject: [PATCH 01/43] fix: a WebAssembly link is in place, so Emscripten writes its .wasm (2026.10.26.2) 2026.10.26.1 linked every POSIX output to $out.tmp and renamed it. Emscripten names the files it writes beside the output after the output, so a link to app.js.tmp wrote no app.wasm: the edge's implicit output was missing and mcpp pack of a wasm32-emscripten program found no stem family (mcpp-plugins' dist-web). A WebAssembly link is in place, as a PE link is. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- src/build/ninja_backend.cppm | 12 +++++++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd383be0..9157462e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.10.26.2] - 2026-10-26 + +### Fixed + +- **A WebAssembly link writes its `.wasm` again.** 2026.10.26.1 published every + POSIX link through a temporary name, and Emscripten names the file it writes + beside the output after the output: a link to `app.js.tmp` wrote no + `app.wasm`, the edge's implicit output was missing, and `mcpp pack` of a + `wasm32-emscripten` program (mcpp-plugins' `dist-web`) found no stem family. + A WebAssembly link is in place, as a PE link is. + ## [2026.10.26.1] - 2026-10-26 ### Added diff --git a/mcpp.toml b/mcpp.toml index fe5c9603..55c7aa55 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.10.26.1" +version = "2026.10.26.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index ff99a01c..77cb6edf 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.10.26.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.10.26.2"; } // namespace mcpp diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 6da2340b..a69d9716 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2153,15 +2153,21 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // runs it, and on a Windows host `mcpp link-output` first moves the old // artifact aside -- Windows can rename, but not overwrite, a program that // runs (see cmd_link_output). - const bool peTarget = [&] { + // + // A WebAssembly link is in place too. Emscripten names the files it writes + // beside the output after the output (`app.js` and `app.wasm`), so a link + // to `app.js.tmp` wrote no `app.wasm` and the implicit output the edge + // declares was missing (mcpp-plugins' web-consumer, 2026.10.26.1). Nothing + // on the build machine runs the artifact while it links. + const bool inPlaceTarget = [&] { const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); - return t ? t->is_pe() : bool(mcpp::platform::is_windows); + return t ? (t->is_pe() || t->is_wasm()) : bool(mcpp::platform::is_windows); }(); auto publish_link = [&](std::string cmd) { if constexpr (mcpp::platform::is_windows) { return "$mcpp link-output --output $out -- " + cmd; } else { - if (peTarget) return cmd; + if (inPlaceTarget) return cmd; const auto pos = cmd.find("-o $out"); if (pos == std::string::npos) return cmd; cmd.replace(pos, 7, "-o $out.tmp"); From 5e273a09abf4e8a196c87b4b24492dcd81bba1d5 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 18:44:43 +0900 Subject: [PATCH 02/43] docs(agents): the ecosystem design part 2 -- openkal's atoms and image format, mcpplibs.os, and part 1's closure Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...em-part2-os-library-over-openkal-design.md | 637 ++++++++++++++++++ .agents/docs/README.md | 4 +- 2 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 .agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md new file mode 100644 index 00000000..27c75356 --- /dev/null +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -0,0 +1,637 @@ +--- +subject: ecosystem +status: accepted +--- + +# 生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录 + +日期:2026-10-26。前文:[part 1](2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md)(控制契约、并发、单一 C++ 运行时、 +IDE 生态,已随 mcpp 2026.10.26.1 发布)。分析基线:mcpp `bfa9b8fe`(release/2026.10.26.2);openkal `355b01a`(0.16.0); +openkal-linux 0.17.0、openkal-macos 0.14.1、openkal-windows 0.12.0(index 当前最新的一组,三者都依赖 openkal 0.16.0); +openkal-musl 0.20.1;speak-agent 的 openkal 系 fork(openkal#1、openkal-musl#1:动态加载);xlings `f437e780`; +mcppls 0.0.17(`Sunrisepeak/mcpp-language-server`);mcpp-ide 本地仓库 `6ea6178` 加未提交的工作。 + +**修订记录** + +- rev 1:由讨论收敛,含 openkal 能力的实测(临时 PR [mcpp#843](https://github.com/mcpp-community/mcpp/pull/843),run + 40274573232、40275769974、40276337637、40277053969;后两次修正了探测程序自身的两处错误,见 E15、E18)。 +- rev 2(第一轮评审):分层原则(内核层能力拆成 openkal 原子,`os` 只做组合与约定);新增原子 G1–G6 与 K10;G7 不进 openkal; + O2 / O3 / O6 / O8 定案;L1(实现的共享形态)、L2(openkal 之上的加载)进本批。 +- **rev 3(第二轮评审,本稿)**:二进制跨平台进本批——openkal 双模式(原生格式 / ok 映像),映像约定进 SPEC,参考实现 + `libokld`、`libokrun`、`okrun`、`okld` 放在新仓库 `mcpplibs/openkal-image`,mcpp 增加 `-openkal` 目标;`-openkal` 只固定 + `kernel-abi`,`c-abi` 与 `c++-abi` 由依赖图决定(openkal-musl、libc++ 不是唯一实现);mcppls 与 mcpp-ide 为第一方使用者; + xlings 本批不改。正文按 rev 3 重写,rev 1、rev 2 的结论已并入;rev 3 的生态级自审见 §15,其结论已并入正文。 +- rev 6(第五轮评审,本稿):探测 P 完成([mcpp#843](https://github.com/mcpp-community/mcpp/pull/843),run 40295631789、 + 40296883212),结果见 §3.2(E22–E29):同一份 ok 映像字节在 linux-x86_64、windows-x64、linux-aarch64、macos-arm64 上运行; + TLS 定为 emulated TLS(O10 关闭);`os::host()` 在 ok 目标上采用"普通声明"形态(MSVC 不接受 `if consteval` 标记形态)。 + rev 6 的自审见 §15 末节。 +- rev 5(第四轮评审):命名定案——**ok 格式**(openkal image format);可执行映像 `.oke`、可加载库 `.okso`(目标文件 `.o`、 + 静态库 `.a` 不变);库 `libokld`(加载器与格式定义的唯一实现)、`libokrun`(运行器核心);程序 `okrun`、`okld`;仓库 + `mcpplibs/openkal-image` 为三个包的工作空间(`openkal-ld`、`openkal-run`、`openkal-tools`)。"kal 模式"改称"ok 模式"。M3 不再需要 + 单独的嵌入库,由 libokrun、映像段与 `mcpp pack` 生成的入口组成。rev 5 的自审见 §15 末节。 +- rev 4(第三轮评审):O10、O11 定案(映像签名进约定,§8.3);`os` 保留编译期平台常量为一等接口(`os::PLATFORM`、 + `os::is_windows` 等,供 `if constexpr` 使用),并区分"编译目标"与"运行宿主"(§6.1 R3、§8.6);`os` 不限于 openkal 体系的项目; + [mcpp#844](https://github.com/mcpp-community/mcpp/issues/844)(release 的 `trim_paths` 使切换成员时公共依赖反复重编)确认为 + mcpp 缺陷,并入 #842(§10.4,D13);O9 的候选名 `krunner` 与 KDE 的 KRunner(`/usr/bin/krunner`)重名,待定。 + +**读者**:评审与实现者。涉及的仓库(每个一个 PR,§11):mcpplibs/openkal、openkal-linux、openkal-macos、openkal-windows、 +openkal-musl、openkal-llvm-runtime、**新建** mcpplibs/openkal-image、**新建** mcpplibs/os、mcpp-community/mcpp(#842)、 +**新建** mcpp-community/mcpp-ide、Sunrisepeak/mcpp-language-server、mcpplibs/mcpp-index、mcpp-community/mcpp-plugins(#51)。 +openxlings/xlings 本批不改。 + +## 0. 结论 + +| # | 决定 | +|---|---| +| D1 | **三层分工**:openkal 是与 OS、内核无关的最小内核 ABI(C ABI;面向写 C 库、运行时、裸机程序的开发者),只提供**最小、可组合的原子能力**;`mcpplibs.os` 是其上的 C++ OS 库,面向一般开发者,提供好用、更强的通用 API 与少量平台专属能力;各项目只保留领域逻辑(§1) | +| D2 | **分层原则**:一项需求拆成"openkal 原子 + 上层组合"。内核层能力一律以原子进 openkal(语义只用 openkal 自己的词汇;环境间的差异用按资源查询或不提供接口表达);`os` 只做组合与约定;`os` 中的原生代码只用于非内核的环境服务(提权、Dev Drive)与平台专属模块(§1、§4) | +| D3 | **openkal 是 `os` 唯一的内核层后端**,不设两套后端。实测(§3):openkal 的三个实现在默认安排(借用宿主运行时)下与 glibc、musl、libSystem、UCRT、libstdc++、libc++、MSVC STL 同进程工作,`cl.exe` 与 llvm(MSVC ABI)都能构建 | +| D4 | **新增原子**(S1,§7.2):G1 rename 不覆盖;G2 自身映像路径;G3 目录句柄下按名字会合的连接;G4 按请求结束;G5 进程自身、按编号打开、进程信息;G6 终端输出解释 ECMA-48;G8 宿主环境查询(仅供约定使用);L2 部分发布与保护粒度。实现缺陷 K1–K10 在各实现修(§7.1) | +| D5 | **openkal 双模式**(§8):同一份源码,**原生模式**产出各 OS 的原生格式(今天的做法);**ok 模式**产出 `-openkal` 映像,同一份字节在该架构的各 OS 上运行。映像约定作为 SPEC 的规范性一章;参考实现 `libokld`(加载器与格式)、`libokrun`(运行器核心)、`okrun`(启动器)、`okld`(映像工具)在新仓库 `mcpplibs/openkal-image`;配一致性套件 | +| D6 | **一个进程只有一份 openkal 实现**(L1):规范写明,并给出共享形态的条件;ok 模式下这一份是 okrun 的;`os` 的资源类型在 openkal 非共享形态时不跨共享对象边界(R6) | +| D7 | **映像约定只约束边界**:只导入 kal_* 与其他映像、调用约定、TLS 访问方式、展开信息不出映像;不规定映像内的 C 库与 C++ 运行时。mcpp 的 `-openkal` 目标只固定 `kernel-abi = openkal`,`c-abi`、`c++-abi` 由依赖图决定,openkal-musl 与 openkal-llvm-runtime 只是默认提供者 | +| D8 | **`os` 的接口规则** R0–R6;`.cppm` 声明 / `.cpp` 实现;Windows 实现单元自己声明所用的 Win32 函数;名称 `mcpplibs:os` / `mcpplibs.os.*` / `mcpplibs::os`(§6) | +| D9 | **交叉验证**:上游 PR 以 git 分支引用(`{ git = …, branch = "" }`),消费方 PR 的 CI 引用 os 与 openkal 系的 **PR 分支**;全部合入后经 mcpp-index 发布,消费方改为版本依赖后合入。第一方使用者:mcpp、mcpp-ide、mcppls(§11) | +| D10 | **mcpp 只有一个 PR**:#842 保持版本 2026.10.26.2,标题追加新增范围;含 wasm 回归修复、GCC 模块映射修复(D11)、`os` 接入、`-openkal` 目标(§10) | +| D11 | **GCC 模块映射以 BMI 目录为仓库根(`$root gcm.cache`)**,全局缓存键对含模块的包增加"BMI 路径约定"一轴:只有含模块的依赖重建一次(§10.2) | +| D13 | **`trim_paths` 的映射按包给出**(mcpp#844):一个编译单元只带固定根(home、store、工具链、sysroot、构建目录)与**它自己的包及其依赖闭包**的映射,按长度与字典序排序;不再把整个计划的包映射放进全局参数。公共依赖的命令行因此与所选成员无关(§10.4) | +| D12 | **探测已完成**(§3.2):同一份映像字节在 x86_64(Linux、Windows)与 aarch64(Linux、macOS)上运行;TLS 为 emulated TLS;x86_64 Windows 以导出入口与回调跳板转接约定;`os::host()` 在 ok 目标上为普通声明 | + +## 1. 定位与分层 + +``` +各项目的领域逻辑 mcpp:目标轴、runtime_search、脚手架;xlings:subos、隔离策略;mcppls:沙箱判定 +mcpplibs.os 面向一般开发者:通用 API + 少量平台专属能力;组合 openkal 原子,加上约定 +openkal-image 映像约定的参考实现:libokld(加载器与格式)、libokrun(运行器核心)、okrun(启动器)、okld(映像工具) +openkal-kit 只由 kal_* 组合出来的设施(channel、endpoint),不属于规范 +openkal 与 OS、内核无关的运行时级 ABI:最小可组合原子、能力查询、映像约定、一致性套件 +openkal- 实现:原生模式下随程序链接;ok 模式下随 okrun +``` + +| | openkal | `mcpplibs.os` | +|---|---|---| +| 读者 | 写 C 库、运行时、加载器、裸机程序的人 | 写工具、服务、IDE 后端、CLI 的一般开发者 | +| 形式 | C ABI(头文件为正本,模块为同一声明的另一种入口) | C++ 模块:RAII、`std::expected`、`std::filesystem::path`、UTF-8 | +| 范围 | 最小、可组合的原子;不能由已有接口组合出来 | 组合与约定:PATH 查找、原子发布、进程树、目录约定、颜色、路径映射、本地 IPC | +| 名字 | 相对目录句柄(preopen),无当前目录,无 `..` | 绝对与相对路径都可用,内部映射到 preopen | +| 差异 | 能力字与按资源查询(SPEC §6.2) | 同样用查询表达;平台专属能力单列,不推荐使用 | +| 变化 | 极严格:规范、SURFACE、所有实现、一致性套件一起变;只增不改(SPEC §8) | 按 semver | + +**判断一项能力放在哪一层**(D2): + +1. 是内核层的能力吗?是——拆出最小原子:只用 openkal 的词汇定义,三实现可实现或以查询 / 不提供表达,不能由已有接口组合。 +2. 原子之上的丰富功能(命名、默认目录、清理、帧、重试、策略)在 `os` 组合。 +3. 不在内核层的环境服务(UAC 同意界面、Dev Drive)与没有可移植含义的机制(Landlock、seccomp、`SCM_RIGHTS`)在 `os` 的按 OS + 实现单元或平台模块。 +4. std 已做对的,`os` 不包装。 + +## 2. 需求清单:四个项目的平台层 + +| 项目 | 位置与规模 | 底座 | 已有的绕行 | +|---|---|---|---| +| mcpp | `modules/platform`,20 个模块约 6.9k 行 | libc / Win32,约 100 处 `#if` | — | +| xlings | `modules/platform`(partition),约 9.5k 行 | libc / Win32,`platform.cpp` 单文件 33 处 `#if` | — | +| mcppls | `modules/platform` 约 3.1k 行 + `modules/os/{linux,macos,windows}` | openkal(openkal-musl + llvm-runtime 全栈) | WA-PLATFORM-001(macOS SIGPIPE);PRoot 下禁用 `execveat`;手工声明 7 个 Win32 函数取进程时间;Linux 读 `/proc` | +| mcpp-ide | `src/platform`,约 0.6k 行 | libc / Win32,已按 D8 的形态重构 | — | + +| 能力 | 来源 | 需要的语义 | +|---|---|---| +| OS 常量(可执行后缀、PATH 分隔符) | ×4 | 原生模式下编译期已知;ok 模式下运行时得知(§8.6) | +| 启动子进程:argv、环境、工作目录、三条管道 | ×4 | 参数原样到达;子进程只继承指定的句柄 | +| PATH / PATHEXT 查找 | ×4 | 与 shell 一致 | +| 进程树:请求结束、强制结束、孙进程一并结束 | mcpp、xlings、mcpp-ide、mcppls | 进程组 / Job | +| 子进程随父进程结束 | mcpp、xlings、mcpp-ide | — | +| 非阻塞与带时限等待 | ×4 | — | +| 本进程被请求结束的通知与按请求结束 | mcpp(`cancelled` 记录,SPEC-003 的 130/143)、mcpp-ide、xlings | 普通上下文里清理,再以"被请求结束"的状态终止 | +| 自身可执行文件路径 | ×4 | 定位 home、载荷、相邻程序 | +| 原子写、写穿符号链接、不覆盖的发布、文件系统能力 | mcpp、xlings、mcppls | NFSv3 等不支持 rename-noreplace 的卷 | +| 文件锁 | mcpp、xlings | 可等待 | +| 链接:符号链接、目录联接 | xlings、mcpp | Windows 上不需要特权 | +| 环境变量:读、枚举、子进程的环境 | ×4 | — | +| 修改本进程环境变量 | xlings(85 处,多为给 git 子进程设变量) | 不提供(§6.4) | +| 目录约定 | ×4 | XDG、`APPDATA`/`LOCALAPPDATA`、`~/Library` | +| 终端:交互、尺寸、VT、颜色、UTF-8 | mcpp、xlings、mcpp-ide | `NO_COLOR`、`CLICOLOR_FORCE` | +| 标准流的二进制模式与整条写入 | mcpp-ide、mcppls | Content-Length 帧 | +| 本地连接与回环 TCP | xlings(AF_UNIX)、mcppls(回环 TCP) | 访问控制、单实例会合 | +| 进程身份、存活、CPU 时间(按编号) | mcppls(租约、LSP `processId`、预算) | 编号会被复用,需与启动时刻比对 | +| 线程级 CPU(另一个进程) | mcppls(clangd 诊断) | — | +| 机器真实架构 | xlings、mcpp | Rosetta、WoW64 | +| Linux 隔离、提权、Dev Drive | xlings、mcppls | 平台专属 | +| **动态加载**(插件) | MC++ 插件(fork 的动机)、L2 | 一个进程一份运行时 | +| **同架构二进制跨平台** | mcppls、mcpp-ide 的载荷 | 每种架构一份应用逻辑 | + +## 3. 实测:openkal 作为唯一底座 + +探测程序:[mcpp#843](https://github.com/mcpp-community/mcpp/pull/843) 的 `.github/probe/okprobe`——普通宿主程序(`import std`、 +`std::jthread`、`std::filesystem`)依赖 openkal 0.16.0 与本平台实现(借用安排),子进程是它自己。9 个组合:linux x86_64(gcc +dev、gcc release、llvm、musl static)、linux aarch64、macos arm64(llvm dev、release)、windows x64(llvm(MSVC ABI)、`cl.exe`)。 + +| # | 事实 | 范围 | +|---|---|---| +| E1 | `kernel-abi` 解析为 openkal,`c-abi` 保持 glibc / musl;构建、链接、运行正常 | Linux | +| E2 | `cl.exe` 与 llvm(MSVC ABI)都能构建运行;preopen 为当前目录与每个盘符;绝对路径映射为"盘符 + 相对名"后可打开 | Windows | +| E3 | 启动子进程:argv 中的空格、引号、反斜杠原样到达;完整环境(Windows 156 条)、环境覆盖、工作目录、三条管道、退出码正确;4 线程并发启动 20 个子进程,无一读端被推迟 EOF | 9 组合 | +| E4 | `kal_process_channel` 单向:`mine` 为读端;子进程 stdin 需把一对反过来用;规范文字未写方向 | 源码 | +| E5 | `kal_process_job_terminate` 结束子进程及其孙进程 | 9 组合 | +| E6 | `kal_timeout_wait_process` 以 1 ns 为时限即非阻塞等待 | 9 组合 | +| E7 | **openkal-linux 0.17.0**:stop word 的信号返回 trampoline 是无段指令的顶层 `asm`,GCC `-O0` 把它放进 `.bss`,SIGTERM 后 SIGSEGV;GCC `-O2`、llvm、musl `-O2` 正常 | 本机与 CI | +| E8 | stop 处理器用原始 `rt_sigaction` 安装,覆盖宿主处理器,并覆盖启动方继承下来的 `SIG_IGN` | 源码 | +| E9 | 文件:`create|exclusive`、flush(`fsync`)、替换 rename、文件锁、容量、`info` 正确;`..` 被拒绝;APFS 卷报告不区分大小写 | 9 组合 | +| E10 | **openkal-windows 0.12.0 不创建、不读取链接**;`set_executable_at` 为 `not_supported`(符合规范) | Windows | +| E11 | 无"不覆盖的 rename" | 规范 | +| E12 | 无自身映像路径、无本地端点、无本进程环境修改(后者为规范有意) | 规范 | +| E13 | `kal_task_wait/wake` 在宿主 `std::thread` 之间可用;回环 TCP、`kal_timeout_accept` 正确 | 9 组合 | +| E14 | mcpp:已推送的 `$root` 修复让旧版本写入全局缓存的依赖 BMI 不可读(`gcm.cache/gcm.cache/…`) | 本机 | +| E15 | `kal_env_var_at` 返回名字;openkal-windows 拒绝不含 `=` 的环境条目,openkal-linux 接受并传给 `execve` | Windows / Linux | +| E16 | openkal-windows:stop word 返回 word 但 props 不声明;控制台处理器返回 `FALSE`,进程随即被结束;`kal_process_terminate` 为强制结束 | Windows | +| E17 | stop word 在 macOS 只有 arm64;`BOUND_LIFETIME` 只在 Linux | 源码 | +| E18 | macOS arm64:spawn、管道、完整环境、进程树、链接、锁、stop word 正确;不声明 `BOUND_LIFETIME` | macOS | +| E19 | mcpp 2026.10.26.1:任何有依赖的工程中,带映射的单元 `import std.compat` 失败 | 本机 | +| E20 | openkal-linux 在构造函数中忽略 SIGPIPE(借用安排下改变宿主处置);openkal-macos 未做,写入关闭的管道会结束程序 | 源码 | +| E21 | openkal-windows 以字节写控制台(不经 `WriteConsoleW`),由控制台代码页解释 | 源码 | + +### 3.2 实测:ok 映像与 `os::host()`(探测 P) + +探测程序:#843 的 `.github/probe/okimage`。`image/app.c` 以 llvm 23.1.3 编译为无 OS 的 ELF(`--target=-unknown-none-elf +-ffreestanding -fPIC -femulated-tls`,aarch64 另加 `-ffixed-x18`),`ld.lld -shared -z max-page-size=65536` 链接,只导入 8 个 kal_*; +映像在 Linux 上构建一次、作为工件分发。`runner/` 是原型运行器(mcpp + llvm 构建,链接本平台 openkal 实现),映射映像、做 +`RELATIVE` / `GLOB_DAT` / `JUMP_SLOT` / `ABS64` 重定位、把 kal_* 绑定到以映像约定导出的入口后调用 `ok_main`。`hostconst/` 在五种 +编译器组合上检验 `os::host()` 的两种形态。 + +| # | 事实 | 范围 | +|---|---|---| +| E22 | **同一份字节**:`app-x86_64.oke`(sha256 `86a8756c…`)在 linux-x86_64 与 windows-x64 上运行,`app-aarch64.oke`(`38b8ef4a…`)在 linux-aarch64 与 macos-arm64 上运行,四处输出一致、退出 0。本机构建得到相同的 sha256:映像构建是可复现的 | 4 个运行点 | +| E23 | **x86_64 Windows 的约定转接**:运行器为每个 kal_* 提供 SysV 入口(clang `sysv_abi`),转调 MS 约定的实现;`kal_task_start` 的回调方向由一个 MS 约定的跳板进入映像的 SysV 入口。8 个参数的混合宽度调用、按值传递的 `kal_stream`、两个上下文的启动与等待都正确 | windows-x64 | +| E24 | **TLS**:映像以 `-femulated-tls` 编译,`__emutls_get_address` 在映像内只用 `kal_task_current` 与 `kal_alloc` 实现;主上下文与两个新上下文各自的 `thread_local` 值互不干扰(1 / 10 / 20),新上下文从初始值开始 | 4 个运行点 | +| E25 | aarch64 映像以 `-ffixed-x18` 编译(Darwin 与 Windows 保留 x18),在 Linux 与 macOS 上运行;Windows ARM64 未测 | aarch64 | +| E26 | macOS arm64:运行器(ad-hoc 签名、未启用 hardened runtime)对匿名映射 `mprotect` 为 RX 被允许;启用 hardened runtime 并公证的 okrun 所需的 entitlement 未测 | macos-arm64 | +| E27 | 64 KiB 段对齐(`-z max-page-size=65536`)使每个段独占保护单位,满足 Linux 4K、macOS arm64 16K、Windows 64K 分配粒度 | 4 个运行点 | +| E28 | `os::host()` 形态 A(`constexpr` 函数,`if consteval` 一支调用非 `constexpr` 标记):GCC 16、clang 23(Linux、macOS、Windows)接受且在常量表达式中使用时报错;**MSVC 19.44 拒绝函数定义本身**(C3615,未实现 P2448),即使只在运行时调用 | 5 个编译器组合 | +| E29 | `os::host()` 形态 B(ok 目标上为普通声明,原生目标上为 `constexpr` 函数):五个组合都接受运行时调用,都拒绝常量表达式中的使用并点名 `host`;原生目标上 `static_assert(host() == PLATFORM)` 与 `if constexpr (host() == …)` 在五个组合上成立 | 5 个编译器组合 | + +## 4. 覆盖矩阵(rev 3) + +**A** openkal 已有、三实现可用;**A\*** 已有、某实现缺(§7.1);**N** 新原子(§7.2);**O** `os` 组合;**E** `os` 的按 OS 实现 +(非内核环境服务);**P** 平台专属模块;**S** std 已有;**X** 留在项目。 + +| 能力 | openkal | `os` | +|---|---|---| +| 启动子进程、管道、环境、工作目录 | A(E3、E4) | O:`os::process::spawn`(stdin 按 E4 反用 channel) | +| PATH / PATHEXT | A:`kal_env_var` + `kal_fs_info` | O | +| 进程树 | A(E5) | O | +| 随父结束 | A\*:Windows K5;macOS 无原语、不声明 | O:查询报告 | +| 等待 | A(E6) | O | +| 结束通知与按请求结束 | A\*(K1–K3、K6)+ N:G4 | O:`os::interrupt` | +| 自身映像路径 | N:G2 | O | +| 原子写、不覆盖发布、卷能力 | A + N:G1 | O:发布策略(含 NFSv3 回退) | +| 文件锁 | A | O | +| 链接 | A\*:Windows K4 | O | +| 环境、子进程环境 | A(E15) | O;本进程修改不提供 | +| 目录约定 | A:`kal_env_var` | O | +| 终端交互、尺寸、输入模式 | A | O | +| 终端输出解释 ECMA-48 | N:G6 | O:颜色与样式 | +| 控制台 UTF-8 输出 | A\*:K10(E21) | — | +| 本地连接 | N:G3 | O:`os::ipc::local`(运行目录、命名、清理、帧、单实例) | +| 回环 TCP | A | O | +| 进程编号、身份、存活、CPU 时间 | N:G5 | O:租约、客户端存活、CPU 预算 | +| 线程级 CPU(另一个进程) | — | E | +| 机器真实架构 | —(不进 openkal) | E | +| 宿主环境(ok 模式下的约定) | N:G8 | O:运行时约定(§8.6) | +| 动态加载 | N:L2(`publish_part`、`granularity`) | — (由 libokld 承担) | +| 提权、Dev Drive | — | E | +| Landlock、seccomp、namespaces、`SCM_RIGHTS` | — | P | +| 遍历、创建、删除 | — | S | +| 目标轴、subos、沙箱判定 | — | X | + +## 5. 结论:openkal 足以支撑,不需要第二个后端 + +1. **共存成立**(E1、E2):一份 `os` 实现服务 glibc / musl / libSystem / UCRT 宿主与 openkal-musl 全栈。 +2. **缺口的性质**:没有一项需要第二个后端——A\* 是实现缺陷(§7.1),N 是满足接纳准则的原子(§7.2),E / P 本不属于内核层。 +3. **第二个后端的代价**:每项能力在 POSIX 与 Win32 上再写一遍(四个项目今天的状态),并掩盖 openkal 的缺陷(E7 这类问题只在 + 真实消费者上暴露)。 +4. **前提**:`os` 发布之前,A\* 与 N 在 openkal 及其实现中发布;`os` 不写临时绕行;mcppls 的绕行随之删除。 + +## 6. `mcpplibs.os` + +### 6.1 接口规则 + +- **R0** 不重复 std。 +- **R1** 能通用就通用:可移植的能力只出现在 `os::xxx`,按平台的实现私有,由 `os::xxx` 转发;平台模块不重复导出。 +- **R2** 平台模块只放没有可移植含义的东西,不推荐使用;每个导出写明为什么没有通用形式;CI 检查平台模块与通用 API 无同名。 +- **R3** **编译期平台常量是一等接口**:`os::PLATFORM`(枚举:`linux`、`macos`、`windows`、`openkal`……,即编译目标)、 + `os::is_linux` / `is_macos` / `is_windows` / `is_posix`、`os::exe_suffix`、`os::path_list_separator`,供 `if constexpr` 分派; + `os` 被任何多平台项目使用,不限于 openkal 体系。**`os::host()` 是 `constexpr` 函数**(运行宿主):原生模式下宿主就是编译目标, + 这是编译时已知的信息,`host()` 可用于常量表达式(`static_assert`、`if constexpr (os::host() == os::platform::windows)`);ok 模式下 + 宿主在编译时未知,`host()` 是普通声明——在常量表达式中使用即为编译错误(点名 `os::host`),运行时由 G8 回答(§8.6,E28、E29)。按资源变化的差异(卷是否区分大小写、能否建链接)用查询 + (`os::fs::caps(path)`)。使用准则:只在原生模式构建的代码可用 `if constexpr (os::is_windows)` 或 `if constexpr (os::host() == …)`; + 也要构建为映像的代码在运行时调用 `os::host()`,原生构建中它被折叠为常量,零开销。 +- **R4** 原生句柄不进通用类型。 +- **R5** 平台模块处处声明、只在本平台定义:弃置语句中的 odr-use 不要求定义([basic.def.odr]),非弃置的误用是链接错误。 +- **R6** openkal 不是共享形态时,`os` 的资源类型不跨共享对象边界(D6)。 + +### 6.2 构建形态 + +``` +mcpplibs/os + mcpp.toml [dependencies] openkal;[target..dependencies] openkal- + [build] sources = src/*.cppm src/*.cpp;[target..build] sources = src//* + src/os.cppm export module mcpplibs.os; + src//target.cppm 原生模式下的编译期常量(每个 OS 一份);openkal 目标下没有这一分区 + src/{process,fs,env,dirs,terminal,stdio,interrupt,net,ipc}.cppm / .cpp 接口 / 基于 kal_* 的实现 + src//*.cpp E 类实现与平台模块的定义 + src/{linux,macos,windows,posix}.cppm 平台模块接口(R5) + tests/ 行为测试 + 平台模块误用的链接期失败用例 +``` + +- 接口单元 `import std;`,不含系统头、不用宏;基于 openkal 的实现只 `import openkal.*`。 +- Windows 实现单元不包含 ``,自己声明所用函数(`dllimport`、`__stdcall`),因此在 MSVC ABI 与 openkal-musl 两种 C + 环境下都能编译;POSIX 实现单元的系统头只在全局模块片段。 +- 实现单元的变化不改变 BMI(mcpp 的 `bmi-equal` restat)。 + +### 6.3 名称 + +包 `mcpplibs:os`,仓库 `mcpplibs/os`,模块 `mcpplibs.os`、`mcpplibs.os.<能力>`、`mcpplibs.os.<平台>`,命名空间 `mcpplibs::os`。 +形态对应 Python `os`、Go `os` + `x/sys/{unix,windows}`;`mcpplibs.` 前缀避免与用户模块 `os` 冲突(mcpp#732)。 + +### 6.4 语义决定 + +- **路径**:接受 `std::filesystem::path`;词法规范化后映射为"最长前缀的 preopen + 正斜杠相对名"(mcppls `resolve_name` 为种子); + 另提供按链接解析的 `os::fs::resolve`。 +- **本进程环境不可修改**(与 openkal 一致);子进程环境由 `spawn` 指定。 +- **结束**:`os::interrupt::requested()` / `wait()` 基于 stop word,处理在普通线程;`os::interrupt::end_as_requested()` 基于 G4。 + mcpp 的取消:看到 word → 清理、写 `cancelled` 记录 → 按请求结束;POSIX 上父进程看到信号状态(shell 得到 130/143),Windows + 上为 `STATUS_CONTROL_C_EXIT`(part 1 rev 4)。 +- **EPIPE**:openkal 让写入关闭的管道得到错误而非被结束(E20、K8);消费方的输出层把它视为正常结束(mcpp 的 `| head`)。 +- **错误**:`std::expected`,可移植分类由 `kal_err_*` 映射,保留原生码。 + +## 7. openkal 的修改 + +### 7.1 实现缺陷(规范不变) + +| # | 仓库 | 缺陷 | 修复 | 证据 | +|---|---|---|---|---| +| K1 | openkal-linux | 顶层 `asm` 无段指令 | `.pushsection .text,"ax",@progbits` … `.popsection`;CI 增加 GCC `-O0` 的 stop word 用例 | E7 | +| K2 | openkal-linux、openkal-macos | arm 覆盖启动方继承的 `SIG_IGN` | 按 §7.2 的接管规则:armed 即接管,但启动方安排不接收的请求不恢复接收 | E8 | +| K3 | openkal-windows | stop word:props 未声明;处理器返回 `FALSE` | props 声明;`CTRL_C` / `CTRL_BREAK` 返回 `TRUE`(进程继续,由程序处理);`CLOSE` / `LOGOFF` / `SHUTDOWN` 只置 word,系统随后结束进程,规范写明 | E16 | +| K4 | openkal-windows | 链接不创建、不读取 | 目录用 junction(不需要特权);文件用 `ALLOW_UNPRIVILEGED_CREATE`,否则 `not_supported`;读取用 `FSCTL_GET_REPARSE_POINT`;props 按实际声明 | E10 | +| K5 | openkal-windows | `BOUND_LIFETIME` 未实现 | 每个子进程一个私有 `KILL_ON_JOB_CLOSE` Job(嵌套 Job),与调用方的 unit 无关 | E17 | +| K6 | openkal-macos | stop word 只在 arm64 | x86_64 待 mcpp 的 macOS x86_64 发布后在 CI 运行(O5) | E17 | +| K7 | openkal(文字)、openkal-linux | channel 方向未写;环境条目格式两实现不一致 | 规范写明 `mine` 为读端、环境条目为 `NAME=value`;两实现一致地拒绝非法条目 | E4、E15 | +| K8 | openkal-macos | SIGPIPE 结束程序 | 以已实测的 `sigaction` 结构忽略 SIGPIPE;mcppls 删除 WA-PLATFORM-001 | E20 | +| K9 | openkal-windows | UNC 路径与启动后挂载的卷不可达 | 增加 UNC 根的 preopen;名字未命中时重新枚举卷;实现前实测 | O7 | +| K10 | openkal-windows | 控制台按代码页解释字节 | 交互式控制台流转为 UTF-16 用 `WriteConsoleW`;实现前实测 | E21 | +| K11 | openkal-linux | PRoot 下 `execveat` | 实现内已有 ENOSYS 回退;补"已知在翻译器下运行时从第一次起用 `execve`",mcppls 删除其提前设置 | mcppls | + +### 7.2 新增原子(S1,各实现随之) + +接纳准则:(1) 目标内核都有的原语,或可由实现以查询 / 不提供表达;(2) 不能由已有接口组合而不丢失原子性或引入竞争;(3) 上层确有 +需要;(4) 语义只用 openkal 自己的词汇,不带任何 OS 的形状。 + +| # | 原子 | 中性定义 | 各实现 | 上层需要 | +|---|---|---|---|---| +| G1 | `kal_fs_rename` 的新版本,带 `KAL_RENAME_NO_REPLACE`;`KAL_FS_PROP_RENAME_NO_REPLACE` 按卷 | 目标存在则失败,原子 | `renameat2`、`renamex_np(RENAME_EXCL)`、`ReplaceIfExists = 0` | 不覆盖的发布 | +| G2 | `kal_env_image(char*, cap)` | 环境在启动时告诉程序的、本映像的名字 | `AT_EXECFN` / `/proc/self/exe`、apple[] `executable_path=`、PEB `ImagePathName`;ok 模式下由 okrun 给出映像路径 | 定位 home、载荷 | +| G3 | `kal_net_listen_at(kal_dir, name, len, …)`、`kal_net_connect_at(…)` | 在目录句柄下按名字会合的连接;返回已有的连接与监听句柄;`KAL_FS_PROP_LOCAL_ENDPOINT` 按卷 | macOS `bindat`/`connectat`;Linux 经 `/proc/self/fd//name`;Windows AF_UNIX(10 1803+) | `os::ipc::local` | +| G4 | `kal_process_end_as_requested(void)` | 以"未 arm 时该请求本会造成的方式"结束本程序;等待者经 `kal_process_wait` 看到 `terminated`;尚无请求时返回 `kal_err_invalid` | POSIX 恢复默认处置并重发;Windows `STATUS_CONTROL_C_EXIT` | SPEC-003 的 130/143;shell 的循环中断 | +| G4′ | stop word 的接管规则(文字) | arm 后结束请求只经 word 送达;启动方安排不接收的请求不恢复接收 | — | 一个所有者 | +| G5 | `kal_process_self`、`kal_process_open_id(kal_u64, …)`、`kal_process_info(kal_process, wanted, kal_process_info*)` | 编号为环境给出的不透明值,**可能被复用**;信息含编号、启动时刻、处理器时间,`present` 位标明可分辨者(形状同 `kal_fs_file_info`) | pidfd / `/proc`、`proc_pidinfo` / kqueue、`OpenProcess` / `GetProcessTimes` | 租约、客户端存活、CPU 预算 | +| G6 | 终端输出位置 `KAL_TERM_OUT_SEQUENCES` | 环境解释写入的 ECMA-48 控制序列(ECMA-48 为国际标准,非某个 OS) | POSIX 终端恒为真;Windows `ENABLE_VIRTUAL_TERMINAL_PROCESSING` | 颜色与样式 | +| G8 | `kal_env_system(char*, cap)` | 宿主环境的名字(开放集合:`linux`、`macos`、`windows`……);**规范限定只用于选择约定**,不得用于改变 kal_* 的行为或推断 kal_* 的能力(能力只经查询) | 实现给出常量;ok 模式下为 okrun 所在的系统 | ok 模式下 `os` 的运行时约定 | +| L1 | 文字:一个进程只有一份实现;共享形态的条件(soname 按大版本,导出面即 SURFACE) | — | 各实现增加共享形态构建 | 插件、ok 模式 | +| L2 | `kal_exec_publish_part(region, offset, size)`、`kal_exec_granularity()` | 区域的一部分可执行、其余可写;保护粒度 | `mprotect`、`VirtualProtect`(speak-agent/openkal#1,rebase 到 0.16) | 加载器 | + +不进 openkal:机器真实架构(G7,`os` 实现)、线程级 CPU、修改本进程环境、结束请求的原因(SPEC 的预留不变)。 + +每条原子按 openkal 的既有流程:SPEC 条款与修订说明(§11 记录理由)、头文件与模块、`SURFACE.txt`、`tools/gen-macros.sh` 重新生成、 +三个实现、一致性用例;只增不改(SPEC §8);openkal 升小版本。 + +## 8. 二进制跨平台:ok 模式 + +### 8.1 能做到的与做不到的 + +- **能**:同一 CPU 架构下,一份映像字节在 Linux、macOS、Windows 上运行。映像只含 kal_* 以上的代码(应用、`os`、C 库、C++ + 运行时、展开器);与 OS 相关的全部在宿主侧的 okrun。 +- **不能**:一个文件被三个内核直接执行。初始映像的映射由内核完成且只认原生格式(Linux `binfmt_elf`;XNU 的 Mach-O 加载并在 + arm64 上强制代码签名;NT 以 `SEC_IMAGE` 映射 PE),三种格式的魔数都在文件开头、互相冲突。动态链接(`ld.so`、dyld、ntdll Ldr) + 在内核之上,这一层的格式可以自选——ok 模式工作在这一层。 + +### 8.2 模式 + +| 模式 | 产物 | 运行 | 用途 | 结论 | +|---|---|---|---|---| +| M1 原生 | 每个 OS 一份原生二进制 | 直接 | 默认;mcpp 自身 | 保留 | +| M2 映像 | 每种架构一份 `app.oke`(可加载库 `libx.okso`;目标文件 `.o`、静态库 `.a` 不变) | `okrun app.oke …`;Linux 可注册 `binfmt_misc` | 开发、IDE 载荷 | 采纳 | +| M3 自包含 | 每个 OS 一个原生可执行文件:libokrun + **以段嵌入的同一份映像** | 直接;可签名、公证 | 面向最终用户 | 采纳 | +| M4 多重格式单文件(APE 式) | 一个文件 | 依赖 shell 兜底 | — | 不采纳:macOS arm64 不能直接运行未签名的加载器;无法 Authenticode 与公证;杀毒误报 | + +M3 的映像必须作为段(Mach-O `-sectcreate __OKE __image`、PE `.oke` 节、ELF `.oke` 段)而非追加在文件尾:签名只覆盖签名前的内容。 +M3 由链接 libokrun、映像段与一个极小的入口(由 `mcpp pack` 生成)得到,**应用不重新编译**;各 OS 的文件内映像字节相同,可逐字节比对。okrun 不依赖厂商 SDK, +因此一台 Linux 主机即可用 lld 交叉产出三个 OS 的 M3(签名仍在各自平台)。 + +### 8.3 映像约定(SPEC 新章,规范性) + +| 项 | 规定 | +|---|---| +| 调用约定 | 按架构固定:x86_64 为 SysV,aarch64 为 AAPCS64;实现按约定导出 kal_*(Windows x64 由工具从 SURFACE 与头文件生成转接,clang `sysv_abi`;Darwin 可变参数与 Windows x18 由实现在边界处理) | +| 映像 | ELF 子集:`ET_DYN`(PIE);允许的重定位类型表(含 `DT_RELR`);无 TEXTREL;段对齐 ≥ 64 KiB;只导入 kal_* 与其他映像 | +| `.note.openkal` | 约定版本、架构、**所需接口**(由导入的 kal_* 推导);加载器据此在加载时拒绝并说明缺哪个接口(SPEC §6.1 在映像上的对应) | +| TLS | **emulated TLS**(E24,O10 关闭):映像以 `-femulated-tls` 编译,不访问线程指针(Windows x64 用户态不能设置 `%fs` 基址,macOS arm64 的线程指针对用户态只读);`__emutls_get_address` 由映像内的 C 运行时在 kal_* 之上实现(以 `kal_task_current` 区分上下文;`kal_task_current` 在上下文结束后可复用,C 运行时在它启动的上下文结束时释放该上下文的槽)。不新增原子 | +| 平台寄存器 | aarch64 映像不得使用 x18(`-ffixed-x18`,E25) | +| 入口 | okrun 以何种状态把控制交给映像入口;`kal_env_*` 由 okrun 的实现回答;G2 回答映像路径 | +| 回调 | 实现调用映像的函数指针(`kal_task_start` 的 `entry`、`kal_space_start`)按映像约定进入;在约定不同的平台(x86_64 Windows)由实现以跳板转接(E23) | +| 接口的有无 | 由加载器在装载时按 `.note.openkal` 的所需接口检查,不满足即拒绝装载:加载器在装载时承担链接器在链接时的职责,SPEC §6.1("接口缺失由链接器报告")与 §3.2、§6.2 不需要为 ok 模式另立运行时协商操作。这回应了 openkal 自己的 `2026-08-28-beyond-static-linking-abi-review.md` §9.3 与 §9.5(产物须携带自己的下限) | +| 展开 | 展开信息与异常不出映像;kal_* 边界为 C ABI | +| 版本 | 映像声明约定版本;加载器拒绝更高的主版本;次版本只增 | +| 签名 | `.note.openkal` 可带签名(对映像内容摘要的签名,算法与编码由约定规定,如 Ed25519);信任根与"是否要求签名"是加载器的策略(okrun 提供要求签名的选项与信任根配置);`okld sign` 生成签名,`okld check` 验证格式 | +| 双模式 | 原生模式(现有规范)与 ok 模式并列;同一源码 | + +参考实现(非规范性,SPEC 与 README 列出):libokld、libokrun、okrun、okld;一致性套件增加"一致性映像"与"加载器检查"。README 的"程序可以 +依赖什么"表增加 ok 模式一行。 + +### 8.4 `mcpplibs/openkal-image`(新仓库,一个 PR) + +仓库为 mcpp 工作空间,含三个包(index 中保持 openkal 家族前缀):`openkal-ld`(lib 目标 `okld` → `libokld`)、`openkal-run` +(lib 目标 `okrun` → `libokrun`)、`openkal-tools`(bin `okrun`、`okld`)。分成三个包的原因:mcpp 的 lib 目标 `X` 产出 `libX`,同一个包 +里 bin 与 lib 不能同名。一个仓库、一个 PR、一个版本号。命名:格式为 **ok 格式**(openkal image format);`okrun` 读作 openkal run, +`okld` 读作 openkal ld;库与程序一一对应(`libokld` / `okld`,`libokrun` / `okrun`)。`libokld` 是运行时加载器而 `okld` 是构建机工具, +同词根、不同角色,与 GNU 的 `ld` / `ld.so` / `libdl` 相同,文档写明。 + +| 目标 | 性质 | 内容 | +|---|---|---| +| `libokld`(包 `openkal-ld`,lib 目标 `okld`) | 加载器,且是**映像格式定义的唯一实现**(freestanding C++,不用异常与 RTTI;对外 C ABI 头文件,前缀 `okld_*`,另有同一声明的模块形式) | 格式(ELF 子集、重定位表、`.note.openkal`、签名)、映射、重定位(x86_64、aarch64;riscv64 随一致性套件)、绑定、TLS、`DT_NEEDED`、`dl_iterate_phdr` 支持、GDB JIT 登记;使用者:libokrun、`okld`、openkal-musl 的 `dlopen`。源自 speak-agent/openkal-musl 的 `okm_dl*` | +| `libokrun`(包 `openkal-run`,lib 目标 `okrun`) | 运行器核心(C ABI 前缀 `okrun_*`,另有模块形式) | 以 libokld 装载映像,把映像导入的 kal_* 绑定到本进程那一份 openkal 实现,交出入口、处理退出;依赖 openkal、openkal-ld、`[target..dependencies]` openkal-(`standalone`);源码无平台分支;使用者:`okrun`、M3、在进程内承载 ok 映像的其他宿主 | +| `okrun`(包 `openkal-tools`) | bin(mcpp + C++ Modules) | libokrun 之上的薄 `main`;不链接宿主 C 库,进程内只有映像里的一套 C / C++ 运行时 | +| `okld`(包 `openkal-tools`) | bin(构建机工具) | `okld check`(约定检查)、`okld note`(推导所需接口写入 note)、`okld sign`(签名)、`okld inspect`;复用 libokld 的格式定义;不替代链接器;非 mcpp 的工具链也能用 | + +发布:okrun 的 macOS 版签名并带可执行内存 entitlement、公证;Windows 版 Authenticode;okrun 构建**不启用 CFG**,或在映射映像时登记 +有效调用目标(否则 CFG 拦截进入映像的间接调用)。 + +### 8.5 mcpp 的 `-openkal` 目标(#842) + +| 项 | 内容 | +|---|---| +| 目标 | OS 值 `openkal`(`-openkal`、`cfg(os = "openkal")`);映射到编译器三元组 `-unknown-none-elf` 与参数(`-fPIC -femulated-tls`,aarch64 加 `-ffixed-x18`;链接 `-shared -z max-page-size=65536 -z now`,E22–E27) | +| 目标侧 | 只固定 `kernel-abi = openkal`;`c-abi`、`c++-abi` 由依赖图的 `provides` 决定;目标表给出默认提供者(openkal-musl、openkal-llvm-runtime),可替换;不带 C 库的映像合法 | +| 产物 | bin → `.oke`;shared → `.okso`;static → `.a`;链接后调用 `okld check` 与 `okld note`,失败即构建失败并指出违反的条款 | +| 运行 | `run` / `test` 经已有的 `[target.].runner`,使用依赖图中宿主平台的 okrun(从源码构建) | +| 打包 | `pack` 增加 M2(映像)与 M3(每个 OS:libokrun + 映像段)格式 | +| 其他 | 编译数据库与 clangd 的目标参数;文档 21、22、24、50;e2e:同一映像字节在各 OS 运行 | + +### 8.6 `os` 在 ok 模式下 + +`-openkal` 目标上 `os::PLATFORM == os::platform::openkal`,`is_linux` / `is_macos` / `is_windows` 均为 `false`(编译目标 +不是任何一个 OS),`os::host()` 是**普通声明**(形态 B,E29;不用 `if consteval` 形态,因为 MSVC 拒绝,E28),在常量表达式中使用 +即编译错误并点名 `os::host`,运行时由 G8 回答;原生目标上 `os::host()` 是返回 `PLATFORM` 的 `constexpr` 函数。`exe_suffix` 等按宿主而变的常量在该目标上不提供(误用即编译错误),由 `os::host()` 回答;约定(PATH 分隔符、 +PATHEXT、配置目录、可执行后缀)由 G8 与按资源查询决定;平台模块只有声明(R5)。原生模式下一切不变。 +消费方中以 `if constexpr (os::is_windows)` 写的代码,凡也要构建为映像的,改用 `os::host()`(mcpp-ide 的平台层即在此列)。 + +### 8.7 第一方使用者 + +- **mcppls**:已在 openkal 栈;同时产出 M1 与 M2 / M3;VSIX 载荷为一份映像 + 各 OS 的 okrun(或 M3);不依赖 xlings。 +- **mcpp-ide 后端**:映像构建切到 openkal-musl + openkal-llvm-runtime(它的选择,非约定的要求);保留 M1,新增 M2 / M3;VS Code + 与 JetBrains 适配层按"原生后端"或"okrun + 映像"启动;调试配置对 `.oke` 目标生成 okrun + lldb(JIT 接口)。 +- **mcpp 自身**:保持原生(工具链探测、PE / Mach-O 产物、xlings 集成)。 + +## 9. openkal-musl 与 openkal-llvm-runtime + +- **openkal-musl**(一个 PR):`dlopen`/`dlsym`/`dladdr`/`dl_iterate_phdr` 改用 libokld(包 `openkal-ld`;上游化 fork 的成果,不再自带加载器); + 映像形态(无按 OS 的移植层,入口由 okrun 给出,TLS 按约定);`examples/dlopen`(11 项检查)、`examples/early-constructor` 进 CI。 +- **openkal-llvm-runtime**(一个 PR):为 `-openkal` 构建 libc++、libunwind、compiler-rt。 +- 二者是默认提供者,不是约定的一部分(D7)。 + +## 10. mcpp(#842) + +### 10.1 接入 `os` + +`modules/platform` 中 A / A\* / N / O 的部分改为 `mcpplibs.os`;领域部分保留;`bounded_process` 由 `os::process` 替代;取消路径按 +§6.4;EPIPE 视为正常结束;`axis` 的架构宏改用 `os`;`modules/platform` 中 `#if` 归零。 + +### 10.2 GCC 模块映射(D11,替换 `9016c0b1`) + +缺陷(2026.10.1.2 起):根包中只导入根包模块的单元不给映射(GCC 默认映射器,仓库根 `gcm.cache`),带映射的单元仓库根为空;GCC 在 +导入方的仓库根下按 BMI 记录的路径查找**间接**导入,两种写法相遇即失败(e2e 940;E19 的 `std.compat` 同因)。 + +`9016c0b1` 的 `$root gcm.cache` 统一了构建内的约定,但全局缓存里旧约定写入的依赖 BMI 被复用而读不出(E14)。"有映射就全部给映射、 +不写 `$root`"不修 E19(`std`、`std.compat` 由默认映射器构建)。**仓库根只能统一为 `gcm.cache`**。 + +方案:保留 `$root`;`cache_key.cppm` 的 E 轴对 GCC 下**提供模块的包**加入 `bmi_path_convention = "$root=bmi-dir"`——含模块的依赖 +重建一次,对象文件包的键不变;不提升 `kCacheEpoch`。测试:940 增加 C(带映射的单元 `import std.compat`);升级通道(`ci-upgrade.yml`) +增加 `modules` 变体:上一个发布用 GCC 构建依赖 openkal 的工程(`openkal.timeout` 间接导入 `openkal.datagram`)填充缓存,新版本 +第一次构建成功且不取旧 BMI、第二次从缓存取;`test_module_address` 断言 `$root`;`cache_key` 单测。本机实测:只有 `$root` 而无此轴的 +构建在 2026.10.26.1 填充的缓存上失败(`openkal.types: failed to read compiled module`),加轴后通过。 + +### 10.3 范围 + +wasm 回归、§10.1、§10.2、§10.4、§8.5;版本 2026.10.26.2,标题追加范围。代价:2026.10.26.1 的 wasm 回归(mcpp-plugins `dist-web`)在发布前 +持续存在。 + +### 10.4 `trim_paths` 的映射按包给出(mcpp#844,D13) + +**缺陷**(2026.10.24.1 起):`trim_map` 由整个计划的包集合生成,`flags.cppm` 把整张表追加到全局编译参数。工作空间中所选成员 +变化时(`-p cli` / `-p gui`),公共依赖的命令行随之变化,ninja 判其过期而重编,两个成员在同一个配置目录里来回覆盖公共对象。 +这违背 #751 与 e2e 872 的不变量("成员无论如何选择只编译一次");872 只在 dev profile(`trim_paths` 默认关闭)下运行,因此没有 +发现。本机复现(2026.10.26.1,gcc,三成员工作空间):`--release` 下 `-p cli`、`-p gui`、`-p cli` 每次都重编 `core`;dev 下只编 +第一次。 + +**修复**:一个编译单元的映射只含固定根与**它所属包及其依赖闭包**的包根——这些是它的输出中可能出现的全部路径(自身源码、依赖的 +头文件与模块、生成文件、工具链与 store)。集合只取决于包的闭包,与所选成员无关;按路径长度、再按字典序排序(保持"最长前缀 +优先"的既有约定)。映射进入按包的编译参数(与模块映射同一处),不再进入全局参数。全局缓存键已含依赖闭包(Merkle 的 F 轴), +不需要新轴。先例:Cargo 的 `trim-paths` 按 crate 给出 `--remap-path-prefix`。 + +**测试**:e2e 872 增加 F(`--release`,`trim_paths` 开启):交替选择成员时公共依赖不重编——即 issue 的 CLI → GUI → CLI,在 +2026.10.26.1 上失败(`-p app` 重编 3 个单元)、本版通过;`ninja_backend` 单测断言共享参数不含映射、一个单元只带它所属包的映射、 +映射集合等于固定根加闭包且按前缀长度排序。 + +## 11. 交付 + +### 11.1 每个仓库一个 PR + +| 仓库 | 内容 | +|---|---| +| mcpplibs/openkal | S1:G1–G6、G4′、G8、L1、L2、K7 文字;映像约定章;一致性用例(原子、共享形态、一致性映像、加载器检查);README ok 模式一行 | +| mcpplibs/openkal-linux | K1、K2、K7、K11;G1–G6、G8、L2;共享形态;按约定导出 | +| mcpplibs/openkal-macos | K2、K8;G1–G6、G8、L2;共享形态;按约定导出 | +| mcpplibs/openkal-windows | K3、K4、K5、K9、K10;G1–G6、G8、L2;共享形态;按约定导出(SysV 转接) | +| mcpplibs/openkal-musl | §9 | +| mcpplibs/openkal-llvm-runtime | §9 | +| **mcpplibs/openkal-image(新建)** | §8.4 | +| **mcpplibs/os(新建)** | §6;CI:原生 9 组合 + `-openkal` | +| mcpp-community/mcpp(#842) | §10 | +| **mcpp-community/mcpp-ide(新建)** | part 1 §14 的全部 + `os` + M1 / M2 / M3 + 调试配置 | +| Sunrisepeak/mcpp-language-server | #71 + `os` + 删除 WA-PLATFORM-001、PRoot 提前设置、vendored openkal-linux + M2 / M3 载荷 | +| mcpplibs/mcpp-index | openkal 系新版本、openkal-image、os、mcpp、`mcpp.plugins` 0.20.0 | +| mcpp-community/mcpp-plugins(#51) | 引擎 pin 到新 mcpp | + +### 11.2 依赖链 + +``` +P 探测 PR(D12):已完成(§3.2) +S1 openkal ─┬─→ 三个实现(K*、N、共享形态、按约定导出) + ├─→ openkal-musl、openkal-llvm-runtime(映像形态、-openkal) + └─→ openkal-image:libokld → okld → libokrun → okrun(libokrun 需三个实现的 standalone) +实现 + musl + runtime + image ─→ os(原生 9 组合 + -openkal) +os + image ─┬─→ mcpp #842:D11(可在 S1 之前单独完成并跑 CI)→ os 接入 → 取消路径 → -openkal 目标 → 文档 + ├─→ mcpp-ide:os → 会话测试 → 协议 schema → 生成器 → webui → vscode → jetbrains → agents → M2/M3 → CI + └─→ mcppls:#71 → os → 删除绕行 → M2/M3 +发布:S1 → 实现 / musl / runtime(tag)→ image(tag)→ index → os(tag、index)→ 消费方改为版本依赖、CI 再绿 → 合入 + → mcpp 发布(取消 main CI、release.yml、gtc 补 GitCode、重跑 main CI、合入 index bump)→ mcppls / mcpp-ide 发布 + → plugins #51 → index → 净室生态验证 +``` + +### 11.3 分支引用 + +上游 PR 之间、消费方对上游,CI 中以 `{ git = …, branch = "" }` 引用;合入前改为版本,`mcpp.lock` 中不留 git 依赖(CI 检查)。 + +## 12. 验收 + +1. **同一映像字节**:每种架构构建一次 `.oke`,CI 记录 sha256,在 x86_64(linux、windows)与 aarch64(linux-arm、macos-arm64、 + windows-11-arm)上经 okrun 与 M3 运行 `os` 的行为测试与 mcpp-ide 后端会话测试;M3 内的映像字节与 `.oke` 相同。 +2. `os` 原生 9 组合全绿;平台模块误用的链接期失败用例;GCC `-O0` 的 stop word 用例(K1)。 +3. openkal 一致性套件:新原子、共享形态(两个映像共用一份实现的句柄与 stop word)、一致性映像、加载器检查。 +4. openkal-musl:`examples/dlopen`、`examples/early-constructor`。 +5. mcpp:940(A、B、C)、941 与 872 的 release 一遍(#844)、升级 e2e、全量 e2e;`modules/platform` 中 `#if` 为 0;mcpp-plugins 的 `ci.yml -f mcpp_source_ref`。 +6. mcppls 删除绕行后 conformance 与 nightly 不回退。 +7. 发布后净室生态验证。 + +## 13. part 1 的收尾 + +| 项 | 状态 | 去向 | +|---|---|---| +| mcpp / xlings 2026.10.26.1、index #568、xim-pkgindex #1081/#1082 | 已发布 | — | +| wasm 回归 | 修复在 #842 | §10.3 | +| plugins #51 | 待新 mcpp | §11 | +| mcppls #71 | 未开始 | §11 | +| mcpp-ide | 后端本地实现并对真实 mcpp 冒烟通过;平台层已重构;其余未完成;仓库未建 | §11 | +| mcpp-vscode | 不动(mcpp-vscode#23) | 不变 | +| 临时 PR #840、#843 | 未关闭 | #840 随本设计关闭;#843 在探测 P 合并入后关闭 | +| 净室验证 | 未做 | §12 第 7 条 | + +## 14. 回归与缺陷记录(本轮) + +| # | 现象 | 根因 | 处理 | +|---|---|---|---| +| B1 | 2026.10.26.1:Emscripten 不写 `.wasm` | 临时名链接 | wasm 原地链接(#842) | +| B2 | 间接导入找不到 BMI | §10.2 | D11 | +| B3 | B2 的首个修复让旧缓存不可读(已推送,未合入) | 缓存键不含映射内容 | D11 + 升级 e2e | +| B3a | `import std.compat` 在带映射的单元失败 | 同 B2 | D11 | +| B4 | openkal-linux stop word 在 GCC `-O0` 下 SIGSEGV | E7 | K1 | +| B4a | openkal-macos 写入关闭的流时进程被结束 | E20 | K8 | +| B5 | openkal-windows 不支持链接;stop word 声明与语义 | E10、E16 | K3、K4 | +| B6 | part 1 实施中修复的问题 | 见 part 1 | 已随 2026.10.26.1 发布 | +| B7 | release 下切换工作空间成员时公共依赖反复重编(mcpp#844) | 全局参数携带整个计划的 `trim_paths` 映射 | D13 | + +## 15. 生态级多视角自审(rev 3) + +**架构** + +- 分层一致:每项需求都落到"openkal 原子 / `os` 组合 / E / P / X"之一(§4),无一项以第二个后端实现。 +- 映像约定是 openkal 的定义扩展(从"声明级可移植"到"架构级二进制约定"),按 README 首句"portable kernel application binary + interface"仍在其职责内;它不涉及内核修改,全部在用户态。 +- 参考实现与规范分离(openkal-image 不进 SPEC 正文);加载器从 openkal-musl 移出,使 okrun 不依赖 C 库。 +- **发现**:最初的名称 `krun` 与 containers/crun 构建 libkrun 支持时安装的 `/usr/bin/krun`(Fedora / RHEL 的 `crun-krun` 包) + 重名,`krunner` 与 KDE KRunner 重名;改为 `okrun` / `okld`(O9)。 + +**稳定性** + +- B3 在自审中由实测发现(E14),D11 以升级 e2e 守护;E7 只在 GCC `-O0` 出现,各 CI 矩阵增加 gcc dev。 +- ok 模式的两个硬点(TLS、Windows 转接)先探测再定案(D12),探测失败只修订 §8,不影响 §6、§7、§10 的其余部分——它们在各 PR + 中是独立的检查点。 +- **发现**:okrun 若启用 CFG,会拦截进入映像的间接调用;已写入 §8.4(不启用或登记有效调用目标)。 +- **发现**:Windows 上映像内的硬件异常没有 SEH 展开表,WER 的崩溃栈不完整;okrun 的崩溃处理打印映像相对地址,`okld inspect` 可 + 符号化;C++ 异常走映像内的 DWARF,不受影响。 + +**优雅简洁** + +- 新原子都复用已有类型(G3 返回已有连接句柄,G5 照搬 `kal_fs_file_info` 的形状,G4 以 `kal_process_wait` 的 `terminated` 定义)。 +- G4 不引入原因词汇;G8 以规范限定用途,避免它成为"按 OS 分支"的入口。 + +**用户体验** + +- 最终用户拿到的是 M3:普通的、已签名的原生可执行文件;开发与 IDE 用 M2。 +- 调试:gdb、lldb(含 lldb-dap)经 JIT 接口看到映像符号;WinDbg 与 Visual Studio 调试器看不到,文档写明,mcpp-ide 对 `.oke` 目标 + 生成 lldb 配置。 +- `| head` 下 mcpp 不再被 SIGPIPE 静默结束:输出层把 EPIPE 视为正常结束。 + +**兼容与无感升级** + +- 原生模式行为不变;ok 模式是新增目标,不影响已有构建。 +- D11 只重建含模块的依赖一次。 +- **发现**:mcppls 以 `=` 精确 pin openkal-llvm-runtime 0.15.1、openkal-musl 0.19.1,并 vendored openkal-linux 0.15.1;本批的 + openkal 系新版本要求 mcppls 一并更新 pin 并删除 vendored 副本(已列入 §11)。 +- 映像约定带版本,加载器拒绝更高的主版本,次版本只增。 + +**跨平台** + +- 原生 9 组合已实测;ok 模式的五个运行点(§12 第 1 条)由探测与 CI 覆盖。 +- **发现**:G3 在 Windows 需要 10 1803+ 的 AF_UNIX;更早的系统以 `KAL_FS_PROP_LOCAL_ENDPOINT` 不声明报告。G5 的 `open_id` 在 + Linux 需要 pidfd(5.3+),更早的内核以查询报告。 +- macOS 上 okrun 需要可执行内存 entitlement 与公证;Linux 的 SELinux 默认策略允许匿名可执行映射(`execmem` 在受限域中可能被拒, + okrun 以错误报告)。 + +**一致性** + +- K7 统一 channel 方向与环境条目;G8 的值集合与 mcpp 目标词汇(`linux`、`macos`、`windows`)一致。 +- `os` 的错误分类由 `kal_err_*` 映射,三平台一致。 + +**安全** + +- 映像相当于解释器的输入,不受 Gatekeeper 与 SmartScreen 检查;签名与公证只覆盖 okrun。面向最终用户的分发用 M3(签名覆盖嵌入的 + 映像段);M2 用于开发与本身已签名的载荷(VSIX、IDE 插件)。 +- 映射可执行内存的加载器可能触发杀毒启发式;M3 以签名缓解。 + +**测试覆盖** + +- 规范:一致性套件新增四类(§12 第 3 条);实现:各自 CI;`os`:行为测试 + 链接期失败用例;消费方:交叉验证 + 同一映像字节运行。 + +**发布与依赖链** + +- 链条长:S1 → 三实现 → musl / runtime → image → os → 三消费方。D11 不依赖 openkal,可先行完成并跑 CI。 +- wasm 回归在 #842 发布前持续存在(O1 已定,接受)。 +- GCC 16 在模块边界上的问题(mcpp `unix_terminal` 的注释):`os` 接口使用 std 类型;mcpp-ide 已无问题,mcpp 由交叉验证确认, + 若复现在 `os` 一侧解决。 + +**rev 5 的补充自审** + +- **libokrun 被原生宿主程序嵌入时的运行时边界**:宿主进程有自己的 C / C++ 运行时(如 glibc + libstdc++),映像带自己的一套 + (如 openkal-musl + libc++)。openkal README 规则 3 的单位是"映像"而非进程;part 1 D9 允许"孤岛",前提是声明且可机械验证—— + ok 格式"只导入 kal_* 与其他映像"正是 `okld check` 可机械验证的条件,因此映像在宿主内是一个合格的孤岛:跨边界的只有 kal_* 的 C + ABI,不传递任何运行时对象。`okrun` 程序自身以 standalone 构建,进程内只有映像那一套。 +- **嵌入宿主中的进程级状态**:映像与宿主共用一份 openkal 实现(L1),stop word 也只有一份;映像内的 `os::interrupt` arm 即接管 + (G4′)。libokrun 的宿主 API 写明这一点,由宿主决定是否允许映像 arm。 +- **libokld 的分层**:格式解析(只处理字节,不调用 kal_*)与装载(`kal_exec`、`kal_fs`)分开,使 `okld` 作为普通构建机程序在任何 + 宿主上解析与检查映像,装载部分只在需要时链接。 +- **版本**:三个包同一版本号;libokld 声明它支持的映像约定最高版本,`okld check` 与 libokrun 都据此拒绝更高主版本的映像。 +- **M3 的交叉产出**:libokrun 以 standalone 方式构建,不依赖厂商 SDK;mcpp 已有 openkal 交叉构建(三目标 × 四主机)的 CI, + 在一台主机上产出三个 OS 的 M3 是同一机制。 +- **文件关联**:本批不改 xlings,`.oke` 不注册系统关联;M2 只以显式的 `okrun app.oke` 用于 IDE 载荷与开发,面向最终用户的是 M3。 +- **符号前缀**:`okld_*`、`okrun_*` 不使用 openkal 保留的 `kal_` 前缀。 + +**rev 6 的补充自审(探测之后)** + +- **探测覆盖的范围与没有覆盖的**:覆盖四个运行点、两种约定、回调、emulated TLS、可复现构建。未覆盖:Windows ARM64(GitHub 的 + `windows-11-arm` 上 mcpp 无发布)、macOS x86_64、hardened runtime 下的可执行内存 entitlement(E26)、映像之间的 `DT_NEEDED` + (`.okso`)、C++ 运行时(libc++、libunwind)在映像内的异常与展开、`dl_iterate_phdr`。这些进入 openkal-image 与 openkal-musl + 的 PR 的 CI(§12 第 1、4 条),不再单独探测;其中 Windows ARM64 记为 O12。 +- **emulated TLS 的代价**:探测中的实现用全局锁与线性查找,只为证明正确性;C 运行时的实现须为每上下文无锁访问(按 + `kal_task_current` 的哈希槽,或在 C 运行时自己启动上下文时预先登记)。`thread_local` 密集的代码(libc++ 的异常全局量)在 + ok 映像中比原生模式慢,记入文档;性能验收以 mcpp-ide 后端的会话测试耗时对比原生模式给出上限。 +- **`kal_task_current` 的复用**:规范只保证"同一时刻唯一";C 运行时必须在它启动的上下文结束时释放槽,否则复用的编号会读到前一个 + 上下文的 TLS。主上下文与非 C 运行时启动的上下文不受影响。已写入 §8.3。 +- **约定转接的生成**:探测中的 8 个入口是手写的;实现中由工具从 `SURFACE.txt` 与头文件生成,并对含函数指针参数的操作 + (`kal_task_start`、`kal_task_start_sized`、`kal_space_start`)生成跳板。生成器与其产物的一致性纳入 openkal-windows 的 CI。 +- **openkal 自己的先前结论**:openkal `2026-08-28` 评审 §9.2 指出"同一二进制遇到第二个实现"的判据在生态中无法构造;E22 正是这个 + 判据——同一份字节遇到四个实现。§9.5(产物携带下限)由 `.note.openkal` 回答,§9.6(映射粒度)由 L2 的 `kal_exec_granularity` 回答。 +- **`os::host()` 两种形态**:只采用形态 B;`if consteval` 形态在 MSVC 修复 P2448 之前不用。 + +## 16. 开放问题 + +| # | 问题 | 处理 | +|---|---|---| +| O1 | #842 的版本号 | 已定:2026.10.26.2,标题追加范围 | +| O4 | 环境条目的统一拒绝写入规范(K7) | 建议采纳 | +| O5 | macOS x86_64 的 stop word 在 CI 上运行 | 依赖 mcpp 的 macOS x86_64 发布;不阻塞 arm64 | +| O7 | Windows UNC 与后挂载卷 | 已定:K9,实现前实测 | +| O9 | 名称 | 已定:ok 格式;`.oke` / `.okso`;`libokld` / `libokrun`;`okrun` / `okld`(§8.4)。查重:`krun` 与 crun 的 libkrun 启动器重名、`krunner` 与 KDE KRunner 重名、`.ok` 为 Oak 语言源文件与测试期望输出的惯用扩展名,均未采用;`okld` 与 OKWS 的守护进程同名、`.okso` 为 okso.app 的文档格式,影响小,记录在此 | +| O10 | TLS 模型、编译器三元组 | 已定(E24):emulated TLS;映像编译三元组 `-unknown-none-elf`,aarch64 加 `-ffixed-x18` | +| O12 | Windows ARM64 的 ok 映像运行(x18、AAPCS64 与 Windows ARM64 约定的差异) | 进 openkal-windows 与 openkal-image 的 CI;mcpp 在 windows-11-arm 上的发布是前提 | +| O11 | 映像签名 | 已定:进映像约定(§8.3),`okld sign`,okrun 的签名策略 | diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 7abf7a5a..fb911ef5 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -337 records. +338 records. ## By subject @@ -66,6 +66,7 @@ Records that declare one. Everything else is listed by date below. ### ecosystem +- [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — accepted - [控制契约、并发与性能、单一 C++ 运行时,以及 IDE 生态(生态级,#838、#839)](2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md) — active ### heterogeneous @@ -143,6 +144,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-10 +- [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — accepted - [控制契约、并发与性能、单一 C++ 运行时,以及 IDE 生态(生态级,#838、#839)](2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md) — active - [目标基座与执行宿主:sysroot 的通用模型、运行器契约、自备输入,以及同批的发布产物路径(#832)](2026-10-23-target-base-and-execution-host-design.md) — landed - [依赖 image 的资源归属、模块扫描边界与锁文件稳定写入](2026-10-21-resource-ownership-module-scan-and-lock-write-design.md) — active From 90719e013eb21029bd742fd677003cfbea78349a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 18:44:43 +0900 Subject: [PATCH 03/43] fix: GCC module maps name the BMI directory, keyed in the cache; trim_paths is per package (#844) A GCC module map now names the BMI directory as its repository ($root) and every path below it, so a BMI built with GCC's own mapper and one built with a map record their imports at the same paths: an indirect import, and `import std.compat`, are found in a unit with a map. A dependency BMI an earlier mcpp cached spells its imports the earlier way, so the cache key of a package that provides a module under GCC carries the spelling (the upgrade lane's `modules` variant). The dyndep step reads $root. The `trim_paths` prefix map left the flags every unit shares: a unit carries the fixed roots and its own package's closure, so switching workspace members no longer recompiles their common dependencies (e2e 872 F). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/tools/upgrade_lane.sh | 37 ++++++++++ .github/workflows/ci-upgrade.yml | 1 + CHANGELOG.md | 21 ++++++ docs/04-mcpp-toml.md | 7 +- docs/zh/04-mcpp-toml.md | 5 +- modules/dyndep/src/dyndep.cppm | 12 +++- src/build/cache_key.cppm | 21 ++++++ src/build/flags.cppm | 16 ++++- src/build/ninja_backend.cppm | 26 +++++-- src/build/plan.cppm | 41 +++++++++++- src/build/prepare/plan.cpp | 37 +++++++++- ...er_compiles_once_whatever_the_selection.sh | 18 +++++ ..._is_found_whatever_map_its_importer_has.sh | 67 +++++++++++++++++++ tests/unit/test_cache_key.cpp | 18 +++++ tests/unit/test_dyndep.cpp | 14 ++++ tests/unit/test_module_address.cpp | 9 ++- tests/unit/test_ninja_backend.cpp | 67 +++++++++++++++++++ 17 files changed, 395 insertions(+), 22 deletions(-) create mode 100755 tests/e2e/940_an_indirect_import_is_found_whatever_map_its_importer_has.sh diff --git a/.github/tools/upgrade_lane.sh b/.github/tools/upgrade_lane.sh index fce1287f..be23c19d 100644 --- a/.github/tools/upgrade_lane.sh +++ b/.github/tools/upgrade_lane.sh @@ -131,6 +131,31 @@ assert installed.get("revision", 0) == 0, installed PY fi [ "$VARIANT" = orphan ] && { run old-tc-gcc "$W" "$OLD" toolchain install gcc@16.1.0 || fail "the previous release cannot install gcc"; } +# modules_project : `openkal.timeout` imports `openkal.datagram`, an +# INDIRECT import of the program's unit, found at the path its BMI records. +modules_project() { + mkdir -p "$W/$1/src" + cat > "$W/$1/mcpp.toml" <<'TOML' +[package] +name = "kp" +version = "0.1.0" + +[dependencies] +openkal = "0.16.0" + +[target.linux.dependencies] +openkal-linux = "0.17.0" + +[toolchain] +default = "gcc@16.1.0" +TOML + printf 'import openkal.timeout;\nint main() { return 0; }\n' > "$W/$1/src/main.cpp" +} +if [ "$VARIANT" = modules ]; then + run old-tc-gcc "$W" "$OLD" toolchain install gcc@16.1.0 || fail "the previous release cannot install gcc" + modules_project m-old + run old-modules "$W/m-old" "$OLD" build || fail "the previous release cannot build the module project" +fi new_project p-old "$OLD" run old-run "$W/p-old" "$OLD" run || fail "the previous release cannot run a new project" @@ -192,6 +217,18 @@ if [ "$VARIANT" = snapshot ]; then grep -q "packaging revision .* available" "$W/new-toolchain-list.log" \ || fail "toolchain list does not offer the newer packaging" fi +if [ "$VARIANT" = modules ]; then + # A BMI of the previous spelling is not read: the dependency is rebuilt + # once (its cache key carries the spelling), then served from the cache. + modules_project m-new1 + run new-modules1 "$W/m-new1" "$NEW" build -v || fail "the module project does not build over the previous release's cache" + grep -q "STAGE gcm.cache/mcpplibs.openkal/" "$W/new-modules1.log" \ + && fail "a dependency BMI of the previous spelling was taken from the cache" + modules_project m-new2 + run new-modules2 "$W/m-new2" "$NEW" build -v || fail "the second module project failed" + grep -q "STAGE gcm.cache/mcpplibs.openkal/" "$W/new-modules2.log" \ + || fail "the rebuilt dependency was not served from the cache" +fi if [ "$VARIANT" = orphan ]; then run new-provision "$W" "$NEW" toolchain install "$TC" || fail "toolchain install could not replace the payload" grep -q "installed asset registration missing" "$W/new-provision.log" \ diff --git a/.github/workflows/ci-upgrade.yml b/.github/workflows/ci-upgrade.yml index 277f4bcd..3aa0b0a7 100644 --- a/.github/workflows/ci-upgrade.yml +++ b/.github/workflows/ci-upgrade.yml @@ -29,6 +29,7 @@ jobs: - { host: linux-x86_64, runs-on: ubuntu-24.04, variant: plain } - { host: linux-x86_64, runs-on: ubuntu-24.04, variant: snapshot } - { host: linux-x86_64, runs-on: ubuntu-24.04, variant: orphan } + - { host: linux-x86_64, runs-on: ubuntu-24.04, variant: modules } - { host: linux-aarch64, runs-on: ubuntu-24.04-arm, variant: plain } - { host: macos-arm64, runs-on: macos-15, variant: plain } - { host: windows-x86_64, runs-on: windows-latest, variant: plain } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9157462e..34c6da40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,27 @@ `app.wasm`, the edge's implicit output was missing, and `mcpp pack` of a `wasm32-emscripten` program (mcpp-plugins' `dist-web`) found no stem family. A WebAssembly link is in place, as a PE link is. +- **A module's indirect import is found whatever map its importer has (GCC).** + A unit that reaches another package's module is compiled with a module map, + and a unit that imports only the root's modules with GCC's own mapper. A BMI + records the paths of its imports as its compile's mapper spelt them, and the + map named no repository, so a unit with a map that imported a root module + compiled without one looked for that module's import beside the build + directory (`failed to read compiled module`, `Bad import dependency`). The + map now names the BMI directory as its repository (`$root gcm.cache`) and + every path below it, as GCC's own mapper does; the dyndep step reads the + same form. The same cause failed `import std.compat` in a unit with a map, in + any project with a dependency. A dependency's BMI that an earlier mcpp put in + the global build cache spells its imports the earlier way, so the cache key + of a package that provides a module under GCC carries the spelling: such a + package is compiled once more after the upgrade, and a package of object + files keeps its entry. +- **Switching workspace members under `trim_paths` no longer recompiles their + shared dependencies** (#844). The prefix map of the whole plan was part of + the flags every unit shares, so a dependency's command line named the roots + of the members being built, and `--release -p cli` then `-p gui` recompiled + the common packages each time. A unit's map is now the fixed roots and the + roots of its own package's dependency closure. ## [2026.10.26.1] - 2026-10-26 diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index ea3ade7d..2082af5d 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -1505,7 +1505,7 @@ debug = false # -g lto = true # -flto; /GL + /LTCG on cl.exe (note: some packaged gcc builds ship without the LTO plugin) strip = true # -s at link time ndebug = true # -DNDEBUG for every unit of the graph (default: on for release/dist) -trim_paths = true # -ffile-prefix-map for every unit (default: on for release/dist) +trim_paths = true # -ffile-prefix-map per unit (default: on for release/dist) # passthrough escape hatch (fixed keys, open values): cflags = ["-fno-plt"] cxxflags = ["-fno-plt"] @@ -1534,7 +1534,10 @@ ldflags = [] `@/…` (the root package's too, so a report reads `app@1.2.0/src/main.cpp`), the store, home, build directory, toolchain and sysroot to `/mcpp/store`, `/mcpp/home`, `/mcpp/build`, `/mcpp/toolchain` and `/mcpp/sysroot`. Two - checkouts of one tree then embed identical paths. It applies to what `build`, `run` + checkouts of one tree then embed identical paths. A unit's map holds the fixed roots + and the roots of its own package's dependency closure, so a dependency's command line + is the same whichever workspace members a build selects (2026.10.26.2+). It applies to + what `build`, `run` and `pack` produce, not to `mcpp test`: a test binary runs where it was built and commonly finds its fixtures through `__FILE__`. A build with debug information also writes `source-map.gdbinit` and `source-map.lldbinit` beside its outputs, which diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 818ceb5b..c065ea2b 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -1400,7 +1400,7 @@ debug = false # -g lto = true # -flto; /GL + /LTCG on cl.exe (note: some packaged gcc builds ship without the LTO plugin) strip = true # -s at link time ndebug = true # 整个图的每个单元 -DNDEBUG(release/dist 默认开) -trim_paths = true # 整个图的每个单元 -ffile-prefix-map(release/dist 默认开) +trim_paths = true # 按单元的 -ffile-prefix-map(release/dist 默认开) # passthrough escape hatch (fixed keys, open values): cflags = ["-fno-plt"] cxxflags = ["-fno-plt"] @@ -1429,7 +1429,8 @@ ldflags = [] 映射为它所代表的东西:每个包的源码映射为 `@/…`(根包同样如此,报告读作 `app@1.2.0/src/main.cpp`),存储、home、构建目录、工具链与 sysroot 分别映射为 `/mcpp/store`、 `/mcpp/home`、`/mcpp/build`、`/mcpp/toolchain` 与 `/mcpp/sysroot`。同一棵树的两个检出因此嵌入 - 相同的路径。它作用于 `build`、`run` 与 `pack` 的产物,不作用于 `mcpp test`:测试二进制在构建处运行, + 相同的路径。一个单元的映射只含固定根与它所属包的依赖闭包中各包的根,所以无论构建选择哪些工作空间成员, + 依赖的命令行都相同(2026.10.26.2+)。它作用于 `build`、`run` 与 `pack` 的产物,不作用于 `mcpp test`:测试二进制在构建处运行, 且常借 `__FILE__` 找到它的夹具。带调试信息的构建还在输出旁写出 `source-map.gdbinit` 与 `source-map.lldbinit`, 供调试器把路径映射回来。cl.exe 没有文档化的等价开关,构建会报告一次。在 profile 下写 `ndebug = false` 或 `trim_paths = false` 可保留以前的产物;升级后第一次发布档构建会提示一次。 diff --git a/modules/dyndep/src/dyndep.cppm b/modules/dyndep/src/dyndep.cppm index 145602d8..4143bf3c 100644 --- a/modules/dyndep/src/dyndep.cppm +++ b/modules/dyndep/src/dyndep.cppm @@ -63,7 +63,8 @@ struct DyndepOptions { // The BMI path `name` takes under `opts`: the module map's, or the flat one. std::string bmi_path_for(std::string_view name, const DyndepOptions& opts); -// Parse a module map: ` ` per line. +// Parse a module map: ` ` per line. A `$root ` +// line (GCC's repository) puts every relative path after it below ``. std::map> parse_module_map(std::string_view body); // Parse a single .ddi JSON body to a UnitInfo. Returns unexpected on JSON error. @@ -206,6 +207,7 @@ std::string bmi_path_for(std::string_view name, const DyndepOptions& opts) { std::map> parse_module_map(std::string_view body) { std::map> out; + std::string_view root; while (!body.empty()) { auto nl = body.find('\n'); auto line = body.substr(0, nl); @@ -213,7 +215,13 @@ std::map> parse_module_map(std::string_vie while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.remove_suffix(1); auto sp = line.find(' '); if (sp == std::string_view::npos || sp == 0) continue; - out.emplace(std::string(line.substr(0, sp)), std::string(line.substr(sp + 1))); + const auto name = line.substr(0, sp); + const auto path = line.substr(sp + 1); + if (name == "$root") { root = path; continue; } + if (root.empty() || path.starts_with('/') || std::filesystem::path(path).is_absolute()) + out.emplace(std::string(name), std::string(path)); + else + out.emplace(std::string(name), std::string(root) + "/" + std::string(path)); } return out; } diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 96b7f48e..2e3047fd 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -249,6 +249,23 @@ struct PackageAxes { // it) — which is exactly why it is easy to leave out and find later as a // wrong cache hit. Cheap to close now. std::vector moduleExtensions; + // How a BMI this package writes spells the paths of the modules it + // imports — set by the caller only for a package that provides a module + // under a compiler whose BMIs record those paths (GCC), empty otherwise. + // + // GCC writes into a BMI the path of each module it imports as the + // compile's mapper spelt it, and an importer looks for an INDIRECT import + // at that path below its own repository. Since 2026.10.26.2 a module map + // names the BMI directory as its repository (`$root`, design 2026-10-26 + // part 2 §10.2) and spells every path below it; a BMI written before + // spelt `gcm.cache//.gcm`, which an importer under the new map + // reads as `gcm.cache/gcm.cache/...` and cannot find. Measured: the + // first build of any project whose module dependency was cached by + // 2026.10.26.1. The compiler, the flags and the sources of such an entry + // are unchanged, so no other axis moves; this one does, and only for the + // packages whose BMIs carry the spelling. A package of object files keeps + // its key, and `kCacheEpoch` stays where it is. + std::string bmiPathConvention; // F — keys of direct dependencies, sorted std::vector upstreamKeys; }; @@ -363,6 +380,8 @@ nlohmann::json to_json(const BuildAxes& b, const PackageAxes& p) { {"sources", p.sources}, {"module_extensions", p.moduleExtensions}, }; + if (!p.bmiPathConvention.empty()) + j["language"]["bmi_path_convention"] = p.bmiPathConvention; j["upstream"] = p.upstreamKeys; return j; } @@ -412,6 +431,8 @@ std::string key_hex(const BuildAxes& b, const PackageAxes& p) { put_list(s, "srcglobs", p.sourceGlobs); put_list(s, "sources", p.sources); put_list(s, "modexts", p.moduleExtensions); + // Only when set, so the key of a package without one is unchanged. + if (!p.bmiPathConvention.empty()) put(s, "bmipath", p.bmiPathConvention); // F put_list(s, "upstream", p.upstreamKeys); return mcpp::toolchain::hash_string(s); diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 345bff09..9cbe77e0 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -68,6 +68,10 @@ struct CompileFlags { // The link words that name the island's C++ runtime (design 2026-10-26 // §6.3), placed after the build's own runtime in each C++ unit's tail. std::string ldForeignRuntime; + // `trim_paths` (mcpp#844): the rendered prefix map of each package, by + // `CompileUnit::packageName`, appended to that package's C and C++ units. + // Empty on cl.exe, which has no such switch. + std::map> trimByPackage; // A compile-command prefix stated with a toolchain named by path // (`launcher = "ccache"`, mcpp#755): written in front of the compiler in // the build graph, and not into the compile database, whose consumers @@ -1183,8 +1187,16 @@ CompileFlags compute_flags(const BuildPlan& plan) { "paths: the artifact keeps the absolute paths it was built from (clang on " "`*-windows-msvc` trims them)"); } else { - for (auto const& [from, to] : plan.trimMap) - opt_flag += " -ffile-prefix-map=" + escape_path(from) + "=" + to; + // Per package, not here (mcpp#844): a map of the whole plan in + // the flags every unit shares made a dependency's command line + // depend on the members the plan builds, and switching members + // recompiled it. A unit's map is its package's (`packageTrimMaps`). + for (auto const& [package, map] : plan.packageTrimMaps) { + std::string rendered; + for (auto const& [from, to] : map) + rendered += " -ffile-prefix-map=" + escape_path(from) + "=" + to; + f.trimByPackage.emplace(package, std::move(rendered)); + } } } diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index a69d9716..de1eff87 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -1421,6 +1421,18 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // All compile/link flags are computed once via flags.cppm. auto flags = compute_flags(plan); + // A unit's own flags: its package's, then its package's `trim_paths` map + // (mcpp#844), so that nothing outside the package's closure reaches it. + auto unit_trim = [&](const CompileUnit& cu) -> std::string_view { + auto it = flags.trimByPackage.find(cu.packageName); + return it == flags.trimByPackage.end() ? std::string_view{} : std::string_view(it->second); + }; + auto unit_cxx_flags = [&](const CompileUnit& cu) { + return join_compile_flags(cu.packageCxxflags) + std::string(unit_trim(cu)); + }; + auto unit_c_flags = [&](const CompileUnit& cu) { + return join_compile_flags(cu.packageCflags) + std::string(unit_trim(cu)); + }; // Everything that has to sit beside the artifact: the runtime placement // resolver's one answer over the declared deploys, the runtime search @@ -2849,7 +2861,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, : escape_ninja_path(cu.object))); if (auto includes = local_include_flags(cu, dial); !includes.empty()) append(std::format(" local_includes ={}\n", includes)); - if (auto flags = join_compile_flags(cu.packageCxxflags); !flags.empty()) + if (auto flags = unit_cxx_flags(cu); !flags.empty()) append(std::format(" unit_cxxflags ={}\n", flags)); // The scan has the same suffix-recognition problem as the compile: // a driver that does not know `.ixx` hands it to the linker and @@ -2995,7 +3007,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, e += std::format(" sched_cap = {}\n", plan.scheduleCompilerCap); if (auto inc = local_include_flags(cu, dial); !inc.empty()) e += " local_includes =" + inc + "\n"; - if (auto fl = join_compile_flags(cu.packageCxxflags); !fl.empty()) + if (auto fl = unit_cxx_flags(cu); !fl.empty()) e += " unit_cxxflags =" + fl + "\n"; append(std::move(e)); // The join. THE SOURCE IS AN INPUT, and the BMI is only an @@ -3060,7 +3072,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, e += "\n dyndep = " + it->second + "\n"; if (auto inc = local_include_flags(cu, dial); !inc.empty()) e += " local_includes =" + inc + "\n"; - if (auto fl = join_compile_flags(cu.packageCxxflags); !fl.empty()) + if (auto fl = unit_cxx_flags(cu); !fl.empty()) e += " unit_cxxflags =" + fl + "\n"; append(std::move(e)); }; @@ -3100,10 +3112,10 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, if (auto flags = join_compile_flags(unit_asm_flags(cu)); !flags.empty()) out_line += " unit_asmflags =" + flags + "\n"; } else if (is_c_source(cu)) { - if (auto flags = join_compile_flags(cu.packageCflags); !flags.empty()) + if (auto flags = unit_c_flags(cu); !flags.empty()) out_line += " unit_cflags =" + flags + "\n"; } else { - if (auto flags = join_compile_flags(cu.packageCxxflags); !flags.empty()) + if (auto flags = unit_cxx_flags(cu); !flags.empty()) out_line += " unit_cxxflags =" + flags + "\n"; } append(std::move(out_line)); @@ -3153,10 +3165,10 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, if (auto flags = join_compile_flags(unit_asm_flags(cu)); !flags.empty()) out_line += " unit_asmflags =" + flags + "\n"; } else if (is_c_source(cu)) { - if (auto flags = join_compile_flags(cu.packageCflags); !flags.empty()) + if (auto flags = unit_c_flags(cu); !flags.empty()) out_line += " unit_cflags =" + flags + "\n"; } else { - if (auto flags = join_compile_flags(cu.packageCxxflags); !flags.empty()) + if (auto flags = unit_cxx_flags(cu); !flags.empty()) out_line += " unit_cxxflags =" + flags + "\n"; } // Clang needs $bmi_out to emit -fmodule-output=$bmi_out diff --git a/src/build/plan.cppm b/src/build/plan.cppm index fc496871..4d10aa45 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -404,6 +404,15 @@ struct BuildPlan { // a driver that tries the last-given mapping first takes the longest // match. Empty unless the profile trims. std::vector> trimMap; + // THE SAME MAP, PER PACKAGE (mcpp#844, design 2026-10-26 part 2 §10.4), + // by `CompileUnit::packageName`: the fixed roots and the roots of the + // package and its dependency closure -- every prefix the package's units + // can write -- in the order of `trimMap`. A unit's command carries its + // package's map and no other, so a package's command line does not + // depend on which members the plan builds. Filled by prepare, which owns + // the dependency edges. + std::map>, + std::less<>> packageTrimMaps; mcpp::toolchain::Toolchain toolchain; mcpp::toolchain::Fingerprint fingerprint; // Where the target's platform interface, C library and C++ runtime come @@ -810,6 +819,16 @@ bool link_unit_holds_cxx(const BuildPlan& plan, const LinkUnit& lu); // this (#646 F3a). bool image_loads_cxx_shared_library(const BuildPlan& plan, LinkUnit::Kind kind); +// The `trim_paths` map (`BuildPlan::trimMap`) of the packages `keep` marks, +// `keep[i]` for `packages[i]`; every package when `keep` is empty. The fixed +// roots are always in it. +std::vector> +trim_map(const std::vector& packages, + const mcpp::toolchain::Toolchain& tc, + const std::filesystem::path& outputDir, + const std::vector& storeRoots, + const std::vector& keep = {}); + } // namespace mcpp::build namespace mcpp::build { @@ -1567,7 +1586,8 @@ std::vector> trim_map(const std::vector& packages, const mcpp::toolchain::Toolchain& tc, const std::filesystem::path& outputDir, - const std::vector& storeRoots) { + const std::vector& storeRoots, + const std::vector& keep) { std::vector> out; auto add = [&](std::filesystem::path from, std::string to) { if (from.empty() || !from.is_absolute()) return; @@ -1583,11 +1603,14 @@ trim_map(const std::vector& packages, : std::filesystem::path(tc.localRoot), "/mcpp/toolchain"); if (tc.base.stated()) add(tc.base.root, "/mcpp/sysroot"); add(outputDir, "/mcpp/build"); - for (auto const& p : packages) + for (std::size_t i = 0; i < packages.size(); ++i) { + auto const& p = packages[i]; + if (!keep.empty() && (i >= keep.size() || !keep[i])) continue; if (!p.manifest.package.name.empty()) add(p.root, std::format("{}@{}", p.manifest.package.name, p.manifest.package.version.empty() ? std::string("0") : p.manifest.package.version)); + } std::ranges::stable_sort(out, {}, [](auto const& e) { return e.first.native().size(); }); return out; } @@ -3553,8 +3576,20 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (auto const* std_name : {"std", "std.compat"}) entries.emplace(std_name, std::string(traits.bmiDir) + "/" + basename(std_name)); + // GCC's map names the BMI directory as its repository (`$root`) + // and every path below it, as GCC's own mapper does for a unit + // given no map. A BMI records the paths of the modules it imports + // as its compile's mapper spelt them, and an importer finds an + // INDIRECT import at that recorded path below its own repository: + // a root unit compiled without a map, imported by one compiled with + // a repository other than the BMI directory, would send that + // importer to `.gcm` beside the build directory. ModuleScope scope; - for (auto const& [name, path] : entries) scope.content += name + " " + path + "\n"; + const std::string root = std::string(traits.bmiDir) + "/"; + if (gcc) scope.content = "$root " + std::string(traits.bmiDir) + "\n"; + for (auto const& [name, path] : entries) + scope.content += name + " " + + (gcc && path.starts_with(root) ? path.substr(root.size()) : path) + "\n"; if (!gcc && importsQualified) { // `-fmodule-file==` (clang) or `/reference // =` (MSVC) for each module the unit imports that diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index b9ff7cff..8b0eeafe 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -445,6 +445,32 @@ static std::expected step13_make_plan(PrepareState& state, Bu state.stdBmiPath, state.stdObjectPath, state.storeRoots); if (!planResult) return std::unexpected(planResult.error()); ctx.plan = std::move(*planResult); + // `trim_paths` per package (mcpp#844, design 2026-10-26 part 2 §10.4): a + // unit's map is the fixed roots and the roots of its package's dependency + // closure, which is every prefix its outputs can hold and does not depend + // on the members this plan builds. + if (!ctx.plan.trimMap.empty()) { + const auto n = state.packages.size(); + std::vector> deps(n); + for (auto const& e : state.dependencyEdges) + if (e.consumerPackageIndex < n && e.dependencyPackageIndex < n) + deps[e.consumerPackageIndex].push_back(e.dependencyPackageIndex); + for (std::size_t i = 0; i < n; ++i) { + std::vector closure(n, 0); + std::vector todo{i}; + while (!todo.empty()) { + const auto at = todo.back(); + todo.pop_back(); + if (closure[at]) continue; + closure[at] = 1; + for (auto d : deps[at]) todo.push_back(d); + } + ctx.plan.packageTrimMaps.try_emplace( + mcpp::build::qualified_package_name(state.packages[i].manifest), + mcpp::build::trim_map(state.packages, *state.tc, ctx.outputDir, + state.storeRoots, closure)); + } + } note_root_link_words_withheld(state, ctx); if (auto r = scope_lto(state, ctx); !r) return std::unexpected(r.error()); // The request the graph is planned for (§3, §15): the plan's members and @@ -1601,6 +1627,7 @@ static std::expected step13_dependency_cache(PrepareState& st // Sources belonging to each package, package-root-relative and sorted. std::vector> pkgSources(state.packages.size()); + std::vector pkgProvidesModule(state.packages.size(), 0); for (auto& cu : ctx.plan.compileUnits) { // Longest matching root wins. Package roots can nest — a workspace // member lives under the workspace root — and taking the first match @@ -1623,7 +1650,10 @@ static std::expected step13_dependency_cache(PrepareState& st best = p; bestLen = len; bestRel = std::move(rels); } } - if (best != state.packages.size()) pkgSources[best].push_back(std::move(bestRel)); + if (best != state.packages.size()) { + if (!cu.providesModule.empty()) pkgProvidesModule[best] = 1; + pkgSources[best].push_back(std::move(bestRel)); + } } for (auto& v : pkgSources) std::ranges::sort(v); @@ -1690,6 +1720,11 @@ static std::expected step13_dependency_cache(PrepareState& st state.storeRoots.empty() ? std::filesystem::path{} : state.storeRoots.front()); pa.sources = pkgSources[idx]; + // A GCC BMI records its imports' paths as the module map spells + // them, and the map names the BMI directory as its repository + // (`plan.cppm`, the module scopes). See `bmiPathConvention`. + if (pkgProvidesModule[idx] && state.tc->compiler == mcpp::toolchain::CompilerId::GCC) + pa.bmiPathConvention = "$root=bmi-dir"; // What `scope_lto` appended to this package's units is part of // their command, so it is part of the entry's identity. if (state.ltoWithheld.contains( diff --git a/tests/e2e/872_a_member_compiles_once_whatever_the_selection.sh b/tests/e2e/872_a_member_compiles_once_whatever_the_selection.sh index 8463922c..e5505278 100644 --- a/tests/e2e/872_a_member_compiles_once_whatever_the_selection.sh +++ b/tests/e2e/872_a_member_compiles_once_whatever_the_selection.sh @@ -33,6 +33,10 @@ # shared member changes exactly that define (docs/06; e2e 851 C). # E. A dependency's module served from the global cache is staged below its # package's directory, and the program that imports it runs. +# F. B under `--release`, where `trim_paths` is on: a unit's prefix map is +# its package's closure, so no selection recompiles `core` (mcpp#844; +# 2026.10.26.1 recompiled it on every switch between `-p app` and +# `-p tool`). set -e HERE="$(cd "$(dirname "$0")" && pwd)" source "$HERE/_host_path.sh" @@ -137,6 +141,20 @@ for sel in "-p core" "-p app" "-p tool" "-p tool2" "-p dso" "-p app" "-p tool2" done echo "ok: B, no selection recompiles what another selection compiled" +# ── F ────────────────────────────────────────────────────────────────────── +"$MCPP" build --workspace --release > f.log 2>&1 || fail "F: the release workspace did not build" f.log +rlog=$(ls -t $(find target -name .ninja_log -not -path '*/.build-mcpp/*') | head -1) +[ -n "$rlog" ] && [ "$rlog" != "$log" ] || fail "F: no release build directory" f.log +grep -rq "file-prefix-map" "$(dirname "$rlog")/build.ninja" \ + || fail "F: the release build trims no path, so this criterion measured nothing" f.log +for sel in "-p app" "-p tool" "-p app" "-p core" "-p tool2" "-p app" "--workspace"; do + before=$(wc -l < "$rlog") + "$MCPP" build --release $sel > f.log 2>&1 || fail "F: build --release $sel failed" f.log + n=$(compiles_after "$rlog" "$before") + [ "$n" = 0 ] || { tail -n +"$(( before + 1 ))" "$rlog"; fail "F: build --release $sel compiled $n units after --workspace" f.log; } +done +echo "ok: F, under trim_paths no selection recompiles what another selection compiled" + # ── C ────────────────────────────────────────────────────────────────────── # compare []: every source of # the member's closure has one argument list in both databases; with a fourth diff --git a/tests/e2e/940_an_indirect_import_is_found_whatever_map_its_importer_has.sh b/tests/e2e/940_an_indirect_import_is_found_whatever_map_its_importer_has.sh new file mode 100755 index 00000000..1240b9c5 --- /dev/null +++ b/tests/e2e/940_an_indirect_import_is_found_whatever_map_its_importer_has.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# requires: +# 940 -- an indirect import is found whatever module map its importer has. +# +# A unit of the root package is given a module map only when it reaches a +# module of another package (pack drive and selection design 2026-10-01, B1); +# a unit that imports only the root's modules is compiled with the compiler's +# own mapper. GCC records in a BMI the path of each module that BMI imports, as +# its compile's mapper spelt it, and an importer finds an INDIRECT import at +# that recorded path below its own repository. Until 2026.10.26.2 a map named +# no repository, so a unit with a map that imported a root module compiled +# without one looked for that module's import beside the build directory: +# +# mcppide.platform.os: error: failed to read compiled module: No such file +# note: compiled module file is 'mcppide.platform.os.gcm' +# +# Criteria: +# A. `app.os` (imports nothing), `app.process` (imports app.os; no map), +# `app.jobs` (imports app.process and a path dependency's module; a map): +# the build succeeds and the program runs. +# B. The reverse: `app.view` (no map) imports `app.jobs` (a map), whose BMI +# records its own imports; the build succeeds. +# C. `app.compat` (a map) imports `std.compat`, whose BMI the compiler's own +# mapper built and which records `std` below that mapper's repository; +# the build succeeds. 2026.10.26.1 failed this in any project with a +# dependency. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +cd "$TMP" +mkdir -p dep/src app/src +cat > dep/mcpp.toml <<'TOML' +[package] +name = "dep" +version = "0.1.0" + +[targets.dep] +kind = "lib" +TOML +printf 'export module dep.lib;\nexport int dep_value() { return 2; }\n' > dep/src/lib.cppm + +cat > app/mcpp.toml <<'TOML' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +dep = { path = "../dep" } +TOML +printf 'export module app.os;\nexport inline constexpr int os_value = 1;\n' > app/src/os.cppm +printf 'export module app.process;\nimport app.os;\nexport int process_value() { return os_value; }\n' > app/src/process.cppm +printf 'export module app.jobs;\nimport app.process;\nimport dep.lib;\nexport int jobs_value() { return process_value() + dep_value(); }\n' > app/src/jobs.cppm +printf 'export module app.view;\nimport app.jobs;\nexport int view_value() { return jobs_value() * 10; }\n' > app/src/view.cppm +printf 'export module app.compat;\nimport std.compat;\nimport dep.lib;\nexport int compat_value() { return static_cast(::strlen("ab")) + dep_value(); }\n' > app/src/compat.cppm +printf '#include \nimport app.view;\nimport app.compat;\nint main() { std::printf("%%d\\n", view_value() + compat_value()); }\n' > app/src/main.cpp + +cd app +"$MCPP" build > build.log 2>&1 || fail "a unit with a map could not read a root BMI's import" build.log +echo "ok: A, B, C the build succeeds" +out=$("$MCPP" run 2>run.log | tr -d '\r' | tail -1) +[ "$out" = "34" ] || fail "the program printed '$out', expected 34" run.log +echo "ok: the program runs" + +echo "PASS: 940 an indirect import is found whatever map its importer has" diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index 5d7b33f8..cf10c9c6 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -723,3 +723,21 @@ TEST(CacheKey, TheMsvcToolsetOfTheClangRowChangesTheKey) { EXPECT_NE(ck::key_hex(a, pkg()), ck::key_hex(ck::build_axes(tc3, m, "-std=c++23", {}, ""), pkg())); } + +// THE BMI PATH CONVENTION MOVES THE KEY OF A PACKAGE THAT HAS ONE, AND NO +// OTHER. A GCC BMI records the paths of the modules it imports as its module +// map spelt them; the map's spelling changed in 2026.10.26.2 (`$root`), and an +// entry written under the old one cannot be imported under the new. The axis +// is set only for a package that provides a module, so a package of object +// files keeps the key it had — the serialization of an unset axis is the +// serialization from before the axis existed. +TEST(CacheKey, TheBmiPathConventionMovesOnlyThePackagesThatHaveOne) { + auto withModules = pkg(); + withModules.bmiPathConvention = "$root=bmi-dir"; + EXPECT_NE(ck::key_hex(axes(), pkg()), ck::key_hex(axes(), withModules)); + + const auto j = ck::to_json(axes(), withModules); + ASSERT_TRUE(j["language"].contains("bmi_path_convention")); + EXPECT_EQ(j["language"]["bmi_path_convention"], "$root=bmi-dir"); + EXPECT_FALSE(ck::to_json(axes(), pkg())["language"].contains("bmi_path_convention")); +} diff --git a/tests/unit/test_dyndep.cpp b/tests/unit/test_dyndep.cpp index 59abe564..1a9280e1 100644 --- a/tests/unit/test_dyndep.cpp +++ b/tests/unit/test_dyndep.cpp @@ -203,3 +203,17 @@ TEST(VerifyUnitExpectations, ExpectNoneMatchesEmptyUnit) { auto err = mcpp::dyndep::verify_unit_expectations(u, std::nullopt, {}); EXPECT_FALSE(err.has_value()); } + +// GCC's map names its repository with `$root`, and every relative path after +// it lies below that directory: the dyndep step must name the BMI the compiler +// reads, or ninja orders the compile before the BMI exists. +TEST(Dyndep, ParseModuleMapRootedAtTheBmiDirectory) { + auto m = parse_module_map("$root gcm.cache\na a.gcm\ndep.m dep/dep.m.gcm\nstd std.gcm\n"); + EXPECT_EQ(m.size(), 3u); + EXPECT_FALSE(m.contains("$root")); + EXPECT_EQ(m.at("a"), "gcm.cache/a.gcm"); + EXPECT_EQ(m.at("dep.m"), "gcm.cache/dep/dep.m.gcm"); + // Without `$root` (clang's and MSVC's maps) a path is taken as written. + auto plain = parse_module_map("m pcm.cache/core/m.pcm\n"); + EXPECT_EQ(plain.at("m"), "pcm.cache/core/m.pcm"); +} diff --git a/tests/unit/test_module_address.cpp b/tests/unit/test_module_address.cpp index 4cec2dd1..cc934197 100644 --- a/tests/unit/test_module_address.cpp +++ b/tests/unit/test_module_address.cpp @@ -199,9 +199,12 @@ TEST(ModuleAddress, MemberBmisAreQualifiedAndTheMapIsTheUnitsReach) { ASSERT_TRUE(gcc) << err; auto const& iface = gcc->core.at("core.cppm"); EXPECT_EQ(iface.bmiFile, "core/corelib.gcm"); - EXPECT_NE(iface.mapContent.find("corelib gcm.cache/core/corelib.gcm\n"), std::string::npos); - EXPECT_NE(iface.mapContent.find("m gcm.cache/core/m.gcm\n"), std::string::npos); - EXPECT_NE(iface.mapContent.find("std gcm.cache/std.gcm\n"), std::string::npos); + // GCC's map is rooted at the BMI directory, as its default mapper is, so + // a BMI records its imports at the same paths whichever mapper made it. + EXPECT_TRUE(iface.mapContent.starts_with("$root gcm.cache\n")) << iface.mapContent; + EXPECT_NE(iface.mapContent.find("corelib core/corelib.gcm\n"), std::string::npos); + EXPECT_NE(iface.mapContent.find("m core/m.gcm\n"), std::string::npos); + EXPECT_NE(iface.mapContent.find("std std.gcm\n"), std::string::npos); EXPECT_EQ(iface.mapContent.find("tool"), std::string::npos) << "the map names a module the unit does not reach:\n" << iface.mapContent; ASSERT_FALSE(iface.flags.empty()); diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index a836c69e..688bdf44 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -8,6 +8,7 @@ import mcpp.build.ninja; import mcpp.build.plan; import mcpp.libs.json; import mcpp.manifest; +import mcpp.modgraph.scanner; // PackageRoot, for trim_map import mcpp.toolchain.dialect; import mcpp.toolchain.model; import mcpp.toolchain.triple; @@ -2971,3 +2972,69 @@ TEST(NinjaBackend, PeExportsPatternsAndAConsumerReachTheDefEdge) { std::error_code ec; std::filesystem::remove_all(plan.outputDir, ec); } + +// `trim_paths` PER PACKAGE (mcpp#844). The map of the whole plan sat in the +// flags every unit shares, so a dependency's command line named the roots of +// whichever members the plan built, and switching members recompiled it. A +// unit now carries its own package's map, and the shared flags carry none. +TEST(NinjaBackend, ATrimMapIsTheUnitsPackagesAndNoOther) { + auto plan = minimal_plan(); + plan.manifest.buildConfig.trimPaths = true; + const std::filesystem::path build = "/w/target/x"; + const std::filesystem::path core = "/w/core"; + const std::filesystem::path app = "/w/app"; + plan.trimMap = {{core, "core@0.1.0"}, {app, "app@0.1.0"}, {build, "/mcpp/build"}}; + plan.packageTrimMaps["core"] = {{core, "core@0.1.0"}, {build, "/mcpp/build"}}; + plan.packageTrimMaps["app"] = {{core, "core@0.1.0"}, {app, "app@0.1.0"}, {build, "/mcpp/build"}}; + plan.compileUnits.push_back({.source = "/w/core/src/core.cpp", .kind = mcpp::SourceKind::Cxx, + .object = "obj/core.o", .packageName = "core"}); + plan.compileUnits.push_back({.source = "/w/app/src/main.cpp", .kind = mcpp::SourceKind::Cxx, + .object = "obj/main.o", .packageName = "app"}); + + const auto flags = compute_flags(plan); + EXPECT_EQ(flags.cxx.find("file-prefix-map"), std::string::npos) << flags.cxx; + EXPECT_EQ(flags.cc.find("file-prefix-map"), std::string::npos) << flags.cc; + + const auto ninja = emit_ninja_string(plan); + auto edge = [&](std::string_view object) { + const auto at = ninja.find(std::format("build {} :", object)); + EXPECT_NE(at, std::string::npos) << ninja; + return ninja.substr(at, ninja.find("\nbuild ", at + 1) - at); + }; + const auto coreEdge = edge("obj/core.o"); + EXPECT_NE(coreEdge.find("-ffile-prefix-map=/w/core=core@0.1.0"), std::string::npos) << coreEdge; + EXPECT_NE(coreEdge.find("-ffile-prefix-map=/w/target/x=/mcpp/build"), std::string::npos) << coreEdge; + EXPECT_EQ(coreEdge.find("/w/app"), std::string::npos) << "a consumer's root reached its dependency: " << coreEdge; + const auto appEdge = edge("obj/main.o"); + EXPECT_NE(appEdge.find("-ffile-prefix-map=/w/app=app@0.1.0"), std::string::npos) << appEdge; + EXPECT_NE(appEdge.find("-ffile-prefix-map=/w/core=core@0.1.0"), std::string::npos) << appEdge; +} + +// The map of a package is the fixed roots and its closure's roots, in the +// order of the whole plan's map: shortest prefix first, so that a driver +// trying the last mapping first takes the longest match. +TEST(NinjaBackend, ATrimMapKeepsTheFixedRootsAndTheMarkedPackages) { + mcpp::toolchain::Toolchain tc; + tc.binaryPath = "/opt/tc/bin/g++"; + std::vector packages(3); + packages[0].root = "/w/app"; packages[0].manifest.package.name = "app"; packages[0].manifest.package.version = "1.0.0"; + packages[1].root = "/w/core"; packages[1].manifest.package.name = "core"; packages[1].manifest.package.version = "0.1.0"; + packages[2].root = "/w/gui"; packages[2].manifest.package.name = "gui"; packages[2].manifest.package.version = "0.2.0"; + + const auto all = trim_map(packages, tc, "/w/target/build-dir", {}); + const auto core = trim_map(packages, tc, "/w/target/build-dir", {}, {0, 1, 0}); + auto names = [](auto const& m) { + std::vector out; + for (auto const& [from, to] : m) out.push_back(to); + return out; + }; + const auto coreNames = names(core); + EXPECT_NE(std::ranges::find(coreNames, "core@0.1.0"), coreNames.end()); + EXPECT_NE(std::ranges::find(coreNames, "/mcpp/toolchain"), coreNames.end()); + EXPECT_NE(std::ranges::find(coreNames, "/mcpp/build"), coreNames.end()); + EXPECT_EQ(std::ranges::find(coreNames, "app@1.0.0"), coreNames.end()); + EXPECT_EQ(std::ranges::find(coreNames, "gui@0.2.0"), coreNames.end()); + EXPECT_EQ(names(all).size(), coreNames.size() + 2); + for (std::size_t i = 1; i < core.size(); ++i) + EXPECT_LE(core[i - 1].first.native().size(), core[i].first.native().size()); +} From f51655467f2d7976d35133ae05ef2002c886f396 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 19:11:40 +0900 Subject: [PATCH 04/43] test: the trim map's fixture is absolute on every host; the design record is active Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...system-part2-os-library-over-openkal-design.md | 2 +- tests/unit/test_ninja_backend.cpp | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index 27c75356..13fda172 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -1,6 +1,6 @@ --- subject: ecosystem -status: accepted +status: active --- # 生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录 diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 688bdf44..d63def33 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -3014,15 +3014,18 @@ TEST(NinjaBackend, ATrimMapIsTheUnitsPackagesAndNoOther) { // order of the whole plan's map: shortest prefix first, so that a driver // trying the last mapping first takes the longest match. TEST(NinjaBackend, ATrimMapKeepsTheFixedRootsAndTheMarkedPackages) { + // Absolute on every host: `trim_map` maps absolute prefixes only, and a + // path without a volume is not absolute on Windows. + const auto w = std::filesystem::current_path().root_path() / "w"; mcpp::toolchain::Toolchain tc; - tc.binaryPath = "/opt/tc/bin/g++"; + tc.binaryPath = std::filesystem::current_path().root_path() / "opt" / "tc" / "bin" / "g++"; std::vector packages(3); - packages[0].root = "/w/app"; packages[0].manifest.package.name = "app"; packages[0].manifest.package.version = "1.0.0"; - packages[1].root = "/w/core"; packages[1].manifest.package.name = "core"; packages[1].manifest.package.version = "0.1.0"; - packages[2].root = "/w/gui"; packages[2].manifest.package.name = "gui"; packages[2].manifest.package.version = "0.2.0"; + packages[0].root = w / "app"; packages[0].manifest.package.name = "app"; packages[0].manifest.package.version = "1.0.0"; + packages[1].root = w / "core"; packages[1].manifest.package.name = "core"; packages[1].manifest.package.version = "0.1.0"; + packages[2].root = w / "gui"; packages[2].manifest.package.name = "gui"; packages[2].manifest.package.version = "0.2.0"; - const auto all = trim_map(packages, tc, "/w/target/build-dir", {}); - const auto core = trim_map(packages, tc, "/w/target/build-dir", {}, {0, 1, 0}); + const auto all = trim_map(packages, tc, w / "target" / "build-dir", {}); + const auto core = trim_map(packages, tc, w / "target" / "build-dir", {}, {0, 1, 0}); auto names = [](auto const& m) { std::vector out; for (auto const& [from, to] : m) out.push_back(to); From f310bb288baa1dac31282c9909fb9a0e9dbf0ca0 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 19:30:13 +0900 Subject: [PATCH 05/43] docs(agents): the index names the part 2 design as active Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .agents/docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/docs/README.md b/.agents/docs/README.md index fb911ef5..e6d722b6 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -66,7 +66,7 @@ Records that declare one. Everything else is listed by date below. ### ecosystem -- [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — accepted +- [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — active - [控制契约、并发与性能、单一 C++ 运行时,以及 IDE 生态(生态级,#838、#839)](2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md) — active ### heterogeneous @@ -144,7 +144,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-10 -- [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — accepted +- [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — active - [控制契约、并发与性能、单一 C++ 运行时,以及 IDE 生态(生态级,#838、#839)](2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md) — active - [目标基座与执行宿主:sysroot 的通用模型、运行器契约、自备输入,以及同批的发布产物路径(#832)](2026-10-23-target-base-and-execution-host-design.md) — landed - [依赖 image 的资源归属、模块扫描边界与锁文件稳定写入](2026-10-21-resource-ownership-module-scan-and-lock-write-design.md) — active From 7ec19c00e4723a04f88025760f194c71835a66a9 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 20:10:23 +0900 Subject: [PATCH 06/43] fix: the ninja backend reads a GCC module map's repository as the dyndep step does A staged BMI's edge waits for the modules its map names, and the backend read the map line by line: `$root gcm.cache' became a module named `$root' and every path lost its repository, so the edge waited for `local-dev.gen-dep/gen.dep.table.gcm' beside the build directory (e2e 849). It reads the map with parse_module_map now. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/ninja_backend.cppm | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index de1eff87..1c360686 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2458,11 +2458,10 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // `CompileUnit::moduleScope`: module name -> BMI path, as the plan resolved // it. std::map>, std::less<>> scopeBmis; - for (auto const& [key, scope] : plan.moduleScopes) { - auto& m = scopeBmis[key]; - std::istringstream lines(scope.content); - for (std::string name, path; lines >> name >> path;) m[name] = path; - } + // Read as the dyndep step reads them: a GCC map names its repository + // (`$root'), and its paths are below that (design 2026-10-26 part 2 §10.2). + for (auto const& [key, scope] : plan.moduleScopes) + scopeBmis[key] = mcpp::dyndep::parse_module_map(scope.content); std::unordered_map> byModule; for (auto& cu : plan.compileUnits) if (!cu.providesModule.empty()) byModule[cu.providesModule].push_back(&cu); From 2f291724128d7ce82f752e95cea373c2b259d5db Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 21:04:06 +0900 Subject: [PATCH 07/43] platform: mcpplibs.os is the operating-system layer; no preprocessor conditional; a closed reader ends mcpp in order The platform constants and architecture, the executable's name, PATH lookup, directory links, no-replace renames, volume identities and terminals come from mcpplibs.os. What it does not carry -- the launchers and their signal guard, flock-compatible file locks, durable sync -- is one implementation unit per system, selected by modules/platform/mcpp.toml. openkal ignores SIGPIPE for its own writes, and the ignore survived exec into every program mcpp started; std::print to a closed stdout threw. mcpp installs a SIGPIPE handler first thing: it ends mcpp as an interrupt does, then by the signal (141), and exec resets it for children. e2e 941. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...em-part2-os-library-over-openkal-design.md | 43 +- CHANGELOG.md | 18 + modules/platform/mcpp.toml | 20 + modules/platform/src/capacity.cppm | 187 ----- modules/platform/src/common.cppm | 137 +--- modules/platform/src/env.cppm | 86 +-- modules/platform/src/fs.cppm | 274 +------- modules/platform/src/fs_caps.cppm | 195 +----- modules/platform/src/{linux => }/linux.cppm | 83 ++- .../platform/src/linux/bounded_process.cpp | 15 + modules/platform/src/linux/capacity.cpp | 91 +++ modules/platform/src/linux/env.cpp | 10 + modules/platform/src/linux/fs_caps.cpp | 46 ++ modules/platform/src/{macos => }/macos.cppm | 141 ++-- .../platform/src/macos/bounded_process.cpp | 18 + modules/platform/src/macos/capacity.cpp | 47 ++ modules/platform/src/macos/env.cpp | 10 + modules/platform/src/macos/fs_caps.cpp | 25 + modules/platform/src/platform.cppm | 51 +- .../bounded_process.cpp} | 308 +------- .../platform/src/posix/bounded_process.cppm | 239 +++++++ modules/platform/src/posix/env.cpp | 12 + modules/platform/src/posix/fs.cpp | 69 ++ modules/platform/src/posix/fs_caps.cpp | 21 + modules/platform/src/posix/process.cpp | 359 ++++++++++ modules/platform/src/posix/scaffold_fs.cpp | 71 ++ modules/platform/src/posix/terminal.cpp | 91 +++ modules/platform/src/posix/windows.cpp | 44 ++ modules/platform/src/process.cppm | 659 +++--------------- modules/platform/src/scaffold_fs.cppm | 146 +--- modules/platform/src/shell.cppm | 29 +- modules/platform/src/terminal.cppm | 355 ++-------- modules/platform/src/unix/terminal.cppm | 22 - modules/platform/src/windows.cppm | 51 ++ .../platform/src/windows/bounded_process.cpp | 568 +++++++++++++++ .../platform/src/windows/bounded_process.cppm | 603 +--------------- modules/platform/src/windows/capacity.cpp | 62 ++ modules/platform/src/windows/env.cpp | 14 + modules/platform/src/windows/fs.cpp | 99 +++ modules/platform/src/windows/fs_caps.cpp | 47 ++ modules/platform/src/windows/process.cpp | 132 ++++ modules/platform/src/windows/scaffold_fs.cpp | 50 ++ modules/platform/src/windows/terminal.cpp | 126 ++++ .../src/windows/{windows.cppm => windows.cpp} | 90 +-- src/main.cpp | 5 + ...s_mcpp_and_no_child_inherits_the_ignore.sh | 75 ++ 46 files changed, 2927 insertions(+), 2917 deletions(-) rename modules/platform/src/{linux => }/linux.cppm (62%) create mode 100644 modules/platform/src/linux/bounded_process.cpp create mode 100644 modules/platform/src/linux/capacity.cpp create mode 100644 modules/platform/src/linux/env.cpp create mode 100644 modules/platform/src/linux/fs_caps.cpp rename modules/platform/src/{macos => }/macos.cppm (81%) create mode 100644 modules/platform/src/macos/bounded_process.cpp create mode 100644 modules/platform/src/macos/capacity.cpp create mode 100644 modules/platform/src/macos/env.cpp create mode 100644 modules/platform/src/macos/fs_caps.cpp rename modules/platform/src/{unix/bounded_process.cppm => posix/bounded_process.cpp} (55%) create mode 100644 modules/platform/src/posix/bounded_process.cppm create mode 100644 modules/platform/src/posix/env.cpp create mode 100644 modules/platform/src/posix/fs.cpp create mode 100644 modules/platform/src/posix/fs_caps.cpp create mode 100644 modules/platform/src/posix/process.cpp create mode 100644 modules/platform/src/posix/scaffold_fs.cpp create mode 100644 modules/platform/src/posix/terminal.cpp create mode 100644 modules/platform/src/posix/windows.cpp delete mode 100644 modules/platform/src/unix/terminal.cppm create mode 100644 modules/platform/src/windows.cppm create mode 100644 modules/platform/src/windows/bounded_process.cpp create mode 100644 modules/platform/src/windows/capacity.cpp create mode 100644 modules/platform/src/windows/env.cpp create mode 100644 modules/platform/src/windows/fs.cpp create mode 100644 modules/platform/src/windows/fs_caps.cpp create mode 100644 modules/platform/src/windows/process.cpp create mode 100644 modules/platform/src/windows/scaffold_fs.cpp create mode 100644 modules/platform/src/windows/terminal.cpp rename modules/platform/src/windows/{windows.cppm => windows.cpp} (52%) create mode 100755 tests/e2e/941_a_closed_reader_ends_mcpp_and_no_child_inherits_the_ignore.sh diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index 13fda172..403cc613 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -259,9 +259,13 @@ mcpplibs/os 另提供按链接解析的 `os::fs::resolve`。 - **本进程环境不可修改**(与 openkal 一致);子进程环境由 `spawn` 指定。 - **结束**:`os::interrupt::requested()` / `wait()` 基于 stop word,处理在普通线程;`os::interrupt::end_as_requested()` 基于 G4。 - mcpp 的取消:看到 word → 清理、写 `cancelled` 记录 → 按请求结束;POSIX 上父进程看到信号状态(shell 得到 130/143),Windows - 上为 `STATUS_CONTROL_C_EXIT`(part 1 rev 4)。 -- **EPIPE**:openkal 让写入关闭的管道得到错误而非被结束(E20、K8);消费方的输出层把它视为正常结束(mcpp 的 `| head`)。 + 取消的可观察语义:清理、写 `cancelled` 记录 → 按请求结束;POSIX 上父进程看到信号状态(shell 得到 130/143),Windows 上为 + `STATUS_CONTROL_C_EXIT`(part 1 rev 4)。mcpp 以其既有的守护实现该语义(§10.1);`os::interrupt` 供 mcpp-ide、xlings 等。 +- **SIGPIPE 与 EPIPE**:openkal-linux、openkal-macos 为本程序的写入忽略 SIGPIPE,写入关闭的管道得到错误而非被结束(E20、K8)。 + 忽略的处置跨 `exec` 保留,故 `kal_process_spawn` 启动的程序恢复本程序启动时的处置(K12)。mcpp 在写出任何内容之前为 SIGPIPE + 安装处理器(处理器跨 `exec` 复位为默认,mcpp 以任何方式启动的程序都以默认处置开始):读端关闭时按中断的顺序清理(结束受守护 + 的进程组、写中断文件、恢复终端模式),再以 SIGPIPE 的默认处置结束,shell 看到的状态与此前相同(141)。逐处检查 EPIPE 不可行: + `std::print` 写入失败抛出 `std::system_error`,mcpp 的输出点逾 470 处。 - **错误**:`std::expected`,可移植分类由 `kal_err_*` 映射,保留原生码。 ## 7. openkal 的修改 @@ -280,6 +284,7 @@ mcpplibs/os | K8 | openkal-macos | SIGPIPE 结束程序 | 以已实测的 `sigaction` 结构忽略 SIGPIPE;mcppls 删除 WA-PLATFORM-001 | E20 | | K9 | openkal-windows | UNC 路径与启动后挂载的卷不可达 | 增加 UNC 根的 preopen;名字未命中时重新枚举卷;实现前实测 | O7 | | K10 | openkal-windows | 控制台按代码页解释字节 | 交互式控制台流转为 UTF-16 用 `WriteConsoleW`;实现前实测 | E21 | +| K12 | openkal-linux、openkal-macos | 为本程序忽略的 SIGPIPE 被启动的程序继承:其 shell 中的 `yes \| head` 报写入错误而不结束,shell 无法撤销启动时的忽略 | 记录启动时的处置;`kal_process_spawn` 的子进程在 `exec` 前恢复之;`tests/sigpipe_not_passed_on.cpp`(启动方为默认 / 忽略两种) | B10 | | K11 | openkal-linux | PRoot 下 `execveat` | 实现内已有 ENOSYS 回退;补"已知在翻译器下运行时从第一次起用 `execve`",mcppls 删除其提前设置 | mcppls | ### 7.2 新增原子(S1,各实现随之) @@ -363,6 +368,12 @@ M3 由链接 libokrun、映像段与一个极小的入口(由 `mcpp pack` 生 | `okrun`(包 `openkal-tools`) | bin(mcpp + C++ Modules) | libokrun 之上的薄 `main`;不链接宿主 C 库,进程内只有映像里的一套 C / C++ 运行时 | | `okld`(包 `openkal-tools`) | bin(构建机工具) | `okld check`(约定检查)、`okld note`(推导所需接口写入 note)、`okld sign`(签名)、`okld inspect`;复用 libokld 的格式定义;不替代链接器;非 mcpp 的工具链也能用 | +实现形态:okrun 以借用安排构建,宿主 C / C++ 运行时在进程内,openkal 实现随之链接。okrun 是 C++ 模块程序,standalone 安排要求 +程序自带入口且不用宿主 C 库,mcpp 不为该安排构建 C++ 程序;映像只导入 kal_*,映像内的运行时与宿主运行时不共享符号,"一个进程一份 +实现"(SPEC 10.1)指 openkal 实现,仍成立。上表"不链接宿主 C 库"由此修订。绑定表由 `run/build.mcpp` 在构建时从 openkal 的 +`SURFACE.txt` 生成(`MCPP_DEP_OPENKAL_DIR` → `MCPP_OUT_DIR`),不提交生成物。macOS 上 G3 以 `__pthread_fchdir`(线程级工作 +目录)实现按目录句柄的绑定与连接。 + 发布:okrun 的 macOS 版签名并带可执行内存 entitlement、公证;Windows 版 Authenticode;okrun 构建**不启用 CFG**,或在映射映像时登记 有效调用目标(否则 CFG 拦截进入映像的间接调用)。 @@ -403,8 +414,27 @@ PATHEXT、配置目录、可执行后缀)由 G8 与按资源查询决定;平 ### 10.1 接入 `os` -`modules/platform` 中 A / A\* / N / O 的部分改为 `mcpplibs.os`;领域部分保留;`bounded_process` 由 `os::process` 替代;取消路径按 -§6.4;EPIPE 视为正常结束;`axis` 的架构宏改用 `os`;`modules/platform` 中 `#if` 归零。 +`modules/platform` 的平台差异只以两种形式表达:`os` 编译期常量上的 `if constexpr`(两支在各系统都编译),与接口中声明、在按系统 +的实现单元中定义的函数(`src//*.cpp`,由 `mcpp.toml` 按目标选择;另一系统的函数只能出现在被丢弃的分支中,否则为链接错误)。 +`#if` 为 0。 + +经 `os` 的部分:平台常量与架构(`common`、`axis`);本程序映像的名字(`fs::self_exe_path` → `os::env::image`);PATH 查找 +(`which` → `os::env::find_executable`,PATH 取当前值,因 mcpp 为其启动的程序设置自身环境变量);目录链接、不覆盖的重命名与卷 +标识(`fs_caps`);终端(是否终端、尺寸、控制序列、控制台的 UTF-8 输出)。 + +保留为按系统实现单元的部分与理由: + +- **启动器**(`bounded_process`、`process`)。Windows 上用户编写的 shell 命令须以原样命令行交给 `cmd.exe /d /s /c ""` + (#425);openkal 只传递参数向量并按 MSVC 规则引用,`cmd.exe` 不按该规则解析,任何引用都改变含引号的命令。只把参数向量启动换成 + `os::process` 而保留 shell 启动,会使同一契约(截止时间、空闲时限、进程树、合并输出、中断守护)有两份实现。启动器因此整体保留, + `os::process` 不替代 `bounded_process`;这修订了本节 rev 3 的"`bounded_process` 由 `os::process` 替代"。 +- **文件锁**(`FileLock`)。`os::fs::lock` 在 POSIX 上是打开文件描述的锁(`F_OFD_SETLK`),与 `flock` 互不排斥;此前每个版本都以 + `flock` 持有 home 中的锁文件,两个版本同时工作时将同时持锁。 +- **中断守护**。POSIX 信号处理器(只做异步信号安全的操作)与 Windows 控制台处理器,实现 §6.4 的取消语义。 +- 耐久同步、进程 umask、`linux` / `macos` / `windows` 模块的系统查询。 + +SIGPIPE 按 §6.4。e2e 941:A,`mcpp run` 启动的程序以默认处置开始 SIGPIPE;B,标准输出无读端时 mcpp 以 SIGPIPE 结束,而非异常、 +中止或正常退出。去掉守护的构建上 A 失败(`pipe=ignored`)。 ### 10.2 GCC 模块映射(D11,替换 `9016c0b1`) @@ -519,6 +549,9 @@ os + image ─┬─→ mcpp #842:D11(可在 S1 之前单独完成并跑 CI | B5 | openkal-windows 不支持链接;stop word 声明与语义 | E10、E16 | K3、K4 | | B6 | part 1 实施中修复的问题 | 见 part 1 | 已随 2026.10.26.1 发布 | | B7 | release 下切换工作空间成员时公共依赖反复重编(mcpp#844) | 全局参数携带整个计划的 `trim_paths` 映射 | D13 | +| B8 | openkal-macos:libc 的 `signal()` 之后 stop word 处理器返回时 SIGTRAP | trampoline 未把内核给出的 sigreturn 令牌(x5)传回 `sigreturn`(x2) | trampoline 传回令牌;`tests/stop_word_and_sigpipe.cpp` | +| B9 | e2e 849:ninja 后端读不出带 `$root` 的 GCC 模块映射 | 后端按行朴素解析映射,未按 dyndep 的解析器 | 改用 `parse_module_map` | +| B10 | 链接 openkal 后,mcpp 与 openkal 启动的程序都以 SIGPIPE 忽略开始;mcpp 的 `std::print` 写入无读端的标准输出时抛出异常 | openkal 为自身写入忽略 SIGPIPE,忽略跨 `exec` 保留(E20 未计及继承) | K12;mcpp 的 SIGPIPE 守护(§6.4、§10.1);e2e 941 | ## 15. 生态级多视角自审(rev 3) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c6da40..69f05bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,24 @@ the common packages each time. A unit's map is now the fixed roots and the roots of its own package's dependency closure. +### Changed + +- **The operating-system layer is `mcpplibs.os` over openkal.** The platform + constants and the architecture, the executable's own name, PATH lookup, + directory links, renames that refuse an existing name, volume identities, + terminals and console output come from `mcpplibs.os`; what that library + does not carry (the launchers and their signal guard, file locks shared with + earlier releases, durable sync) is one implementation unit per system, + selected by the package manifest. `modules/platform` contains no + preprocessor conditional. `which` no longer starts a shell: a program is + found on PATH, with PATHEXT's suffixes on Windows, and the current + directory is not searched on Windows. +- **A closed reader ends mcpp in order.** `mcpp … | head` stops the guarded + child process groups, writes the interrupt files and restores the terminal, + and then ends by SIGPIPE, with the status a shell has always seen (141). + Every program mcpp starts begins with SIGPIPE at its default, although + openkal ignores it for its own writes. + ## [2026.10.26.1] - 2026-10-26 ### Added diff --git a/modules/platform/mcpp.toml b/modules/platform/mcpp.toml index efb3ee44..40ed2948 100644 --- a/modules/platform/mcpp.toml +++ b/modules/platform/mcpp.toml @@ -31,3 +31,23 @@ license = "Apache-2.0" # as the engine consumes them, and the pair is the cross-check. [dev-dependencies.compat] gtest = "1.15.2" + +# The operating-system layer is mcpplibs.os over openkal (design 2026-10-26 +# part 2, §10.1). Referenced by its pull request's branch until it is +# published; mcpp.lock then names the version. +[dependencies.mcpplibs] +os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.1.0" } + +# Every interface is compiled everywhere; implementation units are selected +# per system, so no source in this package carries a preprocessor conditional. +[build] +sources = ["src/*.cppm", "src/*/*.cppm"] + +[target.linux.build] +sources = ["src/linux/*.cpp", "src/posix/*.cpp"] + +[target.macos.build] +sources = ["src/macos/*.cpp", "src/posix/*.cpp"] + +[target.windows.build] +sources = ["src/windows/*.cpp"] diff --git a/modules/platform/src/capacity.cppm b/modules/platform/src/capacity.cppm index ba35b224..4d399f61 100644 --- a/modules/platform/src/capacity.cppm +++ b/modules/platform/src/capacity.cppm @@ -20,29 +20,6 @@ // The interface deliberately names no `std` type: under GCC 16.1 a newly added // module whose EXPORTS mention std types can poison the BMIs of everything // downstream of it, and the failures point at unrelated modules. Integers only. -module; - -#if defined(_WIN32) -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#include // malloc / free — clang does not get these from windows.h -#elif defined(__APPLE__) -#include -#include -#include -#include -#else -#include -#include -#include // atoi -#include -#endif - export module mcpp.platform.capacity; export namespace mcpp::platform::capacity { @@ -80,170 +57,6 @@ int recommended_jobs(const HostCapacity& cap, namespace mcpp::platform::capacity { -#if defined(_WIN32) - -// The processor relationship table needs the two-call pattern: its length is not -// knowable up front, so ask for the size, allocate, then ask again. -static bool win_core_facts(int& physical, bool& hybrid) { - DWORD bytes = 0; - ::GetLogicalProcessorInformationEx(RelationProcessorCore, nullptr, &bytes); - if (bytes == 0) return false; - auto* buf = static_cast(::malloc(bytes)); - if (!buf) return false; - bool ok = false; - if (::GetLogicalProcessorInformationEx( - RelationProcessorCore, - reinterpret_cast(buf), &bytes)) { - int count = 0, firstClass = -1; - DWORD off = 0; - while (off < bytes) { - auto* info = reinterpret_cast(buf + off); - if (info->Size == 0) break; - ++count; - const int cls = static_cast(info->Processor.EfficiencyClass); - if (firstClass < 0) firstClass = cls; - else if (cls != firstClass) hybrid = true; - off += info->Size; - } - if (count > 0) { physical = count; ok = true; } - } - ::free(buf); - return ok; -} - -HostCapacity host_capacity() { - HostCapacity cap; - SYSTEM_INFO si{}; - ::GetSystemInfo(&si); - if (si.dwNumberOfProcessors > 0) cap.logicalCores = static_cast(si.dwNumberOfProcessors); - cap.physicalCores = cap.logicalCores; - win_core_facts(cap.physicalCores, cap.heterogeneous); - - MEMORYSTATUSEX ms{}; - ms.dwLength = sizeof(ms); - if (::GlobalMemoryStatusEx(&ms)) { - cap.totalBytes = ms.ullTotalPhys; - cap.availableBytes = ms.ullAvailPhys; - } - return cap; -} - -#elif defined(__APPLE__) - -HostCapacity host_capacity() { - HostCapacity cap; - const long n = ::sysconf(_SC_NPROCESSORS_ONLN); - if (n > 0) cap.logicalCores = static_cast(n); - - int value = 0; - size_t len = sizeof(value); - cap.physicalCores = (::sysctlbyname("hw.physicalcpu", &value, &len, nullptr, 0) == 0 && value > 0) - ? value : cap.logicalCores; - - // Apple Silicon is performance + efficiency by construction; hw.nperflevels - // says so directly and is absent on Intel Macs. - value = 0; len = sizeof(value); - if (::sysctlbyname("hw.nperflevels", &value, &len, nullptr, 0) == 0) - cap.heterogeneous = value > 1; - - unsigned long long mem = 0; len = sizeof(mem); - if (::sysctlbyname("hw.memsize", &mem, &len, nullptr, 0) == 0) cap.totalBytes = mem; - - // Free + inactive is the honest "could be handed to a new process" figure on - // Darwin; wired and active are not available in any useful sense. - vm_statistics64_data_t vm{}; - mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; - if (::host_statistics64(::mach_host_self(), HOST_VM_INFO64, - reinterpret_cast(&vm), &count) == KERN_SUCCESS) { - const unsigned long long page = static_cast(::getpagesize()); - cap.availableBytes = (static_cast(vm.free_count) - + static_cast(vm.inactive_count)) * page; - } - if (cap.availableBytes == 0) cap.availableBytes = cap.totalBytes; - return cap; -} - -#else // Linux and other POSIX - -static bool read_meminfo_kb(const char* key, unsigned long long& out) { - FILE* f = ::fopen("/proc/meminfo", "r"); - if (!f) return false; - char line[256]; - bool found = false; - const size_t klen = ::strlen(key); - while (::fgets(line, sizeof(line), f)) { - if (::strncmp(line, key, klen) != 0) continue; - unsigned long long kb = 0; - if (::sscanf(line + klen, " %llu", &kb) == 1) { out = kb * 1024ull; found = true; } - break; - } - ::fclose(f); - return found; -} - -static int read_cpuinfo_cores() { - FILE* f = ::fopen("/proc/cpuinfo", "r"); - if (!f) return 0; - char line[256]; - int cores = 0; - while (::fgets(line, sizeof(line), f)) { - if (::strncmp(line, "cpu cores", 9) != 0) continue; - const char* colon = ::strchr(line, ':'); - if (colon) cores = ::atoi(colon + 1); - break; - } - ::fclose(f); - return cores; -} - -// Hybrid x86 reports differing per-CPU maximum frequencies. Cheapest reliable -// signal short of CPUID; when cpufreq is absent the answer is "cannot tell", -// which must be reported as NOT heterogeneous — a false positive here would -// halve the job count on an ordinary homogeneous server. -static bool detect_hybrid(int logical) { - if (logical <= 1) return false; - long first = -1; - for (int i = 0; i < logical; ++i) { - char path[128]; - ::snprintf(path, sizeof(path), - "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i); - FILE* f = ::fopen(path, "r"); - if (!f) return false; - long v = 0; - const int got = ::fscanf(f, "%ld", &v); - ::fclose(f); - if (got != 1) return false; - if (first < 0) first = v; - else if (v != first) return true; - } - return false; -} - -HostCapacity host_capacity() { - HostCapacity cap; - const long n = ::sysconf(_SC_NPROCESSORS_ONLN); - if (n > 0) cap.logicalCores = static_cast(n); - - const int cores = read_cpuinfo_cores(); - cap.physicalCores = cores > 0 ? cores : cap.logicalCores; - cap.heterogeneous = detect_hybrid(cap.logicalCores); - - const long pages = ::sysconf(_SC_PHYS_PAGES); - const long psize = ::sysconf(_SC_PAGE_SIZE); - if (pages > 0 && psize > 0) - cap.totalBytes = static_cast(pages) - * static_cast(psize); - - // MemAvailable is the kernel's own estimate of what a new workload can get - // without swapping — strictly better than MemFree, which excludes reclaimable - // page cache and would make every warm machine look starved. - if (!read_meminfo_kb("MemAvailable:", cap.availableBytes)) - cap.availableBytes = cap.totalBytes; - return cap; -} - -#endif - int recommended_jobs(const HostCapacity& cap, unsigned long long perJobBytes, unsigned long long reserveBytes, int ceiling) { // A heterogeneous machine's logical count is not a count of equal workers, diff --git a/modules/platform/src/common.cppm b/modules/platform/src/common.cppm index 2c6f241b..3e247bc8 100644 --- a/modules/platform/src/common.cppm +++ b/modules/platform/src/common.cppm @@ -1,114 +1,49 @@ -// mcpp.platform.common — centralized platform-specific constants. +// mcpp.platform.common — the platform constants mcpp's code dispatches on. // -// Consumers import this module (via mcpp.platform) instead of scattering -// #if/_WIN32 / __APPLE__ blocks throughout the codebase. All compile-time -// branching for platform constants lives here. - -module; +// They are mcpplibs.os's compile-time constants (design 2026-10-26 part 2, +// R3), spelt as mcpp's code has always read them; nothing here is a macro, and +// every branch on them is an `if constexpr' that discards the other systems. export module mcpp.platform.common; import std; +import mcpplibs.os.platform; + +namespace mcpp::platform { +namespace os = mcpplibs::os; +} export namespace mcpp::platform { // ── Binary / library name conventions ───────────────────────────────────── -#if defined(_WIN32) -constexpr std::string_view exe_suffix = ".exe"; -constexpr std::string_view static_lib_ext = ".lib"; -constexpr std::string_view shared_lib_ext = ".dll"; -constexpr std::string_view lib_prefix = ""; -#elif defined(__APPLE__) -constexpr std::string_view exe_suffix = ""; -constexpr std::string_view static_lib_ext = ".a"; -constexpr std::string_view shared_lib_ext = ".dylib"; -constexpr std::string_view lib_prefix = "lib"; -#else -// Linux and other POSIX -constexpr std::string_view exe_suffix = ""; -constexpr std::string_view static_lib_ext = ".a"; -constexpr std::string_view shared_lib_ext = ".so"; -constexpr std::string_view lib_prefix = "lib"; -#endif - -// ── Shell / process helpers ──────────────────────────────────────────────── - -#if defined(_WIN32) -constexpr std::string_view null_redirect = "2>nul"; -#else -constexpr std::string_view null_redirect = "2>/dev/null"; -#endif - -// ── Platform identification ──────────────────────────────────────────────── - -#if defined(_WIN32) -constexpr bool is_windows = true; -constexpr bool is_macos = false; -constexpr bool is_linux = false; -#elif defined(__APPLE__) -constexpr bool is_windows = false; -constexpr bool is_macos = true; -constexpr bool is_linux = false; -#elif defined(__linux__) -constexpr bool is_windows = false; -constexpr bool is_macos = false; -constexpr bool is_linux = true; -#else -constexpr bool is_windows = false; -constexpr bool is_macos = false; -constexpr bool is_linux = false; -#endif - -// ── Platform name string ─────────────────────────────────────────────────── - -constexpr std::string_view name = -#if defined(_WIN32) - "windows"; -#elif defined(__APPLE__) - "macos"; -#elif defined(__linux__) - "linux"; -#else - "unknown"; -#endif - -// Host CPU architecture. Uses the GNU triple spelling (`aarch64`, not the -// `arm64` token xlings uses for its release assets) so it composes directly -// with target triples like `aarch64-linux-musl`. Used to decide native vs. -// cross toolchain selection (target arch == host_arch → native musl-gcc; -// otherwise a `-gcc` cross toolchain). -constexpr std::string_view host_arch = -#if defined(__aarch64__) || defined(_M_ARM64) - "aarch64"; -#elif defined(__x86_64__) || defined(_M_X64) - "x86_64"; -#elif defined(__i386__) || defined(_M_IX86) - "i686"; // the GNU spelling; `x86` is MSVC's, and `triple::parse` writes it `i686` -#elif defined(__riscv) && (__riscv_xlen == 64) - "riscv64"; -#else - "unknown"; -#endif - -// xpkg platform key (used by resolver for xpkg.lua lookups). -// Note: macOS uses "macosx" (not "macos") for xpkg compatibility. -constexpr std::string_view xpkg_platform = -#if defined(_WIN32) - "windows"; -#elif defined(__APPLE__) - "macosx"; -#elif defined(__linux__) - "linux"; -#else - "linux"; -#endif - -// ── Link strategy capabilities ───────────────────────────────────────── -// Used by build/flags.cppm to avoid #ifdef blocks in linker flag logic. - -constexpr bool supports_full_static = is_linux; // macOS/Windows cannot +constexpr bool is_windows = os::is_windows; +constexpr bool is_macos = os::is_macos; +constexpr bool is_linux = os::is_linux; + +constexpr std::string_view exe_suffix = os::exe_suffix; +constexpr std::string_view static_lib_ext = is_windows ? ".lib" : ".a"; +constexpr std::string_view shared_lib_ext = is_windows ? ".dll" : is_macos ? ".dylib" : ".so"; +constexpr std::string_view lib_prefix = is_windows ? "" : "lib"; + +// ── Shell redirect to null device ───────────────────────────────────────── + +constexpr std::string_view null_redirect = is_windows ? "2>nul" : "2>/dev/null"; + +// ── Platform identification ─────────────────────────────────────────────── + +constexpr std::string_view name = os::name(os::PLATFORM); + +// The architecture of THIS build, in the GNU spelling `triple::parse' writes. +constexpr std::string_view host_arch = os::name(os::ARCH); + +// The platform key xpkg descriptors use: `macosx' where the triple says `macos'. +constexpr std::string_view xpkg_platform = is_windows ? "windows" : is_macos ? "macosx" : "linux"; + +// ── Feature flags ───────────────────────────────────────────────────────── + +constexpr bool supports_full_static = is_linux; // macOS/Windows cannot constexpr bool supports_rpath = !is_windows; // ELF + Mach-O only -constexpr bool needs_explicit_libcxx = is_macos; // macOS: -lc++ required +constexpr bool needs_explicit_libcxx = is_macos; // macOS: -lc++ required } // namespace mcpp::platform diff --git a/modules/platform/src/env.cppm b/modules/platform/src/env.cppm index daea2569..42dc23a5 100644 --- a/modules/platform/src/env.cppm +++ b/modules/platform/src/env.cppm @@ -2,19 +2,16 @@ // // Windows: uses _putenv_s to mutate the calling process environment. // POSIX: supports scoped current-process env mutation for child processes. - -module; -#include -#if defined(_WIN32) -#include // _putenv_s -#else -#include // setenv / unsetenv -extern char** environ; -#endif +// +// The two operations that differ between systems --- the process's own +// variable table, and writing to it --- are declared below and defined once +// per system (src//env.cpp); everything else is one text, dispatching +// on mcpp.platform.common's constants. export module mcpp.platform.env; import std; +import mcpp.platform.common; export namespace mcpp::platform::env { @@ -146,8 +143,19 @@ std::string prepend_path_list(std::string_view key, std::string build_env_prefix( const std::vector>& vars); +// The process's own variable table as the system holds it: `NAME=value' +// entries, null-terminated, current with every `set' and `unset'. For a +// launcher that passes it on (src//env.cpp). +char** environment(); + } // namespace mcpp::platform::env +// The per-system primitive (src//env.cpp). Not exported. +namespace mcpp::platform::env::detail { +// Sets `key' to `value', or removes it when `value' is null. +void put(const std::string& key, const char* value); +} // namespace mcpp::platform::env::detail + // ─── Implementation ────────────────────────────────────────────────────── namespace mcpp::platform::env { @@ -238,12 +246,7 @@ std::optional get(std::string_view key) { std::vector with_prefix(std::string_view prefix) { std::vector out; -#if defined(_WIN32) - char** env = _environ; -#else - char** env = environ; -#endif - for (; env && *env; ++env) { + for (char** env = environment(); env && *env; ++env) { std::string_view e(*env); if (!e.starts_with(prefix)) continue; auto eq = e.find('='); @@ -255,19 +258,11 @@ std::vector with_prefix(std::string_view prefix) { } void set(const std::string& key, const std::string& value) { -#if defined(_WIN32) - _putenv_s(key.c_str(), value.c_str()); -#else - setenv(key.c_str(), value.c_str(), 1); -#endif + detail::put(key, value.c_str()); } void unset(const std::string& key) { -#if defined(_WIN32) - _putenv_s(key.c_str(), ""); -#else - unsetenv(key.c_str()); -#endif + detail::put(key, nullptr); } ScopedEnv::ScopedEnv(std::string key, std::optional value) @@ -293,36 +288,23 @@ ScopedEnv::~ScopedEnv() { } std::string path_list_separator() { -#if defined(_WIN32) - return ";"; -#else - return ":"; -#endif + return is_windows ? ";" : ":"; } std::string runtime_library_path_key() { -#if defined(_WIN32) - return "PATH"; -#elif defined(__APPLE__) + if constexpr (is_windows) return "PATH"; // DYLD_LIBRARY_PATH affects every executable launched by ninja, including // ninja itself, and can make macOS system frameworks load an incompatible // private libc++/libc++abi. Keep macOS on toolchain-provided rpaths. - return ""; -#elif defined(__linux__) - return "LD_LIBRARY_PATH"; -#else - return ""; -#endif + else if constexpr (is_macos) return ""; + else if constexpr (is_linux) return "LD_LIBRARY_PATH"; + else return ""; } std::string host_tool_runtime_library_path_key() { -#if defined(__APPLE__) - return "DYLD_LIBRARY_PATH"; -#elif defined(__linux__) - return "LD_LIBRARY_PATH"; -#else - return ""; -#endif + if constexpr (is_macos) return "DYLD_LIBRARY_PATH"; + else if constexpr (is_linux) return "LD_LIBRARY_PATH"; + else return ""; } std::string strip_private_glibc(std::string_view paths) { @@ -379,11 +361,12 @@ std::string prepend_path_list(std::string_view key, std::string build_env_prefix( const std::vector>& vars) { -#if defined(_WIN32) - for (auto& [k, v] : vars) - _putenv_s(k.c_str(), v.c_str()); - return ""; -#else + // Windows: the variables are set in this process, which every child + // started afterwards inherits; there is no prefix a command line carries. + if constexpr (is_windows) { + for (auto& [k, v] : vars) set(k, v); + return ""; + } std::string prefix; for (auto& [k, v] : vars) { prefix += k; @@ -397,7 +380,6 @@ std::string build_env_prefix( prefix += ' '; } return prefix; -#endif } } // namespace mcpp::platform::env diff --git a/modules/platform/src/fs.cppm b/modules/platform/src/fs.cppm index ed9dbd2e..c6dd1f14 100644 --- a/modules/platform/src/fs.cppm +++ b/modules/platform/src/fs.cppm @@ -6,52 +6,30 @@ // 2. which() — find an executable in PATH (was in config.cppm & // probe.cppm) // 3. FileLock — RAII exclusive file lock (was in bmi_cache.cppm) - -module; -#include -#include -#if defined(_WIN32) -#include -#include -#define popen _popen -#define pclose _pclose -#elif defined(__APPLE__) -#include // _NSGetExecutablePath -#else -#include -#include -#include -#endif - -// POSIX file locking (shared between macOS and Linux) -#if !defined(_WIN32) -#include -#include -#include -#endif +// +// The first two are mcpplibs.os's; replace_file, append_atomically and the +// lock are each system's own (posix/fs.cpp, windows/fs.cpp). export module mcpp.platform.fs; import std; import mcpp.platform.common; +import mcpplibs.os.env; export namespace mcpp::platform::fs { // ── self_exe_path ───────────────────────────────────────────────────────── // -// Returns the absolute path of the currently running executable. -// Windows: GetModuleFileNameA -// macOS: _NSGetExecutablePath -// Linux: /proc/self/exe +// Returns the absolute path of the currently running executable, as the +// system states it (mcpplibs::os::env::image). std::filesystem::path self_exe_path(); // ── which ───────────────────────────────────────────────────────────────── // -// Find an executable by name in PATH. -// Windows: `where ` -// POSIX: `command -v ` -// A name that the shell answers with a bare word -- a builtin such as `true` -- -// is then looked for in PATH directly, so the program is found where one exists. +// Find an executable by name in PATH, with PATHEXT's suffixes on Windows +// (mcpplibs::os::env::find_executable). PATH is read as it is now: mcpp sets +// variables of its own environment for the programs it starts. A name that is +// also a shell builtin (`true`) is the program of that name on PATH. std::optional which(std::string_view binary_name); // ── extended_length ─────────────────────────────────────────────────────── @@ -94,6 +72,10 @@ bool append_atomically(const std::filesystem::path& file, std::string_view bytes // RAII exclusive non-blocking file lock. // Windows: LockFileEx on a HANDLE // POSIX: flock(2) on a file descriptor +// Not mcpplibs::os::fs::lock, whose POSIX lock is an open file description's +// (F_OFD_SETLK): an flock and such a lock do not exclude each other, and every +// release before this one holds these files with flock -- two releases at work +// in one MCPP_HOME would each hold the lock at once. class FileLock { public: // Try to acquire an exclusive lock on /.lock (or /). @@ -131,13 +113,9 @@ public: FileLock& operator=(const FileLock&) = delete; private: -#if defined(_WIN32) - void* handle_ = reinterpret_cast(static_cast(-1)); // INVALID_HANDLE_VALUE - explicit FileLock(void* h) : handle_(h) {} -#else - int fd_ = -1; - explicit FileLock(int fd) : fd_(fd) {} -#endif + // A descriptor, or a HANDLE; -1 is neither (INVALID_HANDLE_VALUE). + std::intptr_t native_ = -1; + explicit FileLock(std::intptr_t native) : native_(native) {} }; } // namespace mcpp::platform::fs @@ -147,84 +125,18 @@ private: namespace mcpp::platform::fs { std::filesystem::path self_exe_path() { - std::error_code ec; -#if defined(_WIN32) - char buf[MAX_PATH]; - DWORD len = GetModuleFileNameA(NULL, buf, MAX_PATH); - if (len > 0 && len < MAX_PATH) { - auto p = std::filesystem::canonical(buf, ec); - if (!ec) return p; - } -#elif defined(__APPLE__) - char buf[4096]; - uint32_t size = sizeof(buf); - if (_NSGetExecutablePath(buf, &size) == 0) { - auto p = std::filesystem::canonical(buf, ec); - if (!ec) return p; + if (auto image = mcpplibs::os::env::image()) { + std::error_code ec; + auto p = std::filesystem::canonical(*image, ec); + return ec ? *image : p; } -#else - auto p = std::filesystem::read_symlink("/proc/self/exe", ec); - if (!ec) return p; -#endif // Fallback: hope the binary is in PATH return "mcpp"; } std::optional which(std::string_view binary_name) { - std::string name(binary_name); -#if defined(_WIN32) - std::string cmd = "where " + name + " 2>nul"; -#else - std::string cmd = "command -v '" + name + "' 2>/dev/null buf{}; - std::string out; - std::FILE* fp = ::popen(cmd.c_str(), "r"); - if (!fp) return std::nullopt; - while (std::fgets(buf.data(), static_cast(buf.size()), fp) != nullptr) - out += buf.data(); - int rc = ::pclose(fp); - - // Trim and take first line - while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) - out.pop_back(); - auto nl = out.find('\n'); - if (nl != std::string::npos) out.resize(nl); - - // A NAME THAT IS ALSO A SHELL BUILTIN comes back as the bare word: `command - // -v true` prints `true`, not `/usr/bin/true`, because the shell answers - // with what it would run. The answer is still a name on PATH -- the program - // exists -- so PATH is walked here rather than the name reported as missing. - // Found while verifying a bare-name payload override (mcpp#755): the - // refusal said `'true' is not found on PATH` on a machine carrying - // /usr/bin/true, which is the kind of answer that sends a reader to the - // wrong place. - if (rc == 0 && !out.empty() && !std::filesystem::exists(out) - && std::filesystem::path(out).filename() == out) { - if (const char* path = std::getenv("PATH")) { -#if defined(_WIN32) - constexpr char sep = ';'; -#else - constexpr char sep = ':'; -#endif - std::string_view rest(path); - while (!rest.empty()) { - const auto at = rest.find(sep); - const auto dir = rest.substr(0, at); - if (!dir.empty()) { - std::error_code ec; - const auto cand = std::filesystem::path(dir) / out; - if (std::filesystem::is_regular_file(cand, ec)) return cand; - } - if (at == std::string_view::npos) break; - rest.remove_prefix(at + 1); - } - } - } - - if (rc != 0 || out.empty()) return std::nullopt; - if (!std::filesystem::exists(out)) return std::nullopt; - return std::filesystem::path(out); + const char* path = std::getenv("PATH"); + return mcpplibs::os::env::find_executable(binary_name, path ? path : ""); } std::string windows_extended_length_spelling(std::string_view in) { @@ -275,7 +187,7 @@ std::string windows_extended_length_spelling(std::string_view in) { } std::filesystem::path extended_length(const std::filesystem::path& p) { -#if defined(_WIN32) + if constexpr (!is_windows) return p; if (p.empty()) return p; std::error_code ec; auto abs = std::filesystem::absolute(p, ec); @@ -287,58 +199,6 @@ std::filesystem::path extended_length(const std::filesystem::path& p) { const auto spelled = windows_extended_length_spelling(narrow); return std::filesystem::path(std::u8string( reinterpret_cast(spelled.data()), spelled.size())); -#else - return p; -#endif -} - -bool replace_file(const std::filesystem::path& source, - const std::filesystem::path& destination, - std::error_code& ec) { -#if defined(_WIN32) - // 直接替换已有文件,不能先删除 last-known-good CDB。编辑器或杀毒软件 - // 可能短暂占用目标文件,sharing violation 仅做有限退避后再报告失败。 - auto delay = std::chrono::milliseconds{50}; - for (int attempt = 0; attempt < 4; ++attempt) { - if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), - MOVEFILE_REPLACE_EXISTING)) { - ec.clear(); - return true; - } - const auto error = GetLastError(); - if (error != ERROR_SHARING_VIOLATION || attempt == 3) { - ec = std::error_code(static_cast(error), std::system_category()); - return false; - } - std::this_thread::sleep_for(delay); - delay *= 3; - } - return false; -#else - // 临时文件与目标文件位于同一文件系统时,rename 提供原子替换。 - std::filesystem::rename(source, destination, ec); - return !ec; -#endif -} - -bool append_atomically(const std::filesystem::path& file, std::string_view bytes) { -#if defined(_WIN32) - HANDLE h = CreateFileW(file.wstring().c_str(), FILE_APPEND_DATA, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); - if (h == INVALID_HANDLE_VALUE) return false; - DWORD written = 0; - const bool ok = WriteFile(h, bytes.data(), static_cast(bytes.size()), - &written, nullptr) && written == bytes.size(); - CloseHandle(h); - return ok; -#else - const int fd = ::open(file.c_str(), O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644); - if (fd < 0) return false; - const auto n = ::write(fd, bytes.data(), bytes.size()); - ::close(fd); - return n == static_cast(bytes.size()); -#endif } // ── FileLock ────────────────────────────────────────────────────────────── @@ -384,90 +244,4 @@ std::string FileLock::holder(const std::filesystem::path& dir, std::string_view return in ? std::string(std::istreambuf_iterator(in), {}) : std::string{}; } -#if defined(_WIN32) - -std::optional FileLock::try_acquire(const std::filesystem::path& dir, - std::string_view name) { - std::error_code ec; - std::filesystem::create_directories(dir, ec); - auto lockPath = dir / std::string(name); - HANDLE h = CreateFileW(lockPath.wstring().c_str(), - GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, - NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); - if (h == INVALID_HANDLE_VALUE) return std::nullopt; - OVERLAPPED ov = {}; - if (!LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, - 0, 1, 0, &ov)) { - CloseHandle(h); - return std::nullopt; - } - return FileLock{h}; -} - -FileLock::~FileLock() { - if (handle_ != INVALID_HANDLE_VALUE) { - OVERLAPPED ov = {}; - UnlockFileEx(static_cast(handle_), 0, 1, 0, &ov); - CloseHandle(static_cast(handle_)); - } -} - -FileLock::FileLock(FileLock&& o) noexcept : handle_(o.handle_) { - o.handle_ = INVALID_HANDLE_VALUE; -} - -FileLock& FileLock::operator=(FileLock&& o) noexcept { - if (this != &o) { - if (handle_ != INVALID_HANDLE_VALUE) { - OVERLAPPED ov = {}; - UnlockFileEx(static_cast(handle_), 0, 1, 0, &ov); - CloseHandle(static_cast(handle_)); - } - handle_ = o.handle_; - o.handle_ = INVALID_HANDLE_VALUE; - } - return *this; -} - -#else // POSIX - -std::optional FileLock::try_acquire(const std::filesystem::path& dir, - std::string_view name) { - std::error_code ec; - std::filesystem::create_directories(dir, ec); - auto lockPath = dir / std::string(name); - int fd = ::open(lockPath.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644); - if (fd < 0) return std::nullopt; - if (::flock(fd, LOCK_EX | LOCK_NB) != 0) { - ::close(fd); - return std::nullopt; - } - return FileLock{fd}; -} - -FileLock::~FileLock() { - if (fd_ >= 0) { - ::flock(fd_, LOCK_UN); - ::close(fd_); - } -} - -FileLock::FileLock(FileLock&& o) noexcept : fd_(o.fd_) { - o.fd_ = -1; -} - -FileLock& FileLock::operator=(FileLock&& o) noexcept { - if (this != &o) { - if (fd_ >= 0) { - ::flock(fd_, LOCK_UN); - ::close(fd_); - } - fd_ = o.fd_; - o.fd_ = -1; - } - return *this; -} - -#endif - } // namespace mcpp::platform::fs diff --git a/modules/platform/src/fs_caps.cppm b/modules/platform/src/fs_caps.cppm index 82953dc6..f515c5bf 100644 --- a/modules/platform/src/fs_caps.cppm +++ b/modules/platform/src/fs_caps.cppm @@ -7,39 +7,10 @@ // the flag. Each primitive below asks this table, once per filesystem, and // takes the strategy that filesystem allows; none guesses from an errno. -module; - -#if defined(_WIN32) -# ifndef NOMINMAX -# define NOMINMAX -# endif -# include -# include -# include -// GetNamedSecurityInfoW and the token calls live in advapi32. -# pragma comment(lib, "advapi32") -#elif defined(__linux__) -# include -# include -# include -# include -# include -# include -#elif defined(__APPLE__) -# include -# include -# include -# include -# include -# include -#else -# include -# include -#endif - export module mcpp.platform.fs_caps; import std; +import mcpplibs.os.fs; export namespace mcpp::platform { @@ -101,135 +72,20 @@ bool create_directory_link(const std::filesystem::path& target, const std::files namespace mcpp::platform::native { -#if defined(_WIN32) -// MoveFileExW without MOVEFILE_REPLACE_EXISTING refuses an existing name on -// every Windows filesystem (NTFS, ReFS, FAT, SMB). -int rename_no_replace(const std::filesystem::path& from, const std::filesystem::path& to) { - return ::MoveFileExW(from.wstring().c_str(), to.wstring().c_str(), MOVEFILE_WRITE_THROUGH) - ? 0 : static_cast(::GetLastError()); -} -// Windows has no atomic exchange of two names. -int exchange(const std::filesystem::path&, const std::filesystem::path&) { return ERROR_NOT_SUPPORTED; } -bool unsupported(int error) { return error == ERROR_NOT_SUPPORTED || error == ERROR_INVALID_FUNCTION; } -std::error_category const& category() { return std::system_category(); } -std::uint64_t device_of(const std::filesystem::path& dir) { - wchar_t root[MAX_PATH] {}; - DWORD serial = 0; - if (!::GetVolumePathNameW(dir.wstring().c_str(), root, MAX_PATH)) return 0; - if (!::GetVolumeInformationW(root, nullptr, 0, &serial, nullptr, nullptr, nullptr, 0)) return 0; - return serial; -} -std::string owner_problem(const std::filesystem::path& dir) { - PSID owner = nullptr; - PSECURITY_DESCRIPTOR sd = nullptr; - if (::GetNamedSecurityInfoW(dir.wstring().c_str(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, - &owner, nullptr, nullptr, nullptr, &sd) != ERROR_SUCCESS) - return "its owner cannot be read"; - bool trusted = ::IsWellKnownSid(owner, WinLocalSystemSid) || ::IsWellKnownSid(owner, WinBuiltinAdministratorsSid); - HANDLE token = nullptr; - if (!trusted && ::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &token)) { - DWORD size = 0; - ::GetTokenInformation(token, TokenUser, nullptr, 0, &size); - std::vector buffer(size); - if (::GetTokenInformation(token, TokenUser, buffer.data(), size, &size)) - trusted = ::EqualSid(owner, reinterpret_cast(buffer.data())->User.Sid); - ::CloseHandle(token); - } - ::LocalFree(sd); - return trusted ? std::string{} : "it belongs to another user"; -} -std::string filesystem_type(const std::filesystem::path& dir) { - wchar_t root[MAX_PATH] {}, name[MAX_PATH] {}; - if (!::GetVolumePathNameW(dir.wstring().c_str(), root, MAX_PATH)) return {}; - if (!::GetVolumeInformationW(root, nullptr, 0, nullptr, nullptr, nullptr, name, MAX_PATH)) return {}; - return std::filesystem::path(name).string(); -} -#elif defined(__linux__) -int rename_no_replace(const std::filesystem::path& from, const std::filesystem::path& to) { - constexpr unsigned kNoReplace = 1; // renameat2 RENAME_NOREPLACE - return ::syscall(SYS_renameat2, AT_FDCWD, from.c_str(), AT_FDCWD, to.c_str(), kNoReplace) == 0 ? 0 : errno; -} -int exchange(const std::filesystem::path& a, const std::filesystem::path& b) { - constexpr unsigned kExchange = 2; // renameat2 RENAME_EXCHANGE - return ::syscall(SYS_renameat2, AT_FDCWD, a.c_str(), AT_FDCWD, b.c_str(), kExchange) == 0 ? 0 : errno; -} -bool unsupported(int error) { return error == EINVAL || error == ENOSYS || error == EOPNOTSUPP; } -std::error_category const& category() { return std::generic_category(); } -std::string owner_problem(const std::filesystem::path& dir) { - struct stat st {}; - if (::stat(dir.c_str(), &st) != 0) return "it cannot be read"; - if (st.st_uid == ::getuid()) return {}; - if (st.st_uid != 0) return "it belongs to another user"; - if (::access(dir.c_str(), W_OK) == 0) return "it belongs to root and this user can write it"; - return {}; -} +// Each system's own, in linux/, macos/ and windows/ fs_caps.cpp: what +// mcpplibs.os does not carry, because no other system has it. -std::uint64_t device_of(const std::filesystem::path& dir) { - struct stat st {}; - return ::stat(dir.c_str(), &st) == 0 ? static_cast(st.st_dev) : 0; -} -std::string filesystem_type(const std::filesystem::path& dir) { - struct statfs st {}; - if (::statfs(dir.c_str(), &st) != 0) return {}; - switch (static_cast(st.f_type)) { - case 0xEF53: return "ext4"; - case 0x58465342: return "xfs"; - case 0x9123683E: return "btrfs"; - case 0x01021994: return "tmpfs"; - case 0x794C7630: return "overlay"; - case 0x6969: return "nfs"; - case 0xFF534D42: return "cifs"; - case 0xFE534D42: return "smb2"; - case 0x4D44: return "vfat"; - case 0x2011BAB0: return "exfat"; - case 0x7366746E: return "ntfs3"; - case 0x5346544E: return "ntfs"; - case 0x65735546: return "fuse"; - case 0x01021997: return "9p"; - case 0x2FC12FC1: return "zfs"; - case 0x00C36400: return "ceph"; - default: return std::format("0x{:x}", static_cast(st.f_type)); - } -} -#elif defined(__APPLE__) -int rename_no_replace(const std::filesystem::path& from, const std::filesystem::path& to) { - return ::renamex_np(from.c_str(), to.c_str(), RENAME_EXCL) == 0 ? 0 : errno; -} -int exchange(const std::filesystem::path& a, const std::filesystem::path& b) { - return ::renamex_np(a.c_str(), b.c_str(), RENAME_SWAP) == 0 ? 0 : errno; -} -bool unsupported(int error) { return error == EINVAL || error == ENOTSUP || error == EOPNOTSUPP || error == ENOSYS; } -std::error_category const& category() { return std::generic_category(); } -std::string owner_problem(const std::filesystem::path& dir) { - struct stat st {}; - if (::stat(dir.c_str(), &st) != 0) return "it cannot be read"; - if (st.st_uid == ::getuid()) return {}; - if (st.st_uid != 0) return "it belongs to another user"; - if (::access(dir.c_str(), W_OK) == 0) return "it belongs to root and this user can write it"; - return {}; -} +// `a` and `b` trade places, atomically: 0, else the system's code, which +// `unsupported` says is the filesystem declining. +int exchange(const std::filesystem::path& a, const std::filesystem::path& b); +bool unsupported(int error); +std::error_category const& category(); -std::uint64_t device_of(const std::filesystem::path& dir) { - struct stat st {}; - return ::stat(dir.c_str(), &st) == 0 ? static_cast(st.st_dev) : 0; -} -std::string filesystem_type(const std::filesystem::path& dir) { - struct statfs st {}; - return ::statfs(dir.c_str(), &st) == 0 ? std::string(st.f_fstypename) : std::string{}; -} -#else -// No native primitive: the strategies below that need none. -std::string owner_problem(const std::filesystem::path&) { return {}; } -int rename_no_replace(const std::filesystem::path&, const std::filesystem::path&) { return ENOSYS; } -int exchange(const std::filesystem::path&, const std::filesystem::path&) { return ENOSYS; } -bool unsupported(int error) { return error == ENOSYS || error == EINVAL; } -std::error_category const& category() { return std::generic_category(); } -std::uint64_t device_of(const std::filesystem::path& dir) { - struct stat st {}; - return ::stat(dir.c_str(), &st) == 0 ? static_cast(st.st_dev) : 0; -} -std::string filesystem_type(const std::filesystem::path&) { return {}; } -#endif +// Empty when `dir`'s owner is this user or the system; else why not. +std::string owner_problem(const std::filesystem::path& dir); + +// The filesystem's name, as the system reports it ("" unknown). +std::string filesystem_type(const std::filesystem::path& dir); } // namespace mcpp::platform::native @@ -250,8 +106,8 @@ FsCaps probe_fs_caps(const stdfs::path& dir) { const auto touch = [&](const stdfs::path& p) { std::ofstream(p).put('x'); return stdfs::exists(p, ec); }; if (touch(scratch / "a") && touch(scratch / "c")) { caps.measured = true; - if (const int e = native::rename_no_replace(scratch / "a", scratch / "b"); e != 0) { - caps.rename_no_replace = !native::unsupported(e); + if (const auto r = mcpplibs::os::fs::rename(scratch / "a", scratch / "b", false); !r) { + caps.rename_no_replace = r.error().code != mcpplibs::os::errc::not_supported; stdfs::rename(scratch / "a", scratch / "b", ec); } if (const int e = native::exchange(scratch / "b", scratch / "c"); e != 0) @@ -272,7 +128,8 @@ FsCaps probe_fs_caps(const stdfs::path& dir) { FsCaps fs_caps(const stdfs::path& dir) { static std::mutex lock; static std::map known; - const auto device = native::device_of(dir); + const auto identity = mcpplibs::os::fs::identity_of(dir); + const std::uint64_t device = identity ? identity->volume : 0; { std::scoped_lock guard(lock); if (device != 0) @@ -293,9 +150,11 @@ std::expected publish_no_replace(const stdfs::path& from, con return std::unexpected(std::format("cannot publish {} as {}: {}", from.string(), to.string(), error.message())); }; if (caps.rename_no_replace) { - const int e = native::rename_no_replace(from, to); - if (e == 0) return {}; - if (!native::unsupported(e)) return failed(std::error_code(e, native::category())); + const auto r = mcpplibs::os::fs::rename(from, to, false); + if (r) return {}; + if (r.error().code != mcpplibs::os::errc::not_supported) + return std::unexpected(std::format("cannot publish {} as {}: {}", from.string(), to.string(), + mcpplibs::os::describe(r.error().code))); // Measured elsewhere on this filesystem, refused here (a mount under // the directory probed): the strategies below. } @@ -332,15 +191,7 @@ std::expected rename_no_replace(const stdfs::path& from, cons } bool create_directory_link(const stdfs::path& target, const stdfs::path& link) { - std::error_code ec; - stdfs::create_directory_symlink(target, link, ec); - if (!ec) return true; -#if defined(_WIN32) - const auto cmd = std::format("cmd /c mklink /J \"{}\" \"{}\" >nul 2>&1", link.string(), target.string()); - return std::system(cmd.c_str()) == 0; -#else - return false; -#endif + return mcpplibs::os::fs::create_link(target, link, true).has_value(); } std::string untrusted_because(const stdfs::path& dir) { diff --git a/modules/platform/src/linux/linux.cppm b/modules/platform/src/linux.cppm similarity index 62% rename from modules/platform/src/linux/linux.cppm rename to modules/platform/src/linux.cppm index c660be2d..57f997e0 100644 --- a/modules/platform/src/linux/linux.cppm +++ b/modules/platform/src/linux.cppm @@ -4,11 +4,10 @@ // build_ld_library_path() — construct LD_LIBRARY_PATH prefix // runtime_lib_dirs() — Linux toolchain runtime library search paths -module; - export module mcpp.platform.linux; import std; +import mcpp.platform.common; import mcpp.platform.shell; export namespace mcpp::platform::linux_ { @@ -57,59 +56,59 @@ std::string join_dirs(const std::vector& dirs) { std::string build_ld_library_path_prefix( const std::vector& dirs) { -#if defined(__linux__) - if (dirs.empty()) return ""; - auto joined = join_dirs(dirs); - return std::format("env LD_LIBRARY_PATH={}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}} ", - mcpp::platform::shell::quote(joined)); -#else - (void)dirs; - return ""; -#endif + if constexpr (is_linux) { + if (dirs.empty()) return ""; + auto joined = join_dirs(dirs); + return std::format("env LD_LIBRARY_PATH={}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}} ", + mcpp::platform::shell::quote(joined)); + } else { + (void)dirs; + return ""; + } } std::string build_clean_ld_library_path_prefix( const std::vector& dirs) { -#if defined(__linux__) - if (dirs.empty()) return ""; - auto joined = join_dirs(dirs); - return std::format("env LD_LIBRARY_PATH={} ", - mcpp::platform::shell::quote(joined)); -#else - (void)dirs; - return ""; -#endif + if constexpr (is_linux) { + if (dirs.empty()) return ""; + auto joined = join_dirs(dirs); + return std::format("env LD_LIBRARY_PATH={} ", + mcpp::platform::shell::quote(joined)); + } else { + (void)dirs; + return ""; + } } std::vector runtime_lib_dirs(const std::filesystem::path& toolchain_root) { std::vector dirs; -#if defined(__linux__) - auto add = [&](const std::filesystem::path& p) { - if (std::filesystem::exists(p)) - dirs.push_back(p); - }; - add(toolchain_root / "lib" / "x86_64-unknown-linux-gnu"); - add(toolchain_root / "lib" / "aarch64-unknown-linux-gnu"); - add(toolchain_root / "lib" / "aarch64-linux-gnu"); -#else - (void)toolchain_root; -#endif + if constexpr (is_linux) { + auto add = [&](const std::filesystem::path& p) { + if (std::filesystem::exists(p)) + dirs.push_back(p); + }; + add(toolchain_root / "lib" / "x86_64-unknown-linux-gnu"); + add(toolchain_root / "lib" / "aarch64-unknown-linux-gnu"); + add(toolchain_root / "lib" / "aarch64-linux-gnu"); + } else { + (void)toolchain_root; + } return dirs; } bool is_android_host() { -#if defined(__linux__) - static const bool android = [] { - if (std::getenv("ANDROID_ROOT") && std::getenv("ANDROID_DATA")) return true; - std::error_code ec; - return std::filesystem::exists("/system/bin/linker64", ec) - || std::filesystem::exists("/system/bin/linker", ec); - }(); - return android; -#else - return false; -#endif + if constexpr (is_linux) { + static const bool android = [] { + if (std::getenv("ANDROID_ROOT") && std::getenv("ANDROID_DATA")) return true; + std::error_code ec; + return std::filesystem::exists("/system/bin/linker64", ec) + || std::filesystem::exists("/system/bin/linker", ec); + }(); + return android; + } else { + return false; + } } } // namespace mcpp::platform::linux_ diff --git a/modules/platform/src/linux/bounded_process.cpp b/modules/platform/src/linux/bounded_process.cpp new file mode 100644 index 00000000..0541c6cd --- /dev/null +++ b/modules/platform/src/linux/bounded_process.cpp @@ -0,0 +1,15 @@ +// mcpp.platform.unix.bounded_process on Linux: both ends of a pipe are +// close-on-exec from the call that makes them. +module; +#include +#include + +module mcpp.platform.unix.bounded_process; + +namespace mcpp::platform::unixproc { + +int make_pipe(int fds[2]) { + return ::pipe2(fds, O_CLOEXEC); +} + +} // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/linux/capacity.cpp b/modules/platform/src/linux/capacity.cpp new file mode 100644 index 00000000..260ecae2 --- /dev/null +++ b/modules/platform/src/linux/capacity.cpp @@ -0,0 +1,91 @@ +// mcpp.platform.capacity on linux: what the machine has, from the system's own services. +module; +#include +#include // atoi +#include +#include + +module mcpp.platform.capacity; + +import std; + +namespace mcpp::platform::capacity { + +static bool read_meminfo_kb(const char* key, unsigned long long& out) { + FILE* f = ::fopen("/proc/meminfo", "r"); + if (!f) return false; + char line[256]; + bool found = false; + const size_t klen = ::strlen(key); + while (::fgets(line, sizeof(line), f)) { + if (::strncmp(line, key, klen) != 0) continue; + unsigned long long kb = 0; + if (::sscanf(line + klen, " %llu", &kb) == 1) { out = kb * 1024ull; found = true; } + break; + } + ::fclose(f); + return found; +} + +static int read_cpuinfo_cores() { + FILE* f = ::fopen("/proc/cpuinfo", "r"); + if (!f) return 0; + char line[256]; + int cores = 0; + while (::fgets(line, sizeof(line), f)) { + if (::strncmp(line, "cpu cores", 9) != 0) continue; + const char* colon = ::strchr(line, ':'); + if (colon) cores = ::atoi(colon + 1); + break; + } + ::fclose(f); + return cores; +} + +// Hybrid x86 reports differing per-CPU maximum frequencies. Cheapest reliable +// signal short of CPUID; when cpufreq is absent the answer is "cannot tell", +// which must be reported as NOT heterogeneous — a false positive here would +// halve the job count on an ordinary homogeneous server. +static bool detect_hybrid(int logical) { + if (logical <= 1) return false; + long first = -1; + for (int i = 0; i < logical; ++i) { + char path[128]; + ::snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i); + FILE* f = ::fopen(path, "r"); + if (!f) return false; + long v = 0; + const int got = ::fscanf(f, "%ld", &v); + ::fclose(f); + if (got != 1) return false; + if (first < 0) first = v; + else if (v != first) return true; + } + return false; +} + +HostCapacity host_capacity() { + HostCapacity cap; + const long n = ::sysconf(_SC_NPROCESSORS_ONLN); + if (n > 0) cap.logicalCores = static_cast(n); + + const int cores = read_cpuinfo_cores(); + cap.physicalCores = cores > 0 ? cores : cap.logicalCores; + cap.heterogeneous = detect_hybrid(cap.logicalCores); + + const long pages = ::sysconf(_SC_PHYS_PAGES); + const long psize = ::sysconf(_SC_PAGE_SIZE); + if (pages > 0 && psize > 0) + cap.totalBytes = static_cast(pages) + * static_cast(psize); + + // MemAvailable is the kernel's own estimate of what a new workload can get + // without swapping — strictly better than MemFree, which excludes reclaimable + // page cache and would make every warm machine look starved. + if (!read_meminfo_kb("MemAvailable:", cap.availableBytes)) + cap.availableBytes = cap.totalBytes; + return cap; +} + +} // namespace mcpp::platform::capacity diff --git a/modules/platform/src/linux/env.cpp b/modules/platform/src/linux/env.cpp new file mode 100644 index 00000000..a14e7049 --- /dev/null +++ b/modules/platform/src/linux/env.cpp @@ -0,0 +1,10 @@ +// mcpp.platform.env on Linux: the process's own variable table. +module mcpp.platform.env; + +import std; + +// POSIX names it and no header must declare it; C linkage attaches it to the +// global module. +extern "C" char** environ; + +char** mcpp::platform::env::environment() { return environ; } diff --git a/modules/platform/src/linux/fs_caps.cpp b/modules/platform/src/linux/fs_caps.cpp new file mode 100644 index 00000000..98c2f7ee --- /dev/null +++ b/modules/platform/src/linux/fs_caps.cpp @@ -0,0 +1,46 @@ +// mcpp.platform.fs_caps on Linux: renameat2's exchange and statfs's magic. +module; +#include +#include +#include +#include +#include + +module mcpp.platform.fs_caps; + +import std; + +namespace mcpp::platform::native { + +int exchange(const std::filesystem::path& a, const std::filesystem::path& b) { + constexpr unsigned kExchange = 2; // renameat2 RENAME_EXCHANGE + return ::syscall(SYS_renameat2, AT_FDCWD, a.c_str(), AT_FDCWD, b.c_str(), kExchange) == 0 ? 0 : errno; +} +bool unsupported(int error) { return error == EINVAL || error == ENOSYS || error == EOPNOTSUPP; } +std::error_category const& category() { return std::generic_category(); } + +std::string filesystem_type(const std::filesystem::path& dir) { + struct statfs st {}; + if (::statfs(dir.c_str(), &st) != 0) return {}; + switch (static_cast(st.f_type)) { + case 0xEF53: return "ext4"; + case 0x58465342: return "xfs"; + case 0x9123683E: return "btrfs"; + case 0x01021994: return "tmpfs"; + case 0x794C7630: return "overlay"; + case 0x6969: return "nfs"; + case 0xFF534D42: return "cifs"; + case 0xFE534D42: return "smb2"; + case 0x4D44: return "vfat"; + case 0x2011BAB0: return "exfat"; + case 0x7366746E: return "ntfs3"; + case 0x5346544E: return "ntfs"; + case 0x65735546: return "fuse"; + case 0x01021997: return "9p"; + case 0x2FC12FC1: return "zfs"; + case 0x00C36400: return "ceph"; + default: return std::format("0x{:x}", static_cast(st.f_type)); + } +} + +} // namespace mcpp::platform::native diff --git a/modules/platform/src/macos/macos.cppm b/modules/platform/src/macos.cppm similarity index 81% rename from modules/platform/src/macos/macos.cppm rename to modules/platform/src/macos.cppm index 96e1ffee..756690ce 100644 --- a/modules/platform/src/macos/macos.cppm +++ b/modules/platform/src/macos.cppm @@ -8,17 +8,11 @@ // runtime_lib_dirs() — macOS-specific library search paths // supports_full_static — macOS cannot fully static-link (libSystem) -module; -#include -#include -#if defined(_WIN32) -#define popen _popen -#define pclose _pclose -#endif - export module mcpp.platform.macos; import std; +import mcpp.platform.common; +import mcpp.platform.process; // the queries of Apple's own tools export namespace mcpp::platform::macos { @@ -156,33 +150,22 @@ namespace mcpp::platform::macos { namespace { std::string run_capture_trimmed(const std::string& cmd) { - std::array buf{}; - std::string out; -#if defined(__APPLE__) - std::string full = cmd + " (buf.size()), fp) != nullptr) - out += buf.data(); - ::pclose(fp); + // stdin sealed and the shell's own output kept, as `process::capture' does + // for every command it runs. + std::string out = mcpp::platform::process::capture(cmd).output; while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' ')) out.pop_back(); -#else - (void)cmd; - (void)buf; -#endif return out; } } // namespace bool has_xcode_clt() { -#if defined(__APPLE__) - int rc = std::system("xcode-select -p /dev/null 2>&1"); - return rc == 0; -#else - return false; -#endif + if constexpr (is_macos) { + return mcpp::platform::process::run_silent("xcode-select -p >/dev/null 2>&1") == 0; + } else { + return false; + } } std::optional sdk_layout(std::string_view sdk) { @@ -231,14 +214,14 @@ bool sdkroot_answers(const std::filesystem::path& candidate, } std::optional sdk_version(std::string_view sdk) { -#if defined(__APPLE__) - auto v = run_capture_trimmed(std::format("xcrun --sdk {} --show-sdk-version 2>/dev/null", sdk)); - if (v.empty()) return std::nullopt; - return v; -#else - (void)sdk; - return std::nullopt; -#endif + if constexpr (is_macos) { + auto v = run_capture_trimmed(std::format("xcrun --sdk {} --show-sdk-version 2>/dev/null", sdk)); + if (v.empty()) return std::nullopt; + return v; + } else { + (void)sdk; + return std::nullopt; + } } std::optional sdk_path(std::string_view sdk) { @@ -255,42 +238,42 @@ std::optional sdk_path(std::string_view sdk) { std::filesystem::path p(env); if (std::filesystem::exists(p) && sdkroot_answers(p, sdk)) return p; } -#if defined(__APPLE__) - // 2. xcrun — the canonical query. The named form is the only one that can - // answer for a specific SDK; the generic form returns the ACTIVE - // default, so it is tried only for the default request, where it is - // today's first probe and stays first. - std::vector cmds; - if (sdk == sdk_macos) cmds.emplace_back("xcrun --show-sdk-path 2>/dev/null"); - cmds.push_back(std::format("xcrun --sdk {} --show-sdk-path 2>/dev/null", sdk)); - for (auto const& cmd : cmds) { - auto result = run_capture_trimmed(cmd); - if (!result.empty() && std::filesystem::exists(result)) - return std::filesystem::path(result); - } - // 3. Derive from the active developer dir (`xcode-select -p`) — covers - // machines where xcrun is misconfigured but the SDK is present. - auto devdir = run_capture_trimmed("xcode-select -p 2>/dev/null"); - if (!devdir.empty()) { - std::filesystem::path base(devdir); - for (auto cand : { - base / "Platforms" / layout->platformDir / "Developer" / "SDKs" - / layout->sdkDir, - base / "SDKs" / layout->sdkDir }) { - if (std::filesystem::exists(cand)) return cand; + if constexpr (is_macos) { + // 2. xcrun — the canonical query. The named form is the only one that can + // answer for a specific SDK; the generic form returns the ACTIVE + // default, so it is tried only for the default request, where it is + // today's first probe and stays first. + std::vector cmds; + if (sdk == sdk_macos) cmds.emplace_back("xcrun --show-sdk-path 2>/dev/null"); + cmds.push_back(std::format("xcrun --sdk {} --show-sdk-path 2>/dev/null", sdk)); + for (auto const& cmd : cmds) { + auto result = run_capture_trimmed(cmd); + if (!result.empty() && std::filesystem::exists(result)) + return std::filesystem::path(result); } - } - // 4. Well-known fixed locations (Command-Line-Tools-only / standard Xcode). - std::vector fixed; - if (layout->inCommandLineTools) + // 3. Derive from the active developer dir (`xcode-select -p`) — covers + // machines where xcrun is misconfigured but the SDK is present. + auto devdir = run_capture_trimmed("xcode-select -p 2>/dev/null"); + if (!devdir.empty()) { + std::filesystem::path base(devdir); + for (auto cand : { + base / "Platforms" / layout->platformDir / "Developer" / "SDKs" + / layout->sdkDir, + base / "SDKs" / layout->sdkDir }) { + if (std::filesystem::exists(cand)) return cand; + } + } + // 4. Well-known fixed locations (Command-Line-Tools-only / standard Xcode). + std::vector fixed; + if (layout->inCommandLineTools) + fixed.emplace_back(std::filesystem::path( + "/Library/Developer/CommandLineTools/SDKs") / layout->sdkDir); fixed.emplace_back(std::filesystem::path( - "/Library/Developer/CommandLineTools/SDKs") / layout->sdkDir); - fixed.emplace_back(std::filesystem::path( - "/Applications/Xcode.app/Contents/Developer/Platforms") - / layout->platformDir / "Developer" / "SDKs" / layout->sdkDir); - for (auto const& cand : fixed) - if (std::filesystem::exists(cand)) return cand; -#endif + "/Applications/Xcode.app/Contents/Developer/Platforms") + / layout->platformDir / "Developer" / "SDKs" / layout->sdkDir); + for (auto const& cand : fixed) + if (std::filesystem::exists(cand)) return cand; + } return std::nullopt; } @@ -336,16 +319,16 @@ std::optional sdk_has_target(const std::filesystem::path& sdkRoot, std::vector runtime_lib_dirs(const std::filesystem::path& toolchain_root) { std::vector dirs; -#if defined(__APPLE__) - auto add = [&](const std::filesystem::path& p) { - if (std::filesystem::exists(p)) - dirs.push_back(p); - }; - add(toolchain_root / "lib" / "aarch64-apple-darwin"); - add(toolchain_root / "lib" / "darwin"); -#else - (void)toolchain_root; -#endif + if constexpr (is_macos) { + auto add = [&](const std::filesystem::path& p) { + if (std::filesystem::exists(p)) + dirs.push_back(p); + }; + add(toolchain_root / "lib" / "aarch64-apple-darwin"); + add(toolchain_root / "lib" / "darwin"); + } else { + (void)toolchain_root; + } return dirs; } diff --git a/modules/platform/src/macos/bounded_process.cpp b/modules/platform/src/macos/bounded_process.cpp new file mode 100644 index 00000000..2eb6d179 --- /dev/null +++ b/modules/platform/src/macos/bounded_process.cpp @@ -0,0 +1,18 @@ +// mcpp.platform.unix.bounded_process on macOS: no `pipe2', so the flag is set +// by a second call, inside the LaunchSection every start holds. +module; +#include +#include + +module mcpp.platform.unix.bounded_process; + +namespace mcpp::platform::unixproc { + +int make_pipe(int fds[2]) { + if (::pipe(fds) != 0) return -1; + ::fcntl(fds[0], F_SETFD, FD_CLOEXEC); + ::fcntl(fds[1], F_SETFD, FD_CLOEXEC); + return 0; +} + +} // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/macos/capacity.cpp b/modules/platform/src/macos/capacity.cpp new file mode 100644 index 00000000..4476bd6d --- /dev/null +++ b/modules/platform/src/macos/capacity.cpp @@ -0,0 +1,47 @@ +// mcpp.platform.capacity on macos: what the machine has, from the system's own services. +module; +#include +#include +#include +#include + +module mcpp.platform.capacity; + +import std; + +namespace mcpp::platform::capacity { + +HostCapacity host_capacity() { + HostCapacity cap; + const long n = ::sysconf(_SC_NPROCESSORS_ONLN); + if (n > 0) cap.logicalCores = static_cast(n); + + int value = 0; + size_t len = sizeof(value); + cap.physicalCores = (::sysctlbyname("hw.physicalcpu", &value, &len, nullptr, 0) == 0 && value > 0) + ? value : cap.logicalCores; + + // Apple Silicon is performance + efficiency by construction; hw.nperflevels + // says so directly and is absent on Intel Macs. + value = 0; len = sizeof(value); + if (::sysctlbyname("hw.nperflevels", &value, &len, nullptr, 0) == 0) + cap.heterogeneous = value > 1; + + unsigned long long mem = 0; len = sizeof(mem); + if (::sysctlbyname("hw.memsize", &mem, &len, nullptr, 0) == 0) cap.totalBytes = mem; + + // Free + inactive is the honest "could be handed to a new process" figure on + // Darwin; wired and active are not available in any useful sense. + vm_statistics64_data_t vm{}; + mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; + if (::host_statistics64(::mach_host_self(), HOST_VM_INFO64, + reinterpret_cast(&vm), &count) == KERN_SUCCESS) { + const unsigned long long page = static_cast(::getpagesize()); + cap.availableBytes = (static_cast(vm.free_count) + + static_cast(vm.inactive_count)) * page; + } + if (cap.availableBytes == 0) cap.availableBytes = cap.totalBytes; + return cap; +} + +} // namespace mcpp::platform::capacity diff --git a/modules/platform/src/macos/env.cpp b/modules/platform/src/macos/env.cpp new file mode 100644 index 00000000..5118a86d --- /dev/null +++ b/modules/platform/src/macos/env.cpp @@ -0,0 +1,10 @@ +// mcpp.platform.env on macOS: the process's own variable table, by the call +// that answers for a dynamic library as well as for the program. +module; +#include + +module mcpp.platform.env; + +import std; + +char** mcpp::platform::env::environment() { return *::_NSGetEnviron(); } diff --git a/modules/platform/src/macos/fs_caps.cpp b/modules/platform/src/macos/fs_caps.cpp new file mode 100644 index 00000000..acc0a74d --- /dev/null +++ b/modules/platform/src/macos/fs_caps.cpp @@ -0,0 +1,25 @@ +// mcpp.platform.fs_caps on macOS: renamex_np's swap and statfs's type name. +module; +#include +#include +#include +#include + +module mcpp.platform.fs_caps; + +import std; + +namespace mcpp::platform::native { + +int exchange(const std::filesystem::path& a, const std::filesystem::path& b) { + return ::renamex_np(a.c_str(), b.c_str(), RENAME_SWAP) == 0 ? 0 : errno; +} +bool unsupported(int error) { return error == EINVAL || error == ENOTSUP || error == EOPNOTSUPP || error == ENOSYS; } +std::error_category const& category() { return std::generic_category(); } + +std::string filesystem_type(const std::filesystem::path& dir) { + struct statfs st {}; + return ::statfs(dir.c_str(), &st) == 0 ? std::string(st.f_fstypename) : std::string{}; +} + +} // namespace mcpp::platform::native diff --git a/modules/platform/src/platform.cppm b/modules/platform/src/platform.cppm index 7be8fc19..80de3302 100644 --- a/modules/platform/src/platform.cppm +++ b/modules/platform/src/platform.cppm @@ -7,41 +7,42 @@ // // ─── LAYOUT ──────────────────────────────────────────────────────────────── // -// src/platform/*.cppm the FACADE and the portable modules. A module -// here answers a question the same way everywhere, -// or dispatches once (see "dispatch" below). -// src/platform/unix/ POSIX-only implementations. -// src/platform/windows/ Win32-only implementations. -// src/platform/linux/ Linux-only. -// src/platform/macos/ macOS-only. +// src/*.cppm, src//*.cppm module interfaces, compiled on every +// system: the same declarations everywhere. +// src/linux/*.cpp implementation units compiled for Linux, +// src/macos/*.cpp for macOS, +// src/posix/*.cpp for both (mcpp.toml selects them per target), +// src/windows/*.cpp and for Windows. // -// A directory does not change a module's NAME — `src/platform/linux/linux.cppm` -// still declares `mcpp.platform.linux`, so moving a file here costs no importer -// a single line. The directory is what tells a reader, before opening anything, -// whether a file can contain platform-specific code at all. +// The operating system itself is reached through mcpplibs.os (design +// 2026-10-26 part 2, §10.1): its compile-time constants +// (mcpp.platform.common), the executable's own name, PATH lookup, links, +// renames that refuse an existing name, volume identities, terminals and +// standard streams. What remains in implementation units is what that +// library does not carry: the launchers and their signal guard, file locks +// compatible with every earlier mcpp, durable sync, and the system-specific +// queries of the linux, macos and windows modules. // // ─── HOW PLATFORM DIFFERENCES ARE EXPRESSED ──────────────────────────────── // -// Preferred, in order: +// No preprocessor conditional appears in this package. A difference is one +// of: // -// 1. `if constexpr (mcpp::platform::is_windows)` in the facade layer, calling -// into one implementation module per platform. Both branches then have to +// 1. `if constexpr (mcpp::platform::is_windows)` on mcpplibs.os's constants, +// where both branches compile everywhere. Both branches then have to // COMPILE everywhere, which is what keeps the unused branch from rotting — // the Windows deadline was dead code for months precisely because nothing // on a Linux CI ever compiled it. // -// `mcpp.platform.process`'s bounded-run dispatch is the reference example: -// two implementation modules behind one signature, one dispatch, and the -// only `#if` left is inside each implementation, guarding its own headers. +// 2. A function declared in an interface and defined in one implementation +// unit per system, when the difference is a header or a system call. A +// discarded `if constexpr` branch may name another system's function; +// a call that is not discarded is a link error, never a silent stub. +// `mcpp.platform.process`'s bounded-run dispatch is the reference +// example: two launcher modules behind one signature, one dispatch. // -// 2. A per-platform module whose non-matching build is a no-op stub -// (`mcpp.platform.macos`, `.linux`, `.windows`). Consumers call them -// unconditionally, with no `#ifdef` at the call site. -// -// 3. `#if` inside ONE module, when the difference is a header or a syscall -// rather than a behaviour. Still the common case for the older portable -// modules (`fs`, `env`, `scaffold_fs`); migrating those is incremental -// work, not a precondition for adding new code the right way. +// 3. A module whose non-matching system answers "not here" (`.macos`, +// `.linux`, `.windows`): consumers call it unconditionally. // // A module under a platform directory MUST NOT name a `std` type in its // EXPORTED interface if a widely-imported module will import it: under GCC diff --git a/modules/platform/src/unix/bounded_process.cppm b/modules/platform/src/posix/bounded_process.cpp similarity index 55% rename from modules/platform/src/unix/bounded_process.cppm rename to modules/platform/src/posix/bounded_process.cpp index 1177015e..a6dd88bf 100644 --- a/modules/platform/src/unix/bounded_process.cppm +++ b/modules/platform/src/posix/bounded_process.cpp @@ -1,34 +1,5 @@ -// mcpp.platform.unix.bounded_process — a child process with a deadline on -// POSIX. The peer of mcpp.platform.windows.bounded_process. -// -// WHY THE TWO SIDES ARE SEPARATE MODULES WITH ONE SHARED SIGNATURE -// -// They share no code — this is posix_spawn + a pipe + waitpid + SIGKILL, the -// other is CreateProcess + a Job object + WaitForSingleObject. What they share -// is a CONTRACT: "run this, capture its output, kill it at the deadline, and -// tell me whether you killed it". Putting that contract in one signature and -// each implementation behind its own module is what lets -// `mcpp.platform.process` dispatch once, with `if constexpr`, instead of -// carrying the platform question through twenty-five separate `#if` blocks. -// -// THE INTERFACE NAMES NO `std` TYPE — SAME HARD CONSTRAINT AS THE WINDOWS -// SIDE. See mcpp.platform.windows.bounded_process for the measurements: a new -// module imported by mcpp.platform.process whose EXPORTS mention std types -// corrupts every BMI downstream of it under GCC 16.1. `std` inside the module -// is fine; `std` in what it exports is not. -// -// The POSIX side does not strictly need the constraint (it is reached through -// the same dispatch, so it would be one more module in the same position — and -// the failure is silent enough that "probably fine" is not worth finding out). -// Keeping both sides identical also means the dispatcher marshals once, not -// twice. - +// mcpp.platform.unix.bounded_process on Linux and macOS. module; - -#if defined(__linux__) || defined(__APPLE__) -#ifndef _GNU_SOURCE -#define _GNU_SOURCE // posix_spawn_file_actions_addchdir_np (glibc) -#endif #include // pipe, close, read #include // waitpid #include // posix_spawnp, posix_spawn_file_actions_* @@ -37,233 +8,20 @@ module; #include // fcntl O_NONBLOCK #include // nanosleep #include // fputs, stderr — the out-of-slots diagnostic -#if defined(__linux__) || defined(__APPLE__) #include // tcgetattr, tcsetattr — the terminal mode a signal restores -#endif -#if defined(__APPLE__) -#include // _NSGetEnviron -#endif -#endif -export module mcpp.platform.unix.bounded_process; +module mcpp.platform.unix.bounded_process; import std; - -#if defined(__linux__) -// Declared in the module purview, not the global module fragment: an entity -// with C language linkage is never module-attached, so this names the same -// symbol the loader provides — and GCC rejects non-`#include` content in a -// global module fragment (-Wglobal-module). -extern "C" char **environ; -#endif - -export namespace mcpp::platform::unixproc { - -// Every member is a builtin type. Same reason as the Windows peer. -struct DeadlineRun { - // False on every non-POSIX build, and on POSIX when the child could not be - // spawned. "Could not spawn" and "ran and failed" must not share an exit - // code, so the caller falls back rather than reporting a failure. - bool supported = false; - int exit_code = 0; - bool timed_out = false; - // Non-zero when `supported` is false BECAUSE the spawn was attempted and - // refused: the errno posix_spawnp returned. Zero with `supported` false - // means this build has no bounded launcher at all. The two need opposite - // treatment one layer up — the first is reported with its errno, the - // second falls back to the unbounded launcher — and before this field - // existed both read the same, so the caller spawned a second time and - // discarded the errno the first attempt had in hand (#544). - int spawn_error = 0; -}; - -using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); -using StopPoll = bool (*)(void* ctx); - -// ─── Starting a child is safe for concurrent callers ───────────────────── -// -// Two threads that start children at the same time must not pass one child's -// pipe to the other. A child inherits every descriptor of its parent that is not -// close-on-exec, so a child started by thread B while thread A holds the write -// end of A's capture pipe keeps that end open for as long as it lives. A's -// reader then waits for end of file until B's unrelated child exits: a compile -// that finished at once is reported only when a longer one does, and a deadline -// the reader is meant to enforce is enforced late. -// -// The pipes are therefore created close-on-exec, and `posix_spawn`'s `dup2` -// action, which clears the flag on the descriptor it creates, is the only way a -// descriptor reaches the child. Where the platform has `pipe2` (Linux) that is -// one atomic call. Where it does not (macOS) the flag is set by a second call, -// which leaves a window in which another thread's child could be started; every -// start on such a platform therefore holds one process-wide section, from the -// creation of the pipe to the parent's close of its write end. On Linux the -// section is empty. -// -// `make_pipe` returns 0 on success and -1 on failure, as `pipe` does. Both ends -// are close-on-exec. The caller holds a `LaunchSection` across this call and the -// start of the child. -int make_pipe(int fds[2]); - -class LaunchSection { -public: - LaunchSection(); - ~LaunchSection(); - // Leaves the section before the destructor does: a caller that goes on to - // wait for its child must not hold it for the child's whole life. - void release(); - LaunchSection(const LaunchSection&) = delete; - LaunchSection& operator=(const LaunchSection&) = delete; -private: - bool held_ = false; -}; - -// `argvEntries` is `argvCount` NUL-terminated strings; `envEntries` is -// `envCount` "KEY=VALUE" strings applied on top of the current environment. -// `cwd` may be null. A non-positive `deadlineMs` is rejected with -// supported=false: "no bound" belongs on the caller's untimed path. -// -// A NULL `sink` means "do not capture": the child INHERITS the caller's stdio -// and is still bounded. That is not an optimization — it is the -// `run_exec_deadline` contract. Routing an uncaptured run through a pipe would -// (a) delay every line until the child exits, which is the opposite of what a -// bounded `mcpp test` run is for, and (b) make the child's stdout a pipe -// rather than a terminal, so gtest and friends silently drop their colors. -// -// `idleMs`, when positive, is a second bound: the child is killed once it has -// written nothing for that long. It is meaningful only with a sink, and it is -// what lets a long, progressing child (a download that prints progress) run to -// completion while a wedged one does not (#648). With `idleMs` positive a -// non-positive `deadlineMs` means "no total bound", and so it does with a sink -// and neither bound: a build's ninja, read line by line for its progress. -// -// `ownGroup` non-zero places the child in a process group of its own, kills -// the GROUP at a bound, and registers the group with the signal guard for the -// length of the call, so terminating mcpp takes the child's descendants with -// it. It is opt-in because a child in a background group that reads the -// terminal is stopped by SIGTTIN, and the uncaptured callers of this function -// hand the terminal to their child. -DeadlineRun capture_with_deadline(const char* const* argvEntries, - unsigned long argvCount, - const char* const* envEntries, - unsigned long envCount, - const char* cwd, - long long deadlineMs, - long long idleMs, - int ownGroup, - OutputSink sink, - void* ctx, - StopPoll stop); - -// ─── A child that outlives the call that started it (#496) ─────────────── -// -// `capture_with_deadline` owns its child for the length of one call. A project -// `[hooks] during_build` command is owned for the length of the BUILD, so the -// caller needs a handle it can poll and stop later. Every member is a builtin, -// same constraint as DeadlineRun. -// -// `group`, NOT a pid. The child is placed in a process group of its own -// (posix_spawnattr_setpgroup) and stopped with killpg, because the thing being -// started is a user-authored SHELL command: `sh -c 'player & wait'` makes the -// writer a grandchild, and `kill(pid)` reaches only the shell. A background -// player that survives its build, from a process the user cannot name, is the -// worst outcome this API has — so the group is the unit throughout. -struct BackgroundChild { - bool ok = false; - long long group = 0; // the child's process-group id (== its pid) -}; - -// `inheritStdio == 0` sends the child's output to /dev/null. A spanning hook -// writes CONCURRENTLY with ninja and would otherwise interleave into the middle -// of a compiler diagnostic. -BackgroundChild spawn_background(const char* const* argvEntries, - unsigned long argvCount, - const char* cwd, - int inheritStdio); - -// 1 = still running, 0 = exited, -1 = unknown. When it returns 0, `exitCode` -// (if given) receives the shell convention: the status, or 128+signal. -// -// The code is part of the answer rather than a second call, because the only -// caller that needs it — the `loop` supervisor — has to distinguish "the -// player finished the track" from "the command does not exist". Restarting the -// first forever is the feature; restarting the second forever is a spin. -int background_running(long long group, int* exitCode); - -// SIGTERM, `graceMs`, then SIGKILL — to the GROUP. Reaps the direct child. -void background_stop(long long group, long long graceMs); - -// ─── Ctrl-C ────────────────────────────────────────────────────────────── -// -// Its own process group is what makes killpg possible AND what stops the -// terminal's SIGINT from reaching the child: Ctrl-C would kill mcpp and leave -// the player running. Both halves are required, so the group that must not -// outlive us is registered here for the duration. -// -// The handler does the minimum that is async-signal-safe: killpg (which is), -// then the default action. -// A REGISTRY AND NOT ONE SLOT, BECAUSE THERE IS MORE THAN ONE OWNER. -// -// A spanning `[hooks]` command is guarded for the length of the build, and the -// build's own ninja is guarded for the length of its run — concurrently. With a -// single slot the second registration overwrites the first, so killing mcpp -// mid-build would take down ninja and leave the hook's process running, which -// is the exact outcome the guard exists to prevent. -// -// Fixed capacity and no allocation: the handler reads this array and may not -// allocate. Registering past capacity fails loudly rather than silently -// dropping a group, because a dropped group is an orphan nobody will find. -// -// SAFE FOR CONCURRENT CALLERS. Children are started from several threads at once -// (a workspace's build programs are compiled concurrently), so claiming and -// releasing a slot is serialized by a mutex, and two threads can no longer claim -// the same slot. The handler takes no lock: it reads slots that are written one -// `sig_atomic_t` at a time. The capacity is 256, well above any job count, since -// one group is held per running child. -void guard_group_on_signal(long long group); -void unguard_group(long long group); -void clear_group_guard(); -// Whether no process group is guarded. A caller about to replace mcpp with -// another program (`mcpp run`) requires it: a guarded group is a child that -// would lose its owner. -bool group_guard_idle(); - -// THE TERMINAL MODE A SIGNAL RESTORES (build output design revision 3, -// §5.14). `--play-game` reads keys from the terminal without echo. A Ctrl-C, -// a termination or a hangup that ends mcpp must leave the terminal in the -// mode it found, and the handler installed while ninja runs is the one above, -// so the saved mode is restored there, with tcsetattr, which is -// async-signal-safe. `fd`'s current mode is saved; the caller changes the mode -// after this returns. A process killed outright cannot restore anything. -void guard_terminal_mode(int fd); -// Restores the saved mode and stops guarding it. -void unguard_terminal_mode(); - -// THE FILES A SIGNAL LEAVES (design 2026-10-26 §5.4). A build interrupted while -// it writes its directory says so in its result record: the interrupted form is -// rendered beforehand, and the handler -- after it has stopped the guarded -// groups -- writes each `contents[i]` to `temps[i]` and renames it over -// `paths[i]` (open, write, close and rename are async-signal-safe). While files -// are set the handler is installed. Setting replaces the previous set; at most -// four files. -void set_interrupt_files(const std::vector& paths, - const std::vector& temps, - const std::vector& contents); -void clear_interrupt_files(); - -} // namespace mcpp::platform::unixproc +import mcpp.platform.common; +import mcpp.platform.env; namespace mcpp::platform::unixproc { -#if defined(__linux__) || defined(__APPLE__) - namespace { char** current_environ() { -#if defined(__APPLE__) - return *::_NSGetEnviron(); -#else - return environ; -#endif + return mcpp::platform::env::environment(); } int normalize_status(int status) { @@ -475,7 +233,8 @@ void write_interrupt_files() { // mode, or files to leave. bool handler_needed(); -// The platforms without `pipe2` share one section across every child start. +// The platforms without `pipe2` (macOS) share one section across every child +// start. std::mutex g_launchMutex; // The terminal whose mode the handler restores (-1: none), and that mode. @@ -509,22 +268,17 @@ extern "C" void background_signal_handler(int sig) { } // namespace -int make_pipe(int fds[2]) { -#if defined(__linux__) - return ::pipe2(fds, O_CLOEXEC); -#else - if (::pipe(fds) != 0) return -1; - ::fcntl(fds[0], F_SETFD, FD_CLOEXEC); - ::fcntl(fds[1], F_SETFD, FD_CLOEXEC); - return 0; -#endif +void guard_broken_pipe() { + ::signal(SIGPIPE, background_signal_handler); } +// make_pipe: linux/bounded_process.cpp (pipe2) and macos/bounded_process.cpp. + LaunchSection::LaunchSection() { -#if !defined(__linux__) - g_launchMutex.lock(); - held_ = true; -#endif + if constexpr (!mcpp::platform::is_linux) { + g_launchMutex.lock(); + held_ = true; + } } LaunchSection::~LaunchSection() { release(); } @@ -730,36 +484,4 @@ bool handler_needed() { } } // namespace -#else - -DeadlineRun capture_with_deadline(const char* const*, unsigned long, - const char* const*, unsigned long, - const char*, long long, long long, int, - OutputSink, void*, StopPoll) { - // Not POSIX: mcpp.platform.windows.bounded_process owns this. - return {}; -} - -BackgroundChild spawn_background(const char* const*, unsigned long, - const char*, int) { - return {}; -} -int background_running(long long, int*) { return -1; } -void background_stop(long long, long long) {} -int make_pipe(int[2]) { return -1; } -LaunchSection::LaunchSection() {} -LaunchSection::~LaunchSection() {} -void LaunchSection::release() {} -void guard_group_on_signal(long long) {} -void unguard_group(long long) {} -void clear_group_guard() {} -bool group_guard_idle() { return true; } -void guard_terminal_mode(int) {} -void unguard_terminal_mode() {} -void set_interrupt_files(const std::vector&, const std::vector&, - const std::vector&) {} -void clear_interrupt_files() {} - -#endif - } // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/posix/bounded_process.cppm b/modules/platform/src/posix/bounded_process.cppm new file mode 100644 index 00000000..a6df96b5 --- /dev/null +++ b/modules/platform/src/posix/bounded_process.cppm @@ -0,0 +1,239 @@ +// mcpp.platform.unix.bounded_process — a child process with a deadline on +// POSIX. The peer of mcpp.platform.windows.bounded_process. +// +// WHY THE TWO SIDES ARE SEPARATE MODULES WITH ONE SHARED SIGNATURE +// +// They share no code — this is posix_spawn + a pipe + waitpid + SIGKILL, the +// other is CreateProcess + a Job object + WaitForSingleObject. What they share +// is a CONTRACT: "run this, capture its output, kill it at the deadline, and +// tell me whether you killed it". Putting that contract in one signature and +// each implementation behind its own module is what lets +// `mcpp.platform.process` dispatch once, with `if constexpr`, instead of +// carrying the platform question through twenty-five separate `#if` blocks. +// +// THE INTERFACE NAMES NO `std` TYPE — SAME HARD CONSTRAINT AS THE WINDOWS +// SIDE. See mcpp.platform.windows.bounded_process for the measurements: a new +// module imported by mcpp.platform.process whose EXPORTS mention std types +// corrupts every BMI downstream of it under GCC 16.1. `std` inside the module +// is fine; `std` in what it exports is not. +// +// The POSIX side does not strictly need the constraint (it is reached through +// the same dispatch, so it would be one more module in the same position — and +// the failure is silent enough that "probably fine" is not worth finding out). +// Keeping both sides identical also means the dispatcher marshals once, not +// twice. +// +// The declarations are the same on every system; the definitions are Linux's +// and macOS's (posix/bounded_process.cpp, with linux/ and macos/ for the pipe), +// and a call from another system is a link error. + +export module mcpp.platform.unix.bounded_process; + +import std; + +export namespace mcpp::platform::unixproc { + +// Every member is a builtin type. Same reason as the Windows peer. +struct DeadlineRun { + // False on every non-POSIX build, and on POSIX when the child could not be + // spawned. "Could not spawn" and "ran and failed" must not share an exit + // code, so the caller falls back rather than reporting a failure. + bool supported = false; + int exit_code = 0; + bool timed_out = false; + // Non-zero when `supported` is false BECAUSE the spawn was attempted and + // refused: the errno posix_spawnp returned. Zero with `supported` false + // means this build has no bounded launcher at all. The two need opposite + // treatment one layer up — the first is reported with its errno, the + // second falls back to the unbounded launcher — and before this field + // existed both read the same, so the caller spawned a second time and + // discarded the errno the first attempt had in hand (#544). + int spawn_error = 0; +}; + +using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); +using StopPoll = bool (*)(void* ctx); + +// ─── Starting a child is safe for concurrent callers ───────────────────── +// +// Two threads that start children at the same time must not pass one child's +// pipe to the other. A child inherits every descriptor of its parent that is not +// close-on-exec, so a child started by thread B while thread A holds the write +// end of A's capture pipe keeps that end open for as long as it lives. A's +// reader then waits for end of file until B's unrelated child exits: a compile +// that finished at once is reported only when a longer one does, and a deadline +// the reader is meant to enforce is enforced late. +// +// The pipes are therefore created close-on-exec, and `posix_spawn`'s `dup2` +// action, which clears the flag on the descriptor it creates, is the only way a +// descriptor reaches the child. Where the platform has `pipe2` (Linux) that is +// one atomic call. Where it does not (macOS) the flag is set by a second call, +// which leaves a window in which another thread's child could be started; every +// start on such a platform therefore holds one process-wide section, from the +// creation of the pipe to the parent's close of its write end. On Linux the +// section is empty. +// +// `make_pipe` returns 0 on success and -1 on failure, as `pipe` does. Both ends +// are close-on-exec. The caller holds a `LaunchSection` across this call and the +// start of the child. +int make_pipe(int fds[2]); + +class LaunchSection { +public: + LaunchSection(); + ~LaunchSection(); + // Leaves the section before the destructor does: a caller that goes on to + // wait for its child must not hold it for the child's whole life. + void release(); + LaunchSection(const LaunchSection&) = delete; + LaunchSection& operator=(const LaunchSection&) = delete; +private: + bool held_ = false; +}; + +// `argvEntries` is `argvCount` NUL-terminated strings; `envEntries` is +// `envCount` "KEY=VALUE" strings applied on top of the current environment. +// `cwd` may be null. A non-positive `deadlineMs` is rejected with +// supported=false: "no bound" belongs on the caller's untimed path. +// +// A NULL `sink` means "do not capture": the child INHERITS the caller's stdio +// and is still bounded. That is not an optimization — it is the +// `run_exec_deadline` contract. Routing an uncaptured run through a pipe would +// (a) delay every line until the child exits, which is the opposite of what a +// bounded `mcpp test` run is for, and (b) make the child's stdout a pipe +// rather than a terminal, so gtest and friends silently drop their colors. +// +// `idleMs`, when positive, is a second bound: the child is killed once it has +// written nothing for that long. It is meaningful only with a sink, and it is +// what lets a long, progressing child (a download that prints progress) run to +// completion while a wedged one does not (#648). With `idleMs` positive a +// non-positive `deadlineMs` means "no total bound", and so it does with a sink +// and neither bound: a build's ninja, read line by line for its progress. +// +// `ownGroup` non-zero places the child in a process group of its own, kills +// the GROUP at a bound, and registers the group with the signal guard for the +// length of the call, so terminating mcpp takes the child's descendants with +// it. It is opt-in because a child in a background group that reads the +// terminal is stopped by SIGTTIN, and the uncaptured callers of this function +// hand the terminal to their child. +DeadlineRun capture_with_deadline(const char* const* argvEntries, + unsigned long argvCount, + const char* const* envEntries, + unsigned long envCount, + const char* cwd, + long long deadlineMs, + long long idleMs, + int ownGroup, + OutputSink sink, + void* ctx, + StopPoll stop); + +// ─── A child that outlives the call that started it (#496) ─────────────── +// +// `capture_with_deadline` owns its child for the length of one call. A project +// `[hooks] during_build` command is owned for the length of the BUILD, so the +// caller needs a handle it can poll and stop later. Every member is a builtin, +// same constraint as DeadlineRun. +// +// `group`, NOT a pid. The child is placed in a process group of its own +// (posix_spawnattr_setpgroup) and stopped with killpg, because the thing being +// started is a user-authored SHELL command: `sh -c 'player & wait'` makes the +// writer a grandchild, and `kill(pid)` reaches only the shell. A background +// player that survives its build, from a process the user cannot name, is the +// worst outcome this API has — so the group is the unit throughout. +struct BackgroundChild { + bool ok = false; + long long group = 0; // the child's process-group id (== its pid) +}; + +// `inheritStdio == 0` sends the child's output to /dev/null. A spanning hook +// writes CONCURRENTLY with ninja and would otherwise interleave into the middle +// of a compiler diagnostic. +BackgroundChild spawn_background(const char* const* argvEntries, + unsigned long argvCount, + const char* cwd, + int inheritStdio); + +// 1 = still running, 0 = exited, -1 = unknown. When it returns 0, `exitCode` +// (if given) receives the shell convention: the status, or 128+signal. +// +// The code is part of the answer rather than a second call, because the only +// caller that needs it — the `loop` supervisor — has to distinguish "the +// player finished the track" from "the command does not exist". Restarting the +// first forever is the feature; restarting the second forever is a spin. +int background_running(long long group, int* exitCode); + +// SIGTERM, `graceMs`, then SIGKILL — to the GROUP. Reaps the direct child. +void background_stop(long long group, long long graceMs); + +// ─── Ctrl-C ────────────────────────────────────────────────────────────── +// +// Its own process group is what makes killpg possible AND what stops the +// terminal's SIGINT from reaching the child: Ctrl-C would kill mcpp and leave +// the player running. Both halves are required, so the group that must not +// outlive us is registered here for the duration. +// +// The handler does the minimum that is async-signal-safe: killpg (which is), +// then the default action. +// A REGISTRY AND NOT ONE SLOT, BECAUSE THERE IS MORE THAN ONE OWNER. +// +// A spanning `[hooks]` command is guarded for the length of the build, and the +// build's own ninja is guarded for the length of its run — concurrently. With a +// single slot the second registration overwrites the first, so killing mcpp +// mid-build would take down ninja and leave the hook's process running, which +// is the exact outcome the guard exists to prevent. +// +// Fixed capacity and no allocation: the handler reads this array and may not +// allocate. Registering past capacity fails loudly rather than silently +// dropping a group, because a dropped group is an orphan nobody will find. +// +// SAFE FOR CONCURRENT CALLERS. Children are started from several threads at once +// (a workspace's build programs are compiled concurrently), so claiming and +// releasing a slot is serialized by a mutex, and two threads can no longer claim +// the same slot. The handler takes no lock: it reads slots that are written one +// `sig_atomic_t` at a time. The capacity is 256, well above any job count, since +// one group is held per running child. +void guard_group_on_signal(long long group); +void unguard_group(long long group); +void clear_group_guard(); +// Whether no process group is guarded. A caller about to replace mcpp with +// another program (`mcpp run`) requires it: a guarded group is a child that +// would lose its owner. +bool group_guard_idle(); + +// THE TERMINAL MODE A SIGNAL RESTORES (build output design revision 3, +// §5.14). `--play-game` reads keys from the terminal without echo. A Ctrl-C, +// a termination or a hangup that ends mcpp must leave the terminal in the +// mode it found, and the handler installed while ninja runs is the one above, +// so the saved mode is restored there, with tcsetattr, which is +// async-signal-safe. `fd`'s current mode is saved; the caller changes the mode +// after this returns. A process killed outright cannot restore anything. +void guard_terminal_mode(int fd); +// Restores the saved mode and stops guarding it. +void unguard_terminal_mode(); + +// THE FILES A SIGNAL LEAVES (design 2026-10-26 §5.4). A build interrupted while +// it writes its directory says so in its result record: the interrupted form is +// rendered beforehand, and the handler -- after it has stopped the guarded +// groups -- writes each `contents[i]` to `temps[i]` and renames it over +// `paths[i]` (open, write, close and rename are async-signal-safe). While files +// are set the handler is installed. Setting replaces the previous set; at most +// four files. +void set_interrupt_files(const std::vector& paths, + const std::vector& temps, + const std::vector& contents); +void clear_interrupt_files(); + +// A WRITE WHOSE READER IS GONE ENDS mcpp AS A SIGNAL ABOVE DOES (design +// 2026-10-26 part 2, §6.4): the guarded groups killed, the interrupt files +// written, the terminal's mode restored, and then SIGPIPE's own default, so the +// shell sees the status it always saw (141) for `mcpp … | head`. +// +// A handler and not an ignore. openkal ignores SIGPIPE for its own writes, and +// an ignored disposition survives exec: every program mcpp starts -- a build +// script's `yes | head` included -- would begin with it ignored, and a shell +// cannot undo an ignore it was started with. A handled signal is reset to its +// default by exec. Installed once, before anything is written. +void guard_broken_pipe(); + +} // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/posix/env.cpp b/modules/platform/src/posix/env.cpp new file mode 100644 index 00000000..f3e75232 --- /dev/null +++ b/modules/platform/src/posix/env.cpp @@ -0,0 +1,12 @@ +// mcpp.platform.env on Linux and macOS: writing the process's own variables. +module; +#include + +module mcpp.platform.env; + +import std; + +void mcpp::platform::env::detail::put(const std::string& key, const char* value) { + if (value) ::setenv(key.c_str(), value, 1); + else ::unsetenv(key.c_str()); +} diff --git a/modules/platform/src/posix/fs.cpp b/modules/platform/src/posix/fs.cpp new file mode 100644 index 00000000..37ab023a --- /dev/null +++ b/modules/platform/src/posix/fs.cpp @@ -0,0 +1,69 @@ +// mcpp.platform.fs on Linux and macOS: rename's replacement, O_APPEND, and +// flock. +module; +#include +#include +#include + +module mcpp.platform.fs; + +import std; + +namespace mcpp::platform::fs { + +bool replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec) { + // 临时文件与目标文件位于同一文件系统时,rename 提供原子替换。 + std::filesystem::rename(source, destination, ec); + return !ec; +} + +bool append_atomically(const std::filesystem::path& file, std::string_view bytes) { + const int fd = ::open(file.c_str(), O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644); + if (fd < 0) return false; + const auto n = ::write(fd, bytes.data(), bytes.size()); + ::close(fd); + return n == static_cast(bytes.size()); +} + +// ── FileLock ── + +std::optional FileLock::try_acquire(const std::filesystem::path& dir, + std::string_view name) { + std::error_code ec; + std::filesystem::create_directories(dir, ec); + auto lockPath = dir / std::string(name); + int fd = ::open(lockPath.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644); + if (fd < 0) return std::nullopt; + if (::flock(fd, LOCK_EX | LOCK_NB) != 0) { + ::close(fd); + return std::nullopt; + } + return FileLock{fd}; +} + +FileLock::~FileLock() { + if (native_ >= 0) { + ::flock(static_cast(native_), LOCK_UN); + ::close(static_cast(native_)); + } +} + +FileLock::FileLock(FileLock&& o) noexcept : native_(o.native_) { + o.native_ = -1; +} + +FileLock& FileLock::operator=(FileLock&& o) noexcept { + if (this != &o) { + if (native_ >= 0) { + ::flock(static_cast(native_), LOCK_UN); + ::close(static_cast(native_)); + } + native_ = o.native_; + o.native_ = -1; + } + return *this; +} + +} // namespace mcpp::platform::fs diff --git a/modules/platform/src/posix/fs_caps.cpp b/modules/platform/src/posix/fs_caps.cpp new file mode 100644 index 00000000..2dd12ccf --- /dev/null +++ b/modules/platform/src/posix/fs_caps.cpp @@ -0,0 +1,21 @@ +// mcpp.platform.fs_caps on Linux and macOS: a directory's owner. +module; +#include +#include + +module mcpp.platform.fs_caps; + +import std; + +namespace mcpp::platform::native { + +std::string owner_problem(const std::filesystem::path& dir) { + struct stat st {}; + if (::stat(dir.c_str(), &st) != 0) return "it cannot be read"; + if (st.st_uid == ::getuid()) return {}; + if (st.st_uid != 0) return "it belongs to another user"; + if (::access(dir.c_str(), W_OK) == 0) return "it belongs to root and this user can write it"; + return {}; +} + +} // namespace mcpp::platform::native diff --git a/modules/platform/src/posix/process.cpp b/modules/platform/src/posix/process.cpp new file mode 100644 index 00000000..82b5522e --- /dev/null +++ b/modules/platform/src/posix/process.cpp @@ -0,0 +1,359 @@ +// mcpp.platform.process on Linux and macOS: the launchers that start a +// program directly with posix_spawn (#248), and the C library's pipe and +// wait-status primitives. +// +// The extra environment goes into the child's envp ONLY (merged_environ); it +// never enters mcpp's own environment nor a host /bin/sh. On Linux that is the +// exact fix for the newer-glibc `sh:` crash; on macOS the old shell path built +// `KEY='v' cd && prog`, where POSIX binds the env assignments to `cd` +// alone — the real program (build.mcpp, the only env+cwd call site) received +// NO extra env and lost the whole MCPP_* contract. Direct exec also drops the +// shell quoting / signal / injection surface entirely. cwd is applied via +// posix_spawn_file_actions_addchdir_np, available on both glibc and macOS +// 10.15+ (mcpp's floor is macOS 14). +module; +#include // errno after a refused execve (run_foreground) +#include +#include // O_RDONLY, O_WRONLY for capture_stdout's /dev/null +#include // posix_spawnp, posix_spawn_file_actions_* (incl. addchdir_np) +#include // umask +#include // waitpid +#include // pipe, dup2, close, read + +module mcpp.platform.process; + +import std; +import mcpp.platform.common; +import mcpp.platform.env; +import mcpp.platform.unix.bounded_process; + +namespace mcpp::platform::process { + +void set_umask_022() { + ::umask(022); +} + +int normalize_exit_code(int rc) { + if (WIFEXITED(rc)) + return WEXITSTATUS(rc); + if (WIFSIGNALED(rc)) + return 128 + WTERMSIG(rc); + return rc; +} + +std::FILE* open_pipe(const std::string& command) { return ::popen(command.c_str(), "r"); } +int close_pipe(std::FILE* pipe) { return ::pclose(pipe); } + +namespace { + +char** host_environ() { + return mcpp::platform::env::environment(); +} + +// An outer `mcpp run`/`mcpp test` points LD_LIBRARY_PATH at mcpp's private +// glibc payload so ITS child (a sandbox-linked user binary) can load. When +// that child spawns mcpp again (e.g. a course provider driving `mcpp test`), +// the same value would flow on into the inner mcpp's own children — and the +// sandbox ninja/gcc (host-glibc binaries) then resolve a MISMATCHED libc and +// segfault inside the dynamic linker before main (trace signature: a bare +// `__vdso_time` line). Strip exactly the private-glibc payload entries from +// inherited loader paths: user-supplied entries survive, and an `extra` +// override (the correct per-child value) always wins over the inherited var. +// +// The predicate itself lives in mcpp.platform.env, because the OTHER half of +// this guarantee is there: a composed override (dirs + inherited tail) arrives +// here as `extra` and therefore bypasses the sanitation below, so +// prepend_path_list has to sanitize the tail it carries. +using mcpp::platform::env::strip_private_glibc; + +// Build a child environment block = the current environ with `extra` overrides +// applied. Returned vector owns the strings; the caller derives a NUL-terminated +// char* array from it. Built in the PARENT so the child env never requires a +// post-fork setenv and mcpp's own environment is never touched. +std::vector merged_environ( + const std::vector>& extra) +{ + std::vector out; + std::set overridden; + for (auto& [k, v] : extra) { out.push_back(k + "=" + v); overridden.insert(k); } + for (char** e = host_environ(); e && *e; ++e) { + std::string_view entry(*e); + auto eq = entry.find('='); + std::string key(eq == std::string_view::npos ? entry : entry.substr(0, eq)); + if (overridden.contains(key)) continue; + if (eq != std::string_view::npos + && (key == "LD_LIBRARY_PATH" || key == "DYLD_LIBRARY_PATH")) { + auto cleaned = strip_private_glibc(entry.substr(eq + 1)); + if (!cleaned.empty()) out.push_back(key + "=" + cleaned); + continue; // nothing legitimate left → drop the var entirely + } + out.emplace_back(entry); + } + return out; +} + +} // namespace + +int run_exec(const std::vector& argv, + const std::vector>& extraEnv, + int* spawn_error) +{ + if (spawn_error) *spawn_error = 0; + if (argv.empty()) return 127; + auto envStore = merged_environ(extraEnv); + std::vector envp; + for (auto& s : envStore) envp.push_back(s.data()); + envp.push_back(nullptr); + std::vector cargv; + for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); + cargv.push_back(nullptr); + + // THE CHILD GETS ITS OWN PROCESS GROUP, AND mcpp KILLS THAT GROUP IF IT IS + // ITSELF KILLED. + // + // Without this, terminating mcpp leaves the child running. Measured: every + // `timeout`-terminated `mcpp run` left an orphaned ninja spinning at 100% + // of a core, and one of them outlived the removal of the entire sandbox it + // belonged to — its working directory read `(deleted)` and it was still + // burning a core half an hour later. Any CI that wraps mcpp in `timeout` + // leaks a busy core per timeout. + // + // The group rather than the pid, because the child starts children of its + // own: killing ninja alone would leave its compilers behind. + // + // A signal is not enough on its own, which is why the guard sends SIGKILL. + // ninja records a signal in a flag and acts on it where it waits for a + // subprocess; a ninja with no command running never reaches that check, so + // a polite signal is recorded and never obeyed. + posix_spawnattr_t attr; + ::posix_spawnattr_init(&attr); + ::posix_spawnattr_setpgroup(&attr, 0); // 0 ⇒ new group, id == pid + ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); + + pid_t pid = 0; + int sp = 0; + { + // A child with no pipe of its own is still started outside the window + // another thread's pipe is open in. + LaunchSection launch; + sp = ::posix_spawnp(&pid, cargv[0], nullptr, &attr, cargv.data(), envp.data()); + } + ::posix_spawnattr_destroy(&attr); + if (sp != 0) { + // Reported once: by the caller when it asked for the errno, here + // otherwise. Never dropped — the errno in hand at this line is the + // whole difference between "Exec format error" and a blank line. + if (spawn_error) *spawn_error = sp; + else std::fputs(spawn_failure(argv.front(), sp).c_str(), stderr); + return 127; + } + mcpp::platform::unixproc::guard_group_on_signal(pid); + int status = 0; + while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } + mcpp::platform::unixproc::unguard_group(pid); + return normalize_exit_code(status); +} + +int run_foreground(const std::vector& argv, + const std::vector>& extraEnv, + int* spawn_error) +{ + if (spawn_error) *spawn_error = 0; + if (argv.empty()) return 127; + // A guarded group is a child mcpp still owns (ninja, a `during_build` + // hook). Replacing mcpp would leave it without an owner, so that is an + // internal error and not a silent orphan. + if (!mcpp::platform::unixproc::group_guard_idle()) { + std::fputs("mcpp: internal: a child of mcpp is still running; the program " + "is not started\n", stderr); + return 125; + } + // The terminal's mode and the signal handlers mcpp installed are restored + // here; execve then resets every handled signal to its default. + mcpp::platform::unixproc::unguard_terminal_mode(); + mcpp::platform::unixproc::clear_group_guard(); + + auto envStore = merged_environ(extraEnv); + std::vector envp; + for (auto& s : envStore) envp.push_back(s.data()); + envp.push_back(nullptr); + std::vector cargv; + for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); + cargv.push_back(nullptr); + + std::fflush(nullptr); + // PATH is searched with mcpp's own PATH, as posix_spawnp searched it for + // run_exec, and not with the program's environment. + const std::string& file = argv.front(); + int err = ENOENT; + if (file.find('/') != std::string::npos) { + ::execve(file.c_str(), cargv.data(), envp.data()); + err = errno; + } else { + const char* path = std::getenv("PATH"); + std::string_view dirs = (path && *path) ? path : "/usr/bin:/bin"; + bool denied = false; + for (auto dir : dirs | std::views::split(':')) { + std::string candidate{std::string_view(dir)}; + if (candidate.empty()) candidate = "."; + candidate += '/'; + candidate += file; + ::execve(candidate.c_str(), cargv.data(), envp.data()); + // The search continues past a directory that cannot hold the + // program; any other refusal names the program that was found. + if (errno == EACCES) { denied = true; continue; } + if (errno != ENOENT && errno != ENOTDIR) { err = errno; denied = false; break; } + } + if (denied && err == ENOENT) err = EACCES; + } + if (spawn_error) *spawn_error = err; + else std::fputs(spawn_failure(file, err).c_str(), stderr); + return 127; +} + +RunResult capture_exec( + const std::vector& argv, + const std::vector>& extraEnv, + std::string_view cwd, + int* spawn_error) +{ + RunResult result; + if (spawn_error) *spawn_error = 0; + if (argv.empty()) { result.exit_code = 127; return result; } + // posix_spawn + a pipe; stdout and stderr both go to the pipe so the + // captured text is combined (replaces the old `2>&1`). + auto envStore = merged_environ(extraEnv); + std::vector envp; + for (auto& s : envStore) envp.push_back(s.data()); + envp.push_back(nullptr); + std::vector cargv; + for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); + cargv.push_back(nullptr); + + // Several threads call this at once (a workspace's build programs are + // compiled together). The pipe is close-on-exec, so a child another thread + // starts while this one is open does not keep it open (see + // mcpp.platform.unix.bounded_process for the rule and for what a platform + // without `pipe2` does in addition). + LaunchSection launch; + int fds[2]; + if (mcpp::platform::unixproc::make_pipe(fds) != 0) { + result.exit_code = 127; + return result; + } + + posix_spawn_file_actions_t fa; + ::posix_spawn_file_actions_init(&fa); + // Run the child in `cwd` when requested (e.g. build.mcpp, whose relative + // file writes must land in the project root regardless of mcpp's own cwd). + std::string cwdStore(cwd); + if (!cwdStore.empty()) + ::posix_spawn_file_actions_addchdir_np(&fa, cwdStore.c_str()); + ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); // stdout → pipe + ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); // stderr → same pipe + ::posix_spawn_file_actions_addclose(&fa, fds[0]); + ::posix_spawn_file_actions_addclose(&fa, fds[1]); + + // Owned exactly as `run_exec`'s child is, and for the same reason: this is + // the launcher a FULL build uses, so a `mcpp build` interrupted here is the + // common case rather than the rare one. Fixing only `run_exec` left the + // orphan in place — measured, with the two launchers giving opposite + // answers to the same test. + posix_spawnattr_t attr; + ::posix_spawnattr_init(&attr); + ::posix_spawnattr_setpgroup(&attr, 0); + ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); + + pid_t pid = 0; + int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data()); + ::posix_spawnattr_destroy(&attr); + ::posix_spawn_file_actions_destroy(&fa); + ::close(fds[1]); + launch.release(); + if (sp == 0) mcpp::platform::unixproc::guard_group_on_signal(pid); + if (sp != 0) { + ::close(fds[0]); + result.exit_code = 127; + if (spawn_error) *spawn_error = sp; + else result.output = spawn_failure(argv.front(), sp); + return result; + } + + std::array buf{}; + ssize_t n; + while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) + result.output.append(buf.data(), static_cast(n)); + ::close(fds[0]); + int status = 0; + while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } + mcpp::platform::unixproc::unguard_group(pid); + result.exit_code = normalize_exit_code(status); + return result; +} + +RunResult capture_stdout( + const std::vector& argv, + const std::vector>& extraEnv, + int* spawn_error) +{ + RunResult result; + if (spawn_error) *spawn_error = 0; + if (argv.empty()) { result.exit_code = 127; return result; } + auto envStore = merged_environ(extraEnv); + std::vector envp; + for (auto& s : envStore) envp.push_back(s.data()); + envp.push_back(nullptr); + std::vector cargv; + for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); + cargv.push_back(nullptr); + + // The same rule as capture_exec's. + LaunchSection launch; + int fds[2]; + if (mcpp::platform::unixproc::make_pipe(fds) != 0) { + result.exit_code = 127; + return result; + } + + posix_spawn_file_actions_t fa; + ::posix_spawn_file_actions_init(&fa); + ::posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDONLY, 0); + ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); // stdout -> pipe + ::posix_spawn_file_actions_addopen(&fa, 2, "/dev/null", O_WRONLY, 0); + ::posix_spawn_file_actions_addclose(&fa, fds[0]); + ::posix_spawn_file_actions_addclose(&fa, fds[1]); + + // Owned as capture_exec's child is: a probe that outlives an interrupted + // mcpp is the same orphan, only smaller. + posix_spawnattr_t attr; + ::posix_spawnattr_init(&attr); + ::posix_spawnattr_setpgroup(&attr, 0); + ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); + + pid_t pid = 0; + int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data()); + ::posix_spawnattr_destroy(&attr); + ::posix_spawn_file_actions_destroy(&fa); + ::close(fds[1]); + launch.release(); + if (sp != 0) { + ::close(fds[0]); + result.exit_code = 127; + if (spawn_error) *spawn_error = sp; + return result; + } + mcpp::platform::unixproc::guard_group_on_signal(pid); + + std::array buf{}; + ssize_t n; + while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) + result.output.append(buf.data(), static_cast(n)); + ::close(fds[0]); + int status = 0; + while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } + mcpp::platform::unixproc::unguard_group(pid); + result.exit_code = normalize_exit_code(status); + return result; +} + +} // namespace mcpp::platform::process diff --git a/modules/platform/src/posix/scaffold_fs.cpp b/modules/platform/src/posix/scaffold_fs.cpp new file mode 100644 index 00000000..8dbb91ef --- /dev/null +++ b/modules/platform/src/posix/scaffold_fs.cpp @@ -0,0 +1,71 @@ +// mcpp.platform.scaffold_fs on Linux and macOS: fsync of a file and of a +// directory. +module; +#include +#include +#include + +module mcpp.platform.scaffold_fs; + +import std; + +namespace mcpp::platform { + +namespace { + +std::string errno_message(int code) { + return std::generic_category().message(code); +} + +bool sync_is_unsupported(int code) { + return code == EINVAL || code == EROFS || code == ENOTSUP || code == EOPNOTSUPP; +} + +} // namespace + +std::expected sync_regular_file(const std::filesystem::path& path) { + int descriptor = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (descriptor < 0) { + const int error = errno; + return std::unexpected(std::format( + "cannot open '{}' for durable sync: {}", path.string(), errno_message(error))); + } + if (::fsync(descriptor) != 0) { + const int error = errno; + ::close(descriptor); + if (sync_is_unsupported(error)) return {}; + return std::unexpected(std::format( + "durable sync '{}' failed: {}", path.string(), errno_message(error))); + } + if (::close(descriptor) != 0) { + const int error = errno; + return std::unexpected(std::format( + "close synced file '{}' failed: {}", path.string(), errno_message(error))); + } + return {}; +} + +std::expected sync_directory(const std::filesystem::path& path) { + int descriptor = ::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (descriptor < 0) { + const int error = errno; + if (sync_is_unsupported(error)) return {}; + return std::unexpected(std::format( + "cannot open directory '{}' for durable sync: {}", path.string(), errno_message(error))); + } + if (::fsync(descriptor) != 0) { + const int error = errno; + ::close(descriptor); + if (sync_is_unsupported(error)) return {}; + return std::unexpected(std::format( + "durable directory sync '{}' failed: {}", path.string(), errno_message(error))); + } + if (::close(descriptor) != 0) { + const int error = errno; + return std::unexpected(std::format( + "close synced directory '{}' failed: {}", path.string(), errno_message(error))); + } + return {}; +} + +} // namespace mcpp::platform diff --git a/modules/platform/src/posix/terminal.cpp b/modules/platform/src/posix/terminal.cpp new file mode 100644 index 00000000..97d22d05 --- /dev/null +++ b/modules/platform/src/posix/terminal.cpp @@ -0,0 +1,91 @@ +// mcpp.platform.terminal on Linux and macOS: the terminal device both +// streams reach, keys read from a terminal without a line discipline, and +// standard output diverted at its descriptor. +module; +#include +#include +#include // fcntl, F_DUPFD_CLOEXEC +#include +#include +#include + +module mcpp.platform.terminal; + +import std; +import mcpp.platform.unix.bounded_process; + +namespace mcpp::platform::terminal::native { + +bool same_terminal() { + const int out = ::fileno(stdout), err = ::fileno(stderr); + if (::isatty(out) == 0 || ::isatty(err) == 0) return false; + struct stat a{}, b{}; + if (::fstat(out, &a) != 0 || ::fstat(err, &b) != 0) return false; + return a.st_rdev == b.st_rdev; +} + +unsigned console_output_code_page(Stream) { return 0; } + +bool write_descriptor(int fd, std::string_view text) { + std::size_t done = 0; + while (done < text.size()) { + const auto n = ::write(fd, text.data() + done, text.size() - done); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return false; + done += static_cast(n); + } + return true; +} + +int divert_stdout() { + // Not `dup`: its copy survives exec. Descriptor 3 or above, as `dup` + // would have chosen, so nothing else about the redirection moves. + const int saved = ::fcntl(1, F_DUPFD_CLOEXEC, 3); + if (saved >= 0) ::dup2(2, 1); + return saved; +} + +void restore_stdout(int saved) { + ::dup2(saved, 1); + ::close(saved); +} + +} // namespace mcpp::platform::terminal::native + +namespace mcpp::platform::terminal { + +KeyInput::KeyInput(Stream display) { + if (!is_terminal(display)) return; + if (::isatty(0) == 0 || ::tcgetpgrp(0) != ::getpgrp()) return; + struct termios mode{}; + if (::tcgetattr(0, &mode) != 0) return; + mcpp::platform::unixproc::guard_terminal_mode(0); + struct termios raw = mode; + raw.c_lflag &= static_cast(~(ICANON | ECHO)); // ISIG stays: Ctrl-C interrupts + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + if (::tcsetattr(0, TCSANOW, &raw) != 0) { + mcpp::platform::unixproc::unguard_terminal_mode(); + return; + } + active_ = true; +} + +KeyInput::~KeyInput() { + if (!active_) return; + mcpp::platform::unixproc::unguard_terminal_mode(); +} + +std::vector KeyInput::read() { + std::vector keys; + if (!active_) return keys; + char buf[64]; + while (true) { + const auto n = ::read(0, buf, sizeof buf); // VMIN 0, VTIME 0: never blocks + if (n <= 0) break; + pending_.append(buf, static_cast(n)); + } + return decode_keys(pending_); +} + +} // namespace mcpp::platform::terminal diff --git a/modules/platform/src/posix/windows.cpp b/modules/platform/src/posix/windows.cpp new file mode 100644 index 00000000..5d361905 --- /dev/null +++ b/modules/platform/src/posix/windows.cpp @@ -0,0 +1,44 @@ +// mcpp.platform.windows on Linux and macOS: what the Windows capabilities answer where there is no Windows. +module; +#include + +module mcpp.platform.windows; + +import std; + +namespace mcpp::platform::windows { +bool writable_fixed_drive(char drive, std::wstring_view child) { + (void)drive; + (void)child; + return false; +} +} + +// ─── Implementation ────────────────────────────────────────────────────── + +namespace mcpp::platform::windows { + +void prepend_path(const std::filesystem::path& dir) { + (void)dir; +} + +std::optional machine_registry_path(std::wstring_view subkey, + std::wstring_view value) { + (void)subkey; (void)value; + return std::nullopt; +} + +bool long_paths_enabled() { + return false; +} + +std::optional env_directory(const char* name) { + if (const char* v = std::getenv(name); v && *v) return std::filesystem::path(v); + return std::nullopt; +} + +unsigned active_code_page() { + return 0; +} + +} // namespace mcpp::platform::windows diff --git a/modules/platform/src/process.cppm b/modules/platform/src/process.cppm index e2b3d7ef..6b1b16d6 100644 --- a/modules/platform/src/process.cppm +++ b/modules/platform/src/process.cppm @@ -19,34 +19,7 @@ // Callers are responsible for shell-quoting arguments (see platform.shell). module; -#ifndef _GNU_SOURCE -#define _GNU_SOURCE // for posix_spawn_file_actions_addchdir_np (glibc) -#endif -#include -#include -#if defined(_WIN32) -#include // _putenv_s -#define popen _popen -#define pclose _pclose -#elif defined(__linux__) || defined(__APPLE__) -// Linux and macOS launchers do a direct exec (see run_exec / capture_exec -// below); only Windows keeps the std::system shell path (#248). -#include // pipe, dup2, close, read -#include // errno after a refused execve (run_foreground) -#include // O_RDONLY, O_WRONLY for capture_stdout's /dev/null -#include // waitpid -#include // posix_spawnp, posix_spawn_file_actions_* (incl. addchdir_np) -#include // umask -// The deadline runners' headers (signal.h, errno, poll.h, fcntl.h, time.h) -// left with them: bounded runs now live in mcpp.platform.unix.bounded_process -// and mcpp.platform.windows.bounded_process, and this file only dispatches. -#if defined(__APPLE__) -#include // _NSGetEnviron — direct `environ` is only linkable - // from executables on Apple, not from dylibs -#else -extern "C" char **environ; -#endif -#endif +#include // stdout, stderr: macros, which `import std` does not carry export module mcpp.platform.process; @@ -63,11 +36,14 @@ export namespace mcpp::platform::process { // writable by its owner only (umask 022): what a home shared by every user of // a machine is written with. Nothing on Windows, where the ACL inherited from // the directory decides. -inline void set_umask_022() { -#if !defined(_WIN32) - ::umask(022); -#endif -} +void set_umask_022(); + +// A write to a pipe whose reader is gone (`mcpp … | head`) ends mcpp in +// order, as an interrupt does, and then with the status a shell has always +// seen for it (POSIX: mcpp.platform.unix.bounded_process's guard_broken_pipe). +// Called once, first thing in main. Nothing on Windows, where such a write +// fails and mcpp goes on, as it always has. +void guard_broken_pipe(); struct RunResult { int exit_code = 0; @@ -435,32 +411,65 @@ void set_interrupt_files(const std::vector; + +// The one sentence every launcher prints for a refused spawn. Platform-neutral +// on purpose: the bounded launchers on both platforms now hand their spawn +// error up (DeadlineRun::spawn_error), and the wrappers that receive it are +// compiled everywhere. `error` is an errno on POSIX and a GetLastError() value +// on Windows; the category below renders each in its own vocabulary. +std::string spawn_failure(std::string_view program, int error) { + if constexpr (mcpp::platform::is_windows) + return std::format("CreateProcess('{}') failed (error {}): {}\n", + program, error, std::system_category().message(error)); + else + return std::format("posix_spawnp('{}') failed (error {}): {}\n", + program, error, std::generic_category().message(error)); +} + +namespace { // Append a non-interactive stdin redirect to prevent child processes from // blocking on terminal input. @@ -470,11 +479,7 @@ using LaunchSection = mcpp::platform::unixproc::LaunchSection; // had to press Enter repeatedly to advance). // `cmd.exe` accepts ` merged_environ( - const std::vector>& extra) -{ - std::vector out; - std::set overridden; - for (auto& [k, v] : extra) { out.push_back(k + "=" + v); overridden.insert(k); } - for (char** e = host_environ(); e && *e; ++e) { - std::string_view entry(*e); - auto eq = entry.find('='); - std::string key(eq == std::string_view::npos ? entry : entry.substr(0, eq)); - if (overridden.contains(key)) continue; - if (eq != std::string_view::npos - && (key == "LD_LIBRARY_PATH" || key == "DYLD_LIBRARY_PATH")) { - auto cleaned = strip_private_glibc(entry.substr(eq + 1)); - if (!cleaned.empty()) out.push_back(key + "=" + cleaned); - continue; // nothing legitimate left → drop the var entirely - } - out.emplace_back(entry); - } - return out; -} -#else -// Build a shell command line from an argv vector (Windows + residual non-POSIX -// fallback only; Linux/macOS exec directly, #248). EVERY token is shell-quoted, -// including the program — a payload under `C:\Program Files\...` or a home -// directory with a space in the user name is otherwise cut at the first space -// and reported as `'C:\Program' is not recognized`. -// -// argv[0] used to be left raw here to survive cmd.exe's /c quote stripping. -// That traded one bug for another; finalize_shell_command now feeds cmd the -// outer quote pair it insists on eating, so the quoting below arrives intact. -std::string command_from_argv(const std::vector& argv) { -#if defined(_WIN32) - // One derivation: the tested, host-independent shaper above. - return windows_command_from_argv(argv); -#else - if (argv.empty()) return ""; - std::string cmd = mcpp::platform::shell::quote(argv[0]); - for (std::size_t i = 1; i < argv.size(); ++i) { - cmd += ' '; - cmd += mcpp::platform::shell::quote(argv[i]); - } - return cmd; -#endif -} -#endif - } // namespace int extract_exit_code(int raw_status) { @@ -640,7 +532,7 @@ RunResult capture(std::string_view command) { // `popen` creates the pipe and starts the child in one call, so the // section is exactly that call. LaunchSection launch; - fp = ::popen(cmd.c_str(), "r"); + fp = open_pipe(cmd); } if (!fp) { result.exit_code = -1; @@ -651,7 +543,7 @@ RunResult capture(std::string_view command) { while (std::fgets(buf.data(), static_cast(buf.size()), fp) != nullptr) result.output += buf.data(); - result.exit_code = normalize_exit_code(::pclose(fp)); + result.exit_code = normalize_exit_code(close_pipe(fp)); return result; } @@ -667,40 +559,40 @@ RunResult capture_with_env( std::string_view command, const std::vector>& env) { -#if defined(_WIN32) - // This mutates the calling process's environment, as it always has, and it - // is called from several threads at once when a workspace's build programs - // are compiled together. A variable is therefore set only when its value - // differs, so callers that pass one value (the toolchain's INCLUDE and LIB, - // the same for every compile of a build) stop touching the environment block - // after the first, and the writes that remain are made under the section the - // children are started in. - { - LaunchSection launch; - for (auto& [k, v] : env) { - const char* now = std::getenv(k.c_str()); - if (now && v == now) continue; - _putenv_s(k.c_str(), v.c_str()); + if constexpr (mcpp::platform::is_windows) { + // This mutates the calling process's environment, as it always has, and it + // is called from several threads at once when a workspace's build programs + // are compiled together. A variable is therefore set only when its value + // differs, so callers that pass one value (the toolchain's INCLUDE and LIB, + // the same for every compile of a build) stop touching the environment block + // after the first, and the writes that remain are made under the section the + // children are started in. + { + LaunchSection launch; + for (auto& [k, v] : env) { + const char* now = std::getenv(k.c_str()); + if (now && v == now) continue; + mcpp::platform::env::set(k, v); + } } - } - return capture(command); -#else - std::string prefixed; - for (auto& [k, v] : env) { - prefixed += k; - prefixed += '='; - // Simple quoting for env values - prefixed += '\''; - for (char c : v) { - if (c == '\'') prefixed += "'\\''"; - else prefixed += c; + return capture(command); + } else { + std::string prefixed; + for (auto& [k, v] : env) { + prefixed += k; + prefixed += '='; + // Simple quoting for env values + prefixed += '\''; + for (char c : v) { + if (c == '\'') prefixed += "'\\''"; + else prefixed += c; + } + prefixed += '\''; + prefixed += ' '; } - prefixed += '\''; - prefixed += ' '; + prefixed += command; + return capture(prefixed); } - prefixed += command; - return capture(prefixed); -#endif } int run_silent(std::string_view command) { @@ -715,7 +607,7 @@ int run_streaming(std::string_view command, std::FILE* fp = nullptr; { LaunchSection launch; - fp = ::popen(cmd.c_str(), "r"); + fp = open_pipe(cmd); } if (!fp) return -1; @@ -740,7 +632,7 @@ int run_streaming(std::string_view command, line.remove_suffix(1); if (!line.empty()) on_line(line); } - return normalize_exit_code(::pclose(fp)); + return normalize_exit_code(close_pipe(fp)); } int run_passthrough(std::string_view command, std::string* output) { @@ -748,7 +640,7 @@ int run_passthrough(std::string_view command, std::string* output) { std::FILE* fp = nullptr; { LaunchSection launch; - fp = ::popen(cmd.c_str(), "r"); + fp = open_pipe(cmd); } if (!fp) return -1; @@ -757,287 +649,7 @@ int run_passthrough(std::string_view command, std::string* output) { if (output) *output += buf.data(); std::fputs(buf.data(), stdout); } - return normalize_exit_code(::pclose(fp)); -} - -// run_exec / capture_exec are split by platform on purpose: -// -// Linux / -// macOS — DIRECT exec via posix_spawn (unified in #248). The extra env goes -// into the child's envp ONLY (merged_environ); it never enters -// mcpp's own environment nor a host /bin/sh. On Linux that is the -// exact fix for the newer-glibc `sh:` crash; on macOS the old shell -// path built `KEY='v' cd && prog`, where POSIX binds the env -// assignments to `cd` alone — the real program (build.mcpp, the -// only env+cwd call site) received NO extra env and lost the whole -// MCPP_* contract. Direct exec also drops the shell quoting / -// signal / injection surface entirely. cwd is applied via -// posix_spawn_file_actions_addchdir_np, available on both glibc -// and macOS 10.15+ (mcpp's floor is macOS 14). -// Windows — KEEP the proven std::system shell path. The env-binding hazard -// does not exist here (env goes through _putenv_s, not a prefix), -// so we deliberately do not swap the launch primitive on a platform -// we cannot iterate on locally. -// -// TODO(launcher-unify): Windows is the remaining exception; if it ever needs -// child-only env isolation, move it onto a CreateProcess/_spawn equivalent and -// delete the residual shell branch below. -int run_exec(const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - if (spawn_error) *spawn_error = 0; - if (argv.empty()) return 127; -#if defined(__linux__) || defined(__APPLE__) - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - // THE CHILD GETS ITS OWN PROCESS GROUP, AND mcpp KILLS THAT GROUP IF IT IS - // ITSELF KILLED. - // - // Without this, terminating mcpp leaves the child running. Measured: every - // `timeout`-terminated `mcpp run` left an orphaned ninja spinning at 100% - // of a core, and one of them outlived the removal of the entire sandbox it - // belonged to — its working directory read `(deleted)` and it was still - // burning a core half an hour later. Any CI that wraps mcpp in `timeout` - // leaks a busy core per timeout. - // - // The group rather than the pid, because the child starts children of its - // own: killing ninja alone would leave its compilers behind. - // - // A signal is not enough on its own, which is why the guard sends SIGKILL. - // ninja records a signal in a flag and acts on it where it waits for a - // subprocess; a ninja with no command running never reaches that check, so - // a polite signal is recorded and never obeyed. - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - ::posix_spawnattr_setpgroup(&attr, 0); // 0 ⇒ new group, id == pid - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - - pid_t pid = 0; - int sp = 0; - { - // A child with no pipe of its own is still started outside the window - // another thread's pipe is open in. - LaunchSection launch; - sp = ::posix_spawnp(&pid, cargv[0], nullptr, &attr, cargv.data(), envp.data()); - } - ::posix_spawnattr_destroy(&attr); - if (sp != 0) { - // Reported once: by the caller when it asked for the errno, here - // otherwise. Never dropped — the errno in hand at this line is the - // whole difference between "Exec format error" and a blank line. - if (spawn_error) *spawn_error = sp; - else std::fputs(spawn_failure(argv.front(), sp).c_str(), stderr); - return 127; - } - mcpp::platform::unixproc::guard_group_on_signal(pid); - int status = 0; - while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } - mcpp::platform::unixproc::unguard_group(pid); - return normalize_exit_code(status); -#else - // THE SAME OWNERSHIP AS THE POSIX BRANCH, EXPRESSED IN THIS PLATFORM'S TERMS. - // - // `std::system` gave the child away: it runs through a cmd.exe mcpp does not - // hold a handle to, so terminating mcpp left the tree running exactly as the - // POSIX branch did before its process group. A job object with - // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is the equivalent unit — it takes the - // whole tree, which matters here for the same reason the group does there: - // the child starts compilers of its own. - // - // The command line is built by the same `windows_shell_command_line` the - // rest of this file uses, so the cmd.exe quoting rule has ONE derivation. - // Re-deriving it here is how `/d /s /c` loses an argument. - std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); - // wrap only — run_exec inherits stdio on purpose (see finalize_shell_command). - std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv)); - auto child = mcpp::platform::winproc::spawn_background(cmd.c_str(), nullptr, 1); - if (!child.ok) { - const int refused = static_cast(child.refused); - if (spawn_error) *spawn_error = refused; - else std::fputs(spawn_failure(argv.front(), refused).c_str(), stderr); - return 127; - } - mcpp::platform::winproc::guard_job_on_signal(child.job); - int code = 127; - mcpp::platform::winproc::wait_background(child.process, &code); - mcpp::platform::winproc::unguard_job(child.job); - // Closes both handles; the child has already exited, so this is cleanup - // rather than a kill. - mcpp::platform::winproc::background_stop(child.job, child.process, 0); - return code; -#endif -} - -int run_foreground(const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - if (spawn_error) *spawn_error = 0; - if (argv.empty()) return 127; -#if defined(__linux__) || defined(__APPLE__) - // A guarded group is a child mcpp still owns (ninja, a `during_build` - // hook). Replacing mcpp would leave it without an owner, so that is an - // internal error and not a silent orphan. - if (!mcpp::platform::unixproc::group_guard_idle()) { - std::fputs("mcpp: internal: a child of mcpp is still running; the program " - "is not started\n", stderr); - return 125; - } - // The terminal's mode and the signal handlers mcpp installed are restored - // here; execve then resets every handled signal to its default. - mcpp::platform::unixproc::unguard_terminal_mode(); - mcpp::platform::unixproc::clear_group_guard(); - - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - std::fflush(nullptr); - // PATH is searched with mcpp's own PATH, as posix_spawnp searched it for - // run_exec, and not with the program's environment. - const std::string& file = argv.front(); - int err = ENOENT; - if (file.find('/') != std::string::npos) { - ::execve(file.c_str(), cargv.data(), envp.data()); - err = errno; - } else { - const char* path = std::getenv("PATH"); - std::string_view dirs = (path && *path) ? path : "/usr/bin:/bin"; - bool denied = false; - for (auto dir : dirs | std::views::split(':')) { - std::string candidate{std::string_view(dir)}; - if (candidate.empty()) candidate = "."; - candidate += '/'; - candidate += file; - ::execve(candidate.c_str(), cargv.data(), envp.data()); - // The search continues past a directory that cannot hold the - // program; any other refusal names the program that was found. - if (errno == EACCES) { denied = true; continue; } - if (errno != ENOENT && errno != ENOTDIR) { err = errno; denied = false; break; } - } - if (denied && err == ENOENT) err = EACCES; - } - if (spawn_error) *spawn_error = err; - else std::fputs(spawn_failure(file, err).c_str(), stderr); - return 127; -#else - std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); - std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv)); - unsigned long refused = 0; - const int code = mcpp::platform::winproc::run_foreground(cmd.c_str(), &refused); - if (code == -1 && refused != 0) { - if (spawn_error) *spawn_error = static_cast(refused); - else std::fputs(spawn_failure(argv.front(), static_cast(refused)).c_str(), stderr); - return 127; - } - return code; -#endif -} - -RunResult capture_exec( - const std::vector& argv, - const std::vector>& extraEnv, - std::string_view cwd, - int* spawn_error) -{ - RunResult result; - if (spawn_error) *spawn_error = 0; - if (argv.empty()) { result.exit_code = 127; return result; } -#if defined(__linux__) || defined(__APPLE__) - // posix_spawn + a pipe; stdout and stderr both go to the pipe so the - // captured text is combined (replaces the old `2>&1`). - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - // Several threads call this at once (a workspace's build programs are - // compiled together). The pipe is close-on-exec, so a child another thread - // starts while this one is open does not keep it open (see - // mcpp.platform.unix.bounded_process for the rule and for what a platform - // without `pipe2` does in addition). - LaunchSection launch; - int fds[2]; - if (mcpp::platform::unixproc::make_pipe(fds) != 0) { - result.exit_code = 127; - return result; - } - - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - // Run the child in `cwd` when requested (e.g. build.mcpp, whose relative - // file writes must land in the project root regardless of mcpp's own cwd). - std::string cwdStore(cwd); - if (!cwdStore.empty()) - ::posix_spawn_file_actions_addchdir_np(&fa, cwdStore.c_str()); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); // stdout → pipe - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); // stderr → same pipe - ::posix_spawn_file_actions_addclose(&fa, fds[0]); - ::posix_spawn_file_actions_addclose(&fa, fds[1]); - - // Owned exactly as `run_exec`'s child is, and for the same reason: this is - // the launcher a FULL build uses, so a `mcpp build` interrupted here is the - // common case rather than the rare one. Fixing only `run_exec` left the - // orphan in place — measured, with the two launchers giving opposite - // answers to the same test. - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - ::posix_spawnattr_setpgroup(&attr, 0); - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - - pid_t pid = 0; - int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data()); - ::posix_spawnattr_destroy(&attr); - ::posix_spawn_file_actions_destroy(&fa); - ::close(fds[1]); - launch.release(); - if (sp == 0) mcpp::platform::unixproc::guard_group_on_signal(pid); - if (sp != 0) { - ::close(fds[0]); - result.exit_code = 127; - if (spawn_error) *spawn_error = sp; - else result.output = spawn_failure(argv.front(), sp); - return result; - } - - std::array buf{}; - ssize_t n; - while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) - result.output.append(buf.data(), static_cast(n)); - ::close(fds[0]); - int status = 0; - while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } - mcpp::platform::unixproc::unguard_group(pid); - result.exit_code = normalize_exit_code(status); - return result; -#else - std::string cmd = command_from_argv(argv) + " 2>&1"; - if (!cwd.empty()) { -#if defined(_WIN32) - // cmd.exe `cd` without /d does not switch drives — a project on a - // different drive than mcpp's own cwd would run the child (the - // build.mcpp contract's only cwd consumer) in the wrong directory. - cmd = "cd /d " + mcpp::platform::shell::quote(cwd) + " && " + cmd; -#else - cmd = "cd " + mcpp::platform::shell::quote(cwd) + " && " + cmd; -#endif - } - return capture_with_env(cmd, extraEnv); -#endif + return normalize_exit_code(close_pipe(fp)); } // Host-independent (see the declaration): always the Windows shape. The @@ -1047,77 +659,6 @@ std::string windows_stdout_probe_command(const std::vector& argv) { return windows_command_from_argv(argv) + " 2>nul"; } -RunResult capture_stdout( - const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - RunResult result; - if (spawn_error) *spawn_error = 0; - if (argv.empty()) { result.exit_code = 127; return result; } -#if defined(__linux__) || defined(__APPLE__) - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - // The same rule as capture_exec's. - LaunchSection launch; - int fds[2]; - if (mcpp::platform::unixproc::make_pipe(fds) != 0) { - result.exit_code = 127; - return result; - } - - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - ::posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDONLY, 0); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); // stdout -> pipe - ::posix_spawn_file_actions_addopen(&fa, 2, "/dev/null", O_WRONLY, 0); - ::posix_spawn_file_actions_addclose(&fa, fds[0]); - ::posix_spawn_file_actions_addclose(&fa, fds[1]); - - // Owned as capture_exec's child is: a probe that outlives an interrupted - // mcpp is the same orphan, only smaller. - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - ::posix_spawnattr_setpgroup(&attr, 0); - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - - pid_t pid = 0; - int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data()); - ::posix_spawnattr_destroy(&attr); - ::posix_spawn_file_actions_destroy(&fa); - ::close(fds[1]); - launch.release(); - if (sp != 0) { - ::close(fds[0]); - result.exit_code = 127; - if (spawn_error) *spawn_error = sp; - return result; - } - mcpp::platform::unixproc::guard_group_on_signal(pid); - - std::array buf{}; - ssize_t n; - while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) - result.output.append(buf.data(), static_cast(n)); - ::close(fds[0]); - int status = 0; - while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } - mcpp::platform::unixproc::unguard_group(pid); - result.exit_code = normalize_exit_code(status); - return result; -#else - // cmd.exe reports a program it cannot find on stderr, which the redirect - // discards, and returns 9009; the output stays empty either way. - return capture_with_env(windows_stdout_probe_command(argv), extraEnv); -#endif -} - RunResult capture_host_tool_stdout(const std::vector& argv) { auto key = mcpp::platform::env::host_tool_runtime_library_path_key(); std::optional runtime_env; diff --git a/modules/platform/src/scaffold_fs.cppm b/modules/platform/src/scaffold_fs.cppm index dc731890..96f8b797 100644 --- a/modules/platform/src/scaffold_fs.cppm +++ b/modules/platform/src/scaffold_fs.cppm @@ -2,60 +2,11 @@ // public contract is stronger than std::filesystem::rename: an existing final // name is never replaced, including a target that appears in the commit race. -module; - -#if defined(_WIN32) -# ifndef NOMINMAX -# define NOMINMAX -# endif -# include -#elif defined(__linux__) -# include -# include -# include -# include -# include -#elif defined(__APPLE__) -# include -# include -# include -# include -#else -# include -# include -# include -#endif - export module mcpp.platform.scaffold_fs; import std; import mcpp.platform.fs_caps; -namespace mcpp::platform::detail { - -#if defined(_WIN32) -std::string win32_message(DWORD code) { - return std::system_category().message(static_cast(code)); -} -#else -std::string errno_message(int code) { - return std::generic_category().message(code); -} - -bool sync_is_unsupported(int code) { - return code == EINVAL || code == EROFS -#ifdef ENOTSUP - || code == ENOTSUP -#endif -#ifdef EOPNOTSUPP - || code == EOPNOTSUPP -#endif - ; -} -#endif - -} // namespace mcpp::platform::detail - export namespace mcpp::platform { std::expected atomic_rename_directory_no_replace( @@ -72,103 +23,12 @@ std::expected atomic_rename_directory_no_replace( // Make a newly written regular file durable before its containing staging // directory is published. Unsupported filesystem sync operations are treated // as "best available"; genuine I/O/permission failures abort the transaction. -std::expected sync_regular_file( - const std::filesystem::path& path) { -#if defined(_WIN32) - HANDLE handle = ::CreateFileW( - path.c_str(), GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (handle == INVALID_HANDLE_VALUE) { - const auto error = ::GetLastError(); - return std::unexpected(std::format( - "cannot open '{}' for durable sync: {}", path.string(), - detail::win32_message(error))); - } - if (::FlushFileBuffers(handle) == 0) { - const auto error = ::GetLastError(); - ::CloseHandle(handle); - return std::unexpected(std::format( - "durable sync '{}' failed: {}", path.string(), - detail::win32_message(error))); - } - if (::CloseHandle(handle) == 0) { - const auto error = ::GetLastError(); - return std::unexpected(std::format( - "close synced file '{}' failed: {}", path.string(), - detail::win32_message(error))); - } - return {}; -#else - int descriptor = ::open(path.c_str(), O_RDONLY -#ifdef O_CLOEXEC - | O_CLOEXEC -#endif - ); - if (descriptor < 0) { - const int error = errno; - return std::unexpected(std::format( - "cannot open '{}' for durable sync: {}", path.string(), - detail::errno_message(error))); - } - if (::fsync(descriptor) != 0) { - const int error = errno; - ::close(descriptor); - if (detail::sync_is_unsupported(error)) return {}; - return std::unexpected(std::format( - "durable sync '{}' failed: {}", path.string(), - detail::errno_message(error))); - } - if (::close(descriptor) != 0) { - const int error = errno; - return std::unexpected(std::format( - "close synced file '{}' failed: {}", path.string(), - detail::errno_message(error))); - } - return {}; -#endif -} +// Each system's own: posix/ and windows/ scaffold_fs.cpp. +std::expected sync_regular_file(const std::filesystem::path& path); // Persist the directory entry after the exclusive rename where the host/filesystem // offers directory fsync. MoveFileExW(MOVEFILE_WRITE_THROUGH) already supplies // the corresponding Windows guarantee. -std::expected sync_directory( - const std::filesystem::path& path) { -#if defined(_WIN32) - (void)path; - return {}; -#else - int descriptor = ::open(path.c_str(), O_RDONLY -#ifdef O_DIRECTORY - | O_DIRECTORY -#endif -#ifdef O_CLOEXEC - | O_CLOEXEC -#endif - ); - if (descriptor < 0) { - const int error = errno; - if (detail::sync_is_unsupported(error)) return {}; - return std::unexpected(std::format( - "cannot open directory '{}' for durable sync: {}", path.string(), - detail::errno_message(error))); - } - if (::fsync(descriptor) != 0) { - const int error = errno; - ::close(descriptor); - if (detail::sync_is_unsupported(error)) return {}; - return std::unexpected(std::format( - "durable directory sync '{}' failed: {}", path.string(), - detail::errno_message(error))); - } - if (::close(descriptor) != 0) { - const int error = errno; - return std::unexpected(std::format( - "close synced directory '{}' failed: {}", path.string(), - detail::errno_message(error))); - } - return {}; -#endif -} +std::expected sync_directory(const std::filesystem::path& path); } // namespace mcpp::platform diff --git a/modules/platform/src/shell.cppm b/modules/platform/src/shell.cppm index 5807a757..df5cff22 100644 --- a/modules/platform/src/shell.cppm +++ b/modules/platform/src/shell.cppm @@ -9,11 +9,10 @@ // Use the raw path string as the first token; quote() is safe for // arguments only. -module; - export module mcpp.platform.shell; import std; +import mcpplibs.os.platform; export namespace mcpp::platform::shell { @@ -78,11 +77,7 @@ std::string quote_through_shell(std::string_view s); // stdin is NOT touched here; that's the responsibility of // mcpp::platform::process::seal_stdin, which is auto-applied by capture / // run_silent / run_streaming on all platforms. -#if defined(_WIN32) -constexpr std::string_view silent_redirect = ">nul 2>&1"; -#else -constexpr std::string_view silent_redirect = ">/dev/null 2>&1"; -#endif +constexpr std::string_view silent_redirect = mcpplibs::os::is_windows ? ">nul 2>&1" : ">/dev/null 2>&1"; } // namespace mcpp::platform::shell @@ -124,11 +119,7 @@ std::string quote_windows(std::string_view s) { } std::string paste_arg(std::string_view s) { -#if defined(_WIN32) - constexpr bool is_windows = true; -#else - constexpr bool is_windows = false; -#endif + constexpr bool is_windows = mcpplibs::os::is_windows; const bool plain = !s.empty() && std::ranges::all_of(s, [](unsigned char c) { return std::isalnum(c) || c == '/' || c == '.' || c == '_' || c == '-' || c == ':' || c == '@' || c == '+' || c == '=' || c == ',' || c == '%' || c == '~' @@ -177,19 +168,13 @@ std::string quote_windows_through_cmd(std::string_view s) { } std::string quote(std::string_view s) { -#if defined(_WIN32) - return quote_windows(s); -#else - return quote_posix(s); -#endif + if constexpr (mcpplibs::os::is_windows) return quote_windows(s); + else return quote_posix(s); } std::string quote_through_shell(std::string_view s) { -#if defined(_WIN32) - return quote_windows_through_cmd(s); -#else - return quote_posix(s); -#endif + if constexpr (mcpplibs::os::is_windows) return quote_windows_through_cmd(s); + else return quote_posix(s); } } // namespace mcpp::platform::shell diff --git a/modules/platform/src/terminal.cppm b/modules/platform/src/terminal.cppm index fcdc7060..5c960d7f 100644 --- a/modules/platform/src/terminal.cppm +++ b/modules/platform/src/terminal.cppm @@ -11,39 +11,14 @@ // KeyInput — keys read from the terminal while a game plays module; -#include -#include -#include -#if defined(__unix__) || defined(__APPLE__) -#include -#include -#include -#include -#endif -#if defined(_WIN32) -#include // _dup, _dup2, _close, _get_osfhandle, _fileno -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include // SetHandleInformation, GetConsoleMode, WriteConsoleW -#ifndef ENABLE_VIRTUAL_TERMINAL_PROCESSING // older SDK and MinGW headers -#define ENABLE_VIRTUAL_TERMINAL_PROCESSING 0x0004 -#endif -#else -#include // dup2, close -#include // fcntl, F_DUPFD_CLOEXEC -#endif +#include // stdout, stderr: macros, which `import std` does not carry export module mcpp.platform.terminal; import std; -import mcpp.platform.unix.bounded_process; -import mcpp.platform.unix.terminal; -import mcpp.platform.windows; import mcpp.platform.common; +import mcpplibs.os.terminal; +import mcpplibs.os.stdio; export namespace mcpp::platform::terminal { @@ -187,45 +162,47 @@ private: } // namespace mcpp::platform::terminal +// ─── Each system's own (posix/terminal.cpp, windows/terminal.cpp) ───────── +// +// Not exported. Whether a stream is a terminal, its size, its control +// sequences and UTF-8 text on a console are mcpplibs.os's; what remains is +// what that library does not carry. +namespace mcpp::platform::terminal::native { +// Whether standard output and standard error reach one terminal, both being +// terminals: the same device on POSIX, consoles on Windows. +bool same_terminal(); +// The console's output code page; 0 where a stream is not a console, and on +// every system without one. +unsigned console_output_code_page(Stream s); +// Writes all of `text` to the C runtime's descriptor `fd`; false when a write +// fails. +bool write_descriptor(int fd, std::string_view text); +// A descriptor for standard output that a started program does not inherit, +// standard output then made a copy of standard error; -1 when it cannot be. +int divert_stdout(); +// Standard output put back from `saved`, which is closed. +void restore_stdout(int saved); +} // namespace mcpp::platform::terminal::native + namespace mcpp::platform::terminal { +namespace os = mcpplibs::os; + namespace { std::FILE* file_of(Stream s) { return s == Stream::Out ? stdout : stderr; } - -#if defined(_WIN32) -HANDLE handle_of(Stream s) { - const auto h = reinterpret_cast(::_get_osfhandle(::_fileno(file_of(s)))); - return h == nullptr ? INVALID_HANDLE_VALUE : h; -} - -bool console_of(Stream s, HANDLE* out = nullptr) { - const auto h = handle_of(s); - DWORD mode = 0; - if (h == INVALID_HANDLE_VALUE || !::GetConsoleMode(h, &mode)) return false; - if (out) *out = h; - return true; +os::terminal::stream stream_of(Stream s) { + return s == Stream::Out ? os::terminal::stream::out : os::terminal::stream::err; } -#endif } // namespace bool is_terminal(Stream s) { -#if defined(_WIN32) - return console_of(s); -#elif defined(__unix__) || defined(__APPLE__) - return ::isatty(::fileno(file_of(s))) != 0; -#else - (void)s; - return false; -#endif + return os::terminal::interactive(stream_of(s)); } bool input_is_terminal() { - if constexpr (mcpp::platform::is_windows) - return mcpp::platform::windows::input_is_terminal(); - else - return mcpp::platform::unix_terminal::input_is_terminal(); + return os::terminal::interactive(os::terminal::stream::in); } bool is_tty() { return is_terminal(Stream::Out); } @@ -234,16 +211,10 @@ bool can_move_cursor(Stream s) { if (!is_terminal(s)) return false; if (const char* term = std::getenv("TERM"); term && std::string_view(term) == "dumb") return false; -#if defined(_WIN32) - HANDLE h; - if (!console_of(s, &h)) return false; - DWORD mode = 0; - if (!::GetConsoleMode(h, &mode)) return false; - if (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) return true; - return ::SetConsoleMode(h, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; -#else - return true; -#endif + // A POSIX terminal is taken to obey them; a console is asked, and is + // switched to virtual terminal processing by the asking. + if constexpr (mcpp::platform::is_windows) return os::terminal::sequences(stream_of(s)); + else return true; } namespace { @@ -256,52 +227,34 @@ std::size_t from_env(const char* name, std::size_t fallback) { } // namespace std::size_t cols(Stream s) { -#if defined(_WIN32) - CONSOLE_SCREEN_BUFFER_INFO info{}; - if (HANDLE h; console_of(s, &h) && ::GetConsoleScreenBufferInfo(h, &info)) - return static_cast(info.srWindow.Right - info.srWindow.Left + 1); -#elif defined(__unix__) || defined(__APPLE__) - struct winsize w{}; - if (::ioctl(::fileno(file_of(s)), TIOCGWINSZ, &w) == 0 && w.ws_col > 0) - return w.ws_col; -#else - (void)s; -#endif + if (const auto size = os::terminal::size(stream_of(s)); size && size->columns > 0) + return size->columns; return from_env("COLUMNS", 80); } std::size_t rows(Stream s) { -#if defined(_WIN32) - CONSOLE_SCREEN_BUFFER_INFO info{}; - if (HANDLE h; console_of(s, &h) && ::GetConsoleScreenBufferInfo(h, &info)) - return static_cast(info.srWindow.Bottom - info.srWindow.Top + 1); -#elif defined(__unix__) || defined(__APPLE__) - struct winsize w{}; - if (::ioctl(::fileno(file_of(s)), TIOCGWINSZ, &w) == 0 && w.ws_row > 0) - return w.ws_row; -#else - (void)s; -#endif + if (const auto size = os::terminal::size(stream_of(s)); size && size->rows > 0) + return size->rows; return from_env("LINES", 24); } +namespace { +// The whole of `bytes` to the stream through mcpplibs.os, which gives a +// console UTF-16 (openkal K10): text outside ASCII appears as written +// whatever the console's code page is. +bool write_through_os(Stream s, std::string_view bytes) { + return (s == Stream::Out ? os::stdio::write_output(bytes) : os::stdio::write_error(bytes)).has_value(); +} +} // namespace + void write(Stream s, std::string_view text) { if (text.empty()) return; -#if defined(_WIN32) - if (HANDLE h; console_of(s, &h)) { - std::fflush(file_of(s)); - const int n = ::MultiByteToWideChar(CP_UTF8, 0, text.data(), - static_cast(text.size()), nullptr, 0); - if (n > 0) { - std::wstring wide(static_cast(n), L'\0'); - ::MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), - wide.data(), n); - DWORD written = 0; - if (::WriteConsoleW(h, wide.data(), static_cast(wide.size()), &written, nullptr)) - return; + if constexpr (mcpp::platform::is_windows) { + if (is_terminal(s)) { + std::fflush(file_of(s)); + if (write_through_os(s, text)) return; } } -#endif std::fwrite(text.data(), 1, text.size(), file_of(s)); } @@ -309,63 +262,22 @@ void write_frame(Stream s, std::string_view bytes) { if (bytes.empty()) return; std::fflush(stdout); std::fflush(stderr); -#if defined(_WIN32) - if (HANDLE h; console_of(s, &h)) { - const int n = ::MultiByteToWideChar(CP_UTF8, 0, bytes.data(), - static_cast(bytes.size()), nullptr, 0); - if (n > 0) { - std::wstring wide(static_cast(n), L'\0'); - ::MultiByteToWideChar(CP_UTF8, 0, bytes.data(), static_cast(bytes.size()), - wide.data(), n); - DWORD written = 0; - if (::WriteConsoleW(h, wide.data(), static_cast(wide.size()), &written, nullptr)) - return; - } - } - std::fwrite(bytes.data(), 1, bytes.size(), file_of(s)); - std::fflush(file_of(s)); -#elif defined(__unix__) || defined(__APPLE__) - const int fd = ::fileno(file_of(s)); - const char* p = bytes.data(); - std::size_t left = bytes.size(); - while (left > 0) { - const auto n = ::write(fd, p, left); - if (n < 0) { - if (errno == EINTR) continue; - return; - } - p += n; - left -= static_cast(n); - } -#else + // One write of the whole frame (repeated by the library only for a + // partial write or an interruption); on Windows only to a console, the + // C runtime's text mode keeping the line ends of anything else. + if (!mcpp::platform::is_windows || is_terminal(s)) + if (write_through_os(s, bytes)) return; std::fwrite(bytes.data(), 1, bytes.size(), file_of(s)); std::fflush(file_of(s)); -#endif } bool same_terminal() { -#if defined(_WIN32) - return console_of(Stream::Out) && console_of(Stream::Err); -#elif defined(__unix__) || defined(__APPLE__) - const int out = ::fileno(stdout), err = ::fileno(stderr); - if (::isatty(out) == 0 || ::isatty(err) == 0) return false; - struct stat a{}, b{}; - if (::fstat(out, &a) != 0 || ::fstat(err, &b) != 0) return false; - return a.st_rdev == b.st_rdev; -#else - return false; -#endif + return native::same_terminal(); } bool ambiguous_wide(Stream s) { -#if defined(_WIN32) - if (console_of(s)) { - const UINT cp = ::GetConsoleOutputCP(); - if (cp == 932 || cp == 936 || cp == 949 || cp == 950) return true; - } -#else - (void)s; -#endif + const unsigned cp = native::console_output_code_page(s); + if (cp == 932 || cp == 936 || cp == 949 || cp == 950) return true; for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { const char* v = std::getenv(name); if (!v || !*v) continue; @@ -376,34 +288,21 @@ bool ambiguous_wide(Stream s) { } bool unicode_capable() { -#if defined(_WIN32) - for (const char* name : {"WT_SESSION", "TERM_PROGRAM"}) - if (const char* v = std::getenv(name); v && *v) return true; - return false; -#else - for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { - const char* v = std::getenv(name); - if (!v || !*v) continue; - std::string l(v); - for (auto& c : l) c = static_cast(std::tolower(static_cast(c))); - return l.find("utf-8") != std::string::npos || l.find("utf8") != std::string::npos; + if constexpr (mcpp::platform::is_windows) { + for (const char* name : {"WT_SESSION", "TERM_PROGRAM"}) + if (const char* v = std::getenv(name); v && *v) return true; + return false; + } else { + for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { + const char* v = std::getenv(name); + if (!v || !*v) continue; + std::string l(v); + for (auto& c : l) c = static_cast(std::tolower(static_cast(c))); + return l.find("utf-8") != std::string::npos || l.find("utf8") != std::string::npos; + } + return false; } - return false; -#endif -} - -namespace { - -#if defined(_WIN32) -HANDLE g_inputHandle = INVALID_HANDLE_VALUE; -DWORD g_inputMode = 0; -BOOL WINAPI restore_input_mode(DWORD) { - if (g_inputHandle != INVALID_HANDLE_VALUE) ::SetConsoleMode(g_inputHandle, g_inputMode); - return FALSE; // the next handler, then the default action, still run } -#endif - -} // namespace std::vector decode_keys(std::string& pending) { std::vector keys; @@ -445,77 +344,7 @@ std::vector decode_keys(std::string& pending) { return keys; } -KeyInput::KeyInput(Stream display) { - if (!is_terminal(display)) return; -#if defined(_WIN32) - const HANDLE h = ::GetStdHandle(STD_INPUT_HANDLE); - DWORD mode = 0; - if (h == INVALID_HANDLE_VALUE || !::GetConsoleMode(h, &mode)) return; - g_inputHandle = h; - g_inputMode = mode; - savedMode_ = mode; - ::SetConsoleCtrlHandler(restore_input_mode, TRUE); - // ENABLE_PROCESSED_INPUT stays: Ctrl-C still interrupts. - if (!::SetConsoleMode(h, mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT))) return; - active_ = true; -#elif defined(__unix__) || defined(__APPLE__) - if (::isatty(0) == 0 || ::tcgetpgrp(0) != ::getpgrp()) return; - struct termios mode{}; - if (::tcgetattr(0, &mode) != 0) return; - mcpp::platform::unixproc::guard_terminal_mode(0); - struct termios raw = mode; - raw.c_lflag &= static_cast(~(ICANON | ECHO)); // ISIG stays: Ctrl-C interrupts - raw.c_cc[VMIN] = 0; - raw.c_cc[VTIME] = 0; - if (::tcsetattr(0, TCSANOW, &raw) != 0) { - mcpp::platform::unixproc::unguard_terminal_mode(); - return; - } - active_ = true; -#endif -} - -KeyInput::~KeyInput() { - if (!active_) return; -#if defined(_WIN32) - ::SetConsoleMode(g_inputHandle, static_cast(savedMode_)); - ::SetConsoleCtrlHandler(restore_input_mode, FALSE); - g_inputHandle = INVALID_HANDLE_VALUE; -#elif defined(__unix__) || defined(__APPLE__) - mcpp::platform::unixproc::unguard_terminal_mode(); -#endif -} - -std::vector KeyInput::read() { - std::vector keys; - if (!active_) return keys; -#if defined(_WIN32) - DWORD n = 0; - while (::GetNumberOfConsoleInputEvents(g_inputHandle, &n) && n > 0) { - INPUT_RECORD rec{}; - DWORD got = 0; - if (!::ReadConsoleInputW(g_inputHandle, &rec, 1, &got) || got == 0) break; - if (rec.EventType != KEY_EVENT || !rec.Event.KeyEvent.bKeyDown) continue; - switch (rec.Event.KeyEvent.wVirtualKeyCode) { - case VK_UP: case 'W': keys.push_back(Key::Up); break; - case VK_DOWN: case 'S': keys.push_back(Key::Down); break; - case VK_LEFT: case 'A': keys.push_back(Key::Left); break; - case VK_RIGHT: case 'D': keys.push_back(Key::Right); break; - case VK_SPACE: keys.push_back(Key::Space); break; - default: break; - } - } -#elif defined(__unix__) || defined(__APPLE__) - char buf[64]; - while (true) { - const auto n = ::read(0, buf, sizeof buf); // VMIN 0, VTIME 0: never blocks - if (n <= 0) break; - pending_.append(buf, static_cast(n)); - } - keys = decode_keys(pending_); -#endif - return keys; -} +// KeyInput's constructor, destructor and read: each system's own. namespace { std::atomic g_protocolStdout{-1}; @@ -528,36 +357,12 @@ void write_protocol_stdout(std::string_view text) { std::fflush(stdout); return; } - std::size_t done = 0; - while (done < text.size()) { -#if defined(_WIN32) - const int n = ::_write(fd, text.data() + done, static_cast(text.size() - done)); -#else - const auto n = ::write(fd, text.data() + done, text.size() - done); -#endif - if (n <= 0) return; - done += static_cast(n); - } + native::write_descriptor(fd, text); } StdoutToStderr::StdoutToStderr() { std::fflush(stdout); -#if defined(_WIN32) - saved_ = ::_dup(1); - if (saved_ >= 0) { - // `_dup` duplicates the handle as inheritable, and CreateProcess with - // handle inheritance (every `_popen` and `system`) passes it on. - const auto h = reinterpret_cast(::_get_osfhandle(saved_)); - if (h != INVALID_HANDLE_VALUE) - ::SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0); - ::_dup2(2, 1); - } -#else - // Not `dup`: its copy survives exec. Descriptor 3 or above, as `dup` - // would have chosen, so nothing else about the redirection moves. - saved_ = ::fcntl(1, F_DUPFD_CLOEXEC, 3); - if (saved_ >= 0) ::dup2(2, 1); -#endif + saved_ = native::divert_stdout(); if (saved_ >= 0) { int expected = -1; g_protocolStdout.compare_exchange_strong(expected, saved_); @@ -569,13 +374,7 @@ StdoutToStderr::~StdoutToStderr() { if (saved_ < 0) return; int expected = saved_; g_protocolStdout.compare_exchange_strong(expected, -1); -#if defined(_WIN32) - ::_dup2(saved_, 1); - ::_close(saved_); -#else - ::dup2(saved_, 1); - ::close(saved_); -#endif + native::restore_stdout(saved_); } } // namespace mcpp::platform::terminal diff --git a/modules/platform/src/unix/terminal.cppm b/modules/platform/src/unix/terminal.cppm deleted file mode 100644 index 1bf09758..00000000 --- a/modules/platform/src/unix/terminal.cppm +++ /dev/null @@ -1,22 +0,0 @@ -// POSIX terminal bindings. The portable terminal module selects the platform. -module; -#if defined(__unix__) || defined(__APPLE__) -#include -#endif - -export module mcpp.platform.unix.terminal; - -export namespace mcpp::platform::unix_terminal { -// No std types cross this widely imported backend boundary (GCC 16). -bool input_is_terminal(); -} - -namespace mcpp::platform::unix_terminal { -bool input_is_terminal() { -#if defined(__unix__) || defined(__APPLE__) - return ::isatty(STDIN_FILENO) != 0; -#else - return false; -#endif -} -} diff --git a/modules/platform/src/windows.cppm b/modules/platform/src/windows.cppm new file mode 100644 index 00000000..375ba486 --- /dev/null +++ b/modules/platform/src/windows.cppm @@ -0,0 +1,51 @@ +// mcpp.platform.windows — Windows-specific platform capabilities. +// +// Provides: +// prepend_path() — add a directory to the front of %PATH% +// active_code_page() — the process ANSI code page (GetACP) +// +// Note: Visual Studio / MSVC discovery is in mcpp.toolchain.msvc, which is +// the authoritative module for MSVC toolchain detection. This module +// provides general-purpose Windows platform utilities only. + +export module mcpp.platform.windows; + +import std; + +export namespace mcpp::platform::windows { + +// Prepend a directory to the %PATH% environment variable. +void prepend_path(const std::filesystem::path& dir); + +// The ANSI code page of this process: 65001 when the UTF-8 code page that +// mcpp.exe declares is in effect (Windows 10 version 1903 and later), the +// system's legacy code page otherwise. 0 on every other platform, which has +// no process code page. Every narrow string mcpp exchanges with Win32 is in +// this code page (#693). +unsigned active_code_page(); + +// A string value under HKEY_LOCAL_MACHINE, read from both registry views (the +// 64-bit one first, then WOW6432Node's), as a path. An installer records its +// installation directory there wherever it put it -- the Windows SDK's +// `Installed Roots\KitsRoot10` -- so a search that knows only `C:\Program +// Files` misses an SDK on another drive (D15). nullopt when absent and on +// every other platform. +std::optional machine_registry_path(std::wstring_view subkey, + std::wstring_view value); + +// A directory a known-folder environment variable names (`ProgramFiles`, +// `ProgramFiles(x86)`), or nullopt. The system drive is not always `C:`. +std::optional env_directory(const char* name); + +// Whether the system lets a program that declares itself `longPathAware` use +// paths past MAX_PATH (260): HKLM\SYSTEM\CurrentControlSet\Control\FileSystem +// `LongPathsEnabled` = 1. false when it is 0 or absent, and on every other +// platform, which has no such limit. +bool long_paths_enabled(); +// Used only by the first-run Windows home suggestion; never creates the +// actual home. An existing child must be a plain directory: inspecting its +// reparse-point attributes avoids following a potentially remote target. +// Other platforms answer false without touching a filesystem. +bool writable_fixed_drive(char drive, std::wstring_view child); + +} // namespace mcpp::platform::windows diff --git a/modules/platform/src/windows/bounded_process.cpp b/modules/platform/src/windows/bounded_process.cpp new file mode 100644 index 00000000..4d04810b --- /dev/null +++ b/modules/platform/src/windows/bounded_process.cpp @@ -0,0 +1,568 @@ +// mcpp.platform.windows.bounded_process on Windows. +module; +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include // fputs, stderr — the out-of-slots diagnostic + +module mcpp.platform.windows.bounded_process; + +import std; + +namespace mcpp::platform::winproc { + +namespace { + +// A `\0`-separated, `\0\0`-terminated block: the current environment with +// `envEntries` applied on top. +// +// The override match is case-INSENSITIVE because Windows environment names +// are: passing `Path=` alongside an existing `PATH=` would otherwise leave two +// entries and let the loader pick. +std::string environment_block(const char* const* envEntries, + unsigned long envCount) { + auto upper = [](std::string s) { + for (auto& c : s) + c = static_cast(std::toupper(static_cast(c))); + return s; + }; + + std::vector overriddenUpper; + overriddenUpper.reserve(envCount); + for (unsigned long i = 0; i < envCount; ++i) { + std::string entry(envEntries[i]); + auto eq = entry.find('='); + overriddenUpper.push_back(upper(entry.substr(0, eq == std::string::npos + ? entry.size() : eq))); + } + + std::vector entries; + if (LPCH env = ::GetEnvironmentStringsA()) { + for (const char* p = env; *p; ) { + std::string entry(p); + p += entry.size() + 1; + // Drive-letter entries ("=C:=C:\path") start with '=' and have no + // name to compare; they must survive verbatim or relative paths + // resolve differently in the child. + auto eq = entry.find('=', 1); + if (eq != std::string::npos) { + if (std::ranges::find(overriddenUpper, upper(entry.substr(0, eq))) + != overriddenUpper.end()) + continue; + } + entries.push_back(std::move(entry)); + } + ::FreeEnvironmentStringsA(env); + } + for (unsigned long i = 0; i < envCount; ++i) + entries.emplace_back(envEntries[i]); + + std::string block; + for (auto const& e : entries) { block += e; block.push_back('\0'); } + // An empty block still needs its terminator or CreateProcess reads past + // the buffer. + block.push_back('\0'); + return block; +} + +// The one section every child start shares (see LaunchSection). +std::mutex g_launchMutex; + +struct Handle { + HANDLE h = nullptr; + Handle() = default; + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + ~Handle() { reset(); } + void reset() { + if (h && h != INVALID_HANDLE_VALUE) ::CloseHandle(h); + h = nullptr; + } +}; + +} // namespace + +LaunchSection::LaunchSection() { + g_launchMutex.lock(); + held_ = true; +} + +LaunchSection::~LaunchSection() { release(); } + +void LaunchSection::release() { + if (!held_) return; + held_ = false; + g_launchMutex.unlock(); +} + +DeadlineRun capture_with_deadline(const char* commandLine, + const char* const* envEntries, + unsigned long envCount, + const char* cwd, + long long deadlineMs, + long long idleMs, + OutputSink sink, + void* ctx, + StopPoll stop) +{ + DeadlineRun out; + const bool idleBound = idleMs > 0 && sink != nullptr; + // A child with no bound at all is accepted only with a sink, as on POSIX. + if ((deadlineMs <= 0 && !idleBound && sink == nullptr) || !commandLine || !*commandLine) + return out; + + const bool capture = (sink != nullptr); + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + // From the creation of the pipe to the parent's close of its write end: no + // other child may be started while this pipe is inheritable. + LaunchSection launch; + Handle readEnd, writeEnd; + if (capture) { + if (!::CreatePipe(&readEnd.h, &writeEnd.h, &sa, 0)) return out; + // Only the WRITE end may cross into the child. An inheritable read end + // there would keep the pipe alive past the child's exit and the drain + // below would never see EOF. + if (!::SetHandleInformation(readEnd.h, HANDLE_FLAG_INHERIT, 0)) return out; + } + + Handle job; + job.h = ::CreateJobObjectA(nullptr, nullptr); + if (job.h) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; + jeli.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job.h, JobObjectExtendedLimitInformation, + &jeli, sizeof(jeli)); + } + + // A CAPTURED child gets stdin from NUL, never from the console. + // + // This is not tidiness: mcpp's Windows launchers have always sealed stdin + // (see this file's peer, mcpp.platform.process, and the bug it names — + // xlings / xim / curl / git children blocking on terminal input during + // bootstrap, forcing the user to hammer Enter). The path this replaces + // went through `_popen` with `< NUL` appended, so inheriting the console's + // stdin here would quietly bring that hang back. + // + // An UNCAPTURED child keeps the real stdin, matching run_exec: `mcpp run` + // hands the terminal to the program on purpose. + Handle nulIn; + if (capture) { + nulIn.h = ::CreateFileA("NUL", GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, 0, nullptr); + } + + STARTUPINFOA si{}; + si.cb = sizeof(si); + if (capture) { + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = writeEnd.h; + si.hStdError = writeEnd.h; + // If NUL could not be opened, fall back to the console handle rather + // than handing the child an invalid one — a child with no stdin at all + // fails in ways that look nothing like "stdin was not sealed". + si.hStdInput = (nulIn.h && nulIn.h != INVALID_HANDLE_VALUE) + ? nulIn.h + : ::GetStdHandle(STD_INPUT_HANDLE); + } + // else: no STARTF_USESTDHANDLES — the child inherits our console. + + PROCESS_INFORMATION pi{}; + std::string cmdBuf(commandLine); // CreateProcessA may modify it + auto envBlock = environment_block(envEntries, envCount); + + // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn + // anything — a grandchild created in that gap would escape the kill. + // CREATE_NO_WINDOW only when capturing: an uncaptured run is meant to be + // seen, and suppressing the console for it would hide the output this + // branch exists to show. + BOOL ok = ::CreateProcessA( + nullptr, cmdBuf.data(), nullptr, nullptr, /*bInheritHandles=*/TRUE, + CREATE_SUSPENDED | (capture ? CREATE_NO_WINDOW : 0u), + envBlock.data(), + (cwd && *cwd) ? cwd : nullptr, + &si, &pi); + if (!ok) { out.spawn_error = static_cast(::GetLastError()); return out; } + + Handle proc; proc.h = pi.hProcess; + Handle thread; thread.h = pi.hThread; + if (job.h) ::AssignProcessToJobObject(job.h, proc.h); + ::ResumeThread(thread.h); + + // The parent must drop its copy of the write end or the pipe never reaches + // EOF, even after every child has exited. + writeEnd.reset(); + launch.release(); + + const auto started = std::chrono::steady_clock::now(); + const auto until = deadlineMs > 0 + ? started + std::chrono::milliseconds(deadlineMs) + : std::chrono::steady_clock::time_point::max(); + auto lastOutput = started; + std::array buf{}; + bool killed = false; + + auto drain_available = [&]() -> bool { + if (!capture) return false; + DWORD avail = 0; + if (!::PeekNamedPipe(readEnd.h, nullptr, 0, nullptr, &avail, nullptr)) + return false; + if (avail == 0) return false; + DWORD want = static_cast( + std::min(buf.size(), static_cast(avail))); + DWORD got = 0; + if (!::ReadFile(readEnd.h, buf.data(), want, &got, nullptr) || got == 0) + return false; + if (sink) sink(ctx, buf.data(), static_cast(got)); + lastOutput = std::chrono::steady_clock::now(); + return true; + }; + + for (;;) { + // A stream of heartbeats cannot defer a caller's progress deadline. + if (!killed && (std::chrono::steady_clock::now() >= until || (stop && stop(ctx)))) { + killed = true; + if (job.h) job.reset(); + else ::TerminateProcess(proc.h, 1); + } + if (drain_available()) continue; // empty the pipe before sleeping + + if (::WaitForSingleObject(proc.h, 0) == WAIT_OBJECT_0) { + while (drain_available()) { /* tail */ } + break; + } + + const auto now = std::chrono::steady_clock::now(); + const bool overIdle = idleBound + && now - lastOutput >= std::chrono::milliseconds(idleMs); + if (!killed && (now >= until || overIdle)) { + killed = true; + // Closing the job takes the whole tree with it. TerminateProcess + // alone would leave grandchildren holding the pipe open. + if (job.h) job.reset(); + else ::TerminateProcess(proc.h, 1); + ::WaitForSingleObject(proc.h, 5000); + continue; + } + + ::Sleep(20); + } + + DWORD code = 0; + ::GetExitCodeProcess(proc.h, &code); + out.exit_code = static_cast(code); + out.timed_out = killed; + out.supported = true; + return out; +} + +// ─── Background children ───────────────────────────────────────────────── + +namespace { + +// Read by a console control handler, which runs on a thread of the OS's +// choosing. Only the handle is shared, and closing a job handle is atomic from +// the caller's point of view. +constexpr int kMaxGuardedJobs = 256; +volatile unsigned long long g_guardedJobs[kMaxGuardedJobs] = {}; +// Serializes the WRITERS of the registry and of the handler it installs; the +// console handler never takes it. +std::mutex g_guardMutex; + +// The files an interrupt leaves; every set lives until exit (the POSIX peer +// states why). +struct InterruptFiles { + std::vector paths, temps, contents; +}; +std::atomic g_interrupt{nullptr}; +std::vector> g_interruptSets; + +void write_interrupt_files() { + auto* set = g_interrupt.load(); + if (!set) return; + for (std::size_t i = 0; i < set->paths.size(); ++i) { + HANDLE f = ::CreateFileA(set->temps[i].c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (f == INVALID_HANDLE_VALUE) continue; + DWORD written = 0; + const BOOL ok = ::WriteFile(f, set->contents[i].data(), + static_cast(set->contents[i].size()), &written, nullptr); + ::CloseHandle(f); + if (ok && written == set->contents[i].size()) + ::MoveFileExA(set->temps[i].c_str(), set->paths[i].c_str(), MOVEFILE_REPLACE_EXISTING); + else + ::DeleteFileA(set->temps[i].c_str()); + } +} + +bool handler_needed() { + if (g_interrupt.load()) return true; + for (int i = 0; i < kMaxGuardedJobs; ++i) + if (g_guardedJobs[i] != 0) return true; + return false; +} + +BOOL WINAPI background_console_handler(DWORD) { + // TerminateJobObject, not CloseHandle: this handler races `background_stop` + // on the normal path, and terminating is idempotent while closing the same + // handle twice is not. The handle stays valid for whoever closes it. + for (int i = 0; i < kMaxGuardedJobs; ++i) { + const auto job = g_guardedJobs[i]; + if (job) + ::TerminateJobObject( + reinterpret_cast(static_cast(job)), 1); + } + write_interrupt_files(); + return FALSE; // FALSE = also run the default handler, i.e. still exit +} + +} // namespace + +BackgroundChild spawn_background(const char* commandLine, + const char* cwd, + int inheritStdio) +{ + BackgroundChild out; + if (!commandLine || !*commandLine) return out; + + HANDLE job = ::CreateJobObjectA(nullptr, nullptr); + if (job) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; + jeli.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &jeli, sizeof(jeli)); + } + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + Handle nulIo; + STARTUPINFOA si{}; + si.cb = sizeof(si); + if (!inheritStdio) { + nulIo.h = ::CreateFileA("NUL", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, 0, nullptr); + if (nulIo.h && nulIo.h != INVALID_HANDLE_VALUE) { + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = nulIo.h; + si.hStdOutput = nulIo.h; + si.hStdError = nulIo.h; + } + } + + PROCESS_INFORMATION pi{}; + std::string cmdBuf(commandLine); // CreateProcessA may modify it + + // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn anything + // — a grandchild created in that gap would escape the kill, which for a + // background player is the difference between "stops" and "plays forever". + // + // CREATE_NEW_PROCESS_GROUP is the peer of POSIX_SPAWN_SETPGROUP: the child + // stops receiving the console's Ctrl-C, which is what makes the guard + // below necessary and what stops a stray Ctrl-C from half-killing the tree. + BOOL ok = FALSE; + { + // This child inherits every inheritable handle, another thread's + // capture pipe included, unless it is started outside the window that + // pipe is open in (see LaunchSection). + LaunchSection launch; + ok = ::CreateProcessA( + nullptr, cmdBuf.data(), nullptr, nullptr, + /*bInheritHandles=*/TRUE, + CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP + | (inheritStdio ? 0u : CREATE_NO_WINDOW), + nullptr, (cwd && *cwd) ? cwd : nullptr, &si, &pi); + } + if (!ok) { + out.refused = ::GetLastError(); + if (job) ::CloseHandle(job); + return out; + } + if (job) ::AssignProcessToJobObject(job, pi.hProcess); + ::ResumeThread(pi.hThread); + ::CloseHandle(pi.hThread); + + out.ok = true; + out.job = static_cast( + reinterpret_cast(job)); + out.process = static_cast( + reinterpret_cast(pi.hProcess)); + return out; +} + +int background_running(unsigned long long process, int* exitCode) { + if (!process) return -1; + auto h = reinterpret_cast(static_cast(process)); + const DWORD r = ::WaitForSingleObject(h, 0); + if (r == WAIT_TIMEOUT) return 1; + if (r != WAIT_OBJECT_0) return -1; + if (exitCode) { + DWORD code = 0; + ::GetExitCodeProcess(h, &code); + *exitCode = static_cast(code); + } + return 0; +} + +void background_stop(unsigned long long job, unsigned long long process, + long long graceMs) +{ + auto procH = process ? reinterpret_cast( + static_cast(process)) + : nullptr; + auto jobH = job ? reinterpret_cast( + static_cast(job)) + : nullptr; + + // NO POLITE ASK HERE, AND `graceMs` IS DELIBERATELY UNSPENT. + // + // The obvious "ask first" is + // + // ::GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, ::GetProcessId(procH)); + // + // and it is wrong in a way that does not show up locally. That call + // addresses a process GROUP attached to this console, not a process; when + // the id does not name a live group of ours — which it does not, once the + // child has already exited, and `start /b`-style commands exit at once — + // the event reaches everything sharing the console instead. Measured on + // the Windows e2e runner: the whole suite died eleven seconds into the + // hooks test with exit code -1073741510 (0xC000013A, + // STATUS_CONTROL_C_EXIT) and printed no summary at all, because mcpp had + // Ctrl-Break'd its own console. + // + // Windows has no portable graceful stop for a child with no console and no + // window of its own. The job IS the mechanism; the POSIX peer's + // SIGTERM-then-grace has no equivalent here, and the asymmetry is stated + // in the declaration rather than faked with a call that reaches too far. + (void)graceMs; + + // Closing a KILL_ON_JOB_CLOSE job takes the whole tree. TerminateProcess + // on the child alone would leave whatever it started behind — which for + // `start /b cmd /c player` is the player. + if (jobH) ::CloseHandle(jobH); + if (procH) ::CloseHandle(procH); +} + +void guard_job_on_signal(unsigned long long job) { + if (!job) return; + { + std::lock_guard lock(g_guardMutex); + for (int i = 0; i < kMaxGuardedJobs; ++i) { + if (g_guardedJobs[i] == 0) { + g_guardedJobs[i] = job; + ::SetConsoleCtrlHandler(background_console_handler, TRUE); + return; + } + } + } + std::fputs("mcpp: internal: more than 256 concurrently guarded jobs; the " + "newest is NOT guarded and may outlive mcpp\n", stderr); +} + +void unguard_job(unsigned long long job) { + if (!job) return; + std::lock_guard lock(g_guardMutex); + for (int i = 0; i < kMaxGuardedJobs; ++i) + if (g_guardedJobs[i] == job) g_guardedJobs[i] = 0; + if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); +} + +void clear_job_guard() { + std::lock_guard lock(g_guardMutex); + for (int i = 0; i < kMaxGuardedJobs; ++i) g_guardedJobs[i] = 0; + if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); +} + +void set_interrupt_files(const std::vector& paths, + const std::vector& temps, + const std::vector& contents) { + if (paths.empty() || paths.size() > 4 || temps.size() != paths.size() + || contents.size() != paths.size()) + return; + std::lock_guard lock(g_guardMutex); + g_interruptSets.push_back(std::make_unique(InterruptFiles{paths, temps, contents})); + g_interrupt.store(g_interruptSets.back().get()); + ::SetConsoleCtrlHandler(background_console_handler, TRUE); +} + +void clear_interrupt_files() { + std::lock_guard lock(g_guardMutex); + g_interrupt.store(nullptr); + if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); +} + +namespace { +BOOL WINAPI ignore_interrupt(DWORD type) { + return (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT) ? TRUE : FALSE; +} +} // namespace + +int run_foreground(const char* commandLine, unsigned long* refused) { + if (refused) *refused = 0; + if (!commandLine || !*commandLine) return -1; + + HANDLE job = ::CreateJobObjectA(nullptr, nullptr); + if (job) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; + jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &jeli, sizeof(jeli)); + } + + STARTUPINFOA si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + std::string cmdBuf(commandLine); // CreateProcessA may modify it + + // A handler, not SetConsoleCtrlHandler(nullptr, TRUE): the null form sets + // an attribute the child inherits, and the child would then ignore the + // Ctrl-C it is meant to receive. + ::SetConsoleCtrlHandler(ignore_interrupt, TRUE); + BOOL ok = FALSE; + { + LaunchSection launch; + // No CREATE_NEW_PROCESS_GROUP: that flag is what keeps the console's + // Ctrl-C from the child. CREATE_SUSPENDED so the child joins the job + // before it can start anything. + ok = ::CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, + /*bInheritHandles=*/TRUE, CREATE_SUSPENDED, + nullptr, nullptr, &si, &pi); + } + if (!ok) { + if (refused) *refused = ::GetLastError(); + if (job) ::CloseHandle(job); + ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); + return -1; + } + if (job) ::AssignProcessToJobObject(job, pi.hProcess); + ::ResumeThread(pi.hThread); + ::CloseHandle(pi.hThread); + + ::WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 0; + ::GetExitCodeProcess(pi.hProcess, &code); + ::CloseHandle(pi.hProcess); + if (job) ::CloseHandle(job); + ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); + return static_cast(code); +} + +int wait_background(unsigned long long process, int* exitCode) { + HANDLE h = reinterpret_cast(process); + if (!h) return -1; + ::WaitForSingleObject(h, INFINITE); + DWORD code = 0; + if (!::GetExitCodeProcess(h, &code)) return -1; + if (exitCode) *exitCode = static_cast(code); + return 0; +} + +} // namespace mcpp::platform::winproc diff --git a/modules/platform/src/windows/bounded_process.cppm b/modules/platform/src/windows/bounded_process.cppm index f061ed6f..04ea34e8 100644 --- a/modules/platform/src/windows/bounded_process.cppm +++ b/modules/platform/src/windows/bounded_process.cppm @@ -44,19 +44,10 @@ // // The same constraint is why the caller does the marshalling: see // `mcpp.platform.process`. - -module; - -#if defined(_WIN32) -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#include // fputs, stderr — the out-of-slots diagnostic -#endif +// +// The declarations are the same on every system; the definitions are +// Windows's (windows/bounded_process.cpp), and a call from another system is a +// link error. export module mcpp.platform.windows.bounded_process; @@ -236,589 +227,3 @@ int wait_background(unsigned long long process, int* exitCode); int run_foreground(const char* commandLine, unsigned long* refused); } // namespace mcpp::platform::winproc - -namespace mcpp::platform::winproc { - -#if defined(_WIN32) - -namespace { - -// A `\0`-separated, `\0\0`-terminated block: the current environment with -// `envEntries` applied on top. -// -// The override match is case-INSENSITIVE because Windows environment names -// are: passing `Path=` alongside an existing `PATH=` would otherwise leave two -// entries and let the loader pick. -std::string environment_block(const char* const* envEntries, - unsigned long envCount) { - auto upper = [](std::string s) { - for (auto& c : s) - c = static_cast(std::toupper(static_cast(c))); - return s; - }; - - std::vector overriddenUpper; - overriddenUpper.reserve(envCount); - for (unsigned long i = 0; i < envCount; ++i) { - std::string entry(envEntries[i]); - auto eq = entry.find('='); - overriddenUpper.push_back(upper(entry.substr(0, eq == std::string::npos - ? entry.size() : eq))); - } - - std::vector entries; - if (LPCH env = ::GetEnvironmentStringsA()) { - for (const char* p = env; *p; ) { - std::string entry(p); - p += entry.size() + 1; - // Drive-letter entries ("=C:=C:\path") start with '=' and have no - // name to compare; they must survive verbatim or relative paths - // resolve differently in the child. - auto eq = entry.find('=', 1); - if (eq != std::string::npos) { - if (std::ranges::find(overriddenUpper, upper(entry.substr(0, eq))) - != overriddenUpper.end()) - continue; - } - entries.push_back(std::move(entry)); - } - ::FreeEnvironmentStringsA(env); - } - for (unsigned long i = 0; i < envCount; ++i) - entries.emplace_back(envEntries[i]); - - std::string block; - for (auto const& e : entries) { block += e; block.push_back('\0'); } - // An empty block still needs its terminator or CreateProcess reads past - // the buffer. - block.push_back('\0'); - return block; -} - -// The one section every child start shares (see LaunchSection). -std::mutex g_launchMutex; - -struct Handle { - HANDLE h = nullptr; - Handle() = default; - Handle(const Handle&) = delete; - Handle& operator=(const Handle&) = delete; - ~Handle() { reset(); } - void reset() { - if (h && h != INVALID_HANDLE_VALUE) ::CloseHandle(h); - h = nullptr; - } -}; - -} // namespace - -LaunchSection::LaunchSection() { - g_launchMutex.lock(); - held_ = true; -} - -LaunchSection::~LaunchSection() { release(); } - -void LaunchSection::release() { - if (!held_) return; - held_ = false; - g_launchMutex.unlock(); -} - -DeadlineRun capture_with_deadline(const char* commandLine, - const char* const* envEntries, - unsigned long envCount, - const char* cwd, - long long deadlineMs, - long long idleMs, - OutputSink sink, - void* ctx, - StopPoll stop) -{ - DeadlineRun out; - const bool idleBound = idleMs > 0 && sink != nullptr; - // A child with no bound at all is accepted only with a sink, as on POSIX. - if ((deadlineMs <= 0 && !idleBound && sink == nullptr) || !commandLine || !*commandLine) - return out; - - const bool capture = (sink != nullptr); - - SECURITY_ATTRIBUTES sa{}; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - - // From the creation of the pipe to the parent's close of its write end: no - // other child may be started while this pipe is inheritable. - LaunchSection launch; - Handle readEnd, writeEnd; - if (capture) { - if (!::CreatePipe(&readEnd.h, &writeEnd.h, &sa, 0)) return out; - // Only the WRITE end may cross into the child. An inheritable read end - // there would keep the pipe alive past the child's exit and the drain - // below would never see EOF. - if (!::SetHandleInformation(readEnd.h, HANDLE_FLAG_INHERIT, 0)) return out; - } - - Handle job; - job.h = ::CreateJobObjectA(nullptr, nullptr); - if (job.h) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; - jeli.BasicLimitInformation.LimitFlags = - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - ::SetInformationJobObject(job.h, JobObjectExtendedLimitInformation, - &jeli, sizeof(jeli)); - } - - // A CAPTURED child gets stdin from NUL, never from the console. - // - // This is not tidiness: mcpp's Windows launchers have always sealed stdin - // (see this file's peer, mcpp.platform.process, and the bug it names — - // xlings / xim / curl / git children blocking on terminal input during - // bootstrap, forcing the user to hammer Enter). The path this replaces - // went through `_popen` with `< NUL` appended, so inheriting the console's - // stdin here would quietly bring that hang back. - // - // An UNCAPTURED child keeps the real stdin, matching run_exec: `mcpp run` - // hands the terminal to the program on purpose. - Handle nulIn; - if (capture) { - nulIn.h = ::CreateFileA("NUL", GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, - OPEN_EXISTING, 0, nullptr); - } - - STARTUPINFOA si{}; - si.cb = sizeof(si); - if (capture) { - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdOutput = writeEnd.h; - si.hStdError = writeEnd.h; - // If NUL could not be opened, fall back to the console handle rather - // than handing the child an invalid one — a child with no stdin at all - // fails in ways that look nothing like "stdin was not sealed". - si.hStdInput = (nulIn.h && nulIn.h != INVALID_HANDLE_VALUE) - ? nulIn.h - : ::GetStdHandle(STD_INPUT_HANDLE); - } - // else: no STARTF_USESTDHANDLES — the child inherits our console. - - PROCESS_INFORMATION pi{}; - std::string cmdBuf(commandLine); // CreateProcessA may modify it - auto envBlock = environment_block(envEntries, envCount); - - // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn - // anything — a grandchild created in that gap would escape the kill. - // CREATE_NO_WINDOW only when capturing: an uncaptured run is meant to be - // seen, and suppressing the console for it would hide the output this - // branch exists to show. - BOOL ok = ::CreateProcessA( - nullptr, cmdBuf.data(), nullptr, nullptr, /*bInheritHandles=*/TRUE, - CREATE_SUSPENDED | (capture ? CREATE_NO_WINDOW : 0u), - envBlock.data(), - (cwd && *cwd) ? cwd : nullptr, - &si, &pi); - if (!ok) { out.spawn_error = static_cast(::GetLastError()); return out; } - - Handle proc; proc.h = pi.hProcess; - Handle thread; thread.h = pi.hThread; - if (job.h) ::AssignProcessToJobObject(job.h, proc.h); - ::ResumeThread(thread.h); - - // The parent must drop its copy of the write end or the pipe never reaches - // EOF, even after every child has exited. - writeEnd.reset(); - launch.release(); - - const auto started = std::chrono::steady_clock::now(); - const auto until = deadlineMs > 0 - ? started + std::chrono::milliseconds(deadlineMs) - : std::chrono::steady_clock::time_point::max(); - auto lastOutput = started; - std::array buf{}; - bool killed = false; - - auto drain_available = [&]() -> bool { - if (!capture) return false; - DWORD avail = 0; - if (!::PeekNamedPipe(readEnd.h, nullptr, 0, nullptr, &avail, nullptr)) - return false; - if (avail == 0) return false; - DWORD want = static_cast( - std::min(buf.size(), static_cast(avail))); - DWORD got = 0; - if (!::ReadFile(readEnd.h, buf.data(), want, &got, nullptr) || got == 0) - return false; - if (sink) sink(ctx, buf.data(), static_cast(got)); - lastOutput = std::chrono::steady_clock::now(); - return true; - }; - - for (;;) { - // A stream of heartbeats cannot defer a caller's progress deadline. - if (!killed && (std::chrono::steady_clock::now() >= until || (stop && stop(ctx)))) { - killed = true; - if (job.h) job.reset(); - else ::TerminateProcess(proc.h, 1); - } - if (drain_available()) continue; // empty the pipe before sleeping - - if (::WaitForSingleObject(proc.h, 0) == WAIT_OBJECT_0) { - while (drain_available()) { /* tail */ } - break; - } - - const auto now = std::chrono::steady_clock::now(); - const bool overIdle = idleBound - && now - lastOutput >= std::chrono::milliseconds(idleMs); - if (!killed && (now >= until || overIdle)) { - killed = true; - // Closing the job takes the whole tree with it. TerminateProcess - // alone would leave grandchildren holding the pipe open. - if (job.h) job.reset(); - else ::TerminateProcess(proc.h, 1); - ::WaitForSingleObject(proc.h, 5000); - continue; - } - - ::Sleep(20); - } - - DWORD code = 0; - ::GetExitCodeProcess(proc.h, &code); - out.exit_code = static_cast(code); - out.timed_out = killed; - out.supported = true; - return out; -} - -// ─── Background children ───────────────────────────────────────────────── - -namespace { - -// Read by a console control handler, which runs on a thread of the OS's -// choosing. Only the handle is shared, and closing a job handle is atomic from -// the caller's point of view. -constexpr int kMaxGuardedJobs = 256; -volatile unsigned long long g_guardedJobs[kMaxGuardedJobs] = {}; -// Serializes the WRITERS of the registry and of the handler it installs; the -// console handler never takes it. -std::mutex g_guardMutex; - -// The files an interrupt leaves; every set lives until exit (the POSIX peer -// states why). -struct InterruptFiles { - std::vector paths, temps, contents; -}; -std::atomic g_interrupt{nullptr}; -std::vector> g_interruptSets; - -void write_interrupt_files() { - auto* set = g_interrupt.load(); - if (!set) return; - for (std::size_t i = 0; i < set->paths.size(); ++i) { - HANDLE f = ::CreateFileA(set->temps[i].c_str(), GENERIC_WRITE, 0, nullptr, - CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); - if (f == INVALID_HANDLE_VALUE) continue; - DWORD written = 0; - const BOOL ok = ::WriteFile(f, set->contents[i].data(), - static_cast(set->contents[i].size()), &written, nullptr); - ::CloseHandle(f); - if (ok && written == set->contents[i].size()) - ::MoveFileExA(set->temps[i].c_str(), set->paths[i].c_str(), MOVEFILE_REPLACE_EXISTING); - else - ::DeleteFileA(set->temps[i].c_str()); - } -} - -bool handler_needed() { - if (g_interrupt.load()) return true; - for (int i = 0; i < kMaxGuardedJobs; ++i) - if (g_guardedJobs[i] != 0) return true; - return false; -} - -BOOL WINAPI background_console_handler(DWORD) { - // TerminateJobObject, not CloseHandle: this handler races `background_stop` - // on the normal path, and terminating is idempotent while closing the same - // handle twice is not. The handle stays valid for whoever closes it. - for (int i = 0; i < kMaxGuardedJobs; ++i) { - const auto job = g_guardedJobs[i]; - if (job) - ::TerminateJobObject( - reinterpret_cast(static_cast(job)), 1); - } - write_interrupt_files(); - return FALSE; // FALSE = also run the default handler, i.e. still exit -} - -} // namespace - -BackgroundChild spawn_background(const char* commandLine, - const char* cwd, - int inheritStdio) -{ - BackgroundChild out; - if (!commandLine || !*commandLine) return out; - - HANDLE job = ::CreateJobObjectA(nullptr, nullptr); - if (job) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; - jeli.BasicLimitInformation.LimitFlags = - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, - &jeli, sizeof(jeli)); - } - - SECURITY_ATTRIBUTES sa{}; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - - Handle nulIo; - STARTUPINFOA si{}; - si.cb = sizeof(si); - if (!inheritStdio) { - nulIo.h = ::CreateFileA("NUL", GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, - OPEN_EXISTING, 0, nullptr); - if (nulIo.h && nulIo.h != INVALID_HANDLE_VALUE) { - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdInput = nulIo.h; - si.hStdOutput = nulIo.h; - si.hStdError = nulIo.h; - } - } - - PROCESS_INFORMATION pi{}; - std::string cmdBuf(commandLine); // CreateProcessA may modify it - - // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn anything - // — a grandchild created in that gap would escape the kill, which for a - // background player is the difference between "stops" and "plays forever". - // - // CREATE_NEW_PROCESS_GROUP is the peer of POSIX_SPAWN_SETPGROUP: the child - // stops receiving the console's Ctrl-C, which is what makes the guard - // below necessary and what stops a stray Ctrl-C from half-killing the tree. - BOOL ok = FALSE; - { - // This child inherits every inheritable handle, another thread's - // capture pipe included, unless it is started outside the window that - // pipe is open in (see LaunchSection). - LaunchSection launch; - ok = ::CreateProcessA( - nullptr, cmdBuf.data(), nullptr, nullptr, - /*bInheritHandles=*/TRUE, - CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP - | (inheritStdio ? 0u : CREATE_NO_WINDOW), - nullptr, (cwd && *cwd) ? cwd : nullptr, &si, &pi); - } - if (!ok) { - out.refused = ::GetLastError(); - if (job) ::CloseHandle(job); - return out; - } - if (job) ::AssignProcessToJobObject(job, pi.hProcess); - ::ResumeThread(pi.hThread); - ::CloseHandle(pi.hThread); - - out.ok = true; - out.job = static_cast( - reinterpret_cast(job)); - out.process = static_cast( - reinterpret_cast(pi.hProcess)); - return out; -} - -int background_running(unsigned long long process, int* exitCode) { - if (!process) return -1; - auto h = reinterpret_cast(static_cast(process)); - const DWORD r = ::WaitForSingleObject(h, 0); - if (r == WAIT_TIMEOUT) return 1; - if (r != WAIT_OBJECT_0) return -1; - if (exitCode) { - DWORD code = 0; - ::GetExitCodeProcess(h, &code); - *exitCode = static_cast(code); - } - return 0; -} - -void background_stop(unsigned long long job, unsigned long long process, - long long graceMs) -{ - auto procH = process ? reinterpret_cast( - static_cast(process)) - : nullptr; - auto jobH = job ? reinterpret_cast( - static_cast(job)) - : nullptr; - - // NO POLITE ASK HERE, AND `graceMs` IS DELIBERATELY UNSPENT. - // - // The obvious "ask first" is - // - // ::GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, ::GetProcessId(procH)); - // - // and it is wrong in a way that does not show up locally. That call - // addresses a process GROUP attached to this console, not a process; when - // the id does not name a live group of ours — which it does not, once the - // child has already exited, and `start /b`-style commands exit at once — - // the event reaches everything sharing the console instead. Measured on - // the Windows e2e runner: the whole suite died eleven seconds into the - // hooks test with exit code -1073741510 (0xC000013A, - // STATUS_CONTROL_C_EXIT) and printed no summary at all, because mcpp had - // Ctrl-Break'd its own console. - // - // Windows has no portable graceful stop for a child with no console and no - // window of its own. The job IS the mechanism; the POSIX peer's - // SIGTERM-then-grace has no equivalent here, and the asymmetry is stated - // in the declaration rather than faked with a call that reaches too far. - (void)graceMs; - - // Closing a KILL_ON_JOB_CLOSE job takes the whole tree. TerminateProcess - // on the child alone would leave whatever it started behind — which for - // `start /b cmd /c player` is the player. - if (jobH) ::CloseHandle(jobH); - if (procH) ::CloseHandle(procH); -} - -void guard_job_on_signal(unsigned long long job) { - if (!job) return; - { - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedJobs; ++i) { - if (g_guardedJobs[i] == 0) { - g_guardedJobs[i] = job; - ::SetConsoleCtrlHandler(background_console_handler, TRUE); - return; - } - } - } - std::fputs("mcpp: internal: more than 256 concurrently guarded jobs; the " - "newest is NOT guarded and may outlive mcpp\n", stderr); -} - -void unguard_job(unsigned long long job) { - if (!job) return; - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedJobs; ++i) - if (g_guardedJobs[i] == job) g_guardedJobs[i] = 0; - if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); -} - -void clear_job_guard() { - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedJobs; ++i) g_guardedJobs[i] = 0; - if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); -} - -void set_interrupt_files(const std::vector& paths, - const std::vector& temps, - const std::vector& contents) { - if (paths.empty() || paths.size() > 4 || temps.size() != paths.size() - || contents.size() != paths.size()) - return; - std::lock_guard lock(g_guardMutex); - g_interruptSets.push_back(std::make_unique(InterruptFiles{paths, temps, contents})); - g_interrupt.store(g_interruptSets.back().get()); - ::SetConsoleCtrlHandler(background_console_handler, TRUE); -} - -void clear_interrupt_files() { - std::lock_guard lock(g_guardMutex); - g_interrupt.store(nullptr); - if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); -} - -namespace { -BOOL WINAPI ignore_interrupt(DWORD type) { - return (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT) ? TRUE : FALSE; -} -} // namespace - -int run_foreground(const char* commandLine, unsigned long* refused) { - if (refused) *refused = 0; - if (!commandLine || !*commandLine) return -1; - - HANDLE job = ::CreateJobObjectA(nullptr, nullptr); - if (job) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; - jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, - &jeli, sizeof(jeli)); - } - - STARTUPINFOA si{}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - std::string cmdBuf(commandLine); // CreateProcessA may modify it - - // A handler, not SetConsoleCtrlHandler(nullptr, TRUE): the null form sets - // an attribute the child inherits, and the child would then ignore the - // Ctrl-C it is meant to receive. - ::SetConsoleCtrlHandler(ignore_interrupt, TRUE); - BOOL ok = FALSE; - { - LaunchSection launch; - // No CREATE_NEW_PROCESS_GROUP: that flag is what keeps the console's - // Ctrl-C from the child. CREATE_SUSPENDED so the child joins the job - // before it can start anything. - ok = ::CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, - /*bInheritHandles=*/TRUE, CREATE_SUSPENDED, - nullptr, nullptr, &si, &pi); - } - if (!ok) { - if (refused) *refused = ::GetLastError(); - if (job) ::CloseHandle(job); - ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); - return -1; - } - if (job) ::AssignProcessToJobObject(job, pi.hProcess); - ::ResumeThread(pi.hThread); - ::CloseHandle(pi.hThread); - - ::WaitForSingleObject(pi.hProcess, INFINITE); - DWORD code = 0; - ::GetExitCodeProcess(pi.hProcess, &code); - ::CloseHandle(pi.hProcess); - if (job) ::CloseHandle(job); - ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); - return static_cast(code); -} - -int wait_background(unsigned long long process, int* exitCode) { - HANDLE h = reinterpret_cast(process); - if (!h) return -1; - ::WaitForSingleObject(h, INFINITE); - DWORD code = 0; - if (!::GetExitCodeProcess(h, &code)) return -1; - if (exitCode) *exitCode = static_cast(code); - return 0; -} - -#else - -DeadlineRun capture_with_deadline(const char*, const char* const*, unsigned long, - const char*, long long, long long, OutputSink, void*, StopPoll) { - // Not Windows: the POSIX launcher in mcpp.platform.process owns this. - return {}; -} - -LaunchSection::LaunchSection() {} -LaunchSection::~LaunchSection() {} -void LaunchSection::release() {} - -BackgroundChild spawn_background(const char*, const char*, int) { return {}; } -int background_running(unsigned long long, int*) { return -1; } -void background_stop(unsigned long long, unsigned long long, long long) {} -void guard_job_on_signal(unsigned long long) {} -void unguard_job(unsigned long long) {} -void clear_job_guard() {} -void set_interrupt_files(const std::vector&, const std::vector&, - const std::vector&) {} -void clear_interrupt_files() {} -int wait_background(unsigned long long, int*) { return -1; } -int run_foreground(const char*, unsigned long*) { return -1; } - -#endif - -} // namespace mcpp::platform::winproc diff --git a/modules/platform/src/windows/capacity.cpp b/modules/platform/src/windows/capacity.cpp new file mode 100644 index 00000000..8825df18 --- /dev/null +++ b/modules/platform/src/windows/capacity.cpp @@ -0,0 +1,62 @@ +// mcpp.platform.capacity on windows: what the machine has, from the system's own services. +module; + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include // malloc / free — clang does not get these from windows.h + +module mcpp.platform.capacity; + +import std; + +namespace mcpp::platform::capacity { + + +// The processor relationship table needs the two-call pattern: its length is not +// knowable up front, so ask for the size, allocate, then ask again. +static bool win_core_facts(int& physical, bool& hybrid) { + DWORD bytes = 0; + ::GetLogicalProcessorInformationEx(RelationProcessorCore, nullptr, &bytes); + if (bytes == 0) return false; + auto* buf = static_cast(::malloc(bytes)); + if (!buf) return false; + bool ok = false; + if (::GetLogicalProcessorInformationEx( + RelationProcessorCore, + reinterpret_cast(buf), &bytes)) { + int count = 0, firstClass = -1; + DWORD off = 0; + while (off < bytes) { + auto* info = reinterpret_cast(buf + off); + if (info->Size == 0) break; + ++count; + const int cls = static_cast(info->Processor.EfficiencyClass); + if (firstClass < 0) firstClass = cls; + else if (cls != firstClass) hybrid = true; + off += info->Size; + } + if (count > 0) { physical = count; ok = true; } + } + ::free(buf); + return ok; +} + +HostCapacity host_capacity() { + HostCapacity cap; + SYSTEM_INFO si{}; + ::GetSystemInfo(&si); + if (si.dwNumberOfProcessors > 0) cap.logicalCores = static_cast(si.dwNumberOfProcessors); + cap.physicalCores = cap.logicalCores; + win_core_facts(cap.physicalCores, cap.heterogeneous); + + MEMORYSTATUSEX ms{}; + ms.dwLength = sizeof(ms); + if (::GlobalMemoryStatusEx(&ms)) { + cap.totalBytes = ms.ullTotalPhys; + cap.availableBytes = ms.ullAvailPhys; + } + return cap; +} + +} // namespace mcpp::platform::capacity diff --git a/modules/platform/src/windows/env.cpp b/modules/platform/src/windows/env.cpp new file mode 100644 index 00000000..78a405a3 --- /dev/null +++ b/modules/platform/src/windows/env.cpp @@ -0,0 +1,14 @@ +// mcpp.platform.env on Windows: the C runtime's variable table, written with +// `_putenv_s', for which an empty value is removal. +module; +#include + +module mcpp.platform.env; + +import std; + +char** mcpp::platform::env::environment() { return _environ; } + +void mcpp::platform::env::detail::put(const std::string& key, const char* value) { + ::_putenv_s(key.c_str(), value ? value : ""); +} diff --git a/modules/platform/src/windows/fs.cpp b/modules/platform/src/windows/fs.cpp new file mode 100644 index 00000000..68a46853 --- /dev/null +++ b/modules/platform/src/windows/fs.cpp @@ -0,0 +1,99 @@ +// mcpp.platform.fs on Windows: MoveFileExW's replacement, FILE_APPEND_DATA, +// and LockFileEx. +module; +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +module mcpp.platform.fs; + +import std; + +namespace mcpp::platform::fs { + +bool replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec) { + // 直接替换已有文件,不能先删除 last-known-good CDB。编辑器或杀毒软件 + // 可能短暂占用目标文件,sharing violation 仅做有限退避后再报告失败。 + auto delay = std::chrono::milliseconds{50}; + for (int attempt = 0; attempt < 4; ++attempt) { + if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING)) { + ec.clear(); + return true; + } + const auto error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION || attempt == 3) { + ec = std::error_code(static_cast(error), std::system_category()); + return false; + } + std::this_thread::sleep_for(delay); + delay *= 3; + } + return false; +} + +bool append_atomically(const std::filesystem::path& file, std::string_view bytes) { + HANDLE h = CreateFileW(file.wstring().c_str(), FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return false; + DWORD written = 0; + const bool ok = WriteFile(h, bytes.data(), static_cast(bytes.size()), + &written, nullptr) && written == bytes.size(); + CloseHandle(h); + return ok; +} + +// ── FileLock ── + +namespace { + +HANDLE handle_of(std::intptr_t native) { return reinterpret_cast(native); } + +void release(std::intptr_t native) { + if (handle_of(native) == INVALID_HANDLE_VALUE) return; + OVERLAPPED ov = {}; + UnlockFileEx(handle_of(native), 0, 1, 0, &ov); + CloseHandle(handle_of(native)); +} + +} // namespace + +std::optional FileLock::try_acquire(const std::filesystem::path& dir, + std::string_view name) { + std::error_code ec; + std::filesystem::create_directories(dir, ec); + auto lockPath = dir / std::string(name); + HANDLE h = CreateFileW(lockPath.wstring().c_str(), + GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) return std::nullopt; + OVERLAPPED ov = {}; + if (!LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + 0, 1, 0, &ov)) { + CloseHandle(h); + return std::nullopt; + } + return FileLock{reinterpret_cast(h)}; +} + +FileLock::~FileLock() { + release(native_); +} + +FileLock::FileLock(FileLock&& o) noexcept : native_(o.native_) { + o.native_ = -1; +} + +FileLock& FileLock::operator=(FileLock&& o) noexcept { + if (this != &o) { + release(native_); + native_ = o.native_; + o.native_ = -1; + } + return *this; +} + +} // namespace mcpp::platform::fs diff --git a/modules/platform/src/windows/fs_caps.cpp b/modules/platform/src/windows/fs_caps.cpp new file mode 100644 index 00000000..6450ba98 --- /dev/null +++ b/modules/platform/src/windows/fs_caps.cpp @@ -0,0 +1,47 @@ +// mcpp.platform.fs_caps on Windows: a directory's owner from its security +// descriptor, and the volume's filesystem name. +module; +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#pragma comment(lib, "advapi32") + +module mcpp.platform.fs_caps; + +import std; + +namespace mcpp::platform::native { + +// Windows has no atomic exchange of two names. +int exchange(const std::filesystem::path&, const std::filesystem::path&) { return ERROR_NOT_SUPPORTED; } +bool unsupported(int error) { return error == ERROR_NOT_SUPPORTED || error == ERROR_INVALID_FUNCTION; } +std::error_category const& category() { return std::system_category(); } +std::string owner_problem(const std::filesystem::path& dir) { + PSID owner = nullptr; + PSECURITY_DESCRIPTOR sd = nullptr; + if (::GetNamedSecurityInfoW(dir.wstring().c_str(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, + &owner, nullptr, nullptr, nullptr, &sd) != ERROR_SUCCESS) + return "its owner cannot be read"; + bool trusted = ::IsWellKnownSid(owner, WinLocalSystemSid) || ::IsWellKnownSid(owner, WinBuiltinAdministratorsSid); + HANDLE token = nullptr; + if (!trusted && ::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &token)) { + DWORD size = 0; + ::GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector buffer(size); + if (::GetTokenInformation(token, TokenUser, buffer.data(), size, &size)) + trusted = ::EqualSid(owner, reinterpret_cast(buffer.data())->User.Sid); + ::CloseHandle(token); + } + ::LocalFree(sd); + return trusted ? std::string{} : "it belongs to another user"; +} +std::string filesystem_type(const std::filesystem::path& dir) { + wchar_t root[MAX_PATH] {}, name[MAX_PATH] {}; + if (!::GetVolumePathNameW(dir.wstring().c_str(), root, MAX_PATH)) return {}; + if (!::GetVolumeInformationW(root, nullptr, 0, nullptr, nullptr, nullptr, name, MAX_PATH)) return {}; + return std::filesystem::path(name).string(); +} + +} // namespace mcpp::platform::native diff --git a/modules/platform/src/windows/process.cpp b/modules/platform/src/windows/process.cpp new file mode 100644 index 00000000..6680964c --- /dev/null +++ b/modules/platform/src/windows/process.cpp @@ -0,0 +1,132 @@ +// mcpp.platform.process on Windows: the launchers whose programs are started +// through cmd.exe command lines (windows_shell_command_line) by +// mcpp.platform.windows.bounded_process, and the C runtime's pipe primitives. +// +// Windows KEEPS the proven shell path. The env-binding hazard does not exist +// here (env goes through _putenv_s, not a prefix), so the launch primitive is +// not swapped on a platform that cannot be iterated on locally. +module; +#include +#include // _popen, _pclose + +module mcpp.platform.process; + +import std; +import mcpp.platform.common; +import mcpp.platform.env; +import mcpp.platform.shell; +import mcpp.platform.windows.bounded_process; + +namespace mcpp::platform::process { + +void set_umask_022() { + // Nothing: the ACL inherited from the directory decides. +} + +int normalize_exit_code(int rc) { return rc; } + +std::FILE* open_pipe(const std::string& command) { return ::_popen(command.c_str(), "r"); } +int close_pipe(std::FILE* pipe) { return ::_pclose(pipe); } + +namespace { + +// Build a shell command line from an argv vector. EVERY token is quoted, +// including the program — a payload under `C:\Program Files\...` or a home +// directory with a space in the user name is otherwise cut at the first space +// and reported as `'C:\Program' is not recognized`. One derivation: the +// tested, host-independent shaper (windows_command_from_argv). +std::string command_from_argv(const std::vector& argv) { + return windows_command_from_argv(argv); +} + +} // namespace + +int run_exec(const std::vector& argv, + const std::vector>& extraEnv, + int* spawn_error) +{ + if (spawn_error) *spawn_error = 0; + if (argv.empty()) return 127; + // THE SAME OWNERSHIP AS THE POSIX BRANCH, EXPRESSED IN THIS PLATFORM'S TERMS. + // + // `std::system` gave the child away: it runs through a cmd.exe mcpp does not + // hold a handle to, so terminating mcpp left the tree running exactly as the + // POSIX branch did before its process group. A job object with + // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is the equivalent unit — it takes the + // whole tree, which matters here for the same reason the group does there: + // the child starts compilers of its own. + // + // The command line is built by the same `windows_shell_command_line` the + // rest of this file uses, so the cmd.exe quoting rule has ONE derivation. + // Re-deriving it here is how `/d /s /c` loses an argument. + std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); + // wrap only — run_exec inherits stdio on purpose (see finalize_shell_command). + std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv)); + auto child = mcpp::platform::winproc::spawn_background(cmd.c_str(), nullptr, 1); + if (!child.ok) { + const int refused = static_cast(child.refused); + if (spawn_error) *spawn_error = refused; + else std::fputs(spawn_failure(argv.front(), refused).c_str(), stderr); + return 127; + } + mcpp::platform::winproc::guard_job_on_signal(child.job); + int code = 127; + mcpp::platform::winproc::wait_background(child.process, &code); + mcpp::platform::winproc::unguard_job(child.job); + // Closes both handles; the child has already exited, so this is cleanup + // rather than a kill. + mcpp::platform::winproc::background_stop(child.job, child.process, 0); + return code; +} + +int run_foreground(const std::vector& argv, + const std::vector>& extraEnv, + int* spawn_error) +{ + if (spawn_error) *spawn_error = 0; + if (argv.empty()) return 127; + std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); + std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv)); + unsigned long refused = 0; + const int code = mcpp::platform::winproc::run_foreground(cmd.c_str(), &refused); + if (code == -1 && refused != 0) { + if (spawn_error) *spawn_error = static_cast(refused); + else std::fputs(spawn_failure(argv.front(), static_cast(refused)).c_str(), stderr); + return 127; + } + return code; +} + +RunResult capture_exec( + const std::vector& argv, + const std::vector>& extraEnv, + std::string_view cwd, + int* spawn_error) +{ + RunResult result; + if (spawn_error) *spawn_error = 0; + if (argv.empty()) { result.exit_code = 127; return result; } + std::string cmd = command_from_argv(argv) + " 2>&1"; + if (!cwd.empty()) { + // cmd.exe `cd` without /d does not switch drives — a project on a + // different drive than mcpp's own cwd would run the child (the + // build.mcpp contract's only cwd consumer) in the wrong directory. + cmd = "cd /d " + mcpp::platform::shell::quote(cwd) + " && " + cmd; + } + return capture_with_env(cmd, extraEnv); +} + +RunResult capture_stdout( + const std::vector& argv, + const std::vector>& extraEnv, + int* spawn_error) +{ + RunResult result; + if (spawn_error) *spawn_error = 0; + if (argv.empty()) { result.exit_code = 127; return result; } + // cmd.exe reports a program it cannot find on stderr, which the redirect + // discards, and returns 9009; the output stays empty either way. + return capture_with_env(windows_stdout_probe_command(argv), extraEnv); +} + +} // namespace mcpp::platform::process diff --git a/modules/platform/src/windows/scaffold_fs.cpp b/modules/platform/src/windows/scaffold_fs.cpp new file mode 100644 index 00000000..d6d0ffc5 --- /dev/null +++ b/modules/platform/src/windows/scaffold_fs.cpp @@ -0,0 +1,50 @@ +// mcpp.platform.scaffold_fs on Windows: FlushFileBuffers of a file; a +// directory's entry is already durable by MOVEFILE_WRITE_THROUGH. +module; +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +module mcpp.platform.scaffold_fs; + +import std; + +namespace mcpp::platform { + +namespace { + +std::string win32_message(DWORD code) { + return std::system_category().message(static_cast(code)); +} + +} // namespace + +std::expected sync_regular_file(const std::filesystem::path& path) { + HANDLE handle = ::CreateFileW( + path.c_str(), GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) { + const auto error = ::GetLastError(); + return std::unexpected(std::format( + "cannot open '{}' for durable sync: {}", path.string(), win32_message(error))); + } + if (::FlushFileBuffers(handle) == 0) { + const auto error = ::GetLastError(); + ::CloseHandle(handle); + return std::unexpected(std::format( + "durable sync '{}' failed: {}", path.string(), win32_message(error))); + } + if (::CloseHandle(handle) == 0) { + const auto error = ::GetLastError(); + return std::unexpected(std::format( + "close synced file '{}' failed: {}", path.string(), win32_message(error))); + } + return {}; +} + +std::expected sync_directory(const std::filesystem::path&) { + return {}; +} + +} // namespace mcpp::platform diff --git a/modules/platform/src/windows/terminal.cpp b/modules/platform/src/windows/terminal.cpp new file mode 100644 index 00000000..c7536c70 --- /dev/null +++ b/modules/platform/src/windows/terminal.cpp @@ -0,0 +1,126 @@ +// mcpp.platform.terminal on Windows: consoles, the console's output code +// page, keys read as console input events, and standard output diverted at +// the C runtime's descriptor. +module; +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include // _dup, _dup2, _close, _get_osfhandle, _fileno, _write +#include // SetHandleInformation, GetConsoleMode, ReadConsoleInputW + +module mcpp.platform.terminal; + +import std; + +namespace mcpp::platform::terminal::native { + +namespace { + +HANDLE handle_of(Stream s) { + const auto h = reinterpret_cast(::_get_osfhandle(::_fileno(s == Stream::Out ? stdout : stderr))); + return h == nullptr ? INVALID_HANDLE_VALUE : h; +} + +bool console_of(Stream s) { + const auto h = handle_of(s); + DWORD mode = 0; + return h != INVALID_HANDLE_VALUE && ::GetConsoleMode(h, &mode); +} + +} // namespace + +bool same_terminal() { + // A process has at most one console. + return console_of(Stream::Out) && console_of(Stream::Err); +} + +unsigned console_output_code_page(Stream s) { + return console_of(s) ? static_cast(::GetConsoleOutputCP()) : 0; +} + +bool write_descriptor(int fd, std::string_view text) { + std::size_t done = 0; + while (done < text.size()) { + const int n = ::_write(fd, text.data() + done, static_cast(text.size() - done)); + if (n <= 0) return false; + done += static_cast(n); + } + return true; +} + +int divert_stdout() { + const int saved = ::_dup(1); + if (saved >= 0) { + // `_dup` duplicates the handle as inheritable, and CreateProcess with + // handle inheritance (every `_popen` and `system`) passes it on. + const auto h = reinterpret_cast(::_get_osfhandle(saved)); + if (h != INVALID_HANDLE_VALUE) + ::SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0); + ::_dup2(2, 1); + } + return saved; +} + +void restore_stdout(int saved) { + ::_dup2(saved, 1); + ::_close(saved); +} + +} // namespace mcpp::platform::terminal::native + +namespace mcpp::platform::terminal { + +namespace { + +HANDLE g_inputHandle = INVALID_HANDLE_VALUE; +DWORD g_inputMode = 0; +BOOL WINAPI restore_input_mode(DWORD) { + if (g_inputHandle != INVALID_HANDLE_VALUE) ::SetConsoleMode(g_inputHandle, g_inputMode); + return FALSE; // the next handler, then the default action, still run +} + +} // namespace + +KeyInput::KeyInput(Stream display) { + if (!is_terminal(display)) return; + const HANDLE h = ::GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + if (h == INVALID_HANDLE_VALUE || !::GetConsoleMode(h, &mode)) return; + g_inputHandle = h; + g_inputMode = mode; + savedMode_ = mode; + ::SetConsoleCtrlHandler(restore_input_mode, TRUE); + // ENABLE_PROCESSED_INPUT stays: Ctrl-C still interrupts. + if (!::SetConsoleMode(h, mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT))) return; + active_ = true; +} + +KeyInput::~KeyInput() { + if (!active_) return; + ::SetConsoleMode(g_inputHandle, static_cast(savedMode_)); + ::SetConsoleCtrlHandler(restore_input_mode, FALSE); + g_inputHandle = INVALID_HANDLE_VALUE; +} + +std::vector KeyInput::read() { + std::vector keys; + if (!active_) return keys; + DWORD n = 0; + while (::GetNumberOfConsoleInputEvents(g_inputHandle, &n) && n > 0) { + INPUT_RECORD rec{}; + DWORD got = 0; + if (!::ReadConsoleInputW(g_inputHandle, &rec, 1, &got) || got == 0) break; + if (rec.EventType != KEY_EVENT || !rec.Event.KeyEvent.bKeyDown) continue; + switch (rec.Event.KeyEvent.wVirtualKeyCode) { + case VK_UP: case 'W': keys.push_back(Key::Up); break; + case VK_DOWN: case 'S': keys.push_back(Key::Down); break; + case VK_LEFT: case 'A': keys.push_back(Key::Left); break; + case VK_RIGHT: case 'D': keys.push_back(Key::Right); break; + case VK_SPACE: keys.push_back(Key::Space); break; + default: break; + } + } + return keys; +} + +} // namespace mcpp::platform::terminal diff --git a/modules/platform/src/windows/windows.cppm b/modules/platform/src/windows/windows.cpp similarity index 52% rename from modules/platform/src/windows/windows.cppm rename to modules/platform/src/windows/windows.cpp index 679b21fe..4df7ff21 100644 --- a/modules/platform/src/windows/windows.cppm +++ b/modules/platform/src/windows/windows.cpp @@ -1,84 +1,20 @@ -// mcpp.platform.windows — Windows-specific platform capabilities. -// -// Provides: -// prepend_path() — add a directory to the front of %PATH% -// active_code_page() — the process ANSI code page (GetACP) -// -// Note: Visual Studio / MSVC discovery is in mcpp.toolchain.msvc, which is -// the authoritative module for MSVC toolchain detection. This module -// provides general-purpose Windows platform utilities only. - +// mcpp.platform.windows on Windows. module; #include -#if defined(_WIN32) #include // _putenv_s -#ifndef NOMINMAX #define NOMINMAX -#endif -#ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN -#endif #include // GetACP, RegGetValueW // RegGetValueW lives in advapi32, which a link line names only by default on // MinGW; this asks MSVC-ABI links for it too. #pragma comment(lib, "advapi32") -#endif -export module mcpp.platform.windows; +module mcpp.platform.windows; import std; -export namespace mcpp::platform::windows { - -// Prepend a directory to the %PATH% environment variable. -void prepend_path(const std::filesystem::path& dir); - -// The ANSI code page of this process: 65001 when the UTF-8 code page that -// mcpp.exe declares is in effect (Windows 10 version 1903 and later), the -// system's legacy code page otherwise. 0 on every other platform, which has -// no process code page. Every narrow string mcpp exchanges with Win32 is in -// this code page (#693). -unsigned active_code_page(); - -// A string value under HKEY_LOCAL_MACHINE, read from both registry views (the -// 64-bit one first, then WOW6432Node's), as a path. An installer records its -// installation directory there wherever it put it -- the Windows SDK's -// `Installed Roots\KitsRoot10` -- so a search that knows only `C:\Program -// Files` misses an SDK on another drive (D15). nullopt when absent and on -// every other platform. -std::optional machine_registry_path(std::wstring_view subkey, - std::wstring_view value); - -// A directory a known-folder environment variable names (`ProgramFiles`, -// `ProgramFiles(x86)`), or nullopt. The system drive is not always `C:`. -std::optional env_directory(const char* name); - -// Whether the system lets a program that declares itself `longPathAware` use -// paths past MAX_PATH (260): HKLM\SYSTEM\CurrentControlSet\Control\FileSystem -// `LongPathsEnabled` = 1. false when it is 0 or absent, and on every other -// platform, which has no such limit. -bool long_paths_enabled(); -// Used only by the first-run Windows home suggestion; never creates the -// actual home. An existing child must be a plain directory: inspecting its -// reparse-point attributes avoids following a potentially remote target. -// Other platforms answer false without touching a filesystem. -bool writable_fixed_drive(char drive, std::wstring_view child); -bool input_is_terminal(); - -} // namespace mcpp::platform::windows - namespace mcpp::platform::windows { -bool input_is_terminal() { -#if defined(_WIN32) - DWORD mode = 0; - return GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mode) != 0; -#else - return false; -#endif -} - bool writable_fixed_drive(char drive, std::wstring_view child) { -#if defined(_WIN32) const wchar_t root[] = {static_cast(drive), L':', L'\\', L'\0'}; if (GetDriveTypeW(root) != DRIVE_FIXED) return false; const auto candidate = std::filesystem::path(root) / child; @@ -95,11 +31,6 @@ bool writable_fixed_drive(char drive, std::wstring_view child) { if (handle == INVALID_HANDLE_VALUE) return false; CloseHandle(handle); return true; -#else - (void)drive; - (void)child; - return false; -#endif } } @@ -108,18 +39,13 @@ bool writable_fixed_drive(char drive, std::wstring_view child) { namespace mcpp::platform::windows { void prepend_path(const std::filesystem::path& dir) { -#if defined(_WIN32) std::string newPath = dir.string() + ";" + (std::getenv("PATH") ? std::getenv("PATH") : ""); _putenv_s("PATH", newPath.c_str()); -#else - (void)dir; -#endif } std::optional machine_registry_path(std::wstring_view subkey, std::wstring_view value) { -#if defined(_WIN32) const std::wstring key(subkey), name(value); for (DWORD view : {static_cast(KEY_WOW64_64KEY), static_cast(KEY_WOW64_32KEY)}) { DWORD bytes = 0; @@ -136,21 +62,13 @@ std::optional machine_registry_path(std::wstring_view sub if (!buf.empty()) return std::filesystem::path(buf); } return std::nullopt; -#else - (void)subkey; (void)value; - return std::nullopt; -#endif } bool long_paths_enabled() { -#if defined(_WIN32) DWORD value = 0, bytes = sizeof(value); return RegGetValueW(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Control\\FileSystem", L"LongPathsEnabled", RRF_RT_REG_DWORD, nullptr, &value, &bytes) == ERROR_SUCCESS && value == 1; -#else - return false; -#endif } std::optional env_directory(const char* name) { @@ -159,11 +77,7 @@ std::optional env_directory(const char* name) { } unsigned active_code_page() { -#if defined(_WIN32) return static_cast(GetACP()); -#else - return 0; -#endif } } // namespace mcpp::platform::windows diff --git a/src/main.cpp b/src/main.cpp index 648dc046..8285dc27 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,8 +5,13 @@ import std; import mcpp.cli; import mcpp.ui; import mcpp.home; +import mcpp.platform; int main(int argc, char* argv[]) { + // Before anything is written: `mcpp … | head` ends mcpp in order, and no + // program mcpp starts inherits SIGPIPE ignored (part 2 design, §6.4). + mcpp::platform::process::guard_broken_pipe(); + // Line-buffer stdout even when it is not a TTY. // // Without this, progress output is block-buffered and the block SIZE is diff --git a/tests/e2e/941_a_closed_reader_ends_mcpp_and_no_child_inherits_the_ignore.sh b/tests/e2e/941_a_closed_reader_ends_mcpp_and_no_child_inherits_the_ignore.sh new file mode 100755 index 00000000..1c0e807e --- /dev/null +++ b/tests/e2e/941_a_closed_reader_ends_mcpp_and_no_child_inherits_the_ignore.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell python3 +# 941 -- a closed reader ends mcpp in order, and no program mcpp starts +# inherits SIGPIPE ignored (design 2026-10-26 part 2, §6.4 and §10.1). +# +# mcpp reaches the operating system through mcpplibs.os over openkal, and +# openkal-linux and openkal-macos ignore SIGPIPE so that their own writes +# report a closed reader instead of ending the program. An ignored disposition +# survives exec: before the guard, every program mcpp started -- a build +# script's `yes | head` included -- began with SIGPIPE ignored, and mcpp's own +# `std::print` to a closed standard output threw instead of ending mcpp. +# mcpp installs a handler for SIGPIPE before it writes anything: a handler is +# reset to the default by exec, and it ends mcpp as an interrupt does and then +# by the signal itself. +# +# Both processes are started from Python with SIGPIPE at its default +# (`restore_signals`), whatever the shell running this file was given. +# +# Criteria: +# A. A program `mcpp run` starts begins with SIGPIPE at its default. +# B. `mcpp --help` whose standard output has no reader ends by SIGPIPE -- +# the status a shell has always seen for `mcpp … | head` -- and not by an +# uncaught exception, an abort, or a normal exit. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +cd "$TMP" +mkdir -p app/src +cat > app/mcpp.toml <<'TOML' +[package] +name = "app" +version = "0.1.0" + +[targets.app] +kind = "bin" +main = "src/main.cpp" +TOML +cat > app/src/main.cpp <<'CPP' +#include +#include +int main() { + struct sigaction now {}; + sigaction(SIGPIPE, nullptr, &now); + puts(now.sa_handler == SIG_IGN ? "pipe=ignored" : "pipe=default"); + return 0; +} +CPP + +cd app +"$MCPP" build > build.log 2>&1 || fail "the program does not build" build.log + +# A +python3 - "$MCPP" > run.log 2>&1 <<'PY' || fail "A: the program mcpp ran began with SIGPIPE ignored" run.log +import subprocess, sys +r = subprocess.run([sys.argv[1], "run"], capture_output=True, text=True, restore_signals=True) +print(r.stdout); print(r.stderr, file=sys.stderr) +sys.exit(0 if r.returncode == 0 and "pipe=default" in r.stdout else 1) +PY + +# B +python3 - "$MCPP" > pipe.log 2>&1 <<'PY' || fail "B: mcpp did not end by SIGPIPE when its reader was gone" pipe.log +import os, signal, subprocess, sys +r, w = os.pipe() +os.close(r) +p = subprocess.run([sys.argv[1], "--help"], stdout=w, stderr=subprocess.PIPE, text=True, + restore_signals=True) +os.close(w) +print("status", p.returncode); print(p.stderr) +sys.exit(0 if p.returncode == -signal.SIGPIPE and "terminate" not in p.stderr else 1) +PY + +echo "OK" From cc39fc42b2183a3ebed041cee75d85d06f78d35f Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 21:47:06 +0900 Subject: [PATCH 08/43] target -openkal: one ok image per architecture, finished by okld, run by okrun, packed as the image (M2) or a program that carries it (M3) The triple names no operating system (-unknown-none-elf); the convention's flags and an ld.lld link produce an ET_DYN entered at ok_main; okld note and okld check, programs of the graph, finish each image before it takes its name. okrun is the target's runner. The target side's kernel-abi is fixed to openkal; every layer above it is the graph's, and the payload's libc++ is withheld. e2e 942; unit Triple.OpenkalIsAnImageForNoOperatingSystem. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...em-part2-os-library-over-openkal-design.md | 21 ++ CHANGELOG.md | 13 ++ README.md | 1 + README.zh-CN.md | 1 + docs/04-mcpp-toml.md | 6 + docs/10-pack-and-release.md | 20 ++ docs/21-the-target-triple.md | 5 +- docs/24-openkal-cross.md | 38 ++++ docs/zh/04-mcpp-toml.md | 5 + docs/zh/10-pack-and-release.md | 17 ++ docs/zh/21-the-target-triple.md | 5 +- docs/zh/24-openkal-cross.md | 33 +++ modules/manifest/src/targetside_model.cppm | 15 +- modules/toolchain-model/src/triple.cppm | 35 +++- src/build/execute.cppm | 7 + src/build/flags.cppm | 37 ++++ src/build/ninja_backend.cppm | 77 ++++++- src/build/plan.cppm | 4 + src/build/prepare/features.cpp | 1 + src/build/prepare/plan.cpp | 32 +++ src/build/prepare/scan.cpp | 21 ++ src/build/prepare/state.cppm | 4 + src/build/prepare/target_side.cpp | 18 +- src/build/prepare/toolchain.cpp | 4 + src/cli.cppm | 8 +- src/cli/cmd_build.cppm | 55 +++++ src/okimage/okimage.cppm | 93 +++++++++ src/pack/pack.cppm | 194 ++++++++++++++++++ src/toolchain/registry.cppm | 3 + ...ok_image_is_built_once_and_run_by_okrun.sh | 192 +++++++++++++++++ tests/unit/test_artifact_naming.cpp | 3 +- tests/unit/test_toolchain_triple.cpp | 31 +++ 32 files changed, 986 insertions(+), 13 deletions(-) create mode 100644 src/okimage/okimage.cppm create mode 100755 tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index 403cc613..376b1c73 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -388,6 +388,27 @@ M3 由链接 libokrun、映像段与一个极小的入口(由 `mcpp pack` 生 | 打包 | `pack` 增加 M2(映像)与 M3(每个 OS:libokrun + 映像段)格式 | | 其他 | 编译数据库与 clangd 的目标参数;文档 21、22、24、50;e2e:同一映像字节在各 OS 运行 | +实现(#842): + +- **三元组**:`os = "openkal"`,`env` 为空;`llvm_triple` 为 `-unknown-none-elf`;`cfg(unix)`、`cfg(windows)` 为假; + 产物名 `.oke` / `lib.okso` / `.a`;pin `llvm@23.1.3`,每台宿主可构建。 +- **目标侧**:`kernel-abi openkal (the loader)` 由目标固定;`c-abi`、`c++-abi`、`compiler-runtime` 只来自依赖图,否则为无。 + 载荷的 libc++ 与 std 模块不提供给映像。默认提供者不隐式加入依赖图(依赖图只含清单声明的包):`import std;` 在依赖图无 C++ + 运行时时于编译前被拒绝,拒绝信息给出 `openkal-musl` 与 `openkal-llvm-runtime` 的声明。无 C 库的映像以 `-ffreestanding` 编译 + (实测:`--release` 下 `-O2` 合成的 `strlen` 调用被 `okld check` 拒绝)。 +- **链接与收尾**:`ld.lld -shared -z max-page-size=65536 -z now -z noexecstack --hash-style=both`,可执行映像 `-e ok_main`; + 每个映像链接引擎写出的 note 对象;链接产物为 `.unchecked`,`mcpp okimage-finish` 依次执行 `okld note`、`okld check` + (`--with` 所需的 `.okso`),通过后才以映像名发布。`okld` 依次取自依赖图为本机构建的程序、声明的载荷、PATH;缺失时在编译前 + 拒绝并给出 `[build-dependencies] openkal-tools = { tools = ["okld", "okrun"] }`。格式的读取与检查只在 libokld 中实现,mcpp + 不另行实现。 +- **运行**:`okrun` 是该目标的默认 runner;runner 程序的查找把依赖图为本机构建的程序(`tools`)排在载荷与 PATH 之前。 +- **打包**:`vendored`(默认)为 M2(映像与其 `.okso`),`system` 只含映像,`self-contained` 为 M3:生成入口与映像段 + (ELF `.oke`、Mach-O `__OKE,__image`、COFF `.oke`),依赖与 `openkal-tools` 同源的 `openkal-run`,再以普通程序打包(Linux 为 + musl 静态,macOS 与 Windows 只引用系统库);`static` 拒绝。okrun 采用借用安排(§8.4),宿主 C / C++ 运行时在内,故 M3 在各自 + 系统上打包,不由一台宿主交叉产出三份;这修订了 §8.2"一台 Linux 主机即可交叉产出三个 OS 的 M3"。 +- **验证**:e2e 942(A 两行构建为本架构的 ET_DYN;B okrun 运行;C 导入 `puts` 被拒绝且 `bin/` 中仍为上次发布的映像;D 无 C++ + 运行时的 `import std` 被拒绝;E 第二次构建无编译;F M2 与 M3);单测 `Triple.OpenkalIsAnImageForNoOperatingSystem`。 + ### 8.6 `os` 在 ok 模式下 `-openkal` 目标上 `os::PLATFORM == os::platform::openkal`,`is_linux` / `is_macos` / `is_windows` 均为 `false`(编译目标 diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f05bc2..82a1c0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ ## [2026.10.26.2] - 2026-10-26 +### Added + +- **`x86_64-openkal` and `aarch64-openkal`: one ok image per architecture** + (openkal SPEC clause 12). The image is compiled for no operating system + with the convention's flags, linked by `ld.lld` into an ET_DYN entered at + `ok_main` (`bin/.oke`, or `lib.okso` for an image another + loads), and published only after `okld note` has written its requirements + note and `okld check` has checked it. `okrun` runs it on Linux, macOS and + Windows and is the target's runner. Both programs come from the dependency + graph: `[build-dependencies] openkal-tools = { tools = ["okld", "okrun"] }`. + The C library and the C++ run-time inside an image are packages; the + payload's libc++ is not offered to an image. + ### Fixed - **A WebAssembly link writes its `.wasm` again.** 2026.10.26.1 published every diff --git a/README.md b/README.md index f7236078..262681b8 100644 --- a/README.md +++ b/README.md @@ -454,6 +454,7 @@ list` reports for this machine): | `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 23 — Cortex-M4/M7 soft float, M23, M33F/M55F ² | preview | | `aarch64-linux-gnu` | `llvm@23.1.3` | preview | | `riscv64-linux-musl` · `x86_64-macos` | — | planned | +| `x86_64-openkal` · `aarch64-openkal` | llvm 23 — an ok image: one artifact per architecture, run by `okrun` on Linux, macOS and Windows; the C library and C++ run-time inside it are packages ([24](docs/24-openkal-cross.md)) | preview | | `wasm32-emscripten` | `emsdk@6.0.9` — Emscripten ships its own sysroot and its own libc++ module surface; `mcpp run` executes the module with the `node` the payload declares (`xim:node`), not one found on PATH | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370` — bionic from the NDK, one payload for both ABIs; ran on an API 24 x86_64 emulator image | verified | | `aarch64-linux-android` | the same payload and the same build; ran under qemu-user over the system image's own bionic, which the platform emulator cannot do from an x86_64 host | verified | diff --git a/README.zh-CN.md b/README.zh-CN.md index 063c2992..b11b7542 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -403,6 +403,7 @@ mcpp 的身份模型有两条正交的轴:**工具链**是 `family@version`( | `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 23;Cortex-M4/M7 软浮点、M23、M33F/M55F ² | preview | | `aarch64-linux-gnu` | `llvm@23.1.3` | preview | | `riscv64-linux-musl` · `x86_64-macos` | — | planned | +| `x86_64-openkal` · `aarch64-openkal` | llvm 23;ok 映像:每种架构一个产物,由 `okrun` 在 Linux、macOS 与 Windows 上运行;映像内的 C 库与 C++ 运行时是包([24](docs/zh/24-openkal-cross.md)) | preview | | `wasm32-emscripten` | `emsdk@6.0.9`;Emscripten 自带 sysroot 与 libc++ 模块接口;`mcpp run` 使用 payload 声明的 `node`(`xim:node`)运行模块,不使用 PATH 上的 `node` | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370`;bionic 来自 NDK,一个 payload 服务两个 ABI;已在 API 24 的 x86_64 模拟器镜像上运行 | verified | | `aarch64-linux-android` | 同一个 payload、同样的构建;已在 qemu-user 上配合系统镜像自带的 bionic 运行,平台模拟器无法在 x86_64 宿主上做到这一点 | verified | diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 2082af5d..1d4b5e52 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -1308,6 +1308,12 @@ different argv (`-bios default` for an OpenSBI boot, `-bios none -semihosting` for a picolibc image) — and an engine that guesses one is an engine the other board has to fight. A board-support package normally supplies it. +The one target whose runner is a property of the target rather than of a +board is `-openkal` (2026.10.26.2): an ok image is run by `okrun` on every +system, so `okrun` is that target's runner when the project declares none. It is +found as a declared runner's program is, with the programs the dependency graph +built for this machine (`tools = [...]`) searched first ([24](24-openkal-cross.md)). + ### 2.7.3 `runner` on a hosted target (2026.9.2.1+) `[target.].runner` applies to every exact triple, not only to bare diff --git a/docs/10-pack-and-release.md b/docs/10-pack-and-release.md index 45ef4d28..f9d667aa 100644 --- a/docs/10-pack-and-release.md +++ b/docs/10-pack-and-release.md @@ -672,6 +672,26 @@ the loader reads it from that path on every machine, whatever the tree carries. `kind = "lib"` / `"shared"` target is packaged by the library route, which is unaffected. +### ok images (`-openkal`): the image, or a program that carries it + +An ok image runs through `okrun` on every system of its architecture (see +[24](24-openkal-cross.md)). The mode keeps its meaning — what the machine that +runs the package must provide: + +| Mode | The package holds | The machine provides | +|---|---|---| +| `system` | `bin/.oke` | `okrun` and every image it names | +| `vendored` (default) | `bin/.oke` and the `.okso` images it names | `okrun` | +| `self-contained` | `bin/` — a program of this machine that carries the image as a section (`.oke`, `__OKE,__image`) and runs it through libokrun — and the `.okso` images beside it | nothing | +| `static` | refused | — | + +The image is not recompiled for `self-contained`: mcpp generates the entry and +the section, depends on `openkal-run` from the source of the project's +`openkal-tools`, and packs that program as it packs any other — static (musl) +on Linux, with only the system's libraries on macOS and Windows. The bytes in +the section are the `.oke`'s. Another system's program is packed on that +system. + ### Android: the application object and its closure under `lib/` ``` diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index 6163b16a..adc35e63 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -104,7 +104,7 @@ what every `else` branch in the tree assumes. So the format is now one answer: | target | format | |---|---| -| `x86_64-linux-gnu`, `aarch64-linux-android`, `riscv64-none-elf` | ELF | +| `x86_64-linux-gnu`, `aarch64-linux-android`, `riscv64-none-elf`, `x86_64-openkal` | ELF | | `aarch64-macos`, `aarch64-ios` | Mach-O | | `x86_64-windows-gnu`, `x86_64-windows-msvc` | PE | | `wasm32-emscripten` | wasm | @@ -520,6 +520,8 @@ other's rows. | `aarch64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `x86_64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `wasm32-emscripten` | verified | `emsdk@6.0.9` | payload | payload | payload | payload | +| `x86_64-openkal` | preview | `llvm@23.1.3` | payload | payload | payload | payload | +| `aarch64-openkal` | preview | `llvm@23.1.3` | payload | payload | payload | payload | `payload` a toolchain payload here produces it · `graph` no payload, but a dependency can supply the system · `system` located on the machine, not @@ -537,6 +539,7 @@ installed by mcpp · `SDK` the platform's own · `—` unreachable from this hos | `x86_64-windows-musl` | Windows by payload; anywhere by graph | no gcc emits PE+musl, and LLVM cannot spell the triple | | `aarch64-macos` | macOS | the SDK is the machine's | | `*-none-elf` | every host | clang and lld are cross-compilers by construction | +| `*-openkal` | every host | the same; the image is compiled for no operating system and runs on every system through `okrun` ([24](24-openkal-cross.md)) | | `wasm32-emscripten`, `*-linux-android` | every host | the SDK ships its own sysroot and upstream publishes it per host; one archive serves every guest arch | **A `—` is about payloads, not about possibility.** `host_can_serve` answers diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index 15fbe41e..c7fb4804 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -387,6 +387,44 @@ platform interfaces. | Windows | `openkal-windows` | on Win32 and the object manager | | Web (Emscripten) | `openkal-emscripten` | written ABOVE a C library; twelve of fifteen interfaces | +## One Image For Every System: `-openkal` (mcpp 2026.10.26.2) + +Every target above produces one artifact per operating system: the source is +shared and the program is not. The target `-openkal` produces one +artifact per **architecture** — an *ok image* (openkal SPEC clause 12) — that a +loader runs on every system of that architecture. `okrun` is that loader on +Linux, macOS and Windows. + +```toml +[build-dependencies] +openkal-tools = { version = "0.1.0", tools = ["okld", "okrun"] } +``` + +```sh +mcpp build --target x86_64-openkal # bin/app.oke +mcpp run --target x86_64-openkal # okrun bin/app.oke +``` + +| | the engine's action | the reason | +|---|---|---| +| compile | `--target=-unknown-none-elf -fPIC -femulated-tls`; on AArch64 also `-ffixed-x18 -mno-outline-atomics` | an image is told no operating system; it does not address the thread pointer and does not use the platform register, which two systems reserve | +| link | `ld.lld -shared -z max-page-size=65536 -z now`, entry `ok_main`; an image another loads (`kind = "shared"`) is `lib.okso` with its name | ET_DYN, every segment aligned to the largest protection granularity among the systems | +| finish | `okld note` writes the interfaces the image's strong imports require; `okld check` checks every clause against the image and the `.okso` it needs; only then is `bin/.oke` published | a loader refuses an image that requires an interface the implementation lacks, naming it, and an image that breaks the convention is never published | +| run | `okrun` is the target's default runner, found among the programs the graph built for this machine, then in declared payloads, then on PATH | the loader holds the openkal implementation; the image carries none | + +**The kernel interface is fixed; every layer above it is the graph's.** The +report reads `kernel-abi openkal (the loader)`. The C library and the C++ +run-time are packages that provide `mcpp:c-abi` and `mcpp:c++-abi` for this +target (`openkal-musl`, `openkal-llvm-runtime`), and an image with neither is +legal: it defines `ok_main` itself and supplies `__emutls_get_address`. The +payload's libc++ is not offered, because it was configured for the payload's +own system; `import std;` without a C++ run-time in the graph is refused before +anything compiles, naming the packages. + +**Missing `okld` is a refusal, not a warning.** An image linked without its +requirements note would be refused by every loader; the engine names the +dependency to add instead. + ## Bare Metal A target with no operating system is the same model with the platform layer diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index c065ea2b..4d740f8a 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -1218,6 +1218,11 @@ argv(OpenSBI 启动用 `-bios default`,picolibc 镜像用 `-bios none -semihosting`)——一个替某一块板子猜一个值的引擎,就是另一块板子必须 与之对抗的引擎。一个板级支持包通常会提供它。 +唯一一个 runner 属于 target 而非板子的 target 是 `-openkal` +(2026.10.26.2):ok 映像在每个系统上都由 `okrun` 运行,因此工程未声明 runner +时,`okrun` 就是该 target 的 runner。它的查找方式与声明的 runner 程序相同,并 +优先查找依赖图为本机构建的程序(`tools = [...]`)([24](24-openkal-cross.md))。 + ### 2.7.3 hosted 目标上的 `runner`(2026.9.2.1+) `[target.].runner` 适用于每一个精确三元组,不只是裸机。一个 diff --git a/docs/zh/10-pack-and-release.md b/docs/zh/10-pack-and-release.md index 199b6df5..94919095 100644 --- a/docs/zh/10-pack-and-release.md +++ b/docs/zh/10-pack-and-release.md @@ -589,6 +589,23 @@ dylib 的消费者,其链接所带的 `@loader_path` rpath 本就会查找的 打包任何东西,与 PE 相同。`kind = "lib"` / `"shared"` 的 target 走库打包 路线,不受影响。 +### ok 映像(`-openkal`):映像本身,或携带它的程序 + +ok 映像经 `okrun` 在其架构的每个系统上运行(见 [24](24-openkal-cross.md))。mode +的含义不变 —— 运行该包的机器必须提供什么: + +| Mode | 包内内容 | 机器需提供 | +|---|---|---| +| `system` | `bin/.oke` | `okrun` 与它所需的每个映像 | +| `vendored`(默认) | `bin/.oke` 与它所需的 `.okso` 映像 | `okrun` | +| `self-contained` | `bin/`:本机的程序,以段(`.oke`、`__OKE,__image`)携带映像并经 libokrun 运行,旁边是所需的 `.okso` 映像 | 无 | +| `static` | 拒绝 | — | + +`self-contained` 不重新编译映像:mcpp 生成入口与段,依赖与工程的 `openkal-tools` +同源的 `openkal-run`,再按任何程序的方式打包该程序 —— Linux 上为静态(musl), +macOS 与 Windows 上只引用系统自带的库。段中的字节即 `.oke` 的字节。其他系统的 +程序在该系统上打包。 + ### Android:应用目标文件与它的闭包在 `lib/` 下 ``` diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index 583f1c7b..62b5334e 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -94,7 +94,7 @@ LLVM 词表里「非 MSVC 那套 ABI」的标签,继承自 MinGW,而 clang | 目标 | 格式 | |---|---| -| `x86_64-linux-gnu`、`aarch64-linux-android`、`riscv64-none-elf` | ELF | +| `x86_64-linux-gnu`、`aarch64-linux-android`、`riscv64-none-elf`、`x86_64-openkal` | ELF | | `aarch64-macos`、`aarch64-ios` | Mach-O | | `x86_64-windows-gnu`、`x86_64-windows-msvc` | PE | | `wasm32-emscripten` | wasm | @@ -489,6 +489,8 @@ docs/22。 | `aarch64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `x86_64-ios-sim` | verified | `llvm@23.1.3` | — | — | SDK | — | | `wasm32-emscripten` | verified | `emsdk@6.0.9` | 载荷 | 载荷 | 载荷 | 载荷 | +| `x86_64-openkal` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | +| `aarch64-openkal` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | `载荷` 这里有工具链 payload 产出它 · `图` 没有 payload,但依赖能供给 system · `系统` 在机器上被找到,不是 mcpp 安装的 · `SDK` 平台自己的 · @@ -505,6 +507,7 @@ system · `系统` 在机器上被找到,不是 mcpp 安装的 · `SDK` 平台 | `x86_64-windows-musl` | payload 只在 Windows 上;图则任意宿主 | 没有 gcc 能发出 PE + musl,且 LLVM 拼不出这个三元组 | | `aarch64-macos` | macOS | SDK 是那台机器自己的 | | `*-none-elf` | 每一台宿主 | clang 与 lld 按构造就是交叉编译器 | +| `*-openkal` | 每一台宿主 | 同上;映像为“无操作系统”编译,经 `okrun` 在每个系统上运行([24](24-openkal-cross.md)) | | `wasm32-emscripten`、`*-linux-android` | 每一台宿主 | SDK 自带 sysroot,且上游按宿主分别发布;一份归档服务每个 guest 架构 | **一个 `—` 讲的是 payload,不是可能性。** `host_can_serve` 回答的是 diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index fe1f5ffd..2267d8cc 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -343,6 +343,39 @@ wasm-ld: error: obj/main.o: undefined symbol: kal_process_spawn | Windows | `openkal-windows` | 建在 Win32 与对象管理器之上 | | Web(Emscripten) | `openkal-emscripten` | 写在一个 C 库**之上**;十五个接口中的十二个 | +## 每个系统一份映像:`-openkal`(mcpp 2026.10.26.2) + +上面每个 target 都是每个操作系统一个产物:源码共享,程序不共享。target +`-openkal` 每种**架构**产出一个产物 —— *ok 映像*(openkal SPEC 第 12 章)—— +由加载器在该架构的每个系统上运行。`okrun` 是 Linux、macOS 与 Windows 上的加载器。 + +```toml +[build-dependencies] +openkal-tools = { version = "0.1.0", tools = ["okld", "okrun"] } +``` + +```sh +mcpp build --target x86_64-openkal # bin/app.oke +mcpp run --target x86_64-openkal # okrun bin/app.oke +``` + +| | mcpp 的处理 | 原因 | +|---|---|---| +| 编译 | `--target=-unknown-none-elf -fPIC -femulated-tls`;AArch64 另加 `-ffixed-x18 -mno-outline-atomics` | 映像按“无操作系统”编译;不访问线程指针,不使用两个系统保留的平台寄存器 | +| 链接 | `ld.lld -shared -z max-page-size=65536 -z now`,入口 `ok_main`;被其他映像加载的映像(`kind = "shared"`)为带名字的 `lib.okso` | ET_DYN,每个段按各系统中最大的保护粒度对齐 | +| 收尾 | `okld note` 由映像的强导入写入所需接口;`okld check` 对映像及其所需的 `.okso` 检查每一条约定;通过后才发布 `bin/.oke` | 加载器拒绝需要实现所缺接口的映像并指名该接口;违反约定的映像不会被发布 | +| 运行 | `okrun` 是该 target 的默认 runner,依次在依赖图为本机构建的程序、声明的载荷、PATH 中查找 | 加载器持有 openkal 实现,映像不携带实现 | + +**内核接口固定,其上每一层来自依赖图。** 报告显示 `kernel-abi openkal (the +loader)`。C 库与 C++ 运行时是为该 target 提供 `mcpp:c-abi`、`mcpp:c++-abi` 的包 +(`openkal-musl`、`openkal-llvm-runtime`);两者都没有的映像也合法:它自行定义 +`ok_main` 并提供 `__emutls_get_address`。载荷的 libc++ 为载荷自身的系统配置,不 +提供给映像;依赖图中没有 C++ 运行时时,`import std;` 在编译之前被拒绝,并指名 +提供它的包。 + +**缺少 `okld` 是拒绝而非警告。** 没有需求 note 的映像会被每个加载器拒绝;引擎 +转而指出应添加的依赖。 + ## 裸机 一个没有操作系统的 target,是同一个模型,只是平台层由固件而非内核供给。 diff --git a/modules/manifest/src/targetside_model.cppm b/modules/manifest/src/targetside_model.cppm index b448dc17..ad46b4e6 100644 --- a/modules/manifest/src/targetside_model.cppm +++ b/modules/manifest/src/targetside_model.cppm @@ -566,6 +566,10 @@ struct Inputs { std::string targetOs; // mcpp's own OS field, for the payload interface name std::string targetEnv; // mcpp's own ENV field ("musl", "gnu", …) bool freestandingTarget = false; + // An ok image (`-openkal`): its kernel interface is openkal's, held + // by the loader that runs it rather than by a package or a payload, and + // every layer above it is the graph's or absent. + bool openkalTarget = false; // The compiler, which is always a payload and never a package (see // `layer_is_suppliable_by_package`). Present here so that the layering rule @@ -717,7 +721,10 @@ inline TargetSide resolve(const Inputs& in) { ts.compilerRuntime = { Origin::Graph, in.compilerRuntime->display_interface(), in.compilerRuntime->id(), false }; - else if (in.payloadCompilerRuntimeAbsent) + else if (in.payloadCompilerRuntimeAbsent || in.openkalTarget) + // An ok image's runtime routines are the image's own (a C++ + // run-time package, or the program): the payload's are built for an + // operating system the image does not have. ts.compilerRuntime = { Origin::None, {}, {}, false }; else if (!in.compilerFamily.empty()) ts.compilerRuntime = { Origin::Payload, in.compilerFamily, {}, false }; @@ -726,6 +733,10 @@ inline TargetSide resolve(const Inputs& in) { if (in.kernelAbi) ts.kernelAbi = { Origin::Graph, in.kernelAbi->display_interface(), in.kernelAbi->id(), false }; + else if (in.openkalTarget) + // Fixed by the target: an ok image calls `kal_*` and nothing else, + // and the loader binds them to its implementation (SPEC 12.3). + ts.kernelAbi = { Origin::Payload, "openkal", "the loader (okrun)", false }; else if (in.freestandingTarget) // Not a gap. A bare machine has no kernel, and saying so is the // information: the same target reads `openkal` when an implementation @@ -746,7 +757,7 @@ inline TargetSide resolve(const Inputs& in) { ts.cAbi = { Origin::Xpkg, in.sysrootLibc.empty() ? xpkg_interface(in.sysrootXpkg) : in.sysrootLibc, in.sysrootXpkg, false }; - else if (in.freestandingTarget) + else if (in.freestandingTarget || in.openkalTarget) ts.cAbi = { Origin::None, {}, {}, false }; else ts.cAbi = { Origin::Payload, payload_libc_name(in.targetOs, in.targetEnv), diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index 4517022d..4ac1fe8e 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -227,6 +227,9 @@ struct Triple { // and the OS segment is the platform layer rather than a kernel, which // is why `object_format()` reads the ARCH for this row. if (os == "emscripten") return arch + "-unknown-emscripten"; + // An ok image (openkal SPEC clause 12) is compiled for no operating + // system: what it calls is `kal_*`, bound by the loader that runs it. + if (os == "openkal") return arch + "-unknown-none-elf"; if (os == "none") return str(); // freestanding: already LLVM's form return str(); } @@ -309,6 +312,16 @@ struct Triple { // freestanding decision keys off, spelled once here so no consumer // re-derives it from `os == "none"` and drifts. bool is_freestanding() const { return os == "none"; } + // AN OK IMAGE: ONE ARTIFACT PER ARCHITECTURE THAT A LOADER RUNS ON EVERY + // SYSTEM (openkal SPEC clause 12; design 2026-10-26 part 2, §8.5). + // + // Not freestanding: the program is hosted -- a C library and a C++ run-time + // may be in the image, above `kal_*` -- and the kernel interface is + // openkal's, fixed for the target. Not any operating system either: the + // compiler is told none (`-unknown-none-elf`), `cfg(unix)` and + // `cfg(windows)` are both false, and the system it runs on is the + // loader's, known at run time (`os::host()`). + bool is_openkal() const { return os == "openkal"; } // WHETHER THIS ROW'S TOOLCHAIN PIN IS A CAPABILITY RATHER THAN A // CONVENTION — the distinction that decides whether an author may override @@ -357,7 +370,7 @@ struct Triple { // the triple are the NDK's own requirements, not flags a user can be // expected to supply to a different compiler. bool pin_is_capability() const { - return is_freestanding() || (is_pe() && is_musl()) || is_wasm() + return is_freestanding() || is_openkal() || (is_pe() && is_musl()) || is_wasm() || is_android(); } @@ -870,6 +883,13 @@ inline constexpr TargetInfo kKnownTargets[] = { // directories kept off the ARTIFACT's link line. For every row that // predates this one those two are the same directory. { "wasm32-emscripten", "verified", "wasm","emsdk@6.0.9","", true }, + // OK IMAGES (openkal SPEC clause 12; design 2026-10-26 part 2, §8.5). One + // artifact per architecture, run by `okrun` on Linux, macOS and Windows of + // that architecture. clang emits it from one binary for either row; the C + // library and the C++ run-time are the graph's (`provides`), and an image + // with neither is legal. The two architectures the loader relocates. + { "x86_64-openkal", "preview", "ok", "llvm@23.1.3","", true }, + { "aarch64-openkal", "preview", "ok", "llvm@23.1.3","", true }, }; inline std::span known_targets() { return kKnownTargets; } @@ -1189,6 +1209,14 @@ inline ArtifactNaming artifact_naming(const Triple& t, const ArtifactNaming& hos .sharedNeedsImportLib = false, }; } + // SPEC 12: an executable image is `*.oke`, an image another loads `*.okso`. + if (t.os == "openkal") { + return ArtifactNaming{ + .exeSuffix = ".oke", .libPrefix = "lib", + .staticLibExt = ".a", .sharedLibExt = ".okso", + .sharedNeedsImportLib = false, + }; + } if (t.os == "emscripten") { // `artifact_naming`'s one sentence, unchanged: the executable is the // file a runner executes, in the row's own convention. On this row @@ -1277,7 +1305,7 @@ inline std::string android_abi(const Triple& t) { // a claim nothing can yet check; the unit test asserts every known row's // `platform_name` is a member. inline constexpr std::string_view kPlatformNames[] = { - "linux", "macos", "windows", "ios", "android", "emscripten", + "linux", "macos", "windows", "ios", "android", "emscripten", "openkal", }; inline std::string platform_name(const Triple& t) { @@ -1408,6 +1436,7 @@ std::optional parse(std::string_view s) { // filesystem shim, its `main` loop -- which is the same kind of thing // `linux` names and not the same kind of thing `musl` names. if (starts_with(k, "emscripten")) { t.os = "emscripten"; sawOs = true; t.env.clear(); continue; } + if (k == "openkal") { t.os = "openkal"; sawOs = true; t.env.clear(); continue; } // Bare-metal object-format / ABI segments. Only meaningful with // os=none: `riscv64-none-elf`, `arm-none-eabi`, `arm-none-eabihf`. @@ -1468,7 +1497,7 @@ std::optional parse(std::string_view s) { // macOS carries no env segment at all, so nothing was declined there. iOS // and Emscripten are the same shape: the platform layer is the whole of the // identity past the arch, and there is no C-library axis to decline. - if (t.os == "macos" || t.os == "emscripten" + if (t.os == "macos" || t.os == "emscripten" || t.os == "openkal" || (t.os == "ios" && t.env != "sim")) { t.env.clear(); t.envExplicit = false; } diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 4c1692ca..89c7cbe4 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -21,6 +21,7 @@ import mcpp.diag; import mcpp.build.plan; import mcpp.toolchain.triple; import mcpp.freestanding.runner; +import mcpp.okimage; import mcpp.build.runner_lookup; // #544: where the runner's program is import mcpp.home; // config.toml, for the machine's default toolchain import mcpp.libs.toml; @@ -1206,6 +1207,12 @@ RunnerChoice choose_device_action(const BuildContext& ctx, c.tmpl = mcpp::toolchain::payload_default_runner(ctx.tc.binaryPath); c.fromPayload = !c.tmpl.empty(); } + // AND THE TARGET'S OWN, FOR AN OK IMAGE: the loader is what runs one, on + // every system (SPEC 12). `okrun` is found as any runner's program is -- + // first among the programs the graph built for this machine + // (`openkal-tools`), then in declared payloads, then on PATH. + if (isDefault && c.tmpl.empty() && ft && ft->is_openkal()) + c.tmpl = { std::string(mcpp::okimage::kRunner) }; // `--no-runner` is the operator on THIS host stating a host fact the // manifest cannot carry: the triple is native here. On a freestanding // target that leaves nothing to execute, and the caller's existing diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 9cbe77e0..eaa9b14f 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -20,6 +20,7 @@ import mcpp.build.refusal; import mcpp.diag; import mcpp.freestanding.target; import mcpp.freestanding.linkline; +import mcpp.okimage; import mcpp.manifest.types; import mcpp.manifest.flag_words; import mcpp.modgraph.scanner; @@ -2463,6 +2464,42 @@ CompileFlags compute_flags(const BuildPlan& plan) { } } + // ── An ok image (`-openkal`): the convention's flags, and the link + // by `ld.lld` itself ── + // + // Applied by replacement, after every hosted decision, for the reason the + // freestanding block gives: an image has no loader path, no crt files and + // no system library, and what it holds above `kal_*` is the graph's, + // reached through the graph's own `-L`/`-l`. The compile keeps the hosted + // header model of a graph-supplied C library (`-nostdlibinc`, above); the + // C++ headers are withheld unless the graph supplies them too, because the + // payload's were configured for its own operating system. + if (const auto ot = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); + ot && ot->is_openkal()) { + std::string image; + for (auto const& w : mcpp::okimage::compile_flags(ot->arch)) image += " " + w; + if (!plan.targetSide.cxx.fromGraph()) image += " -nostdinc++"; + // AN IMAGE WITH NO C LIBRARY IS A FREESTANDING C ENVIRONMENT, in the + // standard's sense: nothing defines `strlen`, so the compiler must not + // turn a loop into a call to it. Measured: `--release` of an image + // that defines only `memcpy` and `memset` was refused by `okld check` + // for importing `strlen`, which `-O2` had synthesised. + if (plan.targetSide.cAbi.absent()) image += " -ffreestanding"; + f.cxx += image; + f.cc += image; + f.as += image; + + f.ldDriver = mcpp::freestanding::resolve_lld(plan.toolchain.binaryPath); + f.ld = mcpp::okimage::link_flags() + user_ldflags; + f.ldC = f.ld; + f.sysroot.clear(); + f.bFlag.clear(); + f.ldRuntimeFallback.clear(); + f.linkage = "static"; + for (auto& r : f.ldStdlibByRole) r.clear(); + for (auto& r : f.ldStdlibCByRole) r.clear(); + } + // A LINKER STATED BY ROLE (mcpp#755, `tools = { ld = ... }`), AFTER EVERY // SHAPE HAS BUILT ITS LINE. // diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 1c360686..aa907802 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -44,6 +44,7 @@ import mcpp.toolchain.dialect; import mcpp.toolchain.provider; import mcpp.toolchain.registry; import mcpp.toolchain.triple; // shared_soname_flag decides by TARGET, not host +import mcpp.okimage; import mcpp.toolchain.msvc_coroutines; import mcpp.provider; import mcpp.provider.select; @@ -579,6 +580,8 @@ std::string shared_soname_flag(const LinkUnit& lu, const BuildPlan& plan) { const std::string name = lu.soname.empty() ? lu.output.filename().string() : lu.soname; if (macho) return "-Wl,-install_name,@rpath/" + name; + // An ok image is linked by `ld.lld` itself, which takes the bare flag. + if (t && t->is_openkal()) return lu.soname.empty() ? "" : "-soname " + lu.soname; return lu.soname.empty() ? "" : "-Wl,-soname," + lu.soname; } @@ -592,6 +595,7 @@ std::string shared_soname_default(const LinkUnit& lu, const BuildPlan& plan) { const bool pe = t ? t->is_pe() : bool(mcpp::platform::is_windows); const bool macho = t ? t->is_mach_o() : bool(mcpp::platform::is_macos); if (pe || macho) return ""; + if (t && t->is_openkal()) return "-soname " + lu.output.filename().string(); return "-Wl,-soname," + lu.output.filename().string(); } @@ -1469,6 +1473,36 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, placedFiles.push_back({{pl.source}, pl.dest}); } + // ── An ok image: its notes, and the step that finishes it ────────────── + // + // The notes are one assembly source the engine writes (SPEC 12.4) and one + // object every image links. `okimage_finish` runs `okld note` and `okld + // check` on the linked image and only then publishes it under its name, + // so an image that breaks the convention never reaches `bin/`. + const bool okImageTarget = [&] { + const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); + return t && t->is_openkal(); + }(); + const auto okNoteSource = std::filesystem::path("obj") / "okimage" / "note.S"; + const auto okNoteObject = std::filesystem::path("obj") / "okimage" / "note.o"; + if (okImageTarget) { + std::error_code mkec; + std::filesystem::create_directories((plan.outputDir / okNoteSource).parent_path(), mkec); + write_file(plan.outputDir / okNoteSource, mcpp::okimage::note_source()); + const auto tt = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); + append("rule okimage_note\n"); + append(std::format(" command = $cc --target={} --no-default-config -c $in -o $out\n", + tt->llvm_triple())); + append(" description = NOTE $out\n\n"); + append(std::format("build {} : okimage_note {}\n\n", + escape_ninja_path(okNoteObject), escape_ninja_path(okNoteSource))); + append(std::format("okld = {}\n", ninja_tool_word(plan.okldPath))); + append("okimage_with =\n"); + append("rule okimage_finish\n"); + append(" command = $mcpp okimage-finish --okld $okld -- $in $out $okimage_with\n"); + append(" description = IMAGE $out\n\n"); + } + // ── The raw image a flasher takes ────────────────────────────────────── // // Only freestanding targets get this; a hosted binary is loaded by a @@ -2239,6 +2273,24 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, "$ld /nologo /DLL /OUT:$out $implib_flag $def_flag " "$in $ldflags $unit_ldflags", "SHARED"); + } else if (okImageTarget) { + // AN OK IMAGE, LINKED BY `ld.lld` ITSELF (SPEC 12.2). An executable + // image is ET_DYN with the entry `ok_main`; an image another loads + // has its name and no entry. Each edge writes `.unchecked`; + // the `okimage_finish` edge below writes the requirements note, + // checks the convention and publishes ``. + const std::string entry = std::format("-e {}", mcpp::okimage::kEntry); + link_rule("cxx_link", + "$ld_driver $ldflags $unit_ldflags " + entry + " -o $out $in", "LINK"); + link_rule("c_link", + "$ld_driver $c_ldflags $unit_ldflags " + entry + " -o $out $in", "LINK"); + link_rule("cxx_shared", + "$ld_driver $ldflags $soname_default $soname_flag $unit_ldflags -o $out $in", + "SHARED"); + link_rule("c_shared", + "$ld_driver $c_ldflags $soname_default $soname_flag $unit_ldflags -o $out $in", + "SHARED"); + link_rule("cxx_archive", std::string(dial.archiveCmd), "AR"); } else if (!flags.ldDriver.empty()) { // THE LINKER, NOT THE DRIVER, AND THE OBJECTS COME LAST. // @@ -3242,6 +3294,11 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, for (auto& o : lu.objects) { ins += " " + escape_ninja_path(o); } + // An ok image's notes, in every image this plan links; the image is + // written beside its name and finished by its own edge (above). + const bool okImageUnit = okImageTarget && lu.kind != LinkUnit::StaticLibrary; + if (okImageUnit) ins += " " + escape_ninja_path(okNoteObject); + auto okUnchecked = lu.output; okUnchecked += ".unchecked"; // mcpp#426. `separateLinker` (link.exe) takes no driver at all, so the // question does not arise there and the msvc rules are untouched. @@ -3395,7 +3452,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // order-only list of the edge. if (lu.kind != LinkUnit::StaticLibrary) orderOnly += linkOrderOnly; std::string out_line = std::format("build {}{} : {}{}{}{}\n", - escape_ninja_path(lu.output), implicitOut, rule, ins, + escape_ninja_path(okImageUnit ? okUnchecked : lu.output), implicitOut, rule, ins, implicit.empty() ? std::string{} : " |" + implicit, orderOnly.empty() ? std::string{} : " ||" + orderOnly); if (auto flag = shared_soname_flag(lu, plan); !flag.empty()) @@ -3514,6 +3571,24 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, } append(std::move(out_line)); + // The finished image: its requirements written and the convention + // checked against the images it needs, then published (SPEC 12.4). + if (okImageUnit) { + std::string with, withInputs; + auto add_needed = [&](const std::filesystem::path& p) { + if (p.extension() != ".okso") return; + with += " " + ninja_command_word(p.generic_string()); + withInputs += " " + escape_ninja_path(p); + }; + for (auto const& o : lu.objects) add_needed(o); + for (auto const& o : lu.implicitInputs) add_needed(o); + append(std::format("build {} : okimage_finish {}{}\n", + escape_ninja_path(lu.output), escape_ninja_path(okUnchecked), + withInputs.empty() ? std::string{} : " |" + withInputs)); + if (!with.empty()) append(" okimage_with =" + with + "\n"); + append("\n"); + } + // ── Freestanding artifact set ────────────────────────────────────── // // `.bin` is a real edge on the `.elf`, so ninja rebuilds it when the diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 4d10aa45..65223c91 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -530,6 +530,10 @@ struct BuildPlan { std::map> moduleScopes; std::filesystem::path stdCompatObjectPath; // absolute path to prebuilt std.compat.o std::filesystem::path scanDepsPath; // clang-scan-deps binary (Clang only) + // `okld` for an ok-image target (`-openkal`): the program of the graph + // (or of this machine) that writes each image's requirements note and + // checks the convention after the link (mcpp.okimage). Empty elsewhere. + std::filesystem::path okldPath; // NASM assembly (.asm sources). Both resolved in prepare AFTER the plan // exists — only when the plan actually contains .asm units (lazy, hard // failure when unavailable; never a silent skip). diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index fee3e1e3..02132f22 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -1206,6 +1206,7 @@ static void step6_record_tool_provision(PrepareState& state, HostToolCtx& ctx, const std::filesystem::path& p) { for (std::size_t c = 0; c < state.provisionGraph.visible.size(); ++c) { if (!state.provisionGraph.visible[c].contains(ctx.want)) continue; + if (c == 0) state.rootTools[ctx.toolName] = p; auto& v = state.toolEnvByConsumer[c]; std::vector vars; for (auto const& n : state.publishedNamesFor(ctx.depIdx, state.bareBindingsFor(c))) { diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 8b0eeafe..cb42cd9e 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -3,6 +3,7 @@ // resolution.json are written by records.cpp. module mcpp.build.prepare; +import mcpp.okimage; import :state; import mcpp.build.prepare_inputs; @@ -225,6 +226,14 @@ static std::expected step13_source_packages(PrepareState& sta } static std::expected step13_runner_and_xlings(PrepareState& state, BuildContext& ctx) { + // THE PROGRAMS THE GRAPH BUILT FOR THIS MACHINE COME FIRST. A runner a + // dependency's `tools` built (`okrun` from `openkal-tools`) is the one the + // project asked for by depending on it, ahead of a payload's and of PATH. + for (auto const& [name, path] : state.rootTools) { + auto dir = path.parent_path(); + if (std::ranges::find(ctx.xlingsDepBinDirs, dir) == ctx.xlingsDepBinDirs.end()) + ctx.xlingsDepBinDirs.push_back(std::move(dir)); + } // Where a runner may find the programs this project declared (#544). The // same resolution `fillXpkgDirs` hands to build programs, kept as // directories rather than env vars because the reader is mcpp's own @@ -1474,6 +1483,29 @@ static std::expected step13_build_graph_actions(PrepareState& } static std::expected step13_assembly_units(PrepareState& state, BuildContext& ctx) { + // AN OK IMAGE IS FINISHED BY `okld` (mcpp.okimage): the graph's, a + // declared payload's, or this machine's, in that order. Its absence is a + // refusal before anything compiles, naming the dependency that supplies + // it -- not a link that succeeds and an image no loader accepts. + // After `make plan`, which builds the plan these fields belong to. + if (auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple); tt && tt->is_openkal()) { + const std::string tool(mcpp::okimage::kTool); + if (auto it = state.rootTools.find(tool); it != state.rootTools.end()) { + ctx.plan.okldPath = it->second; + } else { + for (auto const& dir : ctx.xlingsDepBinDirs) { + for (auto const& name : { tool, tool + ".exe" }) { + std::error_code ec; + if (std::filesystem::is_regular_file(dir / name, ec)) { ctx.plan.okldPath = dir / name; break; } + } + if (!ctx.plan.okldPath.empty()) break; + } + if (ctx.plan.okldPath.empty()) + if (auto found = mcpp::platform::fs::which(tool)) ctx.plan.okldPath = *found; + } + if (ctx.plan.okldPath.empty() && !state.overrides.plan_only) + return std::unexpected(mcpp::okimage::no_tool_message(tt->str(), tool)); + } // Clang: discover clang-scan-deps for P1689 dyndep scanning. if (mcpp::toolchain::is_clang(*state.tc)) { if (auto sd = mcpp::toolchain::clang::find_scan_deps(*state.tc)) { diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index 4e4a9e5e..30fe8272 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -399,6 +399,12 @@ step11_package_std_module_source(PrepareState& state) { // Both are read only from a package that ALSO provides the capability // below. A package that named a std module without supplying the // library would be describing something it does not have. + // AN OK IMAGE'S STANDARD LIBRARY IS THE GRAPH'S OR NONE. The payload's + // libc++ was configured for the payload's own operating system; inside an + // image it would be a second answer to a question the image's C++ + // run-time package answers. + if (auto ft = mcpp::toolchain::triple::parse(state.tc->targetTriple); ft && ft->is_openkal()) + state.tc->clear_std_modules(); for (auto& pkg : state.packages) { if (pkg.manifest.stdModule.empty()) continue; // Either spelling of the C++ layer (see `provides_cxx_layer`). The @@ -713,6 +719,21 @@ step11_std_module_availability_gate(PrepareState& state, bool needsStdModule) { " exports `mcpplibs.riscv_virt_rt`).", state.tc->targetTriple)); } + if (auto ft = mcpp::toolchain::triple::parse(state.tc->targetTriple); + ft && ft->is_openkal()) + { + return std::unexpected(std::format( + "`import std;` on '{}' needs a C++ run-time inside the image, and " + "the graph supplies none.\n" + " An ok image holds its own C library and C++ run-time, " + "above `kal_*`. Depend on\n" + " the packages that provide them for this target:\n" + "\n" + " [target.'cfg(os = \"openkal\")'.dependencies]\n" + " openkal-musl = \"0.22.0\" # mcpp:c-abi=musl\n" + " openkal-llvm-runtime = \"0.16.0\" # mcpp:c++-abi=libc++, the std module", + state.tc->targetTriple)); + } return std::unexpected(std::format( "source imports std but toolchain '{}' provides no std module source", state.tc->label())); diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 1bda1fa1..284f49e3 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -776,6 +776,10 @@ struct PrepareState { std::map resolved; std::map>> toolEnvByConsumer; + // The programs the graph built for this machine at the ROOT's request + // (`tools = [...]`), by program name. A runner's program and an ok image's + // `okld` are looked up here first. + std::map rootTools; std::function foldDirectiveTailIntoPrivateBuild; diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index c6b8fdd7..7885708f 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -262,6 +262,7 @@ step9_gather_target_side_candidates(PrepareState& state) { in.targetOs = tt->os; in.targetEnv = tt->env; in.freestandingTarget = tt->is_freestanding(); + in.openkalTarget = tt->is_openkal(); // NOT `tt->envExplicit`. By this line the triple has been // canonicalised, and the canonical form of `x86_64-linux` is // `x86_64-linux-gnu` — re-parsing it reports a segment the @@ -463,7 +464,10 @@ step9_resolve_and_realise_cabi(PrepareState& state, TargetSideGather& gather) { auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple); auto realised = mcpp::toolchain::cenv::realise( *state.resolvedTargetSide.cAbiDecl, tt ? tt->os : std::string{}, - tt ? tt->arch : std::string{}, tt && tt->is_freestanding()); + tt ? tt->arch : std::string{}, + // An ok image is told no operating system, as a bare machine + // is: the C library's identity macros are the only ones. + tt && (tt->is_freestanding() || tt->is_openkal())); if (!realised) { refusal::record(refusal::Code::CEnvUnrealisable); return std::unexpected(realised.error()); @@ -624,7 +628,12 @@ step9_resolve_and_realise_cabi(PrepareState& state, TargetSideGather& gather) { // availability check links and runs without the archive; refusing it // would trade a diagnosed hazard for a regression. The degradation // names the platform, the file and the package that supplies it. - if (state.resolvedTargetSide.compilerRuntime.absent() && state.tc) { + // An ok image's runtime routines are the image's own by design + // (mcpp.okimage), so its absence there is not a gap to report. + if (auto okt = state.tc ? mcpp::toolchain::triple::parse(state.tc->targetTriple) + : std::optional{}; + state.resolvedTargetSide.compilerRuntime.absent() && state.tc + && !(okt && okt->is_openkal())) { auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple); mcpp::diag::degraded("target/compiler-runtime", std::format( "the toolchain payload carries no compiler runtime for {} " @@ -1195,8 +1204,11 @@ step9_same_os_check_and_report(PrepareState& state) { "same-OS check: '{}'(os={}) vs '{}'(os={})", reportedTargetName, want ? want->os : "", state.resolvedTargetSide.llvmTriple, got ? got->os : "")); + // An ok image is compiled for no operating system by design + // (`-unknown-none-elf`): the loader is its environment. + const bool okImage = want && got && want->is_openkal() && got->is_freestanding(); if (want && got && !want->os.empty() && !got->os.empty() - && want->os != got->os) { + && want->os != got->os && !okImage) { refusal::record(refusal::Code::OsMismatch); return std::unexpected(std::format( "target '{}' resolved to a toolchain for '{}'.\n" diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index bfbbe11a..b167dd26 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -1061,6 +1061,10 @@ step1_target_row_pin_and_capability_check(PrepareState& state, TargetOverrideCtx ? "A freestanding target has no per-host cross payload: " "clang and lld are\n" " cross-compilers by construction and gcc is not." + : ctx.parsed->is_openkal() + ? "An ok image is emitted by clang and lld for every host: " + "the compiler is told\n" + " no operating system, and gcc has no such target." : ctx.parsed->is_wasm() ? "Nothing but Emscripten emits WebAssembly: `em++` is a " "clang whose target,\n" diff --git a/src/cli.cppm b/src/cli.cppm index cae0453c..894e7a52 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1207,6 +1207,12 @@ int run(int argc, char** argv) { .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_link_output(p, std::span(passthrough)); }))) + .subcommand(cl::App("okimage-finish") + .description("(internal: invoked by ninja for -openkal) Write an ok image's requirements note and check it with okld, then publish it") + .option(cl::Option("okld").takes_value().value_name("PATH").help("the okld program")) + .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + return cmd_okimage_finish(p, std::span(passthrough)); + }))) .subcommand(cl::App("place-dlls") .description("(internal: invoked by ninja) Place beside a Windows program the DLLs it imports from its runtime search directories") .option(cl::Option("output").takes_value().value_name("PATH").help("the stamp to write")) @@ -1676,7 +1682,7 @@ int run(int argc, char** argv) { "update", "search", "publish", "pack", "emit", "xpkg", "toolchain", "cache", "index", "self", "explain", "version", "dyndep", "why", "resolve", "stage", "bmi-equal", "coff-def", - "place-dlls", "link-output", + "place-dlls", "link-output", "okimage-finish", "bmi-compile", "bmi-supervise", "bmi-await", }); bool ok = false; diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index d5289867..f8154b27 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -1472,6 +1472,61 @@ export int cmd_link_output(const mcpplibs::cmdline::ParsedArgs& parsed, return mcpp::platform::process::run_exec(std::vector(command.begin(), command.end())); } +// `mcpp okimage-finish --okld -- […]` +// (internal: invoked by build.ninja for the target `-openkal`; design +// 2026-10-26 part 2, §8.5). +// +// AN IMAGE IS PUBLISHED ONLY ONCE IT MEETS THE CONVENTION. The linked file is +// copied beside the image's name, `okld note` writes its requirements note from +// its strong imports, `okld check` checks every clause of SPEC 12.2 against it +// and the images it needs, and only then does the copy take the name. A check +// that fails leaves no image behind, and the edge runs again next time. +export int cmd_okimage_finish(const mcpplibs::cmdline::ParsedArgs& parsed, + std::span rest) { + namespace fs = std::filesystem; + const std::string okld = parsed.option_or_empty("okld").value(); + if (okld.empty() || rest.size() < 2) { + std::println(stderr, "error: okimage-finish needs --okld -- [needed…]"); + return 2; + } + const fs::path linked = rest[0], image = rest[1]; + auto tmp = image; tmp += ".tmp"; + std::error_code ec; + fs::copy_file(linked, tmp, fs::copy_options::overwrite_existing, ec); + if (ec) { + std::println(stderr, "error: cannot copy {} to {}: {}", linked.string(), tmp.string(), ec.message()); + return 1; + } + auto run = [&](std::vector argv) { + int spawnError = 0; + auto r = mcpp::platform::process::capture_exec(argv, {}, {}, &spawnError); + if (spawnError != 0) { + std::println(stderr, "error: cannot start {}: {}", okld, + std::generic_category().message(spawnError)); + return false; + } + if (r.exit_code != 0) { + std::print(stderr, "{}", r.output); + return false; + } + return true; + }; + std::vector check{ okld, "check", tmp.string() }; + for (std::size_t i = 2; i < rest.size(); ++i) { check.push_back("--with"); check.push_back(rest[i]); } + if (!run({ okld, "note", tmp.string() }) || !run(check)) { + fs::remove(tmp, ec); + std::println(stderr, "error: {} does not meet the ok-image convention (openkal SPEC 12)", + image.filename().string()); + return 1; + } + fs::rename(tmp, image, ec); + if (ec) { + std::println(stderr, "error: cannot publish {}: {}", image.string(), ec.message()); + return 1; + } + return 0; +} + export int cmd_stage(const mcpplibs::cmdline::ParsedArgs& parsed) { // `--list FILE` (#734 E4): many placements, one process. Each destination // keeps the single-file semantics below -- content comparison, an diff --git a/src/okimage/okimage.cppm b/src/okimage/okimage.cppm new file mode 100644 index 00000000..f55e49c1 --- /dev/null +++ b/src/okimage/okimage.cppm @@ -0,0 +1,93 @@ +// mcpp.okimage — what an ok image IS, for the target `-openkal` +// (openkal SPEC clause 12; design 2026-10-26 part 2, §8.5). +// +// One artifact per architecture, which a loader (`okrun`) runs on every system +// of that architecture. The facts below are the convention's, stated once: +// the compile flags that keep an image runnable everywhere, the link that +// produces the shape the loader accepts, and the note the loader reads. The +// format itself -- its reader, its checker, its signature -- is libokld's, and +// the engine reaches it through `okld`, a program of the dependency graph, +// never through a second implementation here. +// +// WHAT IS NOT HERE: the C library and the C++ run-time inside an image. They +// are packages (`provides = ["mcpp:c-abi=…"]`), and an image with neither is +// legal: its entry is its own `ok_main`. + +export module mcpp.okimage; + +import std; + +export namespace mcpp::okimage { + +// The entry of an executable image (SPEC 12.2): `int ok_main(void)`, in the +// image's convention, whose value is the image's status. A C library inside +// the image defines it and calls `main` with `kal_env_arg`'s arguments. +inline constexpr std::string_view kEntry = "ok_main"; + +// The programs of the graph the engine runs for this target. +inline constexpr std::string_view kTool = "okld"; // note, check +inline constexpr std::string_view kRunner = "okrun"; // `mcpp run`, `mcpp test` + +// FLAGS EVERY TRANSLATION UNIT OF AN IMAGE CARRIES (SPEC 12.2). +// +// -fPIC the image is ET_DYN and has no text relocation; +// -femulated-tls a variable is found through `__emutls_get_address`, +// which the image supplies: one system does not let a +// program set the thread pointer, another does not let it +// change it; +// -ffixed-x18 (AArch64) two systems reserve the platform register; +// -mno-outline-atomics +// (AArch64) the outlined helpers read the processor's +// features through an operating system's interface. +inline std::vector compile_flags(std::string_view arch) { + std::vector out{ "-fPIC", "-femulated-tls" }; + if (arch == "aarch64") { + out.emplace_back("-ffixed-x18"); + out.emplace_back("-mno-outline-atomics"); + } + return out; +} + +// THE LINK, BY `ld.lld` ITSELF (SPEC 12.2). Position-independent, every +// loadable segment aligned to 64 KiB (the largest protection granularity among +// the systems), bound now, no executable stack, and both hash tables, which a +// loader may use either of. +inline std::string link_flags() { + return " -shared -z max-page-size=65536 -z now -z noexecstack --hash-style=both"; +} + +// The notes every image carries (SPEC 12.4): the requirements, which `okld +// note` writes from the image's strong imports after the link, and the +// signature, all zero until `okld sign`. The same text `okld note-source` +// prints. +inline std::string_view note_source() { + return + "# The two notes of an ok image (openkal SPEC 12.4), written by mcpp.\n" + "\t.section .note.openkal,\"a\",@note\n" + "\t.p2align 2\n" + "\t.long 8, 16, 1\n" + "\t.asciz \"OPENKAL\"\n" + "\t.long 1, 0\n" + "\t.quad 0\n" + "\t.p2align 2\n" + "\t.long 8, 96, 2\n" + "\t.asciz \"OPENKAL\"\n" + "\t.fill 96, 1, 0\n"; +} + +// The diagnostic for a build of this target with no `okld` to finish it. It +// names the dependency that supplies both programs, and where to write it. +inline std::string no_tool_message(std::string_view triple, std::string_view tool) { + return std::format( + "'{}' builds ok images, and `{}` -- the format's tool, which writes an " + "image's requirements note and checks the convention -- is not provided.\n" + " Declare the package that builds it for this machine:\n" + "\n" + " [build-dependencies]\n" + " openkal-tools = {{ version = \"0.1.0\", tools = [\"okld\", \"okrun\"] }}\n" + "\n" + " `okrun` is then the runner of `mcpp run` and `mcpp test` for this target.", + triple, tool); +} + +} // namespace mcpp::okimage diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 79fbbd2f..dc359c19 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -245,6 +245,10 @@ struct Plan { // the file `.js` in the first place (see `artifact_naming`), so it is the // one fact this format IS recorded under. bool targetIsWasm = false; + // Is the artifact an ok image (`-openkal`, openkal SPEC 12)? From the + // triple, as for wasm: the file is an ELF shared object that no loader of + // this machine runs, and its closure is the images it names. + bool targetIsOkImage = false; // #622 A3/A10: is `builtBinary` an `Application` target whose link form // on THIS row is a shared object (`*-linux-android`, // `toolchain::triple::application_form`)? Read from the CALLER, which @@ -651,6 +655,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, if (auto t = mcpp::toolchain::triple::parse(p.triple)) { p.targetIsWasm = t->object_format() == mcpp::toolchain::triple::ObjectFormat::Wasm; + p.targetIsOkImage = t->is_openkal(); } // THE CONTRACT REACHES PACKAGING. Until now it stopped at the compile and @@ -1970,6 +1975,188 @@ run_wasm(const Plan& plan) return make_tarball(plan.stagingRoot, plan.archivePath); } +// AN OK IMAGE (`-openkal`; design 2026-10-26 part 2, §8.2 and §8.5). +// +// The modes keep their meaning -- what the machine that runs the package must +// provide -- and on this row they are: +// +// system the image alone; the machine provides `okrun` and every +// image it names (M2); +// vendored the image and the images it names (`DT_NEEDED`), beside it; +// the machine provides `okrun` (M2, the default); +// self-contained a program of THIS machine that carries the image as a +// section and runs it through libokrun (M3), with the images +// it names beside it; the machine provides nothing. The image +// is not recompiled: the program is a generated entry, the +// image's bytes and `openkal-run`, built by mcpp for this +// machine. Another system's M3 is packed on that system. +// static refused: an image is loaded, never linked into one file +// with its loader by `ld`. +std::expected +okimage_build_selfcontained(const Plan& plan, const std::filesystem::path& image) +{ + namespace fs = std::filesystem; + // `openkal-run` from the source the project's `openkal-tools` came from: + // the same repository at the same reference selects its member by key + // (05 -- Dependencies), and a published version has a sibling of the same + // version. + auto man = mcpp::manifest::load(plan.projectRoot / "mcpp.toml"); + if (!man) return std::unexpected(Error{std::format( + "cannot read {}: {}", (plan.projectRoot / "mcpp.toml").string(), man.error().format())}); + const mcpp::manifest::DependencySpec* tools = nullptr; + for (auto const& [key, spec] : man->buildDependencies) + if (spec.shortName == "openkal-tools") { tools = &spec; break; } + if (!tools) return std::unexpected(Error{ + "--mode self-contained of an ok image builds a program over `openkal-run` from the " + "source of the project's `openkal-tools`, and [build-dependencies] declares none"}); + std::string runSpec; + if (!tools->git.empty()) { + runSpec = std::format("{{ git = \"{}\", {} = \"{}\" }}", tools->git, + tools->gitRefKind.empty() ? "rev" : tools->gitRefKind, tools->gitRev); + } else if (!tools->path.empty()) { + auto root = fs::path(tools->path); + if (root.is_relative()) root = plan.projectRoot / root; + runSpec = std::format("{{ path = \"{}\" }}", (root.parent_path() / "run").generic_string()); + } else { + runSpec = std::format("\"{}\"", tools->version); + } + + const auto stem = fs::path(plan.binaryName).stem().string(); + const auto dir = plan.projectRoot / "target" / "okimage-pack" / stem; + std::error_code ec; + fs::create_directories(dir / "src", ec); + if (ec) return std::unexpected(Error{std::format("cannot create {}: {}", dir.string(), ec.message())}); + + auto write = [&](const fs::path& p, std::string_view text) -> bool { + std::ofstream out(p, std::ios::binary | std::ios::trunc); + out << text; + return bool(out); + }; + const std::string manifest = std::format( + "# Generated by `mcpp pack --mode self-contained` for {}: a program of this\n" + "# machine that carries the ok image as a section and runs it (M3).\n" + "[package]\nname = \"{}\"\nversion = \"{}\"\n\n" + "[targets.{}]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n\n" + "[build]\nsources = [\"src/main.cpp\", \"src/image.S\"]\n\n" + "[dependencies]\nopenkal-run = {}\n", + plan.binaryName, stem, plan.packageVersion.empty() ? "0.0.0" : plan.packageVersion, + stem, runSpec); + // The section, in the syntax of this machine's object format; the symbol + // spelling carries Mach-O's leading underscore. + const bool macho = mcpp::platform::is_macos; + const bool coff = mcpp::platform::is_windows; + const std::string sym = macho ? "_" : ""; + const std::string section = macho ? "\t.section __OKE,__image\n" + : coff ? "\t.section .oke,\"dr\"\n" + : "\t.section .oke,\"a\",@progbits\n"; + const std::string assembly = std::format( + "# The image's bytes, unchanged (generated by mcpp pack).\n{}" + "\t.p2align 4\n\t.globl {}ok_image_begin\n{}ok_image_begin:\n" + "\t.incbin \"{}\"\n\t.globl {}ok_image_end\n{}ok_image_end:\n", + section, sym, sym, fs::absolute(image).generic_string(), sym, sym); + const std::string main = std::format( + "// Generated by mcpp pack: runs the image this program carries (M3).\n" + "#include \n" + "#include \n" + "extern \"C\" const unsigned char ok_image_begin[], ok_image_end[];\n" + "int main(int argc, char** argv) {{\n" + " char why[512];\n" + " okrun_run_bytes(ok_image_begin, static_cast(ok_image_end - ok_image_begin),\n" + " \"{}\", argc, const_cast(argv), nullptr, why, sizeof why);\n" + " std::fprintf(stderr, \"{}: %s\\n\", why);\n" + " return 127;\n" + "}}\n", + plan.binaryName, stem); + if (!write(dir / "mcpp.toml", manifest) || !write(dir / "src" / "image.S", assembly) + || !write(dir / "src" / "main.cpp", main)) + return std::unexpected(Error{std::format("cannot write the program's sources under {}", dir.string())}); + + // PACKED, NOT MERELY BUILT: the program obeys every rule this command + // keeps for a program -- no build-machine path, its closure beside it. On + // Linux it is static (musl), the one form that runs on every distribution; + // on macOS and Windows the system's own libraries are the only ones it + // names. + mcpp::ui::info("Packing", std::format("{} into a program of this machine (okrun, M3)", plan.binaryName)); + // `-o` with a bare name: the package directory is `target/dist/`. + const auto out = dir / "target" / "dist" / "packed"; + fs::remove_all(out, ec); + std::vector argv{ mcpp::platform::fs::self_exe_path().string(), "pack", + "--format", "dir", "-o", "packed" }; + if constexpr (mcpp::platform::is_linux) { + argv.insert(argv.end(), { "--target", std::format("{}-linux-musl", mcpp::platform::host_arch), + "--mode", "static" }); + } else { + argv.insert(argv.end(), { "--mode", "vendored" }); + } + int spawnError = 0; + auto built = mcpp::platform::process::capture_exec(argv, {}, dir.string(), &spawnError); + if (spawnError != 0 || built.exit_code != 0) + return std::unexpected(Error{std::format( + "packing the self-contained program failed (in {}):\n{}", dir.string(), built.output)}); + if (!fs::is_directory(out / "bin", ec)) + return std::unexpected(Error{std::format( + "the self-contained program's package under {} has no bin/", out.string())}); + return out; +} + +std::expected +run_okimage(const Plan& plan) +{ + namespace fs = std::filesystem; + if (plan.opts.mode == Mode::Static) + return std::unexpected(Error{std::format( + "--mode static does not apply to an ok image: an image is loaded, not linked " + "with its loader into one file.\n" + " `--mode self-contained` packs a program of this machine that carries {} " + "and needs nothing installed.", plan.binaryName)}); + std::error_code ec; + fs::remove_all(plan.stagingRoot, ec); + fs::create_directories(plan.stagingRoot / "bin", ec); + if (ec) return std::unexpected(Error{std::format( + "cannot create staging '{}': {}", plan.stagingRoot.string(), ec.message())}); + + // The images it names, found where the build placed them. + std::vector needed; + if (plan.opts.mode != Mode::None) { + auto facts = mcpp::platform::elf::inspect_elf_runtime(plan.builtBinary); + if (!facts) return std::unexpected(Error{facts.error()}); + const auto builtDir = plan.builtBinary.parent_path(); + for (auto const& name : facts->needed) { + fs::path hit; + for (auto const& d : { builtDir, builtDir.parent_path() / "lib" }) + if (fs::is_regular_file(d / name, ec)) { hit = d / name; break; } + if (hit.empty()) return std::unexpected(Error{std::format( + "{} names the image '{}', which the build did not place beside it", + plan.binaryName, name)}); + needed.push_back(hit); + } + } + + fs::path staged; + if (plan.opts.mode == Mode::BundleAll) { + auto packed = okimage_build_selfcontained(plan, plan.builtBinary); + if (!packed) return std::unexpected(packed.error()); + fs::copy(*packed, plan.stagingRoot, + fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); + staged = plan.stagingRoot / "bin" + / (fs::path(plan.binaryName).stem().string() + std::string(mcpp::platform::exe_suffix)); + } else { + staged = plan.stagingRoot / "bin" / plan.binaryName; + fs::copy_file(plan.builtBinary, staged, fs::copy_options::overwrite_existing, ec); + } + if (ec) return std::unexpected(Error{std::format("copy {} failed: {}", staged.string(), ec.message())}); + for (auto const& n : needed) { + fs::copy_file(n, plan.stagingRoot / "bin" / n.filename(), fs::copy_options::overwrite_existing, ec); + if (ec) return std::unexpected(Error{std::format("copy {} failed: {}", n.string(), ec.message())}); + } + + if (auto r = stage_runtime_files(plan, staged.parent_path()); !r) return r; + copy_if_exists(plan.projectRoot / "README.md", plan.stagingRoot); + copy_if_exists(plan.projectRoot / "LICENSE", plan.stagingRoot); + if (plan.opts.format != Format::Tar) return {}; + return make_tarball(plan.stagingRoot, plan.archivePath); +} + // #622 A3/A10: the Application half whose form on this row is a shared // object (`*-linux-android`). The object is staged under `lib/`, where every // Android packaging tool expects native libraries, and it is never executed: @@ -2159,6 +2346,13 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) return ClosureResult{}; } + // An ok image: the images it names travel beside it, and under + // `self-contained` the image is carried inside a program of this machine. + if (plan.targetIsOkImage) { + if (auto r = detail::run_okimage(plan); !r) return std::unexpected(r.error()); + return ClosureResult{}; + } + using namespace detail; // STEPS 1-3: stage what is DECLARED before asking whether this host can diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index d95b5446..21b4b791 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -1439,6 +1439,9 @@ bool host_can_serve(const triple::Triple& target) { // (a libc wrapper package), and saying `false` here would hide it behind // "this host cannot build it", which is the wrong diagnosis. if (target.is_freestanding()) return true; + // An ok image the same way: clang and lld emit it on every host, and what + // the image holds above `kal_*` is the graph's. + if (target.is_openkal()) return true; return false; } diff --git a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh new file mode 100755 index 00000000..52964298 --- /dev/null +++ b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# requires: llvm python3 +# 942 -- an ok image is built for `-openkal` and run by okrun (design +# 2026-10-26 part 2, §8.5; openkal SPEC clause 12). +# +# The target compiles for no operating system (`-unknown-none-elf`) with +# the convention's flags, links with `ld.lld` into an ET_DYN image entered at +# `ok_main`, and publishes the image only after `okld note` has written its +# requirements and `okld check` has checked every clause. `okld` and `okrun` are +# programs of the graph: `openkal-tools`, built for this machine through +# `[build-dependencies]`. `okrun` is the target's runner. +# +# Criteria: +# A. Both rows build: `x86_64-openkal` and `aarch64-openkal` each produce +# `bin/app.oke`, an ELF shared object of its architecture. +# B. `mcpp run` on the row of this machine's architecture runs the image +# through okrun: its arguments, a callback into the image, thread-local +# storage and a by-value structure all arrive. +# C. An image that imports a C library's name (`puts`) is refused by the +# check, which names the name; the image under `bin/` is still the one the +# last successful build published. +# D. `import std;` with no C++ run-time in the graph is refused before +# anything compiles, naming the packages that provide one. +# E. A second build is up to date. +# F. `mcpp pack` stages the image (M2), and `--mode self-contained` stages a +# program of this machine that carries it and runs with nothing installed +# (M3). +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +case "$(uname -m)" in + x86_64|amd64) HOST_ARCH=x86_64 ;; + aarch64|arm64) HOST_ARCH=aarch64 ;; + *) echo "SKIP: no ok-image row for $(uname -m)"; exit 0 ;; +esac + +TOOLS='openkal-tools = { git = "https://github.com/mcpplibs/openkal-image.git", branch = "openkal-0.17.0", tools = ["okld", "okrun"] }' + +mkdir -p "$TMP/app/src" +cd "$TMP/app" +cat > mcpp.toml < src/main.c <<'C' +/* An image with no C library: it calls kal_* and supplies the two routines + * the compiler may emit, and the emulated thread-local storage the + * convention requires (SPEC 12.2). */ +typedef unsigned long kal_uintptr; +typedef long kal_intptr; +struct kal_stream { kal_uintptr h; }; +struct kal_task { kal_uintptr h; }; +struct kal_stream kal_stdout(void); +kal_intptr kal_stream_write(struct kal_stream, const void*, kal_uintptr); +kal_uintptr kal_env_arg_count(void); +int kal_task_start(void (*entry)(void*), void* arg, struct kal_task* out); +int kal_task_join(struct kal_task); +kal_uintptr kal_task_current(void); +void* kal_alloc(kal_uintptr size, kal_uintptr align); + +void* memcpy(void* d, const void* s, unsigned long n) { + unsigned char* a = d; const unsigned char* b = s; while (n--) *a++ = *b++; return d; +} +void* memset(void* d, int c, unsigned long n) { + unsigned char* a = d; while (n--) *a++ = (unsigned char)c; return d; +} + +struct emutls_control { unsigned long size, align; union { unsigned long index; void* address; } object; void* value; }; +static struct { kal_uintptr id; void* vars[8]; } g_ctx[16]; +static unsigned long g_next; +static volatile int g_lock; +static void lock(void) { while (__atomic_exchange_n(&g_lock, 1, __ATOMIC_ACQUIRE)) {} } +static void unlock(void) { __atomic_store_n(&g_lock, 0, __ATOMIC_RELEASE); } +void* __emutls_get_address(struct emutls_control* c) { + lock(); + if (c->object.index == 0) c->object.index = ++g_next; + const unsigned long index = c->object.index; + const kal_uintptr self = kal_task_current(); + int slot = -1; + for (int i = 0; i < 16; ++i) if (g_ctx[i].id == self) { slot = i; break; } + if (slot < 0) for (int i = 0; i < 16; ++i) if (g_ctx[i].id == 0) { g_ctx[i].id = self; slot = i; break; } + void** var = &g_ctx[slot].vars[index - 1]; + if (!*var) { + unsigned char* p = kal_alloc(c->size, c->align ? c->align : 8); + if (c->value) memcpy(p, c->value, c->size); else memset(p, 0, c->size); + *var = p; + } + void* out = *var; + unlock(); + return out; +} + +static void put(const char* s) { unsigned long n = 0; while (s[n]) ++n; kal_stream_write(kal_stdout(), s, n); } +static __thread long tl = 7; +static void worker(void* arg) { long* seen = arg; *seen = tl; tl = 99; } + +int ok_main(void) { + put(kal_env_arg_count() == 3 ? "image: three arguments\n" : "image: arguments lost\n"); + tl = 1; + long seen = 0; + struct kal_task t; + if (kal_task_start(worker, &seen, &t) == 0) kal_task_join(t); + put(seen == 7 && tl == 1 ? "image: each context has its own storage\n" : "image: storage shared\n"); + return 0; +} +C + +# A +for arch in x86_64 aarch64; do + "$MCPP" build --target "$arch-openkal" > "build-$arch.log" 2>&1 \ + || fail "A: $arch-openkal did not build" "build-$arch.log" + img=$(ls target/"$arch"-openkal/*/bin/app.oke 2>/dev/null | head -1) + [ -n "$img" ] || fail "A: $arch-openkal published no bin/app.oke" "build-$arch.log" + python3 - "$img" "$arch" <<'PY' || fail "A: $arch-openkal's image is not an ET_DYN of its architecture" +import struct, sys +d = open(sys.argv[1], 'rb').read(64) +machine = {'x86_64': 62, 'aarch64': 183}[sys.argv[2]] +ok = d[:4] == b'\x7fELF' and struct.unpack_from(' run.log 2>&1 \ + || fail "B: the image did not run through okrun" run.log +grep -q "image: three arguments" run.log || fail "B: the image's arguments" run.log +grep -q "image: each context has its own storage" run.log || fail "B: thread-local storage or the callback" run.log +echo "ok: okrun runs the image of this machine's architecture" + +# E +"$MCPP" build --target "$HOST_ARCH-openkal" > again.log 2>&1 || fail "E: the second build failed" again.log +grep -q "Compiling" again.log && fail "E: the second build compiled again" again.log +echo "ok: a second build is up to date" + +# F +"$MCPP" pack --target "$HOST_ARCH-openkal" --format dir -o m2 > m2.log 2>&1 || fail "F: pack (M2)" m2.log +[ -f target/dist/m2/bin/app.oke ] || fail "F: the M2 package has no bin/app.oke" m2.log +"$MCPP" pack --target "$HOST_ARCH-openkal" --mode self-contained --format dir -o m3 > m3.log 2>&1 \ + || fail "F: pack --mode self-contained (M3)" m3.log +m3=$(ls target/dist/m3/bin/app target/dist/m3/bin/app.exe 2>/dev/null | head -1) +[ -n "$m3" ] || fail "F: the M3 package has no program" m3.log +"$m3" one two > m3run.log 2>&1 || fail "F: the M3 program did not run" m3run.log +grep -q "image: three arguments" m3run.log || fail "F: the M3 program's image" m3run.log +echo "ok: pack stages the image (M2) and a program that carries it (M3)" + +# C +before=$(cksum < "$(ls target/"$HOST_ARCH"-openkal/*/bin/app.oke | head -1)" ) +cat >> src/main.c <<'C' +int puts(const char*); +void uses_a_c_library(void) { puts("x"); } +C +if "$MCPP" build --target "$HOST_ARCH-openkal" > refuse.log 2>&1; then + fail "C: an image importing puts was published" refuse.log +fi +grep -q "puts" refuse.log || fail "C: the refusal does not name the import" refuse.log +grep -q "ok-image convention" refuse.log || fail "C: the refusal does not name the convention" refuse.log +after=$(cksum < "$(ls target/"$HOST_ARCH"-openkal/*/bin/app.oke | head -1)" ) +[ "$before" = "$after" ] || fail "C: the refused image took the name in bin/" refuse.log +echo "ok: an image that imports a C library's name is refused and not published" + +# D +mkdir -p "$TMP/std/src" +cd "$TMP/std" +cat > mcpp.toml < src/main.cpp +if "$MCPP" build --target "$HOST_ARCH-openkal" > std.log 2>&1; then + fail "D: import std built without a C++ run-time" std.log +fi +grep -q "needs a C++ run-time inside the image" std.log || fail "D: the refusal's reason" std.log +grep -q "openkal-llvm-runtime" std.log || fail "D: the refusal does not name the provider" std.log +echo "ok: import std without a C++ run-time is refused, naming the provider" + +echo "PASS: 942 an ok image is built once and run by okrun" diff --git a/tests/unit/test_artifact_naming.cpp b/tests/unit/test_artifact_naming.cpp index fa8b24c6..65698685 100644 --- a/tests/unit/test_artifact_naming.cpp +++ b/tests/unit/test_artifact_naming.cpp @@ -245,5 +245,6 @@ TEST(PlatformName, EveryKnownRowIsInTheVocabulary) { } EXPECT_TRUE(is_platform_name("emscripten")); EXPECT_FALSE(is_platform_name("web")); - EXPECT_EQ(platform_names_joined(), "linux | macos | windows | ios | android | emscripten"); + EXPECT_TRUE(is_platform_name("openkal")); + EXPECT_EQ(platform_names_joined(), "linux | macos | windows | ios | android | emscripten | openkal"); } diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index d6a8114d..b0adb552 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -862,6 +862,8 @@ TEST(Triple, ExactlyTheseRowsHaveACapabilityPin) { "x86_64-windows-musl", "wasm32-emscripten", "aarch64-linux-android", "x86_64-linux-android", + // Ok images: clang and lld emit them on every host, as for bare metal. + "x86_64-openkal", "aarch64-openkal", }; EXPECT_EQ(capability, expected); @@ -1148,3 +1150,32 @@ TEST(Triple, EveryKnownRowHasAnObjectFormatAndNoneFallsThrough) { EXPECT_EQ(wasm, 1u) << "wasm32-emscripten is the only wasm row today"; EXPECT_EQ(elf + macho + pe + wasm, known_targets().size()); } + +// `-openkal` (design 2026-10-26 part 2, §8.5): an ok image, compiled for +// no operating system, named after the convention, and neither freestanding +// nor any system's. +TEST(Triple, OpenkalIsAnImageForNoOperatingSystem) { + using namespace mcpp::toolchain::triple; + for (auto spelling : { "x86_64-openkal", "x86_64-unknown-openkal" }) { + auto t = parse(spelling); + ASSERT_TRUE(t.has_value()) << spelling; + EXPECT_EQ(t->str(), "x86_64-openkal"); + EXPECT_TRUE(t->is_openkal()); + EXPECT_FALSE(t->is_freestanding()); + EXPECT_EQ(t->family(), ""); + EXPECT_EQ(t->llvm_triple(), "x86_64-unknown-none-elf"); + EXPECT_EQ(t->object_format(), ObjectFormat::Elf); + } + auto a = parse("aarch64-openkal"); + ASSERT_TRUE(a.has_value()); + EXPECT_EQ(a->llvm_triple(), "aarch64-unknown-none-elf"); + const ArtifactNaming host{ "", "lib", ".a", ".so", false }; + const auto n = artifact_naming(*a, host); + EXPECT_EQ(n.exeSuffix, ".oke"); + EXPECT_EQ(n.sharedLibExt, ".okso"); + EXPECT_EQ(n.staticLibExt, ".a"); + const auto* row = find_known_target(*a); + ASSERT_NE(row, nullptr); + EXPECT_EQ(row->pin, "llvm@23.1.3"); + EXPECT_EQ(platform_name(*a), "openkal"); +} From 532f7b4530eea4fa10ee7d37fa221b446d32148b Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 22:01:28 +0900 Subject: [PATCH 09/43] mcpp.lock records a git branch reached through another package, and reads it back as its anchor Only the root's git dependencies were written, so a branch two edges away moved to its tip on every build and an --offline build stopped at it (mcpp's own CI: platform -> mcpplibs.os -> openkal). e2e 943. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...em-part2-os-library-over-openkal-design.md | 1 + CHANGELOG.md | 5 + docs/05-dependencies.md | 4 +- docs/zh/05-dependencies.md | 3 +- src/build/prepare/graph.cpp | 28 ++++-- src/build/prepare/records.cpp | 16 ++++ src/build/prepare/state.cppm | 4 + .../943_a_transitive_git_branch_is_locked.sh | 94 +++++++++++++++++++ 8 files changed, 146 insertions(+), 9 deletions(-) create mode 100755 tests/e2e/943_a_transitive_git_branch_is_locked.sh diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index 376b1c73..0274ba62 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -572,6 +572,7 @@ os + image ─┬─→ mcpp #842:D11(可在 S1 之前单独完成并跑 CI | B7 | release 下切换工作空间成员时公共依赖反复重编(mcpp#844) | 全局参数携带整个计划的 `trim_paths` 映射 | D13 | | B8 | openkal-macos:libc 的 `signal()` 之后 stop word 处理器返回时 SIGTRAP | trampoline 未把内核给出的 sigreturn 令牌(x5)传回 `sigreturn`(x2) | trampoline 传回令牌;`tests/stop_word_and_sigpipe.cpp` | | B9 | e2e 849:ninja 后端读不出带 `$root` 的 GCC 模块映射 | 后端按行朴素解析映射,未按 dyndep 的解析器 | 改用 `parse_module_map` | +| B11 | 间接依赖的 git 分支不进 mcpp.lock:每次构建取分支头,`--offline` 在其上停止(mcpp CI:platform → os → openkal) | 锁文件只写根声明的 git 依赖 | 以限定名写入并读回为锚点;e2e 943 | | B10 | 链接 openkal 后,mcpp 与 openkal 启动的程序都以 SIGPIPE 忽略开始;mcpp 的 `std::print` 写入无读端的标准输出时抛出异常 | openkal 为自身写入忽略 SIGPIPE,忽略跨 `exec` 保留(E20 未计及继承) | K12;mcpp 的 SIGPIPE 守护(§6.4、§10.1);e2e 941 | ## 15. 生态级多视角自审(rev 3) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a1c0be..d9383d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ of a package that provides a module under GCC carries the spelling: such a package is compiled once more after the upgrade, and a package of object files keeps its entry. +- **A git branch reached through another package is locked.** mcpp.lock + recorded only the branches the project itself declared, so a branch two + edges away moved to its tip on every build and stopped an `--offline` build + ("mcpp.lock records no commit for branch"). It is now recorded under the + package's qualified name and read back as its anchor. - **Switching workspace members under `trim_paths` no longer recompiles their shared dependencies** (#844). The prefix map of the whole plan was part of the flags every unit shares, so a dependency's command line named the roots diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index f2f6e0f4..06ab97cd 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -92,7 +92,9 @@ resolution or change which dependencies the lock pins. in history, but a `branch` moves. The first build resolves the branch to a commit and records it in `mcpp.lock`, and every later build rebuilds **that** commit — the lock is authoritative, not a cache hint, so deleting `~/.mcpp/git` or moving to -another machine cannot silently move the build to a newer tip. A newer tip must be requested +another machine cannot silently move the build to a newer tip. This holds for a +branch the project reaches through another package as for its own (2026.10.26.2+): +such a branch is recorded under the package's qualified name. A newer tip must be requested explicitly: ```bash diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index a1a87c11..c95efbbe 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -84,7 +84,8 @@ widget = { version = "1.0", backend = "glfw_opengl3" } # Sugar for: features=[" **Git 依赖与 `mcpp.lock`**:`tag` 和 `rev` 已经指向历史中的一个固定点,而 `branch` 是会移动的。首次构建把分支解析成一个 commit 并写进 `mcpp.lock`, 此后每次构建都重新构建**那个** commit —— lock 是权威来源而不是缓存提示, -所以删掉 `~/.mcpp/git` 或换一台机器,都不会静默切换到更新的分支头。要用新的 +所以删掉 `~/.mcpp/git` 或换一台机器,都不会静默切换到更新的分支头。工程经由 +其他包间接依赖的分支同样如此(2026.10.26.2+),以包的限定名记录。要用新的 分支头,必须显式请求: ```bash diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index f58dd5cd..e10ffb31 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -1442,8 +1442,15 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { // ── 1. which commit ── std::string resolvedGitRev = spec.gitRev; bool fromLock = false; + // The key a lock line is written under: the declaring manifest's + // map key for a dependency the root declares, the qualified + // identity for one reached through another package. + const bool rootDeclared = state.declaredByRoot(item.consumerDepIndex); + const std::string lockKey = rootDeclared ? name + : mcpp::pm::compat::qualified_name( + spec.namespace_, spec.shortName.empty() ? name : spec.shortName); if (spec.gitRefKind == "branch") { - auto it = state.gitLockAnchors.find(name); + auto it = state.gitLockAnchors.find(lockKey); if (it != state.gitLockAnchors.end() && it->second.url == spec.git && it->second.refKind == spec.gitRefKind @@ -1559,19 +1566,26 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { } // A selected workspace member's own git dependencies are locked // as a root's are (workspace design 2026-09-29 §15). - if (state.declaredByRoot(item.consumerDepIndex)) { - // Only root deps are locked: the writer below walks the root - // manifest's [dependencies], so a transitive git branch dep - // has no anchor and still resolves over the network. + // + // AND ONE REACHED THROUGH ANOTHER PACKAGE, under its qualified + // identity. Until 2026.10.26.2 only the root's were written, so a + // branch two edges away had no anchor: every build asked `git + // ls-remote` again, and an offline build stopped at it. Measured + // in mcpp's own CI: the platform member depends on mcpplibs.os by + // branch, os on openkal by branch, and `--offline test` refused + // with "mcpp.lock records no commit for branch 'openkal-0.17.0'". + { auto source = std::format("git+{}#{}={}", spec.git, spec.gitRefKind, spec.gitRev); if (spec.gitRefKind == "branch") source += "@" + resolvedGitRev; - state.root_git_lock_identities[name] = GitLockIdentity{ + auto& identities = rootDeclared ? state.root_git_lock_identities + : state.transitive_git_lock_identities; + identities.try_emplace(lockKey, GitLockIdentity{ .source = std::move(source), .hash = "fnv1a:" + H(spec.git + "|" + spec.gitRefKind + "|" + spec.gitRev + "|" + resolvedGitRev), - }; + }); } sourceCommit = resolvedGitRev; dep_root = gitRoot; diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index 1ea35be2..eed2542b 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -192,6 +192,22 @@ std::expected step13_lockfile(PrepareState& state, BuildConte } lock.packages.push_back(std::move(lp)); } + // And every git dependency reached through another package, at the + // commit this build used: its anchor, read back under the same key. + for (auto const& [key, identity] : state.transitive_git_lock_identities) { + // A package the root (or a member) declares under its own map key + // is that line already, whatever this graph's path to it. + if (std::ranges::any_of(lock.packages, [&](auto const& lp) { return lp.source == identity.source; })) + continue; + if (!gitLocked.insert(key).second) continue; + auto parsed = mcpp::pm::parse_git_source(identity.source); + mcpp::lockfile::LockedPackage lp; + lp.name = key; + lp.version = parsed ? parsed->ref : std::string{}; + lp.source = identity.source; + lp.hash = identity.hash; + lock.packages.push_back(std::move(lp)); + } // Version deps: the whole resolved graph, at the versions actually // chosen. `resolved` is an ordered map, so the file is deterministic. diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 284f49e3..4ad52670 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -656,6 +656,10 @@ struct PrepareState { std::vector> dep_manifests; std::vector dep_cache_identities; std::map root_git_lock_identities; + // Git dependencies reached through another package, by qualified + // identity; written to mcpp.lock beside the root's and read back as their + // anchors. + std::map transitive_git_lock_identities; std::vector dependencyEdges; std::vector graphRequests; std::map dependencyLinkForms; diff --git a/tests/e2e/943_a_transitive_git_branch_is_locked.sh b/tests/e2e/943_a_transitive_git_branch_is_locked.sh new file mode 100755 index 00000000..825e58e2 --- /dev/null +++ b/tests/e2e/943_a_transitive_git_branch_is_locked.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# requires: gcc +# 943 -- a git branch reached through another package is locked as the root's +# own are. +# +# mcpp.lock recorded the commit of a branch dependency the ROOT declared, and +# read it back as the dependency's anchor; a branch two edges away was not +# written, so every build asked `git ls-remote` again and moved to the tip, and +# an `--offline` build stopped at it. Measured in mcpp's own CI, whose platform +# member depends on mcpplibs.os by branch and os on openkal by branch: +# +# offline mode: git dependency 'openkal' needs `git ls-remote` ... +# mcpp.lock records no commit for branch 'openkal-0.17.0' +# +# Criteria: +# A. After the first build mcpp.lock holds the transitive branch at the commit +# the build used. +# B. A commit pushed to that branch afterwards is not taken by the next build: +# the program still prints what the locked commit says. (A `file://` +# remote is local, so B rather than `--offline` is what shows the lock line +# is read back as the anchor.) +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +repo() { # repo : a git repository with one commit of what is there + (cd "$1" && git init --quiet -b main && git config user.email t@local \ + && git config user.name t && git add -A && git commit --quiet -m init) +} + +# leaf: the transitive dependency, on branch main. +mkdir -p "$TMP/leaf/src" +cat > "$TMP/leaf/mcpp.toml" <<'TOML' +[package] +name = "leaf" +version = "0.1.0" + +[targets.leaf] +kind = "lib" +TOML +printf 'export module leaf;\nexport int leaf_value() { return 1; }\n' > "$TMP/leaf/src/leaf.cppm" +repo "$TMP/leaf" + +# mid: depends on leaf by branch. +mkdir -p "$TMP/mid/src" +cat > "$TMP/mid/mcpp.toml" < "$TMP/mid/src/mid.cppm" +repo "$TMP/mid" + +# app: depends on mid by branch; leaf is two edges away. +mkdir -p "$TMP/app/src" +cd "$TMP/app" +cat > mcpp.toml <\nint main() { std::printf("value %%d\\n", mid_value()); }\n' > src/main.cpp + +"$MCPP" build > b1.log 2>&1 || fail "the first build" b1.log +LEAF_COMMIT=$(git -C "$TMP/leaf" rev-parse HEAD) + +# A +grep -q "branch=main@$LEAF_COMMIT" mcpp.lock || fail "A: mcpp.lock does not hold the transitive branch's commit" mcpp.lock +echo "ok: the transitive branch is locked at the commit the build used" + +# B +printf 'export module leaf;\nexport int leaf_value() { return 2; }\n' > "$TMP/leaf/src/leaf.cppm" +(cd "$TMP/leaf" && git commit --quiet -am two) +"$MCPP" build > b2.log 2>&1 || fail "B: the second build" b2.log +"$MCPP" run > run.log 2>&1 || fail "B: the program did not run" run.log +grep -q "value 1" run.log || fail "B: the build moved to the branch's new tip" run.log mcpp.lock +echo "ok: a later commit on the branch is not taken" + +echo "PASS: 943 a transitive git branch is locked" From 481728a26990d337a4bb44d6f6a794992d6b6229 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 22:22:47 +0900 Subject: [PATCH 10/43] ok images: hidden visibility, --gc-sections and --eh-frame-hdr; an image another loads (.okso) is built An image exports what its source marks and nothing else, so the link drops what nothing reaches and the requirements note names only what the program uses; the unwind index is there for dl_iterate_phdr. kind = "shared" on -openkal produces lib.okso. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...em-part2-os-library-over-openkal-design.md | 4 +++- docs/24-openkal-cross.md | 4 ++-- docs/zh/24-openkal-cross.md | 4 ++-- src/build/ninja_backend.cppm | 8 ++++++- src/okimage/okimage.cppm | 24 +++++++++++++++---- ...ok_image_is_built_once_and_run_by_okrun.sh | 6 +++-- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index 0274ba62..43a1cd82 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -396,7 +396,9 @@ M3 由链接 libokrun、映像段与一个极小的入口(由 `mcpp pack` 生 载荷的 libc++ 与 std 模块不提供给映像。默认提供者不隐式加入依赖图(依赖图只含清单声明的包):`import std;` 在依赖图无 C++ 运行时时于编译前被拒绝,拒绝信息给出 `openkal-musl` 与 `openkal-llvm-runtime` 的声明。无 C 库的映像以 `-ffreestanding` 编译 (实测:`--release` 下 `-O2` 合成的 `strlen` 调用被 `okld check` 拒绝)。 -- **链接与收尾**:`ld.lld -shared -z max-page-size=65536 -z now -z noexecstack --hash-style=both`,可执行映像 `-e ok_main`; +- **链接与收尾**:`ld.lld -shared -z max-page-size=65536 -z now -z noexecstack --hash-style=both --gc-sections --eh-frame-hdr`, + 编译加 `-fvisibility=hidden`(映像只导出源码标注的名字;实测:未加时整个 C 库进入映像并被导出,`okld check` 以未调用的复数运算 + 所需的 `__mulxc3` 拒绝之),可执行映像 `-e ok_main`;`kind = "shared"` 产出 `.okso`(引擎对其他无加载器目标的拒绝不适用); 每个映像链接引擎写出的 note 对象;链接产物为 `.unchecked`,`mcpp okimage-finish` 依次执行 `okld note`、`okld check` (`--with` 所需的 `.okso`),通过后才以映像名发布。`okld` 依次取自依赖图为本机构建的程序、声明的载荷、PATH;缺失时在编译前 拒绝并给出 `[build-dependencies] openkal-tools = { tools = ["okld", "okrun"] }`。格式的读取与检查只在 libokld 中实现,mcpp diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index c7fb4804..6bb716a6 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -407,8 +407,8 @@ mcpp run --target x86_64-openkal # okrun bin/app.oke | | the engine's action | the reason | |---|---|---| -| compile | `--target=-unknown-none-elf -fPIC -femulated-tls`; on AArch64 also `-ffixed-x18 -mno-outline-atomics` | an image is told no operating system; it does not address the thread pointer and does not use the platform register, which two systems reserve | -| link | `ld.lld -shared -z max-page-size=65536 -z now`, entry `ok_main`; an image another loads (`kind = "shared"`) is `lib.okso` with its name | ET_DYN, every segment aligned to the largest protection granularity among the systems | +| compile | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden`; on AArch64 also `-ffixed-x18 -mno-outline-atomics`; `-ffreestanding` when the graph supplies no C library | an image is told no operating system; it does not address the thread pointer and does not use the platform register, which two systems reserve; it exports what its source marks `visibility("default")` | +| link | `ld.lld -shared -z max-page-size=65536 -z now --gc-sections --eh-frame-hdr`, entry `ok_main`; an image another loads (`kind = "shared"`) is `lib.okso` with its name | ET_DYN, every segment aligned to the largest protection granularity among the systems; what nothing reaches is discarded, so the requirements note names only the interfaces the program uses | | finish | `okld note` writes the interfaces the image's strong imports require; `okld check` checks every clause against the image and the `.okso` it needs; only then is `bin/.oke` published | a loader refuses an image that requires an interface the implementation lacks, naming it, and an image that breaks the convention is never published | | run | `okrun` is the target's default runner, found among the programs the graph built for this machine, then in declared payloads, then on PATH | the loader holds the openkal implementation; the image carries none | diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index 2267d8cc..f3e9425e 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -361,8 +361,8 @@ mcpp run --target x86_64-openkal # okrun bin/app.oke | | mcpp 的处理 | 原因 | |---|---|---| -| 编译 | `--target=-unknown-none-elf -fPIC -femulated-tls`;AArch64 另加 `-ffixed-x18 -mno-outline-atomics` | 映像按“无操作系统”编译;不访问线程指针,不使用两个系统保留的平台寄存器 | -| 链接 | `ld.lld -shared -z max-page-size=65536 -z now`,入口 `ok_main`;被其他映像加载的映像(`kind = "shared"`)为带名字的 `lib.okso` | ET_DYN,每个段按各系统中最大的保护粒度对齐 | +| 编译 | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden`;AArch64 另加 `-ffixed-x18 -mno-outline-atomics`;依赖图不提供 C 库时加 `-ffreestanding` | 映像按“无操作系统”编译;不访问线程指针,不使用两个系统保留的平台寄存器;只导出源码标注 `visibility("default")` 的名字 | +| 链接 | `ld.lld -shared -z max-page-size=65536 -z now --gc-sections --eh-frame-hdr`,入口 `ok_main`;被其他映像加载的映像(`kind = "shared"`)为带名字的 `lib.okso` | ET_DYN,每个段按各系统中最大的保护粒度对齐;丢弃不可达的代码,需求 note 只列出程序实际使用的接口 | | 收尾 | `okld note` 由映像的强导入写入所需接口;`okld check` 对映像及其所需的 `.okso` 检查每一条约定;通过后才发布 `bin/.oke` | 加载器拒绝需要实现所缺接口的映像并指名该接口;违反约定的映像不会被发布 | | 运行 | `okrun` 是该 target 的默认 runner,依次在依赖图为本机构建的程序、声明的载荷、PATH 中查找 | 加载器持有 openkal 实现,映像不携带实现 | diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index aa907802..720f3794 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -4479,7 +4479,13 @@ std::expected NinjaBackend::build(const BuildPlan& plan // which names an internal rule to somebody who wrote `kind = "shared"` in a // manifest. Defining the rule would be worse: `ld.lld -shared` succeeds and // produces an object nothing on that machine can load. - if (!flags.ldDriver.empty()) { + // An ok image is the exception: an image another loads (`.okso`) is + // placed by the loader (DT_NEEDED) or by `dlopen`, and has its rule. + const bool okImageShared = [&] { + const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); + return t && t->is_openkal(); + }(); + if (!flags.ldDriver.empty() && !okImageShared) { for (auto const& lu : plan.linkUnits) { if (lu.kind != LinkUnit::SharedLibrary) continue; return std::unexpected(BuildError{std::format( diff --git a/src/okimage/okimage.cppm b/src/okimage/okimage.cppm index f55e49c1..dcd07213 100644 --- a/src/okimage/okimage.cppm +++ b/src/okimage/okimage.cppm @@ -38,9 +38,16 @@ inline constexpr std::string_view kRunner = "okrun"; // `mcpp run`, `mcpp test // -ffixed-x18 (AArch64) two systems reserve the platform register; // -mno-outline-atomics // (AArch64) the outlined helpers read the processor's -// features through an operating system's interface. +// features through an operating system's interface; +// -fvisibility=hidden +// an image exports what its source marks +// `visibility("default")` and nothing else, as the +// reference build does: everything else is the image's +// own, so the link can drop what nothing reaches, and the +// requirements note names only the interfaces the program +// uses, not every one its C library could. inline std::vector compile_flags(std::string_view arch) { - std::vector out{ "-fPIC", "-femulated-tls" }; + std::vector out{ "-fPIC", "-femulated-tls", "-fvisibility=hidden" }; if (arch == "aarch64") { out.emplace_back("-ffixed-x18"); out.emplace_back("-mno-outline-atomics"); @@ -50,10 +57,17 @@ inline std::vector compile_flags(std::string_view arch) { // THE LINK, BY `ld.lld` ITSELF (SPEC 12.2). Position-independent, every // loadable segment aligned to 64 KiB (the largest protection granularity among -// the systems), bound now, no executable stack, and both hash tables, which a -// loader may use either of. +// the systems), bound now, no executable stack, both hash tables (a loader may +// use either), what nothing reaches discarded, and the unwind index an +// unwinder finds through `dl_iterate_phdr`. +// +// Measured, a C program over openkal-musl before `--gc-sections` and hidden +// visibility: the whole C library was in the image and exported, and `okld +// check` refused it for importing `__mulxc3` -- from complex arithmetic the +// program never called. inline std::string link_flags() { - return " -shared -z max-page-size=65536 -z now -z noexecstack --hash-style=both"; + return " -shared -z max-page-size=65536 -z now -z noexecstack --hash-style=both" + " --gc-sections --eh-frame-hdr"; } // The notes every image carries (SPEC 12.4): the requirements, which `okld diff --git a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh index 52964298..c311cd73 100755 --- a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh +++ b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh @@ -157,9 +157,11 @@ echo "ok: pack stages the image (M2) and a program that carries it (M3)" # C before=$(cksum < "$(ls target/"$HOST_ARCH"-openkal/*/bin/app.oke | head -1)" ) -cat >> src/main.c <<'C' +# Reached from the entry: the link discards what nothing reaches. +cp src/main.c main.c.good +cat > src/main.c <<'C' int puts(const char*); -void uses_a_c_library(void) { puts("x"); } +int ok_main(void) { return puts("x"); } C if "$MCPP" build --target "$HOST_ARCH-openkal" > refuse.log 2>&1; then fail "C: an image importing puts was published" refuse.log From 394a77df1cf0f04dc2f41c8f91bdda24296289e5 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 22:31:51 +0900 Subject: [PATCH 11/43] ok images: asynchronous unwind tables, so an exception crosses the C of the unwinder and of the C library Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...-ecosystem-part2-os-library-over-openkal-design.md | 3 +++ docs/24-openkal-cross.md | 2 +- docs/zh/24-openkal-cross.md | 2 +- src/okimage/okimage.cppm | 11 ++++++++++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index 43a1cd82..a209939c 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -575,6 +575,9 @@ os + image ─┬─→ mcpp #842:D11(可在 S1 之前单独完成并跑 CI | B8 | openkal-macos:libc 的 `signal()` 之后 stop word 处理器返回时 SIGTRAP | trampoline 未把内核给出的 sigreturn 令牌(x5)传回 `sigreturn`(x2) | trampoline 传回令牌;`tests/stop_word_and_sigpipe.cpp` | | B9 | e2e 849:ninja 后端读不出带 `$root` 的 GCC 模块映射 | 后端按行朴素解析映射,未按 dyndep 的解析器 | 改用 `parse_module_map` | | B11 | 间接依赖的 git 分支不进 mcpp.lock:每次构建取分支头,`--offline` 在其上停止(mcpp CI:platform → os → openkal) | 锁文件只写根声明的 git 依赖 | 以限定名写入并读回为锚点;e2e 943 | +| B12 | 映像中 `throw` 均以 terminate 结束 | `-unknown-none-elf` 默认不生成异步展开表,C 帧(libunwind、C 库)不可展开 | 映像编译加 `-fasynchronous-unwind-tables` | +| B13 | openkal-musl 映像形态:程序的构造函数先于 C 库初始化运行而失败;libc++ 的标准流(优先级 100)先于 C 库初始化 | 初始化数组按链接顺序 | C 库初始化以优先级 1 的构造函数运行 | +| B14 | C 程序映像在 aarch64 上导入 `__multf3` 等 | 128 位 `long double` 例程不在 C 库中 | `llvm.compiler-rt-builtins` 22.1.8.6 为 openkal 行提供宽浮点例程;openkal-musl 以默认特性 `image-builtins` 依赖之,openkal-llvm-runtime 关闭该特性(一个构建一个编译器运行时) | | B10 | 链接 openkal 后,mcpp 与 openkal 启动的程序都以 SIGPIPE 忽略开始;mcpp 的 `std::print` 写入无读端的标准输出时抛出异常 | openkal 为自身写入忽略 SIGPIPE,忽略跨 `exec` 保留(E20 未计及继承) | K12;mcpp 的 SIGPIPE 守护(§6.4、§10.1);e2e 941 | ## 15. 生态级多视角自审(rev 3) diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index 6bb716a6..3e0ed3a0 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -407,7 +407,7 @@ mcpp run --target x86_64-openkal # okrun bin/app.oke | | the engine's action | the reason | |---|---|---| -| compile | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden`; on AArch64 also `-ffixed-x18 -mno-outline-atomics`; `-ffreestanding` when the graph supplies no C library | an image is told no operating system; it does not address the thread pointer and does not use the platform register, which two systems reserve; it exports what its source marks `visibility("default")` | +| compile | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden -fasynchronous-unwind-tables`; on AArch64 also `-ffixed-x18 -mno-outline-atomics`; `-ffreestanding` when the graph supplies no C library | an image is told no operating system; it does not address the thread pointer and does not use the platform register, which two systems reserve; it exports what its source marks `visibility("default")` | | link | `ld.lld -shared -z max-page-size=65536 -z now --gc-sections --eh-frame-hdr`, entry `ok_main`; an image another loads (`kind = "shared"`) is `lib.okso` with its name | ET_DYN, every segment aligned to the largest protection granularity among the systems; what nothing reaches is discarded, so the requirements note names only the interfaces the program uses | | finish | `okld note` writes the interfaces the image's strong imports require; `okld check` checks every clause against the image and the `.okso` it needs; only then is `bin/.oke` published | a loader refuses an image that requires an interface the implementation lacks, naming it, and an image that breaks the convention is never published | | run | `okrun` is the target's default runner, found among the programs the graph built for this machine, then in declared payloads, then on PATH | the loader holds the openkal implementation; the image carries none | diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index f3e9425e..0b573686 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -361,7 +361,7 @@ mcpp run --target x86_64-openkal # okrun bin/app.oke | | mcpp 的处理 | 原因 | |---|---|---| -| 编译 | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden`;AArch64 另加 `-ffixed-x18 -mno-outline-atomics`;依赖图不提供 C 库时加 `-ffreestanding` | 映像按“无操作系统”编译;不访问线程指针,不使用两个系统保留的平台寄存器;只导出源码标注 `visibility("default")` 的名字 | +| 编译 | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden -fasynchronous-unwind-tables`;AArch64 另加 `-ffixed-x18 -mno-outline-atomics`;依赖图不提供 C 库时加 `-ffreestanding` | 映像按“无操作系统”编译;不访问线程指针,不使用两个系统保留的平台寄存器;只导出源码标注 `visibility("default")` 的名字 | | 链接 | `ld.lld -shared -z max-page-size=65536 -z now --gc-sections --eh-frame-hdr`,入口 `ok_main`;被其他映像加载的映像(`kind = "shared"`)为带名字的 `lib.okso` | ET_DYN,每个段按各系统中最大的保护粒度对齐;丢弃不可达的代码,需求 note 只列出程序实际使用的接口 | | 收尾 | `okld note` 由映像的强导入写入所需接口;`okld check` 对映像及其所需的 `.okso` 检查每一条约定;通过后才发布 `bin/.oke` | 加载器拒绝需要实现所缺接口的映像并指名该接口;违反约定的映像不会被发布 | | 运行 | `okrun` 是该 target 的默认 runner,依次在依赖图为本机构建的程序、声明的载荷、PATH 中查找 | 加载器持有 openkal 实现,映像不携带实现 | diff --git a/src/okimage/okimage.cppm b/src/okimage/okimage.cppm index dcd07213..caffe73f 100644 --- a/src/okimage/okimage.cppm +++ b/src/okimage/okimage.cppm @@ -46,8 +46,17 @@ inline constexpr std::string_view kRunner = "okrun"; // `mcpp run`, `mcpp test // own, so the link can drop what nothing reaches, and the // requirements note names only the interfaces the program // uses, not every one its C library could. +// -fasynchronous-unwind-tables +// every frame, C's included, can be unwound: a C++ +// exception crosses the C of the unwinder itself +// (`_Unwind_RaiseException`) and of the C library. The +// hosted triples default to it; `-unknown-none-elf` +// does not -- measured, `_Unwind_Backtrace` in an image +// over openkal-llvm-runtime found no frame at all, and +// every `throw` terminated. inline std::vector compile_flags(std::string_view arch) { - std::vector out{ "-fPIC", "-femulated-tls", "-fvisibility=hidden" }; + std::vector out{ "-fPIC", "-femulated-tls", "-fvisibility=hidden", + "-fasynchronous-unwind-tables" }; if (arch == "aarch64") { out.emplace_back("-ffixed-x18"); out.emplace_back("-mno-outline-atomics"); From 5d12819022a22e19fe3625075f20a8014659b2de Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 22:52:39 +0900 Subject: [PATCH 12/43] a recorded build is replayed only under the lock it was planned with, so mcpp update moves a branch forward; M3 programs name themselves mcpp update cleared mcpp.lock and the next build replayed the old graph, building the old commit and writing it back (2026.10.19.1 onwards). The record carries a digest of mcpp.lock and declines otherwise. e2e 943 C. The generated M3 program runs its image with okrun_options::carried. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...em-part2-os-library-over-openkal-design.md | 1 + CHANGELOG.md | 4 +++ src/build/execute.cppm | 27 +++++++++++++++++++ src/pack/pack.cppm | 4 ++- .../943_a_transitive_git_branch_is_locked.sh | 10 +++++++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index a209939c..400b9075 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -575,6 +575,7 @@ os + image ─┬─→ mcpp #842:D11(可在 S1 之前单独完成并跑 CI | B8 | openkal-macos:libc 的 `signal()` 之后 stop word 处理器返回时 SIGTRAP | trampoline 未把内核给出的 sigreturn 令牌(x5)传回 `sigreturn`(x2) | trampoline 传回令牌;`tests/stop_word_and_sigpipe.cpp` | | B9 | e2e 849:ninja 后端读不出带 `$root` 的 GCC 模块映射 | 后端按行朴素解析映射,未按 dyndep 的解析器 | 改用 `parse_module_map` | | B11 | 间接依赖的 git 分支不进 mcpp.lock:每次构建取分支头,`--offline` 在其上停止(mcpp CI:platform → os → openkal) | 锁文件只写根声明的 git 依赖 | 以限定名写入并读回为锚点;e2e 943 | +| B15 | `mcpp update` 之后的构建仍取旧的分支提交(2026.10.19.1 起即如此) | 快速路径重放旧构建记录,不比较锁文件 | 记录写入时锁文件的摘要,不同则不重放;e2e 943 C | | B12 | 映像中 `throw` 均以 terminate 结束 | `-unknown-none-elf` 默认不生成异步展开表,C 帧(libunwind、C 库)不可展开 | 映像编译加 `-fasynchronous-unwind-tables` | | B13 | openkal-musl 映像形态:程序的构造函数先于 C 库初始化运行而失败;libc++ 的标准流(优先级 100)先于 C 库初始化 | 初始化数组按链接顺序 | C 库初始化以优先级 1 的构造函数运行 | | B14 | C 程序映像在 aarch64 上导入 `__multf3` 等 | 128 位 `long double` 例程不在 C 库中 | `llvm.compiler-rt-builtins` 22.1.8.6 为 openkal 行提供宽浮点例程;openkal-musl 以默认特性 `image-builtins` 依赖之,openkal-llvm-runtime 关闭该特性(一个构建一个编译器运行时) | diff --git a/CHANGELOG.md b/CHANGELOG.md index d9383d3a..d9299a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ of a package that provides a module under GCC carries the spelling: such a package is compiled once more after the upgrade, and a package of object files keeps its entry. +- **`mcpp update` moves a branch dependency forward again.** The record of + the previous build was replayed although mcpp.lock had been cleared, so the + next build kept the old commit and wrote it back. A recorded build is now + replayed only under the lock it was planned with. - **A git branch reached through another package is locked.** mcpp.lock recorded only the branches the project itself declared, so a branch two edges away moved to its tip on every build and stopped an `--offline` build diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 89c7cbe4..afe0fcaa 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -362,6 +362,14 @@ export struct BuildCacheEntry { // empty identity, because an engine whose version is the empty string does // not exist and "no record" must not be representable as a value. std::optional engine; + // THE LOCK THE GRAPH WAS PLANNED UNDER: a digest of the project's mcpp.lock + // when the record was written, `absent` when there was none, empty for an + // entry that predates the field (it declines once). `mcpp update` deletes + // entries from the lock -- or the whole file -- so that the next build + // re-resolves a branch; a replay of the old graph would build the old + // commit and write it back. Measured on 2026.10.19.1: after `mcpp update`, + // `mcpp run` printed the old branch's output on every run. + std::string lockDigest; // Where the build published the project's compile database (`[build] // compile_commands` as resolved, design 2026-10-26 D14), relative to the // project root; empty for none. A fast path publishes to the same place, @@ -399,6 +407,15 @@ std::string join_extensions(const std::vector& extensions) { return out; } +// The digest of a project's mcpp.lock (FNV-1a over its bytes), or `absent`. +export std::string lock_digest(const std::filesystem::path& projectRoot) { + std::ifstream in(projectRoot / "mcpp.lock", std::ios::binary); + if (!in) return "absent"; + std::uint64_t h = 1469598103934665603ull; + for (char c; in.get(c);) { h ^= static_cast(c); h *= 1099511628211ull; } + return std::format("{:016x}", h); +} + export std::vector read_build_cache(const std::filesystem::path& projectRoot) { auto path = projectRoot / kBuildCacheFile; std::ifstream f(path); @@ -574,6 +591,10 @@ export std::vector read_build_cache(const std::filesystem::path e.rootCompileDbRecorded = true; haveNextLine = static_cast(std::getline(f, line)); } + if (haveNextLine && line.starts_with("lock=")) { + e.lockDigest = line.substr(5); + haveNextLine = static_cast(std::getline(f, line)); + } // `engine=`, a tab, and the executable's path; see // EngineIdentity. The version holds no tab, so the path is everything // after the first one. Absent, or without the tab, the entry declines @@ -681,6 +702,7 @@ void write_build_cache(const std::filesystem::path& projectRoot, // The engine that wrote the graph is the one writing the record: both // happen in this process, after the graph was emitted. newEntry.engine = running_engine(); + newEntry.lockDigest = lock_digest(projectRoot); entries.insert(entries.begin(), std::move(newEntry)); // Trim to LRU capacity. @@ -757,6 +779,7 @@ void write_build_cache_entries(const std::filesystem::path& path, f << "group=" << e.group << '\n'; } if (e.rootCompileDbRecorded) f << "compileDb=" << e.rootCompileDb << '\n'; + if (!e.lockDigest.empty()) f << "lock=" << e.lockDigest << '\n'; if (e.engine) f << "engine=" << e.engine->version << '\t' << e.engine->exe << '\n'; } @@ -2221,6 +2244,10 @@ admit_recorded_build(const BuildCacheEntry& e, const ReplayAsk& ask) { const auto manifestTime = std::filesystem::last_write_time(ask.manifest, ec); if (ec || manifestTime > ninjaTime) return std::unexpected(std::format("{} is newer than build.ninja", ask.manifestName)); + if (e.lockDigest.empty()) + return std::unexpected("the recorded build predates the lock it was planned under"); + if (e.lockDigest != lock_digest(ask.manifest.parent_path())) + return std::unexpected("mcpp.lock is not the one the recorded build was planned under"); // mcpp#225: bounded + vcs/build-dir-excluded walk (see sources_newer_than) // instead of a hand-rolled recursive_directory_iterator over src/. diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index dc359c19..ae185164 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -2061,8 +2061,10 @@ okimage_build_selfcontained(const Plan& plan, const std::filesystem::path& image "extern \"C\" const unsigned char ok_image_begin[], ok_image_end[];\n" "int main(int argc, char** argv) {{\n" " char why[512];\n" + " okrun_options options{{}};\n" + " options.carried = 1; // the program is the image's name, and what starts it again\n" " okrun_run_bytes(ok_image_begin, static_cast(ok_image_end - ok_image_begin),\n" - " \"{}\", argc, const_cast(argv), nullptr, why, sizeof why);\n" + " \"{}\", argc, const_cast(argv), &options, why, sizeof why);\n" " std::fprintf(stderr, \"{}: %s\\n\", why);\n" " return 127;\n" "}}\n", diff --git a/tests/e2e/943_a_transitive_git_branch_is_locked.sh b/tests/e2e/943_a_transitive_git_branch_is_locked.sh index 825e58e2..feb819a1 100755 --- a/tests/e2e/943_a_transitive_git_branch_is_locked.sh +++ b/tests/e2e/943_a_transitive_git_branch_is_locked.sh @@ -19,6 +19,10 @@ # the program still prints what the locked commit says. (A `file://` # remote is local, so B rather than `--offline` is what shows the lock line # is read back as the anchor.) +# C. `mcpp update` drops the recorded commit, and the next build takes the +# branch's tip: the record of the previous build is not replayed under a +# lock it was not planned with (2026.10.19.1 printed the old value on every +# run after the update). set -e TMP=$(mktemp -d) @@ -91,4 +95,10 @@ printf 'export module leaf;\nexport int leaf_value() { return 2; }\n' > "$TMP/le grep -q "value 1" run.log || fail "B: the build moved to the branch's new tip" run.log mcpp.lock echo "ok: a later commit on the branch is not taken" +# C +"$MCPP" update > update.log 2>&1 || fail "C: mcpp update" update.log +"$MCPP" run > run2.log 2>&1 || fail "C: the program did not run after the update" run2.log +grep -q "value 2" run2.log || fail "C: the build after mcpp update did not take the branch's tip" run2.log +echo "ok: after mcpp update the next build takes the branch's tip" + echo "PASS: 943 a transitive git branch is locked" From 7719250a74bed9622f9baa0bbd718ad0c74b35d3 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 23:00:49 +0900 Subject: [PATCH 13/43] ok images: a frame larger than a page is probed page by page (Windows commits a stack as its guard page is touched) Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...6-ecosystem-part2-os-library-over-openkal-design.md | 1 + docs/24-openkal-cross.md | 2 +- docs/zh/24-openkal-cross.md | 2 +- src/okimage/okimage.cppm | 10 +++++++++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md index 400b9075..42764a49 100644 --- a/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -576,6 +576,7 @@ os + image ─┬─→ mcpp #842:D11(可在 S1 之前单独完成并跑 CI | B9 | e2e 849:ninja 后端读不出带 `$root` 的 GCC 模块映射 | 后端按行朴素解析映射,未按 dyndep 的解析器 | 改用 `parse_module_map` | | B11 | 间接依赖的 git 分支不进 mcpp.lock:每次构建取分支头,`--offline` 在其上停止(mcpp CI:platform → os → openkal) | 锁文件只写根声明的 git 依赖 | 以限定名写入并读回为锚点;e2e 943 | | B15 | `mcpp update` 之后的构建仍取旧的分支提交(2026.10.19.1 起即如此) | 快速路径重放旧构建记录,不比较锁文件 | 记录写入时锁文件的摘要,不同则不重放;e2e 943 C | +| B16 | 映像在 Windows 上:64 KiB 栈缓冲区处崩溃;启动程序全部失败 | 线程栈按触及守护页提交,大帧越过守护页;映像内 libc++ 的路径语义为 POSIX,盘符名被拼到工作目录后 | 映像编译加逐页栈探测(`-Xclang -fstack-clash-protection`,SPEC 12.2 增"栈"一行);`os::fs::absolute` 按 `host()` 判断绝对名 | | B12 | 映像中 `throw` 均以 terminate 结束 | `-unknown-none-elf` 默认不生成异步展开表,C 帧(libunwind、C 库)不可展开 | 映像编译加 `-fasynchronous-unwind-tables` | | B13 | openkal-musl 映像形态:程序的构造函数先于 C 库初始化运行而失败;libc++ 的标准流(优先级 100)先于 C 库初始化 | 初始化数组按链接顺序 | C 库初始化以优先级 1 的构造函数运行 | | B14 | C 程序映像在 aarch64 上导入 `__multf3` 等 | 128 位 `long double` 例程不在 C 库中 | `llvm.compiler-rt-builtins` 22.1.8.6 为 openkal 行提供宽浮点例程;openkal-musl 以默认特性 `image-builtins` 依赖之,openkal-llvm-runtime 关闭该特性(一个构建一个编译器运行时) | diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index 3e0ed3a0..7db7130e 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -407,7 +407,7 @@ mcpp run --target x86_64-openkal # okrun bin/app.oke | | the engine's action | the reason | |---|---|---| -| compile | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden -fasynchronous-unwind-tables`; on AArch64 also `-ffixed-x18 -mno-outline-atomics`; `-ffreestanding` when the graph supplies no C library | an image is told no operating system; it does not address the thread pointer and does not use the platform register, which two systems reserve; it exports what its source marks `visibility("default")` | +| compile | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden -fasynchronous-unwind-tables -Xclang -fstack-clash-protection`; on AArch64 also `-ffixed-x18 -mno-outline-atomics`; `-ffreestanding` when the graph supplies no C library | an image is told no operating system; it does not address the thread pointer and does not use the platform register, which two systems reserve; it exports what its source marks `visibility("default")` | | link | `ld.lld -shared -z max-page-size=65536 -z now --gc-sections --eh-frame-hdr`, entry `ok_main`; an image another loads (`kind = "shared"`) is `lib.okso` with its name | ET_DYN, every segment aligned to the largest protection granularity among the systems; what nothing reaches is discarded, so the requirements note names only the interfaces the program uses | | finish | `okld note` writes the interfaces the image's strong imports require; `okld check` checks every clause against the image and the `.okso` it needs; only then is `bin/.oke` published | a loader refuses an image that requires an interface the implementation lacks, naming it, and an image that breaks the convention is never published | | run | `okrun` is the target's default runner, found among the programs the graph built for this machine, then in declared payloads, then on PATH | the loader holds the openkal implementation; the image carries none | diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index 0b573686..97498cdd 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -361,7 +361,7 @@ mcpp run --target x86_64-openkal # okrun bin/app.oke | | mcpp 的处理 | 原因 | |---|---|---| -| 编译 | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden -fasynchronous-unwind-tables`;AArch64 另加 `-ffixed-x18 -mno-outline-atomics`;依赖图不提供 C 库时加 `-ffreestanding` | 映像按“无操作系统”编译;不访问线程指针,不使用两个系统保留的平台寄存器;只导出源码标注 `visibility("default")` 的名字 | +| 编译 | `--target=-unknown-none-elf -fPIC -femulated-tls -fvisibility=hidden -fasynchronous-unwind-tables -Xclang -fstack-clash-protection`;AArch64 另加 `-ffixed-x18 -mno-outline-atomics`;依赖图不提供 C 库时加 `-ffreestanding` | 映像按“无操作系统”编译;不访问线程指针,不使用两个系统保留的平台寄存器;只导出源码标注 `visibility("default")` 的名字 | | 链接 | `ld.lld -shared -z max-page-size=65536 -z now --gc-sections --eh-frame-hdr`,入口 `ok_main`;被其他映像加载的映像(`kind = "shared"`)为带名字的 `lib.okso` | ET_DYN,每个段按各系统中最大的保护粒度对齐;丢弃不可达的代码,需求 note 只列出程序实际使用的接口 | | 收尾 | `okld note` 由映像的强导入写入所需接口;`okld check` 对映像及其所需的 `.okso` 检查每一条约定;通过后才发布 `bin/.oke` | 加载器拒绝需要实现所缺接口的映像并指名该接口;违反约定的映像不会被发布 | | 运行 | `okrun` 是该 target 的默认 runner,依次在依赖图为本机构建的程序、声明的载荷、PATH 中查找 | 加载器持有 openkal 实现,映像不携带实现 | diff --git a/src/okimage/okimage.cppm b/src/okimage/okimage.cppm index caffe73f..4f949090 100644 --- a/src/okimage/okimage.cppm +++ b/src/okimage/okimage.cppm @@ -54,9 +54,17 @@ inline constexpr std::string_view kRunner = "okrun"; // `mcpp run`, `mcpp test // does not -- measured, `_Unwind_Backtrace` in an image // over openkal-llvm-runtime found no frame at all, and // every `throw` terminated. +// -Xclang -fstack-clash-protection +// a frame larger than a page is probed page by page: one +// system commits a thread's stack as its guard page is +// touched, and a frame that steps over the guard faults -- +// measured, an image's 64 KiB buffer ended it on Windows +// and nowhere else. Passed to the front end because the +// driver enables the option for hosted triples only. inline std::vector compile_flags(std::string_view arch) { std::vector out{ "-fPIC", "-femulated-tls", "-fvisibility=hidden", - "-fasynchronous-unwind-tables" }; + "-fasynchronous-unwind-tables", + "-Xclang", "-fstack-clash-protection" }; if (arch == "aarch64") { out.emplace_back("-ffixed-x18"); out.emplace_back("-mno-outline-atomics"); From 1e51a130aa4b390b50f47aa80cd3e87a83036c1d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 23:06:06 +0900 Subject: [PATCH 14/43] -openkal: okld is required only by a build that links an image Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/prepare/plan.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index cb42cd9e..262759fe 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1503,7 +1503,12 @@ static std::expected step13_assembly_units(PrepareState& stat if (ctx.plan.okldPath.empty()) if (auto found = mcpp::platform::fs::which(tool)) ctx.plan.okldPath = *found; } - if (ctx.plan.okldPath.empty() && !state.overrides.plan_only) + // Only a build that links an image needs it: a library's archive is + // objects, and the image that takes them is checked where it is linked. + const bool linksAnImage = std::ranges::any_of(ctx.plan.linkUnits, [](auto const& lu) { + return lu.kind != mcpp::build::LinkUnit::StaticLibrary; + }); + if (ctx.plan.okldPath.empty() && linksAnImage && !state.overrides.plan_only) return std::unexpected(mcpp::okimage::no_tool_message(tt->str(), tool)); } // Clang: discover clang-scan-deps for P1689 dyndep scanning. From a65a1a02a950ce3e2721daae870e38fa24055d17 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 26 Oct 2026 23:56:06 +0900 Subject: [PATCH 15/43] a branch moved between ls-remote and the clone is fetched at the resolved commit; an image build with no okld is refused as image-tool-absent, and the matrix declares the openkal cells Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ docs/50-machine-output.md | 1 + docs/zh/50-machine-output.md | 1 + src/build/prepare/graph.cpp | 22 ++++++++++++++++------ src/build/prepare/plan.cpp | 4 +++- src/build/refusal.cppm | 8 ++++++++ tests/matrix/expected.tsv | 29 +++++++++++++++++++++++++++++ 7 files changed, 65 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9299a1d..dfd3c914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ graph: `[build-dependencies] openkal-tools = { tools = ["okld", "okrun"] }`. The C library and the C++ run-time inside an image are packages; the payload's libc++ is not offered to an image. + A build that links an image with no `okld` in the graph is refused with + the machine-readable reason `image-tool-absent`. ### Fixed @@ -51,6 +53,11 @@ edges away moved to its tip on every build and stopped an `--offline` build ("mcpp.lock records no commit for branch"). It is now recorded under the package's qualified name and read back as its anchor. +- **A branch pushed to between `git ls-remote` and the clone is still + fetched.** The clone of a branch is one commit deep, so a push between the + two left it at a newer tip without the commit the build had resolved, and + the build stopped at `unable to read tree`. That commit is now fetched by + its name, or, from a server that refuses that, with the branch's history. - **Switching workspace members under `trim_paths` no longer recompiles their shared dependencies** (#844). The prefix map of the whole plan was part of the flags every unit shares, so a dependency's command line named the roots diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index 734179a2..e2369c7e 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -569,6 +569,7 @@ a program classifying the outcome reads `reason`: | `std-module-precompile` | the standard library's module could not be precompiled for this configuration | | `msvc-redist-unavailable` | an explicit `cxx_runtime = "toolchain-coupled"` on an MSVC-ABI row whose toolset has no redistributable directory to stage *(2026.9.28.1+)* | | `crt-declared-under-host-coupled` | a file of the MSVC C++ runtime is declared beside the program while its contract is host-coupled, under which the system's runtime serves it and no copy is placed *(2026.9.28.2+)* | +| `image-tool-absent` | a build of `-openkal` links an image and finds no `okld`, the program of the graph that notes and checks an ok image *(2026.10.26.2+)* | | `other` | a refusal whose branch has not been given a token yet | **One token is also printed by `mcpp build` itself.** diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index 8d9729d5..f8947534 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -516,6 +516,7 @@ replaced}` —— `origin` 与构建的状态行使用的是同一句话 | `std-module-precompile` | 标准库的模块在这个配置下无法被预编译 | | `msvc-redist-unavailable` | 在 MSVC ABI 的行上显式写了 `cxx_runtime = "toolchain-coupled"`,而该行的工具集没有可放置的 redistributable 目录 *(2026.9.28.1+)* | | `crt-declared-under-host-coupled` | 程序的契约是 host-coupled(由系统的运行时服务、不放置任何副本),而程序旁声明了 MSVC C++ 运行时的文件 *(2026.9.28.2+)* | +| `image-tool-absent` | 一个 `-openkal` 的构建要链接一个映像,而找不到 `okld` —— 为 ok 映像写需求注记并检查约定的、图中的程序 *(2026.10.26.2+)* | | `other` | 一个尚未被赋予记号的拒绝分支 | **其中一个记号也由 `mcpp build` 自己打印。** `interface-not-provided` 会 diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index e10ffb31..dc355b2b 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -1535,18 +1535,28 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { // history before the checkout. Only a tip just read from // ls-remote is guaranteed present in a depth-1 clone. // + // AND ONLY WHILE THE BRANCH STAYS WHERE ls-remote SAW IT. A push + // between the two leaves the clone at a newer tip without the + // commit the build resolved; the commit is then fetched by its + // name, or, from a server that refuses that, the branch's whole + // history. Measured in mcpp's own CI: a push to mcpplibs.os + // between ls-remote and the clone ended the build with + // "unable to read tree (787988c…)". + // // `git -C` rather than `cd &&`: on Windows `cd` does not // change drive without /d, and the cache root routinely lives // on a different one than the project. + const auto dir = mcpp::platform::shell::quote(gitRoot.string()); + const auto rev = mcpp::platform::shell::quote(resolvedGitRev); auto cloneCmd = (spec.gitRefKind == "branch" && !fromLock) ? std::format( - "git clone --progress --depth 1 --branch {} {} {} && " - "git -C {} checkout --quiet {} 2>&1", + "git clone --progress --depth 1 --branch {0} {1} {2} && " + "(git -C {2} checkout --quiet {3} || " + "((git -C {2} fetch --quiet --depth 1 origin {3} || " + "git -C {2} fetch --quiet --unshallow origin) && " + "git -C {2} checkout --quiet {3})) 2>&1", mcpp::platform::shell::quote(spec.gitRev), - mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(resolvedGitRev)) + mcpp::platform::shell::quote(spec.git), dir, rev) : std::format( "git clone --progress {} {} && git -C {} checkout --quiet {} 2>&1", mcpp::platform::shell::quote(spec.git), diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 262759fe..15431840 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1508,8 +1508,10 @@ static std::expected step13_assembly_units(PrepareState& stat const bool linksAnImage = std::ranges::any_of(ctx.plan.linkUnits, [](auto const& lu) { return lu.kind != mcpp::build::LinkUnit::StaticLibrary; }); - if (ctx.plan.okldPath.empty() && linksAnImage && !state.overrides.plan_only) + if (ctx.plan.okldPath.empty() && linksAnImage && !state.overrides.plan_only) { + mcpp::build::refusal::record(mcpp::build::refusal::Code::ImageToolAbsent); return std::unexpected(mcpp::okimage::no_tool_message(tt->str(), tool)); + } } // Clang: discover clang-scan-deps for P1689 dyndep scanning. if (mcpp::toolchain::is_clang(*state.tc)) { diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index d148e886..90d06d63 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -185,6 +185,13 @@ enum class Code { // discover them from (2026.10.5.2). Distinct from every other code: both // statements are the author's, and one of them has to change. LtoExportDiscovery, + // A build of `-openkal` that links an image finds no `okld` -- the + // format's tool, which writes the image's requirements note and checks the + // convention before the image is published (design 2026-10-26 part 2, + // §8.5). Distinct from LldRequiredAbsent, which is about the toolchain's + // payload: `okld` is a program of the dependency graph, and declaring + // openkal-tools under `[build-dependencies]` supplies it. + ImageToolAbsent, Other, // a refusal that has not been given a code yet }; @@ -239,6 +246,7 @@ constexpr std::string_view name(Code c) { case Code::LocalToolchain: return "local-toolchain"; case Code::TargetBase: return "target-base"; case Code::LtoExportDiscovery: return "lto-export-discovery"; + case Code::ImageToolAbsent: return "image-tool-absent"; case Code::Other: return "other"; } return "other"; diff --git a/tests/matrix/expected.tsv b/tests/matrix/expected.tsv index bfc955c5..e8b671eb 100644 --- a/tests/matrix/expected.tsv +++ b/tests/matrix/expected.tsv @@ -16,6 +16,7 @@ # unsupported / host-tool-toolchain 交叉目标下的 build.mcpp 没有可解析的宿主工具链 # unsupported / lld-required-absent 这一行直接经 lld 链接,而本机没有 # unsupported / std-module-precompile std 模块预编译不过 +# unsupported / image-tool-absent 链接 ok 映像而图里没有 okld(扫描工程不声明 openkal-tools) # # 这张表里**没有 `other`**,而这是一条不变量而不是巧合。`other` 在 # `src/build/refusal.cppm` 里的定义是「拒绝了而这一处分支还没有名字」,所以一行 @@ -60,6 +61,8 @@ graph linux-aarch64 aarch64-linux-gnu gcc@16.1.0 - - - - - unsupported layer-req graph linux-aarch64 aarch64-linux-gnu llvm@23.1.3 aarch64-unknown-linux-gnu payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-aarch64 aarch64-linux-musl gcc@16.1.0 - - - - - unsupported layer-requirement graph linux-aarch64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph linux-aarch64 aarch64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +graph linux-aarch64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent graph linux-aarch64 riscv64-linux-musl gcc@16.1.0 - - - - - unsupported tier-planned graph linux-aarch64 riscv64-linux-musl llvm@23.1.3 - - - - - unsupported tier-planned graph linux-aarch64 wasm32-emscripten gcc@16.1.0 - - - - - unsupported capability-pin @@ -70,6 +73,8 @@ graph linux-aarch64 x86_64-linux-musl gcc@16.1.0 - - - - - unsupported host-cann graph linux-aarch64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-aarch64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned graph linux-aarch64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +graph linux-aarch64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +graph linux-aarch64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent graph linux-aarch64 x86_64-windows-gnu gcc@16.1.0 - - - - - unsupported host-cannot-serve graph linux-aarch64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-aarch64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin @@ -80,6 +85,8 @@ graph linux-x86_64 aarch64-linux-gnu gcc@16.1.0 - - - - - unsupported layer-requ graph linux-x86_64 aarch64-linux-gnu llvm@23.1.3 aarch64-unknown-linux-gnu payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-x86_64 aarch64-linux-musl gcc@16.1.0 - - - - - unsupported layer-requirement graph linux-x86_64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph linux-x86_64 aarch64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +graph linux-x86_64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent graph linux-x86_64 riscv64-linux-musl gcc@16.1.0 - - - - - unsupported tier-planned graph linux-x86_64 riscv64-linux-musl llvm@23.1.3 - - - - - unsupported tier-planned graph linux-x86_64 wasm32-emscripten gcc@16.1.0 - - - - - unsupported capability-pin @@ -92,6 +99,8 @@ graph linux-x86_64 x86_64-linux-musl gcc@16.1.0 - - - - - unsupported layer-requ graph linux-x86_64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-x86_64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned graph linux-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +graph linux-x86_64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +graph linux-x86_64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent graph linux-x86_64 x86_64-windows-gnu gcc@16.1.0 - - - - - unsupported layer-requirement graph linux-x86_64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-x86_64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin @@ -100,11 +109,13 @@ graph macos-arm64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capabi graph macos-arm64 aarch64-linux-gnu llvm@23.1.3 aarch64-unknown-linux-gnu host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph macos-arm64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph macos-arm64 aarch64-macos llvm@23.1.3 arm64-apple-macos14.0 host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph macos-arm64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent graph macos-arm64 riscv64-linux-musl llvm@23.1.3 - - - - - unsupported tier-planned graph macos-arm64 wasm32-emscripten llvm@23.1.3 - - - - - unsupported capability-pin graph macos-arm64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin graph macos-arm64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph macos-arm64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +graph macos-arm64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent graph macos-arm64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph macos-arm64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph windows-x86_64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin @@ -113,6 +124,8 @@ graph windows-x86_64 aarch64-linux-gnu llvm@23.1.3 aarch64-unknown-linux-gnu non graph windows-x86_64 aarch64-linux-gnu msvc@system - - - - - unsupported host-tool-toolchain graph windows-x86_64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph windows-x86_64 aarch64-linux-musl msvc@system - - - - - unsupported host-tool-toolchain +graph windows-x86_64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent +graph windows-x86_64 aarch64-openkal msvc@system - - - - - unsupported capability-pin graph windows-x86_64 riscv64-linux-musl llvm@23.1.3 - - - - - unsupported tier-planned graph windows-x86_64 riscv64-linux-musl msvc@system - - - - - unsupported tier-planned graph windows-x86_64 wasm32-emscripten llvm@23.1.3 - - - - - unsupported capability-pin @@ -123,6 +136,8 @@ graph windows-x86_64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl non graph windows-x86_64 x86_64-linux-musl msvc@system - - - - - unsupported host-tool-toolchain graph windows-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned graph windows-x86_64 x86_64-macos msvc@system - - - - - unsupported tier-planned +graph windows-x86_64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent +graph windows-x86_64 x86_64-openkal msvc@system - - - - - unsupported capability-pin graph windows-x86_64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph windows-x86_64 x86_64-windows-gnu msvc@system - - - - - unsupported host-tool-toolchain graph windows-x86_64 x86_64-windows-msvc llvm@23.1.3 - - - - - unsupported std-module-precompile @@ -137,6 +152,8 @@ payload linux-aarch64 aarch64-linux-musl gcc@16.1.0 aarch64-unknown-linux-musl p payload linux-aarch64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced payload linux-aarch64 aarch64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf payload - - - ok none +payload linux-aarch64 aarch64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-aarch64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent payload linux-aarch64 armv7a-none-eabi gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi payload - - - ok none payload linux-aarch64 armv7a-none-eabihf gcc@16.1.0 - - - - - unsupported capability-pin @@ -171,6 +188,8 @@ payload linux-aarch64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned payload linux-aarch64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned payload linux-aarch64 x86_64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 x86_64-none-elf llvm@23.1.3 x86_64-none-elf payload - - - ok none +payload linux-aarch64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-aarch64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent payload linux-aarch64 x86_64-windows-gnu gcc@16.1.0 - - - - - unsupported host-cannot-serve payload linux-aarch64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced payload linux-aarch64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin @@ -183,6 +202,8 @@ payload linux-x86_64 aarch64-linux-musl gcc@16.1.0 aarch64-unknown-linux-musl pa payload linux-x86_64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced payload linux-x86_64 aarch64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf payload - - - ok none +payload linux-x86_64 aarch64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-x86_64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent payload linux-x86_64 armv7a-none-eabi gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi payload - - - ok none payload linux-x86_64 armv7a-none-eabihf gcc@16.1.0 - - - - - unsupported capability-pin @@ -219,6 +240,8 @@ payload linux-x86_64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned payload linux-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned payload linux-x86_64 x86_64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 x86_64-none-elf llvm@23.1.3 x86_64-none-elf payload - - - ok none +payload linux-x86_64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-x86_64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent payload linux-x86_64 x86_64-windows-gnu gcc@16.1.0 x86_64-w64-windows-gnu none ucrt(payload) libstdc++(payload) - ok none payload linux-x86_64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced payload linux-x86_64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin @@ -230,6 +253,7 @@ payload macos-arm64 aarch64-linux-gnu llvm@23.1.3 - - - - - unsupported host-can payload macos-arm64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced payload macos-arm64 aarch64-macos llvm@23.1.3 arm64-apple-macos14.0 host libSystem(payload) libc++(payload) - ok none payload macos-arm64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf host - - - ok none +payload macos-arm64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent payload macos-arm64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi host - - - ok none payload macos-arm64 armv7a-none-eabihf llvm@23.1.3 armv7a-none-eabihf host - - - ok none payload macos-arm64 riscv32-none-elf llvm@23.1.3 riscv32-none-elf host - - - ok none @@ -248,6 +272,7 @@ payload macos-arm64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported capab payload macos-arm64 x86_64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced payload macos-arm64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned payload macos-arm64 x86_64-none-elf llvm@23.1.3 x86_64-none-elf host - - - ok none +payload macos-arm64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent payload macos-arm64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced payload macos-arm64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported host-cannot-serve payload windows-x86_64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin @@ -258,6 +283,8 @@ payload windows-x86_64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported conv payload windows-x86_64 aarch64-linux-musl msvc@system - - - - - unsupported convention-unreplaced payload windows-x86_64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf none - - - ok none payload windows-x86_64 aarch64-none-elf msvc@system - - - - - unsupported capability-pin +payload windows-x86_64 aarch64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent +payload windows-x86_64 aarch64-openkal msvc@system - - - - - unsupported capability-pin payload windows-x86_64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi none - - - ok none payload windows-x86_64 armv7a-none-eabi msvc@system - - - - - unsupported capability-pin payload windows-x86_64 armv7a-none-eabihf llvm@23.1.3 armv7a-none-eabihf none - - - ok none @@ -292,6 +319,8 @@ payload windows-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-plann payload windows-x86_64 x86_64-macos msvc@system - - - - - unsupported tier-planned payload windows-x86_64 x86_64-none-elf llvm@23.1.3 - - - - - unsupported lld-required-absent payload windows-x86_64 x86_64-none-elf msvc@system - - - - - unsupported capability-pin +payload windows-x86_64 x86_64-openkal llvm@23.1.3 - - - - - unsupported image-tool-absent +payload windows-x86_64 x86_64-openkal msvc@system - - - - - unsupported capability-pin payload windows-x86_64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced payload windows-x86_64 x86_64-windows-gnu msvc@system - - - - - unsupported convention-unreplaced payload windows-x86_64 x86_64-windows-msvc llvm@23.1.3 x86_64-pc-windows-msvc none msvc(payload) msvc-stl(payload) - ok none From 52c69f843dbdfb7220aebf394c76c0e9b77f6584 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 00:39:37 +0900 Subject: [PATCH 16/43] workspace = true under a target selector or behind a feature receives the workspace's source, and an unresolved one is refused by name Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 7 ++++ src/project.cppm | 25 ++++++++++++- tests/unit/test_workspace_inheritance.cpp | 45 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd3c914..ab27077c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ edges away moved to its tip on every build and stopped an `--offline` build ("mcpp.lock records no commit for branch"). It is now recorded under the package's qualified name and read back as its anchor. +- **A dependency written `workspace = true` under a target selector or behind + a feature receives the workspace's source.** Only `[dependencies]`, + `[dev-dependencies]` and `[build-dependencies]` were merged, so a git or path + dependency of `[workspace.dependencies]` inherited in + `[target.'cfg(...)'.dependencies]` or `[feature-deps.]` kept an empty + version and resolved from the index. Such an entry no workspace declares is + now refused by name, as one in `[dependencies]` is. - **A branch pushed to between `git ls-remote` and the clone is still fetched.** The clone of a branch is one commit deep, so a push between the two left it at a newer tip without the commit the build had resolved, and diff --git a/src/project.cppm b/src/project.cppm index d8c1cd60..7ffa5c33 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -113,6 +113,21 @@ export void merge_workspace_deps(mcpp::manifest::Manifest& member, merge_map(member.dependencies); merge_map(member.devDependencies); merge_map(member.buildDependencies); + for (auto& [feature, deps] : member.featureDeps) merge_map(deps); + // AND EVERY CONDITIONAL TABLE. `[target.'cfg(...)'.dependencies]` is merged + // into the maps above only when its predicate holds, which is after this + // runs; an entry there written `workspace = true` kept its empty version + // and resolved from the index, so a workspace's git dependency inherited + // under a target selector became the published version of the same name + // (measured in mcpp-language-server: `openkal-linux.workspace = true` under + // `cfg(os = "linux")` resolved openkal-linux 0.17.0 beside a branch of + // openkal that required another). + for (auto& c : member.conditionalConfigs) { + merge_map(c.dependencies); + merge_map(c.devDependencies); + merge_map(c.buildDependencies); + for (auto& [feature, deps] : c.featureDeps) merge_map(deps); + } } // Inherit the workspace root's `[indices]` when the member declares none. @@ -716,10 +731,18 @@ unresolved_workspace_dependency_error(const mcpp::manifest::Manifest& m, return std::nullopt; }; std::optional name; - std::string_view table; + std::string table; if ((name = first(m.dependencies))) table = "dependencies"; else if ((name = first(m.devDependencies))) table = "dev-dependencies"; else if ((name = first(m.buildDependencies))) table = "build-dependencies"; + for (auto it = m.featureDeps.begin(); !name && it != m.featureDeps.end(); ++it) + if ((name = first(it->second))) table = std::format("feature-deps.{}", it->first); + for (auto it = m.conditionalConfigs.begin(); !name && it != m.conditionalConfigs.end(); ++it) { + const std::string at = std::format("target.{}", it->predicate); + if ((name = first(it->dependencies))) table = at + ".dependencies"; + else if ((name = first(it->devDependencies))) table = at + ".dev-dependencies"; + else if ((name = first(it->buildDependencies))) table = at + ".build-dependencies"; + } if (!name) return std::nullopt; return std::format( "{}: [{}] {} = {{ workspace = true }}, but no workspace declares '{}'.\n" diff --git a/tests/unit/test_workspace_inheritance.cpp b/tests/unit/test_workspace_inheritance.cpp index 74f4d5a5..a22ae524 100644 --- a/tests/unit/test_workspace_inheritance.cpp +++ b/tests/unit/test_workspace_inheritance.cpp @@ -420,3 +420,48 @@ TEST(ResolveMember, NoFilterActsOnRootedRootAndRefusesVirtual) { } } // namespace resolve_member + +// A `workspace = true` entry under a target selector, or behind a feature, +// receives the workspace's source as one in [dependencies] does: a git +// dependency inherited there resolved from the index before 2026.10.26.2. +TEST(WorkspaceInheritance, ConditionalAndFeatureTablesInheritTheSource) { + auto ws = mcpp::manifest::parse_string( + "[workspace]\nmembers = [\"m\"]\n\n" + "[workspace.dependencies]\n" + "lib = { git = \"https://example.invalid/lib.git\", branch = \"main\" }\n" + "tool = { path = \"tools/tool\" }\n"); + ASSERT_TRUE(ws.has_value()) << ws.error().format(); + auto m = mcpp::manifest::parse_string( + "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n" + "[features]\nextra = []\n\n" + "[feature-deps.extra]\ntool.workspace = true\n\n" + "[target.'cfg(os = \"linux\")'.dependencies]\nlib.workspace = true\n"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + mcpp::project::merge_workspace_deps(*m, *ws, "/ws"); + ASSERT_FALSE(m->conditionalConfigs.empty()); + bool found = false; + for (auto const& c : m->conditionalConfigs) { + auto it = c.dependencies.find("lib"); + if (it == c.dependencies.end()) continue; + found = true; + EXPECT_FALSE(it->second.inheritWorkspace); + EXPECT_EQ(it->second.git, "https://example.invalid/lib.git"); + EXPECT_EQ(it->second.gitRev, "main"); + } + EXPECT_TRUE(found); + ASSERT_TRUE(m->featureDeps.contains("extra")); + auto const& tool = m->featureDeps.at("extra").at("tool"); + EXPECT_FALSE(tool.inheritWorkspace); + EXPECT_FALSE(tool.path.empty()); + EXPECT_FALSE(mcpp::project::unresolved_workspace_dependency_error(*m, "/ws/m").has_value()); +} + +TEST(WorkspaceInheritance, AnUnresolvedConditionalEntryIsNamed) { + auto m = mcpp::manifest::parse_string( + "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n" + "[target.'cfg(os = \"linux\")'.dependencies]\nlib.workspace = true\n"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto err = mcpp::project::unresolved_workspace_dependency_error(*m, "/m"); + ASSERT_TRUE(err.has_value()); + EXPECT_NE(err->find("dependencies] lib"), std::string::npos) << *err; +} From ddcb8d1598ade916609522e8df6f3846fbaf673e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 01:01:49 +0900 Subject: [PATCH 17/43] a root's checkout answers a dependency's request by version that no index carries (e2e 944) Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 7 ++ src/build/prepare/graph.cpp | 44 ++++++++- ...ckout_answers_a_version_the_index_lacks.sh | 98 +++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100755 tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ab27077c..d2755a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ edges away moved to its tip on every build and stopped an `--offline` build ("mcpp.lock records no commit for branch"). It is now recorded under the package's qualified name and read back as its anchor. +- **A root's checkout answers a dependency's request by version that no index + carries.** The root's path or git declaration of a package overrides a + dependency's request for it by version, but the request was first given to + the index to select, and a package the index did not carry yet (a root + pinning a checkout ahead of a release) refused the build with "no package + found for exact selector". The root's checkout now answers it; a + requirement the checkout does not satisfy is still refused. - **A dependency written `workspace = true` under a target selector or behind a feature receives the workspace's source.** Only `[dependencies]`, `[dev-dependencies]` and `[build-dependencies]` were merged, so a git or path diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index dc355b2b..3e2a881e 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -757,8 +757,28 @@ struct WorklistItemCtx { std::filesystem::path gitMemberCloneRoot; std::filesystem::path dep_root; std::optional dep_manifest; + // The request is by version for an identity the root declared otherwise. + bool rootHeld = false; }; +// The identity a root declaration already holds for a dependency's request +// by version, under the request's namespace, or the default namespace and none +// for a bare name; nothing when the root declares no such package, or the +// request is the root's own. +static std::optional +root_held_identity(PrepareState& state, const WorkItem& item) { + if (!item.spec.isVersion() || state.declaredByRoot(item.consumerDepIndex)) return std::nullopt; + const std::string shortName = item.spec.shortName.empty() ? item.name : item.spec.shortName; + std::vector namespaces; + if (!item.spec.namespace_.empty()) namespaces.push_back(item.spec.namespace_); + else namespaces = { std::string(mcpp::pm::kDefaultNamespace), std::string{} }; + for (auto const& ns : namespaces) + if (auto it = state.resolved.find(ResolvedKey{ ns, shortName }); + it != state.resolved.end() && it->second.fromRoot && it->second.source != "version") + return it->first; + return std::nullopt; +} + static std::expected step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { auto& item = ctx.item; @@ -775,7 +795,22 @@ step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { }}; } - if (auto r = state.selectDependencyCandidate(spec, name); !r) { + // A VERSION THE ROOT HAS ALREADY ANSWERED FOR. A dependency's request + // by version for an identity the root declared by path or git is + // overridden by the root's declaration (row 4 below), whatever the + // index holds; it is not the index's to select or pin. Asked of the + // index first, a package the index does not carry yet -- the case a + // root pins a checkout for, ahead of a release -- refused the build + // with "no package found for exact selector" (measured: openkal-musl's + // `openkal-ld = "0.1.0"` under `cfg(os = "openkal")` against a root's + // git checkout of it). The root's checkout still answers the + // requirement, at the conflict below. + if (auto held = root_held_identity(state, item)) { + spec.namespace_ = held->ns; + spec.shortName = held->shortName; + spec.candidates = {{ .namespace_ = held->ns, .shortName = held->shortName }}; + ctx.rootHeld = true; + } else if (auto r = state.selectDependencyCandidate(spec, name); !r) { return std::unexpected(r.error()); } // A root's declaration (a selected member's included) states the @@ -834,9 +869,10 @@ step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { spec.version = "=" + pin->second; } // Pin SemVer constraint before dedup/fetch. - if (auto r = state.resolveSemver(spec, name); !r) { - return std::unexpected(r.error()); - } + if (!ctx.rootHeld) + if (auto r = state.resolveSemver(spec, name); !r) { + return std::unexpected(r.error()); + } auto& key = ctx.key; key = ResolvedKey{ diff --git a/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh b/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh new file mode 100755 index 00000000..07c32b22 --- /dev/null +++ b/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# requires: gcc +# 944 -- a dependency's request by version is answered by the root's checkout +# of the package, whether or not the index carries it. +# +# The root's declaration of an identity by path or git overrides a +# dependency's request for it by version (the 2026-09-13-630 decision table, +# row 4). The request was first given to the index to select and pin, so a +# package the index did not carry yet -- the case a root pins a checkout for, +# ahead of a release -- refused the build: +# +# error: dependency 'openkal-ld': no package found for exact selector +# +# Measured with openkal-musl's `openkal-ld = "0.1.0"` under `cfg(os = "openkal")` +# against a language server's git checkout of openkal-ld. +# +# Criteria: +# A. A dependency's conditional request by version, for a package no index +# carries, is answered by the root's path checkout under a target selector. +# B. The same by the root's git checkout of a repository member. +# C. A requirement the root's checkout does not satisfy is still refused, +# naming both. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +case "$(uname -s)" in + Linux) OS=linux ;; + Darwin) OS=macos ;; + *) OS=windows ;; +esac + +# A repository whose member is the package, at a name no index has. +mkdir -p "$TMP/ws/m944leaf/src" +printf '[workspace]\nmembers = ["m944leaf"]\n' > "$TMP/ws/mcpp.toml" +printf '[package]\nname = "m944leaf"\nversion = "0.1.0"\n\n[targets.m944leaf]\nkind = "lib"\n' > "$TMP/ws/m944leaf/mcpp.toml" +printf 'export module m944leaf;\nexport int leaf() { return 1; }\n' > "$TMP/ws/m944leaf/src/leaf.cppm" +(cd "$TMP/ws" && git init --quiet -b main && git config user.email t@local && git config user.name t \ + && git add -A && git commit --quiet -m init) + +# A dependency that asks for it by version, on this system only. +mkdir -p "$TMP/mid/src" +cat > "$TMP/mid/mcpp.toml" < "$TMP/mid/src/mid.cppm" + +app() { # app + mkdir -p "$1/src" + cat > "$1/mcpp.toml" < "$1/src/main.cpp" +} + +# A +app "$TMP/a" "{ path = \"$TMP/ws/m944leaf\" }" +(cd "$TMP/a" && "$MCPP" build > build.log 2>&1) || fail "A: the root's path checkout did not answer" "$TMP/a/build.log" +grep -q "no package found" "$TMP/a/build.log" && fail "A: the index was asked" "$TMP/a/build.log" +echo "ok: a path checkout answers a conditional request by version" + +# B +app "$TMP/b" "{ git = \"file://$TMP/ws\", branch = \"main\" }" +(cd "$TMP/b" && "$MCPP" build > build.log 2>&1) || fail "B: the root's git checkout did not answer" "$TMP/b/build.log" +echo "ok: a git checkout of a member answers it" + +# C +sed -i.bak 's/^m944leaf = "0.1.0"/m944leaf = "0.2.0"/' "$TMP/mid/mcpp.toml" +app "$TMP/c" "{ path = \"$TMP/ws/m944leaf\" }" +if (cd "$TMP/c" && "$MCPP" build > build.log 2>&1); then + fail "C: a requirement the checkout does not satisfy was accepted" "$TMP/c/build.log" +fi +grep -q "requires 0.2.0" "$TMP/c/build.log" || fail "C: the refusal does not name the requirement" "$TMP/c/build.log" +echo "ok: an unsatisfied requirement is still refused" + +echo "PASS: 944 a root checkout answers a version the index lacks" From c849fc4f2db98f121b97b8229d0425c74ac2978f Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 01:05:28 +0900 Subject: [PATCH 18/43] the tools a workspace's selected package declares are the root's (okld, okrun for an ok image); e2e 942 G Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 2 ++ src/build/prepare/features.cpp | 5 +++- ...ok_image_is_built_once_and_run_by_okrun.sh | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2755a7e..1956bf6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ payload's libc++ is not offered to an image. A build that links an image with no `okld` in the graph is refused with the machine-readable reason `image-tool-absent`. + The tools a workspace's root package declares are its own, as any root's + are. ### Fixed diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 02132f22..357ed8d9 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -1206,7 +1206,10 @@ static void step6_record_tool_provision(PrepareState& state, HostToolCtx& ctx, const std::filesystem::path& p) { for (std::size_t c = 0; c < state.provisionGraph.visible.size(); ++c) { if (!state.provisionGraph.visible[c].contains(ctx.want)) continue; - if (c == 0) state.rootTools[ctx.toolName] = p; + // The root's own, and a member this build selects, which declares as + // the root does: in a workspace the selected package is not package 0. + if (c == 0 || (c < state.packages.size() && state.packages[c].selectedMember)) + state.rootTools[ctx.toolName] = p; auto& v = state.toolEnvByConsumer[c]; std::vector vars; for (auto const& n : state.publishedNamesFor(ctx.depIdx, state.bareBindingsFor(c))) { diff --git a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh index c311cd73..7f5a5a27 100755 --- a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh +++ b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh @@ -25,6 +25,9 @@ # F. `mcpp pack` stages the image (M2), and `--mode self-contained` stages a # program of this machine that carries it and runs with nothing installed # (M3). +# G. A workspace's root package declares the tools as any root does: the +# image builds and runs through okrun (the selected package is not the +# graph's first, and its tools were not recorded as the root's). set -e TMP=$(mktemp -d) @@ -191,4 +194,29 @@ grep -q "needs a C++ run-time inside the image" std.log || fail "D: the refusal' grep -q "openkal-llvm-runtime" std.log || fail "D: the refusal does not name the provider" std.log echo "ok: import std without a C++ run-time is refused, naming the provider" +# G +mkdir -p "$TMP/ws/src" "$TMP/ws/m/src" +cp "$TMP/app/main.c.good" "$TMP/ws/src/main.c" +cat > "$TMP/ws/mcpp.toml" < "$TMP/ws/m/mcpp.toml" +printf 'int m_value(void) { return 0; }\n' > "$TMP/ws/m/src/m.c" +(cd "$TMP/ws" && "$MCPP" run --target "$HOST_ARCH-openkal" -- one two > run.log 2>&1) \ + || fail "G: a workspace root's image did not build or run" "$TMP/ws/run.log" +grep -q "image: three arguments" "$TMP/ws/run.log" || fail "G: the workspace root's image" "$TMP/ws/run.log" +echo "ok: a workspace root declares the tools as any root does" + echo "PASS: 942 an ok image is built once and run by okrun" From c23db6a4736f3b2bf6d9ed0be7420e654e152d2c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 01:08:48 +0900 Subject: [PATCH 19/43] an unchecked ok image is written beneath obj/okimage, and bin/ holds only what was published Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/ninja_backend.cppm | 10 +++++++--- .../942_an_ok_image_is_built_once_and_run_by_okrun.sh | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 720f3794..037e79ac 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2276,8 +2276,9 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, } else if (okImageTarget) { // AN OK IMAGE, LINKED BY `ld.lld` ITSELF (SPEC 12.2). An executable // image is ET_DYN with the entry `ok_main`; an image another loads - // has its name and no entry. Each edge writes `.unchecked`; - // the `okimage_finish` edge below writes the requirements note, + // has its name and no entry. Each edge writes + // `obj/okimage/.unchecked`; the `okimage_finish` edge below + // writes the requirements note, // checks the convention and publishes ``. const std::string entry = std::format("-e {}", mcpp::okimage::kEntry); link_rule("cxx_link", @@ -3298,7 +3299,10 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // written beside its name and finished by its own edge (above). const bool okImageUnit = okImageTarget && lu.kind != LinkUnit::StaticLibrary; if (okImageUnit) ins += " " + escape_ninja_path(okNoteObject); - auto okUnchecked = lu.output; okUnchecked += ".unchecked"; + // Beneath obj/, not beside the name: what bin/ holds is what was + // published, and an unchecked image is not. + auto okUnchecked = std::filesystem::path("obj/okimage") / lu.output.relative_path(); + okUnchecked += ".unchecked"; // mcpp#426. `separateLinker` (link.exe) takes no driver at all, so the // question does not arise there and the msvc rules are untouched. diff --git a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh index 7f5a5a27..c2c48d3c 100755 --- a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh +++ b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh @@ -12,7 +12,8 @@ # # Criteria: # A. Both rows build: `x86_64-openkal` and `aarch64-openkal` each produce -# `bin/app.oke`, an ELF shared object of its architecture. +# `bin/app.oke`, an ELF shared object of its architecture, and bin/ holds +# no unchecked image. # B. `mcpp run` on the row of this machine's architecture runs the image # through okrun: its arguments, a callback into the image, thread-local # storage and a by-value structure all arrive. @@ -133,6 +134,7 @@ ok = d[:4] == b'\x7fELF' and struct.unpack_from(' /dev/null 2>&1 && fail "A: an unchecked image is in bin/" "build-x86_64.log" echo "ok: both rows publish an ELF image of their architecture" # B From ae6b31b57f98ca8844253984e9cfbffdca82938d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 01:30:38 +0900 Subject: [PATCH 20/43] pack --mode self-contained reads the effective declaration of openkal-tools (workspace inheritance, the target's conditional tables); a workspace root's effective manifest merges its own [workspace.dependencies]; e2e 942 pins the image's environment Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 3 ++ src/pack/pack.cppm | 31 ++++++++++++++----- src/project.cppm | 8 +++++ ...ok_image_is_built_once_and_run_by_okrun.sh | 10 ++++-- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1956bf6b..f95bde79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ the machine-readable reason `image-tool-absent`. The tools a workspace's root package declares are its own, as any root's are. + `mcpp pack --mode self-contained` reads the declaration of openkal-tools a + build resolves: after workspace inheritance and in the target's + `[target.'cfg(os = "openkal")'.build-dependencies]`. ### Fixed diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index ae185164..7aa313d1 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -51,6 +51,8 @@ import mcpp.platform; import mcpp.provider; import mcpp.provider.select; import mcpp.manifest; +import mcpp.project; // the effective manifest a build reads +import mcpp.build.prepare_inputs; // cfgpred: the target's conditional tables import mcpp.toolchain.triple; import mcpp.toolchain.base; import mcpp.ui; @@ -2000,15 +2002,30 @@ okimage_build_selfcontained(const Plan& plan, const std::filesystem::path& image // the same repository at the same reference selects its member by key // (05 -- Dependencies), and a published version has a sibling of the same // version. - auto man = mcpp::manifest::load(plan.projectRoot / "mcpp.toml"); - if (!man) return std::unexpected(Error{std::format( - "cannot read {}: {}", (plan.projectRoot / "mcpp.toml").string(), man.error().format())}); - const mcpp::manifest::DependencySpec* tools = nullptr; - for (auto const& [key, spec] : man->buildDependencies) - if (spec.shortName == "openkal-tools") { tools = &spec; break; } + // + // THE DECLARATION THE BUILD RESOLVED: after workspace inheritance, and + // with the conditional tables whose predicate the image's target meets + // (`[target.'cfg(os = "openkal")'.build-dependencies]`, which is where a + // program built for systems as well as images declares it). The raw file's + // `[build-dependencies]` alone missed both. + auto eff = mcpp::project::load_effective_manifest(plan.projectRoot); + if (!eff) return std::unexpected(Error{std::format( + "cannot read {}: {}", (plan.projectRoot / "mcpp.toml").string(), eff.error())}); + const auto& man = eff->manifest; + std::optional tools; + auto find_in = [&](const std::map& deps) { + for (auto const& [key, spec] : deps) + if (key == "openkal-tools" || spec.shortName == "openkal-tools") tools = spec; + }; + find_in(man.buildDependencies); + const auto ctx = mcpp::build::cfgpred::context_for(plan.triple); + for (auto const& cc : man.conditionalConfigs) + if (!mcpp::build::cfgpred::uses_layer(cc.predicate) + && mcpp::build::cfgpred::matches(cc.predicate, ctx)) + find_in(cc.buildDependencies); if (!tools) return std::unexpected(Error{ "--mode self-contained of an ok image builds a program over `openkal-run` from the " - "source of the project's `openkal-tools`, and [build-dependencies] declares none"}); + "source of the project's `openkal-tools`, and its [build-dependencies] declare none"}); std::string runSpec; if (!tools->git.empty()) { runSpec = std::format("{{ git = \"{}\", {} = \"{}\" }}", tools->git, diff --git a/src/project.cppm b/src/project.cppm index 7ffa5c33..e3eb8074 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -784,6 +784,14 @@ load_effective_manifest(const std::filesystem::path& dir) { if (wsRoot.empty()) { auto m = mcpp::manifest::load(manifestPath); if (!m) return std::unexpected(m.error().format()); + // A workspace root that is a package takes its own + // [workspace.dependencies] as the planner's member "." does + // (`load_member_manifest`); they were not merged here, so a reader of + // the root's effective manifest saw `x.workspace = true` with no source. + if (m->workspace.present) { + const auto workspace = *m; + merge_workspace_deps(*m, workspace, std::filesystem::absolute(dir)); + } inherit_as_root_package(*m, std::filesystem::absolute(dir)); return EffectiveManifest{ std::move(*m), std::nullopt, {}, false }; } diff --git a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh index c2c48d3c..0dee9d27 100755 --- a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh +++ b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh @@ -15,8 +15,8 @@ # `bin/app.oke`, an ELF shared object of its architecture, and bin/ holds # no unchecked image. # B. `mcpp run` on the row of this machine's architecture runs the image -# through okrun: its arguments, a callback into the image, thread-local -# storage and a by-value structure all arrive. +# through okrun: its arguments, its environment, a callback into the +# image, thread-local storage and a by-value structure all arrive. # C. An image that imports a C library's name (`puts`) is refused by the # check, which names the name; the image under `bin/` is still the one the # last successful build published. @@ -25,7 +25,7 @@ # E. A second build is up to date. # F. `mcpp pack` stages the image (M2), and `--mode self-contained` stages a # program of this machine that carries it and runs with nothing installed -# (M3). +# (M3), its image given the program's arguments and environment. # G. A workspace's root package declares the tools as any root does: the # image builds and runs through okrun (the selected package is not the # graph's first, and its tools were not recorded as the root's). @@ -68,6 +68,7 @@ struct kal_task { kal_uintptr h; }; struct kal_stream kal_stdout(void); kal_intptr kal_stream_write(struct kal_stream, const void*, kal_uintptr); kal_uintptr kal_env_arg_count(void); +kal_uintptr kal_env_var_count(void); int kal_task_start(void (*entry)(void*), void* arg, struct kal_task* out); int kal_task_join(struct kal_task); kal_uintptr kal_task_current(void); @@ -111,6 +112,7 @@ static void worker(void* arg) { long* seen = arg; *seen = tl; tl = 99; } int ok_main(void) { put(kal_env_arg_count() == 3 ? "image: three arguments\n" : "image: arguments lost\n"); + put(kal_env_var_count() > 0 ? "image: an environment\n" : "image: no environment\n"); tl = 1; long seen = 0; struct kal_task t; @@ -141,6 +143,7 @@ echo "ok: both rows publish an ELF image of their architecture" "$MCPP" run --target "$HOST_ARCH-openkal" -- one two > run.log 2>&1 \ || fail "B: the image did not run through okrun" run.log grep -q "image: three arguments" run.log || fail "B: the image's arguments" run.log +grep -q "image: an environment" run.log || fail "B: the image's environment" run.log grep -q "image: each context has its own storage" run.log || fail "B: thread-local storage or the callback" run.log echo "ok: okrun runs the image of this machine's architecture" @@ -158,6 +161,7 @@ m3=$(ls target/dist/m3/bin/app target/dist/m3/bin/app.exe 2>/dev/null | head -1) [ -n "$m3" ] || fail "F: the M3 package has no program" m3.log "$m3" one two > m3run.log 2>&1 || fail "F: the M3 program did not run" m3run.log grep -q "image: three arguments" m3run.log || fail "F: the M3 program's image" m3run.log +grep -q "image: an environment" m3run.log || fail "F: the M3 program's image was given no environment" m3run.log echo "ok: pack stages the image (M2) and a program that carries it (M3)" # C From 5728cc29c0df493b075432c48b234c95c7ab00a6 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 01:33:38 +0900 Subject: [PATCH 21/43] pack --mode self-contained resolves the generated project afresh, as the image's tools were Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/pack/pack.cppm | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 7aa313d1..68a0ff27 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -2043,6 +2043,12 @@ okimage_build_selfcontained(const Plan& plan, const std::filesystem::path& image std::error_code ec; fs::create_directories(dir / "src", ec); if (ec) return std::unexpected(Error{std::format("cannot create {}: {}", dir.string(), ec.message())}); + // RESOLVED AS THE BUILD THAT MADE THE IMAGE RESOLVED ITS TOOLS. The + // generated project is this pack's, not a project of its own: a lock it + // kept from an earlier pack held `openkal-run` at that pack's commit while + // the image's tools had moved on, and the program then carried a runner + // older than the one the image was checked by. + fs::remove(dir / "mcpp.lock", ec); auto write = [&](const fs::path& p, std::string_view text) -> bool { std::ofstream out(p, std::ios::binary | std::ios::trunc); From 925df2c2b20e7403341ccdd728c412c1cd5bc378 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 02:02:05 +0900 Subject: [PATCH 22/43] e2e 944: manifest paths in host spelling Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ..._root_checkout_answers_a_version_the_index_lacks.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh b/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh index 07c32b22..dd1b5b07 100755 --- a/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh +++ b/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh @@ -24,6 +24,8 @@ set -e TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT +source "$(dirname "$0")/_host_path.sh" +TMP_HOST="$(host_path "$TMP")" fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } case "$(uname -s)" in @@ -67,7 +69,7 @@ kind = "bin" main = "src/main.cpp" [dependencies] -m944mid = { path = "$TMP/mid" } +m944mid = { path = "$TMP_HOST/mid" } [target.'cfg(os = "$OS")'.dependencies] m944leaf = $2 @@ -76,19 +78,19 @@ TOML } # A -app "$TMP/a" "{ path = \"$TMP/ws/m944leaf\" }" +app "$TMP/a" "{ path = \"$TMP_HOST/ws/m944leaf\" }" (cd "$TMP/a" && "$MCPP" build > build.log 2>&1) || fail "A: the root's path checkout did not answer" "$TMP/a/build.log" grep -q "no package found" "$TMP/a/build.log" && fail "A: the index was asked" "$TMP/a/build.log" echo "ok: a path checkout answers a conditional request by version" # B -app "$TMP/b" "{ git = \"file://$TMP/ws\", branch = \"main\" }" +app "$TMP/b" "{ git = \"file://$TMP_HOST/ws\", branch = \"main\" }" (cd "$TMP/b" && "$MCPP" build > build.log 2>&1) || fail "B: the root's git checkout did not answer" "$TMP/b/build.log" echo "ok: a git checkout of a member answers it" # C sed -i.bak 's/^m944leaf = "0.1.0"/m944leaf = "0.2.0"/' "$TMP/mid/mcpp.toml" -app "$TMP/c" "{ path = \"$TMP/ws/m944leaf\" }" +app "$TMP/c" "{ path = \"$TMP_HOST/ws/m944leaf\" }" if (cd "$TMP/c" && "$MCPP" build > build.log 2>&1); then fail "C: a requirement the checkout does not satisfy was accepted" "$TMP/c/build.log" fi From 322d3e1a3c270b62812bcc387e29894d829d9ed9 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 02:14:18 +0900 Subject: [PATCH 23/43] docs 05/07: a root's checkout answers a version no index carries; workspace = true inherits in every table Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/05-dependencies.md | 2 +- docs/07-workspace.md | 6 ++++++ docs/zh/05-dependencies.md | 2 +- docs/zh/07-workspace.md | 4 ++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 06ab97cd..eaf4fa24 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -170,7 +170,7 @@ requester of the same identity gets. | `version` | `version`, a different constraint | The two constraints are AND-combined by SemVer, as above; an unsatisfiable pair is refused, naming both constraints and both requesters. | | `git` | `git`, a different `rev`/`tag`/`branch` | The root's declaration wins when the root is one of the two requesters; otherwise the declaration resolved first wins. A `dependency/source-override` warning names both requesters and both references, states which one is used and why, and how to take the other. This is never silent. | | `path` | `path`, a different directory | The same rule and the same warning as the row above, compared by canonical absolute directory rather than by git reference. | -| a root `path` or `git` declaration | a dependency's `git` or `version` declaration (a kind clash) | The root's declaration wins, with the same warning. When the losing declaration is a `version` requirement, it is checked against the `[package] version` of the root's resolved checkout; a violated requirement is refused, naming the pin, the requester and the requirement. | +| a root `path` or `git` declaration | a dependency's `git` or `version` declaration (a kind clash) | The root's declaration wins, with the same warning. When the losing declaration is a `version` requirement, it is checked against the `[package] version` of the root's resolved checkout; a violated requirement is refused, naming the pin, the requester and the requirement. The index is not asked to select or pin a request the root has answered, so the root's checkout serves a version no index carries yet (2026.10.26.2+). | | a dependency's `path`/`git` declaration | another dependency's declaration of a different kind (a kind clash, and neither party is the root) | Refused: "requested as both a … dep … and a … dep …. Pick one." The message adds one sentence: declare the identity in the root to settle it. | The root's privilege here is bounded the same way `linkage` is bounded to the diff --git a/docs/07-workspace.md b/docs/07-workspace.md index 828d1889..43afd044 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -144,6 +144,12 @@ hl = { path = "libs/hl", features = ["x"] } hl = { workspace = true, features = ["y"] } # requests x and y ``` +The entry inherits wherever the member writes it: `[dependencies]`, +`[dev-dependencies]`, `[build-dependencies]`, `[feature-deps.]`, and +the same tables under a target selector (`[target.'cfg(os = "linux")'.dependencies]`). +Before 2026.10.26.2 an entry under a target selector or behind a feature kept +no source and was resolved from the index. + A member can override an inherited version: ```toml diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index c95efbbe..9c4225b5 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -155,7 +155,7 @@ sdk = { version = "1.0", visibility = "private" } | `version` | `version`,不同的约束 | 按上文所述,用 SemVer 对两个约束做 AND 合并;无法满足的一对被拒绝,并点名两个约束与两个请求方。 | | `git` | `git`,不同的 `rev`/`tag`/`branch` | 根是两个请求方之一时,根的声明胜出;否则先解析出来的声明胜出。`dependency/source-override` 警告点名两个请求方与两个引用,说明哪一个被采用、原因是什么,以及如何取用另一个。这个警告绝不会被吞掉。 | | `path` | `path`,不同的目录 | 规则与警告同上一行,只是比较的是规范化后的绝对目录,而不是 git 引用。 | -| 根的 `path` 或 `git` 声明 | 某个依赖的 `git` 或 `version` 声明(种类冲突) | 根的声明胜出,警告同上。若败下阵的声明是一条 `version` 需求,它会被拿去与根已解析出的那份 checkout 的 `[package] version` 核对;需求被违反就拒绝,并点名那个 pin、请求方与需求本身。 | +| 根的 `path` 或 `git` 声明 | 某个依赖的 `git` 或 `version` 声明(种类冲突) | 根的声明胜出,警告同上。若败下阵的声明是一条 `version` 需求,它会被拿去与根已解析出的那份 checkout 的 `[package] version` 核对;需求被违反就拒绝,并点名那个 pin、请求方与需求本身。根已回答的请求不再交给索引选择与固定,因此根的 checkout 也能满足索引尚未收录的版本(2026.10.26.2+)。 | | 某个依赖的 `path`/`git` 声明 | 另一个依赖的不同种类的声明(种类冲突,且双方都不是根) | 拒绝:"requested as both a … dep … and a … dep …. Pick one."。消息多出一句:在根里声明该身份即可解决。 | 这里根所拥有的特权,与 `linkage` 只在根 manifest 自己的边上生效(见上文 diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index 8d3d401e..81fdafeb 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -144,6 +144,10 @@ hl = { path = "libs/hl", features = ["x"] } hl = { workspace = true, features = ["y"] } # 请求 x 与 y ``` +条目在成员写下它的任何表里继承:`[dependencies]`、`[dev-dependencies]`、`[build-dependencies]`、 +`[feature-deps.]`,以及目标选择器下的同名表(`[target.'cfg(os = "linux")'.dependencies]`)。 +2026.10.26.2 之前,写在目标选择器下或特性之后的条目不带来源,并从索引解析。 + 成员可以覆盖继承来的版本: ```toml From 4ff8af45bb2921098c3ad3fba4f23cfe72f9666f Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 04:32:10 +0900 Subject: [PATCH 24/43] the lists of directories mcpp composes are mcpplibs.os's (split_list, join_list); e2e 942 takes openkal-tools 0.1.0 Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/platform/mcpp.toml | 4 +- modules/platform/src/env.cppm | 55 ------------------- modules/platform/src/env_lists.cpp | 51 +++++++++++++++++ ...ok_image_is_built_once_and_run_by_okrun.sh | 2 +- 4 files changed, 54 insertions(+), 58 deletions(-) create mode 100644 modules/platform/src/env_lists.cpp diff --git a/modules/platform/mcpp.toml b/modules/platform/mcpp.toml index 40ed2948..6b8e6224 100644 --- a/modules/platform/mcpp.toml +++ b/modules/platform/mcpp.toml @@ -36,12 +36,12 @@ gtest = "1.15.2" # part 2, §10.1). Referenced by its pull request's branch until it is # published; mcpp.lock then names the version. [dependencies.mcpplibs] -os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.1.0" } +os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.1.0-paths" } # Every interface is compiled everywhere; implementation units are selected # per system, so no source in this package carries a preprocessor conditional. [build] -sources = ["src/*.cppm", "src/*/*.cppm"] +sources = ["src/*.cppm", "src/*/*.cppm", "src/*.cpp"] [target.linux.build] sources = ["src/linux/*.cpp", "src/posix/*.cpp"] diff --git a/modules/platform/src/env.cppm b/modules/platform/src/env.cppm index 42dc23a5..6a2dcfc6 100644 --- a/modules/platform/src/env.cppm +++ b/modules/platform/src/env.cppm @@ -287,10 +287,6 @@ ScopedEnv::~ScopedEnv() { } } -std::string path_list_separator() { - return is_windows ? ";" : ":"; -} - std::string runtime_library_path_key() { if constexpr (is_windows) return "PATH"; // DYLD_LIBRARY_PATH affects every executable launched by ninja, including @@ -307,57 +303,6 @@ std::string host_tool_runtime_library_path_key() { else return ""; } -std::string strip_private_glibc(std::string_view paths) { - auto sep = path_list_separator(); - std::string cleaned; - std::size_t start = 0; - while (start <= paths.size()) { - auto end = paths.find(sep, start); - if (end == std::string_view::npos) end = paths.size(); - auto item = paths.substr(start, end - start); - if (!item.empty() && item.find("/xim-x-glibc/") == std::string_view::npos - && item.find("\\xim-x-glibc\\") == std::string_view::npos) { - if (!cleaned.empty()) cleaned += sep; - cleaned += item; - } - if (end == paths.size()) break; - start = end + sep.size(); - } - return cleaned; -} - -std::string prepend_path_list(std::string_view key, - std::span dirs) { - if (key.empty() || dirs.empty()) return ""; - - auto sep = path_list_separator(); - std::string value; - for (auto& dir : dirs) { - if (dir.empty()) continue; - if (!value.empty()) value += sep; - value += dir.string(); - } - if (value.empty()) return ""; - - std::string k(key); - if (auto* existing = std::getenv(k.c_str()); existing && *existing) { - // Loader paths only: the inherited value may carry an OUTER mcpp's - // private-glibc entry, and the composed string becomes an EXPLICIT - // override — which process.cppm's merged_environ takes verbatim, - // skipping the sanitation it applies to inherited variables. Without - // this the strip is defeated exactly when it matters (a nested - // `mcpp run` → `mcpp test` chain), and the child tool segfaults in the - // dynamic linker whenever the payload versions differ. - std::string tail = (k == "LD_LIBRARY_PATH" || k == "DYLD_LIBRARY_PATH") - ? strip_private_glibc(existing) : std::string(existing); - if (!tail.empty()) { - value += sep; - value += tail; - } - } - return value; -} - std::string build_env_prefix( const std::vector>& vars) { diff --git a/modules/platform/src/env_lists.cpp b/modules/platform/src/env_lists.cpp new file mode 100644 index 00000000..49ffae2b --- /dev/null +++ b/modules/platform/src/env_lists.cpp @@ -0,0 +1,51 @@ +// The lists of directories mcpp composes for a child's environment, over mcpplibs.os's +// conventions (the host's separator, an entry quoted whole on Windows). An implementation unit, so +// the interface of mcpp.platform.env imports nothing beyond what it declares. +module mcpp.platform.env; + +import std; +import mcpplibs.os; + +namespace mcpp::platform::env { + +std::string path_list_separator() { + return std::string(1, mcpplibs::os::conventions().path_list_separator); +} + +std::string strip_private_glibc(std::string_view paths) { + std::vector kept; + for (auto& item : mcpplibs::os::env::split_list(paths)) { + const auto text = item.generic_string(); + if (text.find("/xim-x-glibc/") == std::string::npos) kept.push_back(std::move(item)); + } + return mcpplibs::os::env::join_list(kept); +} + +std::string prepend_path_list(std::string_view key, + std::span dirs) { + if (key.empty() || dirs.empty()) return ""; + std::vector given; + for (auto& dir : dirs) if (!dir.empty()) given.push_back(dir); + std::string value = mcpplibs::os::env::join_list(given); + if (value.empty()) return ""; + + std::string k(key); + if (auto* existing = std::getenv(k.c_str()); existing && *existing) { + // Loader paths only: the inherited value may carry an OUTER mcpp's + // private-glibc entry, and the composed string becomes an EXPLICIT + // override — which process.cppm's merged_environ takes verbatim, + // skipping the sanitation it applies to inherited variables. Without + // this the strip is defeated exactly when it matters (a nested + // `mcpp run` → `mcpp test` chain), and the child tool segfaults in the + // dynamic linker whenever the payload versions differ. + std::string tail = (k == "LD_LIBRARY_PATH" || k == "DYLD_LIBRARY_PATH") + ? strip_private_glibc(existing) : std::string(existing); + if (!tail.empty()) { + value += path_list_separator(); + value += tail; + } + } + return value; +} + +} // namespace mcpp::platform::env diff --git a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh index 0dee9d27..c6d64f5e 100755 --- a/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh +++ b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh @@ -41,7 +41,7 @@ case "$(uname -m)" in *) echo "SKIP: no ok-image row for $(uname -m)"; exit 0 ;; esac -TOOLS='openkal-tools = { git = "https://github.com/mcpplibs/openkal-image.git", branch = "openkal-0.17.0", tools = ["okld", "okrun"] }' +TOOLS='openkal-tools = { version = "0.1.0", tools = ["okld", "okrun"] }' mkdir -p "$TMP/app/src" cd "$TMP/app" From 6b72cc88200ccf21b4c7101d1379bcc6fc2490ca Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 05:25:22 +0900 Subject: [PATCH 25/43] modules/platform depends on mcpplibs os 0.1.0 from the index Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/platform/mcpp.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/platform/mcpp.toml b/modules/platform/mcpp.toml index 6b8e6224..3ecf3ad0 100644 --- a/modules/platform/mcpp.toml +++ b/modules/platform/mcpp.toml @@ -33,10 +33,9 @@ license = "Apache-2.0" gtest = "1.15.2" # The operating-system layer is mcpplibs.os over openkal (design 2026-10-26 -# part 2, §10.1). Referenced by its pull request's branch until it is -# published; mcpp.lock then names the version. +# part 2, §10.1). [dependencies.mcpplibs] -os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.1.0-paths" } +os = "0.1.0" # Every interface is compiled everywhere; implementation units are selected # per system, so no source in this package carries a preprocessor conditional. From 851315dadb29facd011158de636d21d8377a6d99 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 05:36:42 +0900 Subject: [PATCH 26/43] CHANGELOG: lists of directories in the environment are mcpplibs.os's Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f95bde79..33604b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,7 +95,10 @@ selected by the package manifest. `modules/platform` contains no preprocessor conditional. `which` no longer starts a shell: a program is found on PATH, with PATHEXT's suffixes on Windows, and the current - directory is not searched on Windows. + directory is not searched on Windows. A list of directories in an + environment variable (`PATH`, the run-time library path) is read and + written with `mcpplibs.os`'s `split_list` and `join_list`: an empty entry + is skipped, and a quoted entry on Windows is unquoted. - **A closed reader ends mcpp in order.** `mcpp … | head` stops the guarded child process groups, writes the interrupt files and restores the terminal, and then ends by SIGPIPE, with the status a shell has always seen (141). From a76303171f5370f5764c134d90c7c0ed5892c09e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 05:52:19 +0900 Subject: [PATCH 27/43] a request by version the local index cannot answer refreshes the index wherever it is in the graph; e2e 945 Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 9 ++ src/build/prepare/graph_load.cpp | 49 ++++++++-- ...nywhere_in_the_graph_asks_for_a_refresh.sh | 90 +++++++++++++++++++ 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100755 tests/e2e/945_a_miss_anywhere_in_the_graph_asks_for_a_refresh.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 33604b8f..7816a56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,15 @@ pinning a checkout ahead of a release) refused the build with "no package found for exact selector". The root's checkout now answers it; a requirement the checkout does not satisfy is still refused. +- **A request by version the local index cannot answer refreshes the index + wherever it is in the graph.** The refresh before the dependency walk asked + only about the root's and the selected members' `[dependencies]`, so a + version named by a path dependency, by a package's own manifest or in a + conditional table, and published after the local index was fetched, refused + the build ("no package found for exact selector") with no refresh at all. + The miss now asks the refresh policy (offline, `[index] auto_refresh`, the + debounce, one sync per run) and the selection is made again; a namespace the + index cannot refute still never refreshes. - **A dependency written `workspace = true` under a target selector or behind a feature receives the workspace's source.** Only `[dependencies]`, `[dev-dependencies]` and `[build-dependencies]` were merged, so a git or path diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp index b4e004c1..ef896495 100644 --- a/src/build/prepare/graph_load.cpp +++ b/src/build/prepare/graph_load.cpp @@ -463,7 +463,43 @@ static void step4a_define_candidate_selection_closures(PrepareState& state) { auto selected = candidates.front(); bool matched = false; - if (spec.isVersion()) { + bool anyLazyGitIndex = false; + // A MISS REFRESHES THE INDEX, WHEREVER THE EDGE IS (#315: a build + // refreshes on a miss, not on age). The refresh before the walk asks + // only about the root's and the selected members' [dependencies], so + // a request by version from a path dependency, a package's own + // dependency, or a conditional table missed against an index older + // than the package and refused the build with no refresh at all + // (measured: mcpp's modules/platform naming `os = "0.1.0"` minutes + // after the index published it, in a CI sandbox whose index was + // "never refreshed"). The miss itself is now the trigger: the policy + // (offline, `auto_refresh`, the debounce, one sync per run) decides, + // and the selection is made again over the refreshed index. + for (int attempt = 0; spec.isVersion() && !matched && attempt < 2; ++attempt) { + if (attempt == 1) { + auto cfgR = state.get_cfg(true); + if (!cfgR) break; + // Only a miss the package index can refute (the namespaces + // whose identity mcpp defines), in the index a refresh + // updates, can be refreshed into an answer: a project's path + // index is read as it is, and a foreign namespace never + // refutes (#315 INV-3). + const auto route = state.index_route(*cfgR); + if (std::ranges::none_of(candidates, [&](const mcpp::pm::DependencyCoordinate& c) { + const auto* idx = route.find_for_ns(c.namespace_); + return route.authoritative_for(c.namespace_) && (!idx || idx->is_builtin()); + })) + break; + const auto env = mcpp::config::make_xlings_env(**cfgR); + auto decision = mcpp::pm::decide_for_miss( + mcpp::pm::policy_for(**cfgR), env, + mcpp::pm::format_package_selector(candidates.front())); + if (auto r = mcpp::pm::apply(decision, env); !r) { // says a declined one under -v + mcpp::ui::warning(r.error()); + break; + } + if (!decision.shouldRefresh) break; + } for (auto& candidate : candidates) { auto lua = state.readStrictLuaForCandidate(candidate); if (!lua) continue; @@ -584,14 +620,17 @@ static void step4a_define_candidate_selection_closures(PrepareState& state) { // package that would have materialized a moment later. Local path // indices and the builtin index are both readable here, so they stay // under the strict rule below. - bool anyLazyGitIndex = std::ranges::any_of(candidates, + anyLazyGitIndex = std::ranges::any_of(candidates, [&](const mcpp::pm::DependencyCoordinate& c) { return state.index_route(nullptr).lazy_git(c.namespace_); }); + if (anyLazyGitIndex) break; // a miss there is no evidence; nothing to refresh for + } - // An exact coordinate that a readable index cannot serve fails at - // resolution. Never carry it into install-time compatibility - // retries, which would reintroduce cross-namespace guessing. + // An exact coordinate that a readable index cannot serve fails at + // resolution. Never carry it into install-time compatibility + // retries, which would reintroduce cross-namespace guessing. + if (spec.isVersion()) { if (!matched && !anyLazyGitIndex) { std::string tried; for (auto& c : candidates) { diff --git a/tests/e2e/945_a_miss_anywhere_in_the_graph_asks_for_a_refresh.sh b/tests/e2e/945_a_miss_anywhere_in_the_graph_asks_for_a_refresh.sh new file mode 100755 index 00000000..0b5d11dd --- /dev/null +++ b/tests/e2e/945_a_miss_anywhere_in_the_graph_asks_for_a_refresh.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# requires: gcc fresh-sandbox +# 945 -- a request by version that the local index cannot answer asks for a +# refresh wherever it is in the graph, not only in the root's [dependencies]. +# +# The refresh before the dependency walk (#315) asked about the root's and the +# selected members' [dependencies]; a package reached by `path` that named a +# version the index did not yet carry refused the build with no refresh at all. +# Measured in mcpp's own CI: modules/platform named `os = "0.1.0"` minutes +# after the index published it, and a sandbox index "never refreshed" answered +# +# error: dependency 'mcpplibs.os': no package found for exact selector +# +# Hermetic, as 173 is: the builtin index is fabricated, and every case is +# offline or debounced, so the decision is observed without a sync. +# +# Criteria: +# A. Offline, the miss in a path dependency reaches the refresh policy, which +# declines for offline mode and says so for that package. +# B. Inside the debounce window, the same miss is declined as "just +# refreshed": the policy, not the position of the edge, decides. +# C. The refusal that follows still names the selector and the index. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +INDEX="$MCPP_HOME/indices/mcpplibs" +mkdir -p "$INDEX/pkgs/w" +echo "abc1234" > "$INDEX/.xlings-index-version" +: > "$INDEX/.mcpp-index-updated" +touch -d '30 days ago' "$INDEX/.mcpp-index-updated" 2>/dev/null \ + || touch -A -300000 "$INDEX/.mcpp-index-updated" 2>/dev/null || true + +# mid: reached by path; it names a package the local index does not carry. +mkdir -p "$TMP/mid/src" +cat > "$TMP/mid/mcpp.toml" <<'TOML' +[package] +name = "mid" +version = "0.1.0" + +[targets.mid] +kind = "lib" + +[dependencies] +absentpkg = "1.0.0" +TOML +printf 'export module mid;\nexport int mid_value() { return 1; }\n' > "$TMP/mid/src/mid.cppm" + +mkdir -p "$TMP/app/src" +cd "$TMP/app" +cat > mcpp.toml <<'TOML' +[package] +name = "app" +version = "0.1.0" + +[targets.app] +kind = "bin" +main = "src/main.cpp" + +[dependencies] +mid = { path = "../mid" } +TOML +echo 'int main() { return 0; }' > src/main.cpp + +# A +MCPP_VERBOSE=1 MCPP_OFFLINE=1 "$MCPP" build > a.log 2>&1 && fail "A: the build resolved a package no index carries" a.log +grep -qE "skip refresh for (mcpplibs[.:])?absentpkg: offline mode" a.log \ + || fail "A: the miss in a path dependency did not reach the refresh policy" a.log +echo "ok: a miss in a path dependency asks for a refresh (declined offline)" + +# B +: > "$INDEX/.mcpp-index-updated" +MCPP_VERBOSE=1 "$MCPP" build > b.log 2>&1 && fail "B: the build resolved a package no index carries" b.log +grep -qE "skip refresh for (mcpplibs[.:])?absentpkg: index was just refreshed" b.log \ + || fail "B: the debounce did not decide the miss in a path dependency" b.log +grep -qE "Refreshing +package index" b.log && fail "B: a sync inside the debounce window" b.log +echo "ok: inside the debounce window the same miss is declined" + +# C +grep -q "dependency 'absentpkg': no package found for exact selector" b.log \ + || fail "C: the refusal does not name the selector" b.log +grep -q "index: local index abc1234" b.log || fail "C: the refusal does not name the index" b.log +echo "ok: the refusal names the selector and the index" + +echo "PASS: 945 a miss anywhere in the graph asks for a refresh" From f5f68538d14194ed1a7c1ff92e493aa8a4b79fc9 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 05:52:54 +0900 Subject: [PATCH 28/43] CI: the bootstrap mcpp builds against the package index as published Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/tools/install_pinned_mcpp.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/tools/install_pinned_mcpp.sh b/.github/tools/install_pinned_mcpp.sh index 017f74e1..93b0509d 100755 --- a/.github/tools/install_pinned_mcpp.sh +++ b/.github/tools/install_pinned_mcpp.sh @@ -95,4 +95,11 @@ GOT=$("$MCPP" --version 2>/dev/null | head -1 \ exit 1; } echo "bootstrap mcpp: $MCPP ($GOT)" >&2 +# The package index the commit builds against is the one published now. The +# sandbox the caller restored holds the index as it was when that cache was +# saved, and the pinned mcpp refreshes on a miss only for the root's own +# [dependencies] (fixed in 2026.10.26.2): a version a path member names -- +# modules/platform's `os = "0.1.0"` -- missed against it with no refresh. +"$MCPP" index update >&2 || echo "::warning::the bootstrap mcpp could not refresh its package index" >&2 + echo "$MCPP" From 775b21ab940324ea8d740c73f493ce03a6df3e3c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 06:16:37 +0900 Subject: [PATCH 29/43] unit: the lists mcpp composes are in the host's spelling; the hermetic e2e bootstrap builds against the index as published Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/ci-linux-e2e.yml | 5 +++++ tests/unit/test_platform_env.cpp | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 93e1a342..6ce1284c 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -421,6 +421,11 @@ jobs: MCPP_BOOT="$HOME/.xlings/subos/current/bin/mcpp" "$MCPP_BOOT" --version "$MCPP_BOOT" self config --mirror GLOBAL + # The restored ~/.mcpp holds the package index as it was when that + # cache was saved; the commit builds against the index as published + # (.github/tools/install_pinned_mcpp.sh says why a miss alone did not + # refresh it before 2026.10.26.2). + "$MCPP_BOOT" index update echo "MCPP_BOOT=$MCPP_BOOT" >> "$GITHUB_ENV" # This job builds its own mcpp on purpose: the build is part of what it diff --git a/tests/unit/test_platform_env.cpp b/tests/unit/test_platform_env.cpp index 572316e9..fe6adf7f 100644 --- a/tests/unit/test_platform_env.cpp +++ b/tests/unit/test_platform_env.cpp @@ -39,13 +39,20 @@ std::string join(std::initializer_list parts) { return out; } +// An entry the composition writes is in the host's spelling (mcpplibs.os +// join_list): `\` between components on Windows. An inherited value it leaves +// alone keeps the spelling it had. +std::string host(std::string_view p) { + return std::filesystem::path(p).make_preferred().string(); +} + } // namespace TEST(PlatformEnv, StripPrivateGlibcDropsOnlyPayloadEntries) { auto in = join({"/usr/lib", "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64", "/opt/mine"}); EXPECT_EQ(mcpp::platform::env::strip_private_glibc(in), - join({"/usr/lib", "/opt/mine"})); + join({host("/usr/lib"), host("/opt/mine")})); } TEST(PlatformEnv, StripPrivateGlibcCanEmptyTheList) { @@ -69,7 +76,7 @@ TEST(PlatformEnv, PrependPathListSanitizesTheInheritedLoaderTail) { std::vector dirs{"/payload/lib"}; EXPECT_EQ(mcpp::platform::env::prepend_path_list("LD_LIBRARY_PATH", dirs), - join({"/payload/lib", "/first", "/last"})); + join({host("/payload/lib"), host("/first"), host("/last")})); } // A payload dir the CALLER passed is the entry the sandbox binary needs — it @@ -81,7 +88,7 @@ TEST(PlatformEnv, PrependPathListKeepsExplicitPayloadDirs) { "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64"}; // Explicit dir kept, inherited (older, mismatched) entry dropped. EXPECT_EQ(mcpp::platform::env::prepend_path_list("LD_LIBRARY_PATH", dirs), - "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64"); + host("/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64")); } // PATH is not a loader search path: leave its inherited value alone. @@ -91,12 +98,12 @@ TEST(PlatformEnv, PrependPathListLeavesPathUntouched) { std::vector dirs{"/tools/bin"}; EXPECT_EQ(mcpp::platform::env::prepend_path_list("PATH", dirs), - join({"/tools/bin", "/bin", "/x/xpkgs/xim-x-glibc/2.39/lib64"})); + join({host("/tools/bin"), "/bin", "/x/xpkgs/xim-x-glibc/2.39/lib64"})); } TEST(PlatformEnv, PrependPathListWithNoInheritedValueIsJustTheDirs) { ScopedVar ld("LD_LIBRARY_PATH", nullptr); std::vector dirs{"/a", "/b"}; EXPECT_EQ(mcpp::platform::env::prepend_path_list("LD_LIBRARY_PATH", dirs), - join({"/a", "/b"})); + join({host("/a"), host("/b")})); } From 673d0fdb4f5920eeb73edda1df72a43059eb27a8 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 07:36:30 +0900 Subject: [PATCH 30/43] a program's beginning can be a dependency's entry target, and a freestanding image states the symbol it is entered at (#845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind = "entry"; main = { from = "/" } on programs, rows and [test]; start on programs, entries, rows and [test]; mcpp:link-start (build-program protocol 16). The entry compiles in its package's context and links only into the programs that select it; required_features gate it and windows_entry follows it. start is accepted on freestanding targets only, renders -e and a --defsym check, and disagreeing statements are refused. A file a target names as main on any row is no longer an ordinary source. The compiled build-program interface is kept by version and text digest. Design 2026-10-26 §24, SPEC-004 §14, docs 04/08/30/40/50, e2e 946 and 947. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...ntract-runtime-and-ide-ecosystem-design.md | 125 +++++++ CHANGELOG.md | 21 ++ docs/04-mcpp-toml.md | 78 +++- docs/08-testing.md | 18 + docs/30-build-mcpp.md | 2 + docs/40-baremetal.md | 6 +- docs/50-machine-output.md | 7 +- docs/specs/manifest-semantics.md | 39 +- docs/zh/04-mcpp-toml.md | 65 +++- docs/zh/08-testing.md | 17 + docs/zh/30-build-mcpp.md | 2 + docs/zh/40-baremetal.md | 5 +- docs/zh/50-machine-output.md | 6 +- modules/buildmcpp/src/directives.cppm | 33 +- modules/buildmcpp/src/program_protocol.cppm | 5 +- modules/manifest/src/toml.cppm | 241 ++++++++++-- modules/manifest/src/types.cppm | 80 +++- src/build/engine_package.cppm | 26 +- src/build/execute.cppm | 15 +- src/build/hostprogram.cppm | 5 + src/build/ninja_backend.cppm | 71 ++++ src/build/plan.cppm | 350 +++++++++++++++++- src/build/prepare/config.cpp | 17 + src/build/prepare/features.cpp | 8 + src/build/prepare/graph.cpp | 6 +- src/build/prepare/target_side.cpp | 9 + src/build/result.cppm | 27 +- src/build/test_targets.cppm | 9 + src/pack/route.cppm | 6 + ...ngines_module_is_compiled_once_per_home.sh | 2 +- ..._a_program_begins_in_a_dependency_entry.sh | 204 ++++++++++ ...n_image_is_entered_at_one_stated_symbol.sh | 149 ++++++++ tests/unit/test_build_directives.cpp | 25 +- tests/unit/test_manifest.cpp | 160 +++++++- tests/unit/test_ninja_backend.cpp | 24 ++ 35 files changed, 1791 insertions(+), 72 deletions(-) create mode 100755 tests/e2e/946_a_program_begins_in_a_dependency_entry.sh create mode 100755 tests/e2e/947_an_image_is_entered_at_one_stated_symbol.sh diff --git a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md index c67618a2..b1433d5c 100644 --- a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md +++ b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md @@ -74,6 +74,8 @@ mcpp-index(mcpplibs)本机快照。 旧引擎用户因此不受影响;整个插件包的引擎下限不因一个规则提高。 - **计划等价**(§21.1 第 10 条):不新增计划格式后,等价性即既有快速路径的正确性,由 #544、#716、#755 与 riscv 的 e2e 覆盖,不另设语料测试。 +- rev 5(2026-10-27,[mcpp#845](https://github.com/mcpp-community/mcpp/issues/845)):增加 §24"程序起点与镜像入口"与 D24, + 在本批 mcpp PR(#842)中实现。原议的 `entry = "<依赖>"` 加 feature 声明的写法不采纳(§24.6)。 关联:**mcpp#838**(sycl lane 下 `std::exception` 匹配不到)、**mcpp#839**(同配置并发构建在同一构建目录竞争、双方失败)、 mcpp#596(unwinder 被拆成两半)、mcpp#514(两份 libc++ 头文件)、mcpp#544 / #716 / #755(快速路径的拒绝清单); @@ -125,6 +127,7 @@ mcpplibs/mcpp-index、Sunrisepeak/mcpp-language-server,以及新建的 mcpp-co | D21 | **libstdc++ 提供包名 `gcc.libstdcxx`**,版本"gcc 版本.修订号",内容借用 `xim:gcc` 载荷(§12.1) | | D22 | **链接产物原子发布**:POSIX 先写 `.tmp` 再改名;PE 先把旧产物改名挪开(`.old-`)再原地链接,正在运行的程序保留其文件。执行段因此无需持锁(§4.1、§4.3、E23) | | D23 | **退出码**:副作用被拒绝为 `5`;被信号取消为 `128 + 信号`(130、143);写入 SPEC-003(§5.4、§5.5) | +| D24 | **入口分两层**:程序起点(L1,`main` 指向的翻译单元及 PE 上的入口函数)属于程序,可由依赖的 `kind = "entry"` 目标提供(`main = { from = "<依赖>/<入口>" }`);镜像入口(L0,`start` 符号)属于面向启动者的一层,只在没有 C 运行时的镜像上可写,可由目标、入口目标或板级包陈述,不一致即拒绝(§24) | ## 1. 问题与证据 @@ -869,6 +872,128 @@ mcpp PR 合并前按惯例对 mcpp-plugins 跑 `ci.yml -f mcpp_source_ref=]` 及行表 `[target..targets.]`),不进入 `[build]`: +`[build]` 是对每个目标生效、按规则合并的包级配置,入口是单个程序的身份,库没有入口。 + +| 键 | 位置 | 含义 | +|---|---|---| +| `main = "<文件>"` | 程序目标、`kind = "entry"` 目标、行表 | 程序代码开始的翻译单元。有 C 运行时的镜像中它定义 `main`;没有 C 运行时的镜像中它定义 `start` 所指的符号 | +| `main = { from = "<依赖>/<入口>" }` | 程序目标、行表、`[test]` | 程序起点由所列依赖的一个 `kind = "entry"` 目标提供。`<依赖>` 是本清单中依赖的键(`openmui`、`compat.gtest`),或本包自身的名字 | +| `kind = "entry"` | 提供方 | 只作为其他程序起点的翻译单元。可陈述 `main`(路径)、`start`、`windows_entry`、`cflags`、`cxxflags`、`defines`、`required_features` | +| `start = "<符号>"` | 程序目标、入口目标、行表、`[test]` | L0 符号 | +| `mcpp:link-start=<符号>` | 构建程序指令(协议 16),`LinkGlobal` | 板级包陈述的 L0 符号 | + +```toml +# 提供方 +[targets.player] +kind = "entry" +main = "src/entry/player.cpp" + +[target.'cfg(os = "android")'.targets.player] +main = "src/entry/player_android.cpp" + +# 使用方 +[dependencies] +openmui = "0.1" + +[targets.notes] +kind = "app" +main = { from = "openmui/player" } + +# 测试 +[test] +main = { from = "compat.gtest/main" } + +# 无 C 库的内核 +[targets.kernel] +kind = "bin" +main = "src/start.S" +start = "_start" +``` + +### 24.4 语义 + +1. **入口翻译单元在提供方的上下文中编译,链入使用方的程序。** 编译使用提供方的头文件目录、模块接口、标志与 + 入口目标自身的 `cflags`、`cxxflags`、`defines`;每个配置编译一次。它不进入提供方的任何产物:入口文件与其他目标的 + `main` 一样从每个映像的对象集合中排除,只链入选择它的程序。 +2. **契约只经符号与运行时数据。** 入口翻译单元不导入使用方的模块。供使用方实现的回调须在提供方接口中以 + `extern "C++"` 或 `extern "C"` 声明:附着于命名模块的声明不能在模块之外定义。 +3. **按目标选择,不参与特性合一。** 特性在依赖图上取并集,入口在每个目标上互斥;以特性承载入口时,两个使用方 + 开启不同的入口特性会使"恰好一个"的规则对其中一方失败。入口目标的 `required_features` 是选择它的前提:未开启时 + 拒绝,并给出开启所需特性的依赖写法。 +4. **使用方的入口标志不再适用。** `main = { from }` 与目标的 `cflags`、`cxxflags`、`defines` 同时出现时拒绝: + 这些标志的作用域是目标自身的入口翻译单元,而该单元已属于提供方。 +5. **`windows_entry` 随入口继承。** 使用方未陈述时取入口目标的值;两者不同时拒绝并指出两处陈述。 + `windows_subsystem` 由使用方陈述。 +6. **测试。** `[test] main = { from }` 为每个发现的测试程序提供起点,测试文件作为该程序的普通翻译单元链入; + `[test] start` 为每个测试镜像陈述 L0。开发依赖中定义 `main` 的对象在测试自身定义 `main` 时不链入的既有规则保留。 +7. **`start` 的合法范围。** 只在独立(freestanding)目标上可写;其他行上拒绝,并说明该行 L0 的归属 + (C 运行时、okrun 的 `ok_main`、Android 运行时)。 +8. **L0 的唯一性。** 目标(或 `[test]`)、所选入口目标与依赖闭包中的 `mcpp:link-start` 可能各自陈述 `start`; + 陈述一致时取该值,不一致时拒绝并列出各陈述的来源。没有任何陈述时,链接脚本的 `ENTRY()` 或链接器默认值生效。 +9. **渲染与校验。** ELF 链接渲染为 `-e <符号>` 与 `--require-defined=<符号>`(经驱动时加 `-Wl,`):后者使未定义的 + 入口符号成为链接错误,而非警告。`start` 优先于链接脚本中的 `ENTRY()`,与命令行优先于脚本的链接器规则一致。 +10. **诊断。** 解析错误指出依赖键或入口名并列出候选;链接因入口符号未定义失败时,附注指出该符号的陈述来源。 + 独立目标构建结束时在 `Size` 行之后给出 `Entry` 行(目标、符号、来源)。结果记录(docs/50)的产物条目增加 + `entry`:`from`(入口的来源)、`start` 与 `start_from`。 + +### 24.5 兼容 + +不使用新键的工程行为不变;`main = "src/start.S"` 的含义不变。`kind = "entry"`、`main` 的表形式、`start` 与 +`[test] main` 为新语法,旧引擎按未知值或未知键拒绝;使用它们的包声明引擎下限 2026.10.26.2。`mcpp:link-start` +属于构建程序协议 16,旧引擎按未知指令处理。 + +### 24.6 不采纳的方案 + +| 方案 | 不采纳的理由 | +|---|---| +| `entry = "<依赖>"` 加 `[features.] entry = "<文件>"`(#845 原议) | 特性在全图合一,入口按目标互斥(§24.4 第 3 条);一件事分写两处并依赖"恰好一个已开启特性"的间接规则;同一包的多个目标无法各选入口 | +| 库导出 `main`(`gtest_main` 的做法) | Android 与 PE 的入口形式不同;依赖链接顺序与归档成员抽取;无法给出来源诊断 | +| 由规则包生成入口源文件 | 入口是普通翻译单元,不应依赖构建插件;需要使用方编译期信息的是代码生成问题 | +| 入口写为函数名,由 mcpp 生成 `main` 调用它 | 需要定义入口 ABI(签名、导入、`wmain`/`WinMain` 到 UTF-8 参数的转换、Android 无参数),mcpp 成为代码生成者并承担各平台入口适配 | +| 入口在使用方上下文中编译 | 无法缓存;提供方代码受使用方标志影响,契约不透明 | +| 跨平台的 `entry_function` 字段 | L1 的取值只在 PE 上存在,其余行上只能是 `main` 或被忽略 | + +### 24.7 验收 + +e2e:依赖提供的程序起点(含回调由使用方实现、`windows_entry` 继承与冲突、`required_features` 未开启的拒绝、 +未知依赖与非入口目标的拒绝、使用方入口标志的拒绝);`[test] main = { from }`;本包自身作为提供方;行表替换入口文件; +独立目标上 `start` 的渲染、`--require-defined` 使缺失符号成为错误、三方陈述冲突的拒绝、hosted 行上的拒绝; +`mcpp:link-start` 指令。单元测试:清单解析的接受与拒绝、ELF 渲染。 + ## 附录 A:讨论收敛记录(摘要) | 问题 | 结论 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7816a56e..4ea9759e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,22 @@ build resolves: after workspace inheritance and in the target's `[target.'cfg(os = "openkal")'.build-dependencies]`. +- **A program's beginning can be a dependency's entry target, and a + freestanding image states the symbol it is entered at** (#845). A framework + declares `[targets.] kind = "entry"` with the translation unit that + supplies `main`, and a program selects it with + `main = { from = "/" }` in place of a file of its own; a + test suite does the same with `[test] main`. The entry is compiled in its + package's context with its own flags, linked only into the programs that + select it, gated by its `required_features`, and its `windows_entry` is + inherited. `start = ""` on a program, an entry, a row or `[test]`, + and `mcpp:link-start` from a board package's build program (protocol 16), + name the ELF entry of a freestanding image; statements that disagree are + refused naming each, the symbol must be defined (it was a linker warning and + an image entered at address 0), and the build reports it on an `Entry` line + and in the result record's `entry`. A file a target names as `main` on any + row is no longer linked as an ordinary source on the other rows. + ### Fixed - **A WebAssembly link writes its `.wasm` again.** 2026.10.26.1 published every @@ -65,6 +81,11 @@ pinning a checkout ahead of a release) refused the build with "no package found for exact selector". The root's checkout now answers it; a requirement the checkout does not satisfy is still refused. +- **A build program compiled against an updated interface of the same mcpp + version reads the new interface.** The compiled `mcpp` module of build + programs was kept by mcpp's version alone, so a build of mcpp between + releases that added a directive served the previous interface. It is kept by + the version and a digest of its text. - **A request by version the local index cannot answer refreshes the index wherever it is in the graph.** The refresh before the dependency walk asked only about the root's and the selected members' `[dependencies]`, so a diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 1d4b5e52..9351f82a 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -261,6 +261,79 @@ naming the three kinds it does know: targets.myapp.kind must be 'bin', 'lib' or 'shared'; got 'app' ``` +#### `kind = "entry"` and `main = { from }` — a beginning another package supplies (mcpp 2026.10.26.2+) + +A framework that supplies a program's `main` declares the translation unit as an +**entry target**, and a program selects it in place of a file of its own: + +```toml +# the framework (provider) +[targets.player] +kind = "entry" +main = "src/entry/player.cpp" + +[target.'cfg(os = "android")'.targets.player] +main = "src/entry/player_android.cpp" # the Android beginning is another file + +# the application (consumer) +[dependencies] +openmui = "0.1" + +[targets.notes] +kind = "app" +main = { from = "openmui/player" } # "/" +``` + +- `` is a key of this manifest's dependency tables (a test also reads + `[dev-dependencies]`), or the package's own name; `` is a + `kind = "entry"` target of that package. +- The entry's translation unit is compiled in the provider's context — its + include directories, its module interfaces, its flags and the entry target's + own `cflags`, `cxxflags` and `defines` — and linked into each program that + selects it. It is in no other image, its own package's included; a file a + target names as `main` on any row is never an ordinary source of an image. +- The contract between the two is symbols and run-time data. A callback the + program defines is declared by the provider with `extern "C++"` or + `extern "C"`: a declaration attached to a named module cannot be defined + outside it. +- An entry target states `main` (a file), and may state `start`, + `windows_entry`, `cflags`, `cxxflags`, `defines` and `required_features`. + Its `windows_entry` is inherited by the program that selects it; a program + stating another is refused. Its `required_features` must be active: a + selection otherwise is refused with the dependency line that enables them. + Keys that describe an artifact (`soname`, `exports`, `linkage`, the + `windows_*` keys of a program) belong to the program and are refused here. +- A program target with `main = { from }` states no `cflags`, `cxxflags` or + `defines`: they apply to the target's own entry, which is now the provider's. +- An entry target is not an artifact of its package: a package that declares + only entries still has the library or program its sources infer. + +Test programs take the same statement in `[test]` (§2.17). + +#### `start` — the symbol an image is entered at (mcpp 2026.10.26.2+) + +An image with no C runtime — a freestanding target — is entered where its +starter jumps: the ELF entry a boot ROM, a loader or an emulator reads. + +```toml +[targets.kernel] +kind = "bin" +main = "src/start.S" +start = "_start" +``` + +- `start` is accepted on program and entry targets, their rows and `[test]`, + and is refused on a target whose image a C runtime enters (every hosted + row), on an ok image (`ok_main`) and on Android, naming who enters it there. +- The program, the entry it begins with and a board package's build program + (`mcpp:link-start`, [30 — build.mcpp](30-build-mcpp.md)) may each state it; + they must agree, and a disagreement is refused naming every statement. +- Stated, it is the image's entry over the linker script's `ENTRY()`, and a + symbol nothing defines fails the link instead of producing an image that + begins at address 0. Unstated, the script's `ENTRY()` or the linker's + default applies, as before. +- The build reports it after `Size`: `Entry kernel _start ([targets.kernel] start = "_start")`. + #### `exports` — the artifact’s published symbol set (mcpp 2026.9.6.5+) ```toml @@ -434,7 +507,8 @@ required_features = ["gui"] # only built when feature `gui` is | `cxxflags` / `cflags` | Extra compile flags for this target. Do **not** put `-std=...` here — use `[package].standard`. | | `required_features` | The target is emitted only when **every** listed feature is active in the build; otherwise it is silently skipped. A gate only — it does not activate features (use `--features` / `[features].default`). **One exception, and it is not a second rule:** when this target is requested as a host tool (`tools = [...]`, §2.14), the target is what was *asked for*, so its `required_features` become the sub-build's *inputs*. Same field, one meaning — the resolution just runs in the opposite direction. | | `windows_subsystem` *(2026.9.12.2+)* | The PE subsystem of an executable: `"console"` (the default) or `"windows"`, a GUI program that starts without a console. Reaches this target's link and no other, and renders nothing on a target that is not PE. See the section above. | -| `windows_entry` *(2026.9.12.2+)* | The entry function the program defines: `"main"` (the default), `"wmain"`, `"WinMain"` or `"wWinMain"`. See the section above. | +| `windows_entry` *(2026.9.12.2+)* | The entry function the program defines: `"main"` (the default), `"wmain"`, `"WinMain"` or `"wWinMain"`. See the section above. An entry target states the function its translation unit defines, and the program that selects it inherits it. | +| `start` *(2026.10.26.2+)* | The symbol the image is entered at, on a freestanding target. See `start` above. | | `windows_code_page` *(2026.9.26.1+)* | The ANSI code page of an executable on Windows: `"utf-8"`, embedded as an application manifest that Windows 10 version 1903 and later honour, or `"legacy"`, the system's code page. A program target that writes neither runs in the system's code page, except a program built as a host tool (§2.14), which defaults to `"utf-8"`. Refused on a library target; renders nothing on a target that is not PE. See *Paths and text encoding* in §2.3. | | `windows_long_paths` *(2026.10.16.1+)* | `true` embeds `longPathAware` in the executable's application manifest: the process may use paths longer than 260 characters where Windows enables them (`LongPathsEnabled`). `false` embeds nothing. A program target that writes neither embeds nothing, except a program built as a host tool (§2.14), which defaults to `true`. Refused on a library target; renders nothing on a target that is not PE. | | `linkage` *(2026.9.15.2+)* | A library target's **default** link form, `"static"` or `"shared"`: the form a consumer that writes no `linkage` receives. Unlike `kind = "shared"` it is not a constraint, so a consumer's explicit statement is honoured. Refused beside `kind = "shared"` and on a program target. See [`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision). | @@ -1978,6 +2052,8 @@ discover = ["tests/**/*.cpp"] # the default | `discover` | array of globs | every file a glob matches is one test program; a glob beginning with `!` removes the files it matches; `[]` discovers none | | `windows_code_page` | `"utf-8"` or `"legacy"` | the ANSI code page of every test program on Windows, in the meaning of the target key of the same name; the default is `"legacy"`, the system's code page *(2026.10.5.2+)* | | `windows_long_paths` | `true` or `false` | whether every test program on Windows is long-path aware, in the meaning of the target key of the same name; the default is `false` *(2026.10.16.1+)* | +| `main` | `{ from = "/" }` | the entry target every test program begins in; each test file is then an ordinary translation unit of its program and defines no `main` (§2.2, `kind = "entry"`) *(2026.10.26.2+)* | +| `start` | symbol | the symbol every test image is entered at, on a freestanding target (§2.2, `start`) *(2026.10.26.2+)* | The globs use the vocabulary of `[build] sources`. A test's name is its path relative to the fixed directory of the first glob that matched it, without the diff --git a/docs/08-testing.md b/docs/08-testing.md index 9a4a4d39..e6f93e32 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -57,6 +57,24 @@ A suite compiled from several sources is a package of its own: a workspace member whose `[build] sources` carries the suite and whose one test program drives it, selected with `mcpp test -p `. +### Where a test program begins + +A test framework that supplies `main` declares it as an entry target +(`kind = "entry"`, docs/04 §2.2), and the package names it once for every test: + +```toml +[dev-dependencies] +fw = "0.1" + +[test] +main = { from = "fw/main" } +``` + +Each test file is then an ordinary translation unit of its program and defines +no `main`. The package's own name selects its own entry, which is how a framework +tests its entry with the callbacks a test file defines. On a freestanding target, +`[test] start` states the symbol every test image is entered at. + ## Running them ```bash diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 60007333..29f13264 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -107,6 +107,7 @@ is ignored, so diagnostics may be logged freely. | `mcpp:deploy=\t` *(2026.9.12.3+, protocol 11)* | place a file this program produced or selected beside the artifact, at ``, relative to the executable's directory — the build-program form of `[runtime] deploy` (docs/04 §2.11). `` may be absolute (an action's own declared output) or resolved against the package root; TAB-separated, because an absolute Windows `` contains a colon. **Reaches the consumer**, joining the same `LinkIntent` `link-lib`/`link-search`/`link-flag` feed — see below | | `mcpp:runtime-search-dir=` *(protocol 12)* | add `` to the launch-time search path — the build-program form of `runtime_search_dirs` (docs/04 §2.11). Relative resolves against the package root; the directory need not exist yet (a `prepare` action may populate it later). **Reaches the consumer**, joining `LinkIntent::runtimeSearchDirs` directly: RUNPATH/rpath on ELF and Mach-O, never `-L`, and `mcpp pack`'s closure search — see below | | `mcpp:link-script=` *(2026.8.19+)* | link with this **linker script** (`-T`; relative resolves against the package root, and the emitted path is absolute because the link runs in the build directory). Reaches the **consumer**, unlike `include-dir` — a board's memory layout is the one thing a consumer cannot write for itself | +| `mcpp:link-start=` *(protocol 16)* | the symbol every image linked over this package is **entered at** — what a board's startup code defines. Reaches the **consumer**, as `link-script` does, and is checked there against the `start` the program or its entry states: an image is entered at one symbol, and two statements that disagree are refused naming both. Accepted on a freestanding target only (docs/04, `start`) | | `mcpp:warning=` *(2026.8.21.2+)* | say something to the user and **keep going**. The one directive that changes no compile line, no link line and no source set. Survives the build cache — see below | | `mcpp:fact==` *(2026.9.5.2+)* | state something the program **established about the machine** (`cuda.driver=12.4`). Compared against floors before anything is compiled; see below | | `mcpp:floor= >= ` *(2026.9.5.2+)* | state what this package **needs** of that quantity. Unmet ⇒ the build is refused with both values (`version-floor-unmet`); a floor nobody stated a fact for is silent | @@ -179,6 +180,7 @@ int main() { | `mcpp::deploy(from, to)` *(2026.9.12.3+, protocol 11)* | `mcpp:deploy=\t` — see below | | `mcpp::runtime_search_dir(dir)` *(protocol 12)* | `mcpp:runtime-search-dir=` — see below | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | +| `mcpp::link_start(symbol)` *(protocol 16)* | `mcpp:link-start=` | | `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` — see below | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | the payload directory of a package declared in `[xlings.workspace]` — by this manifest, or by a dependency compiled into this build program *(2026.9.6.6+)*; `""` when it was not declared or is not installed (see below) | | `mcpp::xpkg_source(ns, name)` / `mcpp::xpkg_program(ns, name)` *(protocol 15)* | where that payload comes from — `payload`, `override`, `pending`, or `""` — and the program an override named | diff --git a/docs/40-baremetal.md b/docs/40-baremetal.md index 93280ea3..bf7ff414 100644 --- a/docs/40-baremetal.md +++ b/docs/40-baremetal.md @@ -267,7 +267,8 @@ No `_start` and no assembly entry point are required, because the board-support package selects picolibc's semihosting `crt0` — the C runtime is initialised before `main` runs, and the return value reaches the host through semihosting. Only a board with no C library at all needs an explicit entry point, declared -by pointing `main` at the source file that carries `_start`. +by pointing `main` at the source file that carries `_start`; `start` names the +symbol when it is not the linker's default (docs/04, `start`). ## What a freestanding target changes @@ -279,7 +280,7 @@ by pointing `main` at the source file that carries `_start`. | C library | The **target's**, resolved by mcpp from the target's own table row exactly as the compiler is. A bare-metal project declares no libc, just as a hosted project declares no glibc. The engine places the sysroot's library directory on the link search path, so a board-support package selects out of it by bare name (`-lc`, `-lcrt0-semihost`). | | Exceptions and RTTI | Off on every translation unit in the graph, including a dependency's, **unless a package supplies a C++ runtime built for this target**. There is otherwise no unwinder and no `libc++abi`, so nothing can throw; `std::optional::value()` alone would reference `__cxa_throw` and three further undefined symbols. The setting belongs to the target rather than to a project's `cxxflags` because a BMI records it, and a dependency compiled with exceptions cannot be imported by a unit without them. A package declaring `provides = ["hosted-standard-library"]` reverses the default: exceptions and RTTI are enabled, `-ffreestanding` is dropped, and `-fasynchronous-unwind-tables` is added. | | `import std` | Available when a package in the graph provides `hosted-standard-library` and names its own `std` module source; otherwise rejected at configure time with a diagnostic rather than at link time. | -| Entry point | `int main()` is available whenever something supplies a `crt0`. A board-support package normally does. | +| Entry point | `int main()` is available whenever something supplies a `crt0`. A board-support package normally does. The image's entry symbol is the linker script's `ENTRY()`, or a `start` the program, its entry or the board states; a stated symbol nothing defines fails the link. | | Default linkage | Static, and not as a preference: there is no loader, so there is no other option. | A project with no dependencies at all still builds, which is the evidence that @@ -694,6 +695,7 @@ general, and not the cause here. See `mcpp:warning=` in | `mcpp:link-lib=` | Adds `-l` to the consumer's link line. Bare names suffice: the target sysroot's library directory is already on the search path. | | `mcpp:link-search=` | Adds `-L`, for a library the package carries itself. | | `mcpp:link-script=` | Adds `-T `. A relative path resolves against the package root, so a script belonging to the target's C library must be named absolutely. | +| `mcpp:link-start=` | The symbol every image over the board is entered at (protocol 16). Checked against the program's `start`: one symbol, or a refusal naming both statements. | | `mcpp:runner=` | Appends one argv token to the run template. argv is ordered, so the template is built by repetition — one directive line per token. | | `mcpp:include-dir=` | Adds an include directory **for this package only**. | diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index e2369c7e..19d9c4e4 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -872,7 +872,7 @@ terminal is therefore visible to an IDE that reads the record, and the reverse. | `status` | `running`, `ok`, `failed`, or `cancelled` | | `configuration` | `triple`, `profile`, `fingerprint`, `outputDir` | | `compileDatabase` | the configuration's `compile_commands.json`, or `null` | -| `artifacts` | `name`, `kind` (`bin`, `test`, `shared`, `static`), `path`, and for a runnable product `launch` | +| `artifacts` | `name`, `kind` (`bin`, `test`, `shared`, `static`), `path`, for a runnable product `launch`, and `entry` when the program's beginning or its entry symbol was stated | | `diagnostics` | the compilers' diagnostics of a failed build: `severity`, `message`, `path`, `range` | `launch` is how `mcpp run` starts the product: `argv`, `cwd`, `env` and @@ -883,6 +883,11 @@ caller's other variables, so the record holds no secret of the caller's. `"run_under"` (an execution host wraps it). A debugger, an IDE or a CI job that starts the program from `launch` starts it as `mcpp run` would. +`entry` says where the program begins and where its image is entered (docs/04, +`kind = "entry"` and `start`): `from` is the `"/"` a +`main = { from }` named, `start` the stated entry symbol and `start_from` the +statement that named it. Each key is present only when stated. + A run ended by SIGINT, SIGTERM or SIGHUP leaves `cancelled` in both files ([exit codes](specs/exit-codes.md) §3.7). A record still `running` whose `pid` no longer exists belongs to a run that was killed. diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md index 293c7012..2d2e4fe4 100644 --- a/docs/specs/manifest-semantics.md +++ b/docs/specs/manifest-semantics.md @@ -5,11 +5,11 @@ | **规范编号** | SPEC-004 | | **标题** | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | | **状态** | **草案(Draft)** | -| **版本** | 1.17 | -| **最后修改** | 2026-10-23 | +| **版本** | 1.18 | +| **最后修改** | 2026-10-27 | | **最低实现版本** | 条件化形状:mcpp **2026.8.29.1**(`[target..build-dependencies]` 起齐备);目标轴:mcpp **2026.9.6.4** | | **作者/维护** | mcpp-community | -| **相关设计文档** | `.agents/docs/2026-09-07-mcpp-toml-unified-semantics-design.md`
`.agents/docs/2026-06-04-manifest-schema-ownership.md`
`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`
`.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md`
`.agents/docs/2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md`
`.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md`
`.agents/docs/2026-10-06-windows-x86-arch-vocabulary-and-rc-follow-ups-design.md`
`.agents/docs/2026-10-10-one-source-one-meaning-design.md`
`.agents/docs/2026-10-11-build-dependencies-one-pipeline-design.md`
`.agents/docs/2026-10-23-target-base-and-execution-host-design.md` | +| **相关设计文档** | `.agents/docs/2026-09-07-mcpp-toml-unified-semantics-design.md`
`.agents/docs/2026-06-04-manifest-schema-ownership.md`
`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`
`.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md`
`.agents/docs/2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md`
`.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md`
`.agents/docs/2026-10-06-windows-x86-arch-vocabulary-and-rc-follow-ups-design.md`
`.agents/docs/2026-10-10-one-source-one-meaning-design.md`
`.agents/docs/2026-10-11-build-dependencies-one-pipeline-design.md`
`.agents/docs/2026-10-23-target-base-and-execution-host-design.md`
`.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md`(§24) | | **相关使用文档** | [docs/04 —— mcpp.toml 字段参考](../04-mcpp-toml.md) | ## 规范用语 @@ -141,6 +141,11 @@ **状态:已实现**(mcpp 2026.9.14.2;`linkage` 为 2026.9.15.2;多个命中的条件表按具体程度生效为 2026.9.28.2,mcpp#728)。 +按行形式也可以陈述程序目标或入口目标的起点(§14):`main`(文件或 `{ from }`)与 +`start`。一行要么陈述库的形态(`kind` 或 `linkage`),要么陈述起点,两者并存**必须**被拒绝; +起点写在库目标上**必须**被拒绝。命中的行替换无条件的 `main` 或 `start`;目标在任一行上 +命名为 `main` 的文件都是起点,**禁止**作为普通源文件链入任何映像。 + ### 3.2 门可以嵌进条件 `[target..feature-deps.]` 合法:条件决定这个门**拉进什么**,不决定 @@ -732,6 +737,33 @@ mcpp 2026.10.10.1,#785、#786;第 14 至 16 条与第 13 条的依赖位置为 m **已实现**(mcpp >= 2026.10.21.1,#826)。 +## 14. 程序起点与镜像入口 + +入口分为两层(mcpp 设计 2026-10-26 §24):**程序起点**是程序代码开始执行的翻译单元, +属于程序;**镜像入口**是启动者进入镜像的符号(ELF `e_entry`),属于面向启动者的一层。 + +1. `[targets.] kind = "entry"` 声明一个入口目标:一个只作为其他程序起点的翻译单元。 + 它**必须**陈述 `main`(文件);可以陈述 `start`、`windows_entry`、`cflags`、`cxxflags`、 + `defines`、`required_features`;陈述产物的键(`soname`、`exports`、`linkage`、 + `windows_auto_export`、`windows_subsystem`、`windows_code_page`、`windows_long_paths`) + **必须**被拒绝。入口目标不产生链接单元;它的文件**禁止**链入未选择它的映像。只声明 + 入口目标的包仍按源文件推断库或程序目标。 +2. 程序目标的 `main` 可以写为 `{ from = "<依赖>/<入口>" }`。`<依赖>` 是本清单依赖表中的键 + (测试另含开发依赖),或本包自身的名字;`<入口>` 是该包的入口目标。入口翻译单元 + **必须**在提供方的上下文中编译(其头文件目录、模块接口、标志与入口目标自身的标志), + 并链入选择它的程序。未知依赖、非入口目标、`required_features` 未开启的入口**必须** + 被拒绝并列出候选或所需写法。`from` 与目标自身的 `cflags`、`cxxflags`、`defines` + 并存**必须**被拒绝。 +3. 入口目标陈述的 `windows_entry` 由选择它的程序继承;程序陈述不同的值**必须**被拒绝。 +4. `[test] main = { from }` 为所发现的每个测试程序提供起点,测试文件作为其普通翻译单元; + `[test] start` 为每个测试镜像陈述入口符号。 +5. `start` 只在没有 C 运行时的镜像(独立目标)上成立;在其他目标上**必须**被拒绝并说明该行 + 入口的归属。目标(或 `[test]`)、所选入口目标与依赖闭包中构建程序的 `mcpp:link-start` + (协议 16)可分别陈述 `start`;陈述不一致**必须**被拒绝并列出全部来源。`start` 使入口符号 + 未定义成为链接错误,优先于链接脚本的 `ENTRY()`。 + +**状态:已实现**(mcpp 2026.10.26.2,mcpp#845)。 + ## 变更记录 | 版本 | 日期 | 变更 | @@ -754,3 +786,4 @@ mcpp 2026.10.10.1,#785、#786;第 14 至 16 条与第 13 条的依赖位置为 m | 1.15 | 2026-10-11 | mcpp 2026.10.11.1(#797):新增 §12 依赖的库与配置(两条轴、实例、一个包一个版本、按配置激活特性、`host-module` 的含义与默认值、构建程序图、构建插件、兼容读法);§9 第 13 条的移除版本改为 mcpp 2027.1,并补依赖位置;新增 §9 第 14 至 16 条(`[xlings.overrides]` 属于构建的根、`[workspace.dependencies]` 的继承、虚拟根旁的 `build.mcpp`)。 | | 1.16 | 2026-10-21 | 新增 §13,明确依赖 EXE、DLL 的资源只进入其所属包的 image,且按计划内目标校验 manifest 冲突(#826)。 | | 1.17 | 2026-10-23 | mcpp 2026.10.24.1:§2 陈述值的四档层次,`sysroot` 与 `run_under` 可出现在工程与机器默认中;§4.2 增加 `run_under`,陈述 `sysroot` 的表写法。 | +| 1.18 | 2026-10-27 | mcpp 2026.10.26.2(mcpp#845):新增 §14 程序起点与镜像入口(`kind = "entry"`、`main = { from }`、`start`、`[test] main` 与 `start`、`mcpp:link-start`);§3.1.1 的按行形式可陈述起点。 | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 4d740f8a..d6848ee6 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -261,6 +261,66 @@ main = "src/main.cpp" targets.myapp.kind must be 'bin', 'lib' or 'shared'; got 'app' ``` +#### `kind = "entry"` 与 `main = { from }` —— 由其他包提供的程序起点(mcpp 2026.10.26.2+) + +提供程序 `main` 的框架把该翻译单元声明为**入口目标**,程序以它代替自己的文件: + +```toml +# 框架(提供方) +[targets.player] +kind = "entry" +main = "src/entry/player.cpp" + +[target.'cfg(os = "android")'.targets.player] +main = "src/entry/player_android.cpp" # Android 上的起点是另一个文件 + +# 应用(使用方) +[dependencies] +openmui = "0.1" + +[targets.notes] +kind = "app" +main = { from = "openmui/player" } # "<依赖>/<入口>" +``` + +- `<依赖>` 是本清单依赖表中的键(测试另读 `[dev-dependencies]`),或本包自身的名字; + `<入口>` 是该包的 `kind = "entry"` 目标。 +- 入口翻译单元在提供方的上下文中编译:提供方的头文件目录、模块接口、标志,以及入口目标 + 自身的 `cflags`、`cxxflags`、`defines`;链入选择它的每个程序,不进入其他映像(包括 + 它自己的包)。目标在任一行上命名为 `main` 的文件都不作为普通源文件链入映像。 +- 两者之间的契约只经符号与运行时数据。由程序定义的回调,提供方以 `extern "C++"` 或 + `extern "C"` 声明:附着于命名模块的声明不能在模块之外定义。 +- 入口目标陈述 `main`(文件),可以陈述 `start`、`windows_entry`、`cflags`、`cxxflags`、 + `defines` 与 `required_features`。`windows_entry` 由选择它的程序继承,程序陈述不同的值 + 被拒绝;`required_features` 必须已开启,否则拒绝并给出开启它们的依赖写法。描述产物的键 + (`soname`、`exports`、`linkage`、程序的 `windows_*` 键)属于程序,在入口目标上被拒绝。 +- 使用 `main = { from }` 的程序目标不陈述 `cflags`、`cxxflags`、`defines`:这些标志作用于 + 目标自身的入口,而该入口已属于提供方。 +- 入口目标不是所在包的产物:只声明入口目标的包仍按源文件推断库或程序目标。 + +测试程序在 `[test]` 中使用同一写法(§2.17)。 + +#### `start` —— 镜像的入口符号(mcpp 2026.10.26.2+) + +没有 C 运行时的镜像(独立目标)从启动者跳转的位置开始执行:启动 ROM、加载器或模拟器读取的 +ELF 入口。 + +```toml +[targets.kernel] +kind = "bin" +main = "src/start.S" +start = "_start" +``` + +- `start` 可写在程序目标、入口目标、它们的行表与 `[test]` 中;在由 C 运行时进入的镜像 + (所有 hosted 行)、ok 镜像(`ok_main`)与 Android 上被拒绝,并说明该行由谁进入镜像。 +- 程序、它选择的入口与板级包的构建程序(`mcpp:link-start`,见 + [30 —— build.mcpp](30-build-mcpp.md))可分别陈述它;陈述必须一致,不一致时拒绝并列出 + 全部来源。 +- 陈述后它优先于链接脚本的 `ENTRY()` 成为镜像入口,且入口符号未定义使链接失败,而不是产生 + 一个从地址 0 开始执行的镜像。未陈述时沿用链接脚本的 `ENTRY()` 或链接器默认值。 +- 构建在 `Size` 之后报告它:`Entry kernel _start ([targets.kernel] start = "_start")`。 + #### `exports` —— 产物发布的符号集合(mcpp 2026.9.6.5+) ```toml @@ -420,7 +480,8 @@ required_features = ["gui"] # only built when feature `gui` is | `cxxflags` / `cflags` | 这个目标专用的额外编译旗标。**不要**把 `-std=...` 写在这里——用 `[package].standard`。 | | `required_features` | 只有当构建中**每一个**列出的 feature 都被激活时,这个目标才会被产出;否则被静默跳过。它只是一道闸——不会激活 feature(用 `--features` / `[features].default`)。**一个例外,但它不是第二条规则:** 当这个目标作为 host 工具被请求时(`tools = [...]`,§2.14),这个目标就是被**请求**的那一个,于是它的 `required_features` 变成子构建的**输入**。同一个字段、同一个含义——只是解析的方向反过来了。 | | `windows_subsystem` *(2026.9.12.2+)* | 可执行文件的 PE subsystem:`"console"`(默认)或 `"windows"`,一个启动时没有控制台的 GUI 程序。只到达这个目标的链接,别处不受影响,在非 PE 的目标上不渲染任何东西。见上一节。 | -| `windows_entry` *(2026.9.12.2+)* | 程序定义的入口函数:`"main"`(默认)、`"wmain"`、`"WinMain"` 或 `"wWinMain"`。见上一节。 | +| `windows_entry` *(2026.9.12.2+)* | 程序定义的入口函数:`"main"`(默认)、`"wmain"`、`"WinMain"` 或 `"wWinMain"`。见上一节。入口目标陈述其翻译单元定义的函数,由选择它的程序继承。 | +| `start` *(2026.10.26.2+)* | 独立目标上镜像的入口符号。见上文 `start`。 | | `windows_code_page` *(2026.9.26.1+)* | 可执行文件在 Windows 上的 ANSI 代码页:`"utf-8"`,以 Windows 10 1903 及以后版本会遵从的应用程序清单嵌入;或 `"legacy"`,即系统代码页。两者都不写的程序目标运行在系统代码页里,作为 host 工具构建的程序(§2.14)除外,它默认为 `"utf-8"`。写在库目标上会被拒绝;在非 PE 目标上不产生任何东西。见 §2.3 的*路径与文本编码*。 | | `windows_long_paths` *(2026.10.16.1+)* | `true` 在可执行文件的应用程序清单中嵌入 `longPathAware`:在 Windows 启用长路径(`LongPathsEnabled`)时,进程可以使用超过 260 个字符的路径。`false` 不嵌入。两者都不写的程序目标不嵌入,作为 host 工具构建的程序(§2.14)除外,它默认为 `true`。写在库目标上会被拒绝;在非 PE 目标上不产生任何东西。 | | `linkage` *(2026.9.15.2+)* | 一个库目标的**默认**链接形态,`"static"` 或 `"shared"`:不写 `linkage` 的消费者得到的形态。与 `kind = "shared"` 不同,它不是约束,因此消费者的显式陈述会被遵从。与 `kind = "shared"` 同写,或写在程序目标上,都会被拒绝。见[`dependency_linkage`](#dependency_linkage--静态还是动态由消费者决定)。 | @@ -1836,6 +1897,8 @@ discover = ["tests/**/*.cpp"] # the default | `discover` | glob 数组 | 每个被某条 glob 匹配到的文件都是一个测试程序;以 `!` 开头的 glob 会移除它匹配到的文件;`[]` 不发现任何测试 | | `windows_code_page` | `"utf-8"` 或 `"legacy"` | Windows 上每个测试程序的 ANSI 代码页,含义与同名的 target 键相同;默认 `"legacy"`,即系统代码页 *(2026.10.5.2+)* | | `windows_long_paths` | `true` 或 `false` | Windows 上每个测试程序是否支持长路径,含义与同名的 target 键相同;默认 `false` *(2026.10.16.1+)* | +| `main` | `{ from = "<依赖>/<入口>" }` | 每个测试程序的起点所在的入口目标;测试文件随之成为程序的普通翻译单元,不定义 `main`(§2.2,`kind = "entry"`) *(2026.10.26.2+)* | +| `start` | 符号 | 独立目标上每个测试镜像的入口符号(§2.2,`start`) *(2026.10.26.2+)* | 这些 glob 使用与 `[build] sources` 相同的词汇。一个测试的名字,是它 相对第一条匹配到它的 glob 所在的固定目录的路径,去掉扩展名。两个 diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 5f9cb7ce..e309fdd3 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -52,6 +52,23 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] `[build] sources` 承载整套套件,其唯一的测试程序驱动套件,用 `mcpp test -p ` 选中。 +### 测试程序的起点 + +提供 `main` 的测试框架把它声明为入口目标(`kind = "entry"`,docs/04 §2.2),包为 +全部测试指名一次: + +```toml +[dev-dependencies] +fw = "0.1" + +[test] +main = { from = "fw/main" } +``` + +每个测试文件随之成为其程序的普通翻译单元,不定义 `main`。本包自身的名字选择本包的入口, +框架借此以测试文件定义的回调测试它自己的入口。在独立目标上,`[test] start` 陈述每个测试 +镜像的入口符号。 + ## 运行测试 ```bash diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 6757adb4..62e950cc 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -99,6 +99,7 @@ mcpp build # compiles + runs build.mcpp, then builds the project | `mcpp:deploy=\t` *(2026.9.12.3+,protocol 11)* | 把本程序生成或选中的一个文件放到产物旁边的 ``(相对可执行文件所在目录)——`[runtime] deploy`(docs/04 §2.11)的构建程序形态。`` 可以是绝对路径(某个 action 自己声明的输出),也可以按包根解析;用 TAB 分隔,因为一个绝对的 Windows `` 本身含冒号。**到达消费者**,并入同一个被 `link-lib`/`link-search`/`link-flag` 喂入的 `LinkIntent`——见下 | | `mcpp:runtime-search-dir=` *(protocol 12)* | 把 `` 加入启动期搜索路径——`runtime_search_dirs`(docs/04 §2.11)的构建程序形态。相对路径按包根解析;该目录在此程序运行时不必存在(一个 `prepare` action 可能之后才把它填充起来)。**到达消费者**,直接并入 `LinkIntent::runtimeSearchDirs`:在 ELF 与 Mach-O 上是 RUNPATH/rpath,绝不是 `-L`,并进入 `mcpp pack` 的闭包搜索——见下 | | `mcpp:link-script=` *(2026.8.19+)* | 用这个**链接脚本**链接(`-T`;相对路径按包根解析,发出的是绝对路径,因为链接是在构建目录里跑的)。与 `include-dir` 不同,它**到达消费者** —— 板子的内存布局恰恰是消费者写不出来的那一项 | +| `mcpp:link-start=<符号>` *(协议 16)* | 链接到本包之上的每个镜像的**入口符号**——板子的启动代码所定义的符号。与 `link-script` 一样**到达消费者**,并在那里与程序或其入口陈述的 `start` 比对:镜像只有一个入口符号,两条不一致的陈述被拒绝并同时点名。只在独立目标上接受(docs/04,`start`) | | `mcpp:warning=` *(2026.8.21.2+)* | 对用户说一句话并**继续**。唯一一条不改变编译行、链接行与源码集的指令。它**穿过构建缓存** —— 见下 | | `mcpp:fact==` *(2026.9.5.2+)* | 陈述程序**测得的机器事实**(`cuda.driver=12.4`)。在编译任何东西之前与 floor 比较;见下 | | `mcpp:floor= >= ` *(2026.9.5.2+)* | 陈述本包对该量的**下界**。不满足 ⇒ 构建被拒并给出两侧取值(`version-floor-unmet`);没有人陈述事实的下界保持沉默 | @@ -163,6 +164,7 @@ int main() { | `mcpp::deploy(from, to)` *(2026.9.12.3+,protocol 11)* | `mcpp:deploy=\t` —— 见下 | | `mcpp::runtime_search_dir(dir)` *(protocol 12)* | `mcpp:runtime-search-dir=` —— 见下 | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | +| `mcpp::link_start(symbol)` *(协议 16)* | `mcpp:link-start=` | | `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` —— 见下 | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | `[xlings.workspace]` 里声明的包的载荷目录 —— 本 manifest 声明的,或编进本构建程序的某个依赖声明的(2026.9.6.6+);没声明或没安装时返回 `""`(见下) | | `mcpp::xpkg_source(ns, name)` / `mcpp::xpkg_program(ns, name)` *(协议 15)* | 该载荷从哪里来 —— `payload`、`override`、`pending` 或 `""` —— 以及一条覆盖点名的程序 | diff --git a/docs/zh/40-baremetal.md b/docs/zh/40-baremetal.md index 2e7e74c9..cd4307eb 100644 --- a/docs/zh/40-baremetal.md +++ b/docs/zh/40-baremetal.md @@ -233,7 +233,7 @@ extern "C" int main() { 不需要 `_start`,也不需要汇编入口,因为板级支持包选取了 picolibc 的 semihosting `crt0`——C 运行时在 `main` 运行之前就已初始化好,返回值经 semihosting 传回宿主。 只有完全没有 C 库的板子才需要显式入口点,做法是让 `main` 指向携带 `_start` 的 -那份源文件。 +那份源文件;入口符号不是链接器默认值时由 `start` 指名(docs/04,`start`)。 ## freestanding 目标带来的变化 @@ -245,7 +245,7 @@ extern "C" int main() { | C 库 | 属于**目标**,由 mcpp 从目标自己的表行解析,方式与解析编译器完全一致。裸机工程不声明 libc,正如 hosted 工程不声明 glibc。引擎把 sysroot 的库目录放进链接搜索路径,板级支持包因此可以按裸名从中选取(`-lc`、`-lcrt0-semihost`)。 | | 异常与 RTTI | 在图中每个翻译单元上都关闭,包括依赖的翻译单元,**除非有包提供为该目标构建的 C++ 运行时**。否则既没有 unwinder,也没有 `libc++abi`,任何东西都无法抛出;仅 `std::optional::value()` 一处,就会引用 `__cxa_throw` 以及另外三个未定义符号。该设定属于目标,而不是工程的 `cxxflags`,因为 BMI 会记录它,而带异常编出的依赖无法被不带异常的单元导入。声明了 `provides = ["hosted-standard-library"]` 的包会翻转这个默认:异常和 RTTI 开启,`-ffreestanding` 被去掉,并加上 `-fasynchronous-unwind-tables`。 | | `import std` | 当图中有包提供 `hosted-standard-library` 且指明了自己的 `std` 模块源码时可用;否则在配置期以诊断拒绝,而不是在链接期才失败。 | -| 入口点 | 只要有东西提供 `crt0`,`int main()` 就可用。板级支持包通常会提供它。 | +| 入口点 | 只要有东西提供 `crt0`,`int main()` 就可用。板级支持包通常会提供它。镜像的入口符号是链接脚本的 `ENTRY()`,或由程序、其入口或板子陈述的 `start`;陈述的符号未被定义时链接失败。 | | 默认链接方式 | 静态,而且这不是偏好:没有加载器,因此没有别的选项。 | 一个完全没有依赖的工程仍然构建得出来,这正是「单凭 ISA 表行已经足够」的证据: @@ -622,6 +622,7 @@ if (const char* dir = mcpp::xpkg_dir("xim", "qemu-riscv"); dir && *dir) { | `mcpp:link-lib=` | 向消费者的链接行加入 `-l`。裸名即可:目标 sysroot 的库目录已经在搜索路径上。 | | `mcpp:link-search=` | 加入 `-L`,用于包自己携带的库。 | | `mcpp:link-script=` | 加入 `-T `。相对路径按包根解析,因此属于目标 C 库的脚本必须用绝对路径指名。 | +| `mcpp:link-start=<符号>` | 板子上每个镜像的入口符号(协议 16)。与程序的 `start` 比对:只有一个符号,否则拒绝并同时点名两条陈述。 | | `mcpp:runner=` | 向运行模板追加一个 argv 记号。argv 是有序的,模板因此由重复构建——一个记号一行指令。 | | `mcpp:include-dir=` | 加入一个**仅对本包生效**的头文件搜索目录。 | diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index f8947534..bcd88fbf 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -785,7 +785,7 @@ mcpp build [target] [-p ] [--release] --message-format json | `status` | `running`、`ok`、`failed` 或 `cancelled` | | `configuration` | `triple`、`profile`、`fingerprint`、`outputDir` | | `compileDatabase` | 该配置的 `compile_commands.json`,或 `null` | -| `artifacts` | `name`、`kind`(`bin`、`test`、`shared`、`static`)、`path`;可运行的产物另有 `launch` | +| `artifacts` | `name`、`kind`(`bin`、`test`、`shared`、`static`)、`path`;可运行的产物另有 `launch`;陈述了程序起点或入口符号时另有 `entry` | | `diagnostics` | 失败构建的编译器诊断:`severity`、`message`、`path`、`range` | `launch` 是 `mcpp run` 启动该产物的方式:`argv`、`cwd`、`env` 与 `via`。`env` 只含 @@ -794,6 +794,10 @@ mcpp 为程序设置或改写的变量(加载器的搜索路径、运行时载 `[target.].runner` 包装)或 `"run_under"`(由执行宿主包装)。调试器、IDE 或 CI 按 `launch` 启动程序,与 `mcpp run` 的启动一致。 +`entry` 说明程序从哪里开始、镜像从哪里进入(docs/04,`kind = "entry"` 与 `start`): +`from` 是 `main = { from }` 指名的 `"<依赖>/<入口>"`,`start` 是陈述的入口符号, +`start_from` 是指名它的陈述。各键只在有陈述时出现。 + 被 SIGINT、SIGTERM 或 SIGHUP 结束的运行在两个文件中都留下 `cancelled` ([退出码](../specs/exit-codes.md) §3.7)。`status` 仍为 `running`、而 `pid` 已不存在 的记录属于一次被强制结束的运行。 diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index 30bdb168..fea05439 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -196,6 +196,11 @@ enum class Slot : std::size_t { ToolDecisions, XpkgRequests, ToolchainStatement, + // Protocol 16 (design 2026-10-26 §24.3): the symbol an image of this + // board is entered at, stated by the package that faces the image's + // starter. Not an ldflag: the engine renders it per linker and refuses + // two statements that disagree, which a flag would make two `-e` words. + LinkStart, Count }; inline constexpr std::size_t kSlotCount = static_cast(Slot::Count); @@ -294,7 +299,7 @@ struct Def { int sinceProtocol; }; -inline constexpr std::array kTable{{ +inline constexpr std::array kTable{{ // wire tag slot scope transform must missingPrefix missingSuffix since {"cxxflag", "cxxflag", Slot::CxxFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, {"cflag", "cflag", Slot::CFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, @@ -483,6 +488,10 @@ inline constexpr std::array kTable{{ {"decision", "decision", Slot::ToolDecisions, Scope::Advisory, Transform::Verbatim, false, "", "", 15}, {"xpkg-request", "xpkg-request", Slot::XpkgRequests, Scope::Claim, Transform::Verbatim, false, "", "", 15}, {"toolchain", "toolchain", Slot::ToolchainStatement, Scope::Claim, Transform::Verbatim, false, "", "", 15}, + // v16 (design 2026-10-26 §24.3): a board's image entry symbol. LinkGlobal, + // as `link-script` is: it is a fact of every image linked over the board. + // kCacheEpoch is not bumped, for the reason `runtime-search-dir` states. + {"link-start", "link-start", Slot::LinkStart, Scope::LinkGlobal, Transform::Verbatim, false, "", "", 16}, }}; // ── Collected output of one run ──────────────────────────────────────────── @@ -1174,6 +1183,11 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { m.runtimeConfig.linkIntent.deploy.push_back( {std::filesystem::path(entry.substr(0, tab)), entry.substr(tab + 1)}); } + + // `link-start` (protocol 16). `link_start_directive_error` has refused a + // program that states two different symbols, so the last line is the one. + for (auto const& symbol : d.at(Slot::LinkStart)) + m.runtimeConfig.linkIntent.start = symbol; } // The five roles `mcpp::action::role` may name, in the order @@ -1330,8 +1344,21 @@ std::string target_directive_error(const mcpp::manifest::Manifest& m, const Dire "windows-subsystem", "windows_subsystem", true); !e.empty()) return e; - return named_target_error(m, d.at(Slot::WindowsEntry), - "windows-entry", "windows_entry", false); + if (auto e = named_target_error(m, d.at(Slot::WindowsEntry), + "windows-entry", "windows_entry", false); + !e.empty()) + return e; + // `link-start` (protocol 16): a symbol, and one per program. + auto const& starts = d.at(Slot::LinkStart); + for (auto const& symbol : starts) { + if (auto msg = mcpp::manifest::start_symbol_problem(symbol); !msg.empty()) + return std::format("build.mcpp emitted `mcpp:link-start={}`: {}", symbol, msg); + if (symbol != starts.front()) + return std::format( + "build.mcpp emitted `mcpp:link-start` twice, as \"{}\" and as \"{}\"; an " + "image is entered at one symbol.", starts.front(), symbol); + } + return {}; } std::string deploy_directive_error(const mcpp::manifest::Manifest& m, const Directives& d) { diff --git a/modules/buildmcpp/src/program_protocol.cppm b/modules/buildmcpp/src/program_protocol.cppm index 0a792074..c72eb17a 100644 --- a/modules/buildmcpp/src/program_protocol.cppm +++ b/modules/buildmcpp/src/program_protocol.cppm @@ -118,7 +118,10 @@ export namespace mcpp::build::program_protocol { // `mcpp:decision=` records which tool a plugin runs, `mcpp:toolchain=` states // the build toolchain from the root's toolchain phase, and `mcpp::phase()` // says which phase is running. No directive of v14 changes spelling. -inline constexpr int kProtocolVersion = 15; +// v16 (design 2026-10-26 §24.3): `mcpp:link-start=` -- the symbol every image +// linked over the package is entered at, the build-program form of a target's +// `start`. No directive of v15 changes spelling. +inline constexpr int kProtocolVersion = 16; // ── Cache-format epoch ───────────────────────────────────────────────────── // diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index dc48cfc0..b40423c5 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1955,8 +1955,10 @@ std::expected parse_document(const t::Document& documen // 2026.9.12.2 lists three kinds), which is correct: a root that // names a form the engine cannot produce must not build. else if (kind_s == "app" || kind_s == "application") t.kind = Target::Application; + // Design 2026-10-26 §24.3: the beginning of another program. + else if (kind_s == "entry") t.kind = Target::Entry; else return std::unexpected(error(origin, - std::format("targets.{}.kind must be 'bin', 'app', 'lib' or 'shared'; got '{}'", tname, kind_s))); + std::format("targets.{}.kind must be 'bin', 'app', 'lib', 'shared' or 'entry'; got '{}'", tname, kind_s))); t.kindDeclaredBy = std::format("[targets.{}] kind = \"{}\"", tname, kind_s); // `linkage` (#642 E1): the library's default form. Refused beside @@ -1981,13 +1983,70 @@ std::expected parse_document(const t::Document& documen // library (its actual entry is `ANativeActivity_onCreate` or the // JNI exports, which is the platform's contract and not mcpp's to // rename -- see `is_program()` and `application_form`). - if (t.is_program()) { + // + // A program's beginning may instead be a dependency's entry target, + // `main = { from = "/" }` (design 2026-10-26 + // §24.3). An entry target is itself the translation unit, so it names + // a file and never another entry. + if (t.is_program() || t.kind == Target::Entry) { auto mit = tt.find("main"); - if (mit == tt.end() || !mit->second.is_string()) { + if (mit != tt.end() && mit->second.is_string()) { + t.main = mit->second.as_string(); + } else if (mit != tt.end() && mit->second.is_table() && t.kind != Target::Entry) { + auto const& mt = mit->second.as_table(); + auto fit = mt.find("from"); + if (fit == mt.end() || !fit->second.is_string() || mt.size() != 1) + return std::unexpected(error(origin, std::format( + "targets.{}.main as a table is `{{ from = \"/\" }}` " + "and nothing else", tname))); + if (auto msg = main_from_problem(fit->second.as_string()); !msg.empty()) + return std::unexpected(error(origin, std::format( + "targets.{}.main: {}", tname, msg))); + t.mainFrom = fit->second.as_string(); + } else if (t.kind == Target::Entry) { + return std::unexpected(error(origin, std::format( + "targets.{} (kind=entry) requires 'main', the file that is the " + "translation unit; `from` names another package's entry and an " + "entry is not one", tname))); + } else { return std::unexpected(error(origin, std::format("targets.{} (kind={}) requires 'main' field", tname, kind_s))); } - t.main = mit->second.as_string(); + t.mainDeclaredBy = std::format("[targets.{}] main", tname); + } + // `start` (design 2026-10-26 §24.4): the symbol the image is entered + // at. Whether this target's row lets an image choose it is a property + // of the target being built and is decided by the plan; a library is + // never entered, which is decidable here. + if (auto sit = tt.find("start"); sit != tt.end()) { + if (!sit->second.is_string()) + return std::unexpected(error(origin, std::format( + "targets.{}.start must be a string, the symbol the image is entered at", + tname))); + if (!t.is_program() && t.kind != Target::Entry) + return std::unexpected(error(origin, std::format( + "targets.{}.start names where an image is entered, and a library " + "target is not entered", tname))); + if (auto msg = start_symbol_problem(sit->second.as_string()); !msg.empty()) + return std::unexpected(error(origin, std::format( + "targets.{}.start: {}", tname, msg))); + t.start = sit->second.as_string(); + t.startDeclaredBy = std::format("[targets.{}] start = \"{}\"", tname, t.start); + } + // An entry target is a translation unit and not an artifact: the keys + // that describe an artifact (its ABI name, its exports, its form, its + // PE subsystem and manifest) belong to the program that selects it. + if (t.kind == Target::Entry) { + static constexpr std::string_view kArtifactKeys[] = { + "soname", "exports", "linkage", "windows_auto_export", + "windows_subsystem", "windows_code_page", "windows_long_paths", + }; + for (auto key : kArtifactKeys) + if (tt.contains(std::string(key))) + return std::unexpected(error(origin, std::format( + "targets.{}.{}: a `kind = \"entry\"` target is the beginning of " + "another program and produces no artifact of its own; state `{}` " + "on the program that selects it", tname, key, key))); } if (auto sit = tt.find("soname"); sit != tt.end()) { if (!sit->second.is_string()) { @@ -2121,7 +2180,10 @@ std::expected parse_document(const t::Document& documen // never meets `application_form`'s SharedObject form in practice) -- // `is_program()` is "is this the program", which is what the PE // subsystem attaches to; `TestBinary` stays refused on purpose. - if ((!t.windowsSubsystem.empty() || !t.windowsEntry.empty() + // An entry target states the entry FUNCTION its translation unit + // defines, which the program selecting it inherits (§24.4 item 5). + if ((!t.windowsSubsystem.empty() + || (!t.windowsEntry.empty() && t.kind != Target::Entry) || !t.windowsCodePage.empty() || t.windowsLongPaths) && !t.is_program()) return std::unexpected(error(origin, std::format( @@ -2131,6 +2193,18 @@ std::expected parse_document(const t::Document& documen : !t.windowsEntry.empty() ? "windows_entry" : !t.windowsCodePage.empty() ? "windows_code_page" : "windows_long_paths"))); + // A target's `cflags`, `cxxflags` and `defines` are scoped to its own + // entry translation unit; with `from` that unit is the dependency's, + // compiled in the dependency's context (§24.4 item 4). + if (!t.mainFrom.empty() + && (!t.cflags.empty() || !t.cxxflags.empty() || !t.defines.empty())) + return std::unexpected(error(origin, std::format( + "targets.{}.{} applies to the target's own entry translation unit, and " + "`main = {{ from = \"{}\" }}` makes that unit the dependency's, compiled " + "with its flags. State the flags on the entry target, or on this package " + "in [build] for the code that is this package's", + tname, !t.cflags.empty() ? "cflags" : !t.cxxflags.empty() ? "cxxflags" : "defines", + t.mainFrom))); // Guard: -std=... belongs to [package].standard, not per-target flags // (same rule as [build].cxxflags). Reject early with a clear message. for (auto const& flag : t.cxxflags) { @@ -2150,6 +2224,7 @@ std::expected parse_document(const t::Document& documen "kind", "linkage", "main", "soname", "exports", "windows_auto_export", "cflags", "cxxflags", "defines", "required_features", "windows_entry", "windows_subsystem", "windows_code_page", "windows_long_paths", + "start", }; for (auto& [key, _] : tt) { bool known = false; @@ -3442,10 +3517,38 @@ std::expected parse_document(const t::Document& documen m.testWindowsLongPaths = value.as_bool(); continue; } + // Design 2026-10-26 §24.4 item 6: the beginning every discovered + // test program takes from a dependency's entry target, and the + // symbol each test image is entered at. Each test file still has a + // name of its own, so `main` here is only ever `{ from }`. + if (key == "main") { + const bool accepted = value.is_table() && value.as_table().size() == 1 + && value.as_table().contains("from") + && value.as_table().at("from").is_string(); + if (!accepted) + return std::unexpected(error(origin, + "[test] main is `{ from = \"/\" }`: the entry " + "target of a dependency that every discovered test program begins " + "in, each test file then being an ordinary source of its program")); + const std::string from = value.as_table().at("from").as_string(); + if (auto msg = main_from_problem(from); !msg.empty()) + return std::unexpected(error(origin, "[test] main: " + msg)); + m.testMainFrom = from; + continue; + } + if (key == "start") { + if (!value.is_string()) + return std::unexpected(error(origin, + "[test] start must be a string, the symbol each test image is entered at")); + if (auto msg = start_symbol_problem(value.as_string()); !msg.empty()) + return std::unexpected(error(origin, "[test] start: " + msg)); + m.testStart = value.as_string(); + continue; + } if (key != "discover") { m.schemaWarnings.push_back(std::format( "[test] has unsupported key '{}' (ignored). Supported keys: " - "discover, windows_code_page, windows_long_paths.", key)); + "discover, main, start, windows_code_page, windows_long_paths.", key)); continue; } bool ok = value.is_array(); @@ -4042,12 +4145,46 @@ std::expected parse_document(const t::Document& documen "`kind = \"shared\"`", header))); auto& row = tval.as_table(); for (auto& [rk, rv] : row) - if (rk != "kind" && rk != "linkage") + if (rk != "kind" && rk != "linkage" && rk != "main" && rk != "start") m.schemaWarnings.push_back(std::format( "{} has unsupported key '{}' (ignored). A row " - "states only `kind` or `linkage`; every other key " + "states a library's form (`kind` or `linkage`) or a " + "program's beginning (`main`, `start`); every other key " "of a target is declared once, under [targets.{}].", header, rk, tname)); + RowTargetKind rowTarget; + // A program's or an entry's beginning on this row (design + // 2026-10-26 §24.3): the file, or `{ from }`, and the symbol + // the image is entered at. Whether the name is such a + // target is checked in `load`. + if (auto mit = row.find("main"); mit != row.end()) { + if (mit->second.is_string()) { + rowTarget.main = mit->second.as_string(); + } else if (mit->second.is_table() && mit->second.as_table().size() == 1 + && mit->second.as_table().contains("from") + && mit->second.as_table().at("from").is_string()) { + const std::string from = mit->second.as_table().at("from").as_string(); + if (auto msg = main_from_problem(from); !msg.empty()) + return std::unexpected(error(origin, std::format( + "{} main: {}", header, msg))); + rowTarget.mainFrom = from; + } else { + return std::unexpected(error(origin, std::format( + "{} main is a file, or `{{ from = \"/\" }}`", + header))); + } + } + if (auto sit = row.find("start"); sit != row.end()) { + if (!sit->second.is_string()) + return std::unexpected(error(origin, std::format( + "{} start must be a string, the symbol the image is entered at", + header))); + if (auto msg = start_symbol_problem(sit->second.as_string()); !msg.empty()) + return std::unexpected(error(origin, std::format( + "{} start: {}", header, msg))); + rowTarget.start = sit->second.as_string(); + } + rowTarget.beginningStatement = header; auto kit = row.find("kind"); auto lit = row.find("linkage"); // `linkage` (#642 E1): the row's DEFAULT form. A row states @@ -4072,30 +4209,39 @@ std::expected parse_document(const t::Document& documen header, lit->second.as_string(), Target::Library); !msg.empty()) return std::unexpected(error(origin, msg)); - RowTargetKind rowDefault; - rowDefault.linkage = lit->second.as_string(); - rowDefault.statement = std::format( - "{} linkage = \"{}\"", header, rowDefault.linkage); - cc.targetKinds[std::string(tname)] = std::move(rowDefault); - continue; - } - if (kit == row.end() || !kit->second.is_string()) + rowTarget.statesForm = true; + rowTarget.linkage = lit->second.as_string(); + rowTarget.statement = std::format( + "{} linkage = \"{}\"", header, rowTarget.linkage); + } else if (kit != row.end()) { + if (!kit->second.is_string()) + return std::unexpected(error(origin, std::format( + "{} must set `kind = \"lib\"`, `kind = \"shared\"` or " + "`linkage = \"static\" | \"shared\"`", header))); + const auto& kindText = kit->second.as_string(); + if (kindText == "lib" || kindText == "library") + rowTarget.kind = Target::Library; + else if (kindText == "shared" || kindText == "dylib" + || kindText == "so" || kindText == "shlib") + rowTarget.kind = Target::SharedLibrary; + else + return std::unexpected(error(origin, std::format( + "{} kind = \"{}\": a row chooses between the library " + "forms only, `lib` and `shared`", header, kindText))); + rowTarget.statesForm = true; + rowTarget.statement = std::format("{} kind = \"{}\"", header, kindText); + } else if (!rowTarget.main && !rowTarget.mainFrom && !rowTarget.start) { return std::unexpected(error(origin, std::format( - "{} must set `kind = \"lib\"`, `kind = \"shared\"` or " - "`linkage = \"static\" | \"shared\"`", header))); - const auto& kindText = kit->second.as_string(); - RowTargetKind rowKind; - if (kindText == "lib" || kindText == "library") - rowKind.kind = Target::Library; - else if (kindText == "shared" || kindText == "dylib" - || kindText == "so" || kindText == "shlib") - rowKind.kind = Target::SharedLibrary; - else + "{} must set `kind = \"lib\" | \"shared\"` or " + "`linkage = \"static\" | \"shared\"` for a library, or " + "`main` or `start` for a program or an entry", header))); + } + if (rowTarget.statesForm && (rowTarget.main || rowTarget.mainFrom || rowTarget.start)) return std::unexpected(error(origin, std::format( - "{} kind = \"{}\": a row chooses between the library " - "forms only, `lib` and `shared`", header, kindText))); - rowKind.statement = std::format("{} kind = \"{}\"", header, kindText); - cc.targetKinds[std::string(tname)] = std::move(rowKind); + "{} states a library's form and a program's beginning; a " + "target is one or the other", header))); + if (rowTarget.statement.empty()) rowTarget.statement = header; + cc.targetKinds[std::string(tname)] = std::move(rowTarget); } } // `[target.] requires_abi = { ... }` -- design 2026-09-12 @@ -4976,8 +5122,11 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { } } - // Auto-target inference (only when no [targets] declared). - if (m.targets.empty()) { + // Auto-target inference (only when no [targets] declared). An entry + // target (design 2026-10-26 §24.3) is the beginning of another package's + // program and no artifact of this one, so a package that declares only + // entries still has the library or program its sources infer. + if (std::ranges::all_of(m.targets, [](const Target& t) { return t.kind == Target::Entry; })) { std::error_code ec; auto mainCpp = root / "src" / "main.cpp"; bool hasMain = std::filesystem::exists(mainCpp, ec); @@ -5168,6 +5317,34 @@ std::expected load(const std::filesystem::path& path, row.statement, names.empty() ? std::string("none") : names), path, 0, 0}); } + // A row states a library's form, or a program's or an entry's + // beginning (design 2026-10-26 §24.3), and each only of the + // target it describes. + if (!row.statesForm) { + // Every file a target names as `main` on any row is a + // beginning; recorded on the target, whichever row is built. + if (row.main) + for (auto& t : m->targets) + if (t.name == name) t.otherMains.push_back(*row.main); + if (!target->is_program() && target->kind != Target::Entry) + return std::unexpected(ManifestError{std::format( + "{}: '{}' is a library target, and `main` and `start` describe " + "the beginning of a program or an entry", + row.beginningStatement, name), path, 0, 0}); + if (row.mainFrom && target->kind == Target::Entry) + return std::unexpected(ManifestError{std::format( + "{}: '{}' is an entry target, which is itself the translation " + "unit; its `main` names a file", row.beginningStatement, name), + path, 0, 0}); + if (row.mainFrom && (!target->cflags.empty() || !target->cxxflags.empty() + || !target->defines.empty())) + return std::unexpected(ManifestError{std::format( + "{}: `main = {{ from = \"{}\" }}` makes the entry translation unit " + "the dependency's, and [targets.{}] states flags for the target's " + "own entry", row.beginningStatement, *row.mainFrom, name), + path, 0, 0}); + continue; + } if (target->kind != Target::Library && target->kind != Target::SharedLibrary) return std::unexpected(ManifestError{row.linkage.empty() diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 05c75d62..11bba26b 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -149,6 +149,31 @@ inline std::string windows_choice_problem(bool subsystem, std::string_view value return accepted ? std::string{} : list; } +// `main = { from = "/" }` and `start = ""` (design +// 2026-10-26 §24.3), checked once for `[targets.]`, its rows and `[test]`. +// Each returns an empty string when the value is accepted, and otherwise the +// reason, which the caller prefixes with the statement. +inline std::string main_from_problem(std::string_view value) { + const auto slash = value.rfind('/'); + if (slash == std::string_view::npos || slash == 0 || slash + 1 == value.size()) + return std::format("`from = \"{}\"` names an entry as \"/\": " + "a key of this manifest's dependency tables (or this package's " + "own name), a slash, and the name of a `kind = \"entry\"` target " + "of that package", value); + return {}; +} +inline std::string start_symbol_problem(std::string_view value) { + auto symbolChar = [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_' || c == '.' || c == '$'; + }; + if (value.empty() || (value[0] >= '0' && value[0] <= '9') + || !std::ranges::all_of(value, symbolChar)) + return std::format("`start = \"{}\"` is not a symbol name: letters, digits, `_`, " + "`.` and `$`, not beginning with a digit", value); + return {}; +} + // The accepted values of `windows_code_page` (#693), which are the vocabulary of // the `activeCodePage` element of a Windows application manifest. `utf-8` embeds // a manifest that makes the process ANSI code page UTF-8 on Windows 10 1903 and @@ -169,8 +194,41 @@ struct Target { // Android application has. See `toolchain::triple::application_form`, // which is the one function that answers this — the manifest carries no // predicate for it. - enum Kind { Library, Binary, SharedLibrary, TestBinary, Application } kind; - std::string main; // for binary / test / app + // `Entry` (`kind = "entry"`, design 2026-10-26 §24): a translation unit + // that is only ever the beginning of ANOTHER program -- a dependency's, + // selected with `main = { from = "/" }`. It is in no + // product of its own package: its `main` is excluded from every image the + // way every other target's entry is, and linked only into a program that + // selects it. + enum Kind { Library, Binary, SharedLibrary, TestBinary, Application, Entry } kind; + // The translation unit the program's code begins in (§24.3): the file + // that defines `main` under a C runtime, or the symbol `start` names in an + // image with none. Empty when `mainFrom` supplies it. + std::string main; // for binary / test / app / entry + // `main = { from = "/" }`: the beginning is the + // `kind = "entry"` target `` of `` (a key of this + // manifest's dependency tables, or this package's own name), compiled in + // that package's context and linked into this program. + std::string mainFrom; + // `start = ""` (§24.4 item 7): the symbol the image is entered at + // (ELF `e_entry`), stated only for an image with no C runtime -- a + // freestanding target. Empty = the linker script's `ENTRY()` or the + // linker's default. `startDeclaredBy` is the statement, for the refusal + // that names two statements which disagree. + std::string start; + std::string startDeclaredBy; + // Where `main` was stated, for diagnostics: `[targets.app] main`, or a + // row's header. + std::string mainDeclaredBy; + // The files this target names as `main` on OTHER rows (and the one a + // matching row replaced). Each is a beginning, never an ordinary source + // of an image, whichever row is built (design 2026-10-26 §24.3). + std::vector otherMains; + // Of an entry target: the `required_features` that are not active in + // this build, set by the feature pass. An entry is not removed for them + // as an artifact target is: a program that selects it is refused, naming + // the features (design 2026-10-26 §24.4 item 3). + std::vector inactiveFeatures; // Whether this target is a PROGRAM -- something `mcpp run` can be asked // for and `mcpp pack` treats as the application pipeline's subject -- @@ -271,6 +329,14 @@ struct RowTargetKind { Target::Kind kind = Target::Library; std::string linkage; std::string statement; + // Whether the row states the library's form (`kind` or `linkage`) at all. + // A row may instead, or also, state a program's or an entry's beginning + // (design 2026-10-26 §24.3): `main` (a file or `{ from }`) and `start`. + bool statesForm = false; + std::optional main; + std::optional mainFrom; + std::optional start; + std::string beginningStatement; // the row's header, for diagnostics }; // Why a `linkage` statement cannot stand, or an empty string when it can. @@ -1289,6 +1355,9 @@ struct LinkIntent { std::vector frameworks; std::vector deployFiles; std::vector deploy; // `runtime.deploy` (#615) + // `mcpp:link-start=` (build-program protocol 16, design 2026-10-26 + // §24.3): the symbol every image linked over this package is entered at. + std::string start; }; // `[runtime]` — requirements needed when linking/launching built binaries. @@ -2099,6 +2168,13 @@ struct Manifest { std::string testWindowsCodePage; // `[test] windows_long_paths`: the same for every discovered test program. std::optional testWindowsLongPaths; + // `[test] main = { from = "/" }` and `[test] start` + // (design 2026-10-26 §24.4 item 6): the beginning every discovered test + // program takes from a dependency's entry target, the test file then + // being an ordinary translation unit of it, and the symbol each test + // image is entered at. + std::string testMainFrom; + std::string testStart; // Set once a workspace root's own package has received `[workspace.package]` // and `[workspace.build]` (`mcpp::project::inherit_as_root_package`). The // manifest is read on two paths before it is planned, and an appended diff --git a/src/build/engine_package.cppm b/src/build/engine_package.cppm index 9d4b10a1..012189f7 100644 --- a/src/build/engine_package.cppm +++ b/src/build/engine_package.cppm @@ -15,11 +15,15 @@ // is stated by `mcpp env` (`engineModules`) and by the build database's // `mcpp:core` collection (SPEC-005). // -// THE DIRECTORY IS IMMUTABLE once written, like a payload in the package -// store: its content is a function of the mcpp version alone. The dependency -// cache therefore keeps its compiled entry globally, under the address it has -// always had (`pkg/_engine/mcpp-build-module@`), and `mcpp cache` -// lists, verifies and collects it as before. +// THE DIRECTORY IS A FUNCTION OF ITS TEXT. A released mcpp writes one text +// per version; a build of mcpp from source between releases carries the same +// version and may carry another text (a directive added to the protocol), and +// a compiled entry kept by version alone then served the previous interface: +// measured, `'link_start' is not a member of 'mcpp'` from a build program +// compiled against a cached interface of the same version. The dependency +// cache keeps the compiled entry globally under `cache_version()`, the version +// and a digest of the text (`pkg/_engine/mcpp-build-module@+`), +// and `mcpp cache` lists, verifies and collects it as before. export module mcpp.build.engine_package; @@ -38,6 +42,10 @@ inline constexpr std::string_view kName = "core"; inline constexpr std::string_view kCacheIndex = "_engine"; inline constexpr std::string_view kCachePackage = "mcpp-build-module"; +// The version under which the dependency cache keeps the compiled interface: +// this mcpp's version and a digest of the text it writes. +std::string cache_version(); + // `$MCPP_HOME/engine`: every version's package lives below it. std::filesystem::path root(); // The package directory of this mcpp version. @@ -129,6 +137,14 @@ std::pair module_texts() { return {std::move(mod), std::move(alias)}; } +std::string cache_version() { + const auto [mod, alias] = module_texts(); + std::uint64_t h = 1469598103934665603ull; // FNV-1a, 64 bits + for (auto const* text : {&mod, &alias}) + for (unsigned char c : *text) { h ^= c; h *= 1099511628211ull; } + return std::format("{}+{:08x}", mcpp::MCPP_VERSION, static_cast(h ^ (h >> 32))); +} + std::expected ensure_package() { const auto dir = package_dir(); const auto [mod, alias] = module_texts(); diff --git a/src/build/execute.cppm b/src/build/execute.cppm index afe0fcaa..79f544b9 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1257,16 +1257,21 @@ RunnerChoice choose_runner(const BuildContext& ctx) { // size line that replaced the outcome would be a different kind of message. // Silent on every hosted target and whenever the tool is absent — an // informational line has no standing to fail a build. +// +// The `Entry` line beside it (design 2026-10-26 §24.4 item 10) says where an +// image is entered and who said so, for an image whose entry was stated. void report_freestanding_size(const BuildContext& ctx) { auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); if (!ft || !ft->is_freestanding()) return; auto tool = mcpp::freestanding::resolve_size_tool(ctx.tc.binaryPath); - if (tool.empty()) return; for (auto const& lu : ctx.plan.linkUnits) { if (lu.kind != mcpp::build::LinkUnit::Binary) continue; auto art = ctx.outputDir / lu.output; std::error_code ec; if (!std::filesystem::exists(art, ec)) continue; + if (!lu.start.empty()) + mcpp::ui::info("Entry", std::format("{} {} ({})", lu.targetName, lu.start, lu.startFrom)); + if (tool.empty()) continue; // An argument vector rather than a `2>/dev/null` command string, // which cmd.exe cannot open on a Windows host. auto out = mcpp::platform::process::capture_stdout( @@ -1311,7 +1316,9 @@ std::vector result_artifacts(const BuildContext& case LinkUnit::StaticLibrary: kind = "static"; break; default: continue; } - res::Artifact a{lu.targetName, kind, ctx.outputDir / lu.output, std::nullopt}; + res::Artifact a{lu.targetName, kind, ctx.outputDir / lu.output, std::nullopt, std::nullopt}; + if (!lu.entryFrom.empty() || !lu.start.empty()) + a.entry = res::Entry{lu.entryFrom, lu.start, lu.startFrom}; if (lu.kind == LinkUnit::Binary && lu.artifactOf.empty()) { res::Launch l; l.cwd = ctx.projectRoot.generic_string(); @@ -1872,6 +1879,10 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, // silently remove the advice along with them. if (auto advice = mcpp::build::link_failure_advice(out); !advice.empty()) mcpp::ui::block(advice); + // Design 2026-10-26 §24.4 item 10, the fast-path form: no plan, so the + // note names where a `start` may come from rather than where it came. + if (auto advice = mcpp::build::start_failure_advice(out, {}); !advice.empty()) + mcpp::ui::block(advice); // mcpp#662, the fast-path form: no `BuildPlan` here (the whole point // of this path is skipping `prepare`), so whether the C library is the // graph's, and its name, come from the file the plan wrote beside diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 1e197f12..18509cc3 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -186,6 +186,11 @@ inline void link_script(const char* path) { std::printf("mcpp:link-scrip // `--exclude-libs`. Reaches the consumer's link line, as `[build] ldflags` // already does -- see the table row for why a private form is not offered. inline void link_flag(const char* flag) { std::printf("mcpp:link-flag=%s\n", flag); } +// The symbol every image linked over this package is entered at (protocol +// 16): what a board's startup code defines. Reaches the consumer's link, as +// `link_script` does, and is checked there against the symbol the program or +// its entry states; an image is entered at one symbol. +inline void link_start(const char* symbol) { std::printf("mcpp:link-start=%s\n", symbol); } // The PE subsystem ("console" | "windows") and the entry function ("main" | // "wmain" | "WinMain" | "wWinMain") of an executable target of THIS package, // named by `target` (#618). The same fields as `[targets.] diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 037e79ac..095d51f1 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -136,6 +136,17 @@ std::vector windows_executable_link_flags(const BuildPlan& plan, bo std::string_view subsystem, std::string_view entry); +// The link flags an image's `start` symbol renders to (design 2026-10-26 +// §24.4 item 9): the ELF entry, and a definition of `__mcpp_start` AS that +// symbol, which turns the linkers' "cannot find entry symbol" warning -- an +// image that links and begins at address 0 -- into an error ("symbol not +// found"). ld.lld, the linker of every freestanding row, has no +// `--require-defined`; `--defsym` with an undefined operand is an error in +// both lld and GNU ld, and costs one absolute alias in the symbol table. +// `direct` is a link driven by `ld.lld` itself rather than by the compiler +// driver. Empty for an empty symbol. Exported so each spelling is a unit test. +std::vector start_link_flags(std::string_view start, bool direct); + // Emitter self-check: every ninja rule's command must begin with a program. // // Exported so the invariant can be stated against hand-written manifests as @@ -160,6 +171,15 @@ std::optional check_rule_commands_name_a_program( // referenced from `__libcpp_allocate` in `__new/allocate.h`. std::string link_failure_advice(std::string_view output); +// Design 2026-10-26 §24.4 item 10: the note for a link that failed because an +// image's entry symbol is defined by nothing it links (`start_link_flags`'s +// `--defsym` operand). `statements` are the plan's link units' symbols and +// the statements that named them; empty on the fast path, which then names +// the three places a `start` may come from instead of the one it came from. +std::string start_failure_advice( + std::string_view output, + const std::vector>& statements); + // The C library said in its manifest which facilities it does not supply, and // the link has just named one of them (design 2026-09-20 §5.7.5). Both // linkers' spellings, as `link_failure_advice` above. @@ -898,6 +918,46 @@ std::vector windows_executable_link_flags(const BuildPlan& plan, bo return out; } +std::vector start_link_flags(std::string_view start, bool direct) { + if (start.empty()) return {}; + if (direct) + return { "-e", std::string(start), std::format("--defsym=__mcpp_start={}", start) }; + return { std::format("-Wl,-e,{}", start), std::format("-Wl,--defsym=__mcpp_start={}", start) }; +} + +std::string start_failure_advice( + std::string_view output, + const std::vector>& statements) { + // lld: "--defsym:1: symbol not found: "; GNU ld: "--defsym:1: + // undefined symbol `' referenced in expression". + auto missing = [&](std::string_view symbol) { + return output.find(std::format("symbol not found: {}", symbol)) != std::string_view::npos + || output.find(std::format("undefined symbol `{}' referenced in expression", symbol)) + != std::string_view::npos; + }; + std::string out; + std::set said; + for (auto const& [symbol, statement] : statements) + if (missing(symbol) && said.insert(symbol).second) + out += std::format( + "\nnote: the image is entered at `{}`, which nothing this program links defines.\n" + " It was named by {}. Define it (the startup code that\n" + " receives control), or state the symbol the startup code defines.\n", + symbol, statement); + if (!out.empty() || !statements.empty()) return out; + constexpr std::string_view kMarker = "symbol not found: "; + if (auto at = output.find(kMarker); at != std::string_view::npos + && output.find("--defsym") != std::string_view::npos) { + auto symbol = output.substr(at + kMarker.size()); + symbol = symbol.substr(0, symbol.find_first_of("\r\n ")); + out += std::format( + "\nnote: the image is entered at `{}`, which nothing this program links defines.\n" + " It is named by the program's `start`, the `start` of the entry it\n" + " begins with, or a package's `mcpp:link-start`.\n", symbol); + } + return out; +} + std::string link_failure_advice(std::string_view output) { // Both linkers, both spellings. lld says "undefined symbol: X"; GNU ld says // "undefined reference to `X'". Matched on the operator's own name rather @@ -3530,6 +3590,11 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, if (lu.kind == LinkUnit::Binary) tail.dependencies += join_link_flags(windows_executable_link_flags( plan, sepLinker, lu.windowsSubsystem, lu.windowsEntry)); + // Design 2026-10-26 §24.4 item 9: the image's entry symbol, which + // the plan admits only on a freestanding target (an ELF row). + if (!lu.start.empty()) + tail.dependencies += join_link_flags( + start_link_flags(lu.start, !flags.ldDriver.empty())); // mcpp#426: a link unit with no C++ in it takes only the part of // the contract that is not a statement about the C++ runtime. // Swapping the driver is not sufficient by itself — this slot names @@ -4995,6 +5060,12 @@ std::expected NinjaBackend::build(const BuildPlan& plan // build.ninja happened to be up to date — which is exactly the kind of // "same decision in two places" this codebase keeps paying for. diagnostics += link_failure_advice(out); + { + std::vector> starts; + for (auto const& lu : plan.linkUnits) + if (!lu.start.empty()) starts.emplace_back(lu.start, lu.startFrom); + diagnostics += start_failure_advice(out, starts); + } // mcpp#662: named precisely here, where `plan.targetSide` is resolved // — the fast path (execute.cppm) calls the same function with no name // and gets the degraded-but-still-correct form. diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 65223c91..1488e1a4 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -200,6 +200,14 @@ struct LinkUnit { // it; see `windows_executable_link_flags`. Empty on every non-Binary unit. std::string windowsSubsystem; std::string windowsEntry; + // Design 2026-10-26 §24: where the program's beginning comes from, as + // `/` when a dependency's entry target supplies it (empty + // for the target's own `main`), and the symbol the image is entered at + // with the statement that put it there (empty = the linker script's + // `ENTRY()` or the linker's default). The backend renders `start`. + std::string entryFrom; + std::string start; + std::string startFrom; }; // One Windows resource script compiled into one linkable resource artifact @@ -2013,7 +2021,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // compiled as an ordinary executable entry on every row but Android, // and as a translation unit of the shared library there -- so it // occupies an object address in this census exactly as `bin` does. - if (!t.is_program() + if (!t.is_program() && t.kind != mcpp::manifest::Target::Entry && t.kind != mcpp::manifest::Target::TestBinary) continue; auto entry = projectRoot / t.main; if (scannedSources.contains(entry)) continue; @@ -2340,17 +2348,21 @@ make_plan(const mcpp::manifest::Manifest& manifest, // exclusive to its target — when assembling another target's link // image we must NOT pull in foreign entries (they each define // `int main(...)`, causing multiple-definition link errors). + // A file a target names as `main` on another row is a beginning too + // (design 2026-10-26 §24.3): it is in no image of this row. std::set entryFilesAcrossTargets; for (auto& t : manifest.targets) { if (!t.main.empty()) { entryFilesAcrossTargets.insert(projectRoot / t.main); } + for (auto const& other : t.otherMains) entryFilesAcrossTargets.insert(projectRoot / other); } for (auto const& p : packages) { for (auto const& t : p.manifest.targets) { if (!t.main.empty()) { entryFilesAcrossTargets.insert(p.root / t.main); } + for (auto const& other : t.otherMains) entryFilesAcrossTargets.insert(p.root / other); } } @@ -2829,6 +2841,248 @@ make_plan(const mcpp::manifest::Manifest& manifest, artifactOnlyPackages.insert(qualified_package_name(packages[i].manifest)); } + // ── Program beginnings and image entries (design 2026-10-26 §24) ────── + // + // ONE PLACE COMPILES A BEGINNING THAT BELONGS TO ANOTHER PACKAGE. A + // program's beginning may be a dependency's `kind = "entry"` target; its + // translation unit is compiled in THAT package's context -- its include + // directories, its module interfaces, its flags and the entry target's own + // `cflags`/`cxxflags`/`defines` -- once per configuration, and linked + // into every program that selects it. It is in `entryFilesAcrossTargets` + // (its target has a `main`), so no other image links it. + std::set entryFlagsApplied; + auto entry_object = [&](std::size_t pkgIdx, const std::filesystem::path& entry, + const mcpp::manifest::Target& flagsOf) -> std::filesystem::path { + auto const& pkgManifest = pkgIdx == 0 ? manifest : packages[pkgIdx].manifest; + const auto pkgRoot = pkgIdx == 0 ? projectRoot : packages[pkgIdx].root; + const auto owner = qualified_package_name(pkgManifest); + std::optional object; + for (auto const& cu : plan.compileUnits) + if (cu.source == entry) { object = cu.object; break; } + if (!object) { + const auto extTable = pkgIdx == 0 ? rootExtTable + : mcpp::extension_table_for(pkgManifest.buildConfig.moduleExtensions, + pkgManifest.buildConfig.deviceExtensions); + CompileUnit cu; + cu.source = entry; + cu.packageName = owner; + cu.kind = mcpp::classify(entry, extTable); + if (pkgIdx < packages.size() && packages[pkgIdx].usageResolved) { + cu.localIncludeDirs = packages[pkgIdx].privateBuild.includeDirs; + cu.localIncludeDirsAfter = packages[pkgIdx].privateBuild.includeDirsAfter; + cu.packageCflags = packages[pkgIdx].privateBuild.cflags; + cu.packageCxxflags = packages[pkgIdx].privateBuild.cxxflags; + } else { + cu.localIncludeDirs = local_include_dirs_for_manifest(pkgRoot, pkgManifest); + cu.localIncludeDirsAfter = local_include_dirs_after_for_manifest(pkgRoot, pkgManifest); + cu.packageCflags = pkgManifest.buildConfig.cflags; + cu.packageCxxflags = pkgManifest.buildConfig.cxxflags; + } + mcpp::modgraph::normalize_include_flags(pkgRoot, cu.packageCflags); + mcpp::modgraph::normalize_include_flags(pkgRoot, cu.packageCxxflags); + apply_c_standard(cu); + const auto scanned = mcpp::modgraph::scan_entry_file(entry, owner, extTable); + for (auto const& req : scanned.requires_) cu.imports.push_back(req.logicalName); + cu.declaration = scanned.provides ? mcpp::modgraph::ModuleDeclaration::Unknown + : scanned.declaration; + cu.object = object_for(entry, owner, std::filesystem::relative(entry, pkgRoot), + pkgIdx).object; + plan.compileUnits.push_back(cu); + object = cu.object; + } + if (entryFlagsApplied.insert(entry).second + && (!flagsOf.defines.empty() || !flagsOf.cflags.empty() || !flagsOf.cxxflags.empty())) { + for (auto& cu : plan.compileUnits) { + if (cu.source != entry) continue; + for (auto const& d : flagsOf.defines) { + const auto element = mcpp::manifest::flag_element("-D" + d); + cu.packageCflags.push_back(element); + cu.packageCxxflags.push_back(element); + } + for (auto const& f : flagsOf.cflags) cu.packageCflags.push_back(f); + for (auto const& f : flagsOf.cxxflags) cu.packageCxxflags.push_back(f); + break; + } + } + return *object; + }; + + // An entry's translation unit carries its target's flags wherever the + // package's sources compile it, selected by a program or not: it is + // checked by every build of its package, with the flags it is built with. + { + auto with_flags = [&](const std::filesystem::path& root, const mcpp::manifest::Target& t) { + if (t.kind != mcpp::manifest::Target::Entry || t.main.empty()) return; + if (t.defines.empty() && t.cflags.empty() && t.cxxflags.empty()) return; + const auto entry = root / t.main; + if (!std::ranges::any_of(plan.compileUnits, + [&](const CompileUnit& cu) { return cu.source == entry; })) + return; + if (!entryFlagsApplied.insert(entry).second) return; + for (auto& cu : plan.compileUnits) { + if (cu.source != entry) continue; + for (auto const& d : t.defines) { + const auto element = mcpp::manifest::flag_element("-D" + d); + cu.packageCflags.push_back(element); + cu.packageCxxflags.push_back(element); + } + for (auto const& f : t.cflags) cu.packageCflags.push_back(f); + for (auto const& f : t.cxxflags) cu.packageCxxflags.push_back(f); + break; + } + }; + for (auto const& t : manifest.targets) with_flags(projectRoot, t); + for (std::size_t i = 1; i < packages.size(); ++i) + for (auto const& t : packages[i].manifest.targets) with_flags(packages[i].root, t); + } + + // `main = { from = "/" }`: the dependency is a key of + // the consumer's own dependency tables (its dev-dependencies too, for a + // test), or the consumer's own name; the entry is a `kind = "entry"` + // target of that package whose required features are active. + struct EntrySelection { + std::size_t packageIndex = 0; + const mcpp::manifest::Target* target = nullptr; + std::string name; // `/`, as the program wrote it + }; + auto select_entry = [&](std::size_t consumerIdx, const mcpp::manifest::Manifest& consumer, + const mcpp::manifest::Target& t) + -> std::expected { + const auto slash = t.mainFrom.rfind('/'); + const std::string depKey = t.mainFrom.substr(0, slash); + const std::string entryName = t.mainFrom.substr(slash + 1); + const std::string statement = std::format("{} = {{ from = \"{}\" }}", + t.mainDeclaredBy.empty() ? std::format("[targets.{}] main", t.name) : t.mainDeclaredBy, + t.mainFrom); + std::optional idx; + if (depKey == consumer.package.name || depKey == qualified_package_name(consumer)) + idx = consumerIdx; + std::vector keys; + auto look = [&](const std::map& deps) { + for (auto const& [depName, spec] : deps) { + keys.push_back(depName); + if (idx || depName != depKey) continue; + for (auto const& candidate : dependency_name_candidates(depName, spec)) + if (auto it = packageIndexByName.find(candidate); it != packageIndexByName.end()) { + idx = it->second; + break; + } + } + }; + look(consumer.dependencies); + if (t.kind == mcpp::manifest::Target::TestBinary) look(consumer.devDependencies); + if (!idx) { + std::string list; + for (auto const& k : keys) list += (list.empty() ? "" : ", ") + k; + return std::unexpected(std::format( + "{}: '{}' is neither a dependency of '{}' nor its own name (its dependencies: {})", + statement, depKey, qualified_package_name(consumer), + list.empty() ? std::string("none") : list)); + } + auto const& provider = *idx == 0 ? manifest : packages[*idx].manifest; + const mcpp::manifest::Target* entry = nullptr; + std::string entries; + for (auto const& candidate : provider.targets) { + if (candidate.kind != mcpp::manifest::Target::Entry) continue; + entries += (entries.empty() ? "" : ", ") + candidate.name; + if (candidate.name == entryName) entry = &candidate; + } + if (!entry) + return std::unexpected(std::format( + "{}: '{}' declares no entry target '{}' (its `kind = \"entry\"` targets: {})", + statement, qualified_package_name(provider), entryName, + entries.empty() ? std::string("none") : entries)); + if (!entry->inactiveFeatures.empty()) { + std::string feats; + for (auto const& f : entry->inactiveFeatures) + feats += (feats.empty() ? "" : ", ") + std::format("\"{}\"", f); + return std::unexpected(std::format( + "{}: the entry '{}' of '{}' requires the feature{} {} of that package, which " + "{} not active in this build. Enable {} on the dependency:\n" + " {} = {{ ..., features = [{}] }}", + statement, entryName, qualified_package_name(provider), + entry->inactiveFeatures.size() == 1 ? "" : "s", feats, + entry->inactiveFeatures.size() == 1 ? "is" : "are", + entry->inactiveFeatures.size() == 1 ? "it" : "them", depKey, feats)); + } + return EntrySelection{ *idx, entry, t.mainFrom }; + }; + + // Apply a selected entry to a link unit: its object, the PE entry + // function its translation unit defines (inherited unless the program + // states another, which is refused), and where it came from. + auto begin_with_entry = [&](LinkUnit& lu, const mcpp::manifest::Target& t, + const EntrySelection& sel) -> std::expected { + auto const& providerRoot = sel.packageIndex == 0 ? projectRoot : packages[sel.packageIndex].root; + const auto entry = providerRoot / sel.target->main; + if (!sel.target->windowsEntry.empty()) { + if (!lu.windowsEntry.empty() && lu.windowsEntry != sel.target->windowsEntry) + return std::unexpected(std::format( + "[targets.{}] windows_entry = \"{}\", and its beginning '{}' defines " + "`{}` ([targets.{}] windows_entry of that package). The entry function is " + "the one the translation unit defines; remove the program's statement, or " + "select an entry that defines `{}`", + t.name, lu.windowsEntry, sel.name, sel.target->windowsEntry, + sel.target->name, lu.windowsEntry)); + lu.windowsEntry = sel.target->windowsEntry; + } + lu.entryFrom = sel.name; + return entry_object(sel.packageIndex, entry, *sel.target); + }; + + // THE SYMBOL AN IMAGE IS ENTERED AT, AND WHO SAID SO (§24.4 items 7-8). + // The program (or `[test]`), its selected entry and every package whose + // objects it links may state one; they must agree, and only an image with + // no C runtime -- a freestanding target -- has one to state. + auto settle_start = [&](LinkUnit& lu, const mcpp::manifest::Target& t, + const EntrySelection* sel, + const std::vector& linkedPackages) + -> std::expected { + std::vector> statements; // symbol, statement + if (!t.start.empty()) + statements.emplace_back(t.start, t.startDeclaredBy.empty() + ? std::format("[targets.{}] start = \"{}\"", t.name, t.start) : t.startDeclaredBy); + if (sel && !sel->target->start.empty()) + statements.emplace_back(sel->target->start, std::format( + "[targets.{}] start = \"{}\" of '{}' (the entry this program begins with)", + sel->target->name, sel->target->start, + qualified_package_name(sel->packageIndex == 0 ? manifest + : packages[sel->packageIndex].manifest))); + for (auto i : linkedPackages) { + auto const& pm = i == 0 ? manifest : packages[i].manifest; + auto const& symbol = pm.runtimeConfig.linkIntent.start; + if (!symbol.empty()) + statements.emplace_back(symbol, std::format( + "`mcpp:link-start={}` from the build program of '{}'", + symbol, qualified_package_name(pm))); + } + if (statements.empty()) return {}; + for (auto const& [symbol, statement] : statements) + if (symbol != statements.front().first) { + std::string all; + for (auto const& [sym, st] : statements) all += "\n " + st; + return std::unexpected(std::format( + "target '{}' is entered at one symbol, and the statements disagree:{}", + t.name, all)); + } + if (!targetTriple.is_freestanding()) { + const std::string owner = targetTriple.is_openkal() + ? std::string("okrun, at `ok_main`, which the image's C library defines") + : targetTriple.is_android() + ? std::string("the Android runtime, through the exports the platform names") + : std::string("the C runtime (`_start`, `mainCRTStartup`), which initialises " + "the program and calls `main`; entering elsewhere skips it"); + return std::unexpected(std::format( + "{}: `start` names the symbol an image with no C runtime is entered at, and " + "on {} the image is entered by {}. It applies to a freestanding target " + "(`--target -none-elf`)", + statements.front().second, targetTriple.str(), owner)); + } + lu.start = statements.front().first; + lu.startFrom = statements.front().second; + return {}; + }; + // 4. Link units (one per [targets.X]) // When any TestBinary target exists, skip Binary/Library/SharedLibrary // targets — `mcpp test` only cares about the test binaries, and pulling @@ -2840,6 +3094,9 @@ make_plan(const mcpp::manifest::Manifest& manifest, } for (auto& t : manifest.targets) { if (inTestMode && t.kind != mcpp::manifest::Target::TestBinary) continue; + // An entry target is the beginning of other programs, linked into + // each that selects it, and no link unit of its own (§24.3). + if (t.kind == mcpp::manifest::Target::Entry) continue; LinkUnit lu; lu.targetName = t.name; lu.package = qualified_package_name(manifest); @@ -2903,12 +3160,23 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } + // The beginning a dependency's entry target supplies (§24.3). + std::optional selectedEntry; + if (!t.mainFrom.empty() && lu.kind != LinkUnit::StaticLibrary) { + auto sel = select_entry(0, manifest, t); + if (!sel) return std::unexpected(sel.error()); + selectedEntry = *sel; + } + // Whether this consumer's own entry source defines `main`. Decides how // kind="lib" dependencies are linked (archive vs inline) so the // gtest_main-style optional entry works on EVERY linker — see the // dependency-linking block further below. Can't tell (no entry) → // false → inline (the pre-archive behavior, always provides the entry). - bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain); + // A selected entry is the program's beginning, so a dev-dependency's + // own main-providing object is not linked beside it. + bool entryDefinesMain = selectedEntry.has_value() + || (lu.entryMain && source_defines_main(*lu.entryMain)); // #622 A3: a `SharedLibrary` unit only ever carries `entryMain` when // it is an `app` on the Android row (an ordinary `kind = "shared"` @@ -3009,6 +3277,34 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } + // A dependency's entry as this program's beginning: its object, and + // for a program with no file of its own, `entryMain` naming it, which + // keeps the implementation loop below from linking it twice. A test + // keeps its own file as `entryMain`; the entry joins beside it. + if (selectedEntry) { + auto object = begin_with_entry(lu, t, *selectedEntry); + if (!object) return std::unexpected(object.error()); + lu.objects.push_back(*object); + if (!lu.entryMain) { + auto const& providerRoot = selectedEntry->packageIndex == 0 + ? projectRoot : packages[selectedEntry->packageIndex].root; + lu.entryMain = providerRoot / selectedEntry->target->main; + } + } + if (lu.kind != LinkUnit::StaticLibrary) { + std::vector linked; + for (std::size_t i = 0; i < std::max(packages.size(), 1); ++i) { + const auto name = i == 0 ? qualified_package_name(manifest) + : qualified_package_name(packages[i].manifest); + if (i != 0 && (sharedDepPackages.contains(name) || placedInImage.contains(name) + || artifactOnlyPackages.contains(name))) + continue; + linked.push_back(i); + } + if (auto r = settle_start(lu, t, selectedEntry ? &*selectedEntry : nullptr, linked); !r) + return std::unexpected(r.error()); + } + // Also include implementation .cpp/.cc/.cxx/.c units, but EXCLUDE any // file registered as another target's entryMain (each binary's main() // is exclusive to that binary). @@ -3135,6 +3431,28 @@ make_plan(const mcpp::manifest::Manifest& manifest, } lu.objects.push_back(*entryObject); } + // The program's beginning from an entry target (§24.3), resolved + // against its own package's dependencies. + std::optional selectedEntry; + if (!r.target.mainFrom.empty()) { + auto sel = select_entry(r.packageIndex, pkg.manifest, r.target); + if (!sel) return std::unexpected(sel.error()); + selectedEntry = *sel; + auto object = begin_with_entry(lu, r.target, *selectedEntry); + if (!object) return std::unexpected(object.error()); + lu.objects.push_back(*object); + auto const& providerRoot = selectedEntry->packageIndex == 0 + ? projectRoot : packages[selectedEntry->packageIndex].root; + lu.entryMain = providerRoot / selectedEntry->target->main; + } + { + std::vector linked; + for (auto i : seen) + if (links_objects_of(qualified_package_name(packages[i].manifest), r.packageIndex)) + linked.push_back(i); + if (auto rs = settle_start(lu, r.target, selectedEntry ? &*selectedEntry : nullptr, linked); !rs) + return std::unexpected(rs.error()); + } for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; if (!links_objects_of(cu.packageName, r.packageIndex)) continue; @@ -3264,6 +3582,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, std::vector units; for (auto const& t : pkg.manifest.targets) { if (memberTestMode && t.kind != mcpp::manifest::Target::TestBinary) continue; + if (t.kind == mcpp::manifest::Target::Entry) continue; // §24.3 // A member's shared libraries are linked once, with the graph's // shared libraries (above), and placed in its product directory. if (t.kind == mcpp::manifest::Target::SharedLibrary) { @@ -3369,7 +3688,32 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } } - const bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain); + // A dependency's entry as this program's beginning (§24.3), as + // for a root's target. + std::optional selectedEntry; + if (!t.mainFrom.empty() && lu.kind != LinkUnit::StaticLibrary) { + auto sel = select_entry(mi, pkg.manifest, t); + if (!sel) return std::unexpected(sel.error()); + selectedEntry = *sel; + auto object = begin_with_entry(lu, t, *selectedEntry); + if (!object) return std::unexpected(object.error()); + lu.objects.push_back(*object); + if (!lu.entryMain) { + auto const& providerRoot = selectedEntry->packageIndex == 0 + ? projectRoot : packages[selectedEntry->packageIndex].root; + lu.entryMain = providerRoot / selectedEntry->target->main; + } + } + if (lu.kind != LinkUnit::StaticLibrary) { + std::vector linked; + for (auto i : closureIdx) + if (links_objects_of(qualified_package_name(packages[i].manifest), mi)) + linked.push_back(i); + if (auto r = settle_start(lu, t, selectedEntry ? &*selectedEntry : nullptr, linked); !r) + return std::unexpected(r.error()); + } + const bool entryDefinesMain = selectedEntry.has_value() + || (lu.entryMain && source_defines_main(*lu.entryMain)); for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; if (!links_objects_of(cu.packageName, mi)) continue; diff --git a/src/build/prepare/config.cpp b/src/build/prepare/config.cpp index c4705c3a..97cefe32 100644 --- a/src/build/prepare/config.cpp +++ b/src/build/prepare/config.cpp @@ -416,6 +416,23 @@ void merge_conditional_config(mcpp::manifest::Manifest& m, for (auto const& [name, row] : cc.targetKinds) { for (auto& t : m.targets) { if (t.name != name) continue; + // A program's or an entry's beginning on this row (design + // 2026-10-26 §24.3): a file replaces `{ from }` and the reverse, + // and `start` replaces the statement it follows. + if (!row.statesForm) { + if ((row.main || row.mainFrom) && !t.main.empty()) + t.otherMains.push_back(t.main); + if (row.main) { t.main = *row.main; t.mainFrom.clear(); } + if (row.mainFrom) { t.mainFrom = *row.mainFrom; t.main.clear(); } + if (row.main || row.mainFrom) + t.mainDeclaredBy = row.beginningStatement + " main"; + if (row.start) { + t.start = *row.start; + t.startDeclaredBy = std::format("{} start = \"{}\"", + row.beginningStatement, *row.start); + } + continue; + } t.kindDeclaredBy = row.statement; t.kindFromRow = true; if (!row.linkage.empty()) { diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 357ed8d9..280a54ec 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -477,8 +477,16 @@ static std::expected step6_activate_features(PrepareState& st // every consumer — and that is exactly a `shared` or `lib` target. // A selected workspace member builds every target, so every one // of its targets is gated, as a root's are (§15). + for (auto& t : pkg.manifest.targets) { + if (t.kind != mcpp::manifest::Target::Entry) continue; + t.inactiveFeatures.clear(); + for (auto const& rf : t.requiredFeatures) + if (std::find(active.begin(), active.end(), rf) == active.end()) + t.inactiveFeatures.push_back(rf); + } std::erase_if(pkg.manifest.targets, [&](const mcpp::manifest::Target& t) { + if (t.kind == mcpp::manifest::Target::Entry) return false; if (!pkg.selectedMember && t.kind != mcpp::manifest::Target::Library && t.kind != mcpp::manifest::Target::SharedLibrary) diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 3e2a881e..1c075ce4 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -1900,13 +1900,13 @@ step4b_finalize_dependency(PrepareState& state, WorklistItemCtx& ctx) { : std::string{}, }); // The engine interface (mcpp#797) is addressed in the dependency cache - // as it always was: by the engine's name and version, in the global - // cache, because its directory is immutable for that version. + // by the engine's name and the version of its text, in the global + // cache, because its directory is a function of that text. if (sourceKind == "path" && mcpp::build::engine::is_engine_package(ctx.dep_root)) { auto& ident = state.dep_cache_identities.back(); ident.indexName = std::string(mcpp::build::engine::kCacheIndex); ident.packageName = std::string(mcpp::build::engine::kCachePackage); - ident.version = state.dep_manifests.back()->package.version; + ident.version = mcpp::build::engine::cache_version(); ident.sourceKind = "version"; ident.sourceRef.clear(); } diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 7885708f..4cc6cfef 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -2381,7 +2381,16 @@ static std::expected step9_rerun_input_prepare_dir(PrepareSta // required features are active in this build; otherwise it is silently // skipped. A pure build-selection knob — it runs before the modgraph/plan // so gated-out targets cost nothing. + // An entry target is kept and its inactive features recorded: a program + // that selects it is refused naming them (design 2026-10-26 §24.4). + for (auto& t : state.m->targets) { + if (t.kind != mcpp::manifest::Target::Entry) continue; + t.inactiveFeatures.clear(); + for (auto const& rf : t.requiredFeatures) + if (!state.activeRootFeatures.contains(rf)) t.inactiveFeatures.push_back(rf); + } std::erase_if(state.m->targets, [&](const mcpp::manifest::Target& t) { + if (t.kind == mcpp::manifest::Target::Entry) return false; for (auto const& rf : t.requiredFeatures) if (!state.activeRootFeatures.contains(rf)) return true; return false; diff --git a/src/build/result.cppm b/src/build/result.cppm index a84b4f83..88797ab0 100644 --- a/src/build/result.cppm +++ b/src/build/result.cppm @@ -41,11 +41,22 @@ struct Launch { std::string via; // "" | "runner" | "run_under" }; +// Where a program begins and where its image is entered (design 2026-10-26 +// §24.4 item 10): `from` is `/` when a dependency's entry +// target is the beginning, `start` the stated entry symbol and `startFrom` +// the statement that named it. Absent from the record when all are empty. +struct Entry { + std::string from; + std::string start; + std::string startFrom; +}; + struct Artifact { std::string name; std::string kind; // bin | test | shared | static std::filesystem::path path; std::optional launch; + std::optional entry; }; // A compiler's diagnostic, from the output of a failed step. @@ -136,6 +147,15 @@ nlohmann::json to_json(const Record& r) { for (auto const& a : r.artifacts) { nlohmann::json j{{"name", a.name}, {"kind", a.kind}, {"path", a.path.generic_string()}}; if (a.launch) j["launch"] = to_json(*a.launch); + if (a.entry) { + nlohmann::json e = nlohmann::json::object(); + if (!a.entry->from.empty()) e["from"] = a.entry->from; + if (!a.entry->start.empty()) { + e["start"] = a.entry->start; + e["start_from"] = a.entry->startFrom; + } + j["entry"] = std::move(e); + } artifacts.push_back(std::move(j)); } nlohmann::json diags = nlohmann::json::array(); @@ -192,7 +212,12 @@ std::optional read(const std::filesystem::path& outputDir) { r.compileDatabase = d["compileDatabase"].get(); if (d.contains("artifacts") && d["artifacts"].is_array()) for (auto const& a : d["artifacts"]) { - Artifact art{a.value("name", ""), a.value("kind", ""), a.value("path", ""), std::nullopt}; + Artifact art{a.value("name", ""), a.value("kind", ""), a.value("path", ""), + std::nullopt, std::nullopt}; + if (a.contains("entry") && a["entry"].is_object()) { + const auto& e = a["entry"]; + art.entry = Entry{e.value("from", ""), e.value("start", ""), e.value("start_from", "")}; + } if (a.contains("launch") && a["launch"].is_object()) { const auto& l = a["launch"]; Launch launch; diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index f872780b..7b0b37c3 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -124,6 +124,15 @@ discover_test_targets(const std::filesystem::path& manifestRoot, if (packageManifest) { target.windowsCodePage = packageManifest->testWindowsCodePage; target.windowsLongPaths = packageManifest->testWindowsLongPaths; + // `[test] main = { from }` and `[test] start` (design 2026-10-26 + // §24.4 item 6): the test file stays `main`, the translation unit + // that is this test's own, and the beginning comes from the entry. + target.mainFrom = packageManifest->testMainFrom; + if (!packageManifest->testMainFrom.empty()) + target.mainDeclaredBy = "[test] main"; + target.start = packageManifest->testStart; + if (!target.start.empty()) + target.startDeclaredBy = std::format("[test] start = \"{}\"", target.start); } for (std::size_t i = 0; i < globFlags.size(); ++i) { if (!globHits[i].contains(file)) continue; diff --git a/src/pack/route.cppm b/src/pack/route.cppm index 6cb3810b..5c6fc72f 100644 --- a/src/pack/route.cppm +++ b/src/pack/route.cppm @@ -79,6 +79,7 @@ std::expected route_pack_target(std::string_view request case mcpp::manifest::Target::SharedLibrary: return "shared"; case mcpp::manifest::Target::TestBinary: return "test"; case mcpp::manifest::Target::Application: return "app"; + case mcpp::manifest::Target::Entry: return "entry"; } return "?"; }; @@ -90,6 +91,11 @@ std::expected route_pack_target(std::string_view request return std::unexpected(std::format( "target '{}' is a test binary; there is nothing to distribute", requested)); + if (t.kind == mcpp::manifest::Target::Entry) + return std::unexpected(std::format( + "target '{}' is an entry, the beginning of another package's " + "program; it is distributed in the program that selects it", + requested)); return PackRoute{ t.name, is_library(t), t.kind == mcpp::manifest::Target::Application }; } diff --git a/tests/e2e/858_the_engines_module_is_compiled_once_per_home.sh b/tests/e2e/858_the_engines_module_is_compiled_once_per_home.sh index a061d927..f6775a93 100755 --- a/tests/e2e/858_the_engines_module_is_compiled_once_per_home.sh +++ b/tests/e2e/858_the_engines_module_is_compiled_once_per_home.sh @@ -6,7 +6,7 @@ # #748 (B1), mcpp#797. The interface is the engine's own text, written as a # package under $MCPP_HOME/engine//modules, and the build programs' # graph depends on it like on any library. Its compiled entry is kept in the -# global cache under `_engine/mcpp-build-module@`, so `mcpp cache list`, +# global cache under `_engine/mcpp-build-module@+`, so `mcpp cache list`, # `info`, `verify` and `gc` treat it as they treat a dependency's entry. # # Criteria: diff --git a/tests/e2e/946_a_program_begins_in_a_dependency_entry.sh b/tests/e2e/946_a_program_begins_in_a_dependency_entry.sh new file mode 100755 index 00000000..db3329f0 --- /dev/null +++ b/tests/e2e/946_a_program_begins_in_a_dependency_entry.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# requires: gcc elf +# 946 -- a program's beginning is a dependency's `kind = "entry"` target +# (design 2026-10-26 §24, mcpp#845). +# +# A framework supplies `main`; the application writes only the callback the +# framework declares. Before, `main` named a file of the program's own package, +# and every application carried the same three-line file. +# +# Criteria: +# A. `main = { from = "fw/player" }` builds a program that begins in fw's +# entry, compiled with fw's flags, and reaches the callback the program +# defines. +# B. The entry is in no other image: fw built as the root links its library, +# and a second program with its own `main` over fw links. +# C. An entry whose `required_features` are inactive is refused naming the +# feature and the dependency line; with the feature it builds. +# D. An unknown dependency key and a target that is not an entry are refused +# with the candidates; flags for the program's own entry beside `from` are +# refused. +# E. `windows_entry` follows the entry, and a program stating another one is +# refused naming both. +# F. A row replaces the entry's file on the rows it matches. +# G. `[test] main = { from }`: test files without `main`, the package's own +# entry named by its own name. +# H. `start` on a hosted target is refused, naming the C runtime. +# I. The result record names where the program begins. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +# fw: a library, an entry that calls the application's callback, and an entry +# that needs a feature. +mkdir -p "$TMP/fw/src/entry" "$TMP/fw/tests" +cat > "$TMP/fw/mcpp.toml" <<'TOML' +[package] +name = "fw" +version = "0.1.0" + +[features] +app = {} + +[targets.fw] +kind = "lib" + +[targets.player] +kind = "entry" +main = "src/entry/player.cpp" +defines = ["FW_PLAYER"] + +[targets.app] +kind = "entry" +main = "src/entry/app.cpp" +required_features = ["app"] + +[target.'cfg(os = "linux")'.targets.app] +main = "src/entry/app_linux.cpp" + +[test] +main = { from = "fw/player" } +TOML +cat > "$TMP/fw/src/fw.cppm" <<'CPP' +export module fw; +import std; +// The callback an application defines. Declared with C++ language linkage at +// global scope: a declaration attached to this module could not be defined +// outside it. +export extern "C++" int fw_app(); +export namespace fw { +int twice(int v) { return 2 * v; } +} +CPP +cat > "$TMP/fw/src/entry/player.cpp" <<'CPP' +import std; +import fw; +#ifndef FW_PLAYER +#error the entry is compiled with its own target's defines +#endif +int main() { + std::println("player {}", fw::twice(fw_app())); + return 0; +} +CPP +cat > "$TMP/fw/src/entry/app.cpp" <<'CPP' +import std; +import fw; +int main() { std::println("app {}", fw_app()); return 0; } +CPP +cat > "$TMP/fw/src/entry/app_linux.cpp" <<'CPP' +import std; +import fw; +int main() { std::println("app-linux {}", fw_app()); return 0; } +CPP +cat > "$TMP/fw/tests/callback.cpp" <<'CPP' +int fw_app() { return 21; } +CPP + +# notes: the application, which writes only the callback. +mkdir -p "$TMP/notes/src" +cat > "$TMP/notes/src/app.cpp" <<'CPP' +int fw_app() { return 21; } +CPP +FW_DEP='fw = { path = "../fw" }' +notes() { # notes [dependency line] + local dep="${2:-$FW_DEP}" + cat > "$TMP/notes/mcpp.toml" < a.log 2>&1 || fail "A: the program over fw's entry did not build" a.log +"$MCPP" run > a-run.log 2>&1 || fail "A: the program did not run" a-run.log +grep -q "player 42" a-run.log || fail "A: the program did not begin in fw's entry with its flags" a-run.log +echo "ok: a program begins in a dependency's entry and reaches its own callback" + +# B +(cd "$TMP/fw" && "$MCPP" build > "$TMP/b-fw.log" 2>&1) || fail "B: fw as the root did not build" "$TMP/b-fw.log" +mkdir -p "$TMP/other/src" +cat > "$TMP/other/mcpp.toml" <<'TOML' +[package] +name = "other" +version = "0.1.0" + +[dependencies] +fw = { path = "../fw" } +TOML +printf 'import std;\nimport fw;\nint fw_app() { return 1; }\nint main() { std::println("own {}", fw::twice(3)); }\n' > "$TMP/other/src/main.cpp" +(cd "$TMP/other" && "$MCPP" run > "$TMP/b-other.log" 2>&1) || fail "B: a program with its own main over fw did not link" "$TMP/b-other.log" +grep -q "own 6" "$TMP/b-other.log" || fail "B: the program with its own main did not run" "$TMP/b-other.log" +echo "ok: the entry is in no image that does not select it" + +# C +notes 'main = { from = "fw/app" }' +"$MCPP" build > c.log 2>&1 && fail "C: an entry with an inactive required feature was linked" c.log +grep -q 'requires the feature "app"' c.log || fail "C: the refusal does not name the feature" c.log +grep -q 'fw = { ..., features = \["app"\] }' c.log || fail "C: the refusal does not give the dependency line" c.log +notes 'main = { from = "fw/app" }' 'fw = { path = "../fw", features = ["app"] }' +"$MCPP" run > c-run.log 2>&1 || fail "C: the entry with its feature did not build" c-run.log +echo "ok: an entry's required features gate its selection" + +# D +notes 'main = { from = "nope/player" }' +"$MCPP" build > d1.log 2>&1 && fail "D: an unknown dependency was accepted" d1.log +grep -q "'nope' is neither a dependency of 'notes' nor its own name (its dependencies: fw)" d1.log \ + || fail "D: the unknown dependency is not named with the candidates" d1.log +notes 'main = { from = "fw/fw" }' +"$MCPP" build > d2.log 2>&1 && fail "D: a library target was accepted as an entry" d2.log +grep -q "declares no entry target 'fw' (its \`kind = \"entry\"\` targets: app, player)" d2.log \ + || fail "D: the entry targets are not listed" d2.log +notes 'main = { from = "fw/player" } +defines = ["X=1"]' +"$MCPP" build > d3.log 2>&1 && fail "D: flags for the program's own entry were accepted beside from" d3.log +grep -q "targets.notes.defines applies to the target's own entry translation unit" d3.log \ + || fail "D: the flags refusal does not say why" d3.log +echo "ok: a selection that names nothing is refused with the candidates" + +# E +sed -i 's/^defines = \["FW_PLAYER"\]/&\nwindows_entry = "main"/' "$TMP/fw/mcpp.toml" +notes 'main = { from = "fw/player" } +windows_entry = "wmain"' +"$MCPP" build > e.log 2>&1 && fail "E: two entry functions were accepted" e.log +grep -q 'windows_entry = "wmain", and its beginning .fw/player. defines `main`' e.log \ + || fail "E: the conflict does not name both statements" e.log +echo "ok: the entry function follows the entry" + +# F +notes 'main = { from = "fw/app" }' 'fw = { path = "../fw", features = ["app"] }' +grep -q "app-linux 21" c-run.log || fail "F: the row did not replace the entry's file" c-run.log +echo "ok: a row replaces the entry's file" + +# G +(cd "$TMP/fw" && "$MCPP" test > "$TMP/g.log" 2>&1) || fail "G: the test over the package's own entry failed" "$TMP/g.log" +grep -q "callback" "$TMP/g.log" || fail "G: the test did not run" "$TMP/g.log" +echo "ok: test programs begin in an entry named by the package's own name" + +# H +notes 'main = { from = "fw/player" } +start = "_start"' +"$MCPP" build > h.log 2>&1 && fail "H: start on a hosted target was accepted" h.log +grep -q "the image is entered by the C runtime" h.log || fail "H: the refusal does not name the C runtime" h.log +echo "ok: start on a hosted target is refused" + +# I +notes 'main = { from = "fw/player" }' +"$MCPP" build --message-format json > i.json 2>/dev/null || fail "I: the json build failed" i.json +grep -q '"entry":{"from":"fw/player"}' i.json || fail "I: the result record does not name where the program begins" i.json +echo "ok: the result record names the program's beginning" + +echo "PASS: 946 a program begins in a dependency entry" diff --git a/tests/e2e/947_an_image_is_entered_at_one_stated_symbol.sh b/tests/e2e/947_an_image_is_entered_at_one_stated_symbol.sh new file mode 100755 index 00000000..0bc7b652 --- /dev/null +++ b/tests/e2e/947_an_image_is_entered_at_one_stated_symbol.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# 947 -- the symbol a freestanding image is entered at (design 2026-10-26 +# §24.4, mcpp#845). +# +# An image with no C runtime is entered where its starter jumps: the ELF entry +# a loader or an emulator reads. It came only from the linker script's +# `ENTRY()` or the linker's default `_start`, and a symbol nothing defined was a +# warning -- "cannot find entry symbol; not setting start address" -- so the +# image linked and began at address 0. +# +# Criteria (aarch64-none-elf, the zero-libc tier, no emulator needed): +# A. `start = "_reset"` makes `_reset` the image's entry over the script's +# `ENTRY(_start)`, and the build says so on an `Entry` line. +# B. A `start` that nothing defines fails the link, and the note names the +# statement. +# C. A board package's `mcpp:link-start` enters every image over it. +# D. The program and the board stating different symbols is refused, naming +# both. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +READELF=$(find "$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm" -name llvm-readelf 2>/dev/null | head -1) +NM=$(find "$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm" -name llvm-nm 2>/dev/null | head -1) + +# board: a linker script and a build program that states the entry symbol. +mkdir -p "$TMP/board/src" +cat > "$TMP/board/mcpp.toml" <<'TOML' +[package] +name = "board" +version = "0.1.0" + +[targets.board] +kind = "lib" +TOML +printf 'export module board;\nexport namespace board { inline int id() { return 7; } }\n' > "$TMP/board/src/board.cppm" +cat > "$TMP/board/link.ld" <<'LD' +ENTRY(_start) +SECTIONS { + . = 0x40000000; + .text : { *(.text.entry) *(.text*) } + .rodata : { *(.rodata*) } + .data : { *(.data*) } + .bss : { *(.bss*) *(COMMON) } + . = ALIGN(16); . = . + 0x4000; __stack_top = .; +} +LD +board_program() { # board_program + cat > "$TMP/board/build.mcpp" < "$TMP/kern/src/main.cpp" <<'CPP' +import board; +extern "C" void kmain() { + volatile unsigned int* const uart = reinterpret_cast(0x09000000); + *uart = static_cast(board::id()); + for (;;) { asm volatile("wfi"); } +} +asm(".section .text.entry,\"ax\",@progbits\n" + ".globl _start\n" + "_start:\n" + " b _start\n" + ".globl _reset\n" + "_reset:\n" + " ldr x30, =__stack_top\n" + " mov sp, x30\n" + " bl kmain\n" + "1: b 1b\n"); +CPP +kern() { # kern + cat > "$TMP/kern/mcpp.toml" < + printf '0x%x\n' "0x$("$NM" "$1" | awk -v s="$2" '$3 == s { print $1 }')" +} + +cd "$TMP/kern" + +# A +board_program '' +kern 'start = "_reset"' +"$MCPP" build > a.log 2>&1 || fail "A: the image did not build" a.log +ELF=$(find target/aarch64-none-elf -type f -name kern | head -1) +[ -n "$ELF" ] || fail "A: no image" a.log +[ "$(entry_of "$ELF")" = "$(address_of "$ELF" _reset)" ] \ + || fail "A: the image is not entered at _reset ($(entry_of "$ELF"))" a.log +grep -q 'Entry .*kern _reset (\[targets.kern\] start = "_reset")' a.log \ + || fail "A: no Entry line naming the statement" a.log +echo "ok: the stated symbol is the image's entry" + +# B +kern 'start = "_missing"' +"$MCPP" build > b.log 2>&1 && fail "B: an image entered at an undefined symbol linked" b.log +grep -q "the image is entered at \`_missing\`, which nothing this program links defines" b.log \ + || fail "B: the note does not name the symbol" b.log +grep -q 'It was named by \[targets.kern\] start = "_missing"' b.log \ + || fail "B: the note does not name the statement" b.log +echo "ok: an entry symbol nothing defines fails the link" + +# C +board_program 'mcpp::link_start("_reset");' +kern '' +"$MCPP" build > c.log 2>&1 || fail "C: the image over the board did not build" c.log +ELF=$(find target/aarch64-none-elf -type f -name kern | head -1) +[ "$(entry_of "$ELF")" = "$(address_of "$ELF" _reset)" ] \ + || fail "C: the board's link-start did not enter the image" c.log +echo "ok: a board's link-start enters every image over it" + +# D +kern 'start = "_start"' +"$MCPP" build > d.log 2>&1 && fail "D: two entry symbols were accepted" d.log +grep -q "target 'kern' is entered at one symbol, and the statements disagree" d.log \ + || fail "D: the conflict is not stated" d.log +grep -q '\[targets.kern\] start = "_start"' d.log && grep -q 'mcpp:link-start=_reset. from the build program of' d.log \ + || fail "D: the conflict does not name both statements" d.log +echo "ok: two statements of the entry symbol are refused" + +echo "PASS: 947 an image is entered at one stated symbol" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index eb3c353f..1cfc561c 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -1062,16 +1062,33 @@ TEST(BuildDirectives, DeployRowIsProtocolElevenWithLinkGlobalScopeAndATag) { EXPECT_EQ(def->scope, dirs::Scope::LinkGlobal); EXPECT_EQ(def->sinceProtocol, 11); EXPECT_FALSE(def->tag.empty()); - EXPECT_EQ(dirs::kProtocolVersion, 15); + EXPECT_EQ(dirs::kProtocolVersion, 16); } -TEST(BuildDirectives, ProtocolFifteenIsAcceptedAndSixteenIsNot) { - auto ok = parse("mcpp:protocol=15\n"); +TEST(BuildDirectives, ProtocolSixteenIsAcceptedAndSeventeenIsNot) { + auto ok = parse("mcpp:protocol=16\n"); EXPECT_FALSE(dirs::protocol_error(ok).has_value()); - auto no = parse("mcpp:protocol=16\n"); + auto no = parse("mcpp:protocol=17\n"); EXPECT_TRUE(dirs::protocol_error(no).has_value()); } +// Design 2026-10-26 §24.3: a board's image entry symbol reaches the manifest's +// link intent; a non-symbol and two different symbols are refused. +TEST(BuildDirectives, LinkStartIsProtocolSixteenAndReachesTheLinkIntent) { + auto def = dirs::find_by_wire("link-start"); + ASSERT_NE(def, nullptr); + EXPECT_EQ(def->scope, dirs::Scope::LinkGlobal); + EXPECT_EQ(def->sinceProtocol, 16); + mcpp::manifest::Manifest m; + auto d = parse("mcpp:link-start=_reset\n"); + EXPECT_EQ(dirs::target_directive_error(m, d), ""); + dirs::apply(m, d); + EXPECT_EQ(m.runtimeConfig.linkIntent.start, "_reset"); + EXPECT_NE(dirs::target_directive_error(m, parse("mcpp:link-start=1bad\n")), ""); + EXPECT_NE(dirs::target_directive_error( + m, parse("mcpp:link-start=_reset\nmcpp:link-start=_start\n")), ""); +} + TEST(BuildDirectives, DeployWithAnAbsoluteFromYieldsOneEntryOnTheManifest) { const std::string from = under_root("gen/x.bin"); auto d = parse(std::format("mcpp:deploy={}\tres\n", from)); diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 13a13709..6f732b66 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -2786,9 +2786,9 @@ kind = "{}" } } -// The refusal for an unrecognized `kind` lists all four accepted spellings -- -// including the new one -- so a typo's message never falls behind the parser. -TEST(Manifest, KindRefusalListsAllFourKinds) { +// The refusal for an unrecognized `kind` lists all five accepted spellings -- +// including the newest -- so a typo's message never falls behind the parser. +TEST(Manifest, KindRefusalListsAllFiveKinds) { constexpr auto src = R"( [package] name = "p" @@ -2798,7 +2798,7 @@ kind = "framework" )"; auto m = mcpp::manifest::parse_string(src); ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("'bin', 'app', 'lib' or 'shared'"), + EXPECT_NE(m.error().message.find("'bin', 'app', 'lib', 'shared' or 'entry'"), std::string::npos) << m.error().message; } @@ -6916,3 +6916,155 @@ package = { std::error_code ec; std::filesystem::remove_all(root, ec); } + +// Design 2026-10-26 §24.3: an entry target, a program that begins in a +// dependency's entry, and the symbol an image is entered at. +TEST(Manifest, ParsesEntryTargetsAndAProgramThatBeginsInOne) { + constexpr auto src = R"( +[package] +name = "fw" +version = "0.1.0" +[targets.player] +kind = "entry" +main = "src/entry/player.cpp" +start = "_reset" +windows_entry = "WinMain" +defines = ["PLAYER"] +required_features = ["player"] +[targets.notes] +kind = "app" +main = { from = "openmui/player" } +start = "_start" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->targets.size(), 2u); + auto const& entry = m->targets[0].name == "player" ? m->targets[0] : m->targets[1]; + auto const& notes = m->targets[0].name == "notes" ? m->targets[0] : m->targets[1]; + EXPECT_EQ(entry.kind, mcpp::manifest::Target::Entry); + EXPECT_FALSE(entry.is_program()); + EXPECT_EQ(entry.main, "src/entry/player.cpp"); + EXPECT_EQ(entry.start, "_reset"); + EXPECT_EQ(entry.windowsEntry, "WinMain"); + EXPECT_EQ(notes.main, ""); + EXPECT_EQ(notes.mainFrom, "openmui/player"); + EXPECT_EQ(notes.start, "_start"); + EXPECT_EQ(notes.startDeclaredBy, "[targets.notes] start = \"_start\""); + EXPECT_TRUE(m->schemaWarnings.empty()); +} + +TEST(Manifest, RefusesWhatAnEntryOrABeginningCannotState) { + auto refused = [](std::string_view body, std::string_view needle) { + auto m = mcpp::manifest::parse_string( + std::format("[package]\nname = \"p\"\nversion = \"0.1.0\"\n{}", body)); + ASSERT_FALSE(m.has_value()) << body; + EXPECT_NE(m.error().format().find(needle), std::string::npos) + << body << "\n" << m.error().format(); + }; + refused("[targets.e]\nkind = \"entry\"\nmain = \"e.cpp\"\nsoname = \"libe.so\"\n", + "produces no artifact of its own"); + refused("[targets.e]\nkind = \"entry\"\nmain = { from = \"fw/x\" }\n", + "requires 'main', the file that is the translation unit"); + refused("[targets.a]\nkind = \"bin\"\nmain = { from = \"fw/x\", path = \"y\" }\n", + "and nothing else"); + refused("[targets.a]\nkind = \"bin\"\nmain = { from = \"fwx\" }\n", + "\"/\""); + refused("[targets.a]\nkind = \"bin\"\nmain = \"a.cpp\"\nstart = \"1x\"\n", + "is not a symbol name"); + refused("[targets.l]\nkind = \"lib\"\nstart = \"_start\"\n", + "a library target is not entered"); + refused("[targets.a]\nkind = \"bin\"\nmain = { from = \"fw/x\" }\ndefines = [\"X\"]\n", + "applies to the target's own entry translation unit"); + refused("[test]\nmain = \"t.cpp\"\n", "[test] main is `{ from"); + refused("[test]\nstart = \"9\"\n", "[test] start"); +} + +TEST(Manifest, ParsesTestMainFromAndStart) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "p" +version = "0.1.0" +[test] +main = { from = "compat.gtest/main" } +start = "_reset" +)"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->testMainFrom, "compat.gtest/main"); + EXPECT_EQ(m->testStart, "_reset"); +} + +// A row states a program's or an entry's beginning, and only of such a target; +// every file a target names as `main` on any row is recorded on it. +TEST(Manifest, ARowStatesABeginningOfAProgramOrAnEntry) { + auto tmp = std::filesystem::temp_directory_path() / "mcpp_row_beginning"; + std::filesystem::create_directories(tmp); + auto path = tmp / "mcpp.toml"; + auto write = [&](std::string_view body) { + std::ofstream os(path); + os << "[package]\nname = \"p\"\nversion = \"0.1.0\"\n" << body; + }; + write(R"( +[targets.p] +kind = "lib" +[targets.player] +kind = "entry" +main = "src/entry/player.cpp" +[target.'cfg(os = "android")'.targets.player] +main = "src/entry/player_android.cpp" +start = "_reset" +)"); + auto m = mcpp::manifest::load(path); + ASSERT_TRUE(m) << (m ? "" : m.error().message); + auto it = std::ranges::find_if(m->targets, [](auto const& t) { return t.name == "player"; }); + ASSERT_NE(it, m->targets.end()); + EXPECT_EQ(it->otherMains, (std::vector{"src/entry/player_android.cpp"})); + ASSERT_EQ(m->conditionalConfigs.size(), 1u); + auto const& row = m->conditionalConfigs[0].targetKinds.at("player"); + EXPECT_FALSE(row.statesForm); + EXPECT_EQ(row.main, std::optional("src/entry/player_android.cpp")); + EXPECT_EQ(row.start, std::optional("_reset")); + + write(R"( +[targets.p] +kind = "lib" +[target.'cfg(os = "android")'.targets.p] +main = "x.cpp" +)"); + auto lib = mcpp::manifest::load(path); + ASSERT_FALSE(lib); + EXPECT_NE(lib.error().message.find("is a library target"), std::string::npos) << lib.error().message; + + write(R"( +[targets.p] +kind = "lib" +[target.'cfg(os = "android")'.targets.p] +kind = "shared" +main = "x.cpp" +)"); + auto both = mcpp::manifest::load(path); + ASSERT_FALSE(both); + EXPECT_NE(both.error().message.find("a target is one or the other"), std::string::npos) + << both.error().message; + std::error_code ec; + std::filesystem::remove_all(tmp, ec); +} + +// A package that declares only entries still has the library its sources +// infer: an entry is no artifact of its own package. +TEST(Manifest, OnlyEntryTargetsStillInferTheLibrary) { + auto tmp = std::filesystem::temp_directory_path() / "mcpp_entry_infer"; + std::filesystem::create_directories(tmp / "src" / "entry"); + { std::ofstream(tmp / "src" / "fw.cppm") << "export module fw;\n"; } + { + std::ofstream os(tmp / "mcpp.toml"); + os << "[package]\nname = \"fw\"\nversion = \"0.1.0\"\n" + "[targets.player]\nkind = \"entry\"\nmain = \"src/entry/player.cpp\"\n"; + } + auto m = mcpp::manifest::load(tmp / "mcpp.toml"); + ASSERT_TRUE(m) << (m ? "" : m.error().message); + EXPECT_TRUE(std::ranges::any_of(m->targets, [](auto const& t) { + return t.name == "fw" && t.kind == mcpp::manifest::Target::Library; + })); + std::error_code ec; + std::filesystem::remove_all(tmp, ec); +} diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index d63def33..92e6b966 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -3041,3 +3041,27 @@ TEST(NinjaBackend, ATrimMapKeepsTheFixedRootsAndTheMarkedPackages) { for (std::size_t i = 1; i < core.size(); ++i) EXPECT_LE(core[i - 1].first.native().size(), core[i].first.native().size()); } + +// Design 2026-10-26 §24.4 item 9: the entry symbol of a freestanding image, and +// the `--defsym` that makes an undefined one a link error, through the driver +// and to ld.lld itself. +TEST(NinjaBackend, StartRendersTheEntryAndItsDefinitionCheck) { + using mcpp::build::start_link_flags; + using Flags = std::vector; + EXPECT_EQ(start_link_flags("", false), Flags{}); + EXPECT_EQ(start_link_flags("_reset", false), + (Flags{"-Wl,-e,_reset", "-Wl,--defsym=__mcpp_start=_reset"})); + EXPECT_EQ(start_link_flags("_reset", true), + (Flags{"-e", "_reset", "--defsym=__mcpp_start=_reset"})); +} + +TEST(NinjaBackend, StartFailureAdviceNamesTheStatement) { + using mcpp::build::start_failure_advice; + const std::string lld = "ld.lld: error: --defsym:1: symbol not found: _missing\n"; + const auto named = start_failure_advice(lld, {{"_missing", "[targets.k] start = \"_missing\""}}); + EXPECT_NE(named.find("It was named by [targets.k] start = \"_missing\""), std::string::npos) << named; + const auto generic = start_failure_advice(lld, {}); + EXPECT_NE(generic.find("`_missing`"), std::string::npos) << generic; + EXPECT_NE(generic.find("mcpp:link-start"), std::string::npos) << generic; + EXPECT_EQ(start_failure_advice("undefined symbol: foo", {}), ""); +} From d4bf988447405d2743538da31200303e2506b838 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 07:53:56 +0900 Subject: [PATCH 31/43] SPEC-007: protocol 16 (link_start) Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/specs/build-plugins.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specs/build-plugins.md b/docs/specs/build-plugins.md index 7aff95cb..538e1543 100644 --- a/docs/specs/build-plugins.md +++ b/docs/specs/build-plugins.md @@ -5,8 +5,8 @@ | 规范编号 | SPEC-007 | | 标题 | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | | 状态 | 草案 v0.9 | -| 版本 | 0.9 | -| 最后修改 | 2026-10-11 | +| 版本 | 0.10 | +| 最后修改 | 2026-10-27 | | 对应实现 | 逐条标注。未注明版本的「已实现」条款对应 mcpp >= 2026.9.26.1;注明 mcpp#702 的条款对应 mcpp >= 2026.9.26.2;注明 mcpp#707、#708、#709、#711 的条款对应 mcpp >= 2026.9.27.1;注明 mcpp#723 的条款对应 mcpp >= 2026.9.28.1;注明 mcpp 2026.9.28.2 的条款对应该版本;§9 与注明 mcpp#734 的条款对应 mcpp >= 2026.9.28.3;注明 mcpp#778 的条款对应 mcpp >= 2026.10.10.1;§10 对应 mcpp >= 2026.10.11.1 | | 相关设计文档 | `.agents/docs/2026-09-26-compile-database-and-issue-699-design.md`(§5)、`.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md`(WS1、WS3)、`.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md`(§3、§4)、`.agents/docs/2026-10-10-one-source-one-meaning-design.md`(§4)、`.agents/docs/2026-10-11-build-dependencies-one-pipeline-design.md` | | 相关 issue | mcpp#699、mcpp#701、mcpp#702、mcpp#703、mcpp#707、mcpp#708、mcpp#709、mcpp#711、mcpp#734、mcpp#755、mcpp#778、mcpp#797 | @@ -257,6 +257,7 @@ | 13 | 2026.9.27.1 | action 的 `env` 与 `cwd` | | 14 | 2026.9.28.3 | `mcpp.core`、构建信息(R9.3)、`mcpp::report`(R9.4) | | 15 | 2026.10.1.3 | 来源(R6.5、R6.6、R9.9):`xpkg_source`、`xpkg_program`、`xpkg_request`、`xpkg_pending`、`phase`、`decision`、`toolchain` | + | 16 | 2026.10.26.2 | 镜像入口:`link_start`(`mcpp:link-start=`,docs/04 `start`) | - **R9.3** 构建信息以事实陈述解析出的工具链,不针对任何外部构建系统:`tool(role)`(这一行的 工具)、`abi_tool(role)`(目标 ABI 的原生工具,MSVC ABI 上为工具集的 `cl`、`link`、`lib`、 @@ -328,3 +329,4 @@ | 0.8 | 2026-10-10 | 随 mcpp 2026.10.10.1(mcpp#778):新增 R3.9(action 的输出只有一个写入者,生成的翻译单元以声明单元进入规划,生成的模块接口须声明 `.provides()`)与 R3.10(构建程序的输出目录按配置分开,`resolution.json` 记录 `outDir`)。 | | 0.2 | 2026-09-26 | 随 mcpp 2026.9.26.2 落地:R1.3 的警告、R2.1 的 `runtime_search_dir`、R2.4、R3.3 的 `prepare`(目录须含文件;链接边等待所有 `prepare`)、R3.5、R3.6、R4.1、R4.3、R5.2、R5.3 标为已实现。 | | 0.9 | 2026-10-11 | 随 mcpp 2026.10.11.1(mcpp#797):新增 §10 构建程序的库与构建插件(R10.1–R10.6,P1–P5);变更记录移为 §11。 | +| 0.10 | 2026-10-27 | 随 mcpp 2026.10.26.2(mcpp#845):R9.2 的协议表增加协议 16(`link_start`,镜像入口)。 | From feab2c9c553bced4a4f04cbaa37437cd50600e57 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 07:54:28 +0900 Subject: [PATCH 32/43] e2e 652: the kind refusal lists entry Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- tests/e2e/652_an_application_is_a_kind.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/e2e/652_an_application_is_a_kind.sh b/tests/e2e/652_an_application_is_a_kind.sh index 084d978c..0c7052fe 100755 --- a/tests/e2e/652_an_application_is_a_kind.sh +++ b/tests/e2e/652_an_application_is_a_kind.sh @@ -17,8 +17,9 @@ # (~/.xlings/data/xpkgs/xim-x-mcpp/2026.9.12.2/bin/mcpp) while writing this # script: `kind = "app"` is refused, naming three kinds, which is correct -- # an application manifest is a root, and a root that names a form an older -# engine cannot produce must not build. The new refusal lists FOUR: -# 'bin', 'app', 'lib' or 'shared' -- asserted below on the engine under test. +# engine cannot produce must not build. The refusal lists every kind: +# 'bin', 'app', 'lib', 'shared' or 'entry' (2026.10.26.2) -- asserted below on +# the engine under test. set -e TMP=$(mktemp -d) @@ -52,7 +53,7 @@ cat > src/tool.cpp <<'CPP' int main() { std::puts("TOOL-RUN"); return 0; } CPP -# ── 1. The refusal, on the engine under test, names four kinds ───────────── +# ── 1. The refusal, on the engine under test, names every kind ──────────── cat > bad.toml <<'TOML' [package] name = "p" @@ -65,9 +66,9 @@ mkdir -p bad/src cp bad.toml bad/mcpp.toml printf 'int main(){}\n' > bad/src/main.cpp out=$( cd bad && "$MCPP" build 2>&1 ) && fail "an unrecognized kind must be refused" <(echo "$out") -grep -q "must be 'bin', 'app', 'lib' or 'shared'" <<<"$out" \ - || fail "the refusal does not list all four kinds" <(echo "$out") -echo "unrecognized kind lists four kinds OK" +grep -q "must be 'bin', 'app', 'lib', 'shared' or 'entry'" <<<"$out" \ + || fail "the refusal does not list every kind" <(echo "$out") +echo "unrecognized kind lists every kind OK" # ── 2. The host build: both targets link, `app` == `bin` off Android ─────── "$MCPP" build > build.log 2>&1 || fail "the host build failed" build.log From 9b1af950c341ba3b82b54d49718659f78b608695 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 11:07:39 +0900 Subject: [PATCH 33/43] =?UTF-8?q?mcpp=20keeps=20no=20operating-system=20la?= =?UTF-8?q?yer=20of=20its=20own:=20modules/platform=20dissolved=20(design?= =?UTF-8?q?=202026-10-26=20=C2=A725,=20D25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operating system is reached through mcpplibs.os 0.2.0. What was mcpp's own meaning under `platform` now lives in the module that owns it: - modules/invocation (mcpp.invocation): the environment of one invocation, statements over the started environment; every package reads through it and every started program receives it. mcpp no longer writes its own process environment; xlings' environment is given per invocation. - mcpp.effects: the constraints of a run. - mcpp.run: every program mcpp starts, by argument vector, in a unit that ends with mcpp; the general `execute(argv, Options)`; the command interpreter only for commands that are text by nature. A watcher thread over os::interrupt replaces the signal handlers; a closed standard output ends mcpp in the same order. - src/runtime (launch_env, search, env_contract), mcpp.build.command_line, mcpp.build.capacity, mcpp.ui.terminal, mcpp.home.files (the lock, in native/), toolchain-model's mcpp.toolchain.platform and mcpp.toolchain.apple_sdk, scaffold's project-name rule. Platform differences are `if constexpr` and per-system implementation units; no OS header remains in src/ or modules/. Interfaces that carried implementations were split where GCC 16.1 wrote compiled interfaces their importers could not read (§25.7). A failed detached code generation is withdrawn so the next build compiles the unit again. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...ntract-runtime-and-ide-ecosystem-design.md | 118 + .github/tools/install_pinned_mcpp.sh | 2 +- .../workflows/ci-aarch64-fresh-install.yml | 2 +- .github/workflows/ci-macos.yml | 2 +- CHANGELOG.md | 34 + docs/90-build-from-source.md | 25 +- docs/91-toolchain-internals.md | 2 +- docs/zh/90-build-from-source.md | 24 +- docs/zh/91-toolchain-internals.md | 2 +- mcpp.toml | 24 +- modules/buildmcpp/mcpp.toml | 1 + modules/buildmcpp/src/program_protocol.cppm | 5 +- modules/buildmcpp/src/tool_store.cppm | 5 +- modules/invocation/mcpp.toml | 16 + modules/invocation/src/invocation.cpp | 135 ++ modules/invocation/src/invocation.cppm | 62 + modules/log/mcpp.toml | 3 + modules/log/src/log.cppm | 18 +- modules/manifest/mcpp.toml | 6 +- modules/manifest/src/glob.cppm | 8 +- modules/manifest/src/toml.cppm | 19 +- modules/manifest/src/types.cppm | 1 - modules/manifest/src/xpkg.cppm | 21 +- modules/platform/mcpp.toml | 52 - modules/platform/src/common.cppm | 49 - modules/platform/src/env.cppm | 330 --- modules/platform/src/env_lists.cpp | 51 - modules/platform/src/fs.cppm | 247 -- modules/platform/src/fs_caps.cppm | 229 -- modules/platform/src/linux.cppm | 114 - .../platform/src/linux/bounded_process.cpp | 15 - modules/platform/src/linux/capacity.cpp | 91 - modules/platform/src/linux/env.cpp | 10 - modules/platform/src/linux/fs_caps.cpp | 46 - modules/platform/src/macos.cppm | 335 --- .../platform/src/macos/bounded_process.cpp | 18 - modules/platform/src/macos/capacity.cpp | 47 - modules/platform/src/macos/env.cpp | 10 - modules/platform/src/macos/fs_caps.cpp | 25 - modules/platform/src/platform.cppm | 68 - .../platform/src/posix/bounded_process.cpp | 487 ---- .../platform/src/posix/bounded_process.cppm | 239 -- modules/platform/src/posix/env.cpp | 12 - modules/platform/src/posix/fs.cpp | 69 - modules/platform/src/posix/fs_caps.cpp | 21 - modules/platform/src/posix/process.cpp | 359 --- modules/platform/src/posix/scaffold_fs.cpp | 71 - modules/platform/src/posix/terminal.cpp | 91 - modules/platform/src/posix/windows.cpp | 44 - modules/platform/src/process.cppm | 1075 --------- modules/platform/src/project_name.cppm | 35 - modules/platform/src/scaffold_fs.cppm | 34 - modules/platform/src/shell.cppm | 180 -- modules/platform/src/terminal.cppm | 380 --- modules/platform/src/windows.cppm | 51 - .../platform/src/windows/bounded_process.cpp | 568 ----- .../platform/src/windows/bounded_process.cppm | 229 -- modules/platform/src/windows/capacity.cpp | 62 - modules/platform/src/windows/env.cpp | 14 - modules/platform/src/windows/fs.cpp | 99 - modules/platform/src/windows/fs_caps.cpp | 47 - modules/platform/src/windows/process.cpp | 132 -- modules/platform/src/windows/scaffold_fs.cpp | 50 - modules/platform/src/windows/terminal.cpp | 126 - modules/platform/src/windows/windows.cpp | 83 - modules/platform/tests/test_fs_caps.cpp | 127 - modules/toolchain-model/mcpp.toml | 7 +- modules/toolchain-model/src/apple_sdk.cpp | 197 ++ modules/toolchain-model/src/apple_sdk.cppm | 131 + modules/toolchain-model/src/linkmodel.cppm | 5 +- modules/toolchain-model/src/model.cppm | 7 +- modules/toolchain-model/src/platform.cpp | 24 + .../src/platform.cppm} | 41 +- modules/toolchain-model/src/triple.cppm | 17 +- .../tests/test_platform_axis.cpp} | 8 +- native/posix/lock.cpp | 28 + native/windows/lock.cpp | 32 + src/bmi_cache.cppm | 4 +- src/build/backend.cppm | 2 +- src/build/build_database.cppm | 1 - src/build/build_program.cppm | 19 +- src/build/capacity.cpp | 56 + .../platform/src => src/build}/capacity.cppm | 39 +- src/build/command_line.cpp | 144 ++ src/build/command_line.cppm | 110 + src/build/compile_commands.cppm | 12 +- src/build/cxx_runtime.cppm | 2 +- src/build/dirlock.cppm | 32 +- src/build/execute.cppm | 101 +- src/build/flags.cppm | 41 +- src/build/hermetic.cppm | 14 +- src/build/loader_contract.cppm | 14 +- src/build/ninja_backend.cppm | 112 +- src/build/pe_exports.cppm | 8 +- src/build/plan.cppm | 61 +- src/build/prepare.cppm | 9 +- src/build/prepare/base.cpp | 21 +- src/build/prepare/config.cpp | 7 +- src/build/prepare/driver.cpp | 4 +- src/build/prepare/features.cpp | 15 +- src/build/prepare/fetch.cpp | 48 +- src/build/prepare/graph.cpp | 63 +- src/build/prepare/graph_load.cpp | 20 +- src/build/prepare/host_graph.cpp | 16 +- src/build/prepare/local_toolchain.cpp | 7 +- src/build/prepare/manifest.cpp | 6 +- src/build/prepare/options.cpp | 13 +- src/build/prepare/plan.cpp | 33 +- src/build/prepare/records.cpp | 14 +- src/build/prepare/scan.cpp | 31 +- src/build/prepare/sources.cpp | 18 +- src/build/prepare/state.cppm | 16 +- src/build/prepare/target_side.cpp | 6 +- src/build/prepare/toolchain.cpp | 59 +- src/build/prepare/toolchain_decision.cpp | 1 - src/build/prepare/toolchain_env.cpp | 14 +- src/build/prepare/windows_resources.cpp | 4 - src/build/prepare/xlings.cpp | 1 - src/build/prepare_inputs.cppm | 4 +- src/build/progress.cppm | 32 +- src/build/refusal.cppm | 4 +- src/build/resources.cppm | 4 +- src/build/result.cppm | 13 +- src/build/runner_lookup.cppm | 18 +- src/build/runtime_validation.cppm | 81 +- src/build/schedule/detach_codegen.cppm | 206 +- src/build/schedule/policy.cppm | 15 +- src/build/symbol_provision.cppm | 4 +- src/cli.cppm | 87 +- src/cli/cmd_build.cppm | 70 +- src/cli/cmd_publish.cppm | 6 +- src/cli/cmd_self.cppm | 24 +- src/cli/cmd_xpkg.cppm | 8 +- src/config.cppm | 85 +- src/doctor.cppm | 280 +-- src/effects.cpp | 76 + src/effects.cppm | 69 + src/fallback/probe_sysroot.cppm | 4 +- src/fallback/xlings_binary.cppm | 18 +- src/fetcher/progress.cppm | 3 +- src/home.cppm | 49 +- src/home/files.cpp | 109 + src/home/files.cppm | 84 + src/hooks.cppm | 6 +- src/main.cpp | 28 +- src/modgraph/p1689.cppm | 7 +- src/pack/library.cppm | 63 +- src/pack/library_pipeline.cppm | 1 - src/pack/pack.cppm | 724 +++--- src/pack/pipeline.cppm | 19 +- src/pack/strip.cppm | 40 +- src/pm/commands.cppm | 4 +- src/pm/index_contract.cppm | 23 +- src/pm/index_management.cppm | 4 +- src/pm/index_refresh.cppm | 7 +- src/pm/package_fetcher.cppm | 4 +- src/pm/publisher.cppm | 16 +- src/pm/refresh_policy.cppm | 6 +- src/pm/resolver.cppm | 17 +- src/provider/xlings/provider.cppm | 12 +- src/provider/xlings/subos_info.cppm | 4 +- src/provider/xlings/xlings.cpp | 2043 ++++++++++++++++ src/provider/xlings/xlings.cppm | 2101 +---------------- src/publish/pipeline.cppm | 6 +- src/run.cppm | 199 ++ src/run/run.cpp | 591 +++++ src/runtime/binding.cppm | 15 +- src/runtime/elf.cppm | 31 +- .../runtime/env_contract.cppm | 8 +- src/runtime/launch_env.cpp | 63 + src/runtime/launch_env.cppm | 45 + .../runtime/search.cppm | 8 +- src/scaffold/create.cppm | 6 +- src/scaffold/project_name.cppm | 30 +- src/scaffold/template.cppm | 23 +- src/toolchain/base_probe.cppm | 20 +- src/toolchain/cenv_probe.cppm | 4 +- src/toolchain/clang.cpp | 557 +++++ src/toolchain/clang.cppm | 563 +---- src/toolchain/compat.cppm | 4 +- src/toolchain/detect.cppm | 45 +- src/toolchain/gcc.cppm | 26 +- src/toolchain/hostflags.cppm | 13 +- src/toolchain/lifecycle.cppm | 26 +- src/toolchain/llvm.cppm | 4 +- src/toolchain/msvc.cppm | 304 ++- src/toolchain/msvc_coroutines.cppm | 4 +- src/toolchain/post_install.cppm | 9 +- src/toolchain/probe.cppm | 128 +- src/toolchain/registry.cppm | 67 +- src/toolchain/stdmod.cppm | 16 +- src/ui.cppm | 38 +- src/ui/terminal.cpp | 260 ++ src/ui/terminal.cppm | 160 ++ src/wire.cppm | 4 +- tests/unit/test_artifact_naming.cpp | 23 +- tests/unit/test_build_database.cpp | 4 +- tests/unit/test_build_profile.cpp | 24 +- tests/unit/test_build_resources.cpp | 6 +- tests/unit/test_capacity_jobs.cpp | 10 +- tests/unit/test_cenv_probe.cpp | 4 +- tests/unit/test_compile_commands.cpp | 8 +- tests/unit/test_elf_runtime.cpp | 48 +- tests/unit/test_hermetic_graph_link.cpp | 10 +- tests/unit/test_home.cpp | 38 +- tests/unit/test_hostflags.cpp | 6 +- tests/unit/test_loader_contract.cpp | 4 +- tests/unit/test_manifest.cpp | 110 +- tests/unit/test_network.cpp | 39 +- tests/unit/test_ninja_backend.cpp | 55 +- tests/unit/test_pack_archive_remove.cpp | 26 +- tests/unit/test_platform_apple_sdk.cpp | 31 +- tests/unit/test_platform_env.cpp | 39 +- tests/unit/test_platform_fs.cpp | 48 +- tests/unit/test_platform_terminal.cpp | 6 +- tests/unit/test_pm_index_refresh.cpp | 8 +- tests/unit/test_post_install.cpp | 1 - tests/unit/test_process_run_exec.cpp | 27 +- tests/unit/test_process_seal_stdin.cpp | 202 +- tests/unit/test_progress_render.cpp | 20 +- .../unit/test_run_concurrent_children.cpp | 24 +- tests/unit/test_runtime_contract.cpp | 32 +- tests/unit/test_runtime_search.cpp | 4 +- tests/unit/test_runtime_selection.cpp | 42 +- tests/unit/test_scaffold.cpp | 8 +- tests/unit/test_schedule_policy.cpp | 21 +- tests/unit/test_subos_info.cpp | 12 +- tests/unit/test_symbol_provision.cpp | 2 +- tests/unit/test_target_base.cpp | 21 +- tests/unit/test_target_static.cpp | 3 +- tests/unit/test_toolchain_compat.cpp | 4 +- tests/unit/test_toolchain_descriptor.cpp | 4 +- tests/unit/test_toolchain_detect.cpp | 4 +- tests/unit/test_toolchain_lifecycle.cpp | 12 +- tests/unit/test_toolchain_msvc.cpp | 26 +- tests/unit/test_toolchain_registry.cpp | 21 +- tests/unit/test_ui_streams.cpp | 4 +- tests/unit/test_windows_command_line.cpp | 91 +- tests/unit/test_windows_defaults.cpp | 4 +- tests/unit/test_xlings.cpp | 145 +- tests/unit/test_xlings_version_pin.cpp | 22 +- tests/unit/test_xpkg_emit.cpp | 4 +- tests/unit/test_xpkg_host_tools.cpp | 7 +- 243 files changed, 7876 insertions(+), 11849 deletions(-) create mode 100644 modules/invocation/mcpp.toml create mode 100644 modules/invocation/src/invocation.cpp create mode 100644 modules/invocation/src/invocation.cppm delete mode 100644 modules/platform/mcpp.toml delete mode 100644 modules/platform/src/common.cppm delete mode 100644 modules/platform/src/env.cppm delete mode 100644 modules/platform/src/env_lists.cpp delete mode 100644 modules/platform/src/fs.cppm delete mode 100644 modules/platform/src/fs_caps.cppm delete mode 100644 modules/platform/src/linux.cppm delete mode 100644 modules/platform/src/linux/bounded_process.cpp delete mode 100644 modules/platform/src/linux/capacity.cpp delete mode 100644 modules/platform/src/linux/env.cpp delete mode 100644 modules/platform/src/linux/fs_caps.cpp delete mode 100644 modules/platform/src/macos.cppm delete mode 100644 modules/platform/src/macos/bounded_process.cpp delete mode 100644 modules/platform/src/macos/capacity.cpp delete mode 100644 modules/platform/src/macos/env.cpp delete mode 100644 modules/platform/src/macos/fs_caps.cpp delete mode 100644 modules/platform/src/platform.cppm delete mode 100644 modules/platform/src/posix/bounded_process.cpp delete mode 100644 modules/platform/src/posix/bounded_process.cppm delete mode 100644 modules/platform/src/posix/env.cpp delete mode 100644 modules/platform/src/posix/fs.cpp delete mode 100644 modules/platform/src/posix/fs_caps.cpp delete mode 100644 modules/platform/src/posix/process.cpp delete mode 100644 modules/platform/src/posix/scaffold_fs.cpp delete mode 100644 modules/platform/src/posix/terminal.cpp delete mode 100644 modules/platform/src/posix/windows.cpp delete mode 100644 modules/platform/src/process.cppm delete mode 100644 modules/platform/src/project_name.cppm delete mode 100644 modules/platform/src/scaffold_fs.cppm delete mode 100644 modules/platform/src/shell.cppm delete mode 100644 modules/platform/src/terminal.cppm delete mode 100644 modules/platform/src/windows.cppm delete mode 100644 modules/platform/src/windows/bounded_process.cpp delete mode 100644 modules/platform/src/windows/bounded_process.cppm delete mode 100644 modules/platform/src/windows/capacity.cpp delete mode 100644 modules/platform/src/windows/env.cpp delete mode 100644 modules/platform/src/windows/fs.cpp delete mode 100644 modules/platform/src/windows/fs_caps.cpp delete mode 100644 modules/platform/src/windows/process.cpp delete mode 100644 modules/platform/src/windows/scaffold_fs.cpp delete mode 100644 modules/platform/src/windows/terminal.cpp delete mode 100644 modules/platform/src/windows/windows.cpp delete mode 100644 modules/platform/tests/test_fs_caps.cpp create mode 100644 modules/toolchain-model/src/apple_sdk.cpp create mode 100644 modules/toolchain-model/src/apple_sdk.cppm create mode 100644 modules/toolchain-model/src/platform.cpp rename modules/{platform/src/axis.cppm => toolchain-model/src/platform.cppm} (66%) rename modules/{platform/tests/test_axis_is_self_contained.cpp => toolchain-model/tests/test_platform_axis.cpp} (81%) create mode 100644 native/posix/lock.cpp create mode 100644 native/windows/lock.cpp create mode 100644 src/build/capacity.cpp rename {modules/platform/src => src/build}/capacity.cppm (59%) create mode 100644 src/build/command_line.cpp create mode 100644 src/build/command_line.cppm create mode 100644 src/effects.cpp create mode 100644 src/effects.cppm create mode 100644 src/home/files.cpp create mode 100644 src/home/files.cppm create mode 100644 src/provider/xlings/xlings.cpp create mode 100644 src/run.cppm create mode 100644 src/run/run.cpp rename modules/platform/src/runtime_env_contract.cppm => src/runtime/env_contract.cppm (94%) create mode 100644 src/runtime/launch_env.cpp create mode 100644 src/runtime/launch_env.cppm rename modules/platform/src/runtime_search.cppm => src/runtime/search.cppm (97%) create mode 100644 src/toolchain/clang.cpp create mode 100644 src/ui/terminal.cpp create mode 100644 src/ui/terminal.cppm rename modules/platform/tests/test_process_concurrent_children.cpp => tests/unit/test_run_concurrent_children.cpp (82%) diff --git a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md index b1433d5c..d783f046 100644 --- a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md +++ b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md @@ -76,6 +76,10 @@ mcpp-index(mcpplibs)本机快照。 覆盖,不另设语料测试。 - rev 5(2026-10-27,[mcpp#845](https://github.com/mcpp-community/mcpp/issues/845)):增加 §24"程序起点与镜像入口"与 D24, 在本批 mcpp PR(#842)中实现。原议的 `entry = "<依赖>"` 加 feature 声明的写法不采纳(§24.6)。 +- rev 6(2026-10-27):增加 §25"拆除 `modules/platform`"与 D25,在本批 mcpp PR(#842)中实现,mcpplibs.os 发布 0.2.0。 +- rev 7(2026-10-27,§25 实施收口):§25.3 的去向按实现修订(`mcpp.invocation` 为独立包 `modules/invocation`,锁的 + 实现单元在 `native/`);§25.5 第 2 条的命令解释器范围按实现写明(mcpp 以文本组成并记录的命令行同 ninja 规则一样 + 由解释器执行);增加 §25.7(GCC 16 的模块接口约束,实测)与 §25.8(openkal 补丁发布中实测发现的两处行为)。 关联:**mcpp#838**(sycl lane 下 `std::exception` 匹配不到)、**mcpp#839**(同配置并发构建在同一构建目录竞争、双方失败)、 mcpp#596(unwinder 被拆成两半)、mcpp#514(两份 libc++ 头文件)、mcpp#544 / #716 / #755(快速路径的拒绝清单); @@ -128,6 +132,7 @@ mcpplibs/mcpp-index、Sunrisepeak/mcpp-language-server,以及新建的 mcpp-co | D22 | **链接产物原子发布**:POSIX 先写 `.tmp` 再改名;PE 先把旧产物改名挪开(`.old-`)再原地链接,正在运行的程序保留其文件。执行段因此无需持锁(§4.1、§4.3、E23) | | D23 | **退出码**:副作用被拒绝为 `5`;被信号取消为 `128 + 信号`(130、143);写入 SPEC-003(§5.4、§5.5) | | D24 | **入口分两层**:程序起点(L1,`main` 指向的翻译单元及 PE 上的入口函数)属于程序,可由依赖的 `kind = "entry"` 目标提供(`main = { from = "<依赖>/<入口>" }`);镜像入口(L0,`start` 符号)属于面向启动者的一层,只在没有 C 运行时的镜像上可写,可由目标、入口目标或板级包陈述,不一致即拒绝(§24) | +| D25 | **mcpp 不保留操作系统层**:`modules/platform` 删除。操作系统能力直接调用 mcpplibs.os,缺少的能力加入 mcpplibs.os(0.2.0);`platform` 名下属于 mcpp 自身语义的代码(副作用约束、调用环境、运行时搜索与加载器环境、命令行拼写、平台键、Apple SDK、脚手架、终端交互)归入各自所属的 mcpp 模块(§25) | ## 1. 问题与证据 @@ -994,6 +999,119 @@ e2e:依赖提供的程序起点(含回调由使用方实现、`windows_entry 独立目标上 `start` 的渲染、`--require-defined` 使缺失符号成为错误、三方陈述冲突的拒绝、hosted 行上的拒绝; `mcpp:link-start` 指令。单元测试:清单解析的接受与拒绝、ELF 渲染。 +## 25. 拆除 `modules/platform` + +### 25.1 问题 + +mcpp 2026.10.26.2 把操作系统层换成 mcpplibs.os 时保留了 `modules/platform` 作为外观层:接口不变,内部部分改调 +mcpplibs.os。结果是两个操作系统层并存:`modules/platform` 约 6200 行,142 个文件导入它,其中进程启动与有界进程 +(约 3000 行)、终端检测、文件系统原语、环境变量读取与 mcpplibs.os 重复;其余部分并非操作系统抽象,而是 mcpp 自身的 +语义被放在了 `platform` 名下。 + +### 25.2 原则 + +1. 操作系统能力只有一份,在 mcpplibs.os。mcpp 缺少的能力加入 mcpplibs.os,而不是留在 mcpp。 +2. 属于 mcpp 语义的代码归入拥有该语义的模块,模块名与命名空间一致。 +3. 平台差异以 `if constexpr` 与按系统选择的实现单元表达;原生头文件只出现在必须的最小范围内。 + +### 25.3 去向 + +| 现内容 | 去向 | +|---|---| +| 平台常量(`is_windows`、`host_arch`、`exe_suffix`、`name`) | `mcpplibs::os`(`PLATFORM`、`ARCH`、`is_windows`、`exe_suffix`、`name`) | +| 平台键与轴(`PlatformKey`、`HostPlatform`、`TargetPlatform`、`xpkg_platform*`)、宿主的产物命名与能力(`static_lib_ext`、`shared_lib_ext`、`lib_prefix`、`supports_rpath`、`supports_full_static`、`needs_explicit_libcxx`) | `modules/toolchain-model`(`mcpp.toolchain.platform`):它们是工具链模型的词汇 | +| 环境变量读取 | `mcpplibs::os::env` | +| 环境变量写入(`set`、`unset`、`ScopedEnv`、`with_prefix`、Windows `prepend_path`) | `mcpp.invocation`(独立包 `modules/invocation`,只依赖 mcpplibs.os):本次调用的环境陈述。mcpp 各包(含 `log`、`manifest`、`toolchain-model`、`buildmcpp`)的读取先查陈述再查 `os::env`,每个子进程的环境由陈述得出;mcpp 不再改写自身的进程环境。xlings 的调用环境同样按次给出,`ScopedInvocationEnv` 删除 | +| 副作用约束(`offline_mode`、`effect_denied`、`note_*`、`no_auto_install`、`writes_global_cache`) | `mcpp.effects`(D8) | +| 加载器环境(`runtime_library_path_key`、`host_tool_runtime_library_path_key`、`strip_private_glibc`、`prepend_path_list`、`build_env_prefix`、`inherited_path`、Linux 的 `build_*ld_library_path_prefix`)、`runtime_search`、`runtime_env_contract` | `src/runtime`(`mcpp.runtime.launch_env`、`mcpp.runtime.search`);`src/runtime` 中命名空间 `mcpp::platform::{runtime,elf,search}` 改为 `mcpp::runtime::{binding,elf,search}` | +| 进程(`capture`、`capture_exec*`、`run_*`、`stream_exec`)、有界进程、`unixproc`、`winproc` | `mcpplibs::os::process`。mcpp 的组合(调用环境、宿主工具的加载器路径、取消与中断文件、终端模式恢复)在 `mcpp.run`。以 shell 字符串执行的调用改为参数向量;语义上需要 shell 的(`[hooks]` 命令)显式以 `sh -c` 或 `cmd /d /s /c` 启动 | +| 取消(信号处理器中结束进程组并写中断文件) | `mcpplibs::os::interrupt`(请求在等待中被读取)加 `os::process` 的单元:等待以有界的 `wait_for` 轮询中断请求,收到后 `end_unit`、写入中断文件、恢复终端,并以请求结束本程序(`end_as_requested`)。§5 的退出码不变 | +| shell 引号(`quote*`、`paste_arg`、`silent_redirect`、`null_redirect`) | `mcpp.build.command_line`:ninja 规则、钩子命令与供人复制的命令的拼写(`display(argv)`),以及 Windows 命令解释器的参数(`windows_shell_arguments`)。以 shell 字符串启动程序所需的 `windows_command_from_argv`、`windows_wrap_for_cmd_c` 删除 | +| 终端检测(`is_terminal`、尺寸、颜色) | `mcpplibs::os::terminal`;画面输出、按键解码、`StdoutToStderr`、协议输出、Unicode 判断属于界面,归入 `src/ui`(`mcpp.ui.terminal`) | +| 文件系统(`self_exe_path`、`which`、`replace_file`、`append_atomically`、`extended_length`、`fs_caps`、无替换改名、交换、目录链接、`sync_*`) | `mcpplibs::os::fs` 与 `os::env`(`image`、`find_executable`) | +| `FileLock` | `mcpp.home.files`:与早先发布的 mcpp 共用同一把锁(POSIX 上为 `flock`,与 openkal 的记录锁互不排斥),因此是 mcpp 对自身旧版本的兼容契约。按系统选择的实现单元位于 `native/posix`、`native/windows`(`src/` 之外,由 `[target..build] sources` 选入);Windows 上与 `os::fs::lock` 同为 `LockFileEx` | +| 信任检查(`untrusted_because`,目录属主) | `mcpp.home`,基于 `os::fs` 的属主查询 | +| `capacity` | `mcpplibs::os::machine`(处理器、内存);并行度策略 `recommended_jobs` 归入 `mcpp.build` | +| Windows 事实(`active_code_page`、`long_paths_enabled`、注册表路径、`env_directory`、`writable_fixed_drive`) | `mcpplibs::os::windows`(代码页、长路径);home 的默认位置选择归入 `mcpp.home` | +| macOS SDK、部署目标 | `src/toolchain`(`mcpp.toolchain.apple_sdk`) | +| `project_name`、`scaffold_fs` | `src/scaffold` | + +### 25.4 mcpplibs.os 0.2.0 + +| 增加 | 原因 | +|---|---| +| `process::exec(options)`:成为该程序;POSIX 上替换映像,其他系统上启动、等待并以其状态结束 | `mcpp run` 的退出码是程序自身的(E16) | +| `process::stdio::output`:标准错误与标准输出共用一个管道;`spawn_options::one_string` | 诊断按出现顺序收集;命令解释器按自身语法读取命令 | +| `fs::sync`、`fs::append`、`fs::extended`、`fs::windows_extended` | 原子发布的持久化;多进程追加;越过 `MAX_PATH` 的写法(规则本身在每个主机上可测) | +| `fs::behaviour`、`fs::measure`、`fs::place`、`fs::exchange` | 文件系统能力的实测与据此选择的无替换发布、交换(原 `fs_caps`) | +| `fs::owner_of`、`fs::writable` | home 的信任检查 | +| `machine::processors()`、`machine::memory_available()` | 并行度(原 `capacity`) | +| `stdio::read_input_for`、`stdio::output_to_error`;`terminal::raw_options`、`terminal::foreground` | 按键输入与协议输出(原终端交互) | +| `interrupt::end_as_output_closed()` | 读端关闭时按系统的方式结束(§25.5 第 4 条) | +| `posix::set_creation_mask` | 系统 home 的文件权限(umask 022) | +| `windows::code_page`、`console_output_code_page`、`long_paths_enabled`、`machine_registry_path`、`writable_fixed_drive` | 控制台输出、长路径声明、Windows SDK 与 home 的默认位置 | + +### 25.5 进程、取消与关闭的读端 + +mcpplibs.os 与 openkal 没有异步信号:结束请求(SIGINT、SIGTERM、SIGHUP,Windows 的控制台事件)被接管后置一个字, +由程序读取(`os::interrupt`);向读端已关闭的流写入返回 `errc::closed`,不结束程序。mcpp 由此采用以下模型, +取代信号处理器: + +1. **一个守望线程。** `main` 开始时由 `mcpp.run` 接管结束请求并启动守望线程,线程阻塞于 `os::interrupt::wait`。 + 请求到达时它结束已登记的子进程单元(`end_unit`),以原子替换写入中断文件,执行已登记的清理(恢复终端), + 然后以请求结束本程序(`end_as_requested`)。主线程无需轮询,等待中的子进程由其单元结束,§5.4 的取消记录与退出码 + 不变。 +2. **子进程。** 由 `mcpp.run` 统一启动:参数向量,`own_unit` 与 `bound_lifetime`,环境由调用覆盖(`mcpp.invocation`) + 得出;捕获、截止时间、空闲时限、逐行回调、停止条件按调用方给出,以 `os::process` 的有界读与有界等待实现。 + 命令解释器只用于本身就是命令文本的命令:用户书写的命令(`[hooks]`)、MSVC 的环境脚本,以及 mcpp 以文本组成并 + 如此记录的命令行——std 模块的构建命令(记录在其元数据中)、链接标志的 `-###` 检查、P1689 调试扫描、分离代码生成 + 所执行的 ninja 规则文本,它们与 ninja 规则同为一行文本。其余调用(编译器探测、git、xlings、tar、strip、patchelf、 + readelf、vswhere、`cl` 横幅)均为参数向量加环境追加。POSIX 解释器为 `sh -c`,Windows 为 + `cmd.exe /d /s /c "<命令>"`,以 `one_string` 原样传递(openkal 0.17.1 `KAL_SPAWN_ONE_STRING`)。 + 通用入口为 `run::execute(argv, Options)`:输入(空或继承)、两个输出流的去向(继承、合并读取、只读标准输出、 + 标准错误分开保留等)、总时限与空闲时限、逐行回调、停止条件、单元、宿主工具的加载器路径;`run_exec`、 + `capture_exec`、`capture_stdout`、`stream_exec` 等为其常用形式。 +3. **`mcpp run`。** `os::process::exec`:POSIX 上替换映像,退出码与信号即程序自身的。 +4. **关闭的读端。** mcpp 的输出写入失败于 `errc::closed`(或 `EPIPE`)时,按第 1 条的顺序结束子进程、写中断文件、 + 执行清理,然后 `os::interrupt::end_as_output_closed()`:POSIX 上以 SIGPIPE 结束(shell 看到 141),与此前相同; + Windows 上写入被拒绝而程序继续,与此前相同。 +5. **openkal 的配套发布。** openkal 0.17.1(`KAL_SPAWN_ONE_STRING`)、openkal-windows 0.13.1(实现它)、 + openkal-linux 0.18.1 与 openkal-macos 0.15.1(SIGHUP 属于结束请求,以及 §25.8 的两处);mcpplibs.os 0.2.0 + 以它们为下限。四个仓库的补丁在同名分支 `openkal-0.17.1` 上准备,openkal 的一致性检查按分支名取实现。 + +### 25.6 验收 + +`modules/platform` 与 `mcpp.platform*` 模块不存在;`mcpp::platform` 命名空间不存在;`src/` 与 `modules/` 中不出现操作系统原生头文件 +(只在 `native/` 的锁实现单元中),平台差异不以预处理条件表达(`if constexpr` 与按系统选入的实现单元)。全部单元测试与 e2e 通过;取消、退出码、SIGPIPE(941)、并发构建、 +`mcpp run` 退出码、Windows 长路径、宿主工具加载器路径的 e2e 不变地通过。 + +### 25.7 GCC 16 的模块接口约束(实测) + +拆除过程中,GCC 16.1 写出的若干编译后接口在下一个导入者处不可读(`failed to read compiled module cluster N: Bad file +data`、`failed to load pendings for 'std::vector'`)。以最小化的导入集合逐一定位,触发条件均为两个接口在同一导入者中 +合并时,其中一个接口单元自身携带了 `std` 模板的实例化: + +| 定位到的组合 | 处理 | +|---|---| +| `mcpp.build.command_line`(实现写在接口单元内)与 `mcpp.provider.xlings.subos_info` | 实现移入 `command_line.cpp` | +| `mcpp.toolchain.apple_sdk`(接口中的 `deployment_target` 函数体、`std::optional` 返回值)与 `mcpp.modgraph.graph` | 函数体移入实现单元;`sdk_has_target` 改为三值枚举 `Slice` | +| `mcpp.toolchain.clang`(实现写在接口单元内)与 `mcpp.modgraph.p1689` | 实现移入 `clang.cpp` | +| `mcpp.provider.xlings`(2000 行实现写在接口单元内) | 实现移入 `xlings.cpp` | +| `mcpp.toolchain.platform::is_android_host`(`inline` 且含静态局部 lambda) | 移入实现单元 | + +规则:新增或移动的模块,接口单元只含声明与常量,实现放在实现单元;接口不导入只有实现需要的模块(如 +`mcpplibs.os.process`)。 + +### 25.8 openkal 补丁发布中实测发现的行为 + +1. **解释器程序的名字(openkal-linux 0.18.1)。** openkal-linux 以打开的描述符启动程序(`execveat(fd, "", + AT_EMPTY_PATH)`),需要解释器的程序(`#!` 脚本)因此得到 `$0 = /dev/fd/`,以 `dirname "$0"` 定位同目录文件的 + 脚本找不到它们(mcpp 的单元测试 `NetworkProcess.MetadataAndResourceDeadlinesStopDescendants` 的桩脚本写入 + `/dev/fd/ticks`)。0.18.1 在内核答 ENOENT(需要解释器)时,若绝对名可知且与描述符是同一文件(设备号与 inode 相同), + 以绝对名 `execve`;否则保留描述符方式。测试 `script_sees_its_name`。 +2. **其他处理器的映像(openkal-macos 0.15.1)。** EBADARCH 与 EBADMACHO 此前译为输入输出错误,现与 ENOEXEC 同译为 + `kal_err_not_program`;mcpp 的 `runner_lookup` 据此只需识别 ENOEXEC 一种"本机不能装载"。测试 + `foreign_image_not_program`。 ## 附录 A:讨论收敛记录(摘要) | 问题 | 结论 | diff --git a/.github/tools/install_pinned_mcpp.sh b/.github/tools/install_pinned_mcpp.sh index 93b0509d..df8c2451 100755 --- a/.github/tools/install_pinned_mcpp.sh +++ b/.github/tools/install_pinned_mcpp.sh @@ -99,7 +99,7 @@ echo "bootstrap mcpp: $MCPP ($GOT)" >&2 # sandbox the caller restored holds the index as it was when that cache was # saved, and the pinned mcpp refreshes on a miss only for the root's own # [dependencies] (fixed in 2026.10.26.2): a version a path member names -- -# modules/platform's `os = "0.1.0"` -- missed against it with no refresh. +# a member's `os = "0.1.0"` -- missed against it with no refresh. "$MCPP" index update >&2 || echo "::warning::the bootstrap mcpp could not refresh its package index" >&2 echo "$MCPP" diff --git a/.github/workflows/ci-aarch64-fresh-install.yml b/.github/workflows/ci-aarch64-fresh-install.yml index d72b44e2..90da0698 100644 --- a/.github/workflows/ci-aarch64-fresh-install.yml +++ b/.github/workflows/ci-aarch64-fresh-install.yml @@ -31,7 +31,7 @@ on: branches: [ main ] paths: - 'src/build/build_program.cppm' - - 'modules/platform/src/process.cppm' + - 'src/run/run.cpp' - 'tests/e2e/168_build_mcpp_musl_host_static.sh' - '.github/workflows/ci-aarch64-fresh-install.yml' push: diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 668c0d47..64de87fa 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -84,7 +84,7 @@ jobs: run: echo 'APPLE_FLOAT_MACROS=-DINFINITY=HUGE_VALF -DNAN=__builtin_nanf("0x7fc00000")' >> "$GITHUB_ENV" # THE SDK mcpp'S OWN RESOLUTION NAMES: `xcrun --show-sdk-path` - # (modules/platform/src/macos/macos.cppm). These raw compiles state the + # (modules/toolchain-model/src/apple_sdk.cpp). These raw compiles state the # SDK the same way, on purpose. # # On the 22.1.8 line this was the known-red spot (mcpp#669 has the diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea9759e..3484838c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,40 @@ and in the result record's `entry`. A file a target names as `main` on any row is no longer linked as an ordinary source on the other rows. +### Changed + +- **mcpp keeps no operating-system layer of its own.** `modules/platform` is + removed; mcpp reaches the operating system through mcpplibs.os 0.2.0, and + what was mcpp's own meaning under that name now lives in the module that + owns it: the environment of an invocation in the `invocation` package, the + constraints of a run in `mcpp.effects`, every program mcpp starts in + `mcpp.run`, the loader environment and the run-time search in + `src/runtime`, command-line spelling in `mcpp.build.command_line`, the + platform keys and the Apple SDK in `toolchain-model`, the terminal and key + input in `src/ui`, the home's lock and trust check in `mcpp.home.files`. + The one primitive kept per system is the lock every release of mcpp takes + (`native/posix`, `native/windows`). +- **A program mcpp starts is named by its argument vector.** Compiler probes, + git, xlings, tar, strip, patchelf, readelf and vswhere are started without a + command interpreter, with their additions to the environment given to them + alone; the interpreter runs only commands that are text by nature (a + `[hooks]` command, an MSVC environment script, and the command lines mcpp + composes and records as text, as ninja's rules are). On Windows the + interpreter receives `cmd.exe /d /s /c ""` as one string. Every + started program reads an empty standard input unless it is the program + `mcpp run` becomes, and runs in a unit that ends with mcpp. +- **No signal handlers.** A request to end (SIGINT, SIGTERM, SIGHUP, a + console's control events) is taken over by mcpplibs.os and read by one + watcher thread, which ends the units of the running programs, writes the + interrupt files, restores the terminal and ends mcpp as the request would + have; the exit codes of §5 are unchanged. A closed standard output ends mcpp + in the same order (141 to a shell on POSIX). +- **mcpp does not write its own process environment.** Variables mcpp states + (its home, `--offline`, a hook's variables, xlings' home and PATH) are this + invocation's statements, read by every lookup mcpp makes and given to the + programs it starts; an xlings invocation's environment is the started + program's, on Windows as elsewhere. + ### Fixed - **A WebAssembly link writes its `.wasm` again.** 2026.10.26.1 published every diff --git a/docs/90-build-from-source.md b/docs/90-build-from-source.md index 76663392..275623d5 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -43,23 +43,32 @@ mcpp build --target x86_64-linux-musl ## Source Layout -mcpp is a **workspace**. `modules/` holds nine packages that the engine imports; -`src/` is the engine itself. +mcpp is a **workspace**. `modules/` holds ten packages that the engine imports; +`src/` is the engine itself. The operating system is reached through +[mcpplibs.os](https://github.com/mcpplibs/os); the one primitive mcpp keeps +per system, the lock every release of mcpp takes, is in `native/`. ``` modules/ workspace members, imported by src/ +├── invocation/ the environment of one invocation: statements over the started one ├── manifest/ manifest and descriptor parsing -├── platform/ operating-system abstraction ├── toolchain-model/ triples, dialects, fingerprints, the link model ├── buildmcpp/ the build.mcpp contract: protocol, directives, provisions ├── source-kind/ source-file role classification ├── versioning/ this binary's version, and SemVer requirements ├── dyndep/ ninja dyndep emission +├── graph/ the generic dependency graph ├── libs/ vendored text-format parsers └── log/ leveled logging +native/ the lock, per system (flock, LockFileEx) + src/ ├── main.cpp entry point +├── run.cppm run/ every program mcpp starts, and how mcpp ends at a request +├── effects.cppm the constraints of a run (offline, denied effects) +├── home.cppm home/ the home, its lock and its trust check +├── ui.cppm ui/ output, the terminal, key input ├── cli.cppm cli/ command dispatch and the commands ├── build/ build orchestration and the ninja backend ├── modgraph/ P1689 module scanning and the dependency graph @@ -68,24 +77,24 @@ src/ ├── pack/ publish/ mcpp pack, mcpp publish and xpkg generation ├── fetcher/ fallback/ download, installation, fallback resolution ├── bmi_cache/ the cross-project BMI cache -├── runtime/ xlings/ the runtime contract and the xlings bridge +├── runtime/ provider/ the runtime contract, launch environment and the xlings bridge ├── freestanding/ bare-metal targets, link line and runner └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 108 C++ tests, discovered by `mcpp test` -└── e2e/ 370 shell scripts against a real binary +├── unit/ 152 C++ tests, discovered by `mcpp test` +└── e2e/ 635 shell scripts against a real binary ``` ## Test Organization Two layers, and they answer different questions. -**Unit tests** (`tests/unit/`, 108 files) are C++ programs `mcpp test` +**Unit tests** (`tests/unit/`, 152 files) are C++ programs `mcpp test` discovers. They exercise a module's contract with no binary and no filesystem state. -**End-to-end tests** (`tests/e2e/NN_.sh`, 370 files) run a real `mcpp` +**End-to-end tests** (`tests/e2e/NN_.sh`, 635 files) run a real `mcpp` binary against a real project. `run_all.sh` is the CI entry point. `mcpp test` does **not** run them. diff --git a/docs/91-toolchain-internals.md b/docs/91-toolchain-internals.md index 98963a12..40e94ac1 100644 --- a/docs/91-toolchain-internals.md +++ b/docs/91-toolchain-internals.md @@ -197,7 +197,7 @@ intent, search mechanism, and post-link verdict are persisted in `resolution.json` schema 2. `mcpp why runtime` only interprets that stored file; re-diagnosis belongs to `xlings doctor`. -### 2.3 The run-time search closure (`modules/platform/src/runtime_search.cppm`) +### 2.3 The run-time search closure (`src/runtime/search.cppm`) mcpp passes `--sysroot=` on the compile **and** link lines, so a library the SubOS provides — `-lGL`, `-lX11`, `-lwayland-client` — resolves with no diff --git a/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index a990f457..b2d4b1f5 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -46,23 +46,31 @@ mcpp build --target x86_64-linux-musl ## 源码结构 -mcpp 是一个**工作空间**。`modules/` 下是引擎 import 的九个包;`src/` 是 -引擎本身。 +mcpp 是一个**工作空间**。`modules/` 下是引擎 import 的十个包;`src/` 是 +引擎本身。操作系统经由 [mcpplibs.os](https://github.com/mcpplibs/os) 访问; +mcpp 按系统保留的唯一原语(各版本 mcpp 共用的锁)位于 `native/`。 ``` modules/ workspace members, imported by src/ +├── invocation/ the environment of one invocation: statements over the started one ├── manifest/ manifest and descriptor parsing -├── platform/ operating-system abstraction ├── toolchain-model/ triples, dialects, fingerprints, the link model ├── buildmcpp/ the build.mcpp contract: protocol, directives, provisions ├── source-kind/ source-file role classification ├── versioning/ this binary's version, and SemVer requirements ├── dyndep/ ninja dyndep emission +├── graph/ the generic dependency graph ├── libs/ vendored text-format parsers └── log/ leveled logging +native/ the lock, per system (flock, LockFileEx) + src/ ├── main.cpp entry point +├── run.cppm run/ every program mcpp starts, and how mcpp ends at a request +├── effects.cppm the constraints of a run (offline, denied effects) +├── home.cppm home/ the home, its lock and its trust check +├── ui.cppm ui/ output, the terminal, key input ├── cli.cppm cli/ command dispatch and the commands ├── build/ build orchestration and the ninja backend ├── modgraph/ P1689 module scanning and the dependency graph @@ -71,23 +79,23 @@ src/ ├── pack/ publish/ mcpp pack, mcpp publish and xpkg generation ├── fetcher/ fallback/ download, installation, fallback resolution ├── bmi_cache/ the cross-project BMI cache -├── runtime/ xlings/ the runtime contract and the xlings bridge +├── runtime/ provider/ the runtime contract, launch environment and the xlings bridge ├── freestanding/ bare-metal targets, link line and runner └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 108 C++ tests, discovered by `mcpp test` -└── e2e/ 370 shell scripts against a real binary +├── unit/ 152 C++ tests, discovered by `mcpp test` +└── e2e/ 635 shell scripts against a real binary ``` ## 测试组织 两层,回答的是不同的问题。 -**单元测试**(`tests/unit/`,108 个文件)是 `mcpp test` 发现的 C++ +**单元测试**(`tests/unit/`,152 个文件)是 `mcpp test` 发现的 C++ 程序。它们检验一个模块的契约,不需要二进制、也不需要文件系统状态。 -**端到端测试**(`tests/e2e/NN_.sh`,370 个文件)拿真实的 `mcpp` +**端到端测试**(`tests/e2e/NN_.sh`,635 个文件)拿真实的 `mcpp` 二进制跑真实工程。`run_all.sh` 是 CI 的入口。`mcpp test` **不**运行它们。 ```bash diff --git a/docs/zh/91-toolchain-internals.md b/docs/zh/91-toolchain-internals.md index 4e47bb24..bb707757 100644 --- a/docs/zh/91-toolchain-internals.md +++ b/docs/zh/91-toolchain-internals.md @@ -181,7 +181,7 @@ edge。精确的 RuntimeBinding、规范身份、link intent、搜索机制与 判决都持久化在 `resolution.json` schema 2 中。`mcpp why runtime` 只解释 这份存储文件;重新诊断的职责属于 `xlings doctor`。 -### 2.3 运行期搜索闭包(`modules/platform/src/runtime_search.cppm`) +### 2.3 运行期搜索闭包(`src/runtime/search.cppm`) mcpp 在编译**与**链接两条命令行上都传 `--sysroot=`,因此 SubOS 提供的库 —— `-lGL`、`-lX11`、`-lwayland-client` —— 不需要用户加任何 flag diff --git a/mcpp.toml b/mcpp.toml index 55c7aa55..85ce3b42 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -34,6 +34,10 @@ default-profile = "release" # # Revert is this one line if anything here is ever unexplained. bmi_schedule = "on" +# mcpp's sources. The one primitive each system has of its own -- the lock +# every release of mcpp takes (native/, design 2026-10-26 §25.3) -- +# lives outside src/ and is selected per system below. +sources = ["src/**/*.cppm", "src/**/*.cpp"] # mcpp.exe declares the UTF-8 code page (mcpp#693); see docs/res/mcpp.rc. Only a PE # target compiles a resource script, so this changes nothing elsewhere. @@ -84,6 +88,20 @@ linkage = "static" [dependencies] mcpplibs.cmdline = "0.0.1" +# The operating system is mcpplibs.os's (design 2026-10-26 §25): mcpp keeps no +# layer of its own. +[dependencies.mcpplibs] +os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } + +[target.linux.build] +sources = ["native/posix/*.cpp"] + +[target.macos.build] +sources = ["native/posix/*.cpp"] + +[target.windows.build] +sources = ["native/windows/*.cpp"] + # `modules/` holds what gets linked into this binary; `src/` is what has not # been separated yet, and shrinking it is the direction. Each entry is an # independent package with its own manifest, so the thing that used to be a @@ -100,26 +118,26 @@ mcpplibs.cmdline = "0.0.1" # it is NOT what makes them importable. That is these path dependencies, and # needing both is easy to get wrong because either one alone looks sufficient. [dependencies.mcpp] +invocation = { path = "modules/invocation" } libs = { path = "modules/libs" } log = { path = "modules/log" } versioning = { path = "modules/versioning" } source-kind = { path = "modules/source-kind" } graph = { path = "modules/graph" } dyndep = { path = "modules/dyndep" } -platform = { path = "modules/platform" } manifest = { path = "modules/manifest" } toolchain-model = { path = "modules/toolchain-model" } buildmcpp = { path = "modules/buildmcpp" } [workspace] members = [ + "modules/invocation", "modules/libs", "modules/log", "modules/versioning", "modules/source-kind", "modules/graph", "modules/dyndep", - "modules/platform", "modules/manifest", "modules/toolchain-model", "modules/buildmcpp", @@ -131,8 +149,6 @@ members = [ gtest = "1.15.2" # Everything else uses M5.0 conventions: -# - sources = src/**/*.{cppm,cpp,cc,c} (default glob picks up -# the 21 .cppm modules + src/main.cpp) # - target = mcpp (kind=bin inferred from src/main.cpp) # - standard = c++23 # - static_stdlib = true (default; portable binary) diff --git a/modules/buildmcpp/mcpp.toml b/modules/buildmcpp/mcpp.toml index e1d58249..01ae7b6a 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -32,6 +32,7 @@ license = "Apache-2.0" # contract change; one that breaks only the global test is a consumer that had # been relying on something the contract never promised. [dependencies.mcpp] +invocation = { path = "../invocation" } libs = { path = "../libs" } manifest = { path = "../manifest" } source-kind = { path = "../source-kind" } diff --git a/modules/buildmcpp/src/program_protocol.cppm b/modules/buildmcpp/src/program_protocol.cppm index c72eb17a..b4b8c59e 100644 --- a/modules/buildmcpp/src/program_protocol.cppm +++ b/modules/buildmcpp/src/program_protocol.cppm @@ -29,6 +29,7 @@ export module mcpp.build.program_protocol; import std; +import mcpp.invocation; export namespace mcpp::build::program_protocol { @@ -197,9 +198,9 @@ std::chrono::milliseconds run_timeout_for(std::optional manifestSecs); namespace mcpp::build::program_protocol { std::optional env_timeout_override() { - const char* v = std::getenv("MCPP_BUILD_PROGRAM_TIMEOUT"); + const auto v = mcpp::invocation::get("MCPP_BUILD_PROGRAM_TIMEOUT"); if (!v) return std::nullopt; - std::string_view sv(v); + std::string_view sv(*v); int parsed = 0; auto r = std::from_chars(sv.data(), sv.data() + sv.size(), parsed); if (r.ec != std::errc{} || r.ptr != sv.data() + sv.size()) return std::nullopt; diff --git a/modules/buildmcpp/src/tool_store.cppm b/modules/buildmcpp/src/tool_store.cppm index a7c4078c..c5a0c5b1 100644 --- a/modules/buildmcpp/src/tool_store.cppm +++ b/modules/buildmcpp/src/tool_store.cppm @@ -44,6 +44,7 @@ export module mcpp.build.tool_store; import std; +import mcpp.invocation; import mcpp.libs.json; import mcpp.manifest; import mcpp.toolchain.fingerprint; @@ -308,8 +309,8 @@ find_override(const mcpp::manifest::Manifest& rootManifest, if (name.empty()) continue; auto var = "MCPP_TOOL_" + sanitize_env(name) + "_" + sanitize_env(std::string(toolName)); - if (const char* v = std::getenv(var.c_str()); v && *v) - return fs::path(v); + if (auto v = mcpp::invocation::get(var)) + return fs::path(*v); } // 2. [tools.overrides] in the ROOT manifest. Accepts both the canonical // and the namespace-stripped spelling, matching how `tools = [...]` diff --git a/modules/invocation/mcpp.toml b/modules/invocation/mcpp.toml new file mode 100644 index 00000000..25a02259 --- /dev/null +++ b/modules/invocation/mcpp.toml @@ -0,0 +1,16 @@ +# invocation — the environment of one invocation of mcpp. +# +# mcpp states variables for itself and for every program it starts (its home, +# the constraints of the run, a hook's variables) without writing them into +# its own process environment. This package is that statement, and every +# package of mcpp reads the environment through it, so a variable stated for +# an invocation is the value each of them reads (design 2026-10-26 §25.3). +[package] +name = "invocation" +namespace = "mcpp" +version = "0.1.0" +description = "The environment of one invocation of mcpp: statements over the started environment" +license = "Apache-2.0" + +[dependencies.mcpplibs] +os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } diff --git a/modules/invocation/src/invocation.cpp b/modules/invocation/src/invocation.cpp new file mode 100644 index 00000000..50cf2d75 --- /dev/null +++ b/modules/invocation/src/invocation.cpp @@ -0,0 +1,135 @@ +// mcpp.invocation over mcpplibs.os: the started environment read through +// os::env, the statements held here. +module mcpp.invocation; + +import std; +import mcpplibs.os.platform; +import mcpplibs.os.env; +import mcpplibs.os.process; + +namespace mcpp::invocation { + +namespace { + +namespace os = mcpplibs::os; + +// Names compare without case on Windows, as the system compares them. +std::string key_of(std::string_view name) { + std::string k(name); + if constexpr (os::is_windows) + for (auto& c : k) c = static_cast(std::toupper(static_cast(c))); + return k; +} + +struct Statement { + std::string name; // as stated + std::optional value; // nullopt: absent +}; + +std::shared_mutex g_lock; +std::map& statements() { + static std::map s; + return s; +} + +std::optional> stated(std::string_view name) { + std::shared_lock g(g_lock); + auto& s = statements(); + if (auto it = s.find(key_of(name)); it != s.end()) return it->second.value; + return std::nullopt; +} + +} // namespace + +std::optional get(std::string_view name) { + if (auto s = stated(name)) { + if (!*s || (*s)->empty()) return std::nullopt; + return **s; + } + auto v = os::env::get(name); + if (!v || v->empty()) return std::nullopt; + return v; +} + +void set(std::string name, std::string value) { + std::unique_lock g(g_lock); + auto k = key_of(name); + statements()[k] = Statement{ std::move(name), std::move(value) }; +} + +void unset(std::string name) { + std::unique_lock g(g_lock); + auto k = key_of(name); + statements()[k] = Statement{ std::move(name), std::nullopt }; +} + +std::vector with_prefix(std::string_view prefix) { + std::map seen; // key -> NAME=value + for (auto& e : os::env::variables()) { + const auto eq = e.find('='); + if (eq == std::string::npos || eq + 1 == e.size()) continue; + if (!std::string_view(e).starts_with(prefix)) continue; + seen[key_of(std::string_view(e).substr(0, eq))] = e; + } + { + std::shared_lock g(g_lock); + for (auto& [k, st] : statements()) { + if (!std::string_view(st.name).starts_with(prefix)) continue; + if (!st.value || st.value->empty()) seen.erase(k); + else seen[k] = st.name + "=" + *st.value; + } + } + std::vector out; + for (auto& [k, e] : seen) out.push_back(e); + std::ranges::sort(out); + return out; +} + +std::vector>> overrides() { + std::shared_lock g(g_lock); + std::vector>> out; + for (auto& [k, st] : statements()) out.emplace_back(st.name, st.value); + return out; +} + +ScopedEnv::ScopedEnv(std::string name, std::optional value) : name_(std::move(name)) { + previous_ = stated(name_); + if (value) set(name_, *value); + else unset(name_); +} + +ScopedEnv::~ScopedEnv() { + std::unique_lock g(g_lock); + auto& s = statements(); + if (!previous_) s.erase(key_of(name_)); + else s[key_of(name_)] = Statement{ name_, *previous_ }; +} + +std::optional env_directory(std::string_view name) { + if (auto v = get(name)) return std::filesystem::path(*v); + return std::nullopt; +} + +void prepend_path(const std::filesystem::path& dir) { + const auto current = get("PATH").value_or(""); + const std::string sep(1, os::path_list_separator); + set("PATH", current.empty() ? dir.string() : dir.string() + sep + current); +} + +std::optional which(std::string_view program) { + return os::env::find_executable(program, get("PATH").value_or("")); +} + +std::filesystem::path self_exe_path() { + return os::env::image().value_or(std::filesystem::path{}); +} + +long process_id() { + static const long id = [] { + auto me = os::process::self(); + return me ? static_cast(me->id) : 0L; + }(); + return id; +} + +} // namespace mcpp::invocation diff --git a/modules/invocation/src/invocation.cppm b/modules/invocation/src/invocation.cppm new file mode 100644 index 00000000..f52f2ac7 --- /dev/null +++ b/modules/invocation/src/invocation.cppm @@ -0,0 +1,62 @@ +// mcpp.invocation — the environment of this invocation of mcpp. +// +// mcpp states variables for itself and for every program it starts: the home +// it uses (MCPP_HOME), the constraints of the run (MCPP_OFFLINE, +// MCPP_DENY_EFFECTS), a hook's variables, a toolchain's PATH on Windows. It +// does not write them into its own process environment: mcpplibs.os reads that +// environment once, as it was given, and a program it starts receives what +// its caller states (design 2026-10-26 §25.3). This module is the statement: +// an override of the started environment, read by every lookup mcpp makes and +// given to every program mcpp starts. +export module mcpp.invocation; + +import std; + +export namespace mcpp::invocation { + +// A variable's value: this invocation's statement, else the environment mcpp +// was started with. Unset and empty are one answer, nullopt. +std::optional get(std::string_view name); + +// States a variable for this invocation and every program it starts. +void set(std::string name, std::string value); +// States that a variable is absent, whatever mcpp was started with. +void unset(std::string name); + +// Every variable whose name begins with `prefix`, as `NAME=value`, sorted, +// empty values left out: for an identity that changes when any of a family of +// variables does. +std::vector with_prefix(std::string_view prefix); + +// The statements, as a started program receives them: a value sets the +// variable, nullopt removes it. +std::vector>> overrides(); + +// A variable stated for a scope, the previous statement restored after it. +class ScopedEnv { +public: + ScopedEnv(std::string name, std::optional value); + ~ScopedEnv(); + ScopedEnv(const ScopedEnv&) = delete; + ScopedEnv& operator=(const ScopedEnv&) = delete; +private: + std::string name_; + std::optional> previous_; // the statement before, if any +}; + +// A directory named by a variable; nothing when it is unset or empty. +std::optional env_directory(std::string_view name); + +// `dir` before PATH, for this invocation's lookups and its programs. +void prepend_path(const std::filesystem::path& dir); + +// A program found on this invocation's PATH (with PATHEXT on Windows). +std::optional which(std::string_view program); + +// The file this program was started from. +std::filesystem::path self_exe_path(); + +// The identifier the system gives this process. +long process_id(); + +} // namespace mcpp::invocation diff --git a/modules/log/mcpp.toml b/modules/log/mcpp.toml index d8433168..8077c0f9 100644 --- a/modules/log/mcpp.toml +++ b/modules/log/mcpp.toml @@ -10,3 +10,6 @@ namespace = "mcpp" version = "0.1.0" description = "Leveled logging for mcpp" license = "Apache-2.0" + +[dependencies.mcpp] +invocation = { path = "../invocation" } diff --git a/modules/log/src/log.cppm b/modules/log/src/log.cppm index cd0ece10..141c2302 100644 --- a/modules/log/src/log.cppm +++ b/modules/log/src/log.cppm @@ -13,12 +13,12 @@ // Log file: /mcpp.log (rotated at max_file_size, keeps max_files) module; -#include #include export module mcpp.log; import std; +import mcpp.invocation; export namespace mcpp::log { @@ -115,12 +115,14 @@ std::string timestamp() { auto tt = std::chrono::system_clock::to_time_t(now); auto ms = std::chrono::duration_cast( now.time_since_epoch()) % 1000; + // std::localtime shares one result among its callers; the copy is taken + // under a lock, which is what localtime_r and localtime_s do per system. std::tm tm{}; -#if defined(_WIN32) - localtime_s(&tm, &tt); -#else - localtime_r(&tt, &tm); -#endif + { + static std::mutex m; + std::lock_guard lock(m); + if (const std::tm* local = std::localtime(&tt)) tm = *local; + } char buf[32]; std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d.%03d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, @@ -190,8 +192,8 @@ void write_stderr(std::string_view tag, std::string_view message) { void init(const Config& cfg) { // Priority: env var > --verbose > config > default - if (auto* e = std::getenv("MCPP_LOG_LEVEL"); e && *e) { - g_level = parse_level(e); + if (auto e = mcpp::invocation::get("MCPP_LOG_LEVEL")) { + g_level = parse_level(e->c_str()); } else if (g_verbose && cfg.level == Level::off) { g_level = Level::info; // --verbose auto-enables info } else { diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index 30fea923..a718d4c0 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -18,13 +18,17 @@ description = "Manifest and descriptor parsing for mcpp" license = "Apache-2.0" [dependencies.mcpp] +invocation = { path = "../invocation" } libs = { path = "../libs" } -platform = { path = "../platform" } source-kind = { path = "../source-kind" } +toolchain-model = { path = "../toolchain-model" } versioning = { path = "../versioning" } # Subsystem-level tests. They see only this package, which is what lets them # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. +[dependencies.mcpplibs] +os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } + [dev-dependencies.compat] gtest = "1.15.2" diff --git a/modules/manifest/src/glob.cppm b/modules/manifest/src/glob.cppm index 54b7360f..793fedc2 100644 --- a/modules/manifest/src/glob.cppm +++ b/modules/manifest/src/glob.cppm @@ -9,8 +9,8 @@ export module mcpp.modgraph.glob; import std; -import mcpp.platform.common; // is_windows -import mcpp.platform.windows; // active_code_page +import mcpplibs.os.platform; +import mcpplibs.os.windows; export namespace mcpp::modgraph { @@ -356,8 +356,8 @@ std::string escaped_spelling(const std::filesystem::path& p) { } std::string no_utf8_spelling_reason() { - if constexpr (mcpp::platform::is_windows) { - const unsigned acp = mcpp::platform::windows::active_code_page(); + if constexpr (mcpplibs::os::is_windows) { + const unsigned acp = mcpplibs::os::windows::code_page(); if (acp != 65001) return std::format( "This process runs in the ANSI code page {} rather than UTF-8: " diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index b40423c5..5f4ae7f9 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -7,14 +7,15 @@ import mcpp.manifest.key_registry; import mcpp.manifest.cfg_selector; import mcpp.targetside; import std; +import mcpp.invocation; import mcpp.source_kind; import mcpp.libs.toml; import mcpp.pm.dep_spec; import mcpp.version_req; import mcpp.pm.dependency_selector; import mcpp.pm.index_spec; -import mcpp.platform; -import mcpp.platform.axis; // the one macos/macosx spelling rule +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.xpkg_version; // the release grammar of `mcpp = ">=V"` import mcpp.version; // MCPP_VERSION -- a refused key may be a newer one @@ -227,12 +228,12 @@ struct LoadContext { // // `macos` AND `macosx` ARE ONE PLATFORM, AND THE RULE IS NOT WRITTEN HERE. // mcpp's triple vocabulary says `macos`; a descriptor and xlings' project file -// say `macosx`. `mcpp::platform::xpkg_platform_key_for` is the one place that +// say `macosx`. `mcpp::toolchain::platform::xpkg_platform_key_for` is the one place that // knows, and `xpkg_platform` is the host in the same vocabulary — an earlier // draft of this section hand-rolled both, which is the second copy of a rule // this file exists to avoid. inline std::string_view host_platform_key() { - return mcpp::platform::xpkg_platform; + return mcpp::toolchain::platform::xpkg_platform; } // Split `:` on the FIRST colon. xlings writes a namespace this @@ -257,7 +258,7 @@ platform_values(const mcpp::libs::toml::Value& v) { for (auto& [k, val] : v.as_table()) { auto canon = k == "default" ? std::optional("default") - : mcpp::platform::xpkg_platform_key_for(k); + : mcpp::toolchain::platform::xpkg_platform_key_for(k); if (!canon) return std::unexpected(std::format( "unknown platform key '{}'; expected one of linux, macosx " @@ -277,7 +278,7 @@ value_for_platform(const std::vector>& vals, // Folded on BOTH sides: a caller may name the host `macos` (mcpp's triple // spelling) while the stored key is already canonical. const std::string_view want = - mcpp::platform::xpkg_platform_key_for(platform).value_or(platform); + mcpp::toolchain::platform::xpkg_platform_key_for(platform).value_or(platform); auto pick = [&](std::string_view k) -> std::optional { for (auto const& [key, v] : vals) if (key == k) return v; return std::nullopt; @@ -923,8 +924,8 @@ std::filesystem::path absolute_sysroot_path(const std::filesystem::path& origin, const std::string& written) { std::filesystem::path p(written); if (written == "~" || written.starts_with("~/")) { - const char* h = std::getenv(mcpp::platform::is_windows ? "USERPROFILE" : "HOME"); - if (h && *h) p = std::filesystem::path(h) / (written.size() > 2 ? written.substr(2) : std::string{}); + if (auto h = mcpp::invocation::get(mcpplibs::os::is_windows ? "USERPROFILE" : "HOME")) + p = std::filesystem::path(*h) / (written.size() > 2 ? written.substr(2) : std::string{}); } if (p.is_relative()) { std::error_code ec; @@ -2993,7 +2994,7 @@ std::expected parse_document(const t::Document& documen // Every platform, for the descriptor emitter. Resolved per // platform rather than stored raw: the emitter wants the answer, // and `default` is part of producing it. - for (auto plat : mcpp::platform::xpkg_platforms) { + for (auto plat : mcpp::toolchain::platform::xpkg_platforms) { auto v = value_for_platform(*vals, plat); if (!v) continue; auto e = make_xlings_entry(k, *v); diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 11bba26b..5e720e17 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -9,7 +9,6 @@ import std; import mcpp.pm.dep_spec; // M5.x pm/ subsystem refactor: DependencySpec lives here import mcpp.pm.compat; // Legacy dependency-key compatibility helpers import mcpp.pm.index_spec; // IndexSpec for [indices] section -import mcpp.platform; import mcpp.manifest.flag_words; import mcpp.targetside; diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index 6a152347..338e7a9e 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -8,8 +8,7 @@ import mcpp.manifest.cfg_selector; import std; import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; -import mcpp.platform; -import mcpp.platform.axis; +import mcpp.toolchain.platform; import mcpp.targetside; import mcpp.version_req; @@ -53,13 +52,13 @@ struct XpkgVersionEntry { // Returns an empty vector if the platform table is missing or has no entries. std::vector list_xpkg_version_entries(std::string_view luaContent, - const mcpp::platform::PlatformKey& platform); + const mcpp::toolchain::platform::PlatformKey& platform); // Keys only, aliases included — the shape every caller that just wants to show // or existence-check the published versions wants. std::vector list_xpkg_versions(std::string_view luaContent, - const mcpp::platform::PlatformKey& platform); + const mcpp::toolchain::platform::PlatformKey& platform); // Union of per-OS version-key lists, semver-descending, deduplicated — the // display shape behind `mcpp add` suggestions and `mcpp search` (#487). See @@ -244,7 +243,7 @@ std::expected synthesize_from_xpkg_lua(std::string_view luaContent, std::string_view packageName, std::string_view packageVersion, - const mcpp::platform::PlatformKey& platform); + const mcpp::toolchain::platform::PlatformKey& platform); // mcpp#237: the mcpp-segment key vocabulary is a CLOSED whitelist (the parse // loop's else-if chain). An unrecognised key is collected into @@ -995,7 +994,7 @@ xpkg_name_form_violation_from_lua(std::string_view luaContent) std::vector list_xpkg_version_entries(std::string_view luaContent, - const mcpp::platform::PlatformKey& platformAxis) { + const mcpp::toolchain::platform::PlatformKey& platformAxis) { const std::string_view platform = platformAxis.key(); // Locate `xpm = { ... = { ["X.Y.Z"] = {...}, ... } ... }`. // We work on a sanitized copy so quoted version keys remain locatable @@ -1137,7 +1136,7 @@ list_xpkg_version_entries(std::string_view luaContent, std::vector list_xpkg_versions(std::string_view luaContent, - const mcpp::platform::PlatformKey& platformAxis) { + const mcpp::toolchain::platform::PlatformKey& platformAxis) { std::vector out; for (auto& e : list_xpkg_version_entries(luaContent, platformAxis)) out.push_back(std::move(e.version)); @@ -1236,10 +1235,10 @@ XpkgCatalogFields xpkg_catalog_fields(std::string_view lua) { std::vector all_xpkg_versions_desc(std::string_view luaContent) { std::vector> perPlatform; - perPlatform.reserve(std::size(mcpp::platform::xpkg_platforms)); - for (auto os : mcpp::platform::xpkg_platforms) + perPlatform.reserve(std::size(mcpp::toolchain::platform::xpkg_platforms)); + for (auto os : mcpp::toolchain::platform::xpkg_platforms) perPlatform.push_back(list_xpkg_versions( - luaContent, mcpp::platform::TargetPlatform::for_lint_of(os))); + luaContent, mcpp::toolchain::platform::TargetPlatform::for_lint_of(os))); return merge_xpkg_versions_desc(perPlatform); } @@ -1323,7 +1322,7 @@ std::expected synthesize_from_xpkg_lua(std::string_view luaContent, std::string_view packageName, std::string_view packageVersion, - const mcpp::platform::PlatformKey& platform) + const mcpp::toolchain::platform::PlatformKey& platform) { auto body = extract_mcpp_segment_body(luaContent); if (body.empty()) { diff --git a/modules/platform/mcpp.toml b/modules/platform/mcpp.toml deleted file mode 100644 index 3ecf3ad0..00000000 --- a/modules/platform/mcpp.toml +++ /dev/null @@ -1,52 +0,0 @@ -# platform — the operating-system abstraction, as a package. -# -# Everything mcpp does that differs by OS: filesystem, process spawning, the -# target axis vocabulary, ELF/Mach-O/PE facts, path spelling. It imports -# nothing from mcpp, which is what makes it separable at all -- and it did not -# start that way. -# -# Two edges were cut to get here, and both were the same mistake in different -# clothes: -# -# * `src/platform/xlings/` was never platform. It is the xlings integration, -# which by its nature knows about packages, indexes and manifests, and it -# lived under a name that said otherwise. It moved to `src/xlings/`. -# * `runtime_binding.cppm` imported the whole of `mcpp.config` to read ONE -# path out of it, which put the OS layer above configuration, xlings and -# the package manager. The caller passes the path now, and that file -- -# which describes the xlings runtime contract rather than the OS -- moved -# to `src/runtime/` with `elf_runtime.cppm`, its only dependent here. -# -# 35 modules import this one. That is the reason to make the boundary a fact -# the build enforces rather than a directory convention. -[package] -name = "platform" -namespace = "mcpp" -version = "0.1.0" -description = "Operating-system abstraction for mcpp" -license = "Apache-2.0" - -# Subsystem-level tests. They see only this package, which is what lets them -# state its contract in isolation; the root `tests/unit/` states the same facts -# as the engine consumes them, and the pair is the cross-check. -[dev-dependencies.compat] -gtest = "1.15.2" - -# The operating-system layer is mcpplibs.os over openkal (design 2026-10-26 -# part 2, §10.1). -[dependencies.mcpplibs] -os = "0.1.0" - -# Every interface is compiled everywhere; implementation units are selected -# per system, so no source in this package carries a preprocessor conditional. -[build] -sources = ["src/*.cppm", "src/*/*.cppm", "src/*.cpp"] - -[target.linux.build] -sources = ["src/linux/*.cpp", "src/posix/*.cpp"] - -[target.macos.build] -sources = ["src/macos/*.cpp", "src/posix/*.cpp"] - -[target.windows.build] -sources = ["src/windows/*.cpp"] diff --git a/modules/platform/src/common.cppm b/modules/platform/src/common.cppm deleted file mode 100644 index 3e247bc8..00000000 --- a/modules/platform/src/common.cppm +++ /dev/null @@ -1,49 +0,0 @@ -// mcpp.platform.common — the platform constants mcpp's code dispatches on. -// -// They are mcpplibs.os's compile-time constants (design 2026-10-26 part 2, -// R3), spelt as mcpp's code has always read them; nothing here is a macro, and -// every branch on them is an `if constexpr' that discards the other systems. - -export module mcpp.platform.common; - -import std; -import mcpplibs.os.platform; - -namespace mcpp::platform { -namespace os = mcpplibs::os; -} - -export namespace mcpp::platform { - -// ── Binary / library name conventions ───────────────────────────────────── - -constexpr bool is_windows = os::is_windows; -constexpr bool is_macos = os::is_macos; -constexpr bool is_linux = os::is_linux; - -constexpr std::string_view exe_suffix = os::exe_suffix; -constexpr std::string_view static_lib_ext = is_windows ? ".lib" : ".a"; -constexpr std::string_view shared_lib_ext = is_windows ? ".dll" : is_macos ? ".dylib" : ".so"; -constexpr std::string_view lib_prefix = is_windows ? "" : "lib"; - -// ── Shell redirect to null device ───────────────────────────────────────── - -constexpr std::string_view null_redirect = is_windows ? "2>nul" : "2>/dev/null"; - -// ── Platform identification ─────────────────────────────────────────────── - -constexpr std::string_view name = os::name(os::PLATFORM); - -// The architecture of THIS build, in the GNU spelling `triple::parse' writes. -constexpr std::string_view host_arch = os::name(os::ARCH); - -// The platform key xpkg descriptors use: `macosx' where the triple says `macos'. -constexpr std::string_view xpkg_platform = is_windows ? "windows" : is_macos ? "macosx" : "linux"; - -// ── Feature flags ───────────────────────────────────────────────────────── - -constexpr bool supports_full_static = is_linux; // macOS/Windows cannot -constexpr bool supports_rpath = !is_windows; // ELF + Mach-O only -constexpr bool needs_explicit_libcxx = is_macos; // macOS: -lc++ required - -} // namespace mcpp::platform diff --git a/modules/platform/src/env.cppm b/modules/platform/src/env.cppm deleted file mode 100644 index 6a2dcfc6..00000000 --- a/modules/platform/src/env.cppm +++ /dev/null @@ -1,330 +0,0 @@ -// mcpp.platform.env — platform-aware environment variable operations. -// -// Windows: uses _putenv_s to mutate the calling process environment. -// POSIX: supports scoped current-process env mutation for child processes. -// -// The two operations that differ between systems --- the process's own -// variable table, and writing to it --- are declared below and defined once -// per system (src//env.cpp); everything else is one text, dispatching -// on mcpp.platform.common's constants. - -export module mcpp.platform.env; - -import std; -import mcpp.platform.common; - -export namespace mcpp::platform::env { - -// Get an environment variable. Returns nullopt if not set. -std::optional get(std::string_view key); - -// Set an environment variable in the current process. -void set(const std::string& key, const std::string& value); - -// Remove an environment variable from the current process. On Windows this is -// `_putenv_s(key, "")`, which the CRT defines as removal, so absent and empty -// are one state there, and `get` answers nullopt for both on every platform. -void unset(const std::string& key); - -// Every variable whose name starts with `prefix`, as `NAME=value` in the -// order the process environment holds them, empty values left out. For an -// identity that has to change when any of a family of variables does -// (`MCPP_TARGET__SYSROOT`), where the names are not known in advance. -std::vector with_prefix(std::string_view prefix); - -// ── Network policy: offline mode ────────────────────────────────────────── -// -// One process-wide knob answering "may mcpp reach the network at all?", read -// at each point of use rather than threaded through a dozen signatures — the -// same idiom the older MCPP_NO_AUTO_INSTALL gate already uses. -// -// It lives HERE, in a leaf module, on purpose: the consumers span pm (index -// refresh, package install) and build (toolchain auto-install), and any home -// inside one of those subsystems would make the other import it across a layer -// boundary — mcpp.pm.index_route already imports mcpp.fetcher, so a pm-owned -// helper could not be read from the fetcher without a module cycle. -// -// Set by `--offline` (cli pre-scan writes the env var) or by the user exporting -// MCPP_OFFLINE. "0" and the empty string mean off, matching MCPP_VERBOSE. -bool offline_mode(); - -// THE EFFECTS THIS RUN MAY NOT HAVE (design 2026-10-26 §5.5, D8). -// -// `--deny ` and MCPP_DENY_EFFECTS name effects of docs/50 §4 the -// caller does not allow: `network`, `write-project`, `write-global-cache`, -// `exec-build-script`, `init-mcpp-home` (`read-project` cannot be denied). -// The two are a union, and nothing a project declares widens them: the -// constraint is the caller's. `--offline` is `--deny network`, and both -// spellings answer `offline_mode()`. A run that stops before a denied effect -// records it here, and the process exits 5 (SPEC-003): a caller tells "my -// constraint stopped it" from "it failed" by the status alone. -bool effect_denied(std::string_view effect); -// The deniable effects, for the CLI's validation of `--deny`. -const std::vector& deniable_effects(); -void note_effect_refused(std::string_view effect, std::string_view what); -// "network: install xim:gcc@15.1.0" -- the first refusal of this run, if any. -std::optional effect_refused(); - -// WHETHER THIS RUN WRITES THE GLOBAL CACHE. Answers kept under the home (a -// compiler's probe, the provider's handshake) are read by every command and -// written only by a run whose effects include `write-global-cache`: one that -// plans or builds (it says so with `note_writes_global_cache`) and was not -// denied it. A query -- `self doctor`, `self env`, `toolchain list` -- declares -// no such effect, so it leaves the home exactly as it found it. -void note_writes_global_cache(); -bool writes_global_cache(); - -// The older, narrower spelling: MCPP_NO_AUTO_INSTALL gates ONLY the toolchain -// auto-install, nothing else. Kept working (CI and existing setups export it) -// and kept next to offline_mode() so the two network knobs cannot drift apart. -bool no_auto_install(); - -// WHAT THIS RUN DID WITH THE NETWORK, AS OPPOSED TO WHAT IT MAY DO. -// -// docs/50 defines an envelope's `effects` as what running the command did, -// and `--protocol-version` as what a command may do. The per-run list used to -// be assembled from constants, so a plan that refreshed the index reported no -// `network` (#648 A4). A launcher of a network-bound child (an index refresh, -// an install, a git remote operation) calls `note_network_access` before it -// starts the child; an attempt that fails or times out still counts, because -// the effect is the access. Offline, those launchers return before this call. -// Kept beside offline_mode() for the reason stated above: every layer can reach -// this module, and the record has writers in several of them. -void note_network_access(); -bool network_accessed(); - -// Temporarily set or unset an env var, restoring the prior value on scope exit. -class ScopedEnv { -public: - ScopedEnv(std::string key, std::optional value); - ~ScopedEnv(); - - ScopedEnv(const ScopedEnv&) = delete; - ScopedEnv& operator=(const ScopedEnv&) = delete; - -private: - std::string key_; - std::optional previous_; - bool had_previous_ = false; -}; - -std::string path_list_separator(); -// The PATH this process was started with. On Windows a child's environment is -// applied to this process (`_putenv_s`), so `getenv("PATH")` answers what the -// children launched so far were given; a value that must not depend on that -- -// an environment a build program's run is keyed on -- starts from this one. -const std::string& inherited_path(); -std::string runtime_library_path_key(); -std::string host_tool_runtime_library_path_key(); - -// Drop mcpp's private-glibc payload entries from a loader search path. -// -// An outer `mcpp run`/`mcpp test` points LD_LIBRARY_PATH at the payload so ITS -// child (a sandbox-linked binary) can load. Anything further down the process -// tree that is a HOST binary — /bin/sh, or a payload tool patched against a -// DIFFERENT payload version — then resolves a mismatched libc.so.6 against the -// host ld.so and dies inside the dynamic linker before main (signature: a bare -// `__vdso_time` line, then SIGSEGV). libc and ld.so are version-locked through -// GLIBC_PRIVATE, so this never reproduces when the two happen to match. -// -// Lives here, next to path-list composition, because BOTH consumers need the -// same predicate: process.cppm sanitizes the INHERITED variable for every child, -// and prepend_path_list sanitizes the inherited TAIL it carries into a composed -// override (dirs the caller passed explicitly are always kept — that is the -// entry the sandbox binary actually needs). -std::string strip_private_glibc(std::string_view paths); - -std::string prepend_path_list(std::string_view key, - std::span dirs); - -// Build a shell command prefix that injects the given env vars. -// Windows: calls set() for each var and returns "". -// POSIX: returns "KEY1='val1' KEY2='val2' " (caller prepends to command). -std::string build_env_prefix( - const std::vector>& vars); - -// The process's own variable table as the system holds it: `NAME=value' -// entries, null-terminated, current with every `set' and `unset'. For a -// launcher that passes it on (src//env.cpp). -char** environment(); - -} // namespace mcpp::platform::env - -// The per-system primitive (src//env.cpp). Not exported. -namespace mcpp::platform::env::detail { -// Sets `key' to `value', or removes it when `value' is null. -void put(const std::string& key, const char* value); -} // namespace mcpp::platform::env::detail - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::env { - -namespace { -// Initialised before main, ahead of anything that starts a child. -const std::string kInheritedPath = [] { - const char* p = std::getenv("PATH"); - return p ? std::string(p) : std::string{}; -}(); -} // namespace - -const std::string& inherited_path() { return kInheritedPath; } - -bool offline_mode() { - // Not cached: `mcpp test` runs nested mcpp invocations in-process in some - // paths, and a test that flips the var must see the change. - auto* v = std::getenv("MCPP_OFFLINE"); - return (v && *v && std::string_view(v) != "0") || effect_denied("network"); -} - -const std::vector& deniable_effects() { - static const std::vector names{ - "init-mcpp-home", "write-project", "write-global-cache", "network", "exec-build-script"}; - return names; -} - -bool effect_denied(std::string_view effect) { - auto* v = std::getenv("MCPP_DENY_EFFECTS"); - if (!v || !*v) return false; - std::string_view list(v); - while (!list.empty()) { - auto comma = list.find(','); - auto item = list.substr(0, comma); - while (!item.empty() && item.front() == ' ') item.remove_prefix(1); - while (!item.empty() && item.back() == ' ') item.remove_suffix(1); - if (item == effect) return true; - if (comma == std::string_view::npos) break; - list.remove_prefix(comma + 1); - } - return false; -} - -namespace { -std::mutex g_refusedMutex; -std::optional g_refused; -} - -void note_effect_refused(std::string_view effect, std::string_view what) { - std::lock_guard lock(g_refusedMutex); - if (!g_refused) g_refused = what.empty() ? std::string(effect) - : std::format("{}: {}", effect, what); -} - -std::optional effect_refused() { - std::lock_guard lock(g_refusedMutex); - return g_refused; -} - -namespace { -std::atomic g_writesGlobalCache{false}; -} - -void note_writes_global_cache() { g_writesGlobalCache.store(true); } - -bool writes_global_cache() { - return g_writesGlobalCache.load() && !effect_denied("write-global-cache"); -} - -bool no_auto_install() { - auto* v = std::getenv("MCPP_NO_AUTO_INSTALL"); - return v && *v && std::string_view(v) != "0"; -} - -namespace { -std::atomic g_network_accessed{false}; -} - -void note_network_access() { g_network_accessed.store(true, std::memory_order_relaxed); } -bool network_accessed() { return g_network_accessed.load(std::memory_order_relaxed); } - -std::optional get(std::string_view key) { - std::string k(key); - auto* v = std::getenv(k.c_str()); - if (!v || !*v) return std::nullopt; - return std::string(v); -} - -std::vector with_prefix(std::string_view prefix) { - std::vector out; - for (char** env = environment(); env && *env; ++env) { - std::string_view e(*env); - if (!e.starts_with(prefix)) continue; - auto eq = e.find('='); - if (eq == std::string_view::npos || eq + 1 == e.size()) continue; - out.emplace_back(e); - } - std::ranges::sort(out); - return out; -} - -void set(const std::string& key, const std::string& value) { - detail::put(key, value.c_str()); -} - -void unset(const std::string& key) { - detail::put(key, nullptr); -} - -ScopedEnv::ScopedEnv(std::string key, std::optional value) - : key_(std::move(key)) { - if (auto* existing = std::getenv(key_.c_str())) { - previous_ = std::string(existing); - had_previous_ = true; - } - - if (value) { - set(key_, *value); - } else { - unset(key_); - } -} - -ScopedEnv::~ScopedEnv() { - if (had_previous_ && previous_) { - set(key_, *previous_); - } else { - unset(key_); - } -} - -std::string runtime_library_path_key() { - if constexpr (is_windows) return "PATH"; - // DYLD_LIBRARY_PATH affects every executable launched by ninja, including - // ninja itself, and can make macOS system frameworks load an incompatible - // private libc++/libc++abi. Keep macOS on toolchain-provided rpaths. - else if constexpr (is_macos) return ""; - else if constexpr (is_linux) return "LD_LIBRARY_PATH"; - else return ""; -} - -std::string host_tool_runtime_library_path_key() { - if constexpr (is_macos) return "DYLD_LIBRARY_PATH"; - else if constexpr (is_linux) return "LD_LIBRARY_PATH"; - else return ""; -} - -std::string build_env_prefix( - const std::vector>& vars) -{ - // Windows: the variables are set in this process, which every child - // started afterwards inherits; there is no prefix a command line carries. - if constexpr (is_windows) { - for (auto& [k, v] : vars) set(k, v); - return ""; - } - std::string prefix; - for (auto& [k, v] : vars) { - prefix += k; - prefix += '='; - prefix += '\''; - for (char c : v) { - if (c == '\'') prefix += "'\\''"; - else prefix += c; - } - prefix += '\''; - prefix += ' '; - } - return prefix; -} - -} // namespace mcpp::platform::env diff --git a/modules/platform/src/env_lists.cpp b/modules/platform/src/env_lists.cpp deleted file mode 100644 index 49ffae2b..00000000 --- a/modules/platform/src/env_lists.cpp +++ /dev/null @@ -1,51 +0,0 @@ -// The lists of directories mcpp composes for a child's environment, over mcpplibs.os's -// conventions (the host's separator, an entry quoted whole on Windows). An implementation unit, so -// the interface of mcpp.platform.env imports nothing beyond what it declares. -module mcpp.platform.env; - -import std; -import mcpplibs.os; - -namespace mcpp::platform::env { - -std::string path_list_separator() { - return std::string(1, mcpplibs::os::conventions().path_list_separator); -} - -std::string strip_private_glibc(std::string_view paths) { - std::vector kept; - for (auto& item : mcpplibs::os::env::split_list(paths)) { - const auto text = item.generic_string(); - if (text.find("/xim-x-glibc/") == std::string::npos) kept.push_back(std::move(item)); - } - return mcpplibs::os::env::join_list(kept); -} - -std::string prepend_path_list(std::string_view key, - std::span dirs) { - if (key.empty() || dirs.empty()) return ""; - std::vector given; - for (auto& dir : dirs) if (!dir.empty()) given.push_back(dir); - std::string value = mcpplibs::os::env::join_list(given); - if (value.empty()) return ""; - - std::string k(key); - if (auto* existing = std::getenv(k.c_str()); existing && *existing) { - // Loader paths only: the inherited value may carry an OUTER mcpp's - // private-glibc entry, and the composed string becomes an EXPLICIT - // override — which process.cppm's merged_environ takes verbatim, - // skipping the sanitation it applies to inherited variables. Without - // this the strip is defeated exactly when it matters (a nested - // `mcpp run` → `mcpp test` chain), and the child tool segfaults in the - // dynamic linker whenever the payload versions differ. - std::string tail = (k == "LD_LIBRARY_PATH" || k == "DYLD_LIBRARY_PATH") - ? strip_private_glibc(existing) : std::string(existing); - if (!tail.empty()) { - value += path_list_separator(); - value += tail; - } - } - return value; -} - -} // namespace mcpp::platform::env diff --git a/modules/platform/src/fs.cppm b/modules/platform/src/fs.cppm deleted file mode 100644 index c6dd1f14..00000000 --- a/modules/platform/src/fs.cppm +++ /dev/null @@ -1,247 +0,0 @@ -// mcpp.platform.fs — platform-specific filesystem operations. -// -// Consolidates three patterns that were duplicated across the codebase: -// 1. self_exe_path() — current executable path (was in config.cppm & -// ninja_backend.cppm) -// 2. which() — find an executable in PATH (was in config.cppm & -// probe.cppm) -// 3. FileLock — RAII exclusive file lock (was in bmi_cache.cppm) -// -// The first two are mcpplibs.os's; replace_file, append_atomically and the -// lock are each system's own (posix/fs.cpp, windows/fs.cpp). - -export module mcpp.platform.fs; - -import std; -import mcpp.platform.common; -import mcpplibs.os.env; - -export namespace mcpp::platform::fs { - -// ── self_exe_path ───────────────────────────────────────────────────────── -// -// Returns the absolute path of the currently running executable, as the -// system states it (mcpplibs::os::env::image). -std::filesystem::path self_exe_path(); - -// ── which ───────────────────────────────────────────────────────────────── -// -// Find an executable by name in PATH, with PATHEXT's suffixes on Windows -// (mcpplibs::os::env::find_executable). PATH is read as it is now: mcpp sets -// variables of its own environment for the programs it starts. A name that is -// also a shell builtin (`true`) is the program of that name on PATH. -std::optional which(std::string_view binary_name); - -// ── extended_length ─────────────────────────────────────────────────────── -// -// The form of a path that a file operation may open whatever its length. -// -// A Win32 path that is not in the extended-length form is limited to 259 -// characters, and a relative path counts the working directory against that -// limit. The engine subcommands ninja invokes (`mcpp dyndep`, `mcpp stage`, -// the `bmi-*` edges, `coff-def`, the check stamps) receive paths relative to a -// build directory, and in a host-tool sub-build those paths crossed the limit: -// `clang-scan-deps` wrote a `.ddi` that `mcpp dyndep` then could not read -// (mcpp#641, item 3). -// -// Windows the absolute, lexically normal path with backslashes, prefixed -// `\\?\` (a UNC path `\\server\share\x` becomes -// `\\?\UNC\server\share\x`); a path already in that form is -// returned unchanged. -// POSIX the argument, unchanged. No limit of this kind exists. -std::filesystem::path extended_length(const std::filesystem::path& p); - -// The Windows spelling rule of `extended_length`, as a pure function of an -// absolute, generic (forward-slash or backslash) path string, so that the rule -// is tested on every host. An empty or relative input is returned unchanged. -std::string windows_extended_length_spelling(std::string_view absolutePath); - -// 不先删除目标文件,避免发布失败时丢失最后一份可用的编译数据库。 -bool replace_file(const std::filesystem::path& source, - const std::filesystem::path& destination, - std::error_code& ec); - -// Appends `bytes` to the file, creating it, in one write that another process -// appending to the same file cannot split: `O_APPEND` on POSIX, an open for -// FILE_APPEND_DATA alone on Windows, where the C runtime's append mode seeks -// and then writes, and two writers can overwrite each other. -bool append_atomically(const std::filesystem::path& file, std::string_view bytes); - -// ── FileLock ────────────────────────────────────────────────────────────── -// -// RAII exclusive non-blocking file lock. -// Windows: LockFileEx on a HANDLE -// POSIX: flock(2) on a file descriptor -// Not mcpplibs::os::fs::lock, whose POSIX lock is an open file description's -// (F_OFD_SETLK): an flock and such a lock do not exclude each other, and every -// release before this one holds these files with flock -- two releases at work -// in one MCPP_HOME would each hold the lock at once. -class FileLock { -public: - // Try to acquire an exclusive lock on /.lock (or /). - // Returns nullopt if another process holds the lock. - static std::optional try_acquire(const std::filesystem::path& dir, - std::string_view name = ".lock"); - - // WAITS for the lock on /, which `try_acquire` never does. - // - // Once held, `owner` is published beside the lock as /.owner - // (replaced atomically): who holds it, for a waiter to name. A sidecar - // rather than the lock file's own bytes, because a Windows byte-range lock - // is mandatory and would refuse that read. While the lock is taken by - // another process, `waiting` is called once, after `notice` has passed, - // with what the holder published (empty when it published nothing). - // - // The wait polls with a backoff from 20 ms to 200 ms rather than blocking - // in the kernel: a blocking flock/LockFileEx cannot report the wait, and a - // waiter killed by a signal holds nothing either way. nullopt only when - // the lock file cannot be created at all (an unwritable directory, whose - // build fails on its own terms). - static std::optional acquire( - const std::filesystem::path& dir, std::string_view name, std::string_view owner, - std::chrono::milliseconds notice, - const std::function& waiting); - - // What the current holder of / published (see acquire). - static std::string holder(const std::filesystem::path& dir, std::string_view name); - - ~FileLock(); - FileLock(FileLock&& o) noexcept; - FileLock& operator=(FileLock&& o) noexcept; - - FileLock(const FileLock&) = delete; - FileLock& operator=(const FileLock&) = delete; - -private: - // A descriptor, or a HANDLE; -1 is neither (INVALID_HANDLE_VALUE). - std::intptr_t native_ = -1; - explicit FileLock(std::intptr_t native) : native_(native) {} -}; - -} // namespace mcpp::platform::fs - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::fs { - -std::filesystem::path self_exe_path() { - if (auto image = mcpplibs::os::env::image()) { - std::error_code ec; - auto p = std::filesystem::canonical(*image, ec); - return ec ? *image : p; - } - // Fallback: hope the binary is in PATH - return "mcpp"; -} - -std::optional which(std::string_view binary_name) { - const char* path = std::getenv("PATH"); - return mcpplibs::os::env::find_executable(binary_name, path ? path : ""); -} - -std::string windows_extended_length_spelling(std::string_view in) { - std::string s(in); - for (auto& c : s) if (c == '/') c = '\\'; - // Already extended-length (`\\?\`) or a device path (`\\.\`): the - // prefix disables every further interpretation, so nothing is rewritten. - if (s.starts_with("\\\\?\\") || s.starts_with("\\\\.\\")) return s; - - std::string prefix; - std::string rest; - if (s.starts_with("\\\\")) { - prefix = "\\\\?\\UNC\\"; - rest = s.substr(2); - } else if (s.size() >= 3 && std::isalpha(static_cast(s[0])) - && s[1] == ':' && s[2] == '\\') { - prefix = "\\\\?\\"; - rest = s; - } else { - return std::string(in); // relative or rootless: not this function's input - } - - // The prefix turns off `.`/`..` processing, so the components are resolved - // here, lexically. A `..` never climbs above the drive or the share. - std::vector parts; - std::size_t keep = prefix.ends_with("UNC\\") ? 2 : 1; // share or drive - std::size_t start = 0; - while (start <= rest.size()) { - auto end = rest.find('\\', start); - if (end == std::string::npos) end = rest.size(); - std::string comp = rest.substr(start, end - start); - start = end + 1; - if (comp.empty() || comp == ".") continue; - if (comp == "..") { - if (parts.size() > keep) parts.pop_back(); - continue; - } - parts.push_back(std::move(comp)); - } - std::string out = prefix; - for (std::size_t i = 0; i < parts.size(); ++i) { - if (i) out += '\\'; - out += parts[i]; - } - // A bare drive keeps its root separator: `\\?\C:\`, not `\\?\C:`. - if (parts.size() == 1 && keep == 1) out += '\\'; - return out; -} - -std::filesystem::path extended_length(const std::filesystem::path& p) { - if constexpr (!is_windows) return p; - if (p.empty()) return p; - std::error_code ec; - auto abs = std::filesystem::absolute(p, ec); - if (ec) return p; - // Through UTF-8, so that a name the ANSI code page cannot spell survives: - // the path narrowing rule (mcpp-contributing) forbids `.string()` here. - const auto u8 = abs.generic_u8string(); - std::string narrow(reinterpret_cast(u8.data()), u8.size()); - const auto spelled = windows_extended_length_spelling(narrow); - return std::filesystem::path(std::u8string( - reinterpret_cast(spelled.data()), spelled.size())); -} - -// ── FileLock ────────────────────────────────────────────────────────────── - -std::optional FileLock::acquire( - const std::filesystem::path& dir, std::string_view name, std::string_view owner, - std::chrono::milliseconds notice, - const std::function& waiting) { - namespace fs = std::filesystem; - const auto start = std::chrono::steady_clock::now(); - auto pause = std::chrono::milliseconds(20); - bool told = false; - for (;;) { - if (auto lock = try_acquire(dir, name)) { - if (!owner.empty()) { - const auto record = dir / (std::string(name) + ".owner"); - auto tmp = record; - tmp += std::format(".{:x}", std::random_device{}()); - { - std::ofstream out(tmp, std::ios::binary | std::ios::trunc); - out << owner; - } - std::error_code ec; - fs::rename(tmp, record, ec); - if (ec) fs::remove(tmp, ec); - } - return lock; - } - // try_acquire could not even create the file: nothing to wait for. - std::error_code ec; - if (!fs::exists(dir / std::string(name), ec)) return std::nullopt; - if (!told && std::chrono::steady_clock::now() - start >= notice) { - told = true; - if (waiting) waiting(holder(dir, name)); - } - std::this_thread::sleep_for(pause); - pause = (std::min)(pause * 2, std::chrono::milliseconds(200)); // parenthesised: defines min - } -} - -std::string FileLock::holder(const std::filesystem::path& dir, std::string_view name) { - std::ifstream in(dir / (std::string(name) + ".owner"), std::ios::binary); - return in ? std::string(std::istreambuf_iterator(in), {}) : std::string{}; -} - -} // namespace mcpp::platform::fs diff --git a/modules/platform/src/fs_caps.cppm b/modules/platform/src/fs_caps.cppm deleted file mode 100644 index f515c5bf..00000000 --- a/modules/platform/src/fs_caps.cppm +++ /dev/null @@ -1,229 +0,0 @@ -// What a filesystem does, measured where it is, not assumed from the -// platform (mcpp-community/mcpp#809; the same table as xlings' -// `platform::fs_caps`, openxlings/xlings#671). An MCPP_HOME and a project -// land on whatever the person has: ext4, APFS, NTFS -- and NFSv3, SMB, FAT, a -// FUSE mount, WSL's drvfs. NFSv3 answers renameat2(RENAME_NOREPLACE) with -// EINVAL: `mcpp new` failed there, and so would every publication that asked -// the flag. Each primitive below asks this table, once per filesystem, and -// takes the strategy that filesystem allows; none guesses from an errno. - -export module mcpp.platform.fs_caps; - -import std; -import mcpplibs.os.fs; - -export namespace mcpp::platform { - -struct FsCaps { - std::string type; // "ext4", "nfs", "apfs", "NTFS", ... ("" unknown) - bool rename_no_replace { true }; // a rename that refuses an existing name, atomically - bool rename_exchange { true }; // two entries swapped atomically - bool hard_links { true }; - bool symlinks { true }; - bool case_sensitive { true }; - bool measured { false }; // false: the probe could not run; the platform's native answers - - // Whether every operation mcpp asks for is the filesystem's own. - bool native() const { return rename_no_replace && rename_exchange && hard_links && symlinks; } -}; - -// The filesystem holding `dir` (an existing directory), measured once per -// filesystem per process: a scratch entry in `dir`, removed after. -FsCaps fs_caps(const std::filesystem::path& dir); - -// Measured now, not cached (tests, `mcpp self doctor`). -FsCaps probe_fs_caps(const std::filesystem::path& dir); - -// Publish `from` (a scratch on the same filesystem) under `to`, never over an -// existing entry, by what `caps` says the filesystem does: -// a native rename that refuses replacement; -// else a hard link for a file and the same link again for a symbolic link -// (each refused as atomically when the name exists), the scratch name then -// removed; -// else, for a directory, a rename onto a name that is not there -- the one -// window, a name made there in between, is the caller's own under its lock. -std::expected publish_no_replace(const std::filesystem::path& from, - const std::filesystem::path& to, - const FsCaps& caps); - -// The same, with the caps of `to`'s directory. -std::expected rename_no_replace(const std::filesystem::path& from, - const std::filesystem::path& to); - -// `a` and `b` (both present, on one filesystem) trade places: atomically where -// the filesystem exchanges; else `b` is moved aside, `a` renamed onto `b`'s -// name and the old `b` put at `a` -- a reader of `b` meets the name missing -// for that moment, never a half of either. -std::expected exchange_paths(const std::filesystem::path& a, - const std::filesystem::path& b); - -// Whether a directory another program wrote may be read and run from: it -// belongs to this user or to the system (root; on Windows also Administrators -// and SYSTEM), and when it is not this user's, this user cannot write it. -// Empty: trusted; else why not. Using another person's home is running -// what that person can replace at any time. -std::string untrusted_because(const std::filesystem::path& dir); - -// `link` made a link to the directory `target`: a symbolic link; on Windows, -// where one needs a privilege a user rarely has, a junction. -bool create_directory_link(const std::filesystem::path& target, const std::filesystem::path& link); - -} // namespace mcpp::platform - -namespace mcpp::platform::native { - -// Each system's own, in linux/, macos/ and windows/ fs_caps.cpp: what -// mcpplibs.os does not carry, because no other system has it. - -// `a` and `b` trade places, atomically: 0, else the system's code, which -// `unsupported` says is the filesystem declining. -int exchange(const std::filesystem::path& a, const std::filesystem::path& b); -bool unsupported(int error); -std::error_category const& category(); - -// Empty when `dir`'s owner is this user or the system; else why not. -std::string owner_problem(const std::filesystem::path& dir); - -// The filesystem's name, as the system reports it ("" unknown). -std::string filesystem_type(const std::filesystem::path& dir); - -} // namespace mcpp::platform::native - -namespace mcpp::platform { - -// Not `fs`: mcpp::platform::fs is the namespace of mcpp.platform.fs, and an -// alias of the same name beside it is ill-formed (GCC 16 crashes reading the -// two modules into one translation unit). -namespace stdfs = std::filesystem; - -FsCaps probe_fs_caps(const stdfs::path& dir) { - FsCaps caps; - caps.type = native::filesystem_type(dir); - std::error_code ec; - std::random_device random; - const auto scratch = dir / std::format(".mcpp-fsprobe-{:08x}{:08x}", random(), random()); - if (!stdfs::create_directory(scratch, ec) || ec) return caps; // cannot write here: native answers - const auto touch = [&](const stdfs::path& p) { std::ofstream(p).put('x'); return stdfs::exists(p, ec); }; - if (touch(scratch / "a") && touch(scratch / "c")) { - caps.measured = true; - if (const auto r = mcpplibs::os::fs::rename(scratch / "a", scratch / "b", false); !r) { - caps.rename_no_replace = r.error().code != mcpplibs::os::errc::not_supported; - stdfs::rename(scratch / "a", scratch / "b", ec); - } - if (const int e = native::exchange(scratch / "b", scratch / "c"); e != 0) - caps.rename_exchange = !native::unsupported(e); - ec.clear(); - stdfs::create_hard_link(scratch / "b", scratch / "d", ec); - caps.hard_links = !ec; - ec.clear(); - stdfs::create_symlink("b", scratch / "e", ec); - caps.symlinks = !ec; - ec.clear(); - if (touch(scratch / "Case")) caps.case_sensitive = !stdfs::exists(scratch / "cASE", ec); - } - stdfs::remove_all(scratch, ec); - return caps; -} - -FsCaps fs_caps(const stdfs::path& dir) { - static std::mutex lock; - static std::map known; - const auto identity = mcpplibs::os::fs::identity_of(dir); - const std::uint64_t device = identity ? identity->volume : 0; - { - std::scoped_lock guard(lock); - if (device != 0) - if (const auto it = known.find(device); it != known.end()) return it->second; - } - auto caps = probe_fs_caps(dir); - // An unmeasured answer is not kept: the next directory of that filesystem - // may be writable. - if (device != 0 && caps.measured) { - std::scoped_lock guard(lock); - known.emplace(device, caps); - } - return caps; -} - -std::expected publish_no_replace(const stdfs::path& from, const stdfs::path& to, const FsCaps& caps) { - const auto failed = [&](std::error_code error) { - return std::unexpected(std::format("cannot publish {} as {}: {}", from.string(), to.string(), error.message())); - }; - if (caps.rename_no_replace) { - const auto r = mcpplibs::os::fs::rename(from, to, false); - if (r) return {}; - if (r.error().code != mcpplibs::os::errc::not_supported) - return std::unexpected(std::format("cannot publish {} as {}: {}", from.string(), to.string(), - mcpplibs::os::describe(r.error().code))); - // Measured elsewhere on this filesystem, refused here (a mount under - // the directory probed): the strategies below. - } - std::error_code ec; - const auto status = stdfs::symlink_status(from, ec); - if (ec) return failed(ec); - if (status.type() == stdfs::file_type::symlink) { - // A link made again with the same target refuses a name that is - // there, as the rename flag would. (A hard link to a symbolic link is - // the link on Linux but its target on macOS.) - stdfs::copy_symlink(from, to, ec); - if (ec) return failed(ec); - stdfs::remove(from, ec); - return {}; - } - if (status.type() != stdfs::file_type::directory && caps.hard_links) { - stdfs::create_hard_link(from, to, ec); - if (ec) return failed(ec); - stdfs::remove(from, ec); - return {}; - } - const auto there = stdfs::symlink_status(to, ec); - if (ec && ec != std::errc::no_such_file_or_directory) return failed(ec); - if (stdfs::exists(there)) return failed(std::make_error_code(std::errc::file_exists)); - stdfs::rename(from, to, ec); - if (ec) return failed(ec); - return {}; -} - -std::expected rename_no_replace(const stdfs::path& from, const stdfs::path& to) { - auto parent = to.parent_path(); - if (parent.empty()) parent = "."; - return publish_no_replace(from, to, fs_caps(parent)); -} - -bool create_directory_link(const stdfs::path& target, const stdfs::path& link) { - return mcpplibs::os::fs::create_link(target, link, true).has_value(); -} - -std::string untrusted_because(const stdfs::path& dir) { - std::error_code ec; - if (!stdfs::is_directory(dir, ec)) return "it is not a directory"; - return native::owner_problem(dir); -} - -std::expected exchange_paths(const stdfs::path& a, const stdfs::path& b) { - auto parent = b.parent_path(); - if (parent.empty()) parent = "."; - if (fs_caps(parent).rename_exchange) { - const int e = native::exchange(a, b); - if (e == 0) return {}; - if (!native::unsupported(e)) - return std::unexpected(std::format("cannot exchange {} and {}: {}", a.string(), b.string(), - std::error_code(e, native::category()).message())); - } - std::error_code ec; - std::random_device random; - const auto aside = parent / std::format(".{}.mcpp-aside-{:08x}", b.filename().string(), random()); - stdfs::rename(b, aside, ec); - if (ec) return std::unexpected(std::format("cannot move {} aside: {}", b.string(), ec.message())); - stdfs::rename(a, b, ec); - if (ec) { - std::error_code back; - stdfs::rename(aside, b, back); - return std::unexpected(std::format("cannot move {} to {}: {}", a.string(), b.string(), ec.message())); - } - stdfs::rename(aside, a, ec); - if (ec) return std::unexpected(std::format("cannot move the old {} to {}: {}", b.string(), a.string(), ec.message())); - return {}; -} - -} // namespace mcpp::platform diff --git a/modules/platform/src/linux.cppm b/modules/platform/src/linux.cppm deleted file mode 100644 index 57f997e0..00000000 --- a/modules/platform/src/linux.cppm +++ /dev/null @@ -1,114 +0,0 @@ -// mcpp.platform.linux — Linux-specific platform capabilities. -// -// Provides: -// build_ld_library_path() — construct LD_LIBRARY_PATH prefix -// runtime_lib_dirs() — Linux toolchain runtime library search paths - -export module mcpp.platform.linux; - -import std; -import mcpp.platform.common; -import mcpp.platform.shell; - -export namespace mcpp::platform::linux_ { - -// Build an LD_LIBRARY_PATH shell prefix from a list of directories. -// Returns "env LD_LIBRARY_PATH=:${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} " -// or "" if dirs is empty. -std::string build_ld_library_path_prefix( - const std::vector& dirs); - -// Build an LD_LIBRARY_PATH shell prefix for toolchain host processes. -// Unlike build_ld_library_path_prefix(), this does not append inherited -// LD_LIBRARY_PATH, which may contain target-program runtime directories. -std::string build_clean_ld_library_path_prefix( - const std::vector& dirs); - -// Return Linux toolchain runtime library directories. -std::vector -runtime_lib_dirs(const std::filesystem::path& toolchain_root); - -// Is this Linux an Android (D21)? `ANDROID_ROOT` and `ANDROID_DATA` are set in -// every Android process environment (Termux included), and -// `/system/bin/linker64` is bionic's dynamic loader. Answers which shell -// exists and how a failure is explained; it does not choose a toolchain: -// Android takes the same default as GNU/Linux on its architecture. -bool is_android_host(); - -} // namespace mcpp::platform::linux_ - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::linux_ { - -namespace { - -std::string join_dirs(const std::vector& dirs) { - std::string joined; - for (auto& d : dirs) { - if (!joined.empty()) joined += ':'; - joined += d.string(); - } - return joined; -} - -} // namespace - -std::string build_ld_library_path_prefix( - const std::vector& dirs) { - if constexpr (is_linux) { - if (dirs.empty()) return ""; - auto joined = join_dirs(dirs); - return std::format("env LD_LIBRARY_PATH={}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}} ", - mcpp::platform::shell::quote(joined)); - } else { - (void)dirs; - return ""; - } -} - -std::string build_clean_ld_library_path_prefix( - const std::vector& dirs) { - if constexpr (is_linux) { - if (dirs.empty()) return ""; - auto joined = join_dirs(dirs); - return std::format("env LD_LIBRARY_PATH={} ", - mcpp::platform::shell::quote(joined)); - } else { - (void)dirs; - return ""; - } -} - -std::vector -runtime_lib_dirs(const std::filesystem::path& toolchain_root) { - std::vector dirs; - if constexpr (is_linux) { - auto add = [&](const std::filesystem::path& p) { - if (std::filesystem::exists(p)) - dirs.push_back(p); - }; - add(toolchain_root / "lib" / "x86_64-unknown-linux-gnu"); - add(toolchain_root / "lib" / "aarch64-unknown-linux-gnu"); - add(toolchain_root / "lib" / "aarch64-linux-gnu"); - } else { - (void)toolchain_root; - } - return dirs; -} - -bool is_android_host() { - if constexpr (is_linux) { - static const bool android = [] { - if (std::getenv("ANDROID_ROOT") && std::getenv("ANDROID_DATA")) return true; - std::error_code ec; - return std::filesystem::exists("/system/bin/linker64", ec) - || std::filesystem::exists("/system/bin/linker", ec); - }(); - return android; - } else { - return false; - } -} - -} // namespace mcpp::platform::linux_ diff --git a/modules/platform/src/linux/bounded_process.cpp b/modules/platform/src/linux/bounded_process.cpp deleted file mode 100644 index 0541c6cd..00000000 --- a/modules/platform/src/linux/bounded_process.cpp +++ /dev/null @@ -1,15 +0,0 @@ -// mcpp.platform.unix.bounded_process on Linux: both ends of a pipe are -// close-on-exec from the call that makes them. -module; -#include -#include - -module mcpp.platform.unix.bounded_process; - -namespace mcpp::platform::unixproc { - -int make_pipe(int fds[2]) { - return ::pipe2(fds, O_CLOEXEC); -} - -} // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/linux/capacity.cpp b/modules/platform/src/linux/capacity.cpp deleted file mode 100644 index 260ecae2..00000000 --- a/modules/platform/src/linux/capacity.cpp +++ /dev/null @@ -1,91 +0,0 @@ -// mcpp.platform.capacity on linux: what the machine has, from the system's own services. -module; -#include -#include // atoi -#include -#include - -module mcpp.platform.capacity; - -import std; - -namespace mcpp::platform::capacity { - -static bool read_meminfo_kb(const char* key, unsigned long long& out) { - FILE* f = ::fopen("/proc/meminfo", "r"); - if (!f) return false; - char line[256]; - bool found = false; - const size_t klen = ::strlen(key); - while (::fgets(line, sizeof(line), f)) { - if (::strncmp(line, key, klen) != 0) continue; - unsigned long long kb = 0; - if (::sscanf(line + klen, " %llu", &kb) == 1) { out = kb * 1024ull; found = true; } - break; - } - ::fclose(f); - return found; -} - -static int read_cpuinfo_cores() { - FILE* f = ::fopen("/proc/cpuinfo", "r"); - if (!f) return 0; - char line[256]; - int cores = 0; - while (::fgets(line, sizeof(line), f)) { - if (::strncmp(line, "cpu cores", 9) != 0) continue; - const char* colon = ::strchr(line, ':'); - if (colon) cores = ::atoi(colon + 1); - break; - } - ::fclose(f); - return cores; -} - -// Hybrid x86 reports differing per-CPU maximum frequencies. Cheapest reliable -// signal short of CPUID; when cpufreq is absent the answer is "cannot tell", -// which must be reported as NOT heterogeneous — a false positive here would -// halve the job count on an ordinary homogeneous server. -static bool detect_hybrid(int logical) { - if (logical <= 1) return false; - long first = -1; - for (int i = 0; i < logical; ++i) { - char path[128]; - ::snprintf(path, sizeof(path), - "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i); - FILE* f = ::fopen(path, "r"); - if (!f) return false; - long v = 0; - const int got = ::fscanf(f, "%ld", &v); - ::fclose(f); - if (got != 1) return false; - if (first < 0) first = v; - else if (v != first) return true; - } - return false; -} - -HostCapacity host_capacity() { - HostCapacity cap; - const long n = ::sysconf(_SC_NPROCESSORS_ONLN); - if (n > 0) cap.logicalCores = static_cast(n); - - const int cores = read_cpuinfo_cores(); - cap.physicalCores = cores > 0 ? cores : cap.logicalCores; - cap.heterogeneous = detect_hybrid(cap.logicalCores); - - const long pages = ::sysconf(_SC_PHYS_PAGES); - const long psize = ::sysconf(_SC_PAGE_SIZE); - if (pages > 0 && psize > 0) - cap.totalBytes = static_cast(pages) - * static_cast(psize); - - // MemAvailable is the kernel's own estimate of what a new workload can get - // without swapping — strictly better than MemFree, which excludes reclaimable - // page cache and would make every warm machine look starved. - if (!read_meminfo_kb("MemAvailable:", cap.availableBytes)) - cap.availableBytes = cap.totalBytes; - return cap; -} - -} // namespace mcpp::platform::capacity diff --git a/modules/platform/src/linux/env.cpp b/modules/platform/src/linux/env.cpp deleted file mode 100644 index a14e7049..00000000 --- a/modules/platform/src/linux/env.cpp +++ /dev/null @@ -1,10 +0,0 @@ -// mcpp.platform.env on Linux: the process's own variable table. -module mcpp.platform.env; - -import std; - -// POSIX names it and no header must declare it; C linkage attaches it to the -// global module. -extern "C" char** environ; - -char** mcpp::platform::env::environment() { return environ; } diff --git a/modules/platform/src/linux/fs_caps.cpp b/modules/platform/src/linux/fs_caps.cpp deleted file mode 100644 index 98c2f7ee..00000000 --- a/modules/platform/src/linux/fs_caps.cpp +++ /dev/null @@ -1,46 +0,0 @@ -// mcpp.platform.fs_caps on Linux: renameat2's exchange and statfs's magic. -module; -#include -#include -#include -#include -#include - -module mcpp.platform.fs_caps; - -import std; - -namespace mcpp::platform::native { - -int exchange(const std::filesystem::path& a, const std::filesystem::path& b) { - constexpr unsigned kExchange = 2; // renameat2 RENAME_EXCHANGE - return ::syscall(SYS_renameat2, AT_FDCWD, a.c_str(), AT_FDCWD, b.c_str(), kExchange) == 0 ? 0 : errno; -} -bool unsupported(int error) { return error == EINVAL || error == ENOSYS || error == EOPNOTSUPP; } -std::error_category const& category() { return std::generic_category(); } - -std::string filesystem_type(const std::filesystem::path& dir) { - struct statfs st {}; - if (::statfs(dir.c_str(), &st) != 0) return {}; - switch (static_cast(st.f_type)) { - case 0xEF53: return "ext4"; - case 0x58465342: return "xfs"; - case 0x9123683E: return "btrfs"; - case 0x01021994: return "tmpfs"; - case 0x794C7630: return "overlay"; - case 0x6969: return "nfs"; - case 0xFF534D42: return "cifs"; - case 0xFE534D42: return "smb2"; - case 0x4D44: return "vfat"; - case 0x2011BAB0: return "exfat"; - case 0x7366746E: return "ntfs3"; - case 0x5346544E: return "ntfs"; - case 0x65735546: return "fuse"; - case 0x01021997: return "9p"; - case 0x2FC12FC1: return "zfs"; - case 0x00C36400: return "ceph"; - default: return std::format("0x{:x}", static_cast(st.f_type)); - } -} - -} // namespace mcpp::platform::native diff --git a/modules/platform/src/macos.cppm b/modules/platform/src/macos.cppm deleted file mode 100644 index 756690ce..00000000 --- a/modules/platform/src/macos.cppm +++ /dev/null @@ -1,335 +0,0 @@ -// mcpp.platform.macos — macOS-specific platform capabilities. -// -// Provides: -// has_xcode_clt() — detect Xcode Command Line Tools -// sdk_path(sdk) — discover an Apple SDK via xcrun ("macosx" default) -// sdk_layout(sdk) — the directory names that SDK uses inside Xcode -// sdk_name_of_root(p) — which SDK a path IS, for the SDKROOT override -// runtime_lib_dirs() — macOS-specific library search paths -// supports_full_static — macOS cannot fully static-link (libSystem) - -export module mcpp.platform.macos; - -import std; -import mcpp.platform.common; -import mcpp.platform.process; // the queries of Apple's own tools - -export namespace mcpp::platform::macos { - -// Whether macOS supports full static linking (it does not — libSystem -// must be dynamically linked). -constexpr bool supports_full_static = false; - -// Check whether Xcode Command Line Tools are installed. -// Returns true if `xcode-select -p` succeeds. -bool has_xcode_clt(); - -// THREE SDKS, ONE MACHINE. Apple ships the macOS, iPhoneOS and -// iPhoneSimulator SDKs from one developer directory, and mcpp locates -// rather than installs all three: they are not redistributable, which -// is why the iOS rows carry a located `sysroot` instead of a package. -// `sdk` is the name `xcrun --sdk` takes. -inline constexpr std::string_view sdk_macos = "macosx"; -inline constexpr std::string_view sdk_iphoneos = "iphoneos"; -inline constexpr std::string_view sdk_iphonesim = "iphonesimulator"; - -// Where an SDK lives inside a developer directory: the `.platform` -// directory that holds it, and its own `.sdk` directory name. Pure, and -// nullopt for a name no Apple SDK answers to. -struct SdkLayout { - std::string_view platformDir; // e.g. "iPhoneOS.platform" - std::string_view sdkDir; // e.g. "iPhoneOS.sdk" - bool inCommandLineTools; // the CLT-only install ships it -}; -std::optional sdk_layout(std::string_view sdk); - -// Which SDK a path IS, derived from its own directory name: -// `MacOSX15.4.sdk` -> "macosx", `iPhoneSimulator18.4.sdk` -> -// "iphonesimulator". Empty when the path does not name an SDK. -// -// THIS EXISTS BECAUSE `SDKROOT` NAMES ONE SDK AND THERE ARE THREE -// QUESTIONS. Honouring it for every request would answer an iOS query -// with a macOS SDK whenever a shell had it set -- a wrong sysroot, which -// fails later as missing headers rather than as a bad override. -std::string sdk_name_of_root(const std::filesystem::path& p); - -// Whether an `SDKROOT` override answers a request for `sdk`. -// -// A path that names an SDK answers for that SDK and no other. A path that -// names NO SDK -- a hand-rolled sysroot, which is a spelling clang accepts -- -// answers the DEFAULT question only: it is an answer to "the SDK", not to -// "the iPhoneOS SDK". That asymmetry is what keeps today's callers unchanged -// while making a specific request require positive identification. -bool sdkroot_answers(const std::filesystem::path& candidate, - std::string_view sdk); - -// The version an SDK directory says it is, from its own `SDKSettings.json` -// (`"Version": "18.5"`). Empty when the file is absent or says nothing. Pure -// file reading, so it answers on every host. -std::string sdk_settings_version(const std::filesystem::path& sdkRoot); - -// Whether an SDK carries a slice for `target` (`x86_64-ios-simulator`, -// `arm64-macos`), read from the `targets:` list of its `libSystem.tbd`. The -// slice is what decides whether a row can be built against this SDK; a -// vendor's hardware roadmap does not (D16). nullopt when the stub cannot be -// read. -std::optional sdk_has_target(const std::filesystem::path& sdkRoot, - std::string_view target); - -// Discover an Apple SDK path via `xcrun --show-sdk-path`. -// Returns the SDK path if found, or nullopt. The default argument is -// today's behaviour for today's callers. -std::optional sdk_path(std::string_view sdk = sdk_macos); - -// The version the located SDK was made for, as `xcrun --sdk -// --show-sdk-version` reports it ("18.5"). Empty off macOS and when xcrun -// cannot answer. Read for an iOS row whose manifest states no deployment -// floor, since clang's own default for an unversioned iOS triple is older -// than any SDK on the machine (it refused thread-local storage; measured). -std::optional sdk_version(std::string_view sdk); - -// Built-in default deployment floor (rustc-style: every target has a -// baseline). 14.0 = the floor of the official LLVM static libc++ -// archives; with the default-static stdlib this makes `mcpp run` -// binaries portable to any macOS ≥ 14 out of the box (no declaration -// needed — a fresh user's std::println hello on macOS 14 used to die -// at dyld against the system libc++). Lower floors need a custom -// libc++ build (tracked; data-only swap via xlings-res). -inline constexpr std::string_view default_deployment_target = "14.0"; - -// Resolve the effective macOS deployment target: the -// MACOSX_DEPLOYMENT_TARGET env var (explicit per-invocation override, -// the convention cargo/rustc/cc honor) wins over `manifestValue` (the -// [build] macos_deployment_target project default), which wins over -// the built-in default floor. -// -// WHETHER IT APPLIES IS A TARGET QUESTION, NOT A HOST QUESTION, so -// `targetIsMacos` is a parameter rather than a `#if defined(__APPLE__)` -// read inside this function. Every reader that folds this value into a -// compiler flag, a triple or the build fingerprint builds for a -// particular target (native or `--target`-crossed), and that target's -// OS -- not the machine running mcpp -- decides whether a macOS floor -// makes sense. The resolution itself (env > manifest > 14.0) never -// consults the host either, which is what makes `mcpp build --target -// aarch64-macos` on Linux honour `macos_deployment_target` at all: the -// old `#if defined(__APPLE__)` guard answered empty on every non-Apple -// host regardless of what the target was. -// -// `modules/platform` does not import `mcpp.toolchain.triple` (layering: -// the triple model is built on top of platform, not the reverse), so -// each caller answers the "is the target macOS" question itself -- -// typically `parsedTriple.os == "macos"` -- and passes the bool rather -// than a triple this module cannot name. -// -// Result is never empty when `targetIsMacos` is true; always empty -// when it is false. THE single source of truth — flags.cppm, the BMI -// fingerprint rule and the std-module prebuild must all consume this -// same resolution, or cached std.pcm modules drift from the TUs -// (config-mismatch / unstaged-module failures observed on macos CI). -std::string deployment_target(bool targetIsMacos, std::string_view manifestValue); - -// Return macOS-specific runtime library directories for LLVM toolchains. -std::string deployment_target(bool targetIsMacos, std::string_view manifestValue) { - if (!targetIsMacos) return {}; - if (const char* dt = std::getenv("MACOSX_DEPLOYMENT_TARGET"); dt && *dt) - return dt; - if (!manifestValue.empty()) - return std::string(manifestValue); - return std::string(default_deployment_target); -} - -std::vector -runtime_lib_dirs(const std::filesystem::path& toolchain_root); - -} // namespace mcpp::platform::macos - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::macos { - -namespace { - -std::string run_capture_trimmed(const std::string& cmd) { - // stdin sealed and the shell's own output kept, as `process::capture' does - // for every command it runs. - std::string out = mcpp::platform::process::capture(cmd).output; - while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' ')) - out.pop_back(); - return out; -} - -} // namespace - -bool has_xcode_clt() { - if constexpr (is_macos) { - return mcpp::platform::process::run_silent("xcode-select -p >/dev/null 2>&1") == 0; - } else { - return false; - } -} - -std::optional sdk_layout(std::string_view sdk) { - // ONE TABLE, THREE READERS. Every step of `sdk_path` below that names a - // directory reads it from here, so a fourth SDK is a row and not four - // edits. `inCommandLineTools` is false for the iOS SDKs because a - // CLT-only install genuinely does not ship them -- which is a reason the - // iOS rows can be refused, not a path to probe. - static constexpr SdkLayout kLayouts[] = { - { "MacOSX.platform", "MacOSX.sdk", true }, - { "iPhoneOS.platform", "iPhoneOS.sdk", false }, - { "iPhoneSimulator.platform", "iPhoneSimulator.sdk", false }, - }; - if (sdk == sdk_macos) return kLayouts[0]; - if (sdk == sdk_iphoneos) return kLayouts[1]; - if (sdk == sdk_iphonesim) return kLayouts[2]; - return std::nullopt; -} - -std::string sdk_name_of_root(const std::filesystem::path& p) { - // The installed name carries a version -- `MacOSX15.4.sdk` -- and the - // requested name does not. Lowercase, drop the suffix, drop the digits. - // - // THE SUFFIX IS REQUIRED, because this is positive identification and - // `.sdk` is the marker Apple's own naming gives it. A directory merely - // called `MacOSX` names no SDK; `sdkroot_answers` then routes it to the - // default question, which is what it was before this took a parameter. - std::string name = p.filename().string(); - for (auto& c : name) c = static_cast(std::tolower(c)); - if (!name.ends_with(".sdk")) return {}; - name.resize(name.size() - 4); - while (!name.empty() && (std::isdigit(static_cast(name.back())) - || name.back() == '.')) - name.pop_back(); - for (auto known : {sdk_macos, sdk_iphoneos, sdk_iphonesim}) - if (name == known) return std::string(known); - return {}; -} - -bool sdkroot_answers(const std::filesystem::path& candidate, - std::string_view sdk) { - if (!sdk_layout(sdk)) return false; - auto named = sdk_name_of_root(candidate); - if (!named.empty()) return named == sdk; - return sdk == sdk_macos; -} - -std::optional sdk_version(std::string_view sdk) { - if constexpr (is_macos) { - auto v = run_capture_trimmed(std::format("xcrun --sdk {} --show-sdk-version 2>/dev/null", sdk)); - if (v.empty()) return std::nullopt; - return v; - } else { - (void)sdk; - return std::nullopt; - } -} - -std::optional sdk_path(std::string_view sdk) { - auto layout = sdk_layout(sdk); - if (!layout) return std::nullopt; - // 1. Explicit override wins (matches clang's own SDKROOT handling) -- but - // only for a request it answers. See `sdkroot_answers`. - // - // ON EVERY HOST. `SDKROOT` is Apple's own convention for naming an SDK, - // and an SDK a developer produced from their own Xcode on Linux is - // named the same way (design 2026-10-23 §4.4, D18). The steps below - // ask Apple's tools, which exist only on an Apple host. - if (const char* env = std::getenv("SDKROOT"); env && *env) { - std::filesystem::path p(env); - if (std::filesystem::exists(p) && sdkroot_answers(p, sdk)) return p; - } - if constexpr (is_macos) { - // 2. xcrun — the canonical query. The named form is the only one that can - // answer for a specific SDK; the generic form returns the ACTIVE - // default, so it is tried only for the default request, where it is - // today's first probe and stays first. - std::vector cmds; - if (sdk == sdk_macos) cmds.emplace_back("xcrun --show-sdk-path 2>/dev/null"); - cmds.push_back(std::format("xcrun --sdk {} --show-sdk-path 2>/dev/null", sdk)); - for (auto const& cmd : cmds) { - auto result = run_capture_trimmed(cmd); - if (!result.empty() && std::filesystem::exists(result)) - return std::filesystem::path(result); - } - // 3. Derive from the active developer dir (`xcode-select -p`) — covers - // machines where xcrun is misconfigured but the SDK is present. - auto devdir = run_capture_trimmed("xcode-select -p 2>/dev/null"); - if (!devdir.empty()) { - std::filesystem::path base(devdir); - for (auto cand : { - base / "Platforms" / layout->platformDir / "Developer" / "SDKs" - / layout->sdkDir, - base / "SDKs" / layout->sdkDir }) { - if (std::filesystem::exists(cand)) return cand; - } - } - // 4. Well-known fixed locations (Command-Line-Tools-only / standard Xcode). - std::vector fixed; - if (layout->inCommandLineTools) - fixed.emplace_back(std::filesystem::path( - "/Library/Developer/CommandLineTools/SDKs") / layout->sdkDir); - fixed.emplace_back(std::filesystem::path( - "/Applications/Xcode.app/Contents/Developer/Platforms") - / layout->platformDir / "Developer" / "SDKs" / layout->sdkDir); - for (auto const& cand : fixed) - if (std::filesystem::exists(cand)) return cand; - } - return std::nullopt; -} - -std::string sdk_settings_version(const std::filesystem::path& sdkRoot) { - std::ifstream is(sdkRoot / "SDKSettings.json", std::ios::binary); - if (!is) return {}; - std::string text((std::istreambuf_iterator(is)), std::istreambuf_iterator()); - auto key = text.find("\"Version\""); - if (key == std::string::npos) return {}; - auto q1 = text.find('"', text.find(':', key)); - if (q1 == std::string::npos) return {}; - auto q2 = text.find('"', q1 + 1); - if (q2 == std::string::npos) return {}; - return text.substr(q1 + 1, q2 - q1 - 1); -} - -std::optional sdk_has_target(const std::filesystem::path& sdkRoot, - std::string_view target) { - std::ifstream is(sdkRoot / "usr" / "lib" / "libSystem.tbd", std::ios::binary); - if (!is) return std::nullopt; - std::string line; - while (std::getline(is, line)) { - if (!line.starts_with("targets:")) continue; - // `targets: [ x86_64-ios-simulator, arm64-ios-simulator ]`, possibly - // wrapped onto following lines until the closing bracket. - std::string list = line; - while (list.find(']') == std::string::npos && std::getline(is, line)) list += line; - std::size_t pos = 0; - while ((pos = list.find(target, pos)) != std::string::npos) { - const auto end = pos + target.size(); - const bool startOk = pos == 0 || !(std::isalnum(static_cast(list[pos - 1])) - || list[pos - 1] == '-' || list[pos - 1] == '_'); - const bool endOk = end >= list.size() || !(std::isalnum(static_cast(list[end])) - || list[end] == '-' || list[end] == '_'); - if (startOk && endOk) return true; - pos = end; - } - return false; - } - return std::nullopt; -} - -std::vector -runtime_lib_dirs(const std::filesystem::path& toolchain_root) { - std::vector dirs; - if constexpr (is_macos) { - auto add = [&](const std::filesystem::path& p) { - if (std::filesystem::exists(p)) - dirs.push_back(p); - }; - add(toolchain_root / "lib" / "aarch64-apple-darwin"); - add(toolchain_root / "lib" / "darwin"); - } else { - (void)toolchain_root; - } - return dirs; -} - -} // namespace mcpp::platform::macos diff --git a/modules/platform/src/macos/bounded_process.cpp b/modules/platform/src/macos/bounded_process.cpp deleted file mode 100644 index 2eb6d179..00000000 --- a/modules/platform/src/macos/bounded_process.cpp +++ /dev/null @@ -1,18 +0,0 @@ -// mcpp.platform.unix.bounded_process on macOS: no `pipe2', so the flag is set -// by a second call, inside the LaunchSection every start holds. -module; -#include -#include - -module mcpp.platform.unix.bounded_process; - -namespace mcpp::platform::unixproc { - -int make_pipe(int fds[2]) { - if (::pipe(fds) != 0) return -1; - ::fcntl(fds[0], F_SETFD, FD_CLOEXEC); - ::fcntl(fds[1], F_SETFD, FD_CLOEXEC); - return 0; -} - -} // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/macos/capacity.cpp b/modules/platform/src/macos/capacity.cpp deleted file mode 100644 index 4476bd6d..00000000 --- a/modules/platform/src/macos/capacity.cpp +++ /dev/null @@ -1,47 +0,0 @@ -// mcpp.platform.capacity on macos: what the machine has, from the system's own services. -module; -#include -#include -#include -#include - -module mcpp.platform.capacity; - -import std; - -namespace mcpp::platform::capacity { - -HostCapacity host_capacity() { - HostCapacity cap; - const long n = ::sysconf(_SC_NPROCESSORS_ONLN); - if (n > 0) cap.logicalCores = static_cast(n); - - int value = 0; - size_t len = sizeof(value); - cap.physicalCores = (::sysctlbyname("hw.physicalcpu", &value, &len, nullptr, 0) == 0 && value > 0) - ? value : cap.logicalCores; - - // Apple Silicon is performance + efficiency by construction; hw.nperflevels - // says so directly and is absent on Intel Macs. - value = 0; len = sizeof(value); - if (::sysctlbyname("hw.nperflevels", &value, &len, nullptr, 0) == 0) - cap.heterogeneous = value > 1; - - unsigned long long mem = 0; len = sizeof(mem); - if (::sysctlbyname("hw.memsize", &mem, &len, nullptr, 0) == 0) cap.totalBytes = mem; - - // Free + inactive is the honest "could be handed to a new process" figure on - // Darwin; wired and active are not available in any useful sense. - vm_statistics64_data_t vm{}; - mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; - if (::host_statistics64(::mach_host_self(), HOST_VM_INFO64, - reinterpret_cast(&vm), &count) == KERN_SUCCESS) { - const unsigned long long page = static_cast(::getpagesize()); - cap.availableBytes = (static_cast(vm.free_count) - + static_cast(vm.inactive_count)) * page; - } - if (cap.availableBytes == 0) cap.availableBytes = cap.totalBytes; - return cap; -} - -} // namespace mcpp::platform::capacity diff --git a/modules/platform/src/macos/env.cpp b/modules/platform/src/macos/env.cpp deleted file mode 100644 index 5118a86d..00000000 --- a/modules/platform/src/macos/env.cpp +++ /dev/null @@ -1,10 +0,0 @@ -// mcpp.platform.env on macOS: the process's own variable table, by the call -// that answers for a dynamic library as well as for the program. -module; -#include - -module mcpp.platform.env; - -import std; - -char** mcpp::platform::env::environment() { return *::_NSGetEnviron(); } diff --git a/modules/platform/src/macos/fs_caps.cpp b/modules/platform/src/macos/fs_caps.cpp deleted file mode 100644 index acc0a74d..00000000 --- a/modules/platform/src/macos/fs_caps.cpp +++ /dev/null @@ -1,25 +0,0 @@ -// mcpp.platform.fs_caps on macOS: renamex_np's swap and statfs's type name. -module; -#include -#include -#include -#include - -module mcpp.platform.fs_caps; - -import std; - -namespace mcpp::platform::native { - -int exchange(const std::filesystem::path& a, const std::filesystem::path& b) { - return ::renamex_np(a.c_str(), b.c_str(), RENAME_SWAP) == 0 ? 0 : errno; -} -bool unsupported(int error) { return error == EINVAL || error == ENOTSUP || error == EOPNOTSUPP || error == ENOSYS; } -std::error_category const& category() { return std::generic_category(); } - -std::string filesystem_type(const std::filesystem::path& dir) { - struct statfs st {}; - return ::statfs(dir.c_str(), &st) == 0 ? std::string(st.f_fstypename) : std::string{}; -} - -} // namespace mcpp::platform::native diff --git a/modules/platform/src/platform.cppm b/modules/platform/src/platform.cppm deleted file mode 100644 index 80de3302..00000000 --- a/modules/platform/src/platform.cppm +++ /dev/null @@ -1,68 +0,0 @@ -// mcpp.platform — unified platform abstraction facade. -// -// Import this single module to get access to all platform capabilities: -// -// import mcpp.platform; -// // then use mcpp::platform::fs::self_exe_path(), etc. -// -// ─── LAYOUT ──────────────────────────────────────────────────────────────── -// -// src/*.cppm, src//*.cppm module interfaces, compiled on every -// system: the same declarations everywhere. -// src/linux/*.cpp implementation units compiled for Linux, -// src/macos/*.cpp for macOS, -// src/posix/*.cpp for both (mcpp.toml selects them per target), -// src/windows/*.cpp and for Windows. -// -// The operating system itself is reached through mcpplibs.os (design -// 2026-10-26 part 2, §10.1): its compile-time constants -// (mcpp.platform.common), the executable's own name, PATH lookup, links, -// renames that refuse an existing name, volume identities, terminals and -// standard streams. What remains in implementation units is what that -// library does not carry: the launchers and their signal guard, file locks -// compatible with every earlier mcpp, durable sync, and the system-specific -// queries of the linux, macos and windows modules. -// -// ─── HOW PLATFORM DIFFERENCES ARE EXPRESSED ──────────────────────────────── -// -// No preprocessor conditional appears in this package. A difference is one -// of: -// -// 1. `if constexpr (mcpp::platform::is_windows)` on mcpplibs.os's constants, -// where both branches compile everywhere. Both branches then have to -// COMPILE everywhere, which is what keeps the unused branch from rotting — -// the Windows deadline was dead code for months precisely because nothing -// on a Linux CI ever compiled it. -// -// 2. A function declared in an interface and defined in one implementation -// unit per system, when the difference is a header or a system call. A -// discarded `if constexpr` branch may name another system's function; -// a call that is not discarded is a link error, never a silent stub. -// `mcpp.platform.process`'s bounded-run dispatch is the reference -// example: two launcher modules behind one signature, one dispatch. -// -// 3. A module whose non-matching system answers "not here" (`.macos`, -// `.linux`, `.windows`): consumers call it unconditionally. -// -// A module under a platform directory MUST NOT name a `std` type in its -// EXPORTED interface if a widely-imported module will import it: under GCC -// 16.1 that corrupts every BMI downstream, and the error points at an -// unrelated module. Both bounded_process modules document the measurements. -// Builtin types plus a callback is the shape that works. - -export module mcpp.platform; - -export import mcpp.platform.common; -export import mcpp.platform.shell; -export import mcpp.platform.process; -export import mcpp.platform.fs; -export import mcpp.platform.fs_caps; -export import mcpp.platform.env; -export import mcpp.platform.macos; -export import mcpp.platform.linux; -export import mcpp.platform.windows; -export import mcpp.platform.terminal; - -// The bounded-run implementations are deliberately NOT re-exported: they are -// an implementation detail of `mcpp.platform.process`'s deadline variants, and -// nothing outside it should reach past the dispatch to a specific platform. diff --git a/modules/platform/src/posix/bounded_process.cpp b/modules/platform/src/posix/bounded_process.cpp deleted file mode 100644 index a6dd88bf..00000000 --- a/modules/platform/src/posix/bounded_process.cpp +++ /dev/null @@ -1,487 +0,0 @@ -// mcpp.platform.unix.bounded_process on Linux and macOS. -module; -#include // pipe, close, read -#include // waitpid -#include // posix_spawnp, posix_spawn_file_actions_* -#include // kill, SIGKILL -#include // errno, EINTR -#include // fcntl O_NONBLOCK -#include // nanosleep -#include // fputs, stderr — the out-of-slots diagnostic -#include // tcgetattr, tcsetattr — the terminal mode a signal restores - -module mcpp.platform.unix.bounded_process; - -import std; -import mcpp.platform.common; -import mcpp.platform.env; - -namespace mcpp::platform::unixproc { - -namespace { - -char** current_environ() { - return mcpp::platform::env::environment(); -} - -int normalize_status(int status) { - if (WIFEXITED(status)) return WEXITSTATUS(status); - if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); - return status; -} - -} // namespace - -DeadlineRun capture_with_deadline(const char* const* argvEntries, - unsigned long argvCount, - const char* const* envEntries, - unsigned long envCount, - const char* cwd, - long long deadlineMs, - long long idleMs, - int ownGroup, - OutputSink sink, - void* ctx, - StopPoll stop) -{ - DeadlineRun out; - const bool idleBound = idleMs > 0 && sink != nullptr; - // A child with no bound at all is accepted only with a sink: that caller - // reads the output as it arrives (a build's progress) and has no untimed - // path that could deliver it. - if ((deadlineMs <= 0 && !idleBound && sink == nullptr) || argvCount == 0 || !argvEntries) - return out; - - // The child's environment: ours, minus anything overridden, plus the - // overrides. Names are case-SENSITIVE here (unlike the Windows peer). - std::vector envStore; - { - std::vector overridden; - overridden.reserve(envCount); - for (unsigned long i = 0; i < envCount; ++i) { - std::string_view e(envEntries[i]); - overridden.push_back(e.substr(0, e.find('='))); - } - for (char** p = current_environ(); p && *p; ++p) { - std::string_view e(*p); - auto name = e.substr(0, e.find('=')); - if (std::ranges::find(overridden, name) != overridden.end()) continue; - envStore.emplace_back(e); - } - for (unsigned long i = 0; i < envCount; ++i) - envStore.emplace_back(envEntries[i]); - } - std::vector envp; - envp.reserve(envStore.size() + 1); - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - - std::vector cargv; - cargv.reserve(argvCount + 1); - for (unsigned long i = 0; i < argvCount; ++i) - cargv.push_back(const_cast(argvEntries[i])); - cargv.push_back(nullptr); - - const bool capture = (sink != nullptr); - // From the pipe to the parent's close of its write end: see LaunchSection. - LaunchSection launch; - int fds[2] = {-1, -1}; - if (capture && make_pipe(fds) != 0) return out; - - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - // Same cwd contract as the untimed launcher: a bounded child must land in - // the same directory an unbounded one would, or adding a timeout would - // silently move where a build program's relative writes go. - if (cwd && *cwd) - ::posix_spawn_file_actions_addchdir_np(&fa, cwd); - if (capture) { - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); - ::posix_spawn_file_actions_addclose(&fa, fds[0]); - ::posix_spawn_file_actions_addclose(&fa, fds[1]); - } - // else: no file actions for stdio at all — the child inherits ours, which - // keeps its output live AND keeps it a terminal. - - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - if (ownGroup) { - ::posix_spawnattr_setpgroup(&attr, 0); // 0: a new group, id == pid - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - } - pid_t pid = 0; - int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data()); - ::posix_spawnattr_destroy(&attr); - ::posix_spawn_file_actions_destroy(&fa); - if (capture) ::close(fds[1]); - launch.release(); - if (sp != 0) { out.spawn_error = sp; if (capture) ::close(fds[0]); return out; } - if (ownGroup) guard_group_on_signal(pid); - - // Non-blocking reads so the deadline is still checked while the child is - // quiet. A blocking read on a silent, hung child is exactly the hang this - // whole mechanism exists to stop. - if (capture) - ::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL, 0) | O_NONBLOCK); - - const auto started = std::chrono::steady_clock::now(); - const auto until = deadlineMs > 0 - ? started + std::chrono::milliseconds(deadlineMs) - : std::chrono::steady_clock::time_point::max(); - auto lastOutput = started; - std::array buf{}; - bool killed = false; - int status = 0; - - auto drain = [&]() -> bool { - if (!capture) return false; - ssize_t n; - bool any = false; - for (int chunks = 0; chunks < 16 && (n = ::read(fds[0], buf.data(), buf.size())) > 0; ++chunks) { - sink(ctx, buf.data(), static_cast(n)); - any = true; - } - if (any) lastOutput = std::chrono::steady_clock::now(); - return any; - }; - - for (;;) { - // Output can be continuous. Check budgets before draining another batch. - if (!killed && (std::chrono::steady_clock::now() >= until || (stop && stop(ctx)))) { - if (ownGroup) ::killpg(pid, SIGKILL); - else ::kill(pid, SIGKILL); - killed = true; - } - if (drain()) continue; - - pid_t r = ::waitpid(pid, &status, WNOHANG); - if (r == pid) { - while (drain()) { /* tail — the child is gone, so this ends */ } - break; - } - if (r < 0 && errno != EINTR && errno != ECHILD) break; - - const auto now = std::chrono::steady_clock::now(); - const bool overTotal = now >= until; - const bool overIdle = idleBound - && now - lastOutput >= std::chrono::milliseconds(idleMs); - if (!killed && (overTotal || overIdle)) { - // The group when the child owns one: a shell's grandchild (the - // program the shell ran) would otherwise keep running, and keep - // the pipe open so the drain above never saw end-of-file. - if (ownGroup) ::killpg(pid, SIGKILL); - else ::kill(pid, SIGKILL); - killed = true; - continue; - } - struct timespec ts{0, 20'000'000}; // 20ms - ::nanosleep(&ts, nullptr); - } - if (ownGroup) unguard_group(pid); - if (capture) ::close(fds[0]); - - out.exit_code = normalize_status(status); - out.timed_out = killed; - out.supported = true; - return out; -} - -// ─── Background children ───────────────────────────────────────────────── - -namespace { - -// Read by a signal handler, so `volatile sig_atomic_t` and nothing else: the -// handler may run between any two instructions and may not lock, allocate, or -// call anything that is not async-signal-safe. 0 means "nothing to clean up". -constexpr int kMaxGuardedGroups = 256; -volatile sig_atomic_t g_guardedGroups[kMaxGuardedGroups] = {}; -// Serializes the WRITERS of the registry and of the handlers it installs. The -// handler never takes it. -std::mutex g_guardMutex; - -// The files a signal leaves. A set is never freed while it may be read: the -// handler can run on another thread between the read of the pointer and its -// writes, so every set lives until exit, and `g_interrupt` only moves. -struct InterruptFiles { - std::vector paths, temps, contents; -}; -std::atomic g_interrupt{nullptr}; -std::vector> g_interruptSets; - -void write_interrupt_files() { - auto* set = g_interrupt.load(); - if (!set) return; - for (std::size_t i = 0; i < set->paths.size(); ++i) { - const int fd = ::open(set->temps[i].c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); - if (fd < 0) continue; - const char* p = set->contents[i].data(); - std::size_t left = set->contents[i].size(); - while (left > 0) { - const auto n = ::write(fd, p, left); - if (n <= 0) break; - p += n; - left -= static_cast(n); - } - ::close(fd); - if (left == 0) ::rename(set->temps[i].c_str(), set->paths[i].c_str()); - else ::unlink(set->temps[i].c_str()); - } -} - -// Whether anything still needs the handler: a guarded group, the terminal's -// mode, or files to leave. -bool handler_needed(); - -// The platforms without `pipe2` (macOS) share one section across every child -// start. -std::mutex g_launchMutex; - -// The terminal whose mode the handler restores (-1: none), and that mode. -volatile sig_atomic_t g_terminalFd = -1; -struct termios g_terminalMode{}; - -void install_handlers(); -void uninstall_handlers(); - -extern "C" void background_signal_handler(int sig) { - // killpg is async-signal-safe. SIGKILL rather than SIGTERM: this is the - // path where mcpp is about to stop existing, and there is nobody left to - // escalate if the group ignores the polite request. - // - // SIGKILL also settles a case SIGTERM does not. ninja records a signal in a - // flag and acts on it only where it waits for a subprocess; a ninja with no - // command running never reaches that check, so a polite signal is recorded - // and never obeyed. Measured: an orphaned ninja spinning at 100% of a core - // outlived the removal of the entire sandbox it belonged to. - for (int i = 0; i < kMaxGuardedGroups; ++i) { - const auto group = g_guardedGroups[i]; - if (group > 0) ::killpg(static_cast(group), SIGKILL); - } - write_interrupt_files(); - if (g_terminalFd >= 0) ::tcsetattr(g_terminalFd, TCSANOW, &g_terminalMode); - // Die of the signal we were sent, so the exit status is the one the shell - // and any outer script expect from a Ctrl-C. - ::signal(sig, SIG_DFL); - ::raise(sig); -} - -} // namespace - -void guard_broken_pipe() { - ::signal(SIGPIPE, background_signal_handler); -} - -// make_pipe: linux/bounded_process.cpp (pipe2) and macos/bounded_process.cpp. - -LaunchSection::LaunchSection() { - if constexpr (!mcpp::platform::is_linux) { - g_launchMutex.lock(); - held_ = true; - } -} - -LaunchSection::~LaunchSection() { release(); } - -void LaunchSection::release() { - if (!held_) return; - held_ = false; - g_launchMutex.unlock(); -} - -BackgroundChild spawn_background(const char* const* argvEntries, - unsigned long argvCount, - const char* cwd, - int inheritStdio) -{ - BackgroundChild out; - if (argvCount == 0 || !argvEntries) return out; - - std::vector cargv; - cargv.reserve(argvCount + 1); - for (unsigned long i = 0; i < argvCount; ++i) - cargv.push_back(const_cast(argvEntries[i])); - cargv.push_back(nullptr); - - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - if (cwd && *cwd) - ::posix_spawn_file_actions_addchdir_np(&fa, cwd); - if (!inheritStdio) { - // /dev/null on all three: a spanning hook must not write into ninja's - // output, and must not be able to block on a terminal read either. - ::posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDONLY, 0); - ::posix_spawn_file_actions_addopen(&fa, 1, "/dev/null", O_WRONLY, 0); - ::posix_spawn_file_actions_adddup2(&fa, 1, 2); - } - - // POSIX_SPAWN_SETPGROUP with pgroup 0: the child becomes the leader of a - // new group whose id is its pid. Standard POSIX, unlike SETSID. - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - ::posix_spawnattr_setpgroup(&attr, 0); - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - - pid_t pid = 0; - int sp = 0; - { - // A child that holds no pipe of its own must still not be started in - // the window another thread's pipe is open in (see LaunchSection). - LaunchSection launch; - sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, - cargv.data(), current_environ()); - } - ::posix_spawn_file_actions_destroy(&fa); - ::posix_spawnattr_destroy(&attr); - if (sp != 0) return out; - - out.ok = true; - out.group = pid; - return out; -} - -// DOES NOT REAP, and that is the whole point. -// -// A zombie still holds its pid, so an unreaped leader is what keeps the GROUP -// id from being recycled — and `background_stop` signals that group. Reaping -// here would hand the id back to the kernel between the poll and the kill, -// which on a busy machine is how a stop lands on somebody else's process. -// waitid(WNOWAIT) answers "has it exited?" without giving the id back. -int background_running(long long group, int* exitCode) { - if (group <= 0) return -1; - siginfo_t info{}; - info.si_pid = 0; - if (::waitid(P_PID, static_cast(group), &info, - WEXITED | WNOHANG | WNOWAIT) != 0) - return -1; - if (info.si_pid == 0) return 1; - if (exitCode) - *exitCode = (info.si_code == CLD_EXITED) - ? info.si_status - : 128 + info.si_status; // shell convention for a signal - return 0; -} - -void background_stop(long long group, long long graceMs) { - if (group <= 0) return; - const pid_t pgid = static_cast(group); - - // The group, not the pid. This is the line the design is about: `kill(pid)` - // reaches only the shell mcpp started, and `sh -c 'player & wait'` makes - // the player a grandchild — still holding the audio device afterwards. - ::killpg(pgid, SIGTERM); - - const auto until = std::chrono::steady_clock::now() - + std::chrono::milliseconds(graceMs); - while (background_running(group, nullptr) == 1 - && std::chrono::steady_clock::now() < until) { - struct timespec ts{0, 10'000'000}; // 10ms - ::nanosleep(&ts, nullptr); - } - - // Unconditional, and BEFORE the reap: the leader may have gone politely - // while something it forked has not, and the group is still addressable - // only for as long as the unreaped leader holds the id. - ::killpg(pgid, SIGKILL); - int status = 0; - ::waitpid(pgid, &status, 0); -} - -void guard_group_on_signal(long long group) { - if (group <= 0) return; - { - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedGroups; ++i) { - if (g_guardedGroups[i] == 0) { - g_guardedGroups[i] = static_cast(group); - install_handlers(); - return; - } - } - } - // Out of slots. Say so rather than return silently: an unguarded group is - // a process that outlives mcpp, and the whole point of this file is that - // such a process is never acceptable. - std::fputs("mcpp: internal: more than 256 concurrently guarded process " - "groups; the newest is NOT guarded and may outlive mcpp\n", - stderr); -} - -// Removing the group this call owns, rather than clearing the array, is what -// makes concurrent owners safe: a nested or overlapping run must not disarm -// the guard an outer one still needs. -void unguard_group(long long group) { - if (group <= 0) return; - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedGroups; ++i) - if (g_guardedGroups[i] == static_cast(group)) - g_guardedGroups[i] = 0; - if (!handler_needed()) uninstall_handlers(); -} - -void clear_group_guard() { - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedGroups; ++i) g_guardedGroups[i] = 0; - if (!handler_needed()) uninstall_handlers(); -} - -bool group_guard_idle() { - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedGroups; ++i) - if (g_guardedGroups[i] != 0) return false; - return true; -} - -void guard_terminal_mode(int fd) { - std::lock_guard lock(g_guardMutex); - if (fd < 0 || ::tcgetattr(fd, &g_terminalMode) != 0) return; - g_terminalFd = fd; - install_handlers(); -} - -void unguard_terminal_mode() { - std::lock_guard lock(g_guardMutex); - if (g_terminalFd < 0) return; - ::tcsetattr(g_terminalFd, TCSANOW, &g_terminalMode); - g_terminalFd = -1; - if (!handler_needed()) uninstall_handlers(); -} - -void set_interrupt_files(const std::vector& paths, - const std::vector& temps, - const std::vector& contents) { - if (paths.empty() || paths.size() > 4 || temps.size() != paths.size() - || contents.size() != paths.size()) - return; - std::lock_guard lock(g_guardMutex); - g_interruptSets.push_back(std::make_unique(InterruptFiles{paths, temps, contents})); - g_interrupt.store(g_interruptSets.back().get()); - install_handlers(); -} - -void clear_interrupt_files() { - std::lock_guard lock(g_guardMutex); - g_interrupt.store(nullptr); - if (!handler_needed()) uninstall_handlers(); -} - -namespace { -void install_handlers() { - ::signal(SIGINT, background_signal_handler); - ::signal(SIGTERM, background_signal_handler); - ::signal(SIGHUP, background_signal_handler); -} -void uninstall_handlers() { - ::signal(SIGINT, SIG_DFL); - ::signal(SIGTERM, SIG_DFL); - ::signal(SIGHUP, SIG_DFL); -} -bool handler_needed() { - if (g_terminalFd >= 0 || g_interrupt.load()) return true; - for (int i = 0; i < kMaxGuardedGroups; ++i) - if (g_guardedGroups[i] != 0) return true; - return false; -} -} // namespace - -} // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/posix/bounded_process.cppm b/modules/platform/src/posix/bounded_process.cppm deleted file mode 100644 index a6df96b5..00000000 --- a/modules/platform/src/posix/bounded_process.cppm +++ /dev/null @@ -1,239 +0,0 @@ -// mcpp.platform.unix.bounded_process — a child process with a deadline on -// POSIX. The peer of mcpp.platform.windows.bounded_process. -// -// WHY THE TWO SIDES ARE SEPARATE MODULES WITH ONE SHARED SIGNATURE -// -// They share no code — this is posix_spawn + a pipe + waitpid + SIGKILL, the -// other is CreateProcess + a Job object + WaitForSingleObject. What they share -// is a CONTRACT: "run this, capture its output, kill it at the deadline, and -// tell me whether you killed it". Putting that contract in one signature and -// each implementation behind its own module is what lets -// `mcpp.platform.process` dispatch once, with `if constexpr`, instead of -// carrying the platform question through twenty-five separate `#if` blocks. -// -// THE INTERFACE NAMES NO `std` TYPE — SAME HARD CONSTRAINT AS THE WINDOWS -// SIDE. See mcpp.platform.windows.bounded_process for the measurements: a new -// module imported by mcpp.platform.process whose EXPORTS mention std types -// corrupts every BMI downstream of it under GCC 16.1. `std` inside the module -// is fine; `std` in what it exports is not. -// -// The POSIX side does not strictly need the constraint (it is reached through -// the same dispatch, so it would be one more module in the same position — and -// the failure is silent enough that "probably fine" is not worth finding out). -// Keeping both sides identical also means the dispatcher marshals once, not -// twice. -// -// The declarations are the same on every system; the definitions are Linux's -// and macOS's (posix/bounded_process.cpp, with linux/ and macos/ for the pipe), -// and a call from another system is a link error. - -export module mcpp.platform.unix.bounded_process; - -import std; - -export namespace mcpp::platform::unixproc { - -// Every member is a builtin type. Same reason as the Windows peer. -struct DeadlineRun { - // False on every non-POSIX build, and on POSIX when the child could not be - // spawned. "Could not spawn" and "ran and failed" must not share an exit - // code, so the caller falls back rather than reporting a failure. - bool supported = false; - int exit_code = 0; - bool timed_out = false; - // Non-zero when `supported` is false BECAUSE the spawn was attempted and - // refused: the errno posix_spawnp returned. Zero with `supported` false - // means this build has no bounded launcher at all. The two need opposite - // treatment one layer up — the first is reported with its errno, the - // second falls back to the unbounded launcher — and before this field - // existed both read the same, so the caller spawned a second time and - // discarded the errno the first attempt had in hand (#544). - int spawn_error = 0; -}; - -using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); -using StopPoll = bool (*)(void* ctx); - -// ─── Starting a child is safe for concurrent callers ───────────────────── -// -// Two threads that start children at the same time must not pass one child's -// pipe to the other. A child inherits every descriptor of its parent that is not -// close-on-exec, so a child started by thread B while thread A holds the write -// end of A's capture pipe keeps that end open for as long as it lives. A's -// reader then waits for end of file until B's unrelated child exits: a compile -// that finished at once is reported only when a longer one does, and a deadline -// the reader is meant to enforce is enforced late. -// -// The pipes are therefore created close-on-exec, and `posix_spawn`'s `dup2` -// action, which clears the flag on the descriptor it creates, is the only way a -// descriptor reaches the child. Where the platform has `pipe2` (Linux) that is -// one atomic call. Where it does not (macOS) the flag is set by a second call, -// which leaves a window in which another thread's child could be started; every -// start on such a platform therefore holds one process-wide section, from the -// creation of the pipe to the parent's close of its write end. On Linux the -// section is empty. -// -// `make_pipe` returns 0 on success and -1 on failure, as `pipe` does. Both ends -// are close-on-exec. The caller holds a `LaunchSection` across this call and the -// start of the child. -int make_pipe(int fds[2]); - -class LaunchSection { -public: - LaunchSection(); - ~LaunchSection(); - // Leaves the section before the destructor does: a caller that goes on to - // wait for its child must not hold it for the child's whole life. - void release(); - LaunchSection(const LaunchSection&) = delete; - LaunchSection& operator=(const LaunchSection&) = delete; -private: - bool held_ = false; -}; - -// `argvEntries` is `argvCount` NUL-terminated strings; `envEntries` is -// `envCount` "KEY=VALUE" strings applied on top of the current environment. -// `cwd` may be null. A non-positive `deadlineMs` is rejected with -// supported=false: "no bound" belongs on the caller's untimed path. -// -// A NULL `sink` means "do not capture": the child INHERITS the caller's stdio -// and is still bounded. That is not an optimization — it is the -// `run_exec_deadline` contract. Routing an uncaptured run through a pipe would -// (a) delay every line until the child exits, which is the opposite of what a -// bounded `mcpp test` run is for, and (b) make the child's stdout a pipe -// rather than a terminal, so gtest and friends silently drop their colors. -// -// `idleMs`, when positive, is a second bound: the child is killed once it has -// written nothing for that long. It is meaningful only with a sink, and it is -// what lets a long, progressing child (a download that prints progress) run to -// completion while a wedged one does not (#648). With `idleMs` positive a -// non-positive `deadlineMs` means "no total bound", and so it does with a sink -// and neither bound: a build's ninja, read line by line for its progress. -// -// `ownGroup` non-zero places the child in a process group of its own, kills -// the GROUP at a bound, and registers the group with the signal guard for the -// length of the call, so terminating mcpp takes the child's descendants with -// it. It is opt-in because a child in a background group that reads the -// terminal is stopped by SIGTTIN, and the uncaptured callers of this function -// hand the terminal to their child. -DeadlineRun capture_with_deadline(const char* const* argvEntries, - unsigned long argvCount, - const char* const* envEntries, - unsigned long envCount, - const char* cwd, - long long deadlineMs, - long long idleMs, - int ownGroup, - OutputSink sink, - void* ctx, - StopPoll stop); - -// ─── A child that outlives the call that started it (#496) ─────────────── -// -// `capture_with_deadline` owns its child for the length of one call. A project -// `[hooks] during_build` command is owned for the length of the BUILD, so the -// caller needs a handle it can poll and stop later. Every member is a builtin, -// same constraint as DeadlineRun. -// -// `group`, NOT a pid. The child is placed in a process group of its own -// (posix_spawnattr_setpgroup) and stopped with killpg, because the thing being -// started is a user-authored SHELL command: `sh -c 'player & wait'` makes the -// writer a grandchild, and `kill(pid)` reaches only the shell. A background -// player that survives its build, from a process the user cannot name, is the -// worst outcome this API has — so the group is the unit throughout. -struct BackgroundChild { - bool ok = false; - long long group = 0; // the child's process-group id (== its pid) -}; - -// `inheritStdio == 0` sends the child's output to /dev/null. A spanning hook -// writes CONCURRENTLY with ninja and would otherwise interleave into the middle -// of a compiler diagnostic. -BackgroundChild spawn_background(const char* const* argvEntries, - unsigned long argvCount, - const char* cwd, - int inheritStdio); - -// 1 = still running, 0 = exited, -1 = unknown. When it returns 0, `exitCode` -// (if given) receives the shell convention: the status, or 128+signal. -// -// The code is part of the answer rather than a second call, because the only -// caller that needs it — the `loop` supervisor — has to distinguish "the -// player finished the track" from "the command does not exist". Restarting the -// first forever is the feature; restarting the second forever is a spin. -int background_running(long long group, int* exitCode); - -// SIGTERM, `graceMs`, then SIGKILL — to the GROUP. Reaps the direct child. -void background_stop(long long group, long long graceMs); - -// ─── Ctrl-C ────────────────────────────────────────────────────────────── -// -// Its own process group is what makes killpg possible AND what stops the -// terminal's SIGINT from reaching the child: Ctrl-C would kill mcpp and leave -// the player running. Both halves are required, so the group that must not -// outlive us is registered here for the duration. -// -// The handler does the minimum that is async-signal-safe: killpg (which is), -// then the default action. -// A REGISTRY AND NOT ONE SLOT, BECAUSE THERE IS MORE THAN ONE OWNER. -// -// A spanning `[hooks]` command is guarded for the length of the build, and the -// build's own ninja is guarded for the length of its run — concurrently. With a -// single slot the second registration overwrites the first, so killing mcpp -// mid-build would take down ninja and leave the hook's process running, which -// is the exact outcome the guard exists to prevent. -// -// Fixed capacity and no allocation: the handler reads this array and may not -// allocate. Registering past capacity fails loudly rather than silently -// dropping a group, because a dropped group is an orphan nobody will find. -// -// SAFE FOR CONCURRENT CALLERS. Children are started from several threads at once -// (a workspace's build programs are compiled concurrently), so claiming and -// releasing a slot is serialized by a mutex, and two threads can no longer claim -// the same slot. The handler takes no lock: it reads slots that are written one -// `sig_atomic_t` at a time. The capacity is 256, well above any job count, since -// one group is held per running child. -void guard_group_on_signal(long long group); -void unguard_group(long long group); -void clear_group_guard(); -// Whether no process group is guarded. A caller about to replace mcpp with -// another program (`mcpp run`) requires it: a guarded group is a child that -// would lose its owner. -bool group_guard_idle(); - -// THE TERMINAL MODE A SIGNAL RESTORES (build output design revision 3, -// §5.14). `--play-game` reads keys from the terminal without echo. A Ctrl-C, -// a termination or a hangup that ends mcpp must leave the terminal in the -// mode it found, and the handler installed while ninja runs is the one above, -// so the saved mode is restored there, with tcsetattr, which is -// async-signal-safe. `fd`'s current mode is saved; the caller changes the mode -// after this returns. A process killed outright cannot restore anything. -void guard_terminal_mode(int fd); -// Restores the saved mode and stops guarding it. -void unguard_terminal_mode(); - -// THE FILES A SIGNAL LEAVES (design 2026-10-26 §5.4). A build interrupted while -// it writes its directory says so in its result record: the interrupted form is -// rendered beforehand, and the handler -- after it has stopped the guarded -// groups -- writes each `contents[i]` to `temps[i]` and renames it over -// `paths[i]` (open, write, close and rename are async-signal-safe). While files -// are set the handler is installed. Setting replaces the previous set; at most -// four files. -void set_interrupt_files(const std::vector& paths, - const std::vector& temps, - const std::vector& contents); -void clear_interrupt_files(); - -// A WRITE WHOSE READER IS GONE ENDS mcpp AS A SIGNAL ABOVE DOES (design -// 2026-10-26 part 2, §6.4): the guarded groups killed, the interrupt files -// written, the terminal's mode restored, and then SIGPIPE's own default, so the -// shell sees the status it always saw (141) for `mcpp … | head`. -// -// A handler and not an ignore. openkal ignores SIGPIPE for its own writes, and -// an ignored disposition survives exec: every program mcpp starts -- a build -// script's `yes | head` included -- would begin with it ignored, and a shell -// cannot undo an ignore it was started with. A handled signal is reset to its -// default by exec. Installed once, before anything is written. -void guard_broken_pipe(); - -} // namespace mcpp::platform::unixproc diff --git a/modules/platform/src/posix/env.cpp b/modules/platform/src/posix/env.cpp deleted file mode 100644 index f3e75232..00000000 --- a/modules/platform/src/posix/env.cpp +++ /dev/null @@ -1,12 +0,0 @@ -// mcpp.platform.env on Linux and macOS: writing the process's own variables. -module; -#include - -module mcpp.platform.env; - -import std; - -void mcpp::platform::env::detail::put(const std::string& key, const char* value) { - if (value) ::setenv(key.c_str(), value, 1); - else ::unsetenv(key.c_str()); -} diff --git a/modules/platform/src/posix/fs.cpp b/modules/platform/src/posix/fs.cpp deleted file mode 100644 index 37ab023a..00000000 --- a/modules/platform/src/posix/fs.cpp +++ /dev/null @@ -1,69 +0,0 @@ -// mcpp.platform.fs on Linux and macOS: rename's replacement, O_APPEND, and -// flock. -module; -#include -#include -#include - -module mcpp.platform.fs; - -import std; - -namespace mcpp::platform::fs { - -bool replace_file(const std::filesystem::path& source, - const std::filesystem::path& destination, - std::error_code& ec) { - // 临时文件与目标文件位于同一文件系统时,rename 提供原子替换。 - std::filesystem::rename(source, destination, ec); - return !ec; -} - -bool append_atomically(const std::filesystem::path& file, std::string_view bytes) { - const int fd = ::open(file.c_str(), O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644); - if (fd < 0) return false; - const auto n = ::write(fd, bytes.data(), bytes.size()); - ::close(fd); - return n == static_cast(bytes.size()); -} - -// ── FileLock ── - -std::optional FileLock::try_acquire(const std::filesystem::path& dir, - std::string_view name) { - std::error_code ec; - std::filesystem::create_directories(dir, ec); - auto lockPath = dir / std::string(name); - int fd = ::open(lockPath.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644); - if (fd < 0) return std::nullopt; - if (::flock(fd, LOCK_EX | LOCK_NB) != 0) { - ::close(fd); - return std::nullopt; - } - return FileLock{fd}; -} - -FileLock::~FileLock() { - if (native_ >= 0) { - ::flock(static_cast(native_), LOCK_UN); - ::close(static_cast(native_)); - } -} - -FileLock::FileLock(FileLock&& o) noexcept : native_(o.native_) { - o.native_ = -1; -} - -FileLock& FileLock::operator=(FileLock&& o) noexcept { - if (this != &o) { - if (native_ >= 0) { - ::flock(static_cast(native_), LOCK_UN); - ::close(static_cast(native_)); - } - native_ = o.native_; - o.native_ = -1; - } - return *this; -} - -} // namespace mcpp::platform::fs diff --git a/modules/platform/src/posix/fs_caps.cpp b/modules/platform/src/posix/fs_caps.cpp deleted file mode 100644 index 2dd12ccf..00000000 --- a/modules/platform/src/posix/fs_caps.cpp +++ /dev/null @@ -1,21 +0,0 @@ -// mcpp.platform.fs_caps on Linux and macOS: a directory's owner. -module; -#include -#include - -module mcpp.platform.fs_caps; - -import std; - -namespace mcpp::platform::native { - -std::string owner_problem(const std::filesystem::path& dir) { - struct stat st {}; - if (::stat(dir.c_str(), &st) != 0) return "it cannot be read"; - if (st.st_uid == ::getuid()) return {}; - if (st.st_uid != 0) return "it belongs to another user"; - if (::access(dir.c_str(), W_OK) == 0) return "it belongs to root and this user can write it"; - return {}; -} - -} // namespace mcpp::platform::native diff --git a/modules/platform/src/posix/process.cpp b/modules/platform/src/posix/process.cpp deleted file mode 100644 index 82b5522e..00000000 --- a/modules/platform/src/posix/process.cpp +++ /dev/null @@ -1,359 +0,0 @@ -// mcpp.platform.process on Linux and macOS: the launchers that start a -// program directly with posix_spawn (#248), and the C library's pipe and -// wait-status primitives. -// -// The extra environment goes into the child's envp ONLY (merged_environ); it -// never enters mcpp's own environment nor a host /bin/sh. On Linux that is the -// exact fix for the newer-glibc `sh:` crash; on macOS the old shell path built -// `KEY='v' cd && prog`, where POSIX binds the env assignments to `cd` -// alone — the real program (build.mcpp, the only env+cwd call site) received -// NO extra env and lost the whole MCPP_* contract. Direct exec also drops the -// shell quoting / signal / injection surface entirely. cwd is applied via -// posix_spawn_file_actions_addchdir_np, available on both glibc and macOS -// 10.15+ (mcpp's floor is macOS 14). -module; -#include // errno after a refused execve (run_foreground) -#include -#include // O_RDONLY, O_WRONLY for capture_stdout's /dev/null -#include // posix_spawnp, posix_spawn_file_actions_* (incl. addchdir_np) -#include // umask -#include // waitpid -#include // pipe, dup2, close, read - -module mcpp.platform.process; - -import std; -import mcpp.platform.common; -import mcpp.platform.env; -import mcpp.platform.unix.bounded_process; - -namespace mcpp::platform::process { - -void set_umask_022() { - ::umask(022); -} - -int normalize_exit_code(int rc) { - if (WIFEXITED(rc)) - return WEXITSTATUS(rc); - if (WIFSIGNALED(rc)) - return 128 + WTERMSIG(rc); - return rc; -} - -std::FILE* open_pipe(const std::string& command) { return ::popen(command.c_str(), "r"); } -int close_pipe(std::FILE* pipe) { return ::pclose(pipe); } - -namespace { - -char** host_environ() { - return mcpp::platform::env::environment(); -} - -// An outer `mcpp run`/`mcpp test` points LD_LIBRARY_PATH at mcpp's private -// glibc payload so ITS child (a sandbox-linked user binary) can load. When -// that child spawns mcpp again (e.g. a course provider driving `mcpp test`), -// the same value would flow on into the inner mcpp's own children — and the -// sandbox ninja/gcc (host-glibc binaries) then resolve a MISMATCHED libc and -// segfault inside the dynamic linker before main (trace signature: a bare -// `__vdso_time` line). Strip exactly the private-glibc payload entries from -// inherited loader paths: user-supplied entries survive, and an `extra` -// override (the correct per-child value) always wins over the inherited var. -// -// The predicate itself lives in mcpp.platform.env, because the OTHER half of -// this guarantee is there: a composed override (dirs + inherited tail) arrives -// here as `extra` and therefore bypasses the sanitation below, so -// prepend_path_list has to sanitize the tail it carries. -using mcpp::platform::env::strip_private_glibc; - -// Build a child environment block = the current environ with `extra` overrides -// applied. Returned vector owns the strings; the caller derives a NUL-terminated -// char* array from it. Built in the PARENT so the child env never requires a -// post-fork setenv and mcpp's own environment is never touched. -std::vector merged_environ( - const std::vector>& extra) -{ - std::vector out; - std::set overridden; - for (auto& [k, v] : extra) { out.push_back(k + "=" + v); overridden.insert(k); } - for (char** e = host_environ(); e && *e; ++e) { - std::string_view entry(*e); - auto eq = entry.find('='); - std::string key(eq == std::string_view::npos ? entry : entry.substr(0, eq)); - if (overridden.contains(key)) continue; - if (eq != std::string_view::npos - && (key == "LD_LIBRARY_PATH" || key == "DYLD_LIBRARY_PATH")) { - auto cleaned = strip_private_glibc(entry.substr(eq + 1)); - if (!cleaned.empty()) out.push_back(key + "=" + cleaned); - continue; // nothing legitimate left → drop the var entirely - } - out.emplace_back(entry); - } - return out; -} - -} // namespace - -int run_exec(const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - if (spawn_error) *spawn_error = 0; - if (argv.empty()) return 127; - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - // THE CHILD GETS ITS OWN PROCESS GROUP, AND mcpp KILLS THAT GROUP IF IT IS - // ITSELF KILLED. - // - // Without this, terminating mcpp leaves the child running. Measured: every - // `timeout`-terminated `mcpp run` left an orphaned ninja spinning at 100% - // of a core, and one of them outlived the removal of the entire sandbox it - // belonged to — its working directory read `(deleted)` and it was still - // burning a core half an hour later. Any CI that wraps mcpp in `timeout` - // leaks a busy core per timeout. - // - // The group rather than the pid, because the child starts children of its - // own: killing ninja alone would leave its compilers behind. - // - // A signal is not enough on its own, which is why the guard sends SIGKILL. - // ninja records a signal in a flag and acts on it where it waits for a - // subprocess; a ninja with no command running never reaches that check, so - // a polite signal is recorded and never obeyed. - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - ::posix_spawnattr_setpgroup(&attr, 0); // 0 ⇒ new group, id == pid - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - - pid_t pid = 0; - int sp = 0; - { - // A child with no pipe of its own is still started outside the window - // another thread's pipe is open in. - LaunchSection launch; - sp = ::posix_spawnp(&pid, cargv[0], nullptr, &attr, cargv.data(), envp.data()); - } - ::posix_spawnattr_destroy(&attr); - if (sp != 0) { - // Reported once: by the caller when it asked for the errno, here - // otherwise. Never dropped — the errno in hand at this line is the - // whole difference between "Exec format error" and a blank line. - if (spawn_error) *spawn_error = sp; - else std::fputs(spawn_failure(argv.front(), sp).c_str(), stderr); - return 127; - } - mcpp::platform::unixproc::guard_group_on_signal(pid); - int status = 0; - while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } - mcpp::platform::unixproc::unguard_group(pid); - return normalize_exit_code(status); -} - -int run_foreground(const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - if (spawn_error) *spawn_error = 0; - if (argv.empty()) return 127; - // A guarded group is a child mcpp still owns (ninja, a `during_build` - // hook). Replacing mcpp would leave it without an owner, so that is an - // internal error and not a silent orphan. - if (!mcpp::platform::unixproc::group_guard_idle()) { - std::fputs("mcpp: internal: a child of mcpp is still running; the program " - "is not started\n", stderr); - return 125; - } - // The terminal's mode and the signal handlers mcpp installed are restored - // here; execve then resets every handled signal to its default. - mcpp::platform::unixproc::unguard_terminal_mode(); - mcpp::platform::unixproc::clear_group_guard(); - - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - std::fflush(nullptr); - // PATH is searched with mcpp's own PATH, as posix_spawnp searched it for - // run_exec, and not with the program's environment. - const std::string& file = argv.front(); - int err = ENOENT; - if (file.find('/') != std::string::npos) { - ::execve(file.c_str(), cargv.data(), envp.data()); - err = errno; - } else { - const char* path = std::getenv("PATH"); - std::string_view dirs = (path && *path) ? path : "/usr/bin:/bin"; - bool denied = false; - for (auto dir : dirs | std::views::split(':')) { - std::string candidate{std::string_view(dir)}; - if (candidate.empty()) candidate = "."; - candidate += '/'; - candidate += file; - ::execve(candidate.c_str(), cargv.data(), envp.data()); - // The search continues past a directory that cannot hold the - // program; any other refusal names the program that was found. - if (errno == EACCES) { denied = true; continue; } - if (errno != ENOENT && errno != ENOTDIR) { err = errno; denied = false; break; } - } - if (denied && err == ENOENT) err = EACCES; - } - if (spawn_error) *spawn_error = err; - else std::fputs(spawn_failure(file, err).c_str(), stderr); - return 127; -} - -RunResult capture_exec( - const std::vector& argv, - const std::vector>& extraEnv, - std::string_view cwd, - int* spawn_error) -{ - RunResult result; - if (spawn_error) *spawn_error = 0; - if (argv.empty()) { result.exit_code = 127; return result; } - // posix_spawn + a pipe; stdout and stderr both go to the pipe so the - // captured text is combined (replaces the old `2>&1`). - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - // Several threads call this at once (a workspace's build programs are - // compiled together). The pipe is close-on-exec, so a child another thread - // starts while this one is open does not keep it open (see - // mcpp.platform.unix.bounded_process for the rule and for what a platform - // without `pipe2` does in addition). - LaunchSection launch; - int fds[2]; - if (mcpp::platform::unixproc::make_pipe(fds) != 0) { - result.exit_code = 127; - return result; - } - - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - // Run the child in `cwd` when requested (e.g. build.mcpp, whose relative - // file writes must land in the project root regardless of mcpp's own cwd). - std::string cwdStore(cwd); - if (!cwdStore.empty()) - ::posix_spawn_file_actions_addchdir_np(&fa, cwdStore.c_str()); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); // stdout → pipe - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); // stderr → same pipe - ::posix_spawn_file_actions_addclose(&fa, fds[0]); - ::posix_spawn_file_actions_addclose(&fa, fds[1]); - - // Owned exactly as `run_exec`'s child is, and for the same reason: this is - // the launcher a FULL build uses, so a `mcpp build` interrupted here is the - // common case rather than the rare one. Fixing only `run_exec` left the - // orphan in place — measured, with the two launchers giving opposite - // answers to the same test. - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - ::posix_spawnattr_setpgroup(&attr, 0); - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - - pid_t pid = 0; - int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data()); - ::posix_spawnattr_destroy(&attr); - ::posix_spawn_file_actions_destroy(&fa); - ::close(fds[1]); - launch.release(); - if (sp == 0) mcpp::platform::unixproc::guard_group_on_signal(pid); - if (sp != 0) { - ::close(fds[0]); - result.exit_code = 127; - if (spawn_error) *spawn_error = sp; - else result.output = spawn_failure(argv.front(), sp); - return result; - } - - std::array buf{}; - ssize_t n; - while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) - result.output.append(buf.data(), static_cast(n)); - ::close(fds[0]); - int status = 0; - while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } - mcpp::platform::unixproc::unguard_group(pid); - result.exit_code = normalize_exit_code(status); - return result; -} - -RunResult capture_stdout( - const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - RunResult result; - if (spawn_error) *spawn_error = 0; - if (argv.empty()) { result.exit_code = 127; return result; } - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - // The same rule as capture_exec's. - LaunchSection launch; - int fds[2]; - if (mcpp::platform::unixproc::make_pipe(fds) != 0) { - result.exit_code = 127; - return result; - } - - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - ::posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDONLY, 0); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); // stdout -> pipe - ::posix_spawn_file_actions_addopen(&fa, 2, "/dev/null", O_WRONLY, 0); - ::posix_spawn_file_actions_addclose(&fa, fds[0]); - ::posix_spawn_file_actions_addclose(&fa, fds[1]); - - // Owned as capture_exec's child is: a probe that outlives an interrupted - // mcpp is the same orphan, only smaller. - posix_spawnattr_t attr; - ::posix_spawnattr_init(&attr); - ::posix_spawnattr_setpgroup(&attr, 0); - ::posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); - - pid_t pid = 0; - int sp = ::posix_spawnp(&pid, cargv[0], &fa, &attr, cargv.data(), envp.data()); - ::posix_spawnattr_destroy(&attr); - ::posix_spawn_file_actions_destroy(&fa); - ::close(fds[1]); - launch.release(); - if (sp != 0) { - ::close(fds[0]); - result.exit_code = 127; - if (spawn_error) *spawn_error = sp; - return result; - } - mcpp::platform::unixproc::guard_group_on_signal(pid); - - std::array buf{}; - ssize_t n; - while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) - result.output.append(buf.data(), static_cast(n)); - ::close(fds[0]); - int status = 0; - while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } - mcpp::platform::unixproc::unguard_group(pid); - result.exit_code = normalize_exit_code(status); - return result; -} - -} // namespace mcpp::platform::process diff --git a/modules/platform/src/posix/scaffold_fs.cpp b/modules/platform/src/posix/scaffold_fs.cpp deleted file mode 100644 index 8dbb91ef..00000000 --- a/modules/platform/src/posix/scaffold_fs.cpp +++ /dev/null @@ -1,71 +0,0 @@ -// mcpp.platform.scaffold_fs on Linux and macOS: fsync of a file and of a -// directory. -module; -#include -#include -#include - -module mcpp.platform.scaffold_fs; - -import std; - -namespace mcpp::platform { - -namespace { - -std::string errno_message(int code) { - return std::generic_category().message(code); -} - -bool sync_is_unsupported(int code) { - return code == EINVAL || code == EROFS || code == ENOTSUP || code == EOPNOTSUPP; -} - -} // namespace - -std::expected sync_regular_file(const std::filesystem::path& path) { - int descriptor = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); - if (descriptor < 0) { - const int error = errno; - return std::unexpected(std::format( - "cannot open '{}' for durable sync: {}", path.string(), errno_message(error))); - } - if (::fsync(descriptor) != 0) { - const int error = errno; - ::close(descriptor); - if (sync_is_unsupported(error)) return {}; - return std::unexpected(std::format( - "durable sync '{}' failed: {}", path.string(), errno_message(error))); - } - if (::close(descriptor) != 0) { - const int error = errno; - return std::unexpected(std::format( - "close synced file '{}' failed: {}", path.string(), errno_message(error))); - } - return {}; -} - -std::expected sync_directory(const std::filesystem::path& path) { - int descriptor = ::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (descriptor < 0) { - const int error = errno; - if (sync_is_unsupported(error)) return {}; - return std::unexpected(std::format( - "cannot open directory '{}' for durable sync: {}", path.string(), errno_message(error))); - } - if (::fsync(descriptor) != 0) { - const int error = errno; - ::close(descriptor); - if (sync_is_unsupported(error)) return {}; - return std::unexpected(std::format( - "durable directory sync '{}' failed: {}", path.string(), errno_message(error))); - } - if (::close(descriptor) != 0) { - const int error = errno; - return std::unexpected(std::format( - "close synced directory '{}' failed: {}", path.string(), errno_message(error))); - } - return {}; -} - -} // namespace mcpp::platform diff --git a/modules/platform/src/posix/terminal.cpp b/modules/platform/src/posix/terminal.cpp deleted file mode 100644 index 97d22d05..00000000 --- a/modules/platform/src/posix/terminal.cpp +++ /dev/null @@ -1,91 +0,0 @@ -// mcpp.platform.terminal on Linux and macOS: the terminal device both -// streams reach, keys read from a terminal without a line discipline, and -// standard output diverted at its descriptor. -module; -#include -#include -#include // fcntl, F_DUPFD_CLOEXEC -#include -#include -#include - -module mcpp.platform.terminal; - -import std; -import mcpp.platform.unix.bounded_process; - -namespace mcpp::platform::terminal::native { - -bool same_terminal() { - const int out = ::fileno(stdout), err = ::fileno(stderr); - if (::isatty(out) == 0 || ::isatty(err) == 0) return false; - struct stat a{}, b{}; - if (::fstat(out, &a) != 0 || ::fstat(err, &b) != 0) return false; - return a.st_rdev == b.st_rdev; -} - -unsigned console_output_code_page(Stream) { return 0; } - -bool write_descriptor(int fd, std::string_view text) { - std::size_t done = 0; - while (done < text.size()) { - const auto n = ::write(fd, text.data() + done, text.size() - done); - if (n < 0 && errno == EINTR) continue; - if (n <= 0) return false; - done += static_cast(n); - } - return true; -} - -int divert_stdout() { - // Not `dup`: its copy survives exec. Descriptor 3 or above, as `dup` - // would have chosen, so nothing else about the redirection moves. - const int saved = ::fcntl(1, F_DUPFD_CLOEXEC, 3); - if (saved >= 0) ::dup2(2, 1); - return saved; -} - -void restore_stdout(int saved) { - ::dup2(saved, 1); - ::close(saved); -} - -} // namespace mcpp::platform::terminal::native - -namespace mcpp::platform::terminal { - -KeyInput::KeyInput(Stream display) { - if (!is_terminal(display)) return; - if (::isatty(0) == 0 || ::tcgetpgrp(0) != ::getpgrp()) return; - struct termios mode{}; - if (::tcgetattr(0, &mode) != 0) return; - mcpp::platform::unixproc::guard_terminal_mode(0); - struct termios raw = mode; - raw.c_lflag &= static_cast(~(ICANON | ECHO)); // ISIG stays: Ctrl-C interrupts - raw.c_cc[VMIN] = 0; - raw.c_cc[VTIME] = 0; - if (::tcsetattr(0, TCSANOW, &raw) != 0) { - mcpp::platform::unixproc::unguard_terminal_mode(); - return; - } - active_ = true; -} - -KeyInput::~KeyInput() { - if (!active_) return; - mcpp::platform::unixproc::unguard_terminal_mode(); -} - -std::vector KeyInput::read() { - std::vector keys; - if (!active_) return keys; - char buf[64]; - while (true) { - const auto n = ::read(0, buf, sizeof buf); // VMIN 0, VTIME 0: never blocks - if (n <= 0) break; - pending_.append(buf, static_cast(n)); - } - return decode_keys(pending_); -} - -} // namespace mcpp::platform::terminal diff --git a/modules/platform/src/posix/windows.cpp b/modules/platform/src/posix/windows.cpp deleted file mode 100644 index 5d361905..00000000 --- a/modules/platform/src/posix/windows.cpp +++ /dev/null @@ -1,44 +0,0 @@ -// mcpp.platform.windows on Linux and macOS: what the Windows capabilities answer where there is no Windows. -module; -#include - -module mcpp.platform.windows; - -import std; - -namespace mcpp::platform::windows { -bool writable_fixed_drive(char drive, std::wstring_view child) { - (void)drive; - (void)child; - return false; -} -} - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::windows { - -void prepend_path(const std::filesystem::path& dir) { - (void)dir; -} - -std::optional machine_registry_path(std::wstring_view subkey, - std::wstring_view value) { - (void)subkey; (void)value; - return std::nullopt; -} - -bool long_paths_enabled() { - return false; -} - -std::optional env_directory(const char* name) { - if (const char* v = std::getenv(name); v && *v) return std::filesystem::path(v); - return std::nullopt; -} - -unsigned active_code_page() { - return 0; -} - -} // namespace mcpp::platform::windows diff --git a/modules/platform/src/process.cppm b/modules/platform/src/process.cppm deleted file mode 100644 index 6b1b16d6..00000000 --- a/modules/platform/src/process.cppm +++ /dev/null @@ -1,1075 +0,0 @@ -// mcpp.platform.process — platform-aware process runner. -// -// Centralises all popen/system usage so callers do not scatter #if _WIN32 -// guards or duplicate the popen-read loop. All functions automatically -// seal stdin (redirect from /dev/null on POSIX, from NUL on Windows) to -// prevent interactive prompts from child processes: -// - POSIX: fixes macOS first-run hangs where xcrun / xcode-select would -// block waiting for user input. -// - Windows: fixes first-run hangs where xlings / xim / curl / git child -// processes would block on terminal stdin, forcing the user to press -// Enter repeatedly to advance bootstrap / toolchain install. -// -// Entry points: -// capture — run a command, capture stdout -// run_silent — run a command, discard output -// run_streaming — run a command, stream stdout line by line -// -// NOTE: These functions run commands through the platform shell (sh/cmd.exe). -// Callers are responsible for shell-quoting arguments (see platform.shell). - -module; -#include // stdout, stderr: macros, which `import std` does not carry - -export module mcpp.platform.process; - -import std; -import mcpp.platform.common; // is_windows, for the dispatch -import mcpp.platform.env; -import mcpp.platform.shell; -import mcpp.platform.unix.bounded_process; -import mcpp.platform.windows.bounded_process; - -export namespace mcpp::platform::process { - -// Every file this process creates from here on is readable by everyone and -// writable by its owner only (umask 022): what a home shared by every user of -// a machine is written with. Nothing on Windows, where the ACL inherited from -// the directory decides. -void set_umask_022(); - -// A write to a pipe whose reader is gone (`mcpp … | head`) ends mcpp in -// order, as an interrupt does, and then with the status a shell has always -// seen for it (POSIX: mcpp.platform.unix.bounded_process's guard_broken_pipe). -// Called once, first thing in main. Nothing on Windows, where such a write -// fails and mcpp goes on, as it always has. -void guard_broken_pipe(); - -struct RunResult { - int exit_code = 0; - std::string output; -}; - -// The POSIX shell a command string is handed to (D28): `/bin/sh`, else -// Android's `/system/bin/sh`, else `$PREFIX/bin/sh` (Termux). Resolved once. -// `popen` and `std::system` use the C library's own choice, `/bin/sh`, which -// is why Android below 10 (no `/bin`) is not supported (docs/20). -const std::string& posix_shell(); - -// Run `command` via the platform shell, capture stdout. -// On POSIX, stdin is automatically redirected from /dev/null. -RunResult capture(std::string_view command); - -// Run a host tool while clearing target runtime library search variables. -// This prevents target/program LD_LIBRARY_PATH from poisoning system tools -// such as sha256sum, compiler probes, env, or the shell itself. -RunResult capture_host_tool(std::string_view command); - -// Run `command` with extra environment variables (additive). -// Windows: _putenv_s (mutates calling process env). -// POSIX: prefixes command with VAR=val tokens (no mutation). -RunResult capture_with_env( - std::string_view command, - const std::vector>& env); - -// Launch a program DIRECTLY (no shell), inheriting stdio. argv[0] is the -// program (PATH-searched). `extraEnv` is applied to the CHILD ONLY — the -// calling process environment is never mutated, so a target's loader vars -// (LD_LIBRARY_PATH) cannot poison mcpp itself or any sibling host process. -// Returns a platform-normalized exit code, or 127 if exec fails. -// -// A SPAWN FAILURE IS REPORTED EXACTLY ONCE AND NEVER DROPPED (#544). Every -// launcher below follows one rule: when the child could not be started it -// returns 127 and either (a) stores the errno in `*spawn_error` and prints -// nothing, because a caller that asked for the errno owns the report, or -// (b) with `spawn_error == nullptr`, reports it itself — on stderr here, into -// `output` for the capturing variants. Before this rule `run_exec` turned -// every posix_spawnp failure into a bare 127: `mcpp run` on an artifact the -// kernel refused printed "Running …", a blank line, and exited 1. -// -// `*spawn_error` is 0 whenever the child was spawned, whatever it then did. -// On the residual Windows std::system branch the launch cannot be told apart -// from the child, so it stays 0 and the shell's own message is what is seen. -int run_exec(const std::vector& argv, - const std::vector>& extraEnv = {}, - int* spawn_error = nullptr); - -// THE PROGRAM A USER RUNS (`mcpp run`): it owns the terminal, its signals and -// its exit status, as it would if the shell had started it. -// -// POSIX: mcpp REPLACES ITSELF with the program (execve). The program inherits -// mcpp's process group, which is the terminal's foreground group, so it reads -// the terminal and receives Ctrl-C, Ctrl-\ and Ctrl-Z itself. Whoever waits on -// mcpp waits on the program, so its exit status, a death by signal included, -// is seen unchanged, and nothing can outlive mcpp because nothing remains of -// it. `run_exec` is the wrong launcher for this: its process group of its own -// is a BACKGROUND group on a terminal, where the program's first read stops it -// with SIGTTIN and the terminal's Ctrl-C reaches mcpp instead of the program. -// Returns only when the program could not be started: 127, with the errno in -// `*spawn_error` under the contract of `run_exec`. The caller has finished -// everything it prints before calling: nothing of mcpp runs afterwards. -// -// Windows: the program runs in mcpp's console and process group, mcpp ignores -// Ctrl-C while it waits, and the program's exit code is returned -// (winproc::run_foreground). -int run_foreground(const std::vector& argv, - const std::vector>& extraEnv = {}, - int* spawn_error = nullptr); - -// Same as run_exec but captures stdout AND stderr combined (replaces the old -// `… 2>&1` redirect) into RunResult::output. Required because the only consumer -// (ninja fast-path) parses error text — which ninja writes to stderr — via -// is_stale_ninja_failure / filter_ninja_output. No shell → no quoting/injection. -// `spawn_error`: the same contract as run_exec's. -RunResult capture_exec( - const std::vector& argv, - const std::vector>& extraEnv = {}, - std::string_view cwd = {}, - int* spawn_error = nullptr); - -// A PROGRAM PROBE: run `argv` directly, capture its standard output only, give -// it an empty standard input, and discard its standard error. -// -// This is the argument-vector form of ` 2>/dev/null`, and it -// exists because that string form is written in one shell's grammar. On -// Windows every command string reaches cmd.exe, which resolves `/dev/null` to -// `\dev\null` on the current drive, cannot open it, prints "The system cannot -// find the path specified." on its own stderr and does not run the program. -// Measured on windows-2022 with mcpp 2026.9.14.3: the vendored-xlings version -// probe printed that line in every command after the first, and returned an -// empty version, which silently disabled the pin check it existed for. The -// null device and the redirection belong to this layer; a caller states only -// the program and its arguments. -// -// The output is the program's standard output and nothing else. A program -// that cannot be started yields exit code 127 and EMPTY output: a probe parses -// what it captured, so a launcher error message in `output` would be read as -// the program's answer. `spawn_error` has run_exec's contract. -RunResult capture_stdout( - const std::vector& argv, - const std::vector>& extraEnv = {}, - int* spawn_error = nullptr); - -// capture_stdout for a host tool: the target runtime library search variable is -// cleared for the child, as capture_host_tool does for a command string. -RunResult capture_host_tool_stdout(const std::vector& argv); - -// The command line capture_stdout hands to cmd.exe on Windows. Host-independent -// so the Windows rendering is testable from any platform. -std::string windows_stdout_probe_command(const std::vector& argv); - -// Deadline variants: kill the child once `deadline` elapses and set -// *timed_out. A zero deadline means no limit. -// -// The implementations live per platform — mcpp.platform.unix.bounded_process -// (posix_spawn + SIGKILL) and mcpp.platform.windows.bounded_process -// (CreateProcess + a Job object, so the kill takes the whole tree rather than -// just the direct child). This file dispatches between them ONCE, in -// dispatch_bounded below. -// -// BOTH are real bounds now. They were not: the Windows side used to fall -// through to the unbounded launcher, so every timeout knob (`mcpp test -// --timeout`, `--build-timeout`, `[build] build_program_timeout`) was a silent -// no-op there — set, reported nowhere, and doing nothing. -// `spawn_error`: run_exec's contract. A refused spawn is typed here from the -// bounded launcher's own attempt; there is no second spawn. -int run_exec_deadline(const std::vector& argv, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, - bool* timed_out, - int* spawn_error = nullptr); - -// Run one host-shell command with inherited stdio, a working directory and a -// real deadline. POSIX uses /bin/sh; Windows uses cmd.exe. This is for -// user-authored command strings such as project hooks — programmatic launches -// keep using the argv-based run_exec_deadline API above. -// -// `cwd` is where the command runs; empty means "inherit ours". It is a -// PARAMETER rather than something the caller arranges with a chdir: the -// process-wide working directory is shared state, and the launchers underneath -// already carry a per-child cwd (posix_spawn_file_actions_addchdir_np / -// CreateProcess's lpCurrentDirectory). -// -// Returns 127 when the shell itself could not be started — the same code a -// shell uses for a command it cannot find, and never confusable with a -// command that ran. -int run_shell_deadline(std::string_view command, - std::string_view cwd, - std::chrono::milliseconds deadline, - bool* timed_out); - -// Capture a shell command with child-only environment overrides and a real -// deadline and ownership of the noninteractive child's process group. This -// also supports Windows batch wrappers; native argv launches continue to use -// capture_exec_deadline. -RunResult capture_shell_deadline(std::string_view command, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, bool* timed_out, - std::string_view cwd = {}); - -// ─── A shell command mcpp owns for longer than one call (#496) ─────────── -// -// `run_shell_deadline` owns its child for the length of the call. A project -// `[hooks] during_build` command is owned for the length of the BUILD, so it -// is started here, polled while the build runs, and stopped afterwards. -// -// The handle is opaque and carries whichever platform's identity is real: a -// process GROUP on POSIX, a job object on Windows. Neither is a pid, and that -// is deliberate — the command is user-authored shell, so the thing that must -// die is a tree, not a process. -struct BackgroundCommand { - bool ok = false; - long long group = 0; // POSIX: process-group id - unsigned long long job = 0; // Windows: job object - unsigned long long process = 0; // Windows: the child -}; - -// `inheritStdio == false` discards the child's output. That is the right -// default for anything running alongside the build: its writes would otherwise -// interleave into the middle of a compiler diagnostic. -BackgroundCommand start_shell_background(std::string_view command, - std::string_view cwd, - bool inheritStdio); - -// True while it is still up. When it has exited and `exitCode` is given, the -// code is written there — "the player finished the track" and "the command -// does not exist" are the same event to a poller that only answers yes/no, and -// the `loop` supervisor has to tell them apart. -bool background_running(const BackgroundCommand& child, int* exitCode = nullptr); - -// Asks, waits `grace`, then takes the tree. -void stop_background(const BackgroundCommand& child, - std::chrono::milliseconds grace); - -// Ctrl-C. Registering the child means an interrupted build does not leave it -// running — which, for the case this exists for, is a background player the -// user can no longer name. Only one command is guarded at a time; a build owns -// at most one. -void guard_background_on_signal(const BackgroundCommand& child); -void clear_background_guard(const BackgroundCommand& child); - -// `spawn_error`: run_exec's contract; with it null a refused spawn is -// formatted into `output`, as capture_exec does. `ownGroup` is for -// noninteractive queries: it owns descendants and requires a positive, -// supported deadline rather than falling back to an unbounded capture. -RunResult capture_exec_deadline( - const std::vector& argv, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, - bool* timed_out, - std::string_view cwd = {}, - int* spawn_error = nullptr, - bool ownGroup = false); - -// Run `argv` with its stdout and stderr on one pipe and hand each line to -// `on_line` as it arrives, without the line end. `deadline` bounds the run as -// capture_exec_deadline's does; zero means no bound. A line the child did not -// end is delivered after it exits. Returns the exit code; 127 with -// *spawn_error set when the child could not be started. On a build with no -// bounded launcher the output is delivered after the child exits. -int stream_exec(const std::vector& argv, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, - const std::function& on_line, - bool* timed_out, - int* spawn_error = nullptr); - -// Run `command` silently (discard stdout/stderr). -// On POSIX, stdin is automatically redirected from /dev/null. -int run_silent(std::string_view command); - -// Run `command`, stream stdout line-by-line via callback. -// On POSIX, stdin is automatically redirected from /dev/null. -int run_streaming(std::string_view command, - std::function on_line); - -// run_streaming for a child that mcpp must own and bound (#648). -// -// `run_streaming` hands its child to `popen`: the child is in mcpp's process -// group, is not killed when mcpp is, and is waited on with no limit. That was -// the xlings index refresh that held an editor for eleven minutes on a -// connection that never answered. This runs the same shell command (the same -// quoting, the same sealed stdin) through the deadline launchers instead: -// -// - the child owns a process group on POSIX, registered with the signal -// guard, and runs in a kill-on-close job object on Windows, so -// terminating mcpp terminates the command's whole tree; -// - `total` bounds the run; `idle` bounds the time without output. Either -// may be zero (no bound of that kind), not both; -// - a bound that fires kills the tree and sets *timed_out; the exit code is -// then the killed shell's. -// -// Stdout and stderr share the pipe, as they do for every captured deadline -// run; a caller that parses stdout redirects stderr in the command itself. -// Returns -1 when the launcher could not run the command at all. -// -// `should_stop`, when supplied, is polled on the owning thread even while -// output continues. It must not throw. A true result kills the process tree -// and reports timed_out, just like a deadline; callers can distinguish byte -// progress from heartbeat output without a second reader or a watcher thread. -// -// `split_on_cr` also ends a line at a carriage return that is not part of a -// CRLF pair. A command that redraws one progress line in place (git with -// `--progress`) then reaches `on_line` once per redraw, as it happens, rather -// than once per finished phase. -int run_streaming_bounded(std::string_view command, - std::function on_line, - std::chrono::milliseconds total, - std::chrono::milliseconds idle, - bool* timed_out, - bool split_on_cr = false, - std::function should_stop = {}); - -// Run `command`, passing stdout/stderr through to the terminal. -// Optionally captures stdout into `output` if non-null. -// Returns a platform-normalized exit code (WEXITSTATUS on POSIX). -int run_passthrough(std::string_view command, - std::string* output = nullptr); - -// Extract a platform-normalized exit code from a raw system()/pclose() -// return value. Windows returns the exit code directly; POSIX returns -// a wait-status word requiring WIFEXITED/WEXITSTATUS unwrapping. -int extract_exit_code(int raw_status); - -// ─── Windows command-line shaping (host-independent, for testing) ───────── -// -// `cmd.exe /c ` applies a quote rule that silently mangles most -// command lines (`cmd /?`, /C section): unless the whole string is exactly -// one quoted executable name, cmd removes the FIRST character and the LAST -// quote character, then runs the remainder. A correctly quoted line like -// -// "C:\Program Files\gcc\g++.exe" -c "main.cpp" -// -// therefore arrives as -// -// C:\Program Files\gcc\g++.exe" -c "main.cpp -// -// The fix is to hand cmd an outer pair to consume. These two functions build -// exactly that shape and are compiled on every platform so the rule can be -// unit-tested from Linux/macOS — the Windows branch below is otherwise -// unreachable in every environment mcpp is normally developed on, which is -// how the unquoted-argv[0] bug survived. -std::string windows_command_from_argv(const std::vector& argv); -std::string windows_wrap_for_cmd_c(std::string_view cmd); - -// The command line that runs a USER-AUTHORED shell command through cmd.exe. -// -// NOT `windows_command_from_argv({"cmd.exe", "/d", "/s", "/c", command})`. -// That shape is for a program plus its argv, where CreateProcess's parsing is -// what has to be satisfied. cmd.exe is not parsed that way: its switches must -// arrive BARE (quoted, they are no longer switches), and the command tail is -// governed by the /C quote rule above rather than by argv quoting — so an -// argv-quoted command arrives carrying a pair cmd does not consume. That is -// #425 one layer up, and it is why this is its own shape: -// -// cmd.exe /d /s /c "" -// -// /s makes the rule unconditional (strip exactly the outer pair), so the -// command reaches cmd verbatim no matter how many quotes it contains; /d skips -// AutoRun so a user's registry-installed shell hook cannot alter it. -std::string windows_shell_command_line(std::string_view command); - -// The files an interrupt leaves (design 2026-10-26 §5.4): when SIGINT, SIGTERM -// or SIGHUP (Windows: a console control event) ends this process, each -// `(path, content)` replaces its file -- through a temporary name beside it -// and a rename -- after the guarded children are stopped. Setting replaces the -// previous set; at most four files. -void set_interrupt_files(const std::vector>& files); -void clear_interrupt_files(); - -} // namespace mcpp::platform::process - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::process { - -// Host-independent (see the declarations): always the Windows shape. -std::string windows_command_from_argv(const std::vector& argv) { - if (argv.empty()) return ""; - std::string cmd = mcpp::platform::shell::quote_windows(argv[0]); - for (std::size_t i = 1; i < argv.size(); ++i) { - cmd += ' '; - cmd += mcpp::platform::shell::quote_windows(argv[i]); - } - return cmd; -} - -std::string windows_wrap_for_cmd_c(std::string_view cmd) { - return "\"" + std::string(cmd) + "\""; -} - -std::string windows_shell_command_line(std::string_view command) { - // One derivation for the outer pair: the same wrap the /c rule above is - // written against. - return "cmd.exe /d /s /c " + windows_wrap_for_cmd_c(command); -} - -void set_interrupt_files(const std::vector>& files) { - std::vector paths, temps, contents; - for (auto const& [path, content] : files) { - paths.push_back(path.string()); - temps.push_back(path.string() + ".interrupted"); - contents.push_back(content); - } - if constexpr (mcpp::platform::is_windows) - mcpp::platform::winproc::set_interrupt_files(paths, temps, contents); - else - mcpp::platform::unixproc::set_interrupt_files(paths, temps, contents); -} - -void guard_broken_pipe() { - if constexpr (!mcpp::platform::is_windows) - mcpp::platform::unixproc::guard_broken_pipe(); -} - -void clear_interrupt_files() { - if constexpr (mcpp::platform::is_windows) - mcpp::platform::winproc::clear_interrupt_files(); - else - mcpp::platform::unixproc::clear_interrupt_files(); -} - -// ─── Defined once per system (posix/process.cpp, windows/process.cpp) ──── -// -// Not exported: the four launchers whose bodies are the system's own -// (run_exec, run_foreground, capture_exec, capture_stdout) and these -// primitives are each system's; everything else in this file is one text. - -// A wait status, or what `system' and `pclose' return, as the exit code a -// shell reports: the value itself on Windows; on POSIX the status a program -// returned, or 128 + the signal that ended it. The raw wait-status word only -// *happens* to look right when the core-dump bit is set (SIGSEGV+core → 0x8B = -// 139); without the unwrapping a SIGTERM death would surface as "exit 15". -int normalize_exit_code(int raw); - -// `popen(command, "r")' and its close, in the C library's spelling of each -// system. -std::FILE* open_pipe(const std::string& command); -int close_pipe(std::FILE* pipe); - -// The section every child start holds from the creation of its pipe to the -// parent's close of the write end (see mcpp.platform.unix.bounded_process, which -// states why). Each system's module declares its class everywhere and defines -// it on its own system. -using LaunchSection = std::conditional_t; - -// The one sentence every launcher prints for a refused spawn. Platform-neutral -// on purpose: the bounded launchers on both platforms now hand their spawn -// error up (DeadlineRun::spawn_error), and the wrappers that receive it are -// compiled everywhere. `error` is an errno on POSIX and a GetLastError() value -// on Windows; the category below renders each in its own vocabulary. -std::string spawn_failure(std::string_view program, int error) { - if constexpr (mcpp::platform::is_windows) - return std::format("CreateProcess('{}') failed (error {}): {}\n", - program, error, std::system_category().message(error)); - else - return std::format("posix_spawnp('{}') failed (error {}): {}\n", - program, error, std::generic_category().message(error)); -} - -namespace { - -// Append a non-interactive stdin redirect to prevent child processes from -// blocking on terminal input. -// - POSIX: "< /dev/null" — fixes macOS xcrun / xcode-select hangs. -// - Windows: "`, and cmd applies a quote rule that mangles any -// command line carrying more than one pair of quotes (`cmd /?`, the /C -// section): unless the whole string is exactly one quoted executable name, -// cmd strips the FIRST character and the LAST quote character and runs what -// is left. So -// -// "C:\Program Files\gcc\g++.exe" -c "main.cpp" -// -// becomes -// -// C:\Program Files\gcc\g++.exe" -c "main.cpp -// -// which is why command_from_argv used to leave argv[0] unquoted — the -// program path then survived, at the cost of breaking as soon as it -// contained a space, which every default install path does -// (`C:\Program Files\...`, or any user whose account name has a space). -// -// The documented fix is to give cmd an outer pair to eat, so the inner -// quoting arrives intact. Applied at the single point where a command -// string becomes a child process, so no caller has to remember it, and the -// redirects appended by seal_stdin / silent_redirect stay inside the wrap -// where cmd still parses them after stripping. -std::string wrap_for_cmd_c(std::string_view cmd) { - if constexpr (mcpp::platform::is_windows) return windows_wrap_for_cmd_c(cmd); - else return std::string(cmd); -} - -// Seal stdin AND wrap. Kept separate from wrap_for_cmd_c because run_exec -// deliberately inherits stdio — `mcpp run` hands the terminal to the program -// being run, and sealing its stdin would break every interactive one. -std::string finalize_shell_command(std::string_view cmd) { - return wrap_for_cmd_c(seal_stdin(cmd)); -} - -} // namespace - -int extract_exit_code(int raw_status) { - return normalize_exit_code(raw_status); -} - -RunResult capture(std::string_view command) { - auto cmd = finalize_shell_command(command); - RunResult result; - - std::FILE* fp = nullptr; - { - // `popen` creates the pipe and starts the child in one call, so the - // section is exactly that call. - LaunchSection launch; - fp = open_pipe(cmd); - } - if (!fp) { - result.exit_code = -1; - return result; - } - - std::array buf{}; - while (std::fgets(buf.data(), static_cast(buf.size()), fp) != nullptr) - result.output += buf.data(); - - result.exit_code = normalize_exit_code(close_pipe(fp)); - return result; -} - -RunResult capture_host_tool(std::string_view command) { - auto key = mcpp::platform::env::host_tool_runtime_library_path_key(); - std::optional runtime_env; - if (!key.empty()) - runtime_env.emplace(key, std::nullopt); - return capture(command); -} - -RunResult capture_with_env( - std::string_view command, - const std::vector>& env) -{ - if constexpr (mcpp::platform::is_windows) { - // This mutates the calling process's environment, as it always has, and it - // is called from several threads at once when a workspace's build programs - // are compiled together. A variable is therefore set only when its value - // differs, so callers that pass one value (the toolchain's INCLUDE and LIB, - // the same for every compile of a build) stop touching the environment block - // after the first, and the writes that remain are made under the section the - // children are started in. - { - LaunchSection launch; - for (auto& [k, v] : env) { - const char* now = std::getenv(k.c_str()); - if (now && v == now) continue; - mcpp::platform::env::set(k, v); - } - } - return capture(command); - } else { - std::string prefixed; - for (auto& [k, v] : env) { - prefixed += k; - prefixed += '='; - // Simple quoting for env values - prefixed += '\''; - for (char c : v) { - if (c == '\'') prefixed += "'\\''"; - else prefixed += c; - } - prefixed += '\''; - prefixed += ' '; - } - prefixed += command; - return capture(prefixed); - } -} - -int run_silent(std::string_view command) { - auto cmd = finalize_shell_command(command); - return normalize_exit_code(std::system(cmd.c_str())); -} - -int run_streaming(std::string_view command, - std::function on_line) -{ - auto cmd = finalize_shell_command(command); - std::FILE* fp = nullptr; - { - LaunchSection launch; - fp = open_pipe(cmd); - } - if (!fp) return -1; - - std::array buf{}; - std::string acc; - while (std::fgets(buf.data(), static_cast(buf.size()), fp) != nullptr) { - acc += buf.data(); - std::size_t pos; - while ((pos = acc.find('\n')) != std::string::npos) { - if (on_line) { - auto line = std::string_view{acc}.substr(0, pos); - while (!line.empty() && line.back() == '\r') - line.remove_suffix(1); - on_line(line); - } - acc.erase(0, pos + 1); - } - } - if (!acc.empty() && on_line) { - std::string_view line{acc}; - while (!line.empty() && line.back() == '\r') - line.remove_suffix(1); - if (!line.empty()) on_line(line); - } - return normalize_exit_code(close_pipe(fp)); -} - -int run_passthrough(std::string_view command, std::string* output) { - auto cmd = finalize_shell_command(command); - std::FILE* fp = nullptr; - { - LaunchSection launch; - fp = open_pipe(cmd); - } - if (!fp) return -1; - - std::array buf{}; - while (std::fgets(buf.data(), static_cast(buf.size()), fp) != nullptr) { - if (output) *output += buf.data(); - std::fputs(buf.data(), stdout); - } - return normalize_exit_code(close_pipe(fp)); -} - -// Host-independent (see the declaration): always the Windows shape. The -// redirect names cmd.exe's own null device, and the argv is quoted by the one -// shaper every Windows launch in this file uses. -std::string windows_stdout_probe_command(const std::vector& argv) { - return windows_command_from_argv(argv) + " 2>nul"; -} - -RunResult capture_host_tool_stdout(const std::vector& argv) { - auto key = mcpp::platform::env::host_tool_runtime_library_path_key(); - std::optional runtime_env; - if (!key.empty()) - runtime_env.emplace(key, std::nullopt); - return capture_stdout(argv); -} - -// ─── The ONE place the platform question is asked for a bounded run ──────── -// -// Both launchers answer the same contract behind a `std`-free interface (see -// either module for the BMI corruption that forced that), so everything -// platform-specific about a bounded child is this dispatch plus the two -// implementations — instead of the branches that used to be spread through -// both functions below, with the Windows half of them a silent no-op. -// -// The two calls differ in ONE way, and it is a real difference rather than an -// abstraction leak: POSIX names a program with an argv array, Windows with a -// single quoted command line. Flattening that would move the quoting rules -// somewhere they could not be unit-tested — they are tested right here, via -// windows_command_from_argv. -struct BoundedOutcome { - bool supported = false; - int exit_code = 0; - bool timed_out = false; - int spawn_error = 0; // see DeadlineRun::spawn_error in either launcher - std::string output; -}; - -// `capture == false` runs the child on the caller's stdio: live output, and a -// real terminal for anything that checks. `run_exec_deadline` needs that; the -// capturing variants need the pipe. -// `windowsCommandLine` overrides what the Windows branch launches. Empty (the -// normal case) means "derive it from argv". A shell command is the one caller -// that must NOT be derived that way — see windows_shell_command_line — and the -// POSIX branch is unaffected either way, because it never flattens argv. -// `idle` and `ownGroup` are the streaming runner's (see run_streaming_bounded); -// every other caller passes neither and keeps its behaviour. `on_line`, when -// given, receives each complete line as it arrives instead of the output being -// accumulated. -BoundedOutcome dispatch_bounded( - const std::vector& argv, - const std::vector>& extraEnv, - std::string_view cwd, - std::chrono::milliseconds deadline, - bool capture, - std::string_view windowsCommandLine = {}, - std::chrono::milliseconds idle = std::chrono::milliseconds{0}, - bool ownGroup = false, - const std::function* on_line = nullptr, - bool splitOnCr = false, - const std::function* should_stop = nullptr) -{ - BoundedOutcome outcome; - - std::vector envStore; - envStore.reserve(extraEnv.size()); - for (auto const& [k, v] : extraEnv) envStore.push_back(k + "=" + v); - std::vector envPtrs; - envPtrs.reserve(envStore.size()); - for (auto const& e : envStore) envPtrs.push_back(e.c_str()); - const char* const* envArg = envPtrs.empty() ? nullptr : envPtrs.data(); - const auto envCount = static_cast(envPtrs.size()); - - std::string cwdStore(cwd); - const char* cwdArg = cwdStore.empty() ? nullptr : cwdStore.c_str(); - const auto ms = static_cast(deadline.count()); - - const auto idleMs = static_cast(idle.count()); - - // One sink for both, appending into the outcome's own buffer. Null when the - // caller wants the child on its own stdio. With `on_line`, complete lines - // leave the buffer as they arrive, and the unterminated tail is delivered - // after the child exits, as run_streaming does. - struct StreamCtx { - std::string* buffer; - const std::function* on_line; - bool splitOnCr; - const std::function* should_stop; - } streamCtx{ &outcome.output, on_line, splitOnCr, should_stop }; - const auto stop = should_stop ? +[](void* ctx) -> bool { - return (*static_cast(ctx)->should_stop)(); - } : nullptr; - using Sink = void (*)(void*, const char*, unsigned long); - const Sink sink = !capture ? nullptr - : on_line == nullptr - ? +[](void* ctx, const char* data, unsigned long len) { - static_cast(ctx)->buffer->append(data, len); - } - : +[](void* ctx, const char* data, unsigned long len) { - auto* c = static_cast(ctx); - c->buffer->append(data, len); - // The end of the next line: a newline, or with `splitOnCr` a - // carriage return followed by anything but a newline. A carriage - // return that is the last byte so far waits for the next chunk, - // which decides whether it was half of a CRLF. - auto next_end = [c]() -> std::size_t { - const auto nl = c->buffer->find('\n'); - if (!c->splitOnCr) return nl; - for (std::size_t i = 0; i < c->buffer->size() && i < nl; ++i) { - if ((*c->buffer)[i] != '\r') continue; - if (i + 1 == c->buffer->size()) return std::string::npos; - if ((*c->buffer)[i + 1] != '\n') return i; - } - return nl; - }; - std::size_t pos; - while ((pos = next_end()) != std::string::npos) { - std::string_view line{c->buffer->data(), pos}; - while (!line.empty() && line.back() == '\r') line.remove_suffix(1); - (*c->on_line)(line); - c->buffer->erase(0, pos + 1); - } - }; - - if constexpr (mcpp::platform::is_windows) { - const auto cmd = windowsCommandLine.empty() - ? windows_command_from_argv(argv) - : std::string(windowsCommandLine); - auto r = mcpp::platform::winproc::capture_with_deadline( - cmd.c_str(), envArg, envCount, cwdArg, ms, idleMs, sink, &streamCtx, stop); - outcome.supported = r.supported; - outcome.exit_code = r.exit_code; - outcome.timed_out = r.timed_out; - outcome.spawn_error = r.spawn_error; - } else { - std::vector argvPtrs; - argvPtrs.reserve(argv.size()); - for (auto const& a : argv) argvPtrs.push_back(a.c_str()); - auto r = mcpp::platform::unixproc::capture_with_deadline( - argvPtrs.data(), static_cast(argvPtrs.size()), - envArg, envCount, cwdArg, ms, idleMs, ownGroup ? 1 : 0, - sink, &streamCtx, stop); - outcome.supported = r.supported; - outcome.exit_code = r.exit_code; - outcome.timed_out = r.timed_out; - outcome.spawn_error = r.spawn_error; - } - if (on_line && capture && !outcome.output.empty()) { - std::string_view tail{outcome.output}; - while (!tail.empty() && tail.back() == '\r') tail.remove_suffix(1); - if (!tail.empty()) (*on_line)(tail); - outcome.output.clear(); - } - return outcome; -} - - -int run_exec_deadline(const std::vector& argv, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, - bool* timed_out, - int* spawn_error) -{ - if (timed_out) *timed_out = false; - if (spawn_error) *spawn_error = 0; - if (deadline.count() <= 0) return run_exec(argv, extraEnv, spawn_error); - if (argv.empty()) return 127; - - // capture=false: identical stdio behaviour to `run_exec` — the child writes - // straight to our terminal as it goes. `mcpp test`'s non-JSON path runs - // test binaries through here, and buffering their output until exit would - // undo the observability work that path exists for (and would hide gtest's - // colors by making its stdout a pipe). - auto r = dispatch_bounded(argv, extraEnv, {}, deadline, /*capture=*/false); - if (!r.supported) { - // Attempted and refused: the errno is in hand, so type it or report - // it here. Spawning again through run_exec — what this did before — - // paid for a second refusal and threw the first errno away (#544). - if (r.spawn_error != 0) { - if (spawn_error) *spawn_error = r.spawn_error; - else std::fputs(spawn_failure(argv.front(), r.spawn_error).c_str(), stderr); - return 127; - } - return run_exec(argv, extraEnv, spawn_error); // no bounded launcher here - } - if (timed_out) *timed_out = r.timed_out; - return r.exit_code; -} - -const std::string& posix_shell() { - static const std::string shell = [] { - namespace fs = std::filesystem; - std::error_code ec; - for (const char* p : {"/bin/sh", "/system/bin/sh"}) - if (fs::exists(p, ec)) return std::string(p); - if (const char* prefix = std::getenv("PREFIX"); prefix && *prefix) { - auto p = fs::path(prefix) / "bin" / "sh"; - if (fs::exists(p, ec)) return p.string(); - } - return std::string("/bin/sh"); - }(); - return shell; -} - -int run_shell_deadline(std::string_view command, - std::string_view cwd, - std::chrono::milliseconds deadline, - bool* timed_out) -{ - if (timed_out) *timed_out = false; - if (command.empty() || deadline.count() <= 0) return 127; - - // argv is what the POSIX branch launches; the Windows branch takes the - // shaped command line instead. Both are built here so neither platform's - // spelling can drift into a launcher that does not use it. - const std::vector argv{posix_shell(), "-c", std::string(command)}; - auto r = dispatch_bounded(argv, {}, cwd, deadline, /*capture=*/false, - windows_shell_command_line(command)); - // No fallback to the unbounded launcher here, unlike run_exec_deadline: a - // hook's deadline and its working directory are both part of what the - // caller asked for, and the unbounded path can honour neither. - if (!r.supported) return 127; - if (timed_out) *timed_out = r.timed_out; - return r.exit_code; -} - -RunResult capture_shell_deadline(std::string_view command, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, bool* timed_out, std::string_view cwd) -{ - if (timed_out) *timed_out = false; - RunResult result; - result.exit_code = 127; - if (command.empty() || deadline.count() <= 0) return result; - const std::vector argv{posix_shell(), "-c", seal_stdin(command)}; - auto outcome = dispatch_bounded(argv, extraEnv, cwd, deadline, /*capture=*/true, - windows_shell_command_line(seal_stdin(command)), - std::chrono::milliseconds{0}, /*ownGroup=*/true); - if (timed_out) *timed_out = outcome.timed_out; - if (!outcome.supported) { - if (outcome.spawn_error) - result.output = spawn_failure(argv.front(), outcome.spawn_error); - return result; - } - result.exit_code = outcome.exit_code; - result.output = std::move(outcome.output); - return result; -} - -int run_streaming_bounded(std::string_view command, - std::function on_line, - std::chrono::milliseconds total, - std::chrono::milliseconds idle, - bool* timed_out, - bool split_on_cr, - std::function should_stop) -{ - if (timed_out) *timed_out = false; - if (command.empty() || (total.count() <= 0 && idle.count() <= 0)) return -1; - // The popen path's shaping, minus the outer cmd.exe pair: the Windows - // launcher's command line supplies that pair itself. - const auto sealed = seal_stdin(command); - const std::vector argv{posix_shell(), "-c", sealed}; - std::function sink = - on_line ? std::move(on_line) : [](std::string_view) {}; - auto r = dispatch_bounded(argv, {}, {}, total, /*capture=*/true, - windows_shell_command_line(sealed), idle, - /*ownGroup=*/true, &sink, split_on_cr, should_stop ? &should_stop : nullptr); - if (!r.supported) return -1; - if (timed_out) *timed_out = r.timed_out; - return r.exit_code; -} - -// The same split as dispatch_bounded, for the same reason: POSIX names a -// program with an argv array, Windows with a single command line. Both -// spellings are built here so neither can drift into a launcher that does not -// use it. -BackgroundCommand start_shell_background(std::string_view command, - std::string_view cwd, - bool inheritStdio) -{ - BackgroundCommand out; - if (command.empty()) return out; - const std::string cwdStore(cwd); - const char* cwdArg = cwdStore.empty() ? nullptr : cwdStore.c_str(); - - if constexpr (mcpp::platform::is_windows) { - const auto line = windows_shell_command_line(command); - auto r = mcpp::platform::winproc::spawn_background( - line.c_str(), cwdArg, inheritStdio ? 1 : 0); - out.ok = r.ok; - out.job = r.job; - out.process = r.process; - } else { - const std::string cmdStore(command); - const char* argv[] = {posix_shell().c_str(), "-c", cmdStore.c_str()}; - auto r = mcpp::platform::unixproc::spawn_background( - argv, 3, cwdArg, inheritStdio ? 1 : 0); - out.ok = r.ok; - out.group = r.group; - } - return out; -} - -bool background_running(const BackgroundCommand& child, int* exitCode) { - if (!child.ok) return false; - if constexpr (mcpp::platform::is_windows) - return mcpp::platform::winproc::background_running(child.process, - exitCode) == 1; - else - return mcpp::platform::unixproc::background_running(child.group, - exitCode) == 1; -} - -void stop_background(const BackgroundCommand& child, - std::chrono::milliseconds grace) -{ - if (!child.ok) return; - if constexpr (mcpp::platform::is_windows) - mcpp::platform::winproc::background_stop(child.job, child.process, - grace.count()); - else - mcpp::platform::unixproc::background_stop(child.group, grace.count()); -} - -void guard_background_on_signal(const BackgroundCommand& child) { - if (!child.ok) return; - if constexpr (mcpp::platform::is_windows) - mcpp::platform::winproc::guard_job_on_signal(child.job); - else - mcpp::platform::unixproc::guard_group_on_signal(child.group); -} - -// Releases THIS child, not the guard as a whole: a build's ninja is guarded at -// the same time as a spanning hook, and disarming everything when either -// finishes would leave the other able to outlive mcpp. -void clear_background_guard(const BackgroundCommand& child) { - if constexpr (mcpp::platform::is_windows) - mcpp::platform::winproc::unguard_job(child.job); - else - mcpp::platform::unixproc::unguard_group(child.group); -} - -int stream_exec(const std::vector& argv, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, - const std::function& on_line, - bool* timed_out, - int* spawn_error) -{ - if (timed_out) *timed_out = false; - if (spawn_error) *spawn_error = 0; - if (argv.empty()) return 127; - // Its own process group, registered with the signal guard, as - // capture_exec's child is: a build's ninja must not outlive the mcpp that - // started it when that mcpp alone is signalled (e2e 340). - auto r = dispatch_bounded(argv, extraEnv, {}, deadline, /*capture=*/true, {}, - std::chrono::milliseconds{0}, /*ownGroup=*/true, &on_line); - if (!r.supported) { - if (r.spawn_error != 0) { - if (spawn_error) *spawn_error = r.spawn_error; - else on_line(spawn_failure(argv.front(), r.spawn_error)); - return 127; - } - auto whole = capture_exec(argv, extraEnv, {}, spawn_error); - std::string_view rest = whole.output; - while (!rest.empty()) { - const auto nl = rest.find('\n'); - auto l = rest.substr(0, nl); - while (!l.empty() && l.back() == '\r') l.remove_suffix(1); - on_line(l); - if (nl == std::string_view::npos) break; - rest.remove_prefix(nl + 1); - } - return whole.exit_code; - } - if (timed_out) *timed_out = r.timed_out; - return r.exit_code; -} - -RunResult capture_exec_deadline( - const std::vector& argv, - const std::vector>& extraEnv, - std::chrono::milliseconds deadline, - bool* timed_out, - std::string_view cwd, - int* spawn_error, - bool ownGroup) -{ - if (timed_out) *timed_out = false; - if (spawn_error) *spawn_error = 0; - RunResult result; - if (deadline.count() <= 0) { - if (!ownGroup) return capture_exec(argv, extraEnv, cwd, spawn_error); - result.exit_code = 127; - return result; - } - if (argv.empty()) { result.exit_code = 127; return result; } - - auto r = dispatch_bounded(argv, extraEnv, cwd, deadline, /*capture=*/true, - {}, std::chrono::milliseconds{0}, ownGroup); - // `supported == false` means the child COULD NOT BE SPAWNED — not that it - // ran and failed. Reporting those the same way would hide a launcher - // problem behind a child's exit code. When the launcher attempted the - // spawn, its errno is the diagnostic and there is nothing to retry; only - // a build with no bounded launcher at all falls back to the untimed path. - if (!r.supported) { - if (r.spawn_error != 0) { - result.exit_code = 127; - if (spawn_error) *spawn_error = r.spawn_error; - else result.output = spawn_failure(argv.front(), r.spawn_error); - return result; - } - if (ownGroup) { result.exit_code = 127; return result; } - return capture_exec(argv, extraEnv, cwd, spawn_error); - } - result.exit_code = r.exit_code; - result.output = std::move(r.output); - if (timed_out) *timed_out = r.timed_out; - return result; -} - -} // namespace mcpp::platform::process diff --git a/modules/platform/src/project_name.cppm b/modules/platform/src/project_name.cppm deleted file mode 100644 index 5d7e940b..00000000 --- a/modules/platform/src/project_name.cppm +++ /dev/null @@ -1,35 +0,0 @@ -// Cross-platform filename rules needed by project scaffolding. Kept under the -// platform layer so the scaffold mechanism does not grow Windows-specific -// device-name branches. - -export module mcpp.platform.project_name; - -import std; - -export namespace mcpp::platform { - -bool is_windows_reserved_project_name(std::string_view name) { - // Windows reserves the device basename even when an extension is present - // (`CON.txt`, `LPT1.log`). Comparison is ASCII case-insensitive. - auto dot = name.find('.'); - auto base = name.substr(0, dot); - std::string upper; - upper.reserve(base.size()); - for (unsigned char ch : base) { - upper.push_back(ch >= 'a' && ch <= 'z' - ? static_cast(ch - 'a' + 'A') - : static_cast(ch)); - } - if (upper == "CON" || upper == "PRN" || upper == "AUX" - || upper == "NUL") { - return true; - } - if (upper.size() == 4 - && (upper.starts_with("COM") || upper.starts_with("LPT")) - && upper[3] >= '1' && upper[3] <= '9') { - return true; - } - return false; -} - -} // namespace mcpp::platform diff --git a/modules/platform/src/scaffold_fs.cppm b/modules/platform/src/scaffold_fs.cppm deleted file mode 100644 index 96f8b797..00000000 --- a/modules/platform/src/scaffold_fs.cppm +++ /dev/null @@ -1,34 +0,0 @@ -// Platform filesystem primitives used by transactional scaffolding. The -// public contract is stronger than std::filesystem::rename: an existing final -// name is never replaced, including a target that appears in the commit race. - -export module mcpp.platform.scaffold_fs; - -import std; -import mcpp.platform.fs_caps; - -export namespace mcpp::platform { - -std::expected atomic_rename_directory_no_replace( - const std::filesystem::path& source, - const std::filesystem::path& target) { - // By what the target's filesystem does (fs_caps, #809): renameat2's - // RENAME_NOREPLACE is EINVAL on NFSv3, where `mcpp new` failed. - if (auto published = rename_no_replace(source, target); !published) - return std::unexpected(std::format("atomic no-replace rename '{}' -> '{}' failed: {}", - source.string(), target.string(), published.error())); - return {}; -} - -// Make a newly written regular file durable before its containing staging -// directory is published. Unsupported filesystem sync operations are treated -// as "best available"; genuine I/O/permission failures abort the transaction. -// Each system's own: posix/ and windows/ scaffold_fs.cpp. -std::expected sync_regular_file(const std::filesystem::path& path); - -// Persist the directory entry after the exclusive rename where the host/filesystem -// offers directory fsync. MoveFileExW(MOVEFILE_WRITE_THROUGH) already supplies -// the corresponding Windows guarantee. -std::expected sync_directory(const std::filesystem::path& path); - -} // namespace mcpp::platform diff --git a/modules/platform/src/shell.cppm b/modules/platform/src/shell.cppm deleted file mode 100644 index df5cff22..00000000 --- a/modules/platform/src/shell.cppm +++ /dev/null @@ -1,180 +0,0 @@ -// mcpp.platform.shell — platform-aware shell quoting and redirect helpers. -// -// Provides shell-safe argument quoting for command construction: -// POSIX: single-quote wrapping ('arg') -// Windows: double-quote wrapping ("arg") -// -// NOTE on Windows: do NOT use quote() for the FIRST token in a -// popen/system command string — cmd.exe strips a leading " pair. -// Use the raw path string as the first token; quote() is safe for -// arguments only. - -export module mcpp.platform.shell; - -import std; -import mcpplibs.os.platform; - -export namespace mcpp::platform::shell { - -// Platform-aware shell argument quoting. -std::string quote(std::string_view s); - -// The two halves of `quote`, callable regardless of host. Exposed so the -// Windows command-line shape can be built and unit-tested from any platform -// — the cmd.exe quoting rules are the easiest thing in mcpp to get wrong and -// the hardest to notice, since a Linux/macOS run never executes that code. -std::string quote_windows(std::string_view s); -std::string quote_posix(std::string_view s); - -// AN ARGUMENT OF A COMMAND PRINTED FOR A PERSON TO PASTE (mcpp#832). Left as -// it is when the shell would read it as one word anyway, so the common -// message reads as it always did; quoted by this host's shell rules when it -// would not -- a space, a quote, a glob or a shell metacharacter. Every -// suggested command that carries a path builds the argument with this. -std::string paste_arg(std::string_view s); - -// An argument that must survive TWO parsers: cmd.exe's, and then the child -// program's own argv parsing. -// -// `quote_windows` answers only the second. Its `\"` escape belongs to the -// MSVCRT argv rules, and cmd.exe does not know it: to cmd every `"` simply -// toggles a quote state, so a payload carrying an EVEN number of them before a -// metacharacter leaves that character unquoted. Measured on windows-2022 with -// a JSON argument -- `{"targets":["xim:shaderc@>=2026.3"],"yes":true}` puts -// four quotes before the `>`, and cmd read it as a REDIRECTION, answering -// -// The filename, directory name, or volume label syntax is incorrect. -// -// which arrived as a package-provisioning failure naming a package. -// -// The answer escapes exactly what needs it: quote for the child, then walk the -// result tracking the quote state CMD sees -- every `"` toggles it, because cmd -// does not know MSVCRT's `\"` -- and prefix `^` to any metacharacter that falls -// outside a quoted region. Inside one it is already inert, and `^` there is a -// literal character rather than an escape. -// -// ESCAPING EVERY METACHARACTER INCLUDING THE QUOTES WAS TRIED FIRST AND -// BROKE EVERY PAYLOAD. It is defensible on paper -- with no bare `"`, cmd never -// enters a quoted region and each metacharacter is escaped rather than quoted -// -- and Windows CI answered `exit 1` for every package fetch, including the -// ones whose JSON contains no metacharacter at all. So the rule here is the -// conservative one: a payload with nothing to escape comes out byte-identical -// to `quote_windows`, and only the character that is actually unprotected -// acquires a caret. -// -// `%` IS NOT ESCAPED AND CANNOT BE. Variable expansion happens before caret -// processing, and the batch-file escape (`%%`) is not available on a command -// line. Callers passing text that may contain `%` need a different mechanism; -// the JSON arguments this exists for do not. -std::string quote_windows_through_cmd(std::string_view s); - -// Host-selecting: `quote_windows_through_cmd` on Windows, `quote_posix` -// elsewhere. Use this wherever an argument reaches a shell and may contain -// metacharacters -- notably JSON, and any version constraint spelled `>=`. -std::string quote_through_shell(std::string_view s); - -// Silent redirect — stdout + stderr → /dev/null (or NUL on Windows). -// stdin is NOT touched here; that's the responsibility of -// mcpp::platform::process::seal_stdin, which is auto-applied by capture / -// run_silent / run_streaming on all platforms. -constexpr std::string_view silent_redirect = mcpplibs::os::is_windows ? ">nul 2>&1" : ">/dev/null 2>&1"; - -} // namespace mcpp::platform::shell - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::shell { - -// THE ARGV RULES OF CommandLineToArgvW AND THE MSVC RUNTIME, NOT ONLY `\"`. -// -// Backslashes are literal unless a run of them precedes a `"`: then 2n -// backslashes stand for n and the quote ends (or begins) a quoted region, -// and 2n+1 stand for n and a literal quote. Escaping only the quote is wrong -// whenever backslashes precede it. The case that was met is an -// argument ENDING in a backslash: `"C:\Kits\10\"` closes nothing, because -// its last two characters are an escaped quote, and every following -// argument shifts. Measured 2026-10-05 in a Visual Studio developer -// environment, whose `WindowsSdkDir` ends in `\`: the LLVM row's std module -// precompile received `Files\Microsoft`, `Visual` and the rest of the -// command as separate inputs. Doubling such a run changes only arguments -// that were parsed wrongly before. -std::string quote_windows(std::string_view s) { - std::string out; - out.reserve(s.size() + 2); - out.push_back('"'); - std::size_t backslashes = 0; - for (char c : s) { - if (c == '\\') { ++backslashes; continue; } - if (c == '"') { - out.append(backslashes * 2 + 1, '\\'); - } else { - out.append(backslashes, '\\'); - } - out.push_back(c); - backslashes = 0; - } - out.append(backslashes * 2, '\\'); - out.push_back('"'); - return out; -} - -std::string paste_arg(std::string_view s) { - constexpr bool is_windows = mcpplibs::os::is_windows; - const bool plain = !s.empty() && std::ranges::all_of(s, [](unsigned char c) { - return std::isalnum(c) || c == '/' || c == '.' || c == '_' || c == '-' || c == ':' - || c == '@' || c == '+' || c == '=' || c == ',' || c == '%' || c == '~' - || (c == '\\' && is_windows) || c >= 0x80; - }); - return plain ? std::string(s) : quote(s); -} - -std::string quote_posix(std::string_view s) { - std::string out; - out.reserve(s.size() + 2); - out.push_back('\''); - for (char c : s) { - if (c == '\'') out += "'\\''"; - else out.push_back(c); - } - out.push_back('\''); - return out; -} - -std::string quote_windows_through_cmd(std::string_view s) { - const std::string inner = quote_windows(s); - std::string out; - out.reserve(inner.size() + 8); - // cmd's quote state, which is toggled by EVERY `"` -- it does not know - // MSVCRT's `\"`. Inside a quoted region a metacharacter is already inert - // and `^` is a literal character, so only the characters that fall OUTSIDE - // one are escaped. That keeps the result byte-identical to `quote_windows` - // for every payload with nothing to escape, which is nearly all of them. - bool inQuotes = false; - for (char c : inner) { - if (c == '"') { inQuotes = !inQuotes; out.push_back(c); continue; } - if (!inQuotes) { - switch (c) { - case '<': case '>': case '&': case '|': - case '^': case '(': case ')': - out.push_back('^'); - break; - default: - break; - } - } - out.push_back(c); - } - return out; -} - -std::string quote(std::string_view s) { - if constexpr (mcpplibs::os::is_windows) return quote_windows(s); - else return quote_posix(s); -} - -std::string quote_through_shell(std::string_view s) { - if constexpr (mcpplibs::os::is_windows) return quote_windows_through_cmd(s); - else return quote_posix(s); -} - -} // namespace mcpp::platform::shell diff --git a/modules/platform/src/terminal.cppm b/modules/platform/src/terminal.cppm deleted file mode 100644 index 5c960d7f..00000000 --- a/modules/platform/src/terminal.cppm +++ /dev/null @@ -1,380 +0,0 @@ -// mcpp.platform.terminal — terminal capability detection and output. -// -// Provides: -// is_tty() — whether stdout is a terminal -// is_terminal(s) — whether a standard stream is a terminal -// can_move_cursor(s)— whether a live display may be drawn on it -// cols(s), rows(s) — the size of the terminal a stream reaches -// write(s, text) — UTF-8 text to a standard stream -// write_frame(s, b) — a frame of a live display, in one write -// same_terminal() — whether stdout and stderr reach one terminal -// KeyInput — keys read from the terminal while a game plays - -module; -#include // stdout, stderr: macros, which `import std` does not carry - -export module mcpp.platform.terminal; - -import std; -import mcpp.platform.common; -import mcpplibs.os.terminal; -import mcpplibs.os.stdio; - -export namespace mcpp::platform::terminal { - -// The two standard streams a human reads. -enum class Stream { Out, Err }; - -// Returns true if stdout is connected to a terminal (TTY). -bool is_tty(); -bool input_is_terminal(); - -// Whether the stream is a terminal: `isatty` on POSIX, macOS included, and a -// console on Windows. Until 2026.9.29.5 this was compiled only under -// `__unix__`, which Apple's compilers do not define, so macOS and Windows were -// never terminals and drew neither colour nor a live line. A Windows program -// under mintty writes to a pipe, not a console, and is not a terminal here. -bool is_terminal(Stream s); - -// Whether a display that moves the cursor may be drawn on the stream: a -// terminal, not `TERM=dumb`, and on Windows a console that accepted virtual -// terminal processing. The first call on Windows enables that processing, so -// that the escape sequences mcpp writes are interpreted rather than printed. -bool can_move_cursor(Stream s); - -// The width in columns of the terminal the stream reaches. Tries that terminal -// first (TIOCGWINSZ on the stream's descriptor, or the console's window on -// Windows), falls back to $COLUMNS, then to 80. The stream is a parameter -// because the display is drawn on one stream while the other may be a pipe: a -// width asked of a descriptor that is not a terminal is always the fallback. -std::size_t cols(Stream s); - -// The terminal's height in rows, by the same order, with $LINES and 24. -std::size_t rows(Stream s); - -// Writes UTF-8 text to the stream. A Windows console receives it as UTF-16 -// through WriteConsoleW, so that text outside ASCII (`·`, `→`, a path in -// Chinese) appears as written whatever the console's code page is; the stdio -// buffer is flushed first, so the two paths keep their order. Everything else -// receives the bytes through stdio, unflushed. -void write(Stream s, std::string_view text); - -// Writes a frame of a live display in one operation. The stdio buffers of -// both streams are flushed first, so that everything written before arrives -// before; the frame then leaves in one write(2) on POSIX (repeated only for a -// partial write or an interruption) and in one WriteConsoleW on a Windows -// console. Written through stdio, a frame whose last row has no line end was -// flushed in two parts on a line-buffered terminal, and a terminal that -// painted between them showed the display half-drawn (measured: 184 of 202 -// frames of one build, .agents/docs/2026-09-30-build-output-refinement-design.md -// F1). -void write_frame(Stream s, std::string_view bytes); - -// Whether standard error reaches the terminal standard output reaches: both -// are terminals and, on POSIX, the same device; on Windows both are console -// handles, and a process has at most one console. A line for the stream a live -// display is not drawn on can then travel in the frame written to the stream it -// is drawn on. -bool same_terminal(); - -// Whether the terminal the stream reaches is likely to draw East Asian -// ambiguous-width characters (`·`, `…`, `→`, the box-drawing block) two columns -// wide: a Windows console whose output code page is 932, 936, 949 or 950, or, -// on POSIX, a locale (LC_ALL, LC_CTYPE, then LANG) for Chinese, Japanese or -// Korean. The answer is a likelihood; a live display that budgets its width -// by it is never wider than the terminal either way. -bool ambiguous_wide(Stream s); - -// Whether the terminal can be expected to draw characters beyond ASCII from -// its font or a fallback, braille included: on POSIX a UTF-8 locale (LC_ALL, -// LC_CTYPE, then LANG); on Windows a terminal that names itself (Windows -// Terminal sets WT_SESSION, VS Code and others TERM_PROGRAM). The console -// host alone has no glyph fallback, and its long-standing default font lacks -// the braille block. -bool unicode_capable(); - -// The keys a game reads (build output design revision 3, §5.14): the arrows, -// the space bar, and `wasd` as a second set of arrows. -enum class Key { Up, Down, Left, Right, Space }; - -// The keys in `pending`, the bytes a POSIX terminal sent: an arrow is -// `ESC [ A` to `ESC [ D`, `ESC O A` to `ESC O D` in application mode, or -// `ESC [ 1 ; 5 A` and the like with a modifier; a sequence for any other key -// is skipped whole. An incomplete sequence at the end is left in `pending`. -std::vector decode_keys(std::string& pending); - -// KEYS READ FROM THE TERMINAL FOR THE LIFETIME OF THE OBJECT, without echo and -// without waiting for a line end; Ctrl-C still interrupts. Active only when -// standard input and the stream the display is drawn on (`display`) are both -// terminals and, on POSIX, mcpp is in the terminal's foreground process group: -// a background job that changed the terminal's mode would be stopped by -// SIGTTOU. The mode the terminal had is restored when the object is destroyed, -// and by the signal handler if a signal ends mcpp first (POSIX: -// `unixproc::guard_terminal_mode`; Windows: a console control handler). -class KeyInput { -public: - explicit KeyInput(Stream display); - ~KeyInput(); - KeyInput(const KeyInput&) = delete; - KeyInput& operator=(const KeyInput&) = delete; - bool active() const { return active_; } - // The keys pressed since the last call; never blocks. - std::vector read(); -private: - bool active_ = false; - std::string pending_; // an escape sequence not yet complete - unsigned long savedMode_ = 0; // Windows: the console input mode -}; - -// EVERYTHING WRITTEN TO STANDARD OUTPUT GOES TO STANDARD ERROR UNTIL THIS IS -// DESTROYED. -// -// The redirection is made at the file descriptor, so a child process that -// inherits standard output follows it, and so does narration that prints to -// stdout without consulting any quiet flag. A command whose standard output is -// a document (`mcpp emit build-database`) plans under one of these and prints -// the document after it is gone: people still see the progress, on stderr, and -// the document arrives alone. -// -// THE SAVED DESCRIPTOR IS NOT INHERITED. It is the caller's pipe, and a child -// started during the redirection (a build program, an xlings refresh) that -// inherited it would keep the caller from reading end-of-file for as long as -// the child lives, however long after mcpp itself exited. Measured -// (mcpp-community/mcpp#648): a build program started by -// `emit build-database` held the caller's pipe as its descriptor 3. The copy -// is therefore close-on-exec on POSIX and not inheritable on Windows, so a -// child receives only descriptors 0 to 2, and 1 is standard error here. -// Writes `text` to the process's standard output as it was before any -// StdoutToStderr took it: a machine-readable stream (the build's NDJSON -// events) keeps the channel the protocol owns while the narration around it is -// redirected. -void write_protocol_stdout(std::string_view text); - -class StdoutToStderr { -public: - StdoutToStderr(); - ~StdoutToStderr(); - StdoutToStderr(const StdoutToStderr&) = delete; - StdoutToStderr& operator=(const StdoutToStderr&) = delete; -private: - int saved_ = -1; -}; - -} // namespace mcpp::platform::terminal - -// ─── Each system's own (posix/terminal.cpp, windows/terminal.cpp) ───────── -// -// Not exported. Whether a stream is a terminal, its size, its control -// sequences and UTF-8 text on a console are mcpplibs.os's; what remains is -// what that library does not carry. -namespace mcpp::platform::terminal::native { -// Whether standard output and standard error reach one terminal, both being -// terminals: the same device on POSIX, consoles on Windows. -bool same_terminal(); -// The console's output code page; 0 where a stream is not a console, and on -// every system without one. -unsigned console_output_code_page(Stream s); -// Writes all of `text` to the C runtime's descriptor `fd`; false when a write -// fails. -bool write_descriptor(int fd, std::string_view text); -// A descriptor for standard output that a started program does not inherit, -// standard output then made a copy of standard error; -1 when it cannot be. -int divert_stdout(); -// Standard output put back from `saved`, which is closed. -void restore_stdout(int saved); -} // namespace mcpp::platform::terminal::native - -namespace mcpp::platform::terminal { - -namespace os = mcpplibs::os; - -namespace { - -std::FILE* file_of(Stream s) { return s == Stream::Out ? stdout : stderr; } -os::terminal::stream stream_of(Stream s) { - return s == Stream::Out ? os::terminal::stream::out : os::terminal::stream::err; -} - -} // namespace - -bool is_terminal(Stream s) { - return os::terminal::interactive(stream_of(s)); -} - -bool input_is_terminal() { - return os::terminal::interactive(os::terminal::stream::in); -} - -bool is_tty() { return is_terminal(Stream::Out); } - -bool can_move_cursor(Stream s) { - if (!is_terminal(s)) return false; - if (const char* term = std::getenv("TERM"); term && std::string_view(term) == "dumb") - return false; - // A POSIX terminal is taken to obey them; a console is asked, and is - // switched to virtual terminal processing by the asking. - if constexpr (mcpp::platform::is_windows) return os::terminal::sequences(stream_of(s)); - else return true; -} - -namespace { -std::size_t from_env(const char* name, std::size_t fallback) { - if (auto* e = std::getenv(name); e && *e) { - try { auto n = std::stoul(e); if (n > 0) return n; } catch (...) {} - } - return fallback; -} -} // namespace - -std::size_t cols(Stream s) { - if (const auto size = os::terminal::size(stream_of(s)); size && size->columns > 0) - return size->columns; - return from_env("COLUMNS", 80); -} - -std::size_t rows(Stream s) { - if (const auto size = os::terminal::size(stream_of(s)); size && size->rows > 0) - return size->rows; - return from_env("LINES", 24); -} - -namespace { -// The whole of `bytes` to the stream through mcpplibs.os, which gives a -// console UTF-16 (openkal K10): text outside ASCII appears as written -// whatever the console's code page is. -bool write_through_os(Stream s, std::string_view bytes) { - return (s == Stream::Out ? os::stdio::write_output(bytes) : os::stdio::write_error(bytes)).has_value(); -} -} // namespace - -void write(Stream s, std::string_view text) { - if (text.empty()) return; - if constexpr (mcpp::platform::is_windows) { - if (is_terminal(s)) { - std::fflush(file_of(s)); - if (write_through_os(s, text)) return; - } - } - std::fwrite(text.data(), 1, text.size(), file_of(s)); -} - -void write_frame(Stream s, std::string_view bytes) { - if (bytes.empty()) return; - std::fflush(stdout); - std::fflush(stderr); - // One write of the whole frame (repeated by the library only for a - // partial write or an interruption); on Windows only to a console, the - // C runtime's text mode keeping the line ends of anything else. - if (!mcpp::platform::is_windows || is_terminal(s)) - if (write_through_os(s, bytes)) return; - std::fwrite(bytes.data(), 1, bytes.size(), file_of(s)); - std::fflush(file_of(s)); -} - -bool same_terminal() { - return native::same_terminal(); -} - -bool ambiguous_wide(Stream s) { - const unsigned cp = native::console_output_code_page(s); - if (cp == 932 || cp == 936 || cp == 949 || cp == 950) return true; - for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { - const char* v = std::getenv(name); - if (!v || !*v) continue; - const std::string_view l(v); - return l.starts_with("zh") || l.starts_with("ja") || l.starts_with("ko"); - } - return false; -} - -bool unicode_capable() { - if constexpr (mcpp::platform::is_windows) { - for (const char* name : {"WT_SESSION", "TERM_PROGRAM"}) - if (const char* v = std::getenv(name); v && *v) return true; - return false; - } else { - for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { - const char* v = std::getenv(name); - if (!v || !*v) continue; - std::string l(v); - for (auto& c : l) c = static_cast(std::tolower(static_cast(c))); - return l.find("utf-8") != std::string::npos || l.find("utf8") != std::string::npos; - } - return false; - } -} - -std::vector decode_keys(std::string& pending) { - std::vector keys; - std::size_t i = 0; - while (i < pending.size()) { - const char c = pending[i]; - if (c == '\x1b') { - if (i + 1 >= pending.size()) break; // wait for the rest - if (pending[i + 1] != '[' && pending[i + 1] != 'O') { ++i; continue; } - // Parameter and intermediate bytes (0x20 to 0x3F), then one final - // byte (0x40 to 0x7E) that names the key. - std::size_t end = i + 2; - while (end < pending.size() - && static_cast(pending[end]) >= 0x20 - && static_cast(pending[end]) <= 0x3F) - ++end; - if (end >= pending.size()) break; // wait for the final byte - switch (pending[end]) { - case 'A': keys.push_back(Key::Up); break; - case 'B': keys.push_back(Key::Down); break; - case 'C': keys.push_back(Key::Right); break; - case 'D': keys.push_back(Key::Left); break; - default: break; - } - i = end + 1; - continue; - } - switch (c) { - case ' ': keys.push_back(Key::Space); break; - case 'w': case 'W': keys.push_back(Key::Up); break; - case 's': case 'S': keys.push_back(Key::Down); break; - case 'a': case 'A': keys.push_back(Key::Left); break; - case 'd': case 'D': keys.push_back(Key::Right); break; - default: break; - } - ++i; - } - pending.erase(0, i); - return keys; -} - -// KeyInput's constructor, destructor and read: each system's own. - -namespace { -std::atomic g_protocolStdout{-1}; -} - -void write_protocol_stdout(std::string_view text) { - const int fd = g_protocolStdout.load(); - if (fd < 0) { - std::fwrite(text.data(), 1, text.size(), stdout); - std::fflush(stdout); - return; - } - native::write_descriptor(fd, text); -} - -StdoutToStderr::StdoutToStderr() { - std::fflush(stdout); - saved_ = native::divert_stdout(); - if (saved_ >= 0) { - int expected = -1; - g_protocolStdout.compare_exchange_strong(expected, saved_); - } -} - -StdoutToStderr::~StdoutToStderr() { - std::fflush(stdout); - if (saved_ < 0) return; - int expected = saved_; - g_protocolStdout.compare_exchange_strong(expected, -1); - native::restore_stdout(saved_); -} - -} // namespace mcpp::platform::terminal diff --git a/modules/platform/src/windows.cppm b/modules/platform/src/windows.cppm deleted file mode 100644 index 375ba486..00000000 --- a/modules/platform/src/windows.cppm +++ /dev/null @@ -1,51 +0,0 @@ -// mcpp.platform.windows — Windows-specific platform capabilities. -// -// Provides: -// prepend_path() — add a directory to the front of %PATH% -// active_code_page() — the process ANSI code page (GetACP) -// -// Note: Visual Studio / MSVC discovery is in mcpp.toolchain.msvc, which is -// the authoritative module for MSVC toolchain detection. This module -// provides general-purpose Windows platform utilities only. - -export module mcpp.platform.windows; - -import std; - -export namespace mcpp::platform::windows { - -// Prepend a directory to the %PATH% environment variable. -void prepend_path(const std::filesystem::path& dir); - -// The ANSI code page of this process: 65001 when the UTF-8 code page that -// mcpp.exe declares is in effect (Windows 10 version 1903 and later), the -// system's legacy code page otherwise. 0 on every other platform, which has -// no process code page. Every narrow string mcpp exchanges with Win32 is in -// this code page (#693). -unsigned active_code_page(); - -// A string value under HKEY_LOCAL_MACHINE, read from both registry views (the -// 64-bit one first, then WOW6432Node's), as a path. An installer records its -// installation directory there wherever it put it -- the Windows SDK's -// `Installed Roots\KitsRoot10` -- so a search that knows only `C:\Program -// Files` misses an SDK on another drive (D15). nullopt when absent and on -// every other platform. -std::optional machine_registry_path(std::wstring_view subkey, - std::wstring_view value); - -// A directory a known-folder environment variable names (`ProgramFiles`, -// `ProgramFiles(x86)`), or nullopt. The system drive is not always `C:`. -std::optional env_directory(const char* name); - -// Whether the system lets a program that declares itself `longPathAware` use -// paths past MAX_PATH (260): HKLM\SYSTEM\CurrentControlSet\Control\FileSystem -// `LongPathsEnabled` = 1. false when it is 0 or absent, and on every other -// platform, which has no such limit. -bool long_paths_enabled(); -// Used only by the first-run Windows home suggestion; never creates the -// actual home. An existing child must be a plain directory: inspecting its -// reparse-point attributes avoids following a potentially remote target. -// Other platforms answer false without touching a filesystem. -bool writable_fixed_drive(char drive, std::wstring_view child); - -} // namespace mcpp::platform::windows diff --git a/modules/platform/src/windows/bounded_process.cpp b/modules/platform/src/windows/bounded_process.cpp deleted file mode 100644 index 4d04810b..00000000 --- a/modules/platform/src/windows/bounded_process.cpp +++ /dev/null @@ -1,568 +0,0 @@ -// mcpp.platform.windows.bounded_process on Windows. -module; -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include -#include // fputs, stderr — the out-of-slots diagnostic - -module mcpp.platform.windows.bounded_process; - -import std; - -namespace mcpp::platform::winproc { - -namespace { - -// A `\0`-separated, `\0\0`-terminated block: the current environment with -// `envEntries` applied on top. -// -// The override match is case-INSENSITIVE because Windows environment names -// are: passing `Path=` alongside an existing `PATH=` would otherwise leave two -// entries and let the loader pick. -std::string environment_block(const char* const* envEntries, - unsigned long envCount) { - auto upper = [](std::string s) { - for (auto& c : s) - c = static_cast(std::toupper(static_cast(c))); - return s; - }; - - std::vector overriddenUpper; - overriddenUpper.reserve(envCount); - for (unsigned long i = 0; i < envCount; ++i) { - std::string entry(envEntries[i]); - auto eq = entry.find('='); - overriddenUpper.push_back(upper(entry.substr(0, eq == std::string::npos - ? entry.size() : eq))); - } - - std::vector entries; - if (LPCH env = ::GetEnvironmentStringsA()) { - for (const char* p = env; *p; ) { - std::string entry(p); - p += entry.size() + 1; - // Drive-letter entries ("=C:=C:\path") start with '=' and have no - // name to compare; they must survive verbatim or relative paths - // resolve differently in the child. - auto eq = entry.find('=', 1); - if (eq != std::string::npos) { - if (std::ranges::find(overriddenUpper, upper(entry.substr(0, eq))) - != overriddenUpper.end()) - continue; - } - entries.push_back(std::move(entry)); - } - ::FreeEnvironmentStringsA(env); - } - for (unsigned long i = 0; i < envCount; ++i) - entries.emplace_back(envEntries[i]); - - std::string block; - for (auto const& e : entries) { block += e; block.push_back('\0'); } - // An empty block still needs its terminator or CreateProcess reads past - // the buffer. - block.push_back('\0'); - return block; -} - -// The one section every child start shares (see LaunchSection). -std::mutex g_launchMutex; - -struct Handle { - HANDLE h = nullptr; - Handle() = default; - Handle(const Handle&) = delete; - Handle& operator=(const Handle&) = delete; - ~Handle() { reset(); } - void reset() { - if (h && h != INVALID_HANDLE_VALUE) ::CloseHandle(h); - h = nullptr; - } -}; - -} // namespace - -LaunchSection::LaunchSection() { - g_launchMutex.lock(); - held_ = true; -} - -LaunchSection::~LaunchSection() { release(); } - -void LaunchSection::release() { - if (!held_) return; - held_ = false; - g_launchMutex.unlock(); -} - -DeadlineRun capture_with_deadline(const char* commandLine, - const char* const* envEntries, - unsigned long envCount, - const char* cwd, - long long deadlineMs, - long long idleMs, - OutputSink sink, - void* ctx, - StopPoll stop) -{ - DeadlineRun out; - const bool idleBound = idleMs > 0 && sink != nullptr; - // A child with no bound at all is accepted only with a sink, as on POSIX. - if ((deadlineMs <= 0 && !idleBound && sink == nullptr) || !commandLine || !*commandLine) - return out; - - const bool capture = (sink != nullptr); - - SECURITY_ATTRIBUTES sa{}; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - - // From the creation of the pipe to the parent's close of its write end: no - // other child may be started while this pipe is inheritable. - LaunchSection launch; - Handle readEnd, writeEnd; - if (capture) { - if (!::CreatePipe(&readEnd.h, &writeEnd.h, &sa, 0)) return out; - // Only the WRITE end may cross into the child. An inheritable read end - // there would keep the pipe alive past the child's exit and the drain - // below would never see EOF. - if (!::SetHandleInformation(readEnd.h, HANDLE_FLAG_INHERIT, 0)) return out; - } - - Handle job; - job.h = ::CreateJobObjectA(nullptr, nullptr); - if (job.h) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; - jeli.BasicLimitInformation.LimitFlags = - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - ::SetInformationJobObject(job.h, JobObjectExtendedLimitInformation, - &jeli, sizeof(jeli)); - } - - // A CAPTURED child gets stdin from NUL, never from the console. - // - // This is not tidiness: mcpp's Windows launchers have always sealed stdin - // (see this file's peer, mcpp.platform.process, and the bug it names — - // xlings / xim / curl / git children blocking on terminal input during - // bootstrap, forcing the user to hammer Enter). The path this replaces - // went through `_popen` with `< NUL` appended, so inheriting the console's - // stdin here would quietly bring that hang back. - // - // An UNCAPTURED child keeps the real stdin, matching run_exec: `mcpp run` - // hands the terminal to the program on purpose. - Handle nulIn; - if (capture) { - nulIn.h = ::CreateFileA("NUL", GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, - OPEN_EXISTING, 0, nullptr); - } - - STARTUPINFOA si{}; - si.cb = sizeof(si); - if (capture) { - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdOutput = writeEnd.h; - si.hStdError = writeEnd.h; - // If NUL could not be opened, fall back to the console handle rather - // than handing the child an invalid one — a child with no stdin at all - // fails in ways that look nothing like "stdin was not sealed". - si.hStdInput = (nulIn.h && nulIn.h != INVALID_HANDLE_VALUE) - ? nulIn.h - : ::GetStdHandle(STD_INPUT_HANDLE); - } - // else: no STARTF_USESTDHANDLES — the child inherits our console. - - PROCESS_INFORMATION pi{}; - std::string cmdBuf(commandLine); // CreateProcessA may modify it - auto envBlock = environment_block(envEntries, envCount); - - // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn - // anything — a grandchild created in that gap would escape the kill. - // CREATE_NO_WINDOW only when capturing: an uncaptured run is meant to be - // seen, and suppressing the console for it would hide the output this - // branch exists to show. - BOOL ok = ::CreateProcessA( - nullptr, cmdBuf.data(), nullptr, nullptr, /*bInheritHandles=*/TRUE, - CREATE_SUSPENDED | (capture ? CREATE_NO_WINDOW : 0u), - envBlock.data(), - (cwd && *cwd) ? cwd : nullptr, - &si, &pi); - if (!ok) { out.spawn_error = static_cast(::GetLastError()); return out; } - - Handle proc; proc.h = pi.hProcess; - Handle thread; thread.h = pi.hThread; - if (job.h) ::AssignProcessToJobObject(job.h, proc.h); - ::ResumeThread(thread.h); - - // The parent must drop its copy of the write end or the pipe never reaches - // EOF, even after every child has exited. - writeEnd.reset(); - launch.release(); - - const auto started = std::chrono::steady_clock::now(); - const auto until = deadlineMs > 0 - ? started + std::chrono::milliseconds(deadlineMs) - : std::chrono::steady_clock::time_point::max(); - auto lastOutput = started; - std::array buf{}; - bool killed = false; - - auto drain_available = [&]() -> bool { - if (!capture) return false; - DWORD avail = 0; - if (!::PeekNamedPipe(readEnd.h, nullptr, 0, nullptr, &avail, nullptr)) - return false; - if (avail == 0) return false; - DWORD want = static_cast( - std::min(buf.size(), static_cast(avail))); - DWORD got = 0; - if (!::ReadFile(readEnd.h, buf.data(), want, &got, nullptr) || got == 0) - return false; - if (sink) sink(ctx, buf.data(), static_cast(got)); - lastOutput = std::chrono::steady_clock::now(); - return true; - }; - - for (;;) { - // A stream of heartbeats cannot defer a caller's progress deadline. - if (!killed && (std::chrono::steady_clock::now() >= until || (stop && stop(ctx)))) { - killed = true; - if (job.h) job.reset(); - else ::TerminateProcess(proc.h, 1); - } - if (drain_available()) continue; // empty the pipe before sleeping - - if (::WaitForSingleObject(proc.h, 0) == WAIT_OBJECT_0) { - while (drain_available()) { /* tail */ } - break; - } - - const auto now = std::chrono::steady_clock::now(); - const bool overIdle = idleBound - && now - lastOutput >= std::chrono::milliseconds(idleMs); - if (!killed && (now >= until || overIdle)) { - killed = true; - // Closing the job takes the whole tree with it. TerminateProcess - // alone would leave grandchildren holding the pipe open. - if (job.h) job.reset(); - else ::TerminateProcess(proc.h, 1); - ::WaitForSingleObject(proc.h, 5000); - continue; - } - - ::Sleep(20); - } - - DWORD code = 0; - ::GetExitCodeProcess(proc.h, &code); - out.exit_code = static_cast(code); - out.timed_out = killed; - out.supported = true; - return out; -} - -// ─── Background children ───────────────────────────────────────────────── - -namespace { - -// Read by a console control handler, which runs on a thread of the OS's -// choosing. Only the handle is shared, and closing a job handle is atomic from -// the caller's point of view. -constexpr int kMaxGuardedJobs = 256; -volatile unsigned long long g_guardedJobs[kMaxGuardedJobs] = {}; -// Serializes the WRITERS of the registry and of the handler it installs; the -// console handler never takes it. -std::mutex g_guardMutex; - -// The files an interrupt leaves; every set lives until exit (the POSIX peer -// states why). -struct InterruptFiles { - std::vector paths, temps, contents; -}; -std::atomic g_interrupt{nullptr}; -std::vector> g_interruptSets; - -void write_interrupt_files() { - auto* set = g_interrupt.load(); - if (!set) return; - for (std::size_t i = 0; i < set->paths.size(); ++i) { - HANDLE f = ::CreateFileA(set->temps[i].c_str(), GENERIC_WRITE, 0, nullptr, - CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); - if (f == INVALID_HANDLE_VALUE) continue; - DWORD written = 0; - const BOOL ok = ::WriteFile(f, set->contents[i].data(), - static_cast(set->contents[i].size()), &written, nullptr); - ::CloseHandle(f); - if (ok && written == set->contents[i].size()) - ::MoveFileExA(set->temps[i].c_str(), set->paths[i].c_str(), MOVEFILE_REPLACE_EXISTING); - else - ::DeleteFileA(set->temps[i].c_str()); - } -} - -bool handler_needed() { - if (g_interrupt.load()) return true; - for (int i = 0; i < kMaxGuardedJobs; ++i) - if (g_guardedJobs[i] != 0) return true; - return false; -} - -BOOL WINAPI background_console_handler(DWORD) { - // TerminateJobObject, not CloseHandle: this handler races `background_stop` - // on the normal path, and terminating is idempotent while closing the same - // handle twice is not. The handle stays valid for whoever closes it. - for (int i = 0; i < kMaxGuardedJobs; ++i) { - const auto job = g_guardedJobs[i]; - if (job) - ::TerminateJobObject( - reinterpret_cast(static_cast(job)), 1); - } - write_interrupt_files(); - return FALSE; // FALSE = also run the default handler, i.e. still exit -} - -} // namespace - -BackgroundChild spawn_background(const char* commandLine, - const char* cwd, - int inheritStdio) -{ - BackgroundChild out; - if (!commandLine || !*commandLine) return out; - - HANDLE job = ::CreateJobObjectA(nullptr, nullptr); - if (job) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; - jeli.BasicLimitInformation.LimitFlags = - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, - &jeli, sizeof(jeli)); - } - - SECURITY_ATTRIBUTES sa{}; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - - Handle nulIo; - STARTUPINFOA si{}; - si.cb = sizeof(si); - if (!inheritStdio) { - nulIo.h = ::CreateFileA("NUL", GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, - OPEN_EXISTING, 0, nullptr); - if (nulIo.h && nulIo.h != INVALID_HANDLE_VALUE) { - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdInput = nulIo.h; - si.hStdOutput = nulIo.h; - si.hStdError = nulIo.h; - } - } - - PROCESS_INFORMATION pi{}; - std::string cmdBuf(commandLine); // CreateProcessA may modify it - - // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn anything - // — a grandchild created in that gap would escape the kill, which for a - // background player is the difference between "stops" and "plays forever". - // - // CREATE_NEW_PROCESS_GROUP is the peer of POSIX_SPAWN_SETPGROUP: the child - // stops receiving the console's Ctrl-C, which is what makes the guard - // below necessary and what stops a stray Ctrl-C from half-killing the tree. - BOOL ok = FALSE; - { - // This child inherits every inheritable handle, another thread's - // capture pipe included, unless it is started outside the window that - // pipe is open in (see LaunchSection). - LaunchSection launch; - ok = ::CreateProcessA( - nullptr, cmdBuf.data(), nullptr, nullptr, - /*bInheritHandles=*/TRUE, - CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP - | (inheritStdio ? 0u : CREATE_NO_WINDOW), - nullptr, (cwd && *cwd) ? cwd : nullptr, &si, &pi); - } - if (!ok) { - out.refused = ::GetLastError(); - if (job) ::CloseHandle(job); - return out; - } - if (job) ::AssignProcessToJobObject(job, pi.hProcess); - ::ResumeThread(pi.hThread); - ::CloseHandle(pi.hThread); - - out.ok = true; - out.job = static_cast( - reinterpret_cast(job)); - out.process = static_cast( - reinterpret_cast(pi.hProcess)); - return out; -} - -int background_running(unsigned long long process, int* exitCode) { - if (!process) return -1; - auto h = reinterpret_cast(static_cast(process)); - const DWORD r = ::WaitForSingleObject(h, 0); - if (r == WAIT_TIMEOUT) return 1; - if (r != WAIT_OBJECT_0) return -1; - if (exitCode) { - DWORD code = 0; - ::GetExitCodeProcess(h, &code); - *exitCode = static_cast(code); - } - return 0; -} - -void background_stop(unsigned long long job, unsigned long long process, - long long graceMs) -{ - auto procH = process ? reinterpret_cast( - static_cast(process)) - : nullptr; - auto jobH = job ? reinterpret_cast( - static_cast(job)) - : nullptr; - - // NO POLITE ASK HERE, AND `graceMs` IS DELIBERATELY UNSPENT. - // - // The obvious "ask first" is - // - // ::GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, ::GetProcessId(procH)); - // - // and it is wrong in a way that does not show up locally. That call - // addresses a process GROUP attached to this console, not a process; when - // the id does not name a live group of ours — which it does not, once the - // child has already exited, and `start /b`-style commands exit at once — - // the event reaches everything sharing the console instead. Measured on - // the Windows e2e runner: the whole suite died eleven seconds into the - // hooks test with exit code -1073741510 (0xC000013A, - // STATUS_CONTROL_C_EXIT) and printed no summary at all, because mcpp had - // Ctrl-Break'd its own console. - // - // Windows has no portable graceful stop for a child with no console and no - // window of its own. The job IS the mechanism; the POSIX peer's - // SIGTERM-then-grace has no equivalent here, and the asymmetry is stated - // in the declaration rather than faked with a call that reaches too far. - (void)graceMs; - - // Closing a KILL_ON_JOB_CLOSE job takes the whole tree. TerminateProcess - // on the child alone would leave whatever it started behind — which for - // `start /b cmd /c player` is the player. - if (jobH) ::CloseHandle(jobH); - if (procH) ::CloseHandle(procH); -} - -void guard_job_on_signal(unsigned long long job) { - if (!job) return; - { - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedJobs; ++i) { - if (g_guardedJobs[i] == 0) { - g_guardedJobs[i] = job; - ::SetConsoleCtrlHandler(background_console_handler, TRUE); - return; - } - } - } - std::fputs("mcpp: internal: more than 256 concurrently guarded jobs; the " - "newest is NOT guarded and may outlive mcpp\n", stderr); -} - -void unguard_job(unsigned long long job) { - if (!job) return; - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedJobs; ++i) - if (g_guardedJobs[i] == job) g_guardedJobs[i] = 0; - if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); -} - -void clear_job_guard() { - std::lock_guard lock(g_guardMutex); - for (int i = 0; i < kMaxGuardedJobs; ++i) g_guardedJobs[i] = 0; - if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); -} - -void set_interrupt_files(const std::vector& paths, - const std::vector& temps, - const std::vector& contents) { - if (paths.empty() || paths.size() > 4 || temps.size() != paths.size() - || contents.size() != paths.size()) - return; - std::lock_guard lock(g_guardMutex); - g_interruptSets.push_back(std::make_unique(InterruptFiles{paths, temps, contents})); - g_interrupt.store(g_interruptSets.back().get()); - ::SetConsoleCtrlHandler(background_console_handler, TRUE); -} - -void clear_interrupt_files() { - std::lock_guard lock(g_guardMutex); - g_interrupt.store(nullptr); - if (!handler_needed()) ::SetConsoleCtrlHandler(background_console_handler, FALSE); -} - -namespace { -BOOL WINAPI ignore_interrupt(DWORD type) { - return (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT) ? TRUE : FALSE; -} -} // namespace - -int run_foreground(const char* commandLine, unsigned long* refused) { - if (refused) *refused = 0; - if (!commandLine || !*commandLine) return -1; - - HANDLE job = ::CreateJobObjectA(nullptr, nullptr); - if (job) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; - jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, - &jeli, sizeof(jeli)); - } - - STARTUPINFOA si{}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - std::string cmdBuf(commandLine); // CreateProcessA may modify it - - // A handler, not SetConsoleCtrlHandler(nullptr, TRUE): the null form sets - // an attribute the child inherits, and the child would then ignore the - // Ctrl-C it is meant to receive. - ::SetConsoleCtrlHandler(ignore_interrupt, TRUE); - BOOL ok = FALSE; - { - LaunchSection launch; - // No CREATE_NEW_PROCESS_GROUP: that flag is what keeps the console's - // Ctrl-C from the child. CREATE_SUSPENDED so the child joins the job - // before it can start anything. - ok = ::CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, - /*bInheritHandles=*/TRUE, CREATE_SUSPENDED, - nullptr, nullptr, &si, &pi); - } - if (!ok) { - if (refused) *refused = ::GetLastError(); - if (job) ::CloseHandle(job); - ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); - return -1; - } - if (job) ::AssignProcessToJobObject(job, pi.hProcess); - ::ResumeThread(pi.hThread); - ::CloseHandle(pi.hThread); - - ::WaitForSingleObject(pi.hProcess, INFINITE); - DWORD code = 0; - ::GetExitCodeProcess(pi.hProcess, &code); - ::CloseHandle(pi.hProcess); - if (job) ::CloseHandle(job); - ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); - return static_cast(code); -} - -int wait_background(unsigned long long process, int* exitCode) { - HANDLE h = reinterpret_cast(process); - if (!h) return -1; - ::WaitForSingleObject(h, INFINITE); - DWORD code = 0; - if (!::GetExitCodeProcess(h, &code)) return -1; - if (exitCode) *exitCode = static_cast(code); - return 0; -} - -} // namespace mcpp::platform::winproc diff --git a/modules/platform/src/windows/bounded_process.cppm b/modules/platform/src/windows/bounded_process.cppm deleted file mode 100644 index 04ea34e8..00000000 --- a/modules/platform/src/windows/bounded_process.cppm +++ /dev/null @@ -1,229 +0,0 @@ -// mcpp.platform.windows.bounded_process — a child process with a real -// deadline on Windows. -// -// WHY THIS EXISTS -// -// `capture_exec_deadline` enforced its deadline on POSIX only; everywhere else -// it fell through to the unbounded launcher. So every timeout knob was a -// silent no-op on Windows — `mcpp test --timeout`, `--build-timeout`, and -// `[build] build_program_timeout`. A knob that reports nothing and does -// nothing is worse than an absent one: the user sets it, the build still -// hangs, and nothing connects the two. -// -// WHY A JOB OBJECT -// -// Not thoroughness — correctness. The child inherits the capture pipe's write -// handle, and so does anything IT spawns. Killing only the child leaves a -// grandchild holding that handle, and the parent's drain then blocks forever: -// the timeout would "fire" and hang anyway. A Job with -// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE takes the whole tree down at once, which -// closes every inherited handle and lets the drain finish. -// -// WHY THE INTERFACE HAS NO `std` TYPES IN IT -// -// This is a hard constraint, not a style preference. Measured on GCC 16.1.0 -// while adding this module: a NEW module that (a) is imported by -// `mcpp.platform.process` and (b) names `std` types in its EXPORTED -// interface makes every BMI downstream of `mcpp.platform.process` come back as -// -// mcpp.manifest.xpkg: error: failed to read compiled module cluster 192: -// Bad file data -// -// deterministically, in a clean build, and identically under `ninja -j1` (so -// it is not a write/read race). Bisected precisely: -// -// module exists but nothing imports it -> builds -// imported, exports only `int probe()` -> builds -// imported, `import std;` in the purview-> builds -// imported, exports std::string/… -> every downstream BMI corrupt -// -// So `std` may be used freely INSIDE this module; it must not appear in what -// the module exports. The output is therefore delivered through a callback -// instead of returned as a string, and the environment arrives as an array of -// `"K=V"` C strings. -// -// The same constraint is why the caller does the marshalling: see -// `mcpp.platform.process`. -// -// The declarations are the same on every system; the definitions are -// Windows's (windows/bounded_process.cpp), and a call from another system is a -// link error. - -export module mcpp.platform.windows.bounded_process; - -import std; - -export namespace mcpp::platform::winproc { - -// Every member is a builtin type — see the note above. -struct DeadlineRun { - // False on every non-Windows build, and on Windows when the process could - // not be created at all. The caller must fall back to its unbounded path - // rather than treating this as a failed child: "could not spawn it" and - // "it ran and failed" are different answers and must not share an exit - // code. - bool supported = false; - int exit_code = 0; - bool timed_out = false; - // Non-zero when `supported` is false BECAUSE CreateProcess was attempted - // and refused: its GetLastError() value. Zero with `supported` false means - // this build has no bounded launcher. Same contract as the POSIX peer, - // whose value is an errno; the caller reports the number verbatim and - // does not spawn again (#544). - int spawn_error = 0; -}; - -// ─── Starting a child is safe for concurrent callers ───────────────────── -// -// `CreateProcess` with `bInheritHandles = TRUE` hands a child EVERY inheritable -// handle of its parent, and a capture pipe's write end has to be inheritable for -// its own child. A child started by thread B while thread A holds that write end -// keeps it open for as long as it lives, and A's reader then waits for end of -// file until B's unrelated child exits. (`SetHandleInformation` cannot close the -// window: a handle is inheritable from its creation to the moment the parent -// closes it, and the start of the child lies between the two.) -// -// So the starts that can run at the same time as another, the ones below and -// the `_popen` calls of mcpp.platform.process, hold one process-wide section -// from the creation of the pipe to the parent's close of its write end. The -// alternative, `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`, would restrict what OUR -// launchers pass and leave `_popen` and any third party passing everything; the -// section is one rule for all of them. -// -// On another platform the class exists and does nothing. -class LaunchSection { -public: - LaunchSection(); - ~LaunchSection(); - // Leaves the section before the destructor does. - void release(); - LaunchSection(const LaunchSection&) = delete; - LaunchSection& operator=(const LaunchSection&) = delete; -private: - bool held_ = false; -}; - -// Receives stdout+stderr as it arrives. Called on the calling thread only. -// -// A NULL sink means "do not capture": the child inherits the caller's stdio and -// is still bounded. Same contract as the POSIX peer, and for the same reason — -// an uncaptured bounded run (`run_exec_deadline`) must keep its output LIVE and -// keep the child's stdout a console, or console-detecting children drop their -// colors and every line waits for exit. -using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); -using StopPoll = bool (*)(void* ctx); - -// `commandLine` is already quoted for CreateProcess (callers pass the output -// of windows_command_from_argv). `envEntries` is `envCount` NUL-terminated -// "KEY=VALUE" strings applied on top of the current environment. `cwd` may be -// null. A non-positive `deadlineMs` without a sink is rejected with -// supported=false — "no bound" belongs on the caller's untimed path, which -// needs none of this. -// -// `idleMs`, when positive, kills the child once it has written nothing for that -// long; the POSIX peer states the reason (#648). With `idleMs` positive a -// non-positive `deadlineMs` means "no total bound", and so it does with a sink -// and neither bound. There is no `ownGroup` -// parameter: every child here runs in a job object with -// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE already, which is what the POSIX flag -// buys there. -DeadlineRun capture_with_deadline(const char* commandLine, - const char* const* envEntries, - unsigned long envCount, - const char* cwd, - long long deadlineMs, - long long idleMs, - OutputSink sink, - void* ctx, - StopPoll stop); - -// ─── A child that outlives the call that started it (#496) ─────────────── -// -// The peer of unixproc::spawn_background, and the same contract: a project -// `[hooks] during_build` command is owned for the length of the BUILD, so the -// caller gets a handle it can poll and stop later. Builtins only, like -// DeadlineRun — HANDLEs travel as integers rather than as a std type. -// -// The job object does here what a process group does on POSIX, and does it -// better in one respect: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE means the tree -// dies when the last handle to the job closes, which includes mcpp exiting for -// ANY reason — Ctrl-C, an unhandled exception, or being killed outright. POSIX -// needs an explicit signal handler for the same guarantee and still cannot -// cover `kill -9`. -struct BackgroundChild { - bool ok = false; - unsigned long long job = 0; // HANDLE to the job object - unsigned long long process = 0; // HANDLE to the child - // GetLastError() from a refused CreateProcess. Carried rather than dropped - // for the reason the POSIX peer carries errno: the code in hand at the - // failure is the whole difference between "not found" and "not executable", - // and a caller that maps a spawn failure onto an exit status needs it. - unsigned long refused = 0; -}; - -// `inheritStdio == 0` sends the child's output to NUL. A spanning hook writes -// CONCURRENTLY with ninja and would otherwise interleave into the middle of a -// compiler diagnostic. -BackgroundChild spawn_background(const char* commandLine, - const char* cwd, - int inheritStdio); - -// 1 = still running, 0 = exited, -1 = unknown. When it returns 0, `exitCode` -// (if given) receives the process's exit code. Same contract as the POSIX -// peer, and for the same caller: the `loop` supervisor has to tell "the -// player finished the track" from "the command does not exist". -int background_running(unsigned long long process, int* exitCode); - -// Closes the job, which terminates the whole tree at once. -// -// `graceMs` is accepted for signature parity with the POSIX peer and is NOT -// spent: Windows has no portable graceful stop for a child with no console and -// no window of its own, and the call that looks like one -// (GenerateConsoleCtrlEvent) addresses a process group attached to THIS -// console — see the implementation for what that cost. The asymmetry with the -// POSIX side's SIGTERM-then-grace is real and is declared here rather than -// papered over. -void background_stop(unsigned long long job, unsigned long long process, - long long graceMs); - -// Ctrl-C. The job already covers process death, so this exists only so that a -// deliberate interrupt stops the tree BEFORE mcpp unwinds, rather than as a -// side effect of it exiting. -// A REGISTRY AND NOT ONE SLOT, for the reason the POSIX peer gives: a spanning -// `[hooks]` command and the build's own ninja are guarded at the same time, and -// a single slot lets the second registration disarm the first. -// -// SAFE FOR CONCURRENT CALLERS, as the POSIX peer is: claiming and releasing a -// slot is serialized by a mutex and the console handler takes no lock. The -// capacity is 256. -void guard_job_on_signal(unsigned long long job); -void unguard_job(unsigned long long job); -void clear_job_guard(); - -// The files a Ctrl-C or a Ctrl-Break leaves (the POSIX peer states why): the -// console handler, after it has ended the guarded jobs, writes each -// `contents[i]` to `temps[i]` and moves it over `paths[i]`. While files are set -// the handler is installed. At most four files. -void set_interrupt_files(const std::vector& paths, - const std::vector& temps, - const std::vector& contents); -void clear_interrupt_files(); - -// Wait for a child started by `spawn_background` and return its exit code. -// Blocking, so an owned run costs no polling latency; `background_running` stays -// for the supervisor that must not block. -int wait_background(unsigned long long process, int* exitCode); - -// ─── The program a user runs (`mcpp run`) ──────────────────────────────── -// -// Started in the console mcpp was started from and in mcpp's own process -// group, so the console's input, Ctrl-C and Ctrl-Break reach the program and -// the program decides what they mean. While it runs, mcpp ignores Ctrl-C and -// Ctrl-Break through a handler of its own, which a child does not inherit, so -// that mcpp survives to return the program's status. The kill-on-close job -// still ends the program's tree when mcpp itself ends for any other reason. -// Returns the program's exit code, or -1 when it could not be started, with -// GetLastError() in `*refused`. -int run_foreground(const char* commandLine, unsigned long* refused); - -} // namespace mcpp::platform::winproc diff --git a/modules/platform/src/windows/capacity.cpp b/modules/platform/src/windows/capacity.cpp deleted file mode 100644 index 8825df18..00000000 --- a/modules/platform/src/windows/capacity.cpp +++ /dev/null @@ -1,62 +0,0 @@ -// mcpp.platform.capacity on windows: what the machine has, from the system's own services. -module; - -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include -#include // malloc / free — clang does not get these from windows.h - -module mcpp.platform.capacity; - -import std; - -namespace mcpp::platform::capacity { - - -// The processor relationship table needs the two-call pattern: its length is not -// knowable up front, so ask for the size, allocate, then ask again. -static bool win_core_facts(int& physical, bool& hybrid) { - DWORD bytes = 0; - ::GetLogicalProcessorInformationEx(RelationProcessorCore, nullptr, &bytes); - if (bytes == 0) return false; - auto* buf = static_cast(::malloc(bytes)); - if (!buf) return false; - bool ok = false; - if (::GetLogicalProcessorInformationEx( - RelationProcessorCore, - reinterpret_cast(buf), &bytes)) { - int count = 0, firstClass = -1; - DWORD off = 0; - while (off < bytes) { - auto* info = reinterpret_cast(buf + off); - if (info->Size == 0) break; - ++count; - const int cls = static_cast(info->Processor.EfficiencyClass); - if (firstClass < 0) firstClass = cls; - else if (cls != firstClass) hybrid = true; - off += info->Size; - } - if (count > 0) { physical = count; ok = true; } - } - ::free(buf); - return ok; -} - -HostCapacity host_capacity() { - HostCapacity cap; - SYSTEM_INFO si{}; - ::GetSystemInfo(&si); - if (si.dwNumberOfProcessors > 0) cap.logicalCores = static_cast(si.dwNumberOfProcessors); - cap.physicalCores = cap.logicalCores; - win_core_facts(cap.physicalCores, cap.heterogeneous); - - MEMORYSTATUSEX ms{}; - ms.dwLength = sizeof(ms); - if (::GlobalMemoryStatusEx(&ms)) { - cap.totalBytes = ms.ullTotalPhys; - cap.availableBytes = ms.ullAvailPhys; - } - return cap; -} - -} // namespace mcpp::platform::capacity diff --git a/modules/platform/src/windows/env.cpp b/modules/platform/src/windows/env.cpp deleted file mode 100644 index 78a405a3..00000000 --- a/modules/platform/src/windows/env.cpp +++ /dev/null @@ -1,14 +0,0 @@ -// mcpp.platform.env on Windows: the C runtime's variable table, written with -// `_putenv_s', for which an empty value is removal. -module; -#include - -module mcpp.platform.env; - -import std; - -char** mcpp::platform::env::environment() { return _environ; } - -void mcpp::platform::env::detail::put(const std::string& key, const char* value) { - ::_putenv_s(key.c_str(), value ? value : ""); -} diff --git a/modules/platform/src/windows/fs.cpp b/modules/platform/src/windows/fs.cpp deleted file mode 100644 index 68a46853..00000000 --- a/modules/platform/src/windows/fs.cpp +++ /dev/null @@ -1,99 +0,0 @@ -// mcpp.platform.fs on Windows: MoveFileExW's replacement, FILE_APPEND_DATA, -// and LockFileEx. -module; -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include - -module mcpp.platform.fs; - -import std; - -namespace mcpp::platform::fs { - -bool replace_file(const std::filesystem::path& source, - const std::filesystem::path& destination, - std::error_code& ec) { - // 直接替换已有文件,不能先删除 last-known-good CDB。编辑器或杀毒软件 - // 可能短暂占用目标文件,sharing violation 仅做有限退避后再报告失败。 - auto delay = std::chrono::milliseconds{50}; - for (int attempt = 0; attempt < 4; ++attempt) { - if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), - MOVEFILE_REPLACE_EXISTING)) { - ec.clear(); - return true; - } - const auto error = GetLastError(); - if (error != ERROR_SHARING_VIOLATION || attempt == 3) { - ec = std::error_code(static_cast(error), std::system_category()); - return false; - } - std::this_thread::sleep_for(delay); - delay *= 3; - } - return false; -} - -bool append_atomically(const std::filesystem::path& file, std::string_view bytes) { - HANDLE h = CreateFileW(file.wstring().c_str(), FILE_APPEND_DATA, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); - if (h == INVALID_HANDLE_VALUE) return false; - DWORD written = 0; - const bool ok = WriteFile(h, bytes.data(), static_cast(bytes.size()), - &written, nullptr) && written == bytes.size(); - CloseHandle(h); - return ok; -} - -// ── FileLock ── - -namespace { - -HANDLE handle_of(std::intptr_t native) { return reinterpret_cast(native); } - -void release(std::intptr_t native) { - if (handle_of(native) == INVALID_HANDLE_VALUE) return; - OVERLAPPED ov = {}; - UnlockFileEx(handle_of(native), 0, 1, 0, &ov); - CloseHandle(handle_of(native)); -} - -} // namespace - -std::optional FileLock::try_acquire(const std::filesystem::path& dir, - std::string_view name) { - std::error_code ec; - std::filesystem::create_directories(dir, ec); - auto lockPath = dir / std::string(name); - HANDLE h = CreateFileW(lockPath.wstring().c_str(), - GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, - NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); - if (h == INVALID_HANDLE_VALUE) return std::nullopt; - OVERLAPPED ov = {}; - if (!LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, - 0, 1, 0, &ov)) { - CloseHandle(h); - return std::nullopt; - } - return FileLock{reinterpret_cast(h)}; -} - -FileLock::~FileLock() { - release(native_); -} - -FileLock::FileLock(FileLock&& o) noexcept : native_(o.native_) { - o.native_ = -1; -} - -FileLock& FileLock::operator=(FileLock&& o) noexcept { - if (this != &o) { - release(native_); - native_ = o.native_; - o.native_ = -1; - } - return *this; -} - -} // namespace mcpp::platform::fs diff --git a/modules/platform/src/windows/fs_caps.cpp b/modules/platform/src/windows/fs_caps.cpp deleted file mode 100644 index 6450ba98..00000000 --- a/modules/platform/src/windows/fs_caps.cpp +++ /dev/null @@ -1,47 +0,0 @@ -// mcpp.platform.fs_caps on Windows: a directory's owner from its security -// descriptor, and the volume's filesystem name. -module; -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include -#include -#include -#pragma comment(lib, "advapi32") - -module mcpp.platform.fs_caps; - -import std; - -namespace mcpp::platform::native { - -// Windows has no atomic exchange of two names. -int exchange(const std::filesystem::path&, const std::filesystem::path&) { return ERROR_NOT_SUPPORTED; } -bool unsupported(int error) { return error == ERROR_NOT_SUPPORTED || error == ERROR_INVALID_FUNCTION; } -std::error_category const& category() { return std::system_category(); } -std::string owner_problem(const std::filesystem::path& dir) { - PSID owner = nullptr; - PSECURITY_DESCRIPTOR sd = nullptr; - if (::GetNamedSecurityInfoW(dir.wstring().c_str(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, - &owner, nullptr, nullptr, nullptr, &sd) != ERROR_SUCCESS) - return "its owner cannot be read"; - bool trusted = ::IsWellKnownSid(owner, WinLocalSystemSid) || ::IsWellKnownSid(owner, WinBuiltinAdministratorsSid); - HANDLE token = nullptr; - if (!trusted && ::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &token)) { - DWORD size = 0; - ::GetTokenInformation(token, TokenUser, nullptr, 0, &size); - std::vector buffer(size); - if (::GetTokenInformation(token, TokenUser, buffer.data(), size, &size)) - trusted = ::EqualSid(owner, reinterpret_cast(buffer.data())->User.Sid); - ::CloseHandle(token); - } - ::LocalFree(sd); - return trusted ? std::string{} : "it belongs to another user"; -} -std::string filesystem_type(const std::filesystem::path& dir) { - wchar_t root[MAX_PATH] {}, name[MAX_PATH] {}; - if (!::GetVolumePathNameW(dir.wstring().c_str(), root, MAX_PATH)) return {}; - if (!::GetVolumeInformationW(root, nullptr, 0, nullptr, nullptr, nullptr, name, MAX_PATH)) return {}; - return std::filesystem::path(name).string(); -} - -} // namespace mcpp::platform::native diff --git a/modules/platform/src/windows/process.cpp b/modules/platform/src/windows/process.cpp deleted file mode 100644 index 6680964c..00000000 --- a/modules/platform/src/windows/process.cpp +++ /dev/null @@ -1,132 +0,0 @@ -// mcpp.platform.process on Windows: the launchers whose programs are started -// through cmd.exe command lines (windows_shell_command_line) by -// mcpp.platform.windows.bounded_process, and the C runtime's pipe primitives. -// -// Windows KEEPS the proven shell path. The env-binding hazard does not exist -// here (env goes through _putenv_s, not a prefix), so the launch primitive is -// not swapped on a platform that cannot be iterated on locally. -module; -#include -#include // _popen, _pclose - -module mcpp.platform.process; - -import std; -import mcpp.platform.common; -import mcpp.platform.env; -import mcpp.platform.shell; -import mcpp.platform.windows.bounded_process; - -namespace mcpp::platform::process { - -void set_umask_022() { - // Nothing: the ACL inherited from the directory decides. -} - -int normalize_exit_code(int rc) { return rc; } - -std::FILE* open_pipe(const std::string& command) { return ::_popen(command.c_str(), "r"); } -int close_pipe(std::FILE* pipe) { return ::_pclose(pipe); } - -namespace { - -// Build a shell command line from an argv vector. EVERY token is quoted, -// including the program — a payload under `C:\Program Files\...` or a home -// directory with a space in the user name is otherwise cut at the first space -// and reported as `'C:\Program' is not recognized`. One derivation: the -// tested, host-independent shaper (windows_command_from_argv). -std::string command_from_argv(const std::vector& argv) { - return windows_command_from_argv(argv); -} - -} // namespace - -int run_exec(const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - if (spawn_error) *spawn_error = 0; - if (argv.empty()) return 127; - // THE SAME OWNERSHIP AS THE POSIX BRANCH, EXPRESSED IN THIS PLATFORM'S TERMS. - // - // `std::system` gave the child away: it runs through a cmd.exe mcpp does not - // hold a handle to, so terminating mcpp left the tree running exactly as the - // POSIX branch did before its process group. A job object with - // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is the equivalent unit — it takes the - // whole tree, which matters here for the same reason the group does there: - // the child starts compilers of its own. - // - // The command line is built by the same `windows_shell_command_line` the - // rest of this file uses, so the cmd.exe quoting rule has ONE derivation. - // Re-deriving it here is how `/d /s /c` loses an argument. - std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); - // wrap only — run_exec inherits stdio on purpose (see finalize_shell_command). - std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv)); - auto child = mcpp::platform::winproc::spawn_background(cmd.c_str(), nullptr, 1); - if (!child.ok) { - const int refused = static_cast(child.refused); - if (spawn_error) *spawn_error = refused; - else std::fputs(spawn_failure(argv.front(), refused).c_str(), stderr); - return 127; - } - mcpp::platform::winproc::guard_job_on_signal(child.job); - int code = 127; - mcpp::platform::winproc::wait_background(child.process, &code); - mcpp::platform::winproc::unguard_job(child.job); - // Closes both handles; the child has already exited, so this is cleanup - // rather than a kill. - mcpp::platform::winproc::background_stop(child.job, child.process, 0); - return code; -} - -int run_foreground(const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - if (spawn_error) *spawn_error = 0; - if (argv.empty()) return 127; - std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); - std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv)); - unsigned long refused = 0; - const int code = mcpp::platform::winproc::run_foreground(cmd.c_str(), &refused); - if (code == -1 && refused != 0) { - if (spawn_error) *spawn_error = static_cast(refused); - else std::fputs(spawn_failure(argv.front(), static_cast(refused)).c_str(), stderr); - return 127; - } - return code; -} - -RunResult capture_exec( - const std::vector& argv, - const std::vector>& extraEnv, - std::string_view cwd, - int* spawn_error) -{ - RunResult result; - if (spawn_error) *spawn_error = 0; - if (argv.empty()) { result.exit_code = 127; return result; } - std::string cmd = command_from_argv(argv) + " 2>&1"; - if (!cwd.empty()) { - // cmd.exe `cd` without /d does not switch drives — a project on a - // different drive than mcpp's own cwd would run the child (the - // build.mcpp contract's only cwd consumer) in the wrong directory. - cmd = "cd /d " + mcpp::platform::shell::quote(cwd) + " && " + cmd; - } - return capture_with_env(cmd, extraEnv); -} - -RunResult capture_stdout( - const std::vector& argv, - const std::vector>& extraEnv, - int* spawn_error) -{ - RunResult result; - if (spawn_error) *spawn_error = 0; - if (argv.empty()) { result.exit_code = 127; return result; } - // cmd.exe reports a program it cannot find on stderr, which the redirect - // discards, and returns 9009; the output stays empty either way. - return capture_with_env(windows_stdout_probe_command(argv), extraEnv); -} - -} // namespace mcpp::platform::process diff --git a/modules/platform/src/windows/scaffold_fs.cpp b/modules/platform/src/windows/scaffold_fs.cpp deleted file mode 100644 index d6d0ffc5..00000000 --- a/modules/platform/src/windows/scaffold_fs.cpp +++ /dev/null @@ -1,50 +0,0 @@ -// mcpp.platform.scaffold_fs on Windows: FlushFileBuffers of a file; a -// directory's entry is already durable by MOVEFILE_WRITE_THROUGH. -module; -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include - -module mcpp.platform.scaffold_fs; - -import std; - -namespace mcpp::platform { - -namespace { - -std::string win32_message(DWORD code) { - return std::system_category().message(static_cast(code)); -} - -} // namespace - -std::expected sync_regular_file(const std::filesystem::path& path) { - HANDLE handle = ::CreateFileW( - path.c_str(), GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (handle == INVALID_HANDLE_VALUE) { - const auto error = ::GetLastError(); - return std::unexpected(std::format( - "cannot open '{}' for durable sync: {}", path.string(), win32_message(error))); - } - if (::FlushFileBuffers(handle) == 0) { - const auto error = ::GetLastError(); - ::CloseHandle(handle); - return std::unexpected(std::format( - "durable sync '{}' failed: {}", path.string(), win32_message(error))); - } - if (::CloseHandle(handle) == 0) { - const auto error = ::GetLastError(); - return std::unexpected(std::format( - "close synced file '{}' failed: {}", path.string(), win32_message(error))); - } - return {}; -} - -std::expected sync_directory(const std::filesystem::path&) { - return {}; -} - -} // namespace mcpp::platform diff --git a/modules/platform/src/windows/terminal.cpp b/modules/platform/src/windows/terminal.cpp deleted file mode 100644 index c7536c70..00000000 --- a/modules/platform/src/windows/terminal.cpp +++ /dev/null @@ -1,126 +0,0 @@ -// mcpp.platform.terminal on Windows: consoles, the console's output code -// page, keys read as console input events, and standard output diverted at -// the C runtime's descriptor. -module; -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include -#include // _dup, _dup2, _close, _get_osfhandle, _fileno, _write -#include // SetHandleInformation, GetConsoleMode, ReadConsoleInputW - -module mcpp.platform.terminal; - -import std; - -namespace mcpp::platform::terminal::native { - -namespace { - -HANDLE handle_of(Stream s) { - const auto h = reinterpret_cast(::_get_osfhandle(::_fileno(s == Stream::Out ? stdout : stderr))); - return h == nullptr ? INVALID_HANDLE_VALUE : h; -} - -bool console_of(Stream s) { - const auto h = handle_of(s); - DWORD mode = 0; - return h != INVALID_HANDLE_VALUE && ::GetConsoleMode(h, &mode); -} - -} // namespace - -bool same_terminal() { - // A process has at most one console. - return console_of(Stream::Out) && console_of(Stream::Err); -} - -unsigned console_output_code_page(Stream s) { - return console_of(s) ? static_cast(::GetConsoleOutputCP()) : 0; -} - -bool write_descriptor(int fd, std::string_view text) { - std::size_t done = 0; - while (done < text.size()) { - const int n = ::_write(fd, text.data() + done, static_cast(text.size() - done)); - if (n <= 0) return false; - done += static_cast(n); - } - return true; -} - -int divert_stdout() { - const int saved = ::_dup(1); - if (saved >= 0) { - // `_dup` duplicates the handle as inheritable, and CreateProcess with - // handle inheritance (every `_popen` and `system`) passes it on. - const auto h = reinterpret_cast(::_get_osfhandle(saved)); - if (h != INVALID_HANDLE_VALUE) - ::SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0); - ::_dup2(2, 1); - } - return saved; -} - -void restore_stdout(int saved) { - ::_dup2(saved, 1); - ::_close(saved); -} - -} // namespace mcpp::platform::terminal::native - -namespace mcpp::platform::terminal { - -namespace { - -HANDLE g_inputHandle = INVALID_HANDLE_VALUE; -DWORD g_inputMode = 0; -BOOL WINAPI restore_input_mode(DWORD) { - if (g_inputHandle != INVALID_HANDLE_VALUE) ::SetConsoleMode(g_inputHandle, g_inputMode); - return FALSE; // the next handler, then the default action, still run -} - -} // namespace - -KeyInput::KeyInput(Stream display) { - if (!is_terminal(display)) return; - const HANDLE h = ::GetStdHandle(STD_INPUT_HANDLE); - DWORD mode = 0; - if (h == INVALID_HANDLE_VALUE || !::GetConsoleMode(h, &mode)) return; - g_inputHandle = h; - g_inputMode = mode; - savedMode_ = mode; - ::SetConsoleCtrlHandler(restore_input_mode, TRUE); - // ENABLE_PROCESSED_INPUT stays: Ctrl-C still interrupts. - if (!::SetConsoleMode(h, mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT))) return; - active_ = true; -} - -KeyInput::~KeyInput() { - if (!active_) return; - ::SetConsoleMode(g_inputHandle, static_cast(savedMode_)); - ::SetConsoleCtrlHandler(restore_input_mode, FALSE); - g_inputHandle = INVALID_HANDLE_VALUE; -} - -std::vector KeyInput::read() { - std::vector keys; - if (!active_) return keys; - DWORD n = 0; - while (::GetNumberOfConsoleInputEvents(g_inputHandle, &n) && n > 0) { - INPUT_RECORD rec{}; - DWORD got = 0; - if (!::ReadConsoleInputW(g_inputHandle, &rec, 1, &got) || got == 0) break; - if (rec.EventType != KEY_EVENT || !rec.Event.KeyEvent.bKeyDown) continue; - switch (rec.Event.KeyEvent.wVirtualKeyCode) { - case VK_UP: case 'W': keys.push_back(Key::Up); break; - case VK_DOWN: case 'S': keys.push_back(Key::Down); break; - case VK_LEFT: case 'A': keys.push_back(Key::Left); break; - case VK_RIGHT: case 'D': keys.push_back(Key::Right); break; - case VK_SPACE: keys.push_back(Key::Space); break; - default: break; - } - } - return keys; -} - -} // namespace mcpp::platform::terminal diff --git a/modules/platform/src/windows/windows.cpp b/modules/platform/src/windows/windows.cpp deleted file mode 100644 index 4df7ff21..00000000 --- a/modules/platform/src/windows/windows.cpp +++ /dev/null @@ -1,83 +0,0 @@ -// mcpp.platform.windows on Windows. -module; -#include -#include // _putenv_s -#define NOMINMAX -#define WIN32_LEAN_AND_MEAN -#include // GetACP, RegGetValueW -// RegGetValueW lives in advapi32, which a link line names only by default on -// MinGW; this asks MSVC-ABI links for it too. -#pragma comment(lib, "advapi32") - -module mcpp.platform.windows; - -import std; - -namespace mcpp::platform::windows { -bool writable_fixed_drive(char drive, std::wstring_view child) { - const wchar_t root[] = {static_cast(drive), L':', L'\\', L'\0'}; - if (GetDriveTypeW(root) != DRIVE_FIXED) return false; - const auto candidate = std::filesystem::path(root) / child; - const auto attributes = GetFileAttributesW(candidate.c_str()); - if (attributes == INVALID_FILE_ATTRIBUTES) { - const auto error = GetLastError(); - if (error != ERROR_FILE_NOT_FOUND && error != ERROR_PATH_NOT_FOUND) return false; - } else if (!(attributes & FILE_ATTRIBUTE_DIRECTORY) - || (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) return false; - const auto probe = std::filesystem::path(root) - / std::format(".mcpp-write-{}-{}", GetCurrentProcessId(), GetTickCount64()); - const auto handle = CreateFileW(probe.c_str(), GENERIC_WRITE, 0, nullptr, - CREATE_NEW, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr); - if (handle == INVALID_HANDLE_VALUE) return false; - CloseHandle(handle); - return true; -} -} - -// ─── Implementation ────────────────────────────────────────────────────── - -namespace mcpp::platform::windows { - -void prepend_path(const std::filesystem::path& dir) { - std::string newPath = dir.string() + ";" + - (std::getenv("PATH") ? std::getenv("PATH") : ""); - _putenv_s("PATH", newPath.c_str()); -} - -std::optional machine_registry_path(std::wstring_view subkey, - std::wstring_view value) { - const std::wstring key(subkey), name(value); - for (DWORD view : {static_cast(KEY_WOW64_64KEY), static_cast(KEY_WOW64_32KEY)}) { - DWORD bytes = 0; - if (RegGetValueW(HKEY_LOCAL_MACHINE, key.c_str(), name.c_str(), - RRF_RT_REG_SZ | view, nullptr, nullptr, &bytes) != ERROR_SUCCESS - || bytes < sizeof(wchar_t)) - continue; - std::wstring buf(bytes / sizeof(wchar_t), L'\0'); - if (RegGetValueW(HKEY_LOCAL_MACHINE, key.c_str(), name.c_str(), - RRF_RT_REG_SZ | view, nullptr, buf.data(), &bytes) != ERROR_SUCCESS) - continue; - while (!buf.empty() && (buf.back() == L'\0' || buf.back() == L'\\')) - buf.pop_back(); - if (!buf.empty()) return std::filesystem::path(buf); - } - return std::nullopt; -} - -bool long_paths_enabled() { - DWORD value = 0, bytes = sizeof(value); - return RegGetValueW(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Control\\FileSystem", - L"LongPathsEnabled", RRF_RT_REG_DWORD, nullptr, &value, &bytes) == ERROR_SUCCESS - && value == 1; -} - -std::optional env_directory(const char* name) { - if (const char* v = std::getenv(name); v && *v) return std::filesystem::path(v); - return std::nullopt; -} - -unsigned active_code_page() { - return static_cast(GetACP()); -} - -} // namespace mcpp::platform::windows diff --git a/modules/platform/tests/test_fs_caps.cpp b/modules/platform/tests/test_fs_caps.cpp deleted file mode 100644 index 725a2cf6..00000000 --- a/modules/platform/tests/test_fs_caps.cpp +++ /dev/null @@ -1,127 +0,0 @@ -#include -#if !defined(_WIN32) -#include -#include -#endif - -import std; -import mcpp.platform.fs_caps; - -// What a filesystem does, and the strategy each answer selects (#809). The -// strategies are driven by stated capabilities, so every one of them runs on -// whatever filesystem the test machine has -- the NFSv3 run is the CI lane's. - -namespace fs = std::filesystem; -namespace pf = mcpp::platform; - -namespace { -struct Scratch { - fs::path dir; - Scratch() { - std::random_device random; - dir = fs::temp_directory_path() / std::format("mcpp-fscaps-{:08x}", random()); - fs::create_directories(dir); - } - ~Scratch() { - std::error_code ec; - fs::remove_all(dir, ec); - } -}; -void write(const fs::path& p, std::string_view text) { std::ofstream(p) << text; } -std::string read(const fs::path& p) { - std::ifstream in(p); - return {std::istreambuf_iterator(in), {}}; -} -} // namespace - -TEST(PlatformFsCaps, TheProbeMeasuresAWritableDirectoryAndLeavesNothing) { - Scratch s; - const auto caps = pf::probe_fs_caps(s.dir); - EXPECT_TRUE(caps.measured); - EXPECT_TRUE(fs::is_empty(s.dir)) << "the probe removes its scratch"; - // A directory that cannot be written: the platform's native answers. - const auto none = pf::probe_fs_caps(s.dir / "absent"); - EXPECT_FALSE(none.measured); - EXPECT_TRUE(none.rename_no_replace); -} - -TEST(PlatformFsCaps, EveryStrategyPublishesAndNoneReplacesAName) { - Scratch s; - for (int strategy = 0; strategy < 3; ++strategy) { - pf::FsCaps caps; - caps.rename_no_replace = strategy == 0; - caps.hard_links = strategy <= 1; - const auto dir = s.dir / std::to_string(strategy); - fs::create_directories(dir); - // A file. - write(dir / "scratch", "new"); - ASSERT_TRUE(pf::publish_no_replace(dir / "scratch", dir / "file", caps)) << strategy; - EXPECT_EQ(read(dir / "file"), "new"); - EXPECT_FALSE(fs::exists(dir / "scratch")); - write(dir / "scratch", "newer"); - EXPECT_FALSE(pf::publish_no_replace(dir / "scratch", dir / "file", caps)) << strategy; - EXPECT_EQ(read(dir / "file"), "new") << "an existing name is never replaced"; - // A directory. - fs::create_directory(dir / "staged"); - write(dir / "staged" / "a", "a"); - ASSERT_TRUE(pf::publish_no_replace(dir / "staged", dir / "tree", caps)) << strategy; - EXPECT_EQ(read(dir / "tree" / "a"), "a"); - fs::create_directory(dir / "staged"); - EXPECT_FALSE(pf::publish_no_replace(dir / "staged", dir / "tree", caps)); - // A symbolic link (where the filesystem has them). - std::error_code ec; - fs::create_symlink("file", dir / "link-scratch", ec); - if (!ec) { - ASSERT_TRUE(pf::publish_no_replace(dir / "link-scratch", dir / "link", caps)) << strategy; - EXPECT_EQ(fs::read_symlink(dir / "link"), fs::path("file")); - } - } -} - -TEST(PlatformFsCaps, AnExchangeTradesTwoTreesWhateverTheFilesystemDoes) { - Scratch s; - fs::create_directories(s.dir / "next"); - fs::create_directories(s.dir / "live"); - write(s.dir / "next" / "rev", "2"); - write(s.dir / "live" / "rev", "1"); - ASSERT_TRUE(pf::exchange_paths(s.dir / "next", s.dir / "live")); - EXPECT_EQ(read(s.dir / "live" / "rev"), "2"); - EXPECT_EQ(read(s.dir / "next" / "rev"), "1"); - std::size_t entries = 0; - for ([[maybe_unused]] const auto& e : fs::directory_iterator(s.dir)) ++entries; - EXPECT_EQ(entries, 2u) << "nothing set aside is left"; -} - -// Whom an upstream home may belong to (design 2026-10-16 §8.2): this user, or -// the system when this user cannot change it. -TEST(PlatformTrust, ThisUsersDirectoryIsTrusted) { - Scratch s; - EXPECT_EQ(pf::untrusted_because(s.dir), ""); - EXPECT_NE(pf::untrusted_because(s.dir / "absent"), ""); -} - -#if !defined(_WIN32) -TEST(PlatformTrust, ASystemDirectoryThisUserCanWriteIsNot) { - if (::getuid() == 0) GTEST_SKIP() << "root may write anything"; - const auto rootOwned = [](const char* p) { - struct stat st {}; - return ::stat(p, &st) == 0 && st.st_uid == 0; - }; - // /tmp: root's, and writable by everyone. - if (rootOwned("/tmp") && ::access("/tmp", W_OK) == 0) EXPECT_NE(pf::untrusted_because("/tmp"), ""); - // /usr: root's, and not this user's to write. - if (rootOwned("/usr") && ::access("/usr", W_OK) != 0) EXPECT_EQ(pf::untrusted_because("/usr"), ""); -} -#endif - -TEST(PlatformTrust, ADirectoryLinkReachesItsTarget) { - Scratch s; - fs::create_directories(s.dir / "target"); - write(s.dir / "target" / "f", "x"); - ASSERT_TRUE(pf::create_directory_link(s.dir / "target", s.dir / "link")); - EXPECT_EQ(read(s.dir / "link" / "f"), "x"); - // Removing the link removes the link only. - std::error_code ec; - fs::remove(s.dir / "link", ec); - EXPECT_TRUE(fs::exists(s.dir / "target" / "f")); -} diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index b6cf0421..f4ad4dda 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -4,7 +4,7 @@ # # * WHAT a toolchain is -- the target triple grammar, the Toolchain value, # the command dialect, the standard-flag spelling, the fingerprint, the -# link model. Pure vocabulary over `platform`. +# link model. Pure vocabulary over mcpplibs.os. # * WHICH toolchain is on this machine -- detection, the registry, the gcc / # clang / msvc / llvm families, installation. Those reach into xlings and # the package index, and they stay in `src/`. @@ -20,11 +20,14 @@ description = "Toolchain vocabulary: triples, dialects, fingerprints, link model license = "Apache-2.0" [dependencies.mcpp] -platform = { path = "../platform" } +invocation = { path = "../invocation" } versioning = { path = "../versioning" } # Subsystem-level tests. They see only this package, which is what lets them # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. +[dependencies.mcpplibs] +os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } + [dev-dependencies.compat] gtest = "1.15.2" diff --git a/modules/toolchain-model/src/apple_sdk.cpp b/modules/toolchain-model/src/apple_sdk.cpp new file mode 100644 index 00000000..ce93dff7 --- /dev/null +++ b/modules/toolchain-model/src/apple_sdk.cpp @@ -0,0 +1,197 @@ +// mcpp.toolchain.apple_sdk — the implementation: the queries of Apple's own +// tools. +module mcpp.toolchain.apple_sdk; + +import std; +import mcpp.invocation; +import mcpplibs.os.platform; +import mcpplibs.os.process; + +// ─── Implementation ────────────────────────────────────────────────────── + +namespace mcpp::toolchain::apple { + +namespace { + +// What an Apple tool prints, trimmed; empty when it cannot be run or fails. +// Its error output is not read: a probe parses what it captured. +std::string run_capture_trimmed(std::vector argv) { + mcpplibs::os::process::spawn_options o; + o.program = argv.front(); + o.args.assign(argv.begin() + 1, argv.end()); + o.in = mcpplibs::os::process::stdio::null; + auto r = mcpplibs::os::process::run(o); + if (!r || !r->status.success()) return {}; + std::string out = std::move(r->out); + while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' ')) + out.pop_back(); + return out; +} +} // namespace + +bool has_xcode_clt() { + if constexpr (mcpplibs::os::is_macos) { + return !run_capture_trimmed({"xcode-select", "-p"}).empty(); + } else { + return false; + } +} + +std::optional sdk_layout(std::string_view sdk) { + // ONE TABLE, THREE READERS. Every step of `sdk_path` below that names a + // directory reads it from here, so a fourth SDK is a row and not four + // edits. `inCommandLineTools` is false for the iOS SDKs because a + // CLT-only install genuinely does not ship them -- which is a reason the + // iOS rows can be refused, not a path to probe. + static constexpr SdkLayout kLayouts[] = { + { "MacOSX.platform", "MacOSX.sdk", true }, + { "iPhoneOS.platform", "iPhoneOS.sdk", false }, + { "iPhoneSimulator.platform", "iPhoneSimulator.sdk", false }, + }; + if (sdk == sdk_macos) return kLayouts[0]; + if (sdk == sdk_iphoneos) return kLayouts[1]; + if (sdk == sdk_iphonesim) return kLayouts[2]; + return std::nullopt; +} + +std::string sdk_name_of_root(const std::filesystem::path& p) { + // The installed name carries a version -- `MacOSX15.4.sdk` -- and the + // requested name does not. Lowercase, drop the suffix, drop the digits. + // + // THE SUFFIX IS REQUIRED, because this is positive identification and + // `.sdk` is the marker Apple's own naming gives it. A directory merely + // called `MacOSX` names no SDK; `sdkroot_answers` then routes it to the + // default question, which is what it was before this took a parameter. + std::string name = p.filename().string(); + for (auto& c : name) c = static_cast(std::tolower(c)); + if (!name.ends_with(".sdk")) return {}; + name.resize(name.size() - 4); + while (!name.empty() && (std::isdigit(static_cast(name.back())) + || name.back() == '.')) + name.pop_back(); + for (auto known : {sdk_macos, sdk_iphoneos, sdk_iphonesim}) + if (name == known) return std::string(known); + return {}; +} + +bool sdkroot_answers(const std::filesystem::path& candidate, + std::string_view sdk) { + if (!sdk_layout(sdk)) return false; + auto named = sdk_name_of_root(candidate); + if (!named.empty()) return named == sdk; + return sdk == sdk_macos; +} + +std::optional sdk_version(std::string_view sdk) { + if constexpr (mcpplibs::os::is_macos) { + auto v = run_capture_trimmed({"xcrun", "--sdk", std::string(sdk), "--show-sdk-version"}); + if (v.empty()) return std::nullopt; + return v; + } else { + (void)sdk; + return std::nullopt; + } +} + +std::optional sdk_path(std::string_view sdk) { + auto layout = sdk_layout(sdk); + if (!layout) return std::nullopt; + // 1. Explicit override wins (matches clang's own SDKROOT handling) -- but + // only for a request it answers. See `sdkroot_answers`. + // + // ON EVERY HOST. `SDKROOT` is Apple's own convention for naming an SDK, + // and an SDK a developer produced from their own Xcode on Linux is + // named the same way (design 2026-10-23 §4.4, D18). The steps below + // ask Apple's tools, which exist only on an Apple host. + if (auto env = mcpp::invocation::get("SDKROOT")) { + std::filesystem::path p(*env); + if (std::filesystem::exists(p) && sdkroot_answers(p, sdk)) return p; + } + if constexpr (mcpplibs::os::is_macos) { + // 2. xcrun — the canonical query. The named form is the only one that can + // answer for a specific SDK; the generic form returns the ACTIVE + // default, so it is tried only for the default request, where it is + // today's first probe and stays first. + std::vector> cmds; + if (sdk == sdk_macos) cmds.push_back({"xcrun", "--show-sdk-path"}); + cmds.push_back({"xcrun", "--sdk", std::string(sdk), "--show-sdk-path"}); + for (auto const& cmd : cmds) { + auto result = run_capture_trimmed(cmd); + if (!result.empty() && std::filesystem::exists(result)) + return std::filesystem::path(result); + } + // 3. Derive from the active developer dir (`xcode-select -p`) — covers + // machines where xcrun is misconfigured but the SDK is present. + auto devdir = run_capture_trimmed({"xcode-select", "-p"}); + if (!devdir.empty()) { + std::filesystem::path base(devdir); + for (auto cand : { + base / "Platforms" / layout->platformDir / "Developer" / "SDKs" + / layout->sdkDir, + base / "SDKs" / layout->sdkDir }) { + if (std::filesystem::exists(cand)) return cand; + } + } + // 4. Well-known fixed locations (Command-Line-Tools-only / standard Xcode). + std::vector fixed; + if (layout->inCommandLineTools) + fixed.emplace_back(std::filesystem::path( + "/Library/Developer/CommandLineTools/SDKs") / layout->sdkDir); + fixed.emplace_back(std::filesystem::path( + "/Applications/Xcode.app/Contents/Developer/Platforms") + / layout->platformDir / "Developer" / "SDKs" / layout->sdkDir); + for (auto const& cand : fixed) + if (std::filesystem::exists(cand)) return cand; + } + return std::nullopt; +} + +std::string sdk_settings_version(const std::filesystem::path& sdkRoot) { + std::ifstream is(sdkRoot / "SDKSettings.json", std::ios::binary); + if (!is) return {}; + std::string text((std::istreambuf_iterator(is)), std::istreambuf_iterator()); + auto key = text.find("\"Version\""); + if (key == std::string::npos) return {}; + auto q1 = text.find('"', text.find(':', key)); + if (q1 == std::string::npos) return {}; + auto q2 = text.find('"', q1 + 1); + if (q2 == std::string::npos) return {}; + return text.substr(q1 + 1, q2 - q1 - 1); +} + +Slice sdk_has_target(const std::filesystem::path& sdkRoot, std::string_view target) { + std::ifstream is(sdkRoot / "usr" / "lib" / "libSystem.tbd", std::ios::binary); + if (!is) return Slice::unknown; + std::string line; + while (std::getline(is, line)) { + if (!line.starts_with("targets:")) continue; + // `targets: [ x86_64-ios-simulator, arm64-ios-simulator ]`, possibly + // wrapped onto following lines until the closing bracket. + std::string list = line; + while (list.find(']') == std::string::npos && std::getline(is, line)) list += line; + std::size_t pos = 0; + while ((pos = list.find(target, pos)) != std::string::npos) { + const auto end = pos + target.size(); + const bool startOk = pos == 0 || !(std::isalnum(static_cast(list[pos - 1])) + || list[pos - 1] == '-' || list[pos - 1] == '_'); + const bool endOk = end >= list.size() || !(std::isalnum(static_cast(list[end])) + || list[end] == '-' || list[end] == '_'); + if (startOk && endOk) return Slice::carried; + pos = end; + } + return Slice::absent; + } + return Slice::unknown; +} + + +std::string deployment_target(bool targetIsMacos, std::string_view manifestValue) { + if (!targetIsMacos) return {}; + if (auto dt = mcpp::invocation::get("MACOSX_DEPLOYMENT_TARGET")) + return *dt; + if (!manifestValue.empty()) + return std::string(manifestValue); + return std::string(default_deployment_target); +} + +} // namespace mcpp::toolchain::apple diff --git a/modules/toolchain-model/src/apple_sdk.cppm b/modules/toolchain-model/src/apple_sdk.cppm new file mode 100644 index 00000000..3a9fdd06 --- /dev/null +++ b/modules/toolchain-model/src/apple_sdk.cppm @@ -0,0 +1,131 @@ +// mcpp.toolchain.apple_sdk — Apple's SDKs, their layout and versions, and the +// deployment floor: what a toolchain for an Apple target is built against. +// +// Provides: +// has_xcode_clt() — detect Xcode Command Line Tools +// sdk_path(sdk) — discover an Apple SDK via xcrun ("macosx" default) +// sdk_layout(sdk) — the directory names that SDK uses inside Xcode +// sdk_name_of_root(p) — which SDK a path IS, for the SDKROOT override +// supports_full_static — macOS cannot fully static-link (libSystem) + +export module mcpp.toolchain.apple_sdk; + +import std; +import mcpplibs.os.platform; + +export namespace mcpp::toolchain::apple { + +// Whether macOS supports full static linking (it does not — libSystem +// must be dynamically linked). +constexpr bool supports_full_static = false; + +// Check whether Xcode Command Line Tools are installed. +// Returns true if `xcode-select -p` succeeds. +bool has_xcode_clt(); + +// THREE SDKS, ONE MACHINE. Apple ships the macOS, iPhoneOS and +// iPhoneSimulator SDKs from one developer directory, and mcpp locates +// rather than installs all three: they are not redistributable, which +// is why the iOS rows carry a located `sysroot` instead of a package. +// `sdk` is the name `xcrun --sdk` takes. +inline constexpr std::string_view sdk_macos = "macosx"; +inline constexpr std::string_view sdk_iphoneos = "iphoneos"; +inline constexpr std::string_view sdk_iphonesim = "iphonesimulator"; + +// Where an SDK lives inside a developer directory: the `.platform` +// directory that holds it, and its own `.sdk` directory name. Pure, and +// nullopt for a name no Apple SDK answers to. +struct SdkLayout { + std::string_view platformDir; // e.g. "iPhoneOS.platform" + std::string_view sdkDir; // e.g. "iPhoneOS.sdk" + bool inCommandLineTools; // the CLT-only install ships it +}; +std::optional sdk_layout(std::string_view sdk); + +// Which SDK a path IS, derived from its own directory name: +// `MacOSX15.4.sdk` -> "macosx", `iPhoneSimulator18.4.sdk` -> +// "iphonesimulator". Empty when the path does not name an SDK. +// +// THIS EXISTS BECAUSE `SDKROOT` NAMES ONE SDK AND THERE ARE THREE +// QUESTIONS. Honouring it for every request would answer an iOS query +// with a macOS SDK whenever a shell had it set -- a wrong sysroot, which +// fails later as missing headers rather than as a bad override. +std::string sdk_name_of_root(const std::filesystem::path& p); + +// Whether an `SDKROOT` override answers a request for `sdk`. +// +// A path that names an SDK answers for that SDK and no other. A path that +// names NO SDK -- a hand-rolled sysroot, which is a spelling clang accepts -- +// answers the DEFAULT question only: it is an answer to "the SDK", not to +// "the iPhoneOS SDK". That asymmetry is what keeps today's callers unchanged +// while making a specific request require positive identification. +bool sdkroot_answers(const std::filesystem::path& candidate, + std::string_view sdk); + +// The version an SDK directory says it is, from its own `SDKSettings.json` +// (`"Version": "18.5"`). Empty when the file is absent or says nothing. Pure +// file reading, so it answers on every host. +std::string sdk_settings_version(const std::filesystem::path& sdkRoot); + +// Whether an SDK carries a slice for `target` (`x86_64-ios-simulator`, +// `arm64-macos`), read from the `targets:` list of its `libSystem.tbd`. The +// slice is what decides whether a row can be built against this SDK; a +// vendor's hardware roadmap does not (D16). `unknown` when the stub cannot be +// read. Three named answers rather than an optional bool; measured with GCC +// 16.1, the optional form here left the compiled interface of a module that +// imports this one beside mcpp.modgraph.graph unreadable to its importers. +enum class Slice { unknown, carried, absent }; +Slice sdk_has_target(const std::filesystem::path& sdkRoot, std::string_view target); + +// Discover an Apple SDK path via `xcrun --show-sdk-path`. +// Returns the SDK path if found, or nullopt. The default argument is +// today's behaviour for today's callers. +std::optional sdk_path(std::string_view sdk = sdk_macos); + +// The version the located SDK was made for, as `xcrun --sdk +// --show-sdk-version` reports it ("18.5"). Empty off macOS and when xcrun +// cannot answer. Read for an iOS row whose manifest states no deployment +// floor, since clang's own default for an unversioned iOS triple is older +// than any SDK on the machine (it refused thread-local storage; measured). +std::optional sdk_version(std::string_view sdk); + +// Built-in default deployment floor (rustc-style: every target has a +// baseline). 14.0 = the floor of the official LLVM static libc++ +// archives; with the default-static stdlib this makes `mcpp run` +// binaries portable to any macOS ≥ 14 out of the box (no declaration +// needed — a fresh user's std::println hello on macOS 14 used to die +// at dyld against the system libc++). Lower floors need a custom +// libc++ build (tracked; data-only swap via xlings-res). +inline constexpr std::string_view default_deployment_target = "14.0"; + +// Resolve the effective macOS deployment target: the +// MACOSX_DEPLOYMENT_TARGET env var (explicit per-invocation override, +// the convention cargo/rustc/cc honor) wins over `manifestValue` (the +// [build] macos_deployment_target project default), which wins over +// the built-in default floor. +// +// WHETHER IT APPLIES IS A TARGET QUESTION, NOT A HOST QUESTION, so +// `targetIsMacos` is a parameter rather than a `#if defined(__APPLE__)` +// read inside this function. Every reader that folds this value into a +// compiler flag, a triple or the build fingerprint builds for a +// particular target (native or `--target`-crossed), and that target's +// OS -- not the machine running mcpp -- decides whether a macOS floor +// makes sense. The resolution itself (env > manifest > 14.0) never +// consults the host either, which is what makes `mcpp build --target +// aarch64-macos` on Linux honour `macos_deployment_target` at all: the +// old `#if defined(__APPLE__)` guard answered empty on every non-Apple +// host regardless of what the target was. +// +// Each caller answers the "is the target macOS" question itself -- +// typically `parsedTriple.os == "macos"` -- and passes the bool. +// +// Result is never empty when `targetIsMacos` is true; always empty +// when it is false. THE single source of truth — flags.cppm, the BMI +// fingerprint rule and the std-module prebuild must all consume this +// same resolution, or cached std.pcm modules drift from the TUs +// (config-mismatch / unstaged-module failures observed on macos CI). +std::string deployment_target(bool targetIsMacos, std::string_view manifestValue); + + + +} // namespace mcpp::toolchain::apple diff --git a/modules/toolchain-model/src/linkmodel.cppm b/modules/toolchain-model/src/linkmodel.cppm index c97ed9cf..9f2dc1a6 100644 --- a/modules/toolchain-model/src/linkmodel.cppm +++ b/modules/toolchain-model/src/linkmodel.cppm @@ -18,7 +18,8 @@ export module mcpp.toolchain.linkmodel; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.apple_sdk; import mcpp.toolchain.model; import mcpp.toolchain.triple; @@ -611,7 +612,7 @@ ToolchainLinkModel resolve_link_model(const Toolchain& tc) { lm.mode = CLibMode::Sysroot; lm.sysroot = tc.base.root; } - else if (auto sdk = mcpp::platform::is_macos ? mcpp::platform::macos::sdk_path() + else if (auto sdk = mcpplibs::os::is_macos ? mcpp::toolchain::apple::sdk_path() : std::nullopt) { // `macosx@system`: the SDK `xcrun` answers on an Apple host. Only // there -- `sdk_path` reads `SDKROOT` on every host now, and an diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index b92d2272..23870f19 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -5,6 +5,7 @@ export module mcpp.toolchain.model; import std; import mcpp.toolchain.triple; export import mcpp.toolchain.base; +import mcpp.toolchain.platform; export namespace mcpp::toolchain { @@ -384,15 +385,15 @@ std::vector graph_runtime_compile_flags(const Toolchain& tc); // // This is a property of the TARGET, not of the machine doing the build — // a Windows host cross-compiling to x86_64-linux-musl still produces a fully -// static ELF. `mcpp::platform::supports_full_static` answers a DIFFERENT +// static ELF. `mcpp::toolchain::platform::supports_full_static` answers a DIFFERENT // question ("can THIS machine's own binaries be static"), and using it here // silently dropped `-static` from every Windows→Linux cross build. // // `hostCapability` is threaded in explicitly rather than read from -// mcpp::platform so the decision is testable on any host: passing false +// mcpp::toolchain::platform so the decision is testable on any host: passing false // models a Windows/macOS host. It is only consulted for the host target // (empty triple), where target *is* host. Callers pass -// mcpp::platform::supports_full_static — keeping that dependency at the call +// mcpp::toolchain::platform::supports_full_static — keeping that dependency at the call // site leaves this module free of any platform import. bool target_supports_full_static(std::string_view targetTriple, bool hostCapability); diff --git a/modules/toolchain-model/src/platform.cpp b/modules/toolchain-model/src/platform.cpp new file mode 100644 index 00000000..36e48dd3 --- /dev/null +++ b/modules/toolchain-model/src/platform.cpp @@ -0,0 +1,24 @@ +// mcpp.toolchain.platform — the facts of the host that are read at run time. +module mcpp.toolchain.platform; + +import std; +import mcpp.invocation; +import mcpplibs.os.platform; + +namespace mcpp::toolchain::platform { + +bool is_android_host() { + if constexpr (mcpplibs::os::is_linux) { + static const bool android = [] { + if (mcpp::invocation::get("ANDROID_ROOT") && mcpp::invocation::get("ANDROID_DATA")) return true; + std::error_code ec; + return std::filesystem::exists("/system/bin/linker64", ec) + || std::filesystem::exists("/system/bin/linker", ec); + }(); + return android; + } else { + return false; + } +} + +} // namespace mcpp::toolchain::platform diff --git a/modules/platform/src/axis.cppm b/modules/toolchain-model/src/platform.cppm similarity index 66% rename from modules/platform/src/axis.cppm rename to modules/toolchain-model/src/platform.cppm index 0d6e38e0..bd47178e 100644 --- a/modules/platform/src/axis.cppm +++ b/modules/toolchain-model/src/platform.cppm @@ -1,4 +1,5 @@ -// mcpp.platform.axis — the host/target platform axes, as distinct types. +// mcpp.toolchain.platform — the host/target platform axes, as distinct types, +// and the facts of the host in the toolchain model's vocabulary. // // mcpp resolves "which platform?" along TWO axes that are equal only for a // native build: @@ -25,12 +26,38 @@ // interesting bugs here are all "the wrong axis was passed", and a string // parameter cannot express the difference. -export module mcpp.platform.axis; +export module mcpp.toolchain.platform; import std; -import mcpp.platform; +import mcpplibs.os.platform; -export namespace mcpp::platform { +export namespace mcpp::toolchain::platform { + +// THE MACHINE mcpp RUNS ON, in the toolchain model's vocabulary. Each is a fact +// of the host, read where a HOST answer is the right one (a host tool, a +// payload for this machine); an artifact for a target asks the target's triple. +inline constexpr std::string_view name = mcpplibs::os::name(mcpplibs::os::PLATFORM); +inline constexpr std::string_view host_arch = mcpplibs::os::name(mcpplibs::os::ARCH); +inline constexpr std::string_view xpkg_platform = mcpplibs::os::is_windows ? "windows" + : mcpplibs::os::is_macos ? "macosx" : "linux"; +inline constexpr std::string_view static_lib_ext = mcpplibs::os::is_windows ? ".lib" : ".a"; +inline constexpr std::string_view shared_lib_ext = mcpplibs::os::is_windows ? ".dll" + : mcpplibs::os::is_macos ? ".dylib" : ".so"; +inline constexpr std::string_view lib_prefix = mcpplibs::os::is_windows ? "" : "lib"; +inline constexpr bool supports_full_static = mcpplibs::os::is_linux; // macOS and Windows cannot +inline constexpr bool supports_rpath = !mcpplibs::os::is_windows; // ELF and Mach-O +inline constexpr bool needs_explicit_libcxx = mcpplibs::os::is_macos; // macOS: -lc++ + +// Is this Linux an Android (D21)? `ANDROID_ROOT` and `ANDROID_DATA` are set in +// every Android process environment (Termux included), and +// `/system/bin/linker64` is bionic's dynamic loader. Answers which shell exists +// and how a failure is explained; it does not choose a toolchain: Android takes +// the same default as GNU/Linux on its architecture. +bool is_android_host(); + +} // namespace mcpp::toolchain::platform + +export namespace mcpp::toolchain::platform { // THE spelling rule, and the only one. // @@ -71,7 +98,7 @@ private: class HostPlatform : public PlatformKey { public: static HostPlatform current() { - return HostPlatform(std::string(mcpp::platform::xpkg_platform)); + return HostPlatform(std::string(xpkg_platform)); } private: @@ -89,7 +116,7 @@ public: // whole code base did unconditionally before #254. return TargetPlatform(std::string( xpkg_platform_key_for(tripleOs) - .value_or(mcpp::platform::xpkg_platform))); + .value_or(xpkg_platform))); } // For tooling that walks platforms it is not running on and is not @@ -104,4 +131,4 @@ private: explicit TargetPlatform(std::string key) : PlatformKey(std::move(key)) {} }; -} // namespace mcpp::platform +} // namespace mcpp::toolchain::platform diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index 4ac1fe8e..8807e4a2 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -25,7 +25,8 @@ export module mcpp.toolchain.triple; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; export namespace mcpp::toolchain::triple { @@ -1063,8 +1064,8 @@ std::optional did_you_mean(std::string_view input); // user-facing host default, independent of how mcpp itself was linked). inline Triple host_triple() { Triple t; - t.arch = std::string(mcpp::platform::host_arch); - t.os = std::string(mcpp::platform::name); + t.arch = std::string(mcpp::toolchain::platform::host_arch); + t.os = std::string(mcpp::toolchain::platform::name); // Derived from the machine rather than written by anyone, so it states no // request: a host build must not be refused for "contradicting" a C library // its own triple never asked for. @@ -1136,12 +1137,12 @@ namespace pins { } inline std::string_view host_default_toolchain(bool msvcUsable) { - if constexpr (mcpp::platform::is_macos) { + if constexpr (mcpplibs::os::is_macos) { return kFirstRunMac; - } else if constexpr (mcpp::platform::is_windows) { + } else if constexpr (mcpplibs::os::is_windows) { return msvcUsable ? kFirstRunWinMsvc : kFirstRunWinGnu; } else { - return linux_default_toolchain(mcpp::platform::host_arch); + return linux_default_toolchain(mcpp::toolchain::platform::host_arch); } } } // namespace pins @@ -1149,7 +1150,7 @@ namespace pins { // ── Artifact naming conventions ────────────────────────────────────────────── // // How a built artifact is NAMED is a property of the TARGET, never of the -// machine doing the build. `mcpp::platform::{exe_suffix,lib_prefix,…}` answer a +// machine doing the build. `mcpp::toolchain::platform::{lib_prefix,…}` answer a // different question — "what does THIS machine call its own binaries" — and // using them to name build outputs is wrong the moment host != target. // @@ -1179,7 +1180,7 @@ struct ArtifactNaming { // Naming for an explicit target triple. An EMPTY triple means "build for this // machine", and only then is the host answer the correct one — so the caller -// passes it in rather than this module reaching for mcpp::platform, which keeps +// passes it in rather than this module reaching for the host's facts, which keeps // the decision testable from any host (and keeps this module dependency-free). inline ArtifactNaming artifact_naming(const Triple& t, const ArtifactNaming& hostNaming) { if (t.empty()) return hostNaming; diff --git a/modules/platform/tests/test_axis_is_self_contained.cpp b/modules/toolchain-model/tests/test_platform_axis.cpp similarity index 81% rename from modules/platform/tests/test_axis_is_self_contained.cpp rename to modules/toolchain-model/tests/test_platform_axis.cpp index c7497936..bb0aa272 100644 --- a/modules/platform/tests/test_axis_is_self_contained.cpp +++ b/modules/toolchain-model/tests/test_platform_axis.cpp @@ -1,7 +1,7 @@ #include import std; -import mcpp.platform.axis; +import mcpp.toolchain.platform; // SUBSYSTEM-LEVEL. This package imports nothing from mcpp, so these tests can // only see what the package itself promises — which is the point. The @@ -13,7 +13,7 @@ TEST(PlatformAxis, TheHostPlatformKeyIsNeverEmpty) { // Every descriptor lookup is keyed on this. An empty key would not fail — // it would match the section named "", which no descriptor has, and the // package would simply appear to be unavailable everywhere. - auto host = mcpp::platform::HostPlatform::current(); + auto host = mcpp::toolchain::platform::HostPlatform::current(); EXPECT_FALSE(host.key().empty()); } @@ -21,7 +21,7 @@ TEST(PlatformAxis, TwoKeysCompareByValue) { // `PlatformKey` is only obtainable through an axis type, so possession of // one already says which axis it came from. Equality therefore has to be // about the key and not the object. - auto a = mcpp::platform::HostPlatform::current(); - auto b = mcpp::platform::HostPlatform::current(); + auto a = mcpp::toolchain::platform::HostPlatform::current(); + auto b = mcpp::toolchain::platform::HostPlatform::current(); EXPECT_TRUE(a == b); } diff --git a/native/posix/lock.cpp b/native/posix/lock.cpp new file mode 100644 index 00000000..3f8c7fbb --- /dev/null +++ b/native/posix/lock.cpp @@ -0,0 +1,28 @@ +// The lock every release of mcpp takes on Linux and macOS: flock(2). +module; +#include +#include +#include + +module mcpp.home.files; + +import std; + +namespace mcpp::home::native { + +std::intptr_t lock_file(const std::filesystem::path& file) { + const int fd = ::open(file.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644); + if (fd < 0) return -1; + if (::flock(fd, LOCK_EX | LOCK_NB) != 0) { + ::close(fd); + return -1; + } + return fd; +} + +void unlock_file(std::intptr_t handle) { + ::flock(static_cast(handle), LOCK_UN); + ::close(static_cast(handle)); +} + +} // namespace mcpp::home::native diff --git a/native/windows/lock.cpp b/native/windows/lock.cpp new file mode 100644 index 00000000..88e81752 --- /dev/null +++ b/native/windows/lock.cpp @@ -0,0 +1,32 @@ +// The lock every release of mcpp takes on Windows: LockFileEx on the first byte. +module; +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +module mcpp.home.files; + +import std; + +namespace mcpp::home::native { + +std::intptr_t lock_file(const std::filesystem::path& file) { + HANDLE h = CreateFileW(file.wstring().c_str(), GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return -1; + OVERLAPPED ov = {}; + if (!LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &ov)) { + CloseHandle(h); + return -1; + } + return reinterpret_cast(h); +} + +void unlock_file(std::intptr_t handle) { + OVERLAPPED ov = {}; + UnlockFileEx(reinterpret_cast(handle), 0, 1, 0, &ov); + CloseHandle(reinterpret_cast(handle)); +} + +} // namespace mcpp::home::native diff --git a/src/bmi_cache.cppm b/src/bmi_cache.cppm index 2aace200..f5c40ea3 100644 --- a/src/bmi_cache.cppm +++ b/src/bmi_cache.cppm @@ -60,7 +60,7 @@ export module mcpp.bmi_cache; import std; import mcpp.libs.json; -import mcpp.platform; +import mcpp.home.files; export namespace mcpp::bmi_cache { @@ -396,7 +396,7 @@ populate_from(const CacheKey& key, auto cacheDir = key.dir(); std::error_code ec; std::filesystem::create_directories(cacheDir, ec); - auto lock = mcpp::platform::fs::FileLock::try_acquire(cacheDir); + auto lock = mcpp::home::FileLock::try_acquire(cacheDir); if (!lock) { // Another writer holds the lock; it will finish the entry. return {}; diff --git a/src/build/backend.cppm b/src/build/backend.cppm index 3e4d3041..41cf45e5 100644 --- a/src/build/backend.cppm +++ b/src/build/backend.cppm @@ -29,7 +29,7 @@ struct BuildOptions { // by the CI job timeout — which kills the process and takes its unflushed // output with it. This is the knob that turns that into an attributable // failure. POSIX only: the deadline runner has no kill-by-handle path on - // Windows (see mcpp.platform.process), where the value is ignored. + // Windows (see mcpp.run), where the value is ignored. unsigned buildTimeoutSecs = 0; // The report of this build directory (build progress design 2026-09-29). // Set, ninja runs without `--quiet` and is read as it runs: the step diff --git a/src/build/build_database.cppm b/src/build/build_database.cppm index fbdcdabb..baaf0353 100644 --- a/src/build/build_database.cppm +++ b/src/build/build_database.cppm @@ -33,7 +33,6 @@ import mcpp.libs.json; import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; -import mcpp.platform; import mcpp.toolchain.fingerprint; import mcpp.toolchain.linkmodel; import mcpp.toolchain.model; diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index f263620a..a9555376 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -17,9 +17,10 @@ import mcpp.diag; // structured diagnostics of build programs (#734 E11) import mcpp.home; // cache_root — the global home of the engine's compiled module (#748) import mcpp.log; // the compile of a build program is timestamped in the verbose log import mcpp.manifest; -import mcpp.platform; +import mcpp.effects; +import mcpp.runtime.launch_env; +import mcpp.run; import mcpp.pm.mangle; // imported_module_names -- what build.mcpp asks for -import mcpp.platform.process; import mcpp.toolchain.cppfly; // std_flag (dialect- and c++fly-aware -std= spelling) import mcpp.toolchain.dialect; // CommandDialect — gnu vs cl.exe spellings import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex) @@ -37,6 +38,7 @@ import mcpp.toolchain.stdmod; // ensure_built — the SAME std BMI the ma import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value) import mcpp.ui; import mcpp.version; // MCPP_VERSION — the hint names the engine the reader is on +import mcpp.invocation; namespace { // #734 E11: a build program's structured diagnostics, in the engine's own form. @@ -599,8 +601,7 @@ std::string abs_against_root(const fs::path& root, std::string_view p) { } std::string env_value(const std::string& name) { - const char* v = std::getenv(name.c_str()); - return v ? std::string(v) : std::string(); + return mcpp::invocation::get(name).value_or(std::string()); } fs::path build_dir(const fs::path& root, const BuildProgramEnv& env) { @@ -796,8 +797,8 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv // replaces a variable outright in the child, so writing only the // project's own directory would silently be the substitution this // deliberately is not. - if (auto const& inherited = mcpp::platform::env::inherited_path(); !inherited.empty()) { - path += mcpp::platform::env::path_list_separator(); + if (auto const& inherited = mcpp::runtime::launch_env::inherited_path(); !inherited.empty()) { + path += mcpp::runtime::launch_env::path_list_separator(); path += inherited; } e.emplace_back("PATH", path); @@ -1186,8 +1187,8 @@ std::expected run_build_program_impl( // running code from the workspace, so the refusal is here, where a program // would start -- a program whose answer is current runs nothing and is // not refused. - if (mcpp::platform::env::effect_denied("exec-build-script")) { - mcpp::platform::env::note_effect_refused("exec-build-script", std::format("{} build program", who)); + if (mcpp::effects::effect_denied("exec-build-script")) { + mcpp::effects::note_effect_refused("exec-build-script", std::format("{} build program", who)); return std::unexpected(std::format( "effect denied: exec-build-script -- {}'s build program would run, and the caller " "denied running code from the workspace (--deny exec-build-script)", who)); @@ -1200,7 +1201,7 @@ std::expected run_build_program_impl( // the one who knows how long it takes. const auto deadline = dirs::run_timeout_for(m.buildConfig.buildProgramTimeoutSecs); - auto rres = mcpp::platform::process::capture_exec_deadline( + auto rres = mcpp::run::capture_exec_deadline( {bin.string()}, childEnv, deadline, &timedOut, root.string()); if (timedOut) { // Name the manifest to edit. Without this the user edits their OWN diff --git a/src/build/capacity.cpp b/src/build/capacity.cpp new file mode 100644 index 00000000..48fa2b69 --- /dev/null +++ b/src/build/capacity.cpp @@ -0,0 +1,56 @@ +// mcpp.build.capacity: the job budget, and the host measured by mcpplibs.os. +module mcpp.build.capacity; + +import std; +import mcpplibs.os.machine; + + +// ─── Implementation ──────────────────────────────────────────────────────── + +namespace mcpp::build::capacity { + +int recommended_jobs(const HostCapacity& cap, unsigned long long perJobBytes, + unsigned long long reserveBytes, int ceiling) { + // A heterogeneous machine's logical count is not a count of equal workers, + // so fall back to physical cores there rather than pretending E-cores and + // SMT siblings are whole CPUs. + int cpuBudget = cap.heterogeneous ? cap.physicalCores : cap.logicalCores; + if (cpuBudget < 1) cpuBudget = 1; + + int memBudget = cpuBudget; + if (perJobBytes > 0 && cap.availableBytes > reserveBytes) { + const unsigned long long usable = cap.availableBytes - reserveBytes; + const unsigned long long fits = usable / perJobBytes; + memBudget = fits > 0 ? static_cast(fits > 1000000 ? 1000000 : fits) : 1; + } else if (perJobBytes > 0) { + // Less memory available than the reserve: still make progress, but one + // job at a time. Refusing to build would be worse than building slowly. + memBudget = 1; + } + + int jobs = cpuBudget < memBudget ? cpuBudget : memBudget; + if (jobs < 1) jobs = 1; + if (ceiling > 0 && jobs > ceiling) jobs = ceiling; + return jobs; +} + +} // namespace mcpp::build::capacity + +namespace mcpp::build::capacity { + +// The measurements are mcpplibs.os's (machine::processors, memory and +// memory_available); the budget above is this module's. +HostCapacity host_capacity() { + namespace machine = mcpplibs::os::machine; + HostCapacity cap; + const auto p = machine::processors(); + cap.logicalCores = static_cast(p.logical); + cap.physicalCores = static_cast(p.physical); + cap.heterogeneous = p.heterogeneous; + cap.totalBytes = machine::memory().value_or(0); + cap.availableBytes = machine::memory_available().value_or(cap.totalBytes); + if (cap.availableBytes == 0) cap.availableBytes = cap.totalBytes; + return cap; +} + +} // namespace mcpp::build::capacity diff --git a/modules/platform/src/capacity.cppm b/src/build/capacity.cppm similarity index 59% rename from modules/platform/src/capacity.cppm rename to src/build/capacity.cppm index 4d399f61..69f713df 100644 --- a/modules/platform/src/capacity.cppm +++ b/src/build/capacity.cppm @@ -1,4 +1,4 @@ -// mcpp.platform.capacity — how much machine is actually available. +// mcpp.build.capacity — how much machine is actually available. // // Exists because `nproc` is the wrong number to build with, in two separate ways // that both have measurements behind them: @@ -20,9 +20,9 @@ // The interface deliberately names no `std` type: under GCC 16.1 a newly added // module whose EXPORTS mention std types can poison the BMIs of everything // downstream of it, and the failures point at unrelated modules. Integers only. -export module mcpp.platform.capacity; +export module mcpp.build.capacity; -export namespace mcpp::platform::capacity { +export namespace mcpp::build::capacity { struct HostCapacity { int logicalCores = 1; @@ -51,35 +51,4 @@ int recommended_jobs(const HostCapacity& cap, unsigned long long reserveBytes = 2ull * 1024 * 1024 * 1024, int ceiling = 64); -} // namespace mcpp::platform::capacity - -// ─── Implementation ──────────────────────────────────────────────────────── - -namespace mcpp::platform::capacity { - -int recommended_jobs(const HostCapacity& cap, unsigned long long perJobBytes, - unsigned long long reserveBytes, int ceiling) { - // A heterogeneous machine's logical count is not a count of equal workers, - // so fall back to physical cores there rather than pretending E-cores and - // SMT siblings are whole CPUs. - int cpuBudget = cap.heterogeneous ? cap.physicalCores : cap.logicalCores; - if (cpuBudget < 1) cpuBudget = 1; - - int memBudget = cpuBudget; - if (perJobBytes > 0 && cap.availableBytes > reserveBytes) { - const unsigned long long usable = cap.availableBytes - reserveBytes; - const unsigned long long fits = usable / perJobBytes; - memBudget = fits > 0 ? static_cast(fits > 1000000 ? 1000000 : fits) : 1; - } else if (perJobBytes > 0) { - // Less memory available than the reserve: still make progress, but one - // job at a time. Refusing to build would be worse than building slowly. - memBudget = 1; - } - - int jobs = cpuBudget < memBudget ? cpuBudget : memBudget; - if (jobs < 1) jobs = 1; - if (ceiling > 0 && jobs > ceiling) jobs = ceiling; - return jobs; -} - -} // namespace mcpp::platform::capacity +} // namespace mcpp::build::capacity \ No newline at end of file diff --git a/src/build/command_line.cpp b/src/build/command_line.cpp new file mode 100644 index 00000000..2ee1aca2 --- /dev/null +++ b/src/build/command_line.cpp @@ -0,0 +1,144 @@ +// mcpp.build.command_line — the implementation, apart from the interface so +// that its instantiations are not carried by every module that imports it. +module mcpp.build.command_line; + +import std; +import mcpplibs.os.platform; + +// ─── Implementation ────────────────────────────────────────────────────── + +namespace mcpp::build::command_line { + +// THE ARGV RULES OF CommandLineToArgvW AND THE MSVC RUNTIME, NOT ONLY `\"`. +// +// Backslashes are literal unless a run of them precedes a `"`: then 2n +// backslashes stand for n and the quote ends (or begins) a quoted region, +// and 2n+1 stand for n and a literal quote. Escaping only the quote is wrong +// whenever backslashes precede it. The case that was met is an +// argument ENDING in a backslash: `"C:\Kits\10\"` closes nothing, because +// its last two characters are an escaped quote, and every following +// argument shifts. Measured 2026-10-05 in a Visual Studio developer +// environment, whose `WindowsSdkDir` ends in `\`: the LLVM row's std module +// precompile received `Files\Microsoft`, `Visual` and the rest of the +// command as separate inputs. Doubling such a run changes only arguments +// that were parsed wrongly before. +std::string quote_windows(std::string_view s) { + std::string out; + out.reserve(s.size() + 2); + out.push_back('"'); + std::size_t backslashes = 0; + for (char c : s) { + if (c == '\\') { ++backslashes; continue; } + if (c == '"') { + out.append(backslashes * 2 + 1, '\\'); + } else { + out.append(backslashes, '\\'); + } + out.push_back(c); + backslashes = 0; + } + out.append(backslashes * 2, '\\'); + out.push_back('"'); + return out; +} + +std::string paste_arg(std::string_view s) { + constexpr bool is_windows = mcpplibs::os::is_windows; + const bool plain = !s.empty() && std::ranges::all_of(s, [](unsigned char c) { + return std::isalnum(c) || c == '/' || c == '.' || c == '_' || c == '-' || c == ':' + || c == '@' || c == '+' || c == '=' || c == ',' || c == '%' || c == '~' + || (c == '\\' && is_windows) || c >= 0x80; + }); + return plain ? std::string(s) : quote(s); +} + +std::string quote_posix(std::string_view s) { + std::string out; + out.reserve(s.size() + 2); + out.push_back('\''); + for (char c : s) { + if (c == '\'') out += "'\\''"; + else out.push_back(c); + } + out.push_back('\''); + return out; +} + +std::string quote_windows_through_cmd(std::string_view s) { + const std::string inner = quote_windows(s); + std::string out; + out.reserve(inner.size() + 8); + // cmd's quote state, which is toggled by EVERY `"` -- it does not know + // MSVCRT's `\"`. Inside a quoted region a metacharacter is already inert + // and `^` is a literal character, so only the characters that fall OUTSIDE + // one are escaped. That keeps the result byte-identical to `quote_windows` + // for every payload with nothing to escape, which is nearly all of them. + bool inQuotes = false; + for (char c : inner) { + if (c == '"') { inQuotes = !inQuotes; out.push_back(c); continue; } + if (!inQuotes) { + switch (c) { + case '<': case '>': case '&': case '|': + case '^': case '(': case ')': + out.push_back('^'); + break; + default: + break; + } + } + out.push_back(c); + } + return out; +} + +std::string quote(std::string_view s) { + if constexpr (mcpplibs::os::is_windows) return quote_windows(s); + else return quote_posix(s); +} + +std::string quote_through_shell(std::string_view s) { + if constexpr (mcpplibs::os::is_windows) return quote_windows_through_cmd(s); + else return quote_posix(s); +} + + +std::string display(const std::vector& argv) { + std::string out; + for (auto const& a : argv) { + if (!out.empty()) out += ' '; + const bool plain = !a.empty() && std::ranges::all_of(a, [](char c) { + return std::isalnum(static_cast(c)) + || std::string_view("_-./:=+@%,").contains(c) + || (mcpplibs::os::is_windows && c == '\\'); + }); + out += plain ? a : quote(a); + } + return out; +} + +std::vector windows_shell_arguments(std::string_view command) { + return { "/d", "/s", "/c", "\"" + std::string(command) + "\"" }; +} + +std::string windows_shell_command_line(std::string_view command) { + std::string line = "cmd.exe"; + for (auto const& a : windows_shell_arguments(command)) line += " " + a; + return line; +} + +std::string clean_ld_library_path_prefix(const std::vector& dirs) { + if constexpr (mcpplibs::os::is_linux) { + if (dirs.empty()) return ""; + std::string joined; + for (auto& d : dirs) { + if (!joined.empty()) joined += ':'; + joined += d.string(); + } + return std::format("env LD_LIBRARY_PATH={} ", quote(joined)); + } else { + (void)dirs; + return ""; + } +} + +} // namespace mcpp::build::command_line diff --git a/src/build/command_line.cppm b/src/build/command_line.cppm new file mode 100644 index 00000000..8a99f580 --- /dev/null +++ b/src/build/command_line.cppm @@ -0,0 +1,110 @@ +// mcpp.build.command_line — the spelling of a command that a shell reads: a +// ninja rule, a hook, a command printed for a person to paste. +// +// Provides shell-safe argument quoting for command construction: +// POSIX: single-quote wrapping ('arg') +// Windows: double-quote wrapping ("arg") +// +// NOTE on Windows: do NOT use quote() for the FIRST token in a +// popen/system command string — cmd.exe strips a leading " pair. +// Use the raw path string as the first token; quote() is safe for +// arguments only. + +export module mcpp.build.command_line; + +import std; +import mcpplibs.os.platform; + +export namespace mcpp::build::command_line { + +// Platform-aware shell argument quoting. +std::string quote(std::string_view s); + +// The two halves of `quote`, callable regardless of host. Exposed so the +// Windows command-line shape can be built and unit-tested from any platform +// — the cmd.exe quoting rules are the easiest thing in mcpp to get wrong and +// the hardest to notice, since a Linux/macOS run never executes that code. +std::string quote_windows(std::string_view s); +std::string quote_posix(std::string_view s); + +// AN ARGUMENT OF A COMMAND PRINTED FOR A PERSON TO PASTE (mcpp#832). Left as +// it is when the shell would read it as one word anyway, so the common +// message reads as it always did; quoted by this host's shell rules when it +// would not -- a space, a quote, a glob or a shell metacharacter. Every +// suggested command that carries a path builds the argument with this. +std::string paste_arg(std::string_view s); + +// An argument that must survive TWO parsers: cmd.exe's, and then the child +// program's own argv parsing. +// +// `quote_windows` answers only the second. Its `\"` escape belongs to the +// MSVCRT argv rules, and cmd.exe does not know it: to cmd every `"` simply +// toggles a quote state, so a payload carrying an EVEN number of them before a +// metacharacter leaves that character unquoted. Measured on windows-2022 with +// a JSON argument -- `{"targets":["xim:shaderc@>=2026.3"],"yes":true}` puts +// four quotes before the `>`, and cmd read it as a REDIRECTION, answering +// +// The filename, directory name, or volume label syntax is incorrect. +// +// which arrived as a package-provisioning failure naming a package. +// +// The answer escapes exactly what needs it: quote for the child, then walk the +// result tracking the quote state CMD sees -- every `"` toggles it, because cmd +// does not know MSVCRT's `\"` -- and prefix `^` to any metacharacter that falls +// outside a quoted region. Inside one it is already inert, and `^` there is a +// literal character rather than an escape. +// +// ESCAPING EVERY METACHARACTER INCLUDING THE QUOTES WAS TRIED FIRST AND +// BROKE EVERY PAYLOAD. It is defensible on paper -- with no bare `"`, cmd never +// enters a quoted region and each metacharacter is escaped rather than quoted +// -- and Windows CI answered `exit 1` for every package fetch, including the +// ones whose JSON contains no metacharacter at all. So the rule here is the +// conservative one: a payload with nothing to escape comes out byte-identical +// to `quote_windows`, and only the character that is actually unprotected +// acquires a caret. +// +// `%` IS NOT ESCAPED AND CANNOT BE. Variable expansion happens before caret +// processing, and the batch-file escape (`%%`) is not available on a command +// line. Callers passing text that may contain `%` need a different mechanism; +// the JSON arguments this exists for do not. +std::string quote_windows_through_cmd(std::string_view s); + +// Host-selecting: `quote_windows_through_cmd` on Windows, `quote_posix` +// elsewhere. Use this wherever an argument reaches a shell and may contain +// metacharacters -- notably JSON, and any version constraint spelled `>=`. +std::string quote_through_shell(std::string_view s); + +// An argument vector as a person reads it in a message: an argument with +// nothing a shell would read specially stands as written, any other is quoted +// for the host's shell, so the text can be pasted to run the same program. +std::string display(const std::vector& argv); + +// Silent redirect — stdout + stderr → /dev/null (or NUL on Windows), and the +// error stream alone, for a command a shell reads. +constexpr std::string_view silent_redirect = mcpplibs::os::is_windows ? ">nul 2>&1" : ">/dev/null 2>&1"; +constexpr std::string_view null_redirect = mcpplibs::os::is_windows ? "2>nul" : "2>/dev/null"; + +// ─── The command interpreter of Windows (host-independent, for testing) ──── +// +// A command that is one by nature (a `[hooks]` command, a toolchain's +// environment script) reaches cmd.exe as +// +// cmd.exe /d /s /c "" +// +// /s makes cmd's rule for the rest of the line unconditional (strip exactly +// the outer pair), so the command arrives verbatim however many quotes it +// contains; /d skips AutoRun so a user's registry-installed hook cannot alter +// it. The switches stay bare and the command is wrapped once: cmd reads the +// line by its own grammar, not by the argument rules of CreateProcess, so the +// arguments are given to it as one string (mcpplibs.os `one_string`). +// The arguments after `cmd.exe`, and the whole line they make. +std::vector windows_shell_arguments(std::string_view command); +std::string windows_shell_command_line(std::string_view command); + +// An `env LD_LIBRARY_PATH= ` prefix for a ninja command on Linux: the +// toolchain's own runtime directories for a host process, without the +// inherited value (which may carry a target program's). "" elsewhere and for +// no directories. +std::string clean_ld_library_path_prefix(const std::vector& dirs); + +} // namespace mcpp::build::command_line diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index 0811f5cb..facde555 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -43,8 +43,8 @@ import mcpp.build.plan; import mcpp.build.flags; import mcpp.home; import mcpp.libs.json; -import mcpp.platform.fs; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.home.files; import mcpp.manifest.flag_words; import mcpp.toolchain.model; import mcpp.ui; // the one-time note about a root database no longer asked for @@ -144,7 +144,7 @@ publish_compile_commands( const std::filesystem::path& path, std::string_view fresh, const std::function& fileExists, - ReplaceFile replaceFile = mcpp::platform::fs::replace_file); + ReplaceFile replaceFile = mcpp::home::replace_file); // Copies the configuration database's CURRENT CONTENT on disk at // `configDbPath` over the root file at `rootPath` (following a symlink @@ -167,7 +167,7 @@ publish_root_compile_commands( const std::filesystem::path& rootPath, const std::filesystem::path& targetRoot, const std::filesystem::path& mcppHome, - ReplaceFile replaceFile = mcpp::platform::fs::replace_file); + ReplaceFile replaceFile = mcpp::home::replace_file); // The same for a command that planned several configurations (a workspace // whose members form more than one configuration group, workspace design @@ -183,7 +183,7 @@ publish_root_compile_commands( const std::filesystem::path& rootPath, const std::filesystem::path& targetRoot, const std::filesystem::path& mcppHome, - ReplaceFile replaceFile = mcpp::platform::fs::replace_file); + ReplaceFile replaceFile = mcpp::home::replace_file); // Writes the configuration's database for `plan`, then publishes the root // copy from it. `commandCount` is the configuration database's entry count; @@ -227,7 +227,7 @@ std::vector split_flags(std::string_view s) { unescaped.push_back(s[i]); } return mcpp::manifest::host_command_words(unescaped, - mcpp::platform::is_windows); + mcpplibs::os::is_windows); } namespace { diff --git a/src/build/cxx_runtime.cppm b/src/build/cxx_runtime.cppm index 95748851..71e1689a 100644 --- a/src/build/cxx_runtime.cppm +++ b/src/build/cxx_runtime.cppm @@ -176,7 +176,7 @@ std::string listed(const std::vector& names) { } // namespace std::optional verify_image(const ImageCheck& in) { - namespace elf = mcpp::platform::elf; + namespace elf = mcpp::runtime::elf; const auto foreign = name_of(in.foreign); const auto own = name_of(in.own); diff --git a/src/build/dirlock.cppm b/src/build/dirlock.cppm index d3e6cc7b..17de27a8 100644 --- a/src/build/dirlock.cppm +++ b/src/build/dirlock.cppm @@ -23,17 +23,11 @@ // A waiter is told who holds it once the wait has lasted 300 ms, which is what // the owner record exists for: the process, the command, the caller's label // (`MCPP_CLIENT`), and how long ago it started. -module; -#if defined(_WIN32) -#include -#else -#include -#endif - export module mcpp.build.dirlock; import std; -import mcpp.platform; +import mcpp.invocation; +import mcpp.home.files; import mcpp.ui; export namespace mcpp::build::dirlock { @@ -72,7 +66,7 @@ void set_waiting_observer(std::function acquire(const std::filesystem::path& outputDir); +std::shared_ptr acquire(const std::filesystem::path& outputDir); } // namespace mcpp::build::dirlock @@ -87,13 +81,7 @@ std::function& observer_slot() { static std::function observer; return observer; } -long current_pid() { -#if defined(_WIN32) - return static_cast(::_getpid()); -#else - return static_cast(::getpid()); -#endif -} +long current_pid() { return mcpp::invocation::process_id(); } std::int64_t now_seconds() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); @@ -148,7 +136,7 @@ std::string describe(const Holder& h, std::int64_t now) { } std::string owner_record() { - std::string client = mcpp::platform::env::get("MCPP_CLIENT").value_or(""); + std::string client = mcpp::invocation::get("MCPP_CLIENT").value_or(""); return std::format("pid={}\nstarted={}\ncommand={}\nclient={}\n", current_pid(), now_seconds(), command_slot(), client); @@ -160,17 +148,17 @@ std::string owner_record() { // file from the same process waits for itself (flock and LockFileEx lock per // open file, not per process). The lock is shared while any holder keeps it. std::mutex g_heldMutex; -std::map>& held() { - static std::map> m; +std::map>& held() { + static std::map> m; return m; } -std::shared_ptr acquire(const std::filesystem::path& outputDir) { +std::shared_ptr acquire(const std::filesystem::path& outputDir) { const auto key = outputDir.lexically_normal().generic_string(); std::lock_guard heldLock(g_heldMutex); if (auto existing = held()[key].lock()) return existing; const auto start = std::chrono::steady_clock::now(); - auto lock = mcpp::platform::fs::FileLock::acquire( + auto lock = mcpp::home::FileLock::acquire( outputDir, kLockName, owner_record(), std::chrono::milliseconds(300), [&](std::string_view record) { const auto holder = parse_holder(record); @@ -181,7 +169,7 @@ std::shared_ptr acquire(const std::filesystem::pat std::chrono::steady_clock::now() - start)); }); if (!lock) return nullptr; - auto shared = std::make_shared(std::move(*lock)); + auto shared = std::make_shared(std::move(*lock)); held()[key] = shared; return shared; } diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 79f544b9..dc4f143e 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -49,8 +49,12 @@ import mcpp.provider; import mcpp.provider.select; import mcpp.runtime.binding; import mcpp.log; -import mcpp.platform; -import mcpp.platform.capacity; +import mcpplibs.os.platform; +import mcpp.invocation; +import mcpp.effects; +import mcpp.runtime.launch_env; +import mcpp.run; +import mcpp.home.files; import mcpp.build.schedule.policy; // resolve_jobs — one answer to "how many at once" import mcpp.fetcher.progress; import mcpp.project; @@ -143,7 +147,7 @@ constexpr int kBuildCacheMaxEntries = 32; // compared. std::string toolchain_request_identity() { std::string cli; - if (const char* e = std::getenv("MCPP_TOOLCHAIN"); e) cli = e; + if (auto e = mcpp::invocation::get("MCPP_TOOLCHAIN")) cli = *e; std::string machineDefault; // THE TARGET BASE AND THE EXECUTION HOST, FROM WHEREVER THEY CAN BE STATED // OUTSIDE THE MANIFEST (design 2026-10-23 §3.3, §3.7): the overrides and @@ -169,10 +173,10 @@ std::string toolchain_request_identity() { if (std::filesystem::exists(mcpp::home::system_config_file(), ec)) target_table(mcpp::home::system_config_file()); std::string overrides; - for (auto const& e : mcpp::platform::env::with_prefix("MCPP_TARGET_")) + for (auto const& e : mcpp::invocation::with_prefix("MCPP_TARGET_")) if (e.find("_SYSROOT=") != std::string::npos || e.find("_RUN_UNDER=") != std::string::npos) overrides += e + ";"; - if (const char* s = std::getenv("MCPP_SYSROOT"); s && *s) overrides += std::format("MCPP_SYSROOT={};", s); + if (auto s = mcpp::invocation::get("MCPP_SYSROOT")) overrides += std::format("MCPP_SYSROOT={};", *s); if (overrides.empty() && targets.empty()) return std::format("cli={};default={}", cli, machineDefault); return std::format("cli={};default={};base={}{}", cli, machineDefault, overrides, targets); @@ -276,7 +280,7 @@ export struct BuildCacheEntry { std::string cacheMode; // Exact immutable snapshot used by the build. Optional distinguishes a // current cache from one written by an older mcpp (or a corrupt payload). - std::optional runtimeBinding; + std::optional runtimeBinding; // Source trees outside `projectRoot` that this build read — `path` // dependencies, which is what workspace members are to each other. The // staleness sweep has to cover them or a NEW FILE appearing in one is @@ -491,7 +495,7 @@ export std::vector read_build_cache(const std::filesystem::path haveNextLine = static_cast(std::getline(f, line)); } if (haveNextLine && line.starts_with("runtimeBinding=")) { - auto decoded = mcpp::platform::runtime::deserialize_runtime_binding( + auto decoded = mcpp::runtime::deserialize_runtime_binding( line.substr(15)); if (decoded) e.runtimeBinding = std::move(*decoded); haveNextLine = static_cast(std::getline(f, line)); @@ -646,7 +650,7 @@ void write_build_cache(const std::filesystem::path& projectRoot, const std::string& runEnvValue = "", const std::string& profile = "", const std::string& cacheMode = "", - const mcpp::platform::runtime::RuntimeBinding& runtimeBinding = {}, + const mcpp::runtime::RuntimeBinding& runtimeBinding = {}, std::vector depSourceRoots = {}, bool runnerDeclared = false, bool runTierPending = false, @@ -666,7 +670,7 @@ void write_build_cache(const std::filesystem::path& projectRoot, static std::mutex cacheWrite; std::lock_guard lock(cacheWrite); auto path = projectRoot / kBuildCacheFile; - auto fileLock = mcpp::platform::fs::FileLock::acquire( + auto fileLock = mcpp::home::FileLock::acquire( path.parent_path(), ".build_cache.lock", {}, std::chrono::hours(1), {}); auto entries = read_build_cache(projectRoot); @@ -749,7 +753,7 @@ void write_build_cache_entries(const std::filesystem::path& path, f << "subos=" << e.subosDir << '\n'; if (e.runtimeBinding) f << "runtimeBinding=" - << mcpp::platform::runtime::serialize_runtime_binding(*e.runtimeBinding) + << mcpp::runtime::serialize_runtime_binding(*e.runtimeBinding) << '\n'; f << "profile=" << e.profile << '\n'; f << "cacheMode=" << e.cacheMode << '\n'; @@ -854,8 +858,8 @@ compute_run_targets(const mcpp::build::BuildPlan& plan) { // cached fast path so both derive the same env from the same source. std::pair compute_run_env(const mcpp::build::BuildPlan& plan) { - auto key = mcpp::platform::env::runtime_library_path_key(); - auto value = mcpp::platform::env::prepend_path_list(key, plan.runtimeLibraryDirs); + auto key = mcpp::runtime::launch_env::runtime_library_path_key(); + auto value = mcpp::runtime::launch_env::prepend_path_list(key, plan.runtimeLibraryDirs); if (key.empty() || value.empty()) return {"", ""}; return {key, value}; } @@ -879,12 +883,10 @@ compute_run_env(const mcpp::build::BuildPlan& plan) { // declaration changes and this code does not. std::vector> compute_subos_env(const mcpp::build::BuildPlan& plan) { - return mcpp::platform::runtime::resolve_runtime_environment( + return mcpp::runtime::resolve_runtime_environment( plan.runtimeBinding, [](std::string_view v) -> std::optional { - if (const char* e = std::getenv(std::string(v).c_str())) - return std::string(e); - return std::nullopt; + return mcpp::invocation::get(v); }); } @@ -1067,9 +1069,8 @@ ExecutionHost locate_run_under(const mcpp::build::BuildContext& ctx, std::string ExecutionHost h; if (ctx.runUnder.empty()) return h; h.prefix = ctx.runUnder; - const char* pathEnv = std::getenv("PATH"); auto found = mcpp::build::runner_lookup::locate(h.prefix.front(), ctx.xlingsDepBinDirs, - pathEnv ? pathEnv : ""); + mcpp::invocation::get("PATH").value_or("")); if (!found.program) { h.error = std::format( "{}\n `run_under` comes from {}. Install the program (for example through " @@ -1097,7 +1098,7 @@ ExecutionHost locate_run_under(const mcpp::build::BuildContext& ctx, std::string // here, and is believed. std::optional unreachable_execution(const mcpp::build::BuildContext& ctx, std::string_view tripleKey, bool noRunner) { - if (noRunner || !ctx.runUnder.empty() || mcpp::platform::is_macos) return std::nullopt; + if (noRunner || !ctx.runUnder.empty() || mcpplibs::os::is_macos) return std::nullopt; auto t = mcpp::toolchain::triple::parse(tripleKey); if (!t || !t->is_apple()) return std::nullopt; return std::format( @@ -1274,7 +1275,7 @@ void report_freestanding_size(const BuildContext& ctx) { if (tool.empty()) continue; // An argument vector rather than a `2>/dev/null` command string, // which cmd.exe cannot open on a Windows host. - auto out = mcpp::platform::process::capture_stdout( + auto out = mcpp::run::capture_stdout( {tool.string(), art.string()}); if (out.exit_code != 0 && out.output.empty()) continue; auto s = mcpp::freestanding::parse_size_output(out.output); @@ -1795,9 +1796,9 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, // Unset by default and unset in CI. It changes nothing but ninja's // verbosity, and it is spelled as a topic list rather than a boolean so // that `-d stats` and `-d keeprsp` are reachable without another variable. - if (const char* topics = std::getenv("MCPP_NINJA_DEBUG"); topics && *topics) { + if (auto topics = mcpp::invocation::get("MCPP_NINJA_DEBUG")) { argv.push_back("-d"); - argv.push_back(topics); + argv.push_back(*topics); } std::vector> childEnv; @@ -1859,9 +1860,9 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, // Nobody reads this ninja's progress: it reports no action start // (build progress design 2026-09-29, §6.4). childEnv.emplace_back(std::string(mcpp::build::progress::kStartsEnv), ""); - mcpp::platform::process::RunResult r; - if (scanArgv) r = mcpp::platform::process::capture_exec(*scanArgv, childEnv); - if (r.exit_code == 0) r = mcpp::platform::process::capture_exec(argv, childEnv); + mcpp::run::RunResult r; + if (scanArgv) r = mcpp::run::capture_exec(*scanArgv, childEnv); + if (r.exit_code == 0) r = mcpp::run::capture_exec(argv, childEnv); out = std::move(r.output); status = r.exit_code; } @@ -2311,15 +2312,15 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // // Declining the fast path is the whole fix: `--locked` is for release // builds, audits and CI, none of which are the case the fast path serves. - if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") + if (mcpp::invocation::get("MCPP_LOCKED").value_or("") == "1") return fast_path_declined("build", "MCPP_LOCKED=1 asks for a locked resolution"); // `--managed-only` is checked against the sources prepare decides, which // a fast path does not run (mcpp#755). - if (auto v = mcpp::platform::env::get("MCPP_MANAGED_ONLY"); v && *v != "0") + if (auto v = mcpp::invocation::get("MCPP_MANAGED_ONLY"); v && *v != "0") return fast_path_declined("build", "MCPP_MANAGED_ONLY asks for the sources to be checked"); // The full path refuses a denied write and names it (design 2026-10-26 §5.5). - if (mcpp::platform::env::effect_denied("write-project")) + if (mcpp::effects::effect_denied("write-project")) return fast_path_declined("build", "the caller denied write-project"); auto want = fast_path_identity(projectRoot); if (!want) return fast_path_declined("build", "the manifest or the request could not be read"); @@ -2417,13 +2418,13 @@ export std::optional try_fast_workspace_build( const std::vector>& groups, bool verbose, bool no_cache) { if (no_cache) return fast_path_declined("workspace", "the build cache is off (--no-cache)"); - if (mcpp::platform::env::effect_denied("write-project")) + if (mcpp::effects::effect_denied("write-project")) return fast_path_declined("workspace", "the caller denied write-project"); - if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") + if (mcpp::invocation::get("MCPP_LOCKED").value_or("") == "1") return fast_path_declined("workspace", "MCPP_LOCKED=1 asks for a locked resolution"); // `--managed-only` is checked against the sources prepare decides, which // a fast path does not run (mcpp#755). - if (auto v = mcpp::platform::env::get("MCPP_MANAGED_ONLY"); v && *v != "0") + if (auto v = mcpp::invocation::get("MCPP_MANAGED_ONLY"); v && *v != "0") return fast_path_declined("workspace", "MCPP_MANAGED_ONLY asks for the sources to be checked"); auto join = [](const std::vector& v) { std::string out; @@ -2441,7 +2442,7 @@ export std::optional try_fast_workspace_build( mcpp::build::runtime_validation::ArtifactSnapshot validated; }; std::vector ready; - std::vector> locks; + std::vector> locks; std::string profile; std::error_code ec; const auto wsToml = std::filesystem::last_write_time(wsRoot / "mcpp.toml", ec); @@ -2557,13 +2558,13 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, return fast_path_declined("run", "--print-launch describes the launch the full path composes"); // Same reason as try_fast_build's: this path skips resolution, and // `--locked` is an assertion about resolution. - if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") + if (mcpp::invocation::get("MCPP_LOCKED").value_or("") == "1") return fast_path_declined("run", "MCPP_LOCKED=1 asks for a locked resolution"); // `--managed-only` is checked against the sources prepare decides, which // a fast path does not run (mcpp#755). - if (auto v = mcpp::platform::env::get("MCPP_MANAGED_ONLY"); v && *v != "0") + if (auto v = mcpp::invocation::get("MCPP_MANAGED_ONLY"); v && *v != "0") return fast_path_declined("run", "MCPP_MANAGED_ONLY asks for the sources to be checked"); - if (mcpp::platform::env::effect_denied("write-project")) + if (mcpp::effects::effect_denied("write-project")) return fast_path_declined("run", "the caller denied write-project"); auto want = fast_path_identity(projectRoot); if (!want) return fast_path_declined("run", "the manifest or the request could not be read"); @@ -2686,12 +2687,10 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // Installing/changing a provider invalidates the fast path via the SubOS // manifest mtime check; this invocation never mixes a new run contract // with objects built under the old one. - for (auto& kv : mcpp::platform::runtime::resolve_runtime_environment( + for (auto& kv : mcpp::runtime::resolve_runtime_environment( *match->runtimeBinding, [](std::string_view v) -> std::optional { - if (const char* e = std::getenv(std::string(v).c_str())) - return std::string(e); - return std::nullopt; + return mcpp::invocation::get(v); })) childEnv.push_back(std::move(kv)); @@ -2701,7 +2700,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // `runnerDeclared` gate above), so the artifact is the only thing that // could have been refused. int spawnErr = 0; - const int exitRc = mcpp::platform::process::run_foreground(argv, childEnv, &spawnErr); + const int exitRc = mcpp::run::run_foreground(argv, childEnv, &spawnErr); if (spawnErr != 0) { using namespace mcpp::build::runner_lookup; const auto triple = mcpp::toolchain::triple::host_triple().str(); @@ -2846,9 +2845,8 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx, // different interpreter with different arguments is the failure the // runner key exists to prevent. auto tmpl = choice.tmpl; - const char* pathEnv = std::getenv("PATH"); auto found = mcpp::build::runner_lookup::locate( - tmpl.front(), ctx.xlingsDepBinDirs, pathEnv ? pathEnv : ""); + tmpl.front(), ctx.xlingsDepBinDirs, mcpp::invocation::get("PATH").value_or("")); if (!found.program) { std::println(stderr, "error: {}", mcpp::build::runner_lookup::not_found_message( @@ -2944,7 +2942,7 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx, return 0; } int spawnErr = 0; - const int rc = mcpp::platform::process::run_foreground(argv, childEnv, &spawnErr); + const int rc = mcpp::run::run_foreground(argv, childEnv, &spawnErr); if (spawnErr != 0) { using namespace mcpp::build::runner_lookup; if (!choice.tmpl.empty() || !host.prefix.empty()) @@ -3683,9 +3681,8 @@ static void test_resolve_runner(TestBuild& tb, const TestOptions& testOpts, bool tb.runnerTmpl = runnerChoice.tmpl; auto& runnerTmpl = tb.runnerTmpl; if (!runnerTmpl.empty()) { - const char* pathEnv = std::getenv("PATH"); auto found = mcpp::build::runner_lookup::locate( - runnerTmpl.front(), ctx->xlingsDepBinDirs, pathEnv ? pathEnv : ""); + runnerTmpl.front(), ctx->xlingsDepBinDirs, mcpp::invocation::get("PATH").value_or("")); if (found.program) runnerTmpl.front() = found.program->string(); else tb.invocationNotRunReason = mcpp::build::runner_lookup::not_found_message( runnerChoice.tripleKey, runnerTmpl.front(), found.searched); @@ -3733,11 +3730,11 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // The runtime of THIS member: in a plan of several members, the plan's own // directories are the union over every member, and a member's tests are // told about its closure's alone. - auto runtimeEnvKey = mcpp::platform::env::runtime_library_path_key(); + auto runtimeEnvKey = mcpp::runtime::launch_env::runtime_library_path_key(); std::string runtimeEnvValue; bool hasRuntimeDirs = false; with_member(*ctx, owner, [&] { - runtimeEnvValue = mcpp::platform::env::prepend_path_list( + runtimeEnvValue = mcpp::runtime::launch_env::prepend_path_list( runtimeEnvKey, ctx->plan.runtimeLibraryDirs); hasRuntimeDirs = !ctx->plan.runtimeLibraryDirs.empty(); }); @@ -3751,7 +3748,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // test needing `[runtime] library_dirs` passes on Linux/Windows and fails // here with a dyld error that names neither the cause nor the platform — // so say it out loud rather than leaving the difference silent. - if constexpr (mcpp::platform::is_macos) { + if constexpr (mcpplibs::os::is_macos) { if (runtimeEnvKey.empty() && hasRuntimeDirs) { mcpp::diag::warning("test/runtime-path", "macOS does not inject a runtime library path for test binaries " @@ -3874,13 +3871,13 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, int spawnErr = 0; std::string runOutput; if (capture) { - auto rr = mcpp::platform::process::capture_exec_deadline( + auto rr = mcpp::run::capture_exec_deadline( r.argv, r.env, deadline, &timedOut, {}, &spawnErr); exitCode = rr.exit_code; runOutput = std::move(rr.output); } else { mcpp::ui::status("Running", std::format("bin/{}", r.name)); - exitCode = mcpp::platform::process::run_exec_deadline( + exitCode = mcpp::run::run_exec_deadline( r.argv, r.env, deadline, &timedOut, &spawnErr); } auto ms = std::chrono::duration_cast( @@ -4062,12 +4059,12 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // Prepend the toolchain's environment's bin to the CHILD PATH so test // binaries that shell out to bootstrapped tools (patchelf, ninja) find // them — applied to the child only, not via a leaky shell prefix. - if constexpr (!mcpp::platform::is_windows) { + if constexpr (!mcpplibs::os::is_windows) { if (auto environment = mcpp::provider::environment_of(ctx->tc.binaryPath)) { auto sandboxBin = *environment / "bin"; if (std::filesystem::exists(sandboxBin)) { std::array extra{sandboxBin}; - auto pathVal = mcpp::platform::env::prepend_path_list("PATH", extra); + auto pathVal = mcpp::runtime::launch_env::prepend_path_list("PATH", extra); if (!pathVal.empty()) childEnv.emplace_back("PATH", pathVal); } } diff --git a/src/build/flags.cppm b/src/build/flags.cppm index eaa9b14f..bf2acdd6 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -24,8 +24,10 @@ import mcpp.okimage; import mcpp.manifest.types; import mcpp.manifest.flag_words; import mcpp.modgraph.scanner; -import mcpp.platform; -import mcpp.platform.runtime_search; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; +import mcpp.toolchain.apple_sdk; +import mcpp.runtime.search; import mcpp.toolchain.clang; import mcpp.toolchain.detect; import mcpp.toolchain.dialect; @@ -261,8 +263,8 @@ LinkShape link_shape(LinkHost host, mcpp::build::dist::Format targetFormat, // The host this binary was built for, in `link_shape`'s vocabulary. constexpr LinkHost current_link_host() { - return mcpp::platform::is_windows ? LinkHost::Windows - : mcpp::platform::needs_explicit_libcxx ? LinkHost::MacOS + return mcpplibs::os::is_windows ? LinkHost::Windows + : mcpp::toolchain::platform::needs_explicit_libcxx ? LinkHost::MacOS : LinkHost::Linux; } @@ -497,7 +499,7 @@ std::string shell_quote_arg(std::string_view arg) { if (arg.find_first_of(kNeedsQuote) == std::string_view::npos) return std::string(arg); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { // MSVCRT argument rules: inside double quotes a run of backslashes is // literal unless a `"` follows it, in which case every backslash of // the run is doubled and the quote is escaped. The closing quote is @@ -535,7 +537,7 @@ std::string shell_quote_arg(std::string_view arg) { std::string ninja_command_word(std::string_view word) { // An empty word is still a word. shell_quote_arg leaves it empty, which is // right for its callers that append optional pieces, and wrong here. - if (word.empty()) return mcpp::platform::is_windows ? "\"\"" : "''"; + if (word.empty()) return mcpplibs::os::is_windows ? "\"\"" : "''"; std::string out; for (char c : shell_quote_arg(word)) { if (c == '$') out.push_back('$'); @@ -649,8 +651,8 @@ dist::Format target_object_format(const mcpp::toolchain::Toolchain& tc) { return mcpp::toolchain::is_mingw_target(tc) ? dist::Format::Pe : dist::format_for(tc.targetTriple, - mcpp::platform::needs_explicit_libcxx ? dist::Format::MachO - : mcpp::platform::is_windows ? dist::Format::Pe + mcpp::toolchain::platform::needs_explicit_libcxx ? dist::Format::MachO + : mcpplibs::os::is_windows ? dist::Format::Pe : dist::Format::Elf); } @@ -721,7 +723,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); return t && t->os == "macos"; }(); - std::string macosDeploymentTarget = mcpp::platform::macos::deployment_target( + std::string macosDeploymentTarget = mcpp::toolchain::apple::deployment_target( targetIsMacos, plan.manifest.buildConfig.macosDeploymentTarget); f.cxxBinary = plan.toolchain.binaryPath; @@ -757,7 +759,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { const bool peTarget = [&] { if (auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple)) return t->is_pe(); - return bool(mcpp::platform::is_windows); + return bool(mcpplibs::os::is_windows); }(); // READ, not re-derived. `make_plan` decides this once and the cache key // hashes the same bit; a second scan here is how the compiler and the @@ -1097,9 +1099,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { || triple.find("mingw") != std::string::npos) return LinkIntentFlavor::PeGnu; if (triple.empty()) { - if constexpr (mcpp::platform::is_windows) + if constexpr (mcpplibs::os::is_windows) return LinkIntentFlavor::PeGnu; - if constexpr (mcpp::platform::needs_explicit_libcxx) + if constexpr (mcpp::toolchain::platform::needs_explicit_libcxx) return LinkIntentFlavor::MachO; } return LinkIntentFlavor::Elf; @@ -1129,7 +1131,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { std::string farm_ld; if (linkIntentFlavor == LinkIntentFlavor::Elf) { for (auto const& dir : plan.runtimeSearch) { - if (dir.origin != mcpp::platform::search::Origin::SubosFarm) continue; + if (dir.origin != mcpp::runtime::search::Origin::SubosFarm) continue; farm_ld += ' '; farm_ld += shell_quote_arg(escape_ninja_chars( "-Wl,-rpath," + dir.path.string())); @@ -1358,7 +1360,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { // the right answer for a host-target build, so it is threaded in as the // fallback rather than discarded. const bool full_static_ok = mcpp::toolchain::target_supports_full_static( - plan.toolchain.targetTriple, mcpp::platform::supports_full_static); + plan.toolchain.targetTriple, mcpp::toolchain::platform::supports_full_static); std::string full_static = (full_static_ok && f.linkage == "static") ? " -static" : ""; // ---- C++ runtime distribution contract (issue #336) ------------------- @@ -1467,7 +1469,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { dist::MechanismInput mi; mi.stdlibId = linkedStdlib; - mi.hostIsWindows = mcpp::platform::is_windows; + mi.hostIsWindows = mcpplibs::os::is_windows; mi.fullStaticLibc = (f.linkage == "static"); // The CRT model this WHOLE project is being compiled with. Per-role // contracts cannot move it: cl bakes _MSVC_MT / _MSVC_MD into the std @@ -1550,8 +1552,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { const bool driverPlacesArchives = (format == dist::Format::Elf || format == dist::Format::MachO) && !mi.freestanding && !mi.graphCxxRuntime && !mi.appleCrossTarget; - const std::string driverFlags = - crossTarget + (isClangWithCfg ? " --no-default-config" : ""); + std::vector driverFlags; + if (!plan.toolchain.crossTargetFlag.empty()) driverFlags.push_back(plan.toolchain.crossTargetFlag); + if (isClangWithCfg) driverFlags.push_back("--no-default-config"); auto find_archive = [&](std::string_view name) -> std::filesystem::path { auto searched = search_llvm_root(name); if (!driverPlacesArchives) return searched; @@ -1781,7 +1784,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { } std::string runtime_dirs; - if constexpr (mcpp::platform::supports_rpath) { + if constexpr (mcpp::toolchain::platform::supports_rpath) { // Toolchain runtime dirs (glibc/gcc) as before... for (auto& dir : plan.toolchain.linkRuntimeDirs) { runtime_dirs += " -L" + escape_path(dir); @@ -2097,7 +2100,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { } else if (plan.toolchain.base.stated() && plan.toolchain.base.address == mcpp::toolchain::base::Address::AppleIsysroot) { macos_sdk = " -isysroot " + escape_path(plan.toolchain.base.root); - } else if (auto sdk = mcpp::platform::macos::sdk_path()) { + } else if (auto sdk = mcpp::toolchain::apple::sdk_path()) { macos_sdk = " -isysroot " + escape_path(*sdk); } // AND KEPT, BECAUSE THE GRAPH BRANCH BELOW REPLACES THIS LINE. diff --git a/src/build/hermetic.cppm b/src/build/hermetic.cppm index 27ee6594..20bddb84 100644 --- a/src/build/hermetic.cppm +++ b/src/build/hermetic.cppm @@ -25,12 +25,15 @@ export module mcpp.build.hermetic; import std; import mcpp.log; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.build.command_line; +import mcpp.run; import mcpp.provider; import mcpp.provider.select; import mcpp.toolchain.fingerprint; import mcpp.toolchain.model; import mcpp.toolchain.triple; +import mcpp.invocation; export namespace mcpp::build { @@ -120,7 +123,7 @@ std::expected verify_hermetic_link( bool isolatedGraphLink, const std::vector& graphRoots) { - if constexpr (!mcpp::platform::is_linux) return {}; + if constexpr (!mcpplibs::os::is_linux) return {}; // Provider toolchains only: the compiler must live in a store. A // host/system compiler is the user's explicit opt-in to the host world. @@ -140,7 +143,7 @@ std::expected verify_hermetic_link( } if (!tc.base.root.empty()) allowed.push_back(tc.base.root); - if (const char* e = std::getenv("MCPP_ALLOW_HOST_LIBS"); e && *e && *e != '0') + if (auto e = mcpp::invocation::get("MCPP_ALLOW_HOST_LIBS"); e && e->front() != '0') allowHostLibs = true; auto ldflags = unescape_ninja(ldflagsNinja); @@ -161,9 +164,10 @@ std::expected verify_hermetic_link( } auto cmd = std::format("{} {} -### -x c++ /dev/null -o /dev/null 2>&1", - mcpp::platform::shell::quote(tc.binaryPath.string()), + mcpp::build::command_line::quote(tc.binaryPath.string()), ldflags); - auto r = mcpp::platform::process::capture(cmd); + // The flags are the link's as ninja runs them, text a shell reads. + auto r = mcpp::run::capture_shell(cmd); if (r.exit_code != 0) { // The dry-run itself failing is a link-configuration problem the real // link will also hit; don't duplicate the diagnosis here. diff --git a/src/build/loader_contract.cppm b/src/build/loader_contract.cppm index 6f8a392f..bf608efc 100644 --- a/src/build/loader_contract.cppm +++ b/src/build/loader_contract.cppm @@ -102,8 +102,8 @@ struct TagFinding { std::filesystem::path artifact; Form form = Form::NotElf; RequiredTag required = RequiredTag::NotApplicable; - mcpp::platform::elf::SearchPathTag actual = - mcpp::platform::elf::SearchPathTag::None; + mcpp::runtime::elf::SearchPathTag actual = + mcpp::runtime::elf::SearchPathTag::None; // Three-valued on purpose. `NotChecked` is not `Ok`: an artifact that // could not be read, or that carries no search path at all, has not been @@ -113,7 +113,7 @@ struct TagFinding { Status status = Status::NotChecked; std::string explain() const { - using SearchPathTag = mcpp::platform::elf::SearchPathTag; + using SearchPathTag = mcpp::runtime::elf::SearchPathTag; if (status != Status::Violation) return {}; return std::format( "{}: {} carries {} but the loader contract requires {} — a search " @@ -122,9 +122,9 @@ struct TagFinding { "driver (it never dlopens the vendor itself)", artifact.string(), form == Form::Executable ? "executable" : "shared library", - mcpp::platform::elf::to_string(actual), + mcpp::runtime::elf::to_string(actual), to_string(required), - mcpp::platform::elf::to_string(actual == SearchPathTag::Both + mcpp::runtime::elf::to_string(actual == SearchPathTag::Both ? SearchPathTag::Runpath : actual)); } @@ -133,11 +133,11 @@ struct TagFinding { // Rule E, evaluated where the artifact lands rather than in a repository // workflow the user's machine never sees. TagFinding check_artifact(const std::filesystem::path& artifact) { - using SearchPathTag = mcpp::platform::elf::SearchPathTag; + using SearchPathTag = mcpp::runtime::elf::SearchPathTag; TagFinding out; out.artifact = artifact; - auto facts = mcpp::platform::elf::inspect_elf_runtime(artifact); + auto facts = mcpp::runtime::elf::inspect_elf_runtime(artifact); if (!facts) return out; // not ELF, or unreadable out.form = facts->is_executable() ? Form::Executable : Form::SharedLibrary; diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 095d51f1..58681d25 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -48,7 +48,12 @@ import mcpp.okimage; import mcpp.toolchain.msvc_coroutines; import mcpp.provider; import mcpp.provider.select; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpplibs.os.windows; +import mcpp.invocation; +import mcpp.runtime.launch_env; +import mcpp.build.command_line; +import mcpp.run; import mcpp.ui; import mcpp.log; import mcpp.build.progress; @@ -333,7 +338,7 @@ std::filesystem::path mcpp_exe_path(); namespace mcpp::build { std::filesystem::path mcpp_exe_path() { - return mcpp::platform::fs::self_exe_path(); + return mcpp::invocation::self_exe_path(); } namespace { @@ -592,8 +597,8 @@ std::string shared_soname_flag(const LinkUnit& lu, const BuildPlan& plan) { // an `os` string: the prior `os == "macos"` fell through to the ELF // branch (`-Wl,-soname`, a GNU ld/BFD flag ld64 does not accept) for // iOS, whose `os` is `ios`. - const bool pe = t ? t->is_pe() : bool(mcpp::platform::is_windows); - const bool macho = t ? t->is_mach_o() : bool(mcpp::platform::is_macos); + const bool pe = t ? t->is_pe() : bool(mcpplibs::os::is_windows); + const bool macho = t ? t->is_mach_o() : bool(mcpplibs::os::is_macos); // PE records no such name: a DLL is found by the filename in the importing // module's import table, and there is nothing to override. if (pe) return ""; @@ -612,8 +617,8 @@ std::string shared_soname_flag(const LinkUnit& lu, const BuildPlan& plan) { std::string shared_soname_default(const LinkUnit& lu, const BuildPlan& plan) { if (lu.kind != LinkUnit::SharedLibrary || !lu.soname.empty()) return ""; const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); - const bool pe = t ? t->is_pe() : bool(mcpp::platform::is_windows); - const bool macho = t ? t->is_mach_o() : bool(mcpp::platform::is_macos); + const bool pe = t ? t->is_pe() : bool(mcpplibs::os::is_windows); + const bool macho = t ? t->is_mach_o() : bool(mcpplibs::os::is_macos); if (pe || macho) return ""; if (t && t->is_openkal()) return "-soname " + lu.output.filename().string(); return "-Wl,-soname," + lu.output.filename().string(); @@ -703,23 +708,13 @@ void write_file(const std::filesystem::path& p, std::string_view content) { os << content; } -bool run(const std::string& cmd, std::string& output_capture, bool capture_output = true) { - output_capture.clear(); - if (capture_output) { - auto r = mcpp::platform::process::capture(cmd); - output_capture = r.output; - return r.exit_code == 0; - } - return mcpp::platform::process::run_passthrough(cmd) == 0; -} - bool dyndep_mode_enabled() { // M4 #7: dyndep is now the default. Set MCPP_NINJA_DYNDEP=0 to opt // OUT and fall back to the static-deps emission path. - const char* v = std::getenv("MCPP_NINJA_DYNDEP"); + const auto v = mcpp::invocation::get("MCPP_NINJA_DYNDEP"); if (!v) return true; - std::string_view sv(v); + std::string_view sv(*v); return !(sv == "0" || sv == "off" || sv == "false"); } @@ -820,8 +815,8 @@ bool is_command_line(std::string_view trimmed, std::optional> runtime_env_for_dirs(const std::vector& dirs) { - auto key = mcpp::platform::env::runtime_library_path_key(); - auto value = mcpp::platform::env::prepend_path_list(key, dirs); + auto key = mcpp::runtime::launch_env::runtime_library_path_key(); + auto value = mcpp::runtime::launch_env::prepend_path_list(key, dirs); if (key.empty() || value.empty()) return std::nullopt; return std::pair{std::move(key), std::move(value)}; } @@ -901,7 +896,7 @@ std::vector windows_executable_link_flags(const BuildPlan& plan, bo std::string_view subsystem, std::string_view entry) { const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); - const bool pe = t ? t->is_pe() : bool(mcpp::platform::is_windows); + const bool pe = t ? t->is_pe() : bool(mcpplibs::os::is_windows); if (!pe) return {}; const bool gui = subsystem == "windows"; const std::string_view fn = entry.empty() ? std::string_view("main") : entry; @@ -1405,7 +1400,7 @@ NinjaRun run_ninja_reporting(const std::vector& argv, }; // A ninja that cannot be started is reported through `on_line`, into the // output the failure report reads. - run.exitCode = mcpp::platform::process::stream_exec(argv, env, deadline, on_line, + run.exitCode = mcpp::run::stream_exec(argv, env, deadline, on_line, &run.timedOut); progress.pass_end(); return run; @@ -1614,11 +1609,11 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // `env` prefix. macOS keeps no such path at all // (`runtime_library_path_key`), Windows keeps its PATH. const std::string toolEnv = [&]() -> std::string { - if (!mcpp::platform::is_linux || plan.toolchain.compilerRuntimeDirs.empty()) return {}; - if (auto facts = mcpp::platform::elf::inspect_elf_runtime(flags.cxxBinary); - facts && facts->searchPathTag != mcpp::platform::elf::SearchPathTag::None) + if (!mcpplibs::os::is_linux || plan.toolchain.compilerRuntimeDirs.empty()) return {}; + if (auto facts = mcpp::runtime::elf::inspect_elf_runtime(flags.cxxBinary); + facts && facts->searchPathTag != mcpp::runtime::elf::SearchPathTag::None) return {}; - return mcpp::platform::linux_::build_clean_ld_library_path_prefix( + return mcpp::build::command_line::clean_ld_library_path_prefix( plan.toolchain.compilerRuntimeDirs); }(); append(std::format("cxx = {}{}{}\n", toolEnv, launch, ninja_tool_word(flags.cxxBinary))); @@ -1882,12 +1877,12 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, : "-MMD -MF $out.d ") : ""; const std::string mmd_filter = - (needsGnuModuleFilter && !mcpp::platform::is_windows) + (needsGnuModuleFilter && !mcpplibs::os::is_windows) ? " && awk 'NR==1{print;next} /^[^ ]/{exit} {print}' " "\"$out.d.raw\" > \"$out.d\" && rm -f \"$out.d.raw\"" : ""; const std::string win_depfile_filter = - (needsGnuModuleFilter && mcpp::platform::is_windows) + (needsGnuModuleFilter && mcpplibs::os::is_windows) ? "$mcpp depfile-filter --raw $out.d.raw --out $out.d -- " : ""; auto append_cxx_deps = [&] { @@ -1934,7 +1929,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // makes the response-file shape reachable from a non-Windows test host, // the same over-approximation the link rules use (`separateLinker || // is_windows`). - const bool useCompileRsp = mcpp::platform::is_windows || msvcDeps; + const bool useCompileRsp = mcpplibs::os::is_windows || msvcDeps; // THE BYTE ORDER MARK, FOR THE TOOLS THAT NEED ONE TO READ UTF-8 (#693). // // build.ninja is UTF-8, and Ninja copies `rspfile_content` into the @@ -1976,7 +1971,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, const std::string module_output_flag = traits.needsExplicitModuleOutput ? std::string(traits.moduleOutputPrefix) + "$bmi_out" : ""; append("rule cxx_module\n"); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { // Windows: skip BMI restat optimization (requires POSIX shell). const std::string payload = " $local_includes"; append(std::format(" command = {}$cxx{} $cxxflags $unit_cxxflags" @@ -2072,7 +2067,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, if (twoPhase) { // Edge A — the BMI, and nothing else. `$out` is the BMI here. append("rule cxx_precompile\n"); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { const std::string payload = " $local_includes"; append(std::format( " command = {}$cxx{} $cxxflags $unit_cxxflags{}{} {}$in {}$out\n", @@ -2104,7 +2099,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // write one file. Identical to cxx_object except for the language flag // that tells the driver this source is a module interface. append("rule cxx_module_object\n"); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { const std::string payload = " $local_includes"; append(std::format(" command = {}$cxx{} $cxxflags $unit_cxxflags{} {}{}\n", win_depfile_filter, rsp_ref(payload), module_src_flags, @@ -2122,7 +2117,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, } append("rule cxx_object\n"); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { const std::string payload = " $local_includes"; append(std::format(" command = {}$cxx{} $cxxflags $unit_cxxflags {}{}\n", win_depfile_filter, rsp_ref(payload), mmd_flag, @@ -2267,10 +2262,10 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // on the build machine runs the artifact while it links. const bool inPlaceTarget = [&] { const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); - return t ? (t->is_pe() || t->is_wasm()) : bool(mcpp::platform::is_windows); + return t ? (t->is_pe() || t->is_wasm()) : bool(mcpplibs::os::is_windows); }(); auto publish_link = [&](std::string cmd) { - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { return "$mcpp link-output --output $out -- " + cmd; } else { if (inPlaceTarget) return cmd; @@ -2422,7 +2417,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, const bool placeRuntimeDlls = [&] { if (plan.linkIntent.runtimeSearchDirs.empty()) return false; const auto t = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); - return t ? t->is_pe() : bool(mcpp::platform::is_windows); + return t ? t->is_pe() : bool(mcpplibs::os::is_windows); }(); if (placeRuntimeDlls) { std::string dirs; @@ -2455,7 +2450,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, } append("rule runtime_alias\n"); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { // PE has no soname symlink, so the alias is a copy — and a copy of a // just-rebuilt DLL is exactly the hazard #311 is about (a program still // running from a previous `mcpp run` holds the old one). Route it @@ -2467,7 +2462,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, append(" command = mkdir -p $$(dirname $out) && rm -f $out && ln -s $$(basename $in) $out\n"); } append(" description = ALIAS $out\n"); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { append(" restat = 1\n"); } append("\n"); @@ -3018,8 +3013,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // MCPP_VERIFY_MODGRAPH=1 (read at generation time) extends the // check to every module unit. const bool verifyAll = [] { - const char* v = std::getenv("MCPP_VERIFY_MODGRAPH"); - return v && std::string_view(v) == "1"; + return mcpp::invocation::get("MCPP_VERIFY_MODGRAPH") == "1"; }(); std::map ddi_expect; for (auto& cu : plan.compileUnits) { @@ -3479,7 +3473,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, if (mcpp::toolchain::is_clang(plan.toolchain)) { const auto& compiler = plan.toolchain.binaryPath; const auto nm = compiler.parent_path() - / (mcpp::platform::is_windows ? "llvm-nm.exe" : "llvm-nm"); + / (mcpplibs::os::is_windows ? "llvm-nm.exe" : "llvm-nm"); auto cxxArg = mcpp::modgraph::try_narrow(compiler); auto nmArg = mcpp::modgraph::try_narrow(nm); if (!cxxArg || !nmArg) @@ -3534,8 +3528,8 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, if (lu.kind == LinkUnit::SharedLibrary && !lu.exportPatterns.empty()) { const auto tr = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); const std::string os = tr ? tr->os - : (mcpp::platform::is_macos ? "macos" - : mcpp::platform::is_windows ? "windows" : "linux"); + : (mcpplibs::os::is_macos ? "macos" + : mcpplibs::os::is_windows ? "windows" : "linux"); const auto file = plan.outputDir / "obj" / (lu.targetName + ".exports.gen"); std::error_code mkec; @@ -4319,7 +4313,7 @@ std::optional check_inline_command_lengths(const std::string& manif if (auto lit = literalCommand.find(rule); lit != literalCommand.end()) measured = lit->second; auto over = mcpp::build::cmdlimits::check_inline( - measured, mcpp::platform::is_windows, /*needsShell=*/!mcpp::platform::is_windows); + measured, mcpplibs::os::is_windows, /*needsShell=*/!mcpplibs::os::is_windows); if (!over) continue; // The edge is named by its FIRST output and the count of the rest: @@ -4675,15 +4669,15 @@ std::expected NinjaBackend::build(const BuildPlan& plan // non-ASCII byte, because ASCII is the same text in both encodings; a // refusal here comes before the fast-path record is written, so the next // run asks again. - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { const bool nonAscii = std::ranges::any_of(manifest, [](char c) { return static_cast(c) >= 0x80; }); if (nonAscii) { - auto probe = mcpp::platform::process::capture_stdout( + auto probe = mcpp::run::capture_stdout( {ninjaProgram, "-t", "wincodepage"}); if (auto bad = ninja_encoding_mismatch( - probe.output, mcpp::platform::windows::active_code_page(), + probe.output, mcpplibs::os::windows::code_page(), ninjaProgram)) return std::unexpected(BuildError{*bad, ninja_path}); } @@ -4704,12 +4698,12 @@ std::expected NinjaBackend::build(const BuildPlan& plan if (!joined.empty()) joined += '\x1f'; joined += k; joined += '='; joined += v; }; - if (!mcpp::platform::is_linux) + if (!mcpplibs::os::is_linux) if (auto runtimeEnv = runtime_env_for_dirs(plan.toolchain.compilerRuntimeDirs)) add(runtimeEnv->first, runtimeEnv->second); for (auto& ev : plan.toolchain.envOverrides) add(ev.key, ev.value); r.runtimeEnvValue = std::move(joined); - } else if (auto runtimeEnv = mcpp::platform::is_linux ? std::nullopt + } else if (auto runtimeEnv = mcpplibs::os::is_linux ? std::nullopt : runtime_env_for_dirs(plan.toolchain.compilerRuntimeDirs)) { r.runtimeEnvKey = runtimeEnv->first; r.runtimeEnvValue = runtimeEnv->second; @@ -4743,9 +4737,9 @@ std::expected NinjaBackend::build(const BuildPlan& plan // Unset by default and unset in CI. It changes nothing but ninja's // verbosity, and it is spelled as a topic list rather than a boolean so // that `-d stats` and `-d keeprsp` are reachable without another variable. - if (const char* topics = std::getenv("MCPP_NINJA_DEBUG"); topics && *topics) { + if (auto topics = mcpp::invocation::get("MCPP_NINJA_DEBUG")) { nargv.push_back("-d"); - nargv.push_back(topics); + nargv.push_back(*topics); } if (plan.scheduleNinjaJobs > 0) nargv.push_back(std::format("-j{}", plan.scheduleNinjaJobs)); @@ -4767,7 +4761,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan std::vector> nenv; // On Linux the toolchain's library path prefixes its tools (`$cxx`, ...), // never ninja's environment (D29). - if (!mcpp::platform::is_linux) + if (!mcpplibs::os::is_linux) if (auto runtimeEnv = runtime_env_for_dirs(plan.toolchain.compilerRuntimeDirs)) nenv.emplace_back(runtimeEnv->first, runtimeEnv->second); for (auto& ev : plan.toolchain.envOverrides) @@ -4807,7 +4801,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan // even into the file of a build that runs this one (design §6.4). auto preEnv = nenv; preEnv.emplace_back(std::string(mcpp::build::progress::kStartsEnv), ""); - (void)mcpp::platform::process::capture_exec_deadline(pre, preEnv, preDeadline, nullptr); + (void)mcpp::run::capture_exec_deadline(pre, preEnv, preDeadline, nullptr); } stage("ninja-staged-cache"); } @@ -4839,9 +4833,9 @@ std::expected NinjaBackend::build(const BuildPlan& plan if (!opts.verbose && !report) scan.push_back("--quiet"); scan.insert(scan.end(), {std::string("-C"), plan.outputDir.string()}); if (opts.verbose) scan.push_back("-v"); - if (const char* topics = std::getenv("MCPP_NINJA_DEBUG"); topics && *topics) { + if (auto topics = mcpp::invocation::get("MCPP_NINJA_DEBUG")) { scan.push_back("-d"); - scan.push_back(topics); + scan.push_back(*topics); } if (plan.scheduleNinjaJobs > 0) scan.push_back(std::format("-j{}", plan.scheduleNinjaJobs)); @@ -4860,7 +4854,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan } else { auto scanEnv = nenv; scanEnv.emplace_back(std::string(mcpp::build::progress::kStartsEnv), ""); - auto cap = mcpp::platform::process::capture_exec_deadline( + auto cap = mcpp::run::capture_exec_deadline( scan, scanEnv, scanDeadline, &buildTimedOut); if (cap.exit_code != 0 || buildTimedOut) { out = std::move(cap.output); @@ -4887,7 +4881,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan } else { auto quietEnv = nenv; quietEnv.emplace_back(std::string(mcpp::build::progress::kStartsEnv), ""); - auto cap = mcpp::platform::process::capture_exec_deadline( + auto cap = mcpp::run::capture_exec_deadline( nargv, quietEnv, deadline, &buildTimedOut); out = std::move(cap.output); ninjaExit = cap.exit_code; @@ -4934,7 +4928,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan std::string runtimeFailure; std::filesystem::path runtimeFailureArtifact; for (auto const& checked : runtimeReport.artifacts) { - using Status = mcpp::platform::elf::RuntimeVerdict::Status; + using Status = mcpp::runtime::elf::RuntimeVerdict::Status; auto explanation = checked.verdict.explain(); // `blocking()` rather than a state list: a proven payload mismatch // and a DT_NEEDED the artifact's own loader will never find are @@ -5097,7 +5091,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan } std::string ninja_program_for(const mcpp::toolchain::Toolchain& tc) { - auto ninja_name = std::string("ninja") + std::string(mcpp::platform::exe_suffix); + auto ninja_name = std::string("ninja") + std::string(mcpplibs::os::exe_suffix); if (auto nb = mcpp::provider::beside_binary(tc.binaryPath, "ninja", ninja_name)) return nb->string(); return "ninja"; diff --git a/src/build/pe_exports.cppm b/src/build/pe_exports.cppm index ad10be6d..51acb5a5 100644 --- a/src/build/pe_exports.cppm +++ b/src/build/pe_exports.cppm @@ -5,8 +5,8 @@ export module mcpp.build.pe_exports; import std; import mcpp.build.coff_exports; import mcpp.modgraph.glob; -import mcpp.platform.fs; -import mcpp.platform.process; +import mcpplibs.os.fs; +import mcpp.run; import mcpp.toolchain.triple; export namespace mcpp::build::pe { @@ -114,7 +114,7 @@ bool export_directive(std::string text) { std::expected, std::string> read_object(const std::filesystem::path& path) { - std::ifstream in(mcpp::platform::fs::extended_length(path), std::ios::binary); + std::ifstream in(mcpplibs::os::fs::extended(path), std::ios::binary); if (!in) return std::unexpected("cannot read object"); std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); if (in.bad()) return std::unexpected("cannot finish reading object"); @@ -129,7 +129,7 @@ run_tool(const std::filesystem::path& tool, std::vector args) { auto name = mcpp::modgraph::try_narrow(tool); if (!name) return std::unexpected("the selected LLVM tool has no UTF-8 spelling"); args.insert(args.begin(), *name); - auto result = mcpp::platform::process::capture_exec(args); + auto result = mcpp::run::capture_exec(args); if (result.exit_code != 0) return std::unexpected(std::format("{} exited with status {}:\n{}", *name, result.exit_code, result.output)); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 1488e1a4..161f0c18 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -20,10 +20,11 @@ import mcpp.toolchain.fingerprint; import mcpp.toolchain.linkmodel; import mcpp.toolchain.triple; import mcpp.pack.prebuilt; // is_distribution_package — a shipped shared lib is not rebuilt -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; +import mcpp.runtime.search; +import mcpp.runtime.env_contract; import mcpp.runtime.binding; -import mcpp.platform.runtime_env_contract; -import mcpp.platform.runtime_search; import mcpp.provider; import mcpp.provider.select; import mcpp.home; @@ -498,7 +499,7 @@ struct BuildPlan { // One immutable snapshot selected before workspace member substitution. // Build/run/test and cache fast paths consume this value; none may re-read // xlings active/current state. - mcpp::platform::runtime::RuntimeBinding runtimeBinding; + mcpp::runtime::RuntimeBinding runtimeBinding; std::string cppStandard = "c++23"; std::string cppStandardFlag = "-std=c++23"; // Module-graph-global dialect flags (issue #210), pre-joined with a @@ -589,7 +590,7 @@ struct BuildPlan { mcpp::manifest::LinkIntent linkIntent; // The complete run-time search closure of the artifacts this plan will // produce, in loader order and tagged with where each directory came from - // (`mcpp.platform.runtime_search`). + // (`mcpp.runtime.search`). // // This is the RECORD — what resolution.json publishes and `mcpp why // runtime` explains. Emission is still owned by each origin's existing @@ -602,7 +603,7 @@ struct BuildPlan { // every child process including host binaries — measured to kill // `xdg-open`/`notify-send` outright when a private libc is on it. The farm // is reachable PER OBJECT (DT_RPATH) and must stay that way. - std::vector runtimeSearch; + std::vector runtimeSearch; // Windows runtime-DLL deployment. On PE (`supports_rpath` is false) a // directly-launched .exe cannot RUNPATH-locate a dependency's DLL, so each // *.dll found in a dependency's [runtime] library_dir is copied beside the @@ -719,14 +720,14 @@ void swap_link_group(BuildPlan& plan, BuildPlan::LinkGroup& g) { // providers and materialized artifacts before mcpp sees this snapshot. void merge_runtime_binding_contract( BuildPlan& plan, - const mcpp::platform::runtime::RuntimeBinding& binding); + const mcpp::runtime::RuntimeBinding& binding); // The run-time search closure for this plan, in loader order and tagged with // provenance. Exported so a test can exercise the guards (cross target, non-ELF // format, undeclared SubOS) without linking a binary for each. -std::vector runtime_search_closure( +std::vector runtime_search_closure( const BuildPlan& plan, - const mcpp::platform::runtime::RuntimeBinding& binding); + const mcpp::runtime::RuntimeBinding& binding); // Is `p` inside one of `roots`, judged LEXICALLY? // @@ -970,11 +971,11 @@ std::vector dependency_name_candidates( // triple, and never consulted directly when a target triple is present. mcpp::toolchain::triple::ArtifactNaming host_artifact_naming() { return { - .exeSuffix = mcpp::platform::exe_suffix, - .libPrefix = mcpp::platform::lib_prefix, - .staticLibExt = mcpp::platform::static_lib_ext, - .sharedLibExt = mcpp::platform::shared_lib_ext, - .sharedNeedsImportLib = mcpp::platform::is_windows, + .exeSuffix = mcpplibs::os::exe_suffix, + .libPrefix = mcpp::toolchain::platform::lib_prefix, + .staticLibExt = mcpp::toolchain::platform::static_lib_ext, + .sharedLibExt = mcpp::toolchain::platform::shared_lib_ext, + .sharedNeedsImportLib = mcpplibs::os::is_windows, }; } @@ -1080,7 +1081,7 @@ std::vector shared_library_link_flags( // used to answer this and missed iOS, which links with the same ld64 and // wants the same `@loader_path` -- `os == "ios"` would otherwise take the // ELF branch below and hand `$ORIGIN` to a linker that has no such token. - const bool macho = target.empty() ? bool(mcpp::platform::is_macos) + const bool macho = target.empty() ? bool(mcpplibs::os::is_macos) : target.is_mach_o(); if (pe) { flags.push_back(import_library_for(t, n).generic_string()); @@ -1313,11 +1314,11 @@ ResolvedRuntimeContract resolve_runtime_contract( // does. Farm-first would let a later install silently change which libc an // ALREADY LINKED artifact loads. `search::ordered` is what enforces it, and // e2e 219 asserts it on the produced ELF rather than on this code. -std::vector runtime_search_closure( +std::vector runtime_search_closure( const BuildPlan& plan, - const mcpp::platform::runtime::RuntimeBinding& binding) { - using mcpp::platform::search::Dir; - using mcpp::platform::search::Origin; + const mcpp::runtime::RuntimeBinding& binding) { + using mcpp::runtime::search::Dir; + using mcpp::runtime::search::Origin; // PAYLOAD DIRECTORIES COME FROM THE SAME FUNCTION THAT EMITS THEM. // @@ -1365,7 +1366,7 @@ std::vector runtime_search_closure( // mechanism neither format has. Asked of `object_format()` directly, the // single derivation, instead. const bool elfTarget = triple.empty() - ? bool(mcpp::platform::is_linux) + ? bool(mcpplibs::os::is_linux) : (triple.object_format() == mcpp::toolchain::triple::ObjectFormat::Elf); // THE ARTIFACT'S OWN DIRECTORY — `$ORIGIN` (#415). @@ -1419,12 +1420,12 @@ std::vector runtime_search_closure( for (auto const& dir : binding.searchDirs) closure.push_back({dir, Origin::SubosFarm}); - return mcpp::platform::search::ordered(std::move(closure)); + return mcpp::runtime::search::ordered(std::move(closure)); } void merge_runtime_binding_contract( BuildPlan& plan, - const mcpp::platform::runtime::RuntimeBinding& binding) { + const mcpp::runtime::RuntimeBinding& binding) { auto package_id = [](const mcpp::provider::runtime_info::PackageIdentity& value) { return mcpp::manifest::PackageId{ .namespace_ = value.namespace_, @@ -1658,7 +1659,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // Artifact naming and shared-library link shape are properties of the // TARGET. Resolved once here from tc.targetTriple (empty = host target, in // which case the host constants ARE the right answer) and threaded down, - // so nothing below reaches for mcpp::platform to describe an output. + // so nothing below reaches for the host to describe an output. const auto targetTriple = [&] { auto t = mcpp::toolchain::triple::parse(tc.targetTriple); return t ? *t : mcpp::toolchain::triple::Triple{}; @@ -1672,7 +1673,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // does not name, which is wrong the same way for iOS and for // wasm32-emscripten — see the sibling derivation earlier in this file. const bool elfTarget = targetTriple.empty() - ? bool(mcpp::platform::is_linux) + ? bool(mcpplibs::os::is_linux) : (targetTriple.object_format() == mcpp::toolchain::triple::ObjectFormat::Elf); auto loader_tag_flag = [&](LinkUnit::Kind kind) -> std::string { if (!elfTarget) return {}; @@ -1713,8 +1714,8 @@ make_plan(const mcpp::manifest::Manifest& manifest, // the same mechanism, and the answer to both is annotation. const bool msvcTarget = [&] { const std::string targetOs = targetTriple.empty() - ? (mcpp::platform::is_macos ? "macos" - : mcpp::platform::is_windows ? "windows" : "linux") + ? (mcpplibs::os::is_macos ? "macos" + : mcpplibs::os::is_windows ? "windows" : "linux") : targetTriple.os; // The ABI, from the toolchain rather than from `naming`: on a native // Windows build the host naming constants are the MSVC ones whatever the @@ -1799,7 +1800,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // same file reached through two graph edges) is not duplicated. // On a PE target a destination is compared without case // (DeployFile::is_destination). - const bool peTarget = targetTriple.empty() ? bool(mcpp::platform::is_windows) + const bool peTarget = targetTriple.empty() ? bool(mcpplibs::os::is_windows) : targetTriple.is_pe(); auto add_deploy = [&](const std::filesystem::path& source, std::string_view toDir = {}) { @@ -1892,8 +1893,8 @@ make_plan(const mcpp::manifest::Manifest& manifest, // target spawns is beyond mcpp's reach. Putting the directory in the // artifact's RUNPATH instead is: DT_RUNPATH reaches the object that // carries it and the dlopen() it performs, and nothing else. - if constexpr (mcpp::platform::publishes_via_environment( - mcpp::platform::kPrivateLibcSearchScope)) { + if constexpr (mcpp::runtime::publishes_via_environment( + mcpp::runtime::kPrivateLibcSearchScope)) { if (tc.base.payload && !out.depRuntimeLibraryDirs.empty()) { append_unique_path(out.runtimeLibraryDirs, tc.base.payload->glibcLib); @@ -2602,7 +2603,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, const auto unitDir = lu.output.parent_path().lexically_normal(); if (!naming.sharedNeedsImportLib && !unitDir.empty() && libDir != unitDir) { const auto rel = libDir.lexically_relative(unitDir).generic_string(); - const bool macho = targetTriple.empty() ? bool(mcpp::platform::is_macos) + const bool macho = targetTriple.empty() ? bool(mcpplibs::os::is_macos) : targetTriple.is_mach_o(); if (!rel.empty() && rel != ".") lu.linkFlags.push_back(macho diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 6ccfcb5b..186b999e 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -65,7 +65,7 @@ export import mcpp.build.prepare_inputs; import std; import mcpp.build.version_floor; -import mcpp.platform.axis; +import mcpp.home.files; import mcpp.manifest; import mcpp.source_kind; import mcpp.modgraph.glob; @@ -97,7 +97,6 @@ import mcpp.provider.select; import mcpp.provider.runtime_selection; import mcpp.runtime.binding; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.wire; // Severity, for PlanNote (#699 item 2, E3) import mcpp.bmi_cache; @@ -489,7 +488,7 @@ export struct BuildContext { mcpp::toolchain::Toolchain tc; mcpp::toolchain::Fingerprint fp; mcpp::provider::runtime::RuntimeSelection runtimeSelection; - mcpp::platform::runtime::RuntimeBinding runtimeBinding; + mcpp::runtime::RuntimeBinding runtimeBinding; std::filesystem::path projectRoot; // THE SOURCE TREES THIS BUILD READ THAT ARE NOT UNDER `projectRoot`. // @@ -544,7 +543,7 @@ export struct BuildContext { // dropped (`reset()`) before a program or a test runs. Shared because a // context is copied; the last copy releases it. Null for a planning pass // that describes (plan_only), which works in its own directory. - std::shared_ptr buildLock; + std::shared_ptr buildLock; std::filesystem::path stdBmi; std::filesystem::path stdObject; // plan_only: what the std module build WOULD be (sources are on the @@ -862,7 +861,7 @@ export struct BuildOverrides { // OS. A dependency's own [xlings].subos is never consulted or propagated. std::shared_ptr inherited_runtime_selection; - std::shared_ptr + std::shared_ptr inherited_runtime_binding; std::string target_triple; // empty = host triple, fall through to [toolchain] // --accel: the device backends and architectures this build targets, in diff --git a/src/build/prepare/base.cpp b/src/build/prepare/base.cpp index c5ee4950..3750bd91 100644 --- a/src/build/prepare/base.cpp +++ b/src/build/prepare/base.cpp @@ -18,13 +18,16 @@ import mcpp.config; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.manifest; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; +import mcpp.run; import mcpp.provider; import mcpp.runtime.binding; import mcpp.toolchain.base; import mcpp.toolchain.base_probe; import mcpp.toolchain.triple; import mcpp.ui; +import mcpp.invocation; namespace mcpp::build { @@ -140,8 +143,8 @@ base_statement(PrepareState& state, const mcpp::toolchain::triple::Triple& t) { // 1. Overrides (`--sysroot` is `MCPP_SYSROOT`). for (auto name : {target_env_name(t, "SYSROOT"), std::string("MCPP_SYSROOT")}) { - const char* e = std::getenv(name.c_str()); - if (auto v = override_value(e ? std::string_view(e) : std::string_view{})) + const auto e = mcpp::invocation::get(name); + if (auto v = override_value(e ? std::string_view(*e) : std::string_view{})) return settle(stamped(std::move(*v), "env", {}, 0, name)); } // 2. The project: the target row, then the toolchain's own statement. @@ -275,7 +278,7 @@ std::expected step2_resolve_base(PrepareState& state) { // host's directories itself: the binding names no private loader, so // closure validation models those directories. const bool hostBase = !hostDirs.empty(); - mcpp::platform::runtime::bind_target_base(target, { + mcpp::runtime::bind_target_base(target, { .runtimeId = tc.base.runtime == tb::Runtime::Binding && tc.base.libc == "glibc" && !tc.base.libcVersion.empty() ? std::format("glibc@{}", tc.base.libcVersion) : std::string{}, @@ -350,8 +353,8 @@ std::expected step2_resolve_base(PrepareState& state) { dir = *p; } const auto ecosystemLinux = tc.base.payload ? tc.base.payload->linuxInclude : fs::path{}; - const bool native = tt->os == "linux" && tt->arch == mcpp::platform::host_arch - && mcpp::platform::is_linux; + const bool native = tt->os == "linux" && tt->arch == mcpp::toolchain::platform::host_arch + && mcpplibs::os::is_linux; const bool staticLink = state.m->buildConfig.linkage == "static"; const auto probeDir = state.workRoot / "target" / ".mcpp-base"; @@ -370,7 +373,7 @@ std::expected step2_resolve_base(PrepareState& state) { fs::create_directories(probeDir, ec); { std::ofstream os(probeDir / "mcpp-base-probe.c"); os << "#include \n"; } v.insert(v.end(), {"-E", "-dM", "-x", "c", (probeDir / "mcpp-base-probe.c").string()}); - auto r = mcpp::platform::process::capture_stdout(v); + auto r = mcpp::run::capture_stdout(v); if (r.exit_code == 0) if (auto f = tb::classify_macros(tb::parse_defines(r.output))) { tc.base.libc = f->impl; @@ -501,8 +504,8 @@ std::expected step2_resolve_run_under(PrepareState& state) { record_source(state, std::move(d)); }; const auto envName = target_env_name(*tt, "RUN_UNDER"); - if (const char* e = std::getenv(envName.c_str()); e && *e) { - std::string_view v(e); + if (auto e = mcpp::invocation::get(envName)) { + std::string_view v(*e); if (v == "none") return {}; std::vector argv; std::string cur; diff --git a/src/build/prepare/config.cpp b/src/build/prepare/config.cpp index 97cefe32..f2345818 100644 --- a/src/build/prepare/config.cpp +++ b/src/build/prepare/config.cpp @@ -16,7 +16,7 @@ import mcpp.provider; import mcpp.provider.select; import mcpp.provider.address_set; import mcpp.build.version_floor; -import mcpp.platform.axis; +import mcpplibs.os.platform; import mcpp.manifest; import mcpp.source_kind; import mcpp.modgraph.glob; @@ -35,7 +35,6 @@ import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its mani import mcpp.build.build_program; import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build -import mcpp.platform; import mcpp.ui; import mcpp.project; @@ -493,7 +492,7 @@ std::vector previous_release_words(std::string element, bool define } i = j; } - return mcpp::manifest::host_command_words(line, mcpp::platform::is_windows); + return mcpp::manifest::host_command_words(line, mcpplibs::os::is_windows); } void report_flag_words_changes(const mcpp::manifest::Manifest& m) { @@ -538,7 +537,7 @@ void report_flag_words_changes(const mcpp::manifest::Manifest& m) { for (auto const& e : list) { auto now = mcpp::manifest::flag_words(e); auto before = e.starts_with("-L") || e.starts_with("-Wl,-rpath,") - ? mcpp::manifest::host_command_words(e, mcpp::platform::is_windows) + ? mcpp::manifest::host_command_words(e, mcpplibs::os::is_windows) : previous_release_words(e, false); if (now == before) continue; note_change(std::format( diff --git a/src/build/prepare/driver.cpp b/src/build/prepare/driver.cpp index 44710874..0e0ec7be 100644 --- a/src/build/prepare/driver.cpp +++ b/src/build/prepare/driver.cpp @@ -20,7 +20,7 @@ import mcpp.build.graph_shape; // #407: the graph says which mode wrote it import mcpp.build.build_program; import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build -import mcpp.platform; +import mcpp.effects; import mcpp.log; import mcpp.project; import mcpp.ui; @@ -85,7 +85,7 @@ prepare_build(bool print_fingerprint, BuildOverrides overrides) { // A run that plans writes the global cache (its declared effects say so), // including the answers a compiler probe and the provider's handshake keep. - mcpp::platform::env::note_writes_global_cache(); + mcpp::effects::note_writes_global_cache(); if (!overrides.toolchain_statement.empty()) return prepare_build_pass(print_fingerprint, includeDevDeps, std::move(extraTargets), std::move(overrides)); diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 280a54ec..92761ce1 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -12,7 +12,7 @@ import mcpp.log; import mcpp.build.refusal; import mcpp.build.version_floor; import mcpp.home; -import mcpp.platform.axis; +import mcpplibs.os.platform; import mcpp.manifest; import mcpp.source_kind; import mcpp.modgraph.glob; @@ -50,7 +50,6 @@ import mcpp.provider.select; import mcpp.provider.runtime_selection; import mcpp.runtime.binding; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.pm.resolver; import mcpp.pm.index_spec; import mcpp.pm.index_contract; @@ -62,6 +61,7 @@ import mcpp.pm.dependency_selector; import mcpp.pm.lock_io; import mcpp.ui; import mcpp.wire; // Severity, for PlanNote (#699 item 2, E3) +import mcpp.invocation; namespace mcpp::build { @@ -1359,8 +1359,8 @@ step6_resolve_tool_key(PrepareState& state, HostToolCtx& ctx) { // compiler this build compiles its build programs with. It // is handed to the sub-build as an override and recorded in // the key, so the two cannot disagree. - if (const char* e = std::getenv("MCPP_TOOLCHAIN"); e && *e) - ctx.toolTcSpec = e; + if (auto e = mcpp::invocation::get("MCPP_TOOLCHAIN")) + ctx.toolTcSpec = *e; else if (auto own = host_tool_declared_toolchain( depPkg.manifest, depPkg.root, kCurrentPlatform)) ctx.toolTcSpec = *own; @@ -1422,7 +1422,7 @@ step6_resolve_tool_key(PrepareState& state, HostToolCtx& ctx) { ctx.cacheRoot = mcpp::home::cache_root(); ctx.entry = mcpp::build::tool_store::entry_dir(ctx.cacheRoot, ctx.key); - const auto exeSuffix = std::string(mcpp::platform::exe_suffix); + const auto exeSuffix = std::string(mcpplibs::os::exe_suffix); ctx.binOut = mcpp::build::tool_store::bin_path( ctx.entry, ctx.toolName, exeSuffix); @@ -1523,7 +1523,7 @@ step6_build_tool(PrepareState& state, HostToolCtx& ctx) { const mcpp::provider::runtime::RuntimeSelection>( state.runtimeSelection); sub.inherited_runtime_binding = std::make_shared< - const mcpp::platform::runtime::RuntimeBinding>( + const mcpp::runtime::RuntimeBinding>( state.runtimeBindingSnapshot); sub.tool_chain = state.overrides.tool_chain.empty() ? std::format("root → {}:{}", ctx.depName, ctx.toolName) @@ -1585,8 +1585,7 @@ step6_build_tool(PrepareState& state, HostToolCtx& ctx) { // ninja's own progress and command echoes, which is right // for a normal build and wrong when the question is "what // did the inner build actually do". - if (const char* v = std::getenv("MCPP_TOOL_BUILD_VERBOSE"); - v && *v && std::string_view(v) != "0") + if (auto v = mcpp::invocation::get("MCPP_TOOL_BUILD_VERBOSE"); v && *v != "0") bopt.verbose = true; auto br = be->build(subCtx->plan, bopt); if (!br) { diff --git a/src/build/prepare/fetch.cpp b/src/build/prepare/fetch.cpp index eed014cc..17da94de 100644 --- a/src/build/prepare/fetch.cpp +++ b/src/build/prepare/fetch.cpp @@ -16,7 +16,8 @@ import std; import mcpp.build.refusal; import mcpp.build.version_floor; import mcpp.home; -import mcpp.platform.axis; +import mcpp.effects; +import mcpp.run; import mcpp.libs.json; import mcpp.log; import mcpp.manifest; @@ -33,7 +34,6 @@ import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.config; import mcpp.provider; import mcpp.provider.select; -import mcpp.platform; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.pm.resolver; @@ -79,9 +79,8 @@ bool is_local_git_remote(std::string_view url) { // stderr is folded in so a git warning cannot leak to the user's terminal; // the last line is taken so such a warning cannot corrupt the sha either. std::string git_cache_head(const std::filesystem::path& gitRoot) { - auto r = mcpp::platform::process::capture(std::format( - "git -C {} rev-parse HEAD 2>&1", - mcpp::platform::shell::quote(gitRoot.string()))); + auto r = mcpp::run::execute({ "git", "-C", gitRoot.string(), "rev-parse", "HEAD" }, + { .report_spawn_failure = false }); if (r.exit_code != 0) return {}; std::string out = r.output; while (!out.empty() && (out.back() == '\n' || out.back() == '\r' @@ -125,33 +124,34 @@ std::string git_cache_head(const std::filesystem::path& gitRoot) { // `between` runs after a failed attempt: the clone needs the partial directory // removed, or git's next attempt fails with "already exists and is not an empty // directory" — a second, different error that says nothing about the first. -mcpp::platform::process::RunResult run_with_network_retry( - std::string_view command, +mcpp::run::RunResult run_with_network_retry( + const std::vector& argv, const std::function& between, std::string_view progressLabel) { - mcpp::platform::process::RunResult r{}; - mcpp::platform::env::note_network_access(); // the envelope's `effects` (#648 A4) + mcpp::run::RunResult r{}; + mcpp::effects::note_network_access(); // the envelope's `effects` (#648 A4) // A clone runs for as long as the repository takes to arrive, and its // output was captured whole, so a large one showed nothing until it // finished. With a label, git's download phase is drawn with the renderer // every other acquisition uses (W11), each redraw read as it happens; the // output is still kept whole for the failure message. A clone that writes // nothing for fifteen minutes is stopped as stalled. - auto run_once = [&]() -> mcpp::platform::process::RunResult { - if (progressLabel.empty()) return mcpp::platform::process::capture(command); - mcpp::platform::process::RunResult out; + auto run_once = [&]() -> mcpp::run::RunResult { + if (progressLabel.empty()) return mcpp::run::execute(argv); + std::string output; std::optional bar; - bool timedOut = false; - out.exit_code = mcpp::platform::process::run_streaming_bounded(command, - [&](std::string_view line) { - out.output.append(line).push_back('\n'); + auto out = mcpp::run::execute(argv, { + .idle = std::chrono::minutes{15}, + .on_line = [&](std::string_view line) { + output.append(line).push_back('\n'); auto g = mcpp::fetcher::parse_git_progress(line); if (!g || g->phase != "Receiving objects") return; if (!bar) bar.emplace("Fetching", progressLabel); bar->update(g->percent); }, - std::chrono::milliseconds{0}, std::chrono::minutes{15}, &timedOut, - /*split_on_cr=*/true); + .split_on_cr = true }); + out.output = std::move(output) + out.output; + const bool timedOut = out.timed_out; if (bar) { if (out.exit_code == 0 && !timedOut) bar->finish(); else bar->finish_failed(progressLabel); @@ -305,10 +305,10 @@ provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, mcpp::log::verbose("xlings", std::format( "{}: recorded as provisioned in {}, but no payload is " "installed for: {}", label, stamp.string(), list)); - if (mcpp::platform::env::offline_mode() - || mcpp::platform::env::no_auto_install()) { + if (mcpp::effects::offline_mode() + || mcpp::effects::no_auto_install()) { std::string_view release = - mcpp::platform::env::offline_mode() + mcpp::effects::offline_mode() ? "drop --offline / unset MCPP_OFFLINE" : "unset MCPP_NO_AUTO_INSTALL"; refusal::record(refusal::Code::OfflineDownloadRequired); @@ -339,8 +339,8 @@ provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, // not the block: a project whose packages are already // provisioned still builds offline, which is the behaviour // that would otherwise regress. - if (mcpp::platform::env::offline_mode() - || mcpp::platform::env::no_auto_install()) { + if (mcpp::effects::offline_mode() + || mcpp::effects::no_auto_install()) { // THE ONE PLACE THE LEGACY STAMP IS TRUSTED, and the // reason is that relocating a record must not refuse a // build that worked yesterday. Every project that had @@ -358,7 +358,7 @@ provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, // verified anything. if (!legacy_stamp_matches()) { std::string_view release = - mcpp::platform::env::offline_mode() + mcpp::effects::offline_mode() ? "drop --offline / unset MCPP_OFFLINE" : "unset MCPP_NO_AUTO_INSTALL"; refusal::record(refusal::Code::OfflineDownloadRequired); diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 1c075ce4..cd6410f0 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -16,7 +16,7 @@ import mcpp.provider.select; import mcpp.provider.address_set; import mcpp.build.version_floor; import mcpp.home; -import mcpp.platform.axis; +import mcpp.effects; import mcpp.manifest; import mcpp.source_kind; import mcpp.modgraph.glob; @@ -46,7 +46,6 @@ import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.config; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.pm.resolver; import mcpp.pm.index_spec; import mcpp.pm.index_contract; @@ -1473,7 +1472,7 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { name, need, spec.git, why, verb)); }; const bool offline = - !remoteIsLocal && mcpp::platform::env::offline_mode(); + !remoteIsLocal && mcpp::effects::offline_mode(); // ── 1. which commit ── std::string resolvedGitRev = spec.gitRev; @@ -1502,11 +1501,8 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { "resolve"); // The FIRST network step of a git dependency, and therefore // the one a transient fault is most likely to meet. - auto r = run_with_network_retry(std::format( - "git ls-remote {} {} 2>&1", - mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote( - std::format("refs/heads/{}", spec.gitRev)))); + auto r = run_with_network_retry({ "git", "ls-remote", spec.git, + std::format("refs/heads/{}", spec.gitRev) }); if (r.exit_code != 0) return std::unexpected(std::format( "git ls-remote of '{}' failed:\n{}", @@ -1579,31 +1575,40 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { // between ls-remote and the clone ended the build with // "unable to read tree (787988c…)". // - // `git -C` rather than `cd &&`: on Windows `cd` does not - // change drive without /d, and the cache root routinely lives - // on a different one than the project. - const auto dir = mcpp::platform::shell::quote(gitRoot.string()); - const auto rev = mcpp::platform::shell::quote(resolvedGitRev); - auto cloneCmd = (spec.gitRefKind == "branch" && !fromLock) - ? std::format( - "git clone --progress --depth 1 --branch {0} {1} {2} && " - "(git -C {2} checkout --quiet {3} || " - "((git -C {2} fetch --quiet --depth 1 origin {3} || " - "git -C {2} fetch --quiet --unshallow origin) && " - "git -C {2} checkout --quiet {3})) 2>&1", - mcpp::platform::shell::quote(spec.gitRev), - mcpp::platform::shell::quote(spec.git), dir, rev) - : std::format( - "git clone --progress {} {} && git -C {} checkout --quiet {} 2>&1", - mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(resolvedGitRev)); + // `git -C` rather than a working directory: every step names + // the clone it acts on. + const auto dir = gitRoot.string(); + auto git_in = [&](std::vector args) { + args.insert(args.begin(), { "git", "-C", dir }); + return args; + }; + const bool tipJustRead = spec.gitRefKind == "branch" && !fromLock; + auto cloneArgv = tipJustRead + ? std::vector{ "git", "clone", "--progress", "--depth", "1", + "--branch", spec.gitRev, spec.git, dir } + : std::vector{ "git", "clone", "--progress", spec.git, dir }; // See `run_with_network_retry` for why, and for what the // callback is removing between attempts. - auto r = run_with_network_retry(cloneCmd, [&] { + auto r = run_with_network_retry(cloneArgv, [&] { std::filesystem::remove_all(gitRoot, ec); }, spec.git); + if (r.exit_code == 0) { + auto checkout = mcpp::run::execute(git_in({ "checkout", "--quiet", resolvedGitRev })); + if (checkout.exit_code != 0 && tipJustRead) { + // The commit by its name, else the branch's whole history. + auto fetched = run_with_network_retry( + git_in({ "fetch", "--quiet", "--depth", "1", "origin", resolvedGitRev })); + if (fetched.exit_code != 0) + fetched = run_with_network_retry( + git_in({ "fetch", "--quiet", "--unshallow", "origin" })); + if (fetched.exit_code == 0) + checkout = mcpp::run::execute(git_in({ "checkout", "--quiet", resolvedGitRev })); + else + checkout.output += fetched.output; + } + r.exit_code = checkout.exit_code; + r.output += checkout.output; + } if (r.exit_code != 0) { std::filesystem::remove_all(gitRoot, ec); return std::unexpected(std::format( diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp index ef896495..ee28f0e9 100644 --- a/src/build/prepare/graph_load.cpp +++ b/src/build/prepare/graph_load.cpp @@ -13,7 +13,8 @@ import mcpp.provider; import mcpp.provider.select; import mcpp.provider.address_set; import mcpp.build.version_floor; -import mcpp.platform.axis; +import mcpp.invocation; +import mcpp.effects; import mcpp.log; import mcpp.manifest; import mcpp.source_kind; @@ -31,7 +32,6 @@ import mcpp.build.build_program; import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.config; -import mcpp.platform; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.pm.resolver; @@ -683,7 +683,7 @@ static void step4a_define_candidate_selection_closures(PrepareState& state) { // Offline with no local copy of the index at all, the miss says // nothing about the package: nothing has been downloaded to look // in. That is a download the run needs, not a wrong selector. - if (mcpp::platform::env::offline_mode()) { + if (mcpp::effects::offline_mode()) { if (auto cfgO = state.get_cfg(true); cfgO && !mcpp::provider::open(mcpp::config::make_xlings_env(**cfgO))->default_index_status(0).present) { hint += "\n offline: the package index has never been fetched; " @@ -962,7 +962,7 @@ step4a_load_version_dep_fetch(PrepareState& state, LoadVersionDepCtx& ctx) { // package rather than surfacing a socket error from three layers // down. (The toolchain payload path has its own gate; this is the // dependency path, which does not go through resolve_xpkg_path.) - if (mcpp::platform::env::offline_mode()) { + if (mcpp::effects::offline_mode()) { refusal::record(refusal::Code::OfflineDownloadRequired); return std::unexpected(std::format( "offline mode: dependency '{}' v{} is not installed and " @@ -1051,12 +1051,12 @@ step4a_load_version_dep_fetch(PrepareState& state, LoadVersionDepCtx& ctx) { hookEnv.targetTriple = state.overrides.target_triple; // Six names, fixed by install_hook_env; one guard each. const auto hookVars = mcpp::build::install_hook_env(hookEnv); - mcpp::platform::env::ScopedEnv hookVar0(hookVars.at(0).first, hookVars.at(0).second); - mcpp::platform::env::ScopedEnv hookVar1(hookVars.at(1).first, hookVars.at(1).second); - mcpp::platform::env::ScopedEnv hookVar2(hookVars.at(2).first, hookVars.at(2).second); - mcpp::platform::env::ScopedEnv hookVar3(hookVars.at(3).first, hookVars.at(3).second); - mcpp::platform::env::ScopedEnv hookVar4(hookVars.at(4).first, hookVars.at(4).second); - mcpp::platform::env::ScopedEnv hookVar5(hookVars.at(5).first, hookVars.at(5).second); + mcpp::invocation::ScopedEnv hookVar0(hookVars.at(0).first, hookVars.at(0).second); + mcpp::invocation::ScopedEnv hookVar1(hookVars.at(1).first, hookVars.at(1).second); + mcpp::invocation::ScopedEnv hookVar2(hookVars.at(2).first, hookVars.at(2).second); + mcpp::invocation::ScopedEnv hookVar3(hookVars.at(3).first, hookVars.at(3).second); + mcpp::invocation::ScopedEnv hookVar4(hookVars.at(4).first, hookVars.at(4).second); + mcpp::invocation::ScopedEnv hookVar5(hookVars.at(5).first, hookVars.at(5).second); auto r = install_one(target); // The first address's failure is the one about the package; a // compat spelling tried after it fails as "not found" whatever diff --git a/src/build/prepare/host_graph.cpp b/src/build/prepare/host_graph.cpp index 51fc852a..86056eee 100644 --- a/src/build/prepare/host_graph.cpp +++ b/src/build/prepare/host_graph.cpp @@ -44,13 +44,14 @@ import mcpp.build.tool_store; import mcpp.libs.json; import mcpp.manifest; import mcpp.modgraph.scanner; -import mcpp.platform.fs; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.home.files; import mcpp.pm.mangle; import mcpp.toolchain.fingerprint; import mcpp.toolchain.triple; import mcpp.ui; import mcpp.version; +import mcpp.invocation; namespace mcpp::build { @@ -512,7 +513,7 @@ std::map resolution_pins(const PrepareState& state); static void place_program_copy(const PrepareState& state, std::size_t i, const fs::path& bin) { if (state.overrides.plan_only) return; // a planning pass writes nothing in the project const auto dest = build_program_dir(state, i) - / (mcpp::platform::is_windows ? "build.mcpp.exe" : "build.mcpp.bin"); + / (mcpplibs::os::is_windows ? "build.mcpp.exe" : "build.mcpp.bin"); std::error_code ec; const auto src = fs::last_write_time(bin, ec); if (ec) return; @@ -712,7 +713,7 @@ build_group(PrepareState& state, const fs::path& dir, const std::vector sub.inherited_runtime_selection = std::make_shared(state.runtimeSelection); sub.inherited_runtime_binding = - std::make_shared(state.runtimeBindingSnapshot); + std::make_shared(state.runtimeBindingSnapshot); sub.resolution_pins = resolution_pins(state); sub.build_program_graph = true; sub.project_index_root = state.projectIndexRoot(); @@ -775,8 +776,7 @@ build_group(PrepareState& state, const fs::path& dir, const std::vector mcpp::build::BuildOptions bopt; bopt.ninjaTargets = ninjaGoals; bopt.report = mcpp::build::BuildOptions::Report::Caller; - if (const char* v = std::getenv("MCPP_TOOL_BUILD_VERBOSE"); - v && *v && std::string_view(v) != "0") + if (auto v = mcpp::invocation::get("MCPP_TOOL_BUILD_VERBOSE"); v && *v != "0") bopt.verbose = true; auto br = be->build(ctx->plan, bopt); timer.mark("built"); @@ -964,8 +964,8 @@ ensure_build_programs(PrepareState& state, const std::vector& packa fs::create_directories(dir, ec); // Two configuration groups of one command may share a host toolchain // and a standard: one builds, the other waits and finds it built. - std::optional lock; - for (int waited = 0; !(lock = mcpp::platform::fs::FileLock::try_acquire(dir)); ++waited) { + std::optional lock; + for (int waited = 0; !(lock = mcpp::home::FileLock::try_acquire(dir)); ++waited) { if (waited == 0) mcpp::log::verbose("host-graph", std::format("waiting for {}", path_text(dir))); std::this_thread::sleep_for(std::chrono::milliseconds(50)); diff --git a/src/build/prepare/local_toolchain.cpp b/src/build/prepare/local_toolchain.cpp index 0ebc841b..4623c720 100644 --- a/src/build/prepare/local_toolchain.cpp +++ b/src/build/prepare/local_toolchain.cpp @@ -21,7 +21,8 @@ import mcpp.build.build_program; import mcpp.config; import mcpp.home; import mcpp.manifest; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.invocation; import mcpp.toolchain.registry; import mcpp.toolchain.triple; import mcpp.toolchain.post_install; @@ -262,7 +263,7 @@ step2_use_local_toolchain(PrepareState& state, const mcpp::toolchain::ToolchainS for (auto const& [role, p] : lt.tools) if (role == "cxx") driver = p; if (driver.empty()) driver = root / "bin" / (lt.prefix + (family == "llvm" ? "clang++" : "g++") - + std::string(mcpp::platform::exe_suffix)); + + std::string(mcpplibs::os::exe_suffix)); if (!fs::exists(driver, ec)) return refuse(std::format("no C++ driver at '{}'; a {} toolchain keeps it in " "`/bin/{}{}`, or names it with `tools = {{ cxx = ... }}`", @@ -331,7 +332,7 @@ std::expected step2_apply_local_toolchain(PrepareState& state if (!lt.launcher.empty()) { fs::path l(lt.launcher); if (!l.has_parent_path()) { - auto found = mcpp::platform::fs::which(lt.launcher); + auto found = mcpp::invocation::which(lt.launcher); if (!found) { refusal::record(refusal::Code::LocalToolchain); return std::unexpected(std::format( diff --git a/src/build/prepare/manifest.cpp b/src/build/prepare/manifest.cpp index 85869f77..c852a002 100644 --- a/src/build/prepare/manifest.cpp +++ b/src/build/prepare/manifest.cpp @@ -33,7 +33,6 @@ import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.provider; import mcpp.provider.select; import mcpp.provider.runtime_selection; -import mcpp.platform; import mcpp.pm.resolver; import mcpp.pm.index_spec; import mcpp.pm.index_contract; @@ -45,6 +44,7 @@ import mcpp.pm.dependency_selector; import mcpp.pm.lock_io; import mcpp.ui; import mcpp.project; +import mcpp.invocation; namespace mcpp::build { @@ -705,11 +705,11 @@ std::expected phase0_manifest_and_workspace(PrepareState& sta // the invocation that actually resolves the build. state.cacheMode = resolve_cache_mode(*state.m, state.overrides.cache_mode); { - const char* envMode = std::getenv("MCPP_BUILD_CACHE"); + const std::string envMode = mcpp::invocation::get("MCPP_BUILD_CACHE").value_or(std::string()); for (auto [value, origin] : std::initializer_list< std::pair>{ {state.overrides.cache_mode, "--cache"}, - {envMode ? envMode : "", "MCPP_BUILD_CACHE"}, + {envMode, "MCPP_BUILD_CACHE"}, {state.m->buildConfig.cacheMode, "[build] cache"}}) { if (value.empty() || parse_cache_mode(value)) continue; auto msg = std::format( diff --git a/src/build/prepare/options.cpp b/src/build/prepare/options.cpp index 7f67949d..2807fcee 100644 --- a/src/build/prepare/options.cpp +++ b/src/build/prepare/options.cpp @@ -33,7 +33,8 @@ import mcpp.build.build_program; import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.toolchain.post_install; -import mcpp.platform; +import mcpp.effects; +import mcpp.invocation; namespace mcpp::build { @@ -82,8 +83,8 @@ std::string_view cache_mode_name(CacheMode m) { std::string resolve_compile_commands(const mcpp::manifest::Manifest& m, std::string_view machineDefault) { - if (const char* e = std::getenv("MCPP_COMPILE_COMMANDS"); e && *e) { - const std::string_view v(e); + if (auto e = mcpp::invocation::get("MCPP_COMPILE_COMMANDS")) { + const std::string_view v(*e); return v == "off" || v == "none" ? std::string{} : std::string(v); } if (m.buildConfig.compileCommands) return *m.buildConfig.compileCommands; @@ -97,12 +98,12 @@ CacheMode resolve_cache_mode(const mcpp::manifest::Manifest& m, // is an optimisation, and refusing the build would deny more than asked. const auto mode = [&] { if (auto v = parse_cache_mode(override_mode)) return *v; - if (const char* e = std::getenv("MCPP_BUILD_CACHE"); e && *e) - if (auto v = parse_cache_mode(e)) return *v; + if (auto e = mcpp::invocation::get("MCPP_BUILD_CACHE")) + if (auto v = parse_cache_mode(*e)) return *v; if (auto v = parse_cache_mode(m.buildConfig.cacheMode)) return *v; return CacheMode::Global; }(); - if (mode == CacheMode::Global && mcpp::platform::env::effect_denied("write-global-cache")) + if (mode == CacheMode::Global && mcpp::effects::effect_denied("write-global-cache")) return CacheMode::Local; return mode; } diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 15431840..7dce6c01 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -18,8 +18,10 @@ import mcpp.runtime.elf; // DT_NEEDED of a library the line names import mcpp.build.refusal; import mcpp.build.version_floor; import mcpp.home; -import mcpp.platform.axis; -import mcpp.platform.env; // --deny write-project +import mcpplibs.os.platform; +import mcpp.invocation; +import mcpp.effects; +import mcpp.build.capacity; import mcpp.libs.json; import mcpp.log; import mcpp.manifest; @@ -46,7 +48,6 @@ import mcpp.build.plan; import mcpp.build.schedule.policy; import mcpp.build.flags; // compute_flags — the per-role contracts (#418) import mcpp.build.distribution; // dist::Role / dist::Contract to_string -import mcpp.platform.capacity; // the host fallback handed to schedule::decide import mcpp.build.graph_shape; // #407: the graph says which mode wrote it import mcpp.build.runtime_validation; // declared artifact -> identity verdict import mcpp.build.cache_key; @@ -63,9 +64,7 @@ import mcpp.config; import mcpp.provider; import mcpp.provider.select; import mcpp.runtime.binding; -import mcpp.platform.runtime_search; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.build.runner_lookup; import mcpp.fetcher; import mcpp.fetcher.progress; @@ -528,7 +527,7 @@ static std::expected step13_make_plan(PrepareState& state, Bu if (source.empty() || commands.empty()) return; auto inv = mcpp::build::recover_invocation( commands, source, state.tc->binaryPath, sm.cacheDir, - mcpp::platform::is_windows); + mcpplibs::os::is_windows); if (!inv) { state.planNotes.push_back({"MCPP_BUILD_DATABASE_STD_UNIT_UNDESCRIBED", std::format("no command that builds the {} module names its " @@ -617,7 +616,7 @@ static std::expected step13_cxx_islands(PrepareState& state, if (std::filesystem::exists(d / lib, ec)) { path = d / lib; break; } } if (path.empty() || !std::filesystem::exists(path, ec)) continue; - auto facts = mcpp::platform::elf::inspect_elf_runtime(path); + auto facts = mcpp::runtime::elf::inspect_elf_runtime(path); if (!facts) continue; for (auto const& n : facts->needed) if (rt::runtime_of_soname(n) == other) { @@ -688,8 +687,8 @@ static std::expected step13_cxx_private_runtime(PrepareState& auto const& bc = ctx.plan.manifest.buildConfig; const auto format = dist::format_for( state.tc->targetTriple, - mcpp::platform::is_windows ? dist::Format::Pe - : mcpp::platform::is_macos ? dist::Format::MachO + mcpplibs::os::is_windows ? dist::Format::Pe + : mcpplibs::os::is_macos ? dist::Format::MachO : dist::Format::Elf); const bool privateCopy = dist::stated_shared_library_contract(bc.cxxRuntime, bc.cxxRuntimeShared, @@ -775,8 +774,8 @@ static std::expected step13_cxx_process_runtime(PrepareState& auto const& bc = ctx.plan.manifest.buildConfig; const auto format = dist::format_for( state.tc->targetTriple, - mcpp::platform::is_windows ? dist::Format::Pe - : mcpp::platform::is_macos ? dist::Format::MachO + mcpplibs::os::is_windows ? dist::Format::Pe + : mcpplibs::os::is_macos ? dist::Format::MachO : dist::Format::Elf); const dist::CxxSharedLoad load{ .program = mcpp::build::image_loads_cxx_shared_library( @@ -1040,8 +1039,8 @@ static void step13_graph_and_schedule(PrepareState& state, BuildContext& ctx) { // and only when the user gave no job count — without it that // strategy ships `sched_cap = 0`, which disables the semaphore that // is its ONLY bound on how many compilers run at once. - mcpp::platform::capacity::recommended_jobs( - mcpp::platform::capacity::host_capacity())); + mcpp::build::capacity::recommended_jobs( + mcpp::build::capacity::host_capacity())); ctx.plan.scheduleTag = std::string(mcpp::build::schedule::to_string(decision.strategy)); ctx.plan.scheduleNinjaJobs = decision.ninjaJobs; ctx.plan.scheduleCompilerCap = decision.compilerCap; @@ -1190,7 +1189,7 @@ static std::expected step13_build_graph_actions(PrepareState& // wrapper's: a version change regenerates build.ninja, and a // binary moved under an unchanged version leaves a stale path, // exactly as it would for the compiler. - rep("${mcpp.self}", mcpp::platform::fs::self_exe_path().generic_string()); + rep("${mcpp.self}", mcpp::invocation::self_exe_path().generic_string()); // ABSOLUTE, unlike `${mcpp.target_file:}` and for the same reason // stated the other way round: the staged tree lives outside the // build directory and no ninja edge produces it, so there is no @@ -1501,7 +1500,7 @@ static std::expected step13_assembly_units(PrepareState& stat if (!ctx.plan.okldPath.empty()) break; } if (ctx.plan.okldPath.empty()) - if (auto found = mcpp::platform::fs::which(tool)) ctx.plan.okldPath = *found; + if (auto found = mcpp::invocation::which(tool)) ctx.plan.okldPath = *found; } // Only a build that links an image needs it: a library's archive is // objects, and the image that takes them is checked where it is linked. @@ -2157,8 +2156,8 @@ std::expected phase13_finish(PrepareState& state) { // pass that only describes (plan_only) plans into its own directory. // `--deny write-project` stops here, before the first write (design // 2026-10-26 §5.5). - if (!state.overrides.plan_only && mcpp::platform::env::effect_denied("write-project")) { - mcpp::platform::env::note_effect_refused("write-project", ctx.outputDir.generic_string()); + if (!state.overrides.plan_only && mcpp::effects::effect_denied("write-project")) { + mcpp::effects::note_effect_refused("write-project", ctx.outputDir.generic_string()); return std::unexpected(std::format( "effect denied: write-project -- the build writes {}, and the caller denied writing " "the project (--deny write-project)", ctx.outputDir.generic_string())); diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index eed2542b..7830d89e 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -11,7 +11,8 @@ import mcpp.build.stage; import mcpp.build.refusal; import mcpp.build.version_floor; import mcpp.home; -import mcpp.platform.axis; +import mcpp.invocation; +import mcpp.runtime.search; import mcpp.libs.json; import mcpp.log; import mcpp.manifest; @@ -38,7 +39,6 @@ import mcpp.build.plan; import mcpp.build.schedule.policy; import mcpp.build.flags; // compute_flags — the per-role contracts (#418) import mcpp.build.distribution; // dist::Role / dist::Contract to_string -import mcpp.platform.capacity; // the host fallback handed to schedule::decide import mcpp.build.graph_shape; // #407: the graph says which mode wrote it import mcpp.build.runtime_validation; // declared artifact -> identity verdict import mcpp.build.cache_key; @@ -54,9 +54,7 @@ import mcpp.config; import mcpp.provider; import mcpp.provider.select; import mcpp.runtime.binding; -import mcpp.platform.runtime_search; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.build.runner_lookup; import mcpp.fetcher; import mcpp.fetcher.progress; @@ -291,7 +289,7 @@ std::expected step13_lockfile(PrepareState& state, BuildConte // THE FAILURE NAMES THE DIFFERENCE. "The lock is out of date" is // true and useless; which package moved, from which version to // which, is what the reader does something about. - if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") { + if (mcpp::invocation::get("MCPP_LOCKED").value_or("") == "1") { auto prior = mcpp::lockfile::load(lockPath); if (!prior) { return std::unexpected(std::format( @@ -432,7 +430,7 @@ void step13_resolution_json(PrepareState& state, BuildContext& ctx) { }); } nlohmann::json binding = nlohmann::json::parse( - mcpp::platform::runtime::serialize_runtime_binding( + mcpp::runtime::serialize_runtime_binding( ctx.plan.runtimeBinding), nullptr, false); if (binding.is_discarded()) binding = nlohmann::json::object(); @@ -470,9 +468,9 @@ void step13_resolution_json(PrepareState& state, BuildContext& ctx) { closure.push_back({ {"path", dir.path.generic_string()}, {"origin", std::string( - mcpp::platform::search::to_string(dir.origin))}, + mcpp::runtime::search::to_string(dir.origin))}, {"machine_local", - mcpp::platform::search::is_machine_local(dir.origin)}, + mcpp::runtime::search::is_machine_local(dir.origin)}, }); } nlohmann::json search = { diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index 30fe8272..53e4081a 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -10,7 +10,8 @@ import std; import mcpp.diag; import mcpp.build.refusal; import mcpp.build.version_floor; -import mcpp.platform.axis; +import mcpp.toolchain.apple_sdk; +import mcpp.build.command_line; import mcpp.log; import mcpp.manifest; import mcpp.source_kind; @@ -47,9 +48,8 @@ import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.provider; import mcpp.provider.select; import mcpp.toolchain.post_install; -import mcpp.platform; -import mcpp.platform.macos; import mcpp.log; +import mcpp.invocation; namespace mcpp::build { @@ -115,8 +115,7 @@ static std::expected step11_scan_sources(PrepareState& state) } state.scan = [&] { - const char* sel = std::getenv("MCPP_SCANNER"); - if (sel && std::string_view(sel) == "p1689") { + if (mcpp::invocation::get("MCPP_SCANNER") == "p1689") { auto tmp = std::filesystem::temp_directory_path() / std::format("mcpp_p1689_{}", std::random_device{}()); std::filesystem::create_directories(tmp); @@ -495,7 +494,7 @@ step11_package_std_module_source(PrepareState& state) { // A flag naming a path is relative to the package that named it, // for the same reason the module source is. auto candidate = pkg.root / f; - flags += " " + mcpp::platform::shell::quote(std::filesystem::exists(candidate) ? candidate.string() : f); + flags += " " + mcpp::build::command_line::quote(std::filesystem::exists(candidate) ? candidate.string() : f); } // AND THE HEADERS THIS PACKAGE ITSELF IS BUILT AGAINST. // @@ -531,9 +530,9 @@ step11_package_std_module_source(PrepareState& state) { // reach both or the `std` BMI describes a different world than the // units importing it — which is mcpp#514 exactly. for (auto& d : state.targetSideUsage.includeDirs) - flags += " -isystem " + mcpp::platform::shell::quote(d.string()); + flags += " -isystem " + mcpp::build::command_line::quote(d.string()); for (auto& d : state.targetSideUsage.includeDirsAfter) - flags += " -idirafter " + mcpp::platform::shell::quote(d.string()); + flags += " -idirafter " + mcpp::build::command_line::quote(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 // macros are set, and the ones this package is built with are the ones @@ -573,22 +572,22 @@ step11_package_std_module_source(PrepareState& state) { auto cAbiTt = mcpp::toolchain::triple::parse(state.tc->targetTriple); return cAbiTt && cAbiTt->os == "macos"; }(); - hopt.macosDeploymentTarget = mcpp::platform::macos::deployment_target( + hopt.macosDeploymentTarget = mcpp::toolchain::apple::deployment_target( cAbiTargetIsMacos, state.m->buildConfig.macosDeploymentTarget); for (auto& t : mcpp::toolchain::host_compile_tokens( *state.tc, hopt, mcpp::toolchain::no_escape)) { - const auto q = " " + mcpp::platform::shell::quote(t); + const auto q = " " + mcpp::build::command_line::quote(t); if (flags.find(q) == std::string::npos) flags += q; } for (auto& t : mcpp::toolchain::apple_float_macro_words(*state.tc)) { - const auto q = " " + mcpp::platform::shell::quote(t); + const auto q = " " + mcpp::build::command_line::quote(t); if (flags.find(q) == std::string::npos) flags += q; } } // The same words the package's own units receive from this list // (mcpp.manifest.flag_words), one quoted word each. for (auto& w : mcpp::manifest::flag_words(state.targetSideUsage.cxxflags)) - flags += " " + mcpp::platform::shell::quote(w); + flags += " " + mcpp::build::command_line::quote(w); state.tc->stdModuleFlags = flags; break; } @@ -864,7 +863,7 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { // ensure_built would use, from the one derivation in stdmod.cppm. auto described = mcpp::toolchain::describe_std_module( *state.tc, state.m->package.standard, state.stdFlagAndDialect, - mcpp::platform::macos::deployment_target( + mcpp::toolchain::apple::deployment_target( stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), mcpp::toolchain::default_cache_root(), stdCrt, state.needsStdCompat); @@ -880,7 +879,7 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { } else { auto sm = mcpp::toolchain::ensure_built( *state.tc, state.m->package.standard, state.stdFlagAndDialect, - mcpp::platform::macos::deployment_target( + mcpp::toolchain::apple::deployment_target( stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), mcpp::toolchain::default_cache_root(), stdCrt, state.needsStdCompat); @@ -918,7 +917,7 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { // means the description is unavailable for the plan, silently. auto described = mcpp::toolchain::describe_std_module( *state.tc, state.m->package.standard, state.stdFlagAndDialect, - mcpp::platform::macos::deployment_target( + mcpp::toolchain::apple::deployment_target( stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), mcpp::toolchain::default_cache_root(), stdCrt, state.needsStdCompat); @@ -940,7 +939,7 @@ static void step11_public_module_check(PrepareState& state) { // The re-exports are read by the text scanner only; a P1689 scan reports // imports without saying which are `export import`, and every re-exported // module would then read as private. No reading, no warning. - if (const char* sel = std::getenv("MCPP_SCANNER"); sel && std::string_view(sel) == "p1689") + if (mcpp::invocation::get("MCPP_SCANNER") == "p1689") return; const auto& g = state.scan.graph; auto qualified = [](const mcpp::manifest::Manifest& m) { diff --git a/src/build/prepare/sources.cpp b/src/build/prepare/sources.cpp index aaf265f5..4e091910 100644 --- a/src/build/prepare/sources.cpp +++ b/src/build/prepare/sources.cpp @@ -21,7 +21,8 @@ import mcpp.config; import mcpp.diag; import mcpp.wire; import mcpp.manifest; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.invocation; import mcpp.ui; import mcpp.provider; import mcpp.provider.select; @@ -45,9 +46,8 @@ std::string origin_place(const SourceDecision& d, const fs::path& relativeTo) { if (!ec && !rel.empty() && !rel.generic_string().starts_with("..")) shown = rel.generic_string(); } - const char* home = std::getenv(mcpp::platform::is_windows ? "USERPROFILE" : "HOME"); - if (home && *home) { - const auto h = fs::path(home).generic_string(); + if (auto home = mcpp::invocation::get(mcpplibs::os::is_windows ? "USERPROFILE" : "HOME")) { + const auto h = fs::path(*home).generic_string(); if (shown.starts_with(h + "/")) shown = "~" + shown.substr(h.size()); } return d.originLine > 0 ? std::format("{}:{}", shown, d.originLine) : shown; @@ -142,8 +142,8 @@ payload_override(PrepareState& state, std::string_view key) { }; std::optional stated; const auto var = override_env_var(key); - if (const char* v = std::getenv(var.c_str()); v && *v) { - std::string value(v); + if (auto v = mcpp::invocation::get(var)) { + std::string value(*v); Stated s{.originKind = "env", .originKey = var, .base = fs::current_path()}; if (value.starts_with("path:")) { s.kind = "program"; value = value.substr(5); } s.value = std::move(value); @@ -212,7 +212,7 @@ payload_override(PrepareState& state, std::string_view key) { // later step runs and the one the `Using` line names, so a reader can // see which PATH entry served it. Without a stated version it is the // one class of source mcpp cannot describe beyond its location. - auto found = mcpp::platform::fs::which(stated->value); + auto found = mcpp::invocation::which(stated->value); if (!found) return refuse(std::format("'{}' is not found on PATH", stated->value)); out.program = found->generic_string(); out.root = root_of_program(*found).generic_string(); @@ -626,8 +626,8 @@ std::expected step13_sources(PrepareState& state, BuildContex } std::optional managed_only_refusal(const PrepareState& state) { - const char* env = std::getenv("MCPP_MANAGED_ONLY"); - const bool on = state.overrides.managed_only || (env && *env && std::string_view(env) != "0"); + const auto env = mcpp::invocation::get("MCPP_MANAGED_ONLY"); + const bool on = state.overrides.managed_only || (env && *env != "0"); if (!on) return std::nullopt; std::string list; const auto root = state.root ? *state.root : fs::path{}; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 4ad52670..b36b70f3 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -18,7 +18,8 @@ import mcpp.build.prepare_inputs; import std; import mcpp.targetside; import mcpp.build.version_floor; -import mcpp.platform.axis; +import mcpp.toolchain.platform; +import mcpp.run; import mcpp.libs.json; import mcpp.manifest; import mcpp.source_kind; @@ -58,7 +59,6 @@ import mcpp.provider.runtime_selection; import mcpp.provider.address_set; import mcpp.runtime.binding; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.pm.resolver; import mcpp.pm.index_spec; import mcpp.pm.index_contract; @@ -77,7 +77,7 @@ namespace mcpp::build { // so it lives at file scope rather than in PrepareState: nothing about it // depends on any phase's state, and every phase that reads it (P1's // `tcSpecSource` included) can just name it. -constexpr std::string_view kCurrentPlatform = mcpp::platform::name; +constexpr std::string_view kCurrentPlatform = mcpp::toolchain::platform::name; // Namespace aliases used across several phases (P4's own declarations and // P7/P9's reuse of its helpers); hoisted here so every phase function sees @@ -535,7 +535,7 @@ struct PrepareState { std::optional cfg_opt; bool bootstrap_checked = false; std::function(bool)> get_cfg; - mcpp::platform::runtime::RuntimeBinding runtimeBindingSnapshot; + mcpp::runtime::RuntimeBinding runtimeBindingSnapshot; std::string projectSubosBin; std::string runtimePayload; std::filesystem::path runtimeLibDir; @@ -553,7 +553,7 @@ struct PrepareState { bool windowsGnuFirstRun = false; std::function resolvedAccel; std::function cfgCtx; - std::optional targetPlatform; + std::optional targetPlatform; bool abiThreadsRendered = false; std::function&, std::string_view)> add_once; std::optional tc; @@ -641,7 +641,7 @@ struct PrepareState { // The artifact's runtime when a stated base re-derived it (§4.2 step 6). // `runtimeBindingSnapshot` stays what the COMPILER runs on and what a // host sub-build inherits; the plan and the context read this one. - std::optional targetRuntimeBinding; + std::optional targetRuntimeBinding; // The execution host's wrapper for this target (§3.7), with its origin. std::vector runUnder; SourceDecision runUnderOrigin; @@ -972,8 +972,8 @@ std::string min_platform_version(const mcpp::manifest::Manifest& m, std::string git_cache_head(const std::filesystem::path& gitRoot); // `progressLabel`, when given, draws git's `--progress` download phase as // one bar labelled with it (W11); the command must pass `--progress`. -mcpp::platform::process::RunResult run_with_network_retry( - std::string_view command, +mcpp::run::RunResult run_with_network_retry( + const std::vector& argv, const std::function& between = {}, std::string_view progressLabel = {}); std::vector diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 4cc6cfef..380c4cc7 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -12,7 +12,7 @@ import mcpp.diag; import mcpp.build.refusal; import mcpp.build.version_floor; import mcpp.home; -import mcpp.platform.axis; +import mcpp.toolchain.platform; import mcpp.libs.json; import mcpp.log; import mcpp.manifest; @@ -21,7 +21,6 @@ import mcpp.modgraph.glob; import mcpp.graph; import mcpp.build.progress; // the members' programs wait in order (design 2026-09-29 §4.2) import mcpp.build.schedule.policy; // resolve_jobs — how many programs compile at once (#748, B2) -import mcpp.platform.capacity; // the host fallback when no job count was stated import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.modgraph.validate; @@ -56,7 +55,6 @@ import mcpp.build.build_program; import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.ui; import mcpp.log; import mcpp.wire; // Severity, for PlanNote (#699 item 2, E3) @@ -1468,7 +1466,7 @@ static std::expected step9_dependency_link_forms(PrepareState targetFacts.fullStaticLibc = state.m->buildConfig.linkage == "static" && mcpp::toolchain::target_supports_full_static( - state.tc->targetTriple, mcpp::platform::supports_full_static); + state.tc->targetTriple, mcpp::toolchain::platform::supports_full_static); for (std::size_t i = 1; i < state.packages.size(); ++i) { auto const& pkg = state.packages[i].manifest; diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index b167dd26..13a46e64 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -12,7 +12,11 @@ import mcpp.build.engine_package; import mcpp.diag; import mcpp.build.refusal; import mcpp.build.version_floor; -import mcpp.platform.axis; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; +import mcpp.toolchain.apple_sdk; +import mcpp.effects; +import mcpp.build.command_line; import mcpp.manifest; import mcpp.source_kind; import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses @@ -50,11 +54,10 @@ import mcpp.provider.select; import mcpp.provider.runtime_selection; import mcpp.runtime.binding; import mcpp.toolchain.post_install; -import mcpp.platform; -import mcpp.platform.macos; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.ui; +import mcpp.invocation; namespace mcpp::build { @@ -159,7 +162,7 @@ static std::expected step1_define_early_toolchain_closures(Pr } else { auto cfgRuntime = state.get_cfg(true); if (!cfgRuntime) return std::unexpected(cfgRuntime.error()); - auto resolved = mcpp::platform::runtime::resolve_runtime_binding( + auto resolved = mcpp::runtime::resolve_runtime_binding( state.runtimeSelection, {}, (**cfgRuntime).xlingsHome()); if (!resolved) return std::unexpected(resolved.error()); state.runtimeBindingSnapshot = std::move(*resolved); @@ -388,8 +391,8 @@ static std::expected step1_define_early_toolchain_closures(Pr // What the manifest declared, before the command line replaces it: D11 // says so when the two differ. const auto manifestSpec = state.tcSpec; - if (const char* tcEnv = std::getenv("MCPP_TOOLCHAIN"); tcEnv && *tcEnv) { - state.tcSpec = std::string(tcEnv); + if (auto tcEnv = mcpp::invocation::get("MCPP_TOOLCHAIN")) { + state.tcSpec = *tcEnv; state.tcOrigin = TcOrigin::ManifestToolchain; state.tcFromCommandLine = true; } @@ -456,14 +459,14 @@ static std::expected step1_define_early_toolchain_closures(Pr // mirrors the windows-gnu seed below, which this function's other caller // runs after. state.native_first_run_spec = [&]() -> std::string { - const bool msvcUsable = mcpp::platform::is_windows + const bool msvcUsable = mcpplibs::os::is_windows && state.msvc_usable_either_origin(); return std::string( mcpp::toolchain::triple::pins::host_default_toolchain(msvcUsable)); }; state.windowsGnuFirstRun = false; - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { if (!state.tcSpec.has_value() && state.overrides.target_triple.empty() && state.m->buildConfig.target.empty() && !state.msvc_usable_either_origin()) { @@ -700,7 +703,7 @@ step1_apple_sdk_check(PrepareState& state, TargetOverrideCtx& ctx) { if (!stmt) return std::unexpected(stmt.error()); if (stmt->form == mcpp::toolchain::base::Form::Locator || stmt->form == mcpp::toolchain::base::Form::Path) { - auto sdk = mcpp::toolchain::base::locate_apple(*stmt, mcpp::platform::macos::sdk_macos); + auto sdk = mcpp::toolchain::base::locate_apple(*stmt, mcpp::toolchain::apple::sdk_macos); if (!sdk) { refusal::record(refusal::Code::AppleSdkAbsent); return std::unexpected(std::format( @@ -713,8 +716,8 @@ step1_apple_sdk_check(PrepareState& state, TargetOverrideCtx& ctx) { } if (ctx.parsed && ctx.parsed->is_ios()) { const auto which = ctx.parsed->is_ios_simulator() - ? mcpp::platform::macos::sdk_iphonesim - : mcpp::platform::macos::sdk_iphoneos; + ? mcpp::toolchain::apple::sdk_iphonesim + : mcpp::toolchain::apple::sdk_iphoneos; auto stmt = base_statement(state, *ctx.parsed); if (!stmt) return std::unexpected(stmt.error()); mcpp::toolchain::base::Statement asked = *stmt; @@ -756,8 +759,8 @@ step1_apple_sdk_check(PrepareState& state, TargetOverrideCtx& ctx) { const auto slice = std::format("{}-ios{}", ctx.parsed->arch == "aarch64" ? "arm64" : ctx.parsed->arch, ctx.parsed->is_ios_simulator() ? "-simulator" : ""); - if (auto has = mcpp::platform::macos::sdk_has_target(*state.appleSdkLocated, slice); - has && !*has) { + if (mcpp::toolchain::apple::sdk_has_target(*state.appleSdkLocated, slice) + == mcpp::toolchain::apple::Slice::absent) { refusal::record(refusal::Code::AppleSdkAbsent); return std::unexpected(std::format( "target {}: the {} SDK at {} carries no {} slice (its libSystem.tbd " @@ -1204,7 +1207,7 @@ static std::expected step1_device_axis_and_layer_merge(Prepar c.accelerators.push_back(set.backend); return c; }; - state.targetPlatform = mcpp::platform::TargetPlatform::for_os(state.cfgCtx().os); + state.targetPlatform = mcpp::toolchain::platform::TargetPlatform::for_os(state.cfgCtx().os); // ── L1: merge conditional [target.'cfg(...)'] sections ─────────────────── // Evaluated now (target resolved) against the resolved target — the @@ -1381,7 +1384,7 @@ step2_parse_toolchain_spec(PrepareState& state, ToolchainResolveCtx& ctx) { // of that version unpacks the same installer payloads Visual Studio does, // so an installed copy is the same toolset without a download. `xim:` // opts out: it asks for the package, whose SDK is pinned with it. - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { if (ctx.parsedSpec && !state.tcSpecIsMsvc && ctx.parsedSpec->family == mcpp::toolchain::Family::Msvc && !ctx.parsedSpec->ecosystemOnly && !ctx.parsedSpec->version.empty()) @@ -1403,7 +1406,7 @@ step2_use_installed_pin(PrepareState& state, ToolchainResolveCtx& ctx) { static std::expected step2_use_system_msvc(PrepareState& state) { - if (!mcpp::platform::is_windows) { + if (!mcpplibs::os::is_windows) { return std::unexpected(std::format( "toolchain '{}' is only available on Windows hosts", *state.tcSpec)); } @@ -1646,14 +1649,14 @@ step2_offline_refusal(PrepareState& state) { // Name the knob that actually fired, not a fixed one: telling a user // who passed `--offline` to unset MCPP_NO_AUTO_INSTALL sends them // looking for a variable they never set. - std::string_view release = mcpp::platform::env::offline_mode() + std::string_view release = mcpp::effects::offline_mode() ? "or drop --offline / unset MCPP_OFFLINE to let mcpp auto-install." : "or unset MCPP_NO_AUTO_INSTALL to let mcpp auto-install."; // Windows without a usable MSVC must not be told to install llvm: // that default resolves to clang targeting the MSVC ABI, which is // exactly what this machine cannot build. Name the toolchain that // will actually work there instead. - if (mcpp::platform::is_windows + if (mcpplibs::os::is_windows && !state.msvc_usable_either_origin()) { refusal::record(refusal::Code::OfflineDownloadRequired); return std::unexpected(std::format( @@ -1666,7 +1669,7 @@ step2_offline_refusal(PrepareState& state) { pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget, pins::kFirstRunWinGnu, pins::kFirstRunWinGnuTarget, release)); } - if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_macos || mcpplibs::os::is_windows) { refusal::record(refusal::Code::OfflineDownloadRequired); return std::unexpected(std::format( "no toolchain configured.\n" @@ -1723,7 +1726,7 @@ step2_first_run_auto_install(PrepareState& state) { bool muslDefault = defaultParsed->target.is_musl(); auto defaultPkg = mcpp::toolchain::to_xim_package(*defaultParsed); - if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_macos || mcpplibs::os::is_windows) { mcpp::ui::info("First run", std::format("no toolchain configured — installing {} (LLVM/Clang) as default", defaultSpec)); @@ -2104,7 +2107,7 @@ step2_retarget_for_retargetable_driver(PrepareState& state) { // a path with a space in it. state.tc->stdModuleTargetFlags = " " + state.tc->crossTargetFlag - + " -isysroot " + mcpp::platform::shell::quote(sdk->string()); + + " -isysroot " + mcpp::build::command_line::quote(sdk->string()); } } } @@ -2259,13 +2262,13 @@ step2_windows_runtime_identity(PrepareState& state) { // `glibc@`: ucrtbase.dll is an OS component and mcpp ships no // redistributable for it. See mcpp.runtime.binding. if (!state.tc->base.msvc.sdkVersion.empty()) { - mcpp::platform::runtime::bind_windows_ucrt( + mcpp::runtime::bind_windows_ucrt( state.runtimeBindingSnapshot, state.tc->base.msvc.sdkVersion); state.tc->runtimeContractHash = state.runtimeBindingSnapshot.contractHash; // And the artifact's runtime, when a stated base re-derived it: // the floor and the base's identity are both part of its contract. if (state.targetRuntimeBinding) { - mcpp::platform::runtime::bind_windows_ucrt( + mcpp::runtime::bind_windows_ucrt( *state.targetRuntimeBinding, state.tc->base.msvc.sdkVersion); state.tc->runtimeContractHash = state.targetRuntimeBinding->contractHash; } @@ -2314,9 +2317,9 @@ step2_msvc_abi_without_msvc_repair(PrepareState& state) { !tc_origin_is_user_explicit(state.tcOrigin) && tc_origin_may_persist(state.tcOrigin) && !userChoseMsvcItself - && !mcpp::platform::env::offline_mode() - && !mcpp::platform::env::no_auto_install() - && mcpp::platform::is_windows; + && !mcpp::effects::offline_mode() + && !mcpp::effects::no_auto_install() + && mcpplibs::os::is_windows; if (!mayRepair) { return std::unexpected(msvc_unavailable_guidance(*state.tc)); } @@ -2422,8 +2425,8 @@ std::expected phase2_define_toolchain_resolver(PrepareState& if (auto r = step2_resolve_explicit_spec(state, ctx); !r) return std::unexpected(r.error()); } else if (state.tcSpec.has_value() && *state.tcSpec == "system") { if (auto r = step2_system_toolchain_refusal(state); !r) return std::unexpected(r.error()); - } else if (mcpp::platform::env::offline_mode() - || mcpp::platform::env::no_auto_install()) { + } else if (mcpp::effects::offline_mode() + || mcpp::effects::no_auto_install()) { if (auto r = step2_offline_refusal(state); !r) return std::unexpected(r.error()); } else { if (auto r = step2_first_run_auto_install(state); !r) return std::unexpected(r.error()); diff --git a/src/build/prepare/toolchain_decision.cpp b/src/build/prepare/toolchain_decision.cpp index ba710238..7981be6d 100644 --- a/src/build/prepare/toolchain_decision.cpp +++ b/src/build/prepare/toolchain_decision.cpp @@ -33,7 +33,6 @@ import mcpp.build.build_program; import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.toolchain.post_install; -import mcpp.platform; namespace mcpp::build { diff --git a/src/build/prepare/toolchain_env.cpp b/src/build/prepare/toolchain_env.cpp index 70e9861b..e17a95e4 100644 --- a/src/build/prepare/toolchain_env.cpp +++ b/src/build/prepare/toolchain_env.cpp @@ -11,7 +11,9 @@ import mcpp.diag; import std; import mcpp.build.version_floor; -import mcpp.platform.axis; +import mcpp.toolchain.platform; +import mcpp.toolchain.apple_sdk; +import mcpp.runtime.launch_env; import mcpp.manifest; import mcpp.source_kind; import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses @@ -44,8 +46,6 @@ import mcpp.provider; import mcpp.provider.select; import mcpp.build.resources; // the resource compiler the row uses (#734 E2) import mcpp.toolchain.post_install; -import mcpp.platform; -import mcpp.platform.macos; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.ui; @@ -345,8 +345,8 @@ std::string msvc_arch_of(std::string_view triple) { // preferring the host's own architecture as the engine's toolset search does. std::filesystem::path msvc_bin_dir(const std::filesystem::path& tools, std::string_view arch) { std::error_code ec; - const bool armHost = mcpp::platform::host_arch == std::string_view("aarch64") - || mcpp::platform::host_arch == std::string_view("arm64"); + const bool armHost = mcpp::toolchain::platform::host_arch == std::string_view("aarch64") + || mcpp::toolchain::platform::host_arch == std::string_view("arm64"); const std::array hosts = armHost ? std::array{"Hostarm64", "Hostx64", "Hostx86"} : std::array{"Hostx64", "Hostarm64", "Hostx86"}; @@ -484,7 +484,7 @@ void fill_target_build_env(mcpp::build::BuildProgramEnv& e, if (cfg) { const auto view = mcpp::provider::open(mcpp::config::make_xlings_env(*cfg))->sysroot(); e.pkgConfigLibdir = (view / "usr" / "lib" / "pkgconfig").generic_string() - + mcpp::platform::env::path_list_separator() + + mcpp::runtime::launch_env::path_list_separator() + (view / "usr" / "share" / "pkgconfig").generic_string(); } e.toolchainDir = (tc && !tc->binaryPath.empty()) @@ -614,7 +614,7 @@ std::string min_platform_version(const mcpp::manifest::Manifest& m, // machine mcpp runs on. A non-Apple target (Linux, Windows, wasm, // freestanding) answers empty here, same as it always has. if (t.os == "macos") - return mcpp::platform::macos::deployment_target( + return mcpp::toolchain::apple::deployment_target( /*targetIsMacos=*/true, m.buildConfig.macosDeploymentTarget); return {}; } diff --git a/src/build/prepare/windows_resources.cpp b/src/build/prepare/windows_resources.cpp index 098fde0d..7988f52c 100644 --- a/src/build/prepare/windows_resources.cpp +++ b/src/build/prepare/windows_resources.cpp @@ -12,7 +12,6 @@ import mcpp.build.stage; import mcpp.build.refusal; import mcpp.build.version_floor; import mcpp.home; -import mcpp.platform.axis; import mcpp.libs.json; import mcpp.log; import mcpp.manifest; @@ -36,7 +35,6 @@ import mcpp.build.plan; import mcpp.build.schedule.policy; import mcpp.build.flags; // compute_flags — the per-role contracts (#418) import mcpp.build.distribution; // dist::Role / dist::Contract to_string -import mcpp.platform.capacity; // the host fallback handed to schedule::decide import mcpp.build.graph_shape; // #407: the graph says which mode wrote it import mcpp.build.runtime_validation; // declared artifact -> identity verdict import mcpp.build.cache_key; @@ -53,9 +51,7 @@ import mcpp.config; import mcpp.provider; import mcpp.provider.select; import mcpp.runtime.binding; -import mcpp.platform.runtime_search; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.build.runner_lookup; import mcpp.fetcher; import mcpp.fetcher.progress; diff --git a/src/build/prepare/xlings.cpp b/src/build/prepare/xlings.cpp index bc18afbe..4445f338 100644 --- a/src/build/prepare/xlings.cpp +++ b/src/build/prepare/xlings.cpp @@ -38,7 +38,6 @@ import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.config; import mcpp.toolchain.post_install; -import mcpp.platform; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.pm.resolver; diff --git a/src/build/prepare_inputs.cppm b/src/build/prepare_inputs.cppm index 2ed98435..05985611 100644 --- a/src/build/prepare_inputs.cppm +++ b/src/build/prepare_inputs.cppm @@ -23,7 +23,7 @@ import mcpp.diag; import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; -import mcpp.platform; +import mcpp.toolchain.apple_sdk; import mcpp.toolchain.model; import mcpp.toolchain.fingerprint; import mcpp.toolchain.triple; @@ -548,7 +548,7 @@ std::string canonical_configuration_flags(const mcpp::manifest::Manifest& root, std::string s; s += "-std="; s += root.package.standard; s += " -fmodules"; - if (auto dtv = mcpp::platform::macos::deployment_target( + if (auto dtv = mcpp::toolchain::apple::deployment_target( targetIsMacos, root.buildConfig.macosDeploymentTarget); !dtv.empty()) { s += " macos_deployment_target="; diff --git a/src/build/progress.cppm b/src/build/progress.cppm index 0875d89f..77b637dd 100644 --- a/src/build/progress.cppm +++ b/src/build/progress.cppm @@ -36,7 +36,9 @@ import std; import mcpp.ui; import mcpp.ui.dots_screen; import mcpp.log; -import mcpp.platform; +import mcpp.invocation; +import mcpplibs.os.fs; +import mcpp.ui.terminal; export namespace mcpp::build::progress { @@ -656,14 +658,14 @@ std::vector parse_starts(std::string_view text, std::size_t* consum } void record_action_start(std::string_view stamp) { - const char* file = std::getenv(std::string(kStartsEnv).c_str()); - if (!file || !*file || stamp.empty()) return; + const auto file = mcpp::invocation::get(kStartsEnv); + if (!file || stamp.empty()) return; const auto now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); // One line, one write, in append mode: the lines of actions that start at // the same time do not interleave. - (void)mcpp::platform::fs::append_atomically( - std::filesystem::path(file), std::format("{}\t{}\n", field(stamp), now)); + (void)mcpplibs::os::fs::append( + std::filesystem::path(*file), std::format("{}\t{}\n", field(stamp), now)); } // ─── The model ─────────────────────────────────────────────────────────── @@ -763,7 +765,7 @@ struct Report { // reads. The game is also `animation`'s place on the screen. std::unique_ptr game; std::string gameName; - std::unique_ptr keys; + std::unique_ptr keys; std::vector started; // packages announced since the last frame long long lastFrame = 0; // ms since command start std::size_t lastDone = 0; @@ -1133,7 +1135,7 @@ mcpp::ui::Frame frame() { r.lastDone = done; // The screen takes 25 columns; a terminal narrower than 60 keeps the // counts and the clock instead. - if (mcpp::platform::terminal::cols(mcpp::ui::narration_stream()) >= 60) { + if (mcpp::ui::terminal::cols(mcpp::ui::narration_stream()) >= 60) { screen::Screen sc; r.animation->draw(sc); cells = sc.render(r.animationColour); @@ -1151,7 +1153,7 @@ void poll() { std::lock_guard lock(r.m); if (r.game && r.keys) for (auto k : r.keys->read()) { - using TK = mcpp::platform::terminal::Key; + using TK = mcpp::ui::terminal::Key; r.game->key(k == TK::Up ? screen::Key::Up : k == TK::Down ? screen::Key::Down : k == TK::Left ? screen::Key::Left @@ -1169,14 +1171,14 @@ void poll() { // an animation's name; `plain` keeps the row without a screen; `off` draws no // live row at all. The screen needs a terminal that draws braille. std::unique_ptr choose_animation() { - std::string want = mcpp::platform::env::get("MCPP_PROGRESS").value_or("random"); + std::string want = mcpp::invocation::get("MCPP_PROGRESS").value_or("random"); for (auto& c : want) c = static_cast(std::tolower(static_cast(c))); if (want == "off") { mcpp::ui::set_live_progress(false); return nullptr; } if (want == "plain" || !mcpp::ui::live_progress() - || !mcpp::platform::terminal::unicode_capable()) + || !mcpp::ui::terminal::unicode_capable()) return nullptr; const auto seed = static_cast( std::chrono::steady_clock::now().time_since_epoch().count()); @@ -1191,9 +1193,9 @@ std::unique_ptr choose_animation() { // error) on a terminal, with mcpp in its foreground. Otherwise it is off, and // one line says why. void choose_game(Report& r, std::vector& notes) { - auto want = mcpp::platform::env::get("MCPP_PLAY_GAME").value_or(""); + auto want = mcpp::invocation::get("MCPP_PLAY_GAME").value_or(""); if (want.empty()) return; - mcpp::platform::env::unset("MCPP_PLAY_GAME"); // not inherited by what mcpp runs + mcpp::invocation::unset("MCPP_PLAY_GAME"); // not inherited by what mcpp runs for (auto& c : want) c = static_cast(std::tolower(static_cast(c))); const auto names = screen::game_names(); if (want != "random" && !screen::make_game(want, 0)) { @@ -1203,15 +1205,15 @@ void choose_game(Report& r, std::vector& notes) { want, known)); want = "random"; } - auto progress = mcpp::platform::env::get("MCPP_PROGRESS").value_or(""); + auto progress = mcpp::invocation::get("MCPP_PROGRESS").value_or(""); for (auto& c : progress) c = static_cast(std::tolower(static_cast(c))); if (mcpp::ui::is_quiet() || !mcpp::ui::live_progress() || progress == "plain" || progress == "off" - || !mcpp::platform::terminal::unicode_capable()) { + || !mcpp::ui::terminal::unicode_capable()) { notes.push_back("--play-game: the status row's screen is off here (a terminal that " "draws braille, without --quiet and MCPP_PROGRESS=plain or off, is needed)"); return; } - auto keys = std::make_unique( + auto keys = std::make_unique( mcpp::ui::narration_stream()); if (!keys->active()) { notes.push_back("--play-game: standard input is not a terminal in the foreground, " diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 90d06d63..d384f06a 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -32,7 +32,7 @@ export module mcpp.build.refusal; import std; -import mcpp.platform.env; // an offline download is a refused `network` effect (exit 5) +import mcpp.effects; export namespace mcpp::build::refusal { @@ -278,7 +278,7 @@ void record(Code c) { // `--offline` is `--deny network`, and the process exits 5 (design // 2026-10-26 D23). if (c == Code::OfflineDownloadRequired) - mcpp::platform::env::note_effect_refused("network", "a download is required"); + mcpp::effects::note_effect_refused("network", "a download is required"); } Code take() { Code c = g_last; g_last = Code::None; return c; } diff --git a/src/build/resources.cppm b/src/build/resources.cppm index 1aced340..e8403ae9 100644 --- a/src/build/resources.cppm +++ b/src/build/resources.cppm @@ -49,7 +49,7 @@ import mcpp.manifest; import mcpp.toolchain.detect; import mcpp.toolchain.triple; import mcpp.version_req; -import mcpp.platform.process; // capture_exec: the build program's manifest resource +import mcpp.run; export namespace mcpp::build::resources { @@ -390,7 +390,7 @@ compile_utf8_manifest(const mcpp::toolchain::Toolchain& tc, // rc.exe resolves through the SDK PATH the toolchain states for itself. std::vector> env; for (auto const& ev : tc.envOverrides) env.emplace_back(ev.key, ev.value); - auto r = mcpp::platform::process::capture_exec(argv, env, dir.string()); + auto r = mcpp::run::capture_exec(argv, env, dir.string()); if (r.exit_code != 0) { std::filesystem::remove(out, ec); return std::unexpected(std::format("{} failed (exit {}): {}", diff --git a/src/build/result.cppm b/src/build/result.cppm index 88797ab0..3f9af60a 100644 --- a/src/build/result.cppm +++ b/src/build/result.cppm @@ -29,8 +29,9 @@ export module mcpp.build.result; import std; import mcpp.libs.json; import mcpp.version; // MCPP_VERSION -import mcpp.platform.terminal; // the protocol's stdout under StdoutToStderr -import mcpp.platform.process; // the files an interrupt leaves +import mcpp.run; +import mcpp.ui.terminal; +import mcpp.invocation; export namespace mcpp::build::result { @@ -273,9 +274,9 @@ void write(const Record& r) { std::vector> files{ {r.outputDir / "result.json", render(cancelled)}}; if (!r.projectRoot.empty()) files.emplace_back(last, render_last(cancelled)); - mcpp::platform::process::set_interrupt_files(files); + mcpp::run::set_interrupt_files(files); } else { - mcpp::platform::process::clear_interrupt_files(); + mcpp::run::clear_interrupt_files(); } } @@ -368,7 +369,7 @@ Record begin(const std::filesystem::path& projectRoot, const std::filesystem::pa r.outputDir = outputDir; r.projectRoot = projectRoot; r.fingerprint = outputDir.filename().string(); - if (const char* c = std::getenv("MCPP_CLIENT")) r.client = c; + if (auto c = mcpp::invocation::get("MCPP_CLIENT")) r.client = *c; return r; } @@ -380,7 +381,7 @@ void emit(std::string_view event, nlohmann::json fields) { nlohmann::json line{{"schemaVersion", 1}, {"kind", "mcpp.event"}, {"event", std::string(event)}}; for (auto it = fields.begin(); it != fields.end(); ++it) line[it.key()] = it.value(); std::lock_guard lock(g_emit); - mcpp::platform::terminal::write_protocol_stdout(line.dump() + "\n"); + mcpp::ui::terminal::write_protocol_stdout(line.dump() + "\n"); } } // namespace mcpp::build::result diff --git a/src/build/runner_lookup.cppm b/src/build/runner_lookup.cppm index 09c1d61f..7b9b0587 100644 --- a/src/build/runner_lookup.cppm +++ b/src/build/runner_lookup.cppm @@ -26,7 +26,7 @@ module; export module mcpp.build.runner_lookup; import std; -import mcpp.platform; +import mcpplibs.os.platform; export namespace mcpp::build::runner_lookup { @@ -39,7 +39,7 @@ namespace detail { inline bool executable_file(const std::filesystem::path& p) { std::error_code ec; if (!std::filesystem::is_regular_file(p, ec)) return false; - if constexpr (mcpp::platform::is_windows) return true; + if constexpr (mcpplibs::os::is_windows) return true; auto perms = std::filesystem::status(p, ec).permissions(); using P = std::filesystem::perms; return (perms & (P::owner_exec | P::group_exec | P::others_exec)) != P::none; @@ -92,7 +92,7 @@ inline Lookup locate(std::string_view argv0, out.searched.push_back(d); if (auto c = d / a0; detail::executable_file(c)) { out.program = c; return out; } } - constexpr char sep = mcpp::platform::is_windows ? ';' : ':'; + constexpr char sep = mcpplibs::os::is_windows ? ';' : ':'; for (auto part : std::views::split(pathEnv, sep)) { std::string_view sv(part.begin(), part.end()); if (sv.empty()) continue; @@ -103,17 +103,15 @@ inline Lookup locate(std::string_view argv0, return out; } -// What the kernel's refusal means. Only ENOEXEC (and EBADARCH where the -// platform defines it) says "this host cannot load the artifact"; everything -// else — EACCES, ENOENT on a found program, E2BIG — is reported verbatim and -// never turned into advice about runners. +// What the kernel's refusal means. Only ENOEXEC says "this host cannot load +// the artifact": mcpp.run reports every refusal of that kind under it (a file +// that is no image, and an image built for another processor, EBADARCH on +// macOS); everything else — EACCES, ENOENT on a found program — is reported +// verbatim and never turned into advice about runners. enum class SpawnClass { Unloadable, Other }; inline SpawnClass classify(int e) { if (e == ENOEXEC) return SpawnClass::Unloadable; -#if defined(EBADARCH) - if (e == EBADARCH) return SpawnClass::Unloadable; -#endif return SpawnClass::Other; } diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 3df3c0d6..4659edb9 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -23,12 +23,13 @@ import mcpp.build.plan; import mcpp.build.symbol_provision; import mcpp.manifest; import mcpp.libs.json; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.runtime.search; import mcpp.runtime.elf; import mcpp.runtime.binding; import mcpp.ui; -import mcpp.platform.runtime_search; import mcpp.toolchain.triple; +import mcpp.invocation; export namespace mcpp::build::runtime_validation { @@ -44,7 +45,7 @@ using ArtifactSnapshot = std::map; struct ValidatedArtifact { std::filesystem::path artifact; - mcpp::platform::elf::RuntimeVerdict verdict; + mcpp::runtime::elf::RuntimeVerdict verdict; bool cacheHit = false; }; @@ -63,7 +64,7 @@ struct ValidationReport { struct StoredRuntimeSummary { std::filesystem::path artifact; - mcpp::platform::elf::RuntimeVerdict verdict; + mcpp::runtime::elf::RuntimeVerdict verdict; std::string contractHash; }; @@ -83,7 +84,7 @@ latest_stored_verdict(const std::filesystem::path& targetRoot); // validates before reporting success/running the program. std::optional validated_artifact_snapshot( const std::filesystem::path& outputDir, - const mcpp::platform::runtime::RuntimeBinding& binding); + const mcpp::runtime::RuntimeBinding& binding); bool artifact_snapshot_unchanged(const ArtifactSnapshot& snapshot); @@ -173,7 +174,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, // dangle on a machine with no driver, and turning a correct CPU-only // configuration into a failed build would be a worse defect than the one this // reports. -mcpp::platform::elf::DlopenSurfaceReport +mcpp::runtime::elf::DlopenSurfaceReport check_dlopen_surface(const mcpp::build::BuildPlan& plan); } // namespace mcpp::build::runtime_validation @@ -209,8 +210,8 @@ std::string fingerprint(const std::filesystem::path& artifact, return std::format("{:016x}", hash); } -std::string status_name(mcpp::platform::elf::RuntimeVerdict::Status status) { - using Status = mcpp::platform::elf::RuntimeVerdict::Status; +std::string status_name(mcpp::runtime::elf::RuntimeVerdict::Status status) { + using Status = mcpp::runtime::elf::RuntimeVerdict::Status; switch (status) { case Status::Pass: return "pass"; case Status::ProvenMismatch: return "proven_mismatch"; @@ -228,16 +229,16 @@ std::string status_name(mcpp::platform::elf::RuntimeVerdict::Status status) { // it had not is the worst of both: it links, and mcpp calls it broken. bool host_libs_allowed(const mcpp::build::BuildPlan& plan) { if (plan.manifest.buildConfig.allowHostLibs) return true; - const char* e = std::getenv("MCPP_ALLOW_HOST_LIBS"); - return e && *e && *e != '0'; + const auto e = mcpp::invocation::get("MCPP_ALLOW_HOST_LIBS"); + return e && e->front() != '0'; } // How bad each state is, for rolling many artifacts into one summary. // `Unresolvable` sits above `Inconclusive` (it is proven, not unknown) and // below `ProvenMismatch` (mixing payloads is the more fundamental error, and // it is usually the CAUSE of anything unresolvable alongside it). -int status_severity(mcpp::platform::elf::RuntimeVerdict::Status status) { - using Status = mcpp::platform::elf::RuntimeVerdict::Status; +int status_severity(mcpp::runtime::elf::RuntimeVerdict::Status status) { + using Status = mcpp::runtime::elf::RuntimeVerdict::Status; switch (status) { case Status::Pass: return 0; case Status::Inconclusive: return 1; @@ -247,9 +248,9 @@ int status_severity(mcpp::platform::elf::RuntimeVerdict::Status status) { return 1; } -mcpp::platform::elf::RuntimeVerdict::Status +mcpp::runtime::elf::RuntimeVerdict::Status parse_status(std::string_view value) { - using Status = mcpp::platform::elf::RuntimeVerdict::Status; + using Status = mcpp::runtime::elf::RuntimeVerdict::Status; if (value == "pass") return Status::Pass; if (value == "proven_mismatch") return Status::ProvenMismatch; if (value == "unresolvable") return Status::Unresolvable; @@ -485,7 +486,7 @@ runtime_search_dirs(const mcpp::build::BuildPlan& plan) { // farm and report a pass the target machine will not honour. The model has // to look exactly where the artifact looks. for (auto const& dir : plan.runtimeSearch) { - if (dir.origin != mcpp::platform::search::Origin::SubosFarm) continue; + if (dir.origin != mcpp::runtime::search::Origin::SubosFarm) continue; if (dir.path.empty() || std::ranges::find(out, dir.path) != out.end()) continue; out.push_back(dir.path); } @@ -539,7 +540,7 @@ void sync_resolution_verdict(const mcpp::build::BuildPlan& plan, if (runtime == resolution.end() || !runtime->is_object()) return; nlohmann::json checked = nlohmann::json::array(); - using Status = mcpp::platform::elf::RuntimeVerdict::Status; + using Status = mcpp::runtime::elf::RuntimeVerdict::Status; Status summary = Status::Pass; bool any = false; if (auto artifacts = cache.find("artifacts"); @@ -632,7 +633,7 @@ void record_without_rules(const mcpp::build::BuildPlan& plan, continue; ValidatedArtifact v; v.artifact = artifact; - v.verdict.status = mcpp::platform::elf::RuntimeVerdict::Status::Pass; + v.verdict.status = mcpp::runtime::elf::RuntimeVerdict::Status::Pass; store_artifact(doc, key, fp, v); changed = true; } @@ -643,12 +644,12 @@ ValidationReport validate_changed_artifacts( const mcpp::build::BuildPlan& plan, const ArtifactSnapshot& before) { ValidationReport report; - if constexpr (!mcpp::platform::is_linux) { record_without_rules(plan, before); return report; } + if constexpr (!mcpplibs::os::is_linux) { record_without_rules(plan, before); return report; } // Provider dispatch (see mcpp.runtime.binding): what follows is // ELF/glibc physics, and an identity from another provider — `ucrt@…` on // Windows — has no rules here rather than a missing glibc. if (plan.runtimeBinding.platform != "linux" - || mcpp::platform::runtime::runtime_provider( + || mcpp::runtime::runtime_provider( plan.runtimeBinding.runtimeId) != "glibc") { record_without_rules(plan, before); return report; @@ -755,20 +756,20 @@ ValidationReport validate_changed_artifacts( // explaining itself on every invocation. if (!(now == oldStamp) || cached->verdict.status - != mcpp::platform::elf::RuntimeVerdict::Status::Pass) + != mcpp::runtime::elf::RuntimeVerdict::Status::Pass) report.artifacts.push_back(std::move(*cached)); continue; } ValidatedArtifact validated; validated.artifact = artifact; - auto resolution = mcpp::platform::elf::resolve_runtime_closure( + auto resolution = mcpp::runtime::elf::resolve_runtime_closure( artifact, plan.runtimeBinding, searchDirs); // The SAME opt-out the link-time hermeticity check honours, read the // same way (manifest key or environment). A build that declared it is // reaching outside the sandbox on purpose has taken responsibility for // run-time resolution, so mcpp reports rather than blocks. - validated.verdict = mcpp::platform::elf::validate_runtime_artifact( + validated.verdict = mcpp::runtime::elf::validate_runtime_artifact( artifact, plan.runtimeBinding, resolution, host_libs_allowed(plan)); store_artifact(doc, key, fp, validated); changedCache = true; @@ -781,7 +782,7 @@ ValidationReport validate_changed_artifacts( std::optional validated_artifact_snapshot( const std::filesystem::path& outputDir, - const mcpp::platform::runtime::RuntimeBinding& binding) { + const mcpp::runtime::RuntimeBinding& binding) { auto doc = read_cache(outputDir); if (doc.value("schema", 0) != 1 || doc.value("contract_hash", "") != binding.contractHash) @@ -794,7 +795,7 @@ std::optional validated_artifact_snapshot( for (auto it = artifacts->begin(); it != artifacts->end(); ++it) { if (!it.value().is_object()) return std::nullopt; if (parse_status(it.value().value("status", "inconclusive")) - != mcpp::platform::elf::RuntimeVerdict::Status::Pass) + != mcpp::runtime::elf::RuntimeVerdict::Status::Pass) return std::nullopt; auto artifact = outputDir / it.key(); auto current = stamp(artifact); @@ -861,7 +862,7 @@ check_and_record_loader_tags(const mcpp::build::BuildPlan& plan, const ArtifactSnapshot& before) { namespace loader = mcpp::build::loader; std::vector findings; - if constexpr (!mcpp::platform::is_linux) return findings; + if constexpr (!mcpplibs::os::is_linux) return findings; const auto key = post_link_key(plan); const auto recorded = @@ -877,7 +878,7 @@ check_and_record_loader_tags(const mcpp::build::BuildPlan& plan, return loader::RequiredTag::NotApplicable; }; auto actual_from = [](std::string_view s) { - using Tag = mcpp::platform::elf::SearchPathTag; + using Tag = mcpp::runtime::elf::SearchPathTag; if (s == "DT_RPATH") return Tag::Rpath; if (s == "DT_RUNPATH") return Tag::Runpath; if (s == "DT_RPATH+DT_RUNPATH") return Tag::Both; @@ -930,7 +931,7 @@ check_and_record_loader_tags(const mcpp::build::BuildPlan& plan, ? "executable" : "shared_library"}, {"required", loader::to_string(finding.required)}, {"actual", std::string( - mcpp::platform::elf::to_string(finding.actual))}, + mcpp::runtime::elf::to_string(finding.actual))}, {"status", finding.status == loader::TagFinding::Status::Ok ? "ok" : finding.status == loader::TagFinding::Status::Violation @@ -954,7 +955,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, const ArtifactSnapshot& before) { namespace sp = mcpp::build::symbol_provision; std::vector findings; - if constexpr (!mcpp::platform::is_linux) return findings; + if constexpr (!mcpplibs::os::is_linux) return findings; // The flags this build hands the linker, as ONE vector, because the // question is whether ANY of them took the export decision away from @@ -993,7 +994,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, auto it = closureCache.find(object); if (it != closureCache.end()) return it->second; std::vector names; - if (auto symbols = mcpp::platform::elf::inspect_dynamic_symbols(object)) { + if (auto symbols = mcpp::runtime::elf::inspect_dynamic_symbols(object)) { names.reserve(symbols->defined.size()); for (auto const& symbol : symbols->defined) names.push_back(symbol.name); std::ranges::sort(names); @@ -1022,7 +1023,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, std::vector names; std::error_code ec; if (std::filesystem::exists(object, ec)) - if (auto symbols = mcpp::platform::elf::defined_object_symbols(object)) + if (auto symbols = mcpp::runtime::elf::defined_object_symbols(object)) names = std::move(*symbols); return objectSymbolCache.emplace(object, std::move(names)).first->second; }; @@ -1056,7 +1057,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, // that belongs to the toolchain's own installation. auto shared_plan_definitions = [&](const mcpp::build::LinkUnit& image, - const mcpp::platform::elf::RuntimeResolution& resolution) { + const mcpp::runtime::elf::RuntimeResolution& resolution) { std::map> out; const auto imageObjects = objects_of(image); for (auto const& object : resolution.objects) { @@ -1144,7 +1145,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, if (plan.outputDir / lu.output == artifact) { unit = &lu; break; } } - auto facts = mcpp::platform::elf::inspect_elf_runtime(artifact); + auto facts = mcpp::runtime::elf::inspect_elf_runtime(artifact); if (!facts) continue; // not ELF: no flat namespace // PT_INTERP, not the ELF type. A PIE executable is ET_DYN, exactly // like a shared library, and whether mcpp's toolchain emits PIE is the @@ -1163,7 +1164,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, continue; } - auto symbols = mcpp::platform::elf::inspect_dynamic_symbols(artifact); + auto symbols = mcpp::runtime::elf::inspect_dynamic_symbols(artifact); if (!symbols) { findings.push_back({artifact, sp::not_evaluated(symbols.error())}); continue; @@ -1191,7 +1192,7 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, // Stage two. Only reached when the image exports something, which a // normal build does not. - auto resolution = mcpp::platform::elf::resolve_runtime_closure( + auto resolution = mcpp::runtime::elf::resolve_runtime_closure( artifact, plan.runtimeBinding, searchDirs); std::vector providers; for (auto const& object : resolution.objects) { @@ -1309,7 +1310,7 @@ latest_stored_verdict(const std::filesystem::path& targetRoot) { StoredRuntimeSummary summary; summary.contractHash = doc.value("contract_hash", ""); - using Status = mcpp::platform::elf::RuntimeVerdict::Status; + using Status = mcpp::runtime::elf::RuntimeVerdict::Status; summary.verdict.status = Status::Pass; for (auto it = artifacts->begin(); it != artifacts->end(); ++it) { if (!it.value().is_object()) continue; @@ -1327,13 +1328,13 @@ latest_stored_verdict(const std::filesystem::path& targetRoot) { return summary; } -mcpp::platform::elf::DlopenSurfaceReport +mcpp::runtime::elf::DlopenSurfaceReport check_dlopen_surface(const mcpp::build::BuildPlan& plan) { - mcpp::platform::elf::DlopenSurfaceReport report; + mcpp::runtime::elf::DlopenSurfaceReport report; // The record below is ELF-shaped, so a host that links no ELF publishes // nothing rather than an empty answer about a format it never produces. - if constexpr (!mcpp::platform::is_linux) return report; + if constexpr (!mcpplibs::os::is_linux) return report; // ON LINUX, A NON-ANSWER IS PUBLISHED, NOT OMITTED -- AND NEVER OVER AN // ANSWER. @@ -1497,11 +1498,11 @@ check_dlopen_surface(const mcpp::build::BuildPlan& plan) { for (auto const& [artifact, stamp] : artifacts) { if (std::ranges::find(sharedOutputs, artifact) == sharedOutputs.end()) continue; - auto facts = mcpp::platform::elf::inspect_elf_runtime(artifact); + auto facts = mcpp::runtime::elf::inspect_elf_runtime(artifact); if (facts && !facts->soname.empty()) produced.push_back(facts->soname); } - report = mcpp::platform::elf::inspect_dlopen_surface( + report = mcpp::runtime::elf::inspect_dlopen_surface( plan.depRuntimeLibraryDirs, plan.runtimeBinding, searchDirs, produced); // THE RECORD, AND BOTH DENOMINATORS IN IT. diff --git a/src/build/schedule/detach_codegen.cppm b/src/build/schedule/detach_codegen.cppm index 32ce5da0..f818a375 100644 --- a/src/build/schedule/detach_codegen.cppm +++ b/src/build/schedule/detach_codegen.cppm @@ -63,25 +63,13 @@ // which is a spawn, not a fork; the supervisor is `mcpp` itself re-invoked. // What is compiler-specific is the PREMISE (atomic BMI publication), not the // platform. -module; - -// The global module fragment is the ONLY place a module interface unit may -// #include. These were briefly written after `module :private;`, which GCC -// rejects with the unhelpful "module already declared". -#if defined(_WIN32) -#include -#else -#include -#include -#include -#include -extern char** environ; -#endif - export module mcpp.build.schedule.detach_codegen; import std; -import mcpp.platform.process; +import mcpplibs.os.fs; +import mcpplibs.os.process; +import mcpp.build.command_line; +import mcpp.run; import mcpp.build.stage; export namespace mcpp::build::schedule::detach { @@ -126,7 +114,7 @@ struct CompileRequest { std::filesystem::path depTo; // The BMI as ninja spells it, which is what the copied depfile must name. // `bmi` is the path this edge OPENS, and on Windows it is the absolute, - // extended-length form (mcpp::platform::fs::extended_length); ninja treats + // extended-length form (mcpplibs::os::fs::extended); ninja treats // an edge whose depfile names anything but its own output spelling as // permanently dirty. Empty means `bmi` is already that spelling. std::string bmiTarget; @@ -329,147 +317,45 @@ void settle_bmi(const std::filesystem::path& bmi) { std::filesystem::remove(backup, ec); } -#if defined(_WIN32) - -std::string join_command(const std::vector& argv) { - std::string cmd; - for (const auto& a : argv) { - if (!cmd.empty()) cmd += ' '; - const bool quote = a.find_first_of(" \t\"") != std::string::npos; - if (!quote) { cmd += a; continue; } - cmd += '"'; - for (char c : a) { if (c == '"') cmd += '\\'; cmd += c; } - cmd += '"'; - } - return cmd; -} - +// The supervisor, left running when this edge returns: its streams are the +// null device (HAZARD 1 applies to it as well -- holding ninja's pipe open +// would keep the edge alive long after this process exits), and it runs in a +// unit of its own, so the terminal's interrupt of the build is not delivered +// to it mid-write. bool spawn_detached(const std::vector& argv) { - auto cmd = join_command(argv); - STARTUPINFOA si{}; si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - const BOOL ok = ::CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, FALSE, - DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, - nullptr, nullptr, &si, &pi); - if (!ok) return false; - ::CloseHandle(pi.hProcess); ::CloseHandle(pi.hThread); + namespace process = mcpplibs::os::process; + process::spawn_options o; + o.program = argv.front(); + o.args.assign(argv.begin() + 1, argv.end()); + o.in = o.out = o.err = process::stdio::null; + o.own_unit = true; + auto p = process::process::spawn(o); + if (!p) return false; + p->detach(); return true; } +// `command` as ninja would have run it, through the host's interpreter (the +// command is ninja's text: `cmd.exe /d /s /c ""` on Windows, inside +// the one pair of quotes `/s` removes, #425 #832), its output and errors into +// `logPath`. int run_to_completion(std::string_view command, const std::filesystem::path& logPath) { - // `cmd.exe /c` DOES NOT USE CreateProcess ARGUMENT QUOTING. - // - // This built the line with `join_command({"cmd.exe", "/c", command})`, which - // treats the whole compiler invocation as ONE argv element: it wraps it in - // quotes and escapes every interior `"` as `\"`. cmd.exe parses none of - // that — its /c rule is about counting quotes in the raw string — so a - // command containing quoted paths came out mangled, and the compiler ran - // with flags missing rather than failing outright. On a windows-host cross - // build that surfaced as - // - // failed: gcm.cache/mcpp.libs.json.gcm - // src/libs/json.cppm:3: fatal error: json.hpp: No such file or directory - // - // i.e. `-I` gone, reported as a missing header (#425). - // - // ninja concatenates verbatim (`subprocess-win32.cc`), and this edge exists - // to run exactly what ninja would have run. Do the same, inside the one - // pair of quotes `/s` removes (`windows_shell_command_line`): the command's - // first word is quoted when the compiler's path holds a space (#832), and - // without `/s` cmd.exe strips the first and the LAST quote of the line. - std::string line = mcpp::platform::process::windows_shell_command_line(command); - - SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; - // The wide API: an extended-length log path (`bmi-supervise` receives one, - // see mcpp::platform::fs::extended_length) is honoured only by it, and a - // narrowed spelling would also throw on a name the code page cannot spell. - HANDLE log = ::CreateFileW(logPath.c_str(), GENERIC_WRITE, - FILE_SHARE_READ, &sa, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - // The command, in the log, before it runs. Phase 2 replays this file, so a - // failure arrives with the exact invocation attached — without it a remote - // failure can only be guessed at, which is how #425 cost a CI cycle to even - // localise. - if (log != INVALID_HANDLE_VALUE) { - const std::string banner = "+ " + line + "\r\n"; - DWORD wrote = 0; - ::WriteFile(log, banner.data(), static_cast(banner.size()), &wrote, nullptr); - } - STARTUPINFOA si{}; si.cb = sizeof(si); - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdOutput = si.hStdError = log; - si.hStdInput = ::CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ, &sa, - OPEN_EXISTING, 0, nullptr); - PROCESS_INFORMATION pi{}; - const BOOL ok = ::CreateProcessA(nullptr, line.data(), nullptr, nullptr, TRUE, - 0, nullptr, nullptr, &si, &pi); - if (log != INVALID_HANDLE_VALUE) ::CloseHandle(log); - if (si.hStdInput != INVALID_HANDLE_VALUE) ::CloseHandle(si.hStdInput); - if (!ok) return 127; - ::WaitForSingleObject(pi.hProcess, INFINITE); - DWORD code = 1; - ::GetExitCodeProcess(pi.hProcess, &code); - ::CloseHandle(pi.hProcess); ::CloseHandle(pi.hThread); - return static_cast(code); -} - -#else - -std::vector to_argv(const std::vector& argv) { - std::vector out; - out.reserve(argv.size() + 1); - for (const auto& a : argv) out.push_back(const_cast(a.c_str())); - out.push_back(nullptr); - return out; -} - -bool spawn_detached(const std::vector& argv) { - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - // HAZARD 1 also applies to the supervisor: holding ninja's pipe open would - // keep the edge alive long after this process exits. - ::posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDONLY, 0); - ::posix_spawn_file_actions_addopen(&fa, 1, "/dev/null", O_WRONLY, 0); - ::posix_spawn_file_actions_adddup2(&fa, 1, 2); - - posix_spawnattr_t at; - ::posix_spawnattr_init(&at); -#ifdef POSIX_SPAWN_SETSID - // Its own session, so a Ctrl-C on the build does not take the supervisor - // with it mid-write and leave a half-written object behind. - ::posix_spawnattr_setflags(&at, POSIX_SPAWN_SETSID); -#endif - auto raw = to_argv(argv); - pid_t pid = 0; - const int rc = ::posix_spawnp(&pid, raw[0], &fa, &at, raw.data(), environ); - ::posix_spawn_file_actions_destroy(&fa); - ::posix_spawnattr_destroy(&at); - return rc == 0; -} - -int run_to_completion(std::string_view command, - const std::filesystem::path& logPath) { - const std::vector argv{mcpp::platform::process::posix_shell(), "-c", std::string(command)}; - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - ::posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDONLY, 0); - ::posix_spawn_file_actions_addopen(&fa, 1, logPath.c_str(), - O_WRONLY | O_CREAT | O_TRUNC, 0644); - ::posix_spawn_file_actions_adddup2(&fa, 1, 2); - auto raw = to_argv(argv); - pid_t pid = 0; - const int rc = ::posix_spawnp(&pid, raw[0], &fa, nullptr, raw.data(), environ); - ::posix_spawn_file_actions_destroy(&fa); - if (rc != 0) return 127; - int status = 0; - ::waitpid(pid, &status, 0); - return WIFEXITED(status) ? WEXITSTATUS(status) - : 128 + (WIFSIGNALED(status) ? WTERMSIG(status) : 0); + // Created before the compiler starts: its existence is what tells the + // waiters that a supervisor started. + std::ofstream log(logPath, std::ios::binary | std::ios::trunc); + log.flush(); + auto sh = mcpp::run::shell(command); + auto r = mcpp::run::execute(sh.argv, { .one_string = sh.one_string }); + // A failure carries the command it ran. Phase 2 replays this file, so the + // failure arrives with the exact invocation attached -- without it a + // remote failure can only be guessed at, which is how #425 cost a CI cycle + // to even localise. + if (r.exit_code != 0) log << "+ " << command << '\n'; + log << r.output; + return r.exit_code; } -#endif - } // namespace int compile_release_at_bmi(const CompileRequest& req) { @@ -504,6 +390,10 @@ int compile_release_at_bmi(const CompileRequest& req) { } } + // The BMI this slot publishes, for phase 2 to withdraw if the compile + // fails (see `await_unit`). + if (!req.bmi.empty()) std::ofstream(suffixed(req.slot, ".bmi"), std::ios::trunc) << req.bmi.string(); + const auto token = acquire_token(req.semaphore, req.maxCompilers); // The supervisor reads the SAME argv file rather than receiving the command @@ -656,11 +546,25 @@ int await_unit(const std::filesystem::path& slot, const std::filesystem::path& o std::ifstream in(suffixed(slot, ".log")); if (in) std::cerr << in.rdbuf(); } - if (*rc != 0) return *rc; + // A FAILURE IS NOT KEPT. Phase 1 reported success when the BMI was + // published, so ninja holds that edge done; were the record of the failed + // compile left in place, every later build would replay it here without + // compiling again, a failure of the moment (a full disk) included. The + // record and the published BMI are withdrawn, and the next build compiles + // the unit again. + auto withdraw = [&] { + std::error_code ec; + std::ifstream named(suffixed(slot, ".bmi")); + if (std::string bmi; named && std::getline(named, bmi) && !bmi.empty()) + std::filesystem::remove(bmi, ec); + std::filesystem::remove(suffixed(slot, ".rc"), ec); + }; + if (*rc != 0) { withdraw(); return *rc; } if (!object.empty() && !file_exists(object)) { std::println(std::cerr, "mcpp: compiler reported success but {} is missing", object.string()); + withdraw(); return 1; } return 0; diff --git a/src/build/schedule/policy.cppm b/src/build/schedule/policy.cppm index 428080c5..8fe445ce 100644 --- a/src/build/schedule/policy.cppm +++ b/src/build/schedule/policy.cppm @@ -65,7 +65,8 @@ export module mcpp.build.schedule.policy; import std; import mcpp.toolchain.model; import mcpp.manifest; -import mcpp.platform.capacity; +import mcpp.invocation; +import mcpp.build.capacity; export namespace mcpp::build::schedule { @@ -263,8 +264,8 @@ int resolve_jobs(const manifest::Manifest& m, auto from_text = [&](std::string_view v) -> std::optional { if (v.empty()) return std::nullopt; if (v == "auto") { - const auto cap = platform::capacity::host_capacity(); - return platform::capacity::recommended_jobs(cap); + const auto cap = mcpp::build::capacity::host_capacity(); + return mcpp::build::capacity::recommended_jobs(cap); } int n = 0; const auto* first = v.data(); @@ -277,8 +278,8 @@ int resolve_jobs(const manifest::Manifest& m, if (onInvalid) onInvalid(v); return std::nullopt; }; - if (const char* e = std::getenv("MCPP_JOBS")) - if (auto n = from_text(e)) return *n; + if (auto e = mcpp::invocation::get("MCPP_JOBS")) + if (auto n = from_text(*e)) return *n; if (auto n = from_text(m.buildConfig.jobs)) return *n; // Not through `from_text`: this value has already been parsed as an // integer by the config loader, so "auto" and a malformed spelling are not @@ -292,8 +293,8 @@ int resolve_jobs(const manifest::Manifest& m, std::string requested_switch(const manifest::Manifest& m, const std::function& onInvalid) { std::string v; - if (const char* e = std::getenv("MCPP_BMI_SCHEDULE"); e && *e) - v = e; + if (auto e = mcpp::invocation::get("MCPP_BMI_SCHEDULE")) + v = *e; else if (!m.buildConfig.bmiSchedule.empty()) v = m.buildConfig.bmiSchedule; else diff --git a/src/build/symbol_provision.cppm b/src/build/symbol_provision.cppm index 4cf48da4..dd99a843 100644 --- a/src/build/symbol_provision.cppm +++ b/src/build/symbol_provision.cppm @@ -141,7 +141,7 @@ bool export_dynamic_requested(std::span flags); // machine's COPY relocation type every data symbol would look like a // conflict, and answering wrongly is worse than declining. std::optional> -exported_definitions(const mcpp::platform::elf::DynamicSymbols& symbols); +exported_definitions(const mcpp::runtime::elf::DynamicSymbols& symbols); // Stage two. `closure` is every object the image's loader will consult. std::vector conflicting_exports(std::span exports, @@ -203,7 +203,7 @@ bool export_dynamic_requested(std::span flags) { } std::optional> -exported_definitions(const mcpp::platform::elf::DynamicSymbols& symbols) { +exported_definitions(const mcpp::runtime::elf::DynamicSymbols& symbols) { if (!symbols.copyRelocationsKnown) return std::nullopt; std::vector out; for (auto const& symbol : symbols.defined) { diff --git a/src/cli.cppm b/src/cli.cppm index 894e7a52..e9a7fb9f 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -31,13 +31,16 @@ import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.wire; import mcpp.cli.cmd_sbom; -import mcpp.platform; // is_windows — the PATH separator of `__action --path-prepend` -import mcpp.platform.env; // --offline → MCPP_OFFLINE -import mcpp.platform.process; // __action-stamp runs the checked command -import mcpp.platform.fs; // __action-stamp writes its stamps +import mcpplibs.os.platform; +import mcpplibs.os.fs; +import mcpp.invocation; +import mcpp.effects; +import mcpp.runtime.search; +import mcpp.run; +import mcpplibs.os.posix; +import mcpp.home.files; import mcpp.build.progress; // __action reports its start import mcpp.build.dirlock; // the build directory lock names this command -import mcpp.platform.runtime_search; // linker-wrapper path-injection opt-out import mcpp.ui; import mcpp.log; import mcpp.home; // --system: the system home @@ -192,10 +195,13 @@ int enter_system_home(int argc, char** argv) { return 4; } std::filesystem::remove(probe, ec); - mcpp::platform::process::set_umask_022(); - mcpp::platform::env::set("MCPP_HOME", home.string()); - static std::optional held; - while (!(held = mcpp::platform::fs::FileLock::try_acquire(home))) + // Every file a home shared by the machine's users holds is readable by + // everyone and writable by its owner (umask 022); on Windows the ACL + // inherited from the directory decides. + if constexpr (mcpplibs::os::is_posix) mcpplibs::os::posix::set_creation_mask(022); + mcpp::invocation::set("MCPP_HOME", home.string()); + static std::optional held; + while (!(held = mcpp::home::FileLock::try_acquire(home))) std::this_thread::sleep_for(std::chrono::milliseconds(200)); if (!mcpp::home::is_system_home(home)) std::ofstream(home / std::string(mcpp::home::kHomeMarker)) << "# The machine's mcpp home: written by `mcpp --system`, read by every user.\nmode = \"system\"\n"; @@ -264,7 +270,7 @@ int run(int argc, char** argv) { return 2; } } - mcpp::platform::env::set("MCPP_HOME", absolute.string()); + mcpp::invocation::set("MCPP_HOME", absolute.string()); homeFiltered = std::move(kept); argc = static_cast(homeFiltered.size()); argv = homeFiltered.data(); @@ -284,7 +290,7 @@ int run(int argc, char** argv) { auto item = list.substr(pos, comma == std::string_view::npos ? std::string_view::npos : comma - pos); while (!item.empty() && item.front() == ' ') item.remove_prefix(1); while (!item.empty() && item.back() == ' ') item.remove_suffix(1); - const auto& names = mcpp::platform::env::deniable_effects(); + const auto& names = mcpp::effects::deniable_effects(); if (!item.empty() && std::ranges::find(names, item) == names.end()) return std::string(item); if (comma == std::string_view::npos) break; @@ -292,7 +298,7 @@ int run(int argc, char** argv) { } return std::nullopt; }; - std::string denied = mcpp::platform::env::get("MCPP_DENY_EFFECTS").value_or(""); + std::string denied = mcpp::invocation::get("MCPP_DENY_EFFECTS").value_or(""); for (int i = 1; i < argc; ++i) { const std::string_view a = argv[i]; if (a == "--") break; @@ -310,7 +316,7 @@ int run(int argc, char** argv) { "init-mcpp-home, write-project, write-global-cache, network, exec-build-script", *bad)); return 2; } - if (!denied.empty()) mcpp::platform::env::set("MCPP_DENY_EFFECTS", denied); + if (!denied.empty()) mcpp::invocation::set("MCPP_DENY_EFFECTS", denied); } // ─── --quiet / --no-color: pre-scan ───────────────────────────────── @@ -335,7 +341,7 @@ int run(int argc, char** argv) { // toolchain auto-install, which sit in three subsystems and would each // need a parameter threaded down. Same shape as MCPP_VERBOSE above, and // it makes `MCPP_OFFLINE=1` and `--offline` literally the same switch. - else if (a == "--offline") mcpp::platform::env::set("MCPP_OFFLINE", "1"); + else if (a == "--offline") mcpp::invocation::set("MCPP_OFFLINE", "1"); // `--locked` rides the same side channel, and for the stronger form // of the same reason: its consumer is the resolution write point deep // in mcpp.build.prepare, and it applies to every command that resolves @@ -347,46 +353,46 @@ int run(int argc, char** argv) { // package that moved. See the check itself for why assertion is the // half that reproducibility needs first. else if (a == "--locked" || a == "--frozen") - mcpp::platform::env::set("MCPP_LOCKED", "1"); + mcpp::invocation::set("MCPP_LOCKED", "1"); // `--managed-only` (mcpp#755) rides the same channel: its consumer is // the decision record at the end of prepare, for every command that // prepares, and the fast paths that would skip that record. else if (a == "--managed-only") - mcpp::platform::env::set("MCPP_MANAGED_ONLY", "1"); + mcpp::invocation::set("MCPP_MANAGED_ONLY", "1"); // --jobs rides the same side channel as --offline, for the same reason // recorded there: its consumer is deep in mcpp.build.execute and // threading a parameter down would touch every caller in between. // Accepts `--jobs N`, `--jobs=N`, `-j N` and `-jN`. else if (a == "--jobs" || a == "-j") { - if (i + 1 < argc) mcpp::platform::env::set("MCPP_JOBS", argv[++i]); + if (i + 1 < argc) mcpp::invocation::set("MCPP_JOBS", argv[++i]); } - else if (a.starts_with("--jobs=")) mcpp::platform::env::set("MCPP_JOBS", std::string(a.substr(7))); + else if (a.starts_with("--jobs=")) mcpp::invocation::set("MCPP_JOBS", std::string(a.substr(7))); // --toolchain rides the same channel, for the same reason: its consumer // is deep inside prepare's resolution and threading a parameter down // would touch every caller in between. else if (a == "--toolchain") { - if (i + 1 < argc) mcpp::platform::env::set("MCPP_TOOLCHAIN", argv[++i]); + if (i + 1 < argc) mcpp::invocation::set("MCPP_TOOLCHAIN", argv[++i]); } - else if (a.starts_with("--toolchain=")) mcpp::platform::env::set("MCPP_TOOLCHAIN", std::string(a.substr(12))); + else if (a.starts_with("--toolchain=")) mcpp::invocation::set("MCPP_TOOLCHAIN", std::string(a.substr(12))); // --sysroot is `MCPP_SYSROOT` for this invocation (design 2026-10-23 // §3.3), for the reason --toolchain is: its consumer is the base // statement deep in prepare, and the fast paths read the variable to // know a build's base changed. else if (a == "--sysroot") { - if (i + 1 < argc) mcpp::platform::env::set("MCPP_SYSROOT", argv[++i]); + if (i + 1 < argc) mcpp::invocation::set("MCPP_SYSROOT", argv[++i]); } - else if (a.starts_with("--sysroot=")) mcpp::platform::env::set("MCPP_SYSROOT", std::string(a.substr(10))); + else if (a.starts_with("--sysroot=")) mcpp::invocation::set("MCPP_SYSROOT", std::string(a.substr(10))); else if (a.starts_with("-j") && a.size() > 2) - mcpp::platform::env::set("MCPP_JOBS", std::string(a.substr(2))); + mcpp::invocation::set("MCPP_JOBS", std::string(a.substr(2))); // --play-game rides the same channel: its value is optional // (`--play-game` chooses a game, `--play-game=snake` names one), which // the parser's options do not express, and its consumer is the build's // report deep in mcpp.build.progress (build output design revision 3, // §5.14). The option is also declared on build, run and test below, so // that --help lists it and the parser accepts both spellings. - else if (a == "--play-game") mcpp::platform::env::set("MCPP_PLAY_GAME", "random"); + else if (a == "--play-game") mcpp::invocation::set("MCPP_PLAY_GAME", "random"); else if (a.starts_with("--play-game=")) - mcpp::platform::env::set("MCPP_PLAY_GAME", std::string(a.substr(12))); + mcpp::invocation::set("MCPP_PLAY_GAME", std::string(a.substr(12))); } // `--system` (design 2026-10-16 §9.2): the maintenance commands act on // the machine's system home, whatever the caller's HOME and MCPP_HOME @@ -420,17 +426,16 @@ int run(int argc, char** argv) { // the environment for free. Declared BEFORE the wrapper ships, because // "the exit must be declared, not inferred" is the rule that whole // negotiation established — today this is a no-op. - mcpp::platform::env::set( - std::string(mcpp::platform::search::kLinkerPathInjectionOptOut), - std::string(mcpp::platform::search::kLinkerPathInjectionOptOutValue)); + mcpp::invocation::set( + std::string(mcpp::runtime::search::kLinkerPathInjectionOptOut), + std::string(mcpp::runtime::search::kLinkerPathInjectionOptOutValue)); // Env override (observability, esp. CI): MCPP_VERBOSE= // turns on verbose logging for EVERY mcpp invocation — including the ones // nested inside e2e test scripts that call $MCPP without flags. Lets a // workflow flip on diagnostics globally with one env var. An explicit // --quiet still wins (it is processed above and gates the verbose sinks). - if (const char* v = std::getenv("MCPP_VERBOSE"); - v && *v && std::string_view(v) != "0") + if (auto v = mcpp::invocation::get("MCPP_VERBOSE"); v && *v != "0") mcpp::log::set_verbose(true); // ─── top-level --help / -h / --version intercept ──────────────────── @@ -1349,7 +1354,7 @@ int run(int argc, char** argv) { std::vector cmd; for (++i; i < argc; ++i) cmd.emplace_back(argv[i]); bool timedOut = false; - const int r = mcpp::platform::process::run_exec_deadline( + const int r = mcpp::run::run_exec_deadline( cmd, {}, std::chrono::milliseconds(0), &timedOut); if (r != 0) return r; std::string text; @@ -1433,9 +1438,9 @@ int run(int argc, char** argv) { // the inherited one when it does not. On Windows the name is compared // without case, as the environment compares it. if (!pathPrepend.empty()) { - constexpr char sep = mcpp::platform::is_windows ? ';' : ':'; + constexpr char sep = mcpplibs::os::is_windows ? ';' : ':'; auto is_path = [](std::string_view k) { - if (!mcpp::platform::is_windows) return k == "PATH"; + if (!mcpplibs::os::is_windows) return k == "PATH"; return k.size() == 4 && std::ranges::equal(k, std::string_view("path"), [](char x, char y) { return std::tolower(static_cast(x)) == y; }); @@ -1443,7 +1448,7 @@ int run(int argc, char** argv) { std::string current; auto declared = std::ranges::find_if(env, [&](auto const& kv) { return is_path(kv.first); }); if (declared != env.end()) current = declared->second; - else if (const char* p = std::getenv("PATH")) current = p; + else if (auto p = mcpp::invocation::get("PATH")) current = *p; std::string joined; for (auto const& d : pathPrepend) joined += d + sep; joined += current; @@ -1461,8 +1466,8 @@ int run(int argc, char** argv) { auto declares = [&](std::string_view name) { return std::ranges::any_of(env, [&](auto const& kv) { return kv.first == name; }); }; - if (const char* ns = std::getenv("NINJA_STATUS"); - ns && std::string_view(ns).find("@@mcpp") != std::string_view::npos + if (auto ns = mcpp::invocation::get("NINJA_STATUS"); + ns && ns->find("@@mcpp") != std::string_view::npos && !declares("NINJA_STATUS")) env.emplace_back("NINJA_STATUS", "[%f/%t] "); if (!declares(mcpp::build::progress::kStartsEnv)) @@ -1499,7 +1504,7 @@ int run(int argc, char** argv) { // `run_exec`: no shell, stdio inherited. The analyser's own output has // to reach the terminal unchanged — a check that fails is read by a // human, and capturing would either swallow it or reprint it wrapped. - const int r = mcpp::platform::process::run_exec(cmd, env); + const int r = mcpp::run::run_exec(cmd, env); // The stamps are written ONLY on success. Writing them anyway would // make ninja consider the edge satisfied, so the next build would skip // a check (or a `prepare`) that had never passed. @@ -1522,11 +1527,11 @@ int run(int argc, char** argv) { if (!requireDir.empty()) { std::error_code ec; const auto dir = - mcpp::platform::fs::extended_length(std::filesystem::path{requireDir}); + mcpplibs::os::fs::extended(std::filesystem::path{requireDir}); std::vector stampPaths; for (auto const& s : stamps) stampPaths.push_back(std::filesystem::absolute( - mcpp::platform::fs::extended_length(std::filesystem::path{s}), ec) + mcpplibs::os::fs::extended(std::filesystem::path{s}), ec) .lexically_normal()); bool populated = false; if (std::filesystem::is_directory(dir, ec)) { @@ -1553,7 +1558,7 @@ int run(int argc, char** argv) { std::error_code ec; // Relative to the build directory, which can be deep enough to // take the stamp past the Windows path limit (mcpp#641, item 3). - const auto p = mcpp::platform::fs::extended_length(std::filesystem::path{s}); + const auto p = mcpplibs::os::fs::extended(std::filesystem::path{s}); if (!p.parent_path().empty()) std::filesystem::create_directories(p.parent_path(), ec); if (std::filesystem::exists(p, ec)) { @@ -1726,7 +1731,7 @@ int run(int argc, char** argv) { // (SPEC-003, design 2026-10-26 D23): the caller's constraint, not a // failure of the project, and a script tells the two apart by the status. if (action_rc != 0) - if (auto refused = mcpp::platform::env::effect_refused()) { + if (auto refused = mcpp::effects::effect_refused()) { mcpp::ui::note(std::format("stopped before a denied effect ({}); exit status 5", *refused)); return 5; } diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index f8154b27..d0d729de 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -35,8 +35,12 @@ import mcpp.home; import mcpp.hooks; import mcpp.libs.json; import mcpp.log; -import mcpp.platform; -import mcpp.platform.terminal; +import mcpplibs.os.fs; +import mcpp.invocation; +import mcpp.effects; +import mcpp.run; +import mcpp.home.files; +import mcpp.ui.terminal; import mcpp.project; import mcpp.manifest; import mcpp.toolchain.fingerprint; @@ -219,7 +223,7 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { mcpp::ui::set_error_observer([&errors](std::string_view m) { errors.emplace_back(m); }); int rc = 0; { - mcpp::platform::terminal::StdoutToStderr narration; + mcpp::ui::terminal::StdoutToStderr narration; rc = cmd_build_body(parsed); } mcpp::ui::set_error_observer({}); @@ -245,7 +249,7 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { } } if (rc != 0) { - const bool denied = mcpp::platform::env::effect_refused().has_value(); + const bool denied = mcpp::effects::effect_refused().has_value(); const std::string code = denied ? "MCPP_EFFECT_DENIED" : "MCPP_BUILD_FAILED"; for (auto& e : errors) env.diagnostics.push_back({code, mcpp::wire::Severity::Error, std::move(e)}); @@ -565,7 +569,7 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed) } } std::error_code ec; - if (!mcpp::platform::fs::replace_file(tmp, out, ec)) { + if (!mcpp::home::replace_file(tmp, out, ec)) { std::filesystem::remove(tmp, ec); std::println(stderr, "error: cannot replace '{}'", out.string()); return 1; @@ -657,8 +661,8 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed) // recorded by a member that failed cannot relabel a later member's own // reason (the per-member analogue of the rule this used to apply once). auto plan_failure_code = [&] { - const bool offline = mcpp::platform::env::offline_mode() - || mcpp::platform::env::no_auto_install(); + const bool offline = mcpp::effects::offline_mode() + || mcpp::effects::no_auto_install(); const bool wasOfflineDownload = mcpp::build::refusal::take() == mcpp::build::refusal::Code::OfflineDownloadRequired; return std::string(wasOfflineDownload && offline @@ -693,7 +697,7 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed) // mcpp narrates on standard error, but planning may start programs // that inherit standard output (build programs, installers); the // document is printed after this scope, alone. - mcpp::platform::terminal::StdoutToStderr narration; + mcpp::ui::terminal::StdoutToStderr narration; // A group whose plan fails is planned member by member (appended to // `requests` and reached by this same loop), so a member's failure // still affects that member only (SPEC-005 R5.2). @@ -864,7 +868,7 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed) const auto selector = std::format( "spec={}\ntarget={}\ntoolchain={}\nprofile={}\nfeatures={}\n" "cap={}\naccel={}\nstatic={}\npackage={}\nworkspace={}{}", - spec, ov.target_triple, mcpp::platform::env::get("MCPP_TOOLCHAIN").value_or(""), + spec, ov.target_triple, mcpp::invocation::get("MCPP_TOOLCHAIN").value_or(""), ov.profile, ov.features, ov.capabilities, ov.accel, ov.force_static, packages, parsed.is_flag_set("workspace"), excludes.empty() ? std::string{} : std::format("\nexclude={}", excludes)); @@ -1333,7 +1337,7 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) { } // Every path ninja hands this edge is relative to the build directory, // and a deep build directory takes it past the Windows path limit. - outPath = mcpp::platform::fs::extended_length(outPath); + outPath = mcpplibs::os::fs::extended(outPath); bool single = parsed.is_flag_set("single"); @@ -1349,7 +1353,7 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) { // directory (pack drive and selection design 2026-10-01, B1). std::map> moduleMap; if (auto mm = parsed.option_or_empty("module-map").value(); !mm.empty()) { - std::ifstream is{mcpp::platform::fs::extended_length(std::filesystem::path{mm})}; + std::ifstream is{mcpplibs::os::fs::extended(std::filesystem::path{mm})}; if (!is) { std::println(stderr, "error: cannot read module map '{}'", mm); return 1; @@ -1373,7 +1377,7 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) { std::string expImports = parsed.option_or_empty("expect-imports").value(); if (!expProvides.empty() || !expImports.empty() || parsed.is_flag_set("expect-none")) { - std::ifstream is{mcpp::platform::fs::extended_length( + std::ifstream is{mcpplibs::os::fs::extended( std::filesystem::path{parsed.positional(0)})}; std::string ddiBody{std::istreambuf_iterator(is), {}}; auto unit = mcpp::dyndep::parse_ddi(ddiBody); @@ -1396,12 +1400,12 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) { } } body = mcpp::dyndep::emit_dyndep_single( - mcpp::platform::fs::extended_length( + mcpplibs::os::fs::extended( std::filesystem::path{parsed.positional(0)}), opts); } else { std::vector ddis; for (std::size_t i = 0; i < parsed.positional_count(); ++i) - ddis.emplace_back(mcpp::platform::fs::extended_length( + ddis.emplace_back(mcpplibs::os::fs::extended( std::filesystem::path{parsed.positional(i)})); body = mcpp::dyndep::emit_dyndep_from_files(ddis, /*stdImports=*/{}, opts); } @@ -1469,7 +1473,7 @@ export int cmd_link_output(const mcpplibs::cmdline::ParsedArgs& parsed, break; } } - return mcpp::platform::process::run_exec(std::vector(command.begin(), command.end())); + return mcpp::run::run_exec(std::vector(command.begin(), command.end())); } // `mcpp okimage-finish --okld -- […]` @@ -1499,7 +1503,7 @@ export int cmd_okimage_finish(const mcpplibs::cmdline::ParsedArgs& parsed, } auto run = [&](std::vector argv) { int spawnError = 0; - auto r = mcpp::platform::process::capture_exec(argv, {}, {}, &spawnError); + auto r = mcpp::run::capture_exec(argv, {}, {}, &spawnError); if (spawnError != 0) { std::println(stderr, "error: cannot start {}: {}", okld, std::generic_category().message(spawnError)); @@ -1536,7 +1540,7 @@ export int cmd_stage(const mcpplibs::cmdline::ParsedArgs& parsed) { mcpp::build::stage::StageOptions opts; std::string verify = parsed.option_or_empty("verify").value(); if (verify.empty()) - if (const char* e = std::getenv("MCPP_STAGE_VERIFY"); e && *e) verify = e; + if (auto e = mcpp::invocation::get("MCPP_STAGE_VERIFY")) verify = *e; if (!verify.empty()) opts.verify = mcpp::build::stage::parse_verify(verify); std::ifstream in(std::filesystem::path{listFile}, std::ios::binary); if (!in) { @@ -1559,13 +1563,13 @@ export int cmd_stage(const mcpplibs::cmdline::ParsedArgs& parsed) { auto [it, fresh] = index.emplace(dst, groups.size()); if (fresh) { groups.push_back({dst, {}}); spelled.emplace_back(); } groups[it->second].second.push_back( - mcpp::platform::fs::extended_length(std::filesystem::path{src})); + mcpplibs::os::fs::extended(std::filesystem::path{src})); spelled[it->second].push_back(src); } for (std::size_t g = 0; g < groups.size(); ++g) { auto const& [dst, srcs] = groups[g]; auto r = mcpp::build::stage::stage_files( - srcs, mcpp::platform::fs::extended_length(std::filesystem::path{dst}), opts); + srcs, mcpplibs::os::fs::extended(std::filesystem::path{dst}), opts); if (!r) { // One edge places the whole list, so ninja's echo of the // command no longer shows which files were involved; the @@ -1591,19 +1595,19 @@ export int cmd_stage(const mcpplibs::cmdline::ParsedArgs& parsed) { mcpp::build::stage::StageOptions opts; std::string verify = parsed.option_or_empty("verify").value(); if (verify.empty()) { - if (const char* e = std::getenv("MCPP_STAGE_VERIFY"); e && *e) - verify = e; + if (auto e = mcpp::invocation::get("MCPP_STAGE_VERIFY")) + verify = *e; } if (!verify.empty()) opts.verify = mcpp::build::stage::parse_verify(verify); std::vector sources; for (std::size_t i = 0; i < parsed.positional_count(); ++i) - sources.push_back(mcpp::platform::fs::extended_length( + sources.push_back(mcpplibs::os::fs::extended( std::filesystem::path{parsed.positional(i)})); auto r = mcpp::build::stage::stage_files( - sources, mcpp::platform::fs::extended_length(outPath), opts); + sources, mcpplibs::os::fs::extended(outPath), opts); if (!r) { std::println(stderr, "error: {}", r.error().message); return 1; @@ -1623,8 +1627,8 @@ export int cmd_bmi_equal(const mcpplibs::cmdline::ParsedArgs& parsed) { return 2; } const bool same = mcpp::build::stage::bmi_equivalent( - mcpp::platform::fs::extended_length(std::filesystem::path{parsed.positional(0)}), - mcpp::platform::fs::extended_length(std::filesystem::path{parsed.positional(1)})); + mcpplibs::os::fs::extended(std::filesystem::path{parsed.positional(0)}), + mcpplibs::os::fs::extended(std::filesystem::path{parsed.positional(1)})); // Exit status IS the answer, so it can drive `if ...; then` in the rule // exactly the way `cmp -s` did. No output on either path: this runs once per // module compile and any chatter would land in the build log. @@ -1658,7 +1662,7 @@ export int cmd_coff_def(const mcpplibs::cmdline::ParsedArgs& parsed) { // `[targets.] exports`, one pattern per line (ninja_backend writes it). std::vector patterns; if (auto v = parsed.value("exports-file")) { - std::ifstream in(mcpp::platform::fs::extended_length(std::filesystem::path{*v})); + std::ifstream in(mcpplibs::os::fs::extended(std::filesystem::path{*v})); if (!in) { std::println(stderr, "error: cannot read the exports list '{}'", *v); return 1; @@ -1716,7 +1720,7 @@ export int cmd_coff_def(const mcpplibs::cmdline::ParsedArgs& parsed) { } std::error_code ec; - const auto outOpen = mcpp::platform::fs::extended_length(out); + const auto outOpen = mcpplibs::os::fs::extended(out); if (outOpen.has_parent_path()) std::filesystem::create_directories(outOpen.parent_path(), ec); std::ofstream o(outOpen, std::ios::binary | std::ios::trunc); @@ -1769,7 +1773,7 @@ export int cmd_bmi_compile(const mcpplibs::cmdline::ParsedArgs& parsed) { // Every file path this edge opens goes through `file_path`; `--self` does // not, because it names an executable to spawn rather than a file to open. const auto file_path = [&](std::string_view name) { - return mcpp::platform::fs::extended_length( + return mcpplibs::os::fs::extended( std::filesystem::path{opt_value(parsed, name)}); }; req.bmi = file_path("bmi"); @@ -1797,11 +1801,11 @@ export int cmd_bmi_compile(const mcpplibs::cmdline::ParsedArgs& parsed) { // The supervisor. Detached by phase 1; never named by a build edge. export int cmd_bmi_supervise(const mcpplibs::cmdline::ParsedArgs& parsed) { - const auto slot = mcpp::platform::fs::extended_length( + const auto slot = mcpplibs::os::fs::extended( std::filesystem::path{opt_value(parsed, "slot")}); - const auto token = mcpp::platform::fs::extended_length( + const auto token = mcpplibs::os::fs::extended( std::filesystem::path{opt_value(parsed, "token")}); - const auto command = read_command_file(mcpp::platform::fs::extended_length( + const auto command = read_command_file(mcpplibs::os::fs::extended( std::filesystem::path{opt_value(parsed, "command-file")})); // Only `--slot` is checked here. An empty COMMAND is handed to supervise() // on purpose: it is the one place that can record the failure in the file @@ -1816,9 +1820,9 @@ export int cmd_bmi_supervise(const mcpplibs::cmdline::ParsedArgs& parsed) { // Phase 2: join the detached compiler before anything reads its object. export int cmd_bmi_await(const mcpplibs::cmdline::ParsedArgs& parsed) { - const auto slot = mcpp::platform::fs::extended_length( + const auto slot = mcpplibs::os::fs::extended( std::filesystem::path{opt_value(parsed, "slot")}); - const auto object = mcpp::platform::fs::extended_length( + const auto object = mcpplibs::os::fs::extended( std::filesystem::path{opt_value(parsed, "object")}); if (slot.empty()) { std::println(stderr, "error: bmi-await needs --slot"); diff --git a/src/cli/cmd_publish.cppm b/src/cli/cmd_publish.cppm index 78d2b202..89509e2f 100644 --- a/src/cli/cmd_publish.cppm +++ b/src/cli/cmd_publish.cppm @@ -19,7 +19,7 @@ import mcpp.pack; import mcpp.pack.library_pipeline; import mcpp.pack.pipeline; import mcpp.pack.route; -import mcpp.platform.terminal; +import mcpp.ui.terminal; import mcpp.project; // resolve_member_dir, for `pack -p` (#734 E3) import mcpp.manifest; import mcpp.publish.pipeline; @@ -218,7 +218,7 @@ int cmd_pack_list_formats(const mcpplibs::cmdline::ParsedArgs& parsed) { / mcpp::toolchain::hash_string(root->lexically_normal().generic_string()); std::expected ctx = std::unexpected(std::string{}); { - mcpp::platform::terminal::StdoutToStderr narration; + mcpp::ui::terminal::StdoutToStderr narration; ctx = mcpp::build::prepare_build(/*print_fingerprint=*/false, /*includeDevDeps=*/false, {}, ov); } @@ -274,7 +274,7 @@ export int cmd_pack(const mcpplibs::cmdline::ParsedArgs& parsed) { mcpp::ui::set_error_observer([&errors](std::string_view m) { errors.emplace_back(m); }); int rc = 0; { - mcpp::platform::terminal::StdoutToStderr narration; + mcpp::ui::terminal::StdoutToStderr narration; rc = cmd_pack_body(parsed, &outcome, &library, &libraryRoute); } mcpp::ui::set_error_observer({}); diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index 8419dca6..bbdad598 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -15,11 +15,15 @@ import mcpplibs.cmdline; import mcpp.doctor; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.home; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpplibs.os.windows; +import mcpp.ui.terminal; import mcpp.toolchain.triple; // pins::host_default_toolchain (WS8) import mcpp.toolchain.msvc; // msvc_available_here import mcpp.wire; import mcpp.libs.json; +import mcpp.toolchain.platform; +import mcpp.invocation; namespace mcpp::cli { @@ -50,7 +54,7 @@ nlohmann::json env_data_readonly() { {"registry", s(registry)}, {"xlingsHome", s(registry)}, // registryDir unless overridden {"xlingsBinary", s(registry / "bin" / - ("xlings" + std::string(mcpp::platform::exe_suffix)))}, + ("xlings" + std::string(mcpplibs::os::exe_suffix)))}, {"config", s(config)}, {"buildCache", s(mcpp::home::cache_root())}, // The engine interface a build program imports, as a package directory @@ -66,7 +70,7 @@ nlohmann::json env_data_readonly() { // An Android host (Termux): the same default toolchain as GNU/Linux on // this architecture; reported because the shell and the loader rules // differ (D21, D28). - {"android", mcpp::platform::linux_::is_android_host()}, + {"android", mcpp::toolchain::platform::is_android_host()}, }; } @@ -149,22 +153,22 @@ export int cmd_self_init(const mcpplibs::cmdline::ParsedArgs& parsed) { std::println(stderr, "error: --home requires a non-empty directory"); return 2; } - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { const auto origin = mcpp::home::original_root(); std::error_code ec; - const auto* explicitHome = std::getenv("MCPP_HOME"); + const auto explicitHome = mcpp::invocation::get("MCPP_HOME"); // Only the user-directory fallback participates. An installed portable // tree and an explicitly selected original home retain their behavior. const bool userFallback = mcpp::home::original_source() == "user"; - if (target.empty() && !(explicitHome && *explicitHome) && userFallback + if (target.empty() && !explicitHome && userFallback && !mcpp::home::initialized(origin) && !std::filesystem::exists(origin / "home.toml", ec) && !ec - && mcpp::platform::terminal::input_is_terminal() - && mcpp::platform::terminal::is_terminal(mcpp::platform::terminal::Stream::Err) - && mcpp::platform::windows::writable_fixed_drive('D', L"mcpp")) { + && mcpp::ui::terminal::input_is_terminal() + && mcpp::ui::terminal::is_terminal(mcpp::ui::terminal::Stream::Err) + && mcpplibs::os::windows::writable_fixed_drive('D', L"mcpp")) { auto fallback = mcpp::home::spelling(origin); std::ranges::replace(fallback, '/', '\\'); - mcpp::platform::terminal::write(mcpp::platform::terminal::Stream::Err, + mcpp::ui::terminal::write(mcpp::ui::terminal::Stream::Err, std::format(" Home No existing home found\n" " Drive D:\\ is a writable local fixed drive\n\n" " Choose the mcpp home:\n\n" diff --git a/src/cli/cmd_xpkg.cppm b/src/cli/cmd_xpkg.cppm index 01e08ca1..9cbcb44d 100644 --- a/src/cli/cmd_xpkg.cppm +++ b/src/cli/cmd_xpkg.cppm @@ -18,7 +18,7 @@ export module mcpp.cli.cmd_xpkg; import std; import mcpplibs.cmdline; import mcpp.manifest; -import mcpp.platform.axis; +import mcpp.toolchain.platform; import mcpp.ui; import mcpp.wire; import mcpp.libs.json; @@ -178,7 +178,7 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { std::string anyVersion; for (auto plat : kPlatforms) { auto v = mcpp::manifest::list_xpkg_versions( - lua, mcpp::platform::TargetPlatform::for_lint_of(plat)); + lua, mcpp::toolchain::platform::TargetPlatform::for_lint_of(plat)); if (!v.empty() && anyVersion.empty()) anyVersion = v.front(); versions.emplace(std::string(plat), std::move(v)); } @@ -228,7 +228,7 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { } auto pm = mcpp::manifest::synthesize_from_xpkg_lua( lua, fqn, anyVersion, - mcpp::platform::TargetPlatform::for_lint_of(plat)); + mcpp::toolchain::platform::TargetPlatform::for_lint_of(plat)); if (!pm) { mcpp::ui::error(std::format("{} [{}]: {}", file, plat, pm.error().format())); @@ -254,7 +254,7 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { // Host axis here: `mcpp xpkg parse` with no --all-os is inspecting what // THIS machine would read, which is a diagnostic question about the host. auto m = mcpp::manifest::synthesize_from_xpkg_lua( - lua, fqn, anyVersion, mcpp::platform::HostPlatform::current()); + lua, fqn, anyVersion, mcpp::toolchain::platform::HostPlatform::current()); if (!m) { mcpp::ui::error(std::format("{}: {}", file, m.error().format())); return 1; diff --git a/src/config.cppm b/src/config.cppm index e2dec5b8..f834615b 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -28,7 +28,11 @@ import mcpp.libs.json; import mcpp.pm.index_spec; import mcpp.provider; import mcpp.provider.select; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpplibs.os.windows; +import mcpp.invocation; +import mcpp.effects; +import mcpp.home.files; import mcpp.log; import mcpp.diag; import mcpp.fallback.xlings_binary; @@ -182,18 +186,18 @@ struct GlobalConfig { // Create an xlings::Env from the resolved GlobalConfig. mcpp::provider::Location make_xlings_env(const GlobalConfig& cfg) { -#if defined(_WIN32) - // On Windows, the copied xlings binary in the sandbox may not function - // correctly for large package installs (missing runtime environment). - // When MCPP_VENDORED_XLINGS is set, use the original xlings binary - // directly — it has the full xlings runtime. The XLINGS_HOME env var - // ensures packages are installed into the mcpp sandbox. - if (auto* e = std::getenv("MCPP_VENDORED_XLINGS"); e && *e) { - std::filesystem::path vendored{e}; - if (std::filesystem::exists(vendored)) - return { vendored, cfg.xlingsHome() }; + if constexpr (mcpplibs::os::is_windows) { + // On Windows, the copied xlings binary in the sandbox may not function + // correctly for large package installs (missing runtime environment). + // When MCPP_VENDORED_XLINGS is set, use the original xlings binary + // directly — it has the full xlings runtime. The XLINGS_HOME env var + // ensures packages are installed into the mcpp sandbox. + if (auto e = mcpp::invocation::get("MCPP_VENDORED_XLINGS")) { + std::filesystem::path vendored{*e}; + if (std::filesystem::exists(vendored)) + return { vendored, cfg.xlingsHome() }; + } } -#endif return { cfg.xlingsBinary, cfg.xlingsHome() }; } @@ -238,7 +242,7 @@ std::string check_base_init(const GlobalConfig& cfg) { auto patchelfBin = mcpp::provider::open(xlEnv)->pinned("patchelf", mcpp::provider::kPatchelfVersion) / "bin" / "patchelf"; auto ninjaRoot = mcpp::provider::open(xlEnv)->versions("ninja"); - auto ninja_name = std::string("ninja") + std::string(mcpp::platform::exe_suffix); + auto ninja_name = std::string("ninja") + std::string(mcpplibs::os::exe_suffix); // Check xlings binary if (!std::filesystem::exists(cfg.xlingsBinary)) @@ -249,10 +253,10 @@ std::string check_base_init(const GlobalConfig& cfg) { return "the provider's environment is not initialized"; // Check patchelf (Linux only) -#if !defined(__APPLE__) && !defined(_WIN32) - if (!std::filesystem::exists(patchelfBin)) - return std::format("patchelf missing: {}", patchelfBin.string()); -#endif + if constexpr (!mcpplibs::os::is_macos && !mcpplibs::os::is_windows) { + if (!std::filesystem::exists(patchelfBin)) + return std::format("patchelf missing: {}", patchelfBin.string()); + } // Check ninja bool ninjaOk = false; @@ -407,13 +411,6 @@ void print_status(std::string_view verb, std::string_view msg) { } } -std::expected run_capture(const std::string& cmd) { - auto r = mcpp::platform::process::capture(cmd); - if (r.exit_code != 0 && r.output.empty()) - return std::unexpected("command failed: " + cmd); - return r.output; -} - void write_file(const std::filesystem::path& p, std::string_view content) { std::error_code ec; std::filesystem::create_directories(p.parent_path(), ec); @@ -754,8 +751,8 @@ parse_target_defaults(const mcpp::libs::toml::Document& doc, "{} [target.{}].sysroot is a table without `path`", file.string(), triple)); std::filesystem::path p(path); if (path == "~" || path.starts_with("~/")) - if (auto* h = std::getenv(mcpp::platform::is_windows ? "USERPROFILE" : "HOME"); h && *h) - p = std::filesystem::path(h) / (path.size() > 2 ? path.substr(2) : std::string{}); + if (auto h = mcpp::invocation::get(mcpplibs::os::is_windows ? "USERPROFILE" : "HOME")) + p = std::filesystem::path(*h) / (path.size() > 2 ? path.substr(2) : std::string{}); if (p.is_relative()) p = file.parent_path() / p; text = "path:" + p.lexically_normal().generic_string(); } else { @@ -847,7 +844,7 @@ std::expected load_or_init( // creation guard (`if fs::exists(homeDir/"bin"/"xlings")`), // making ensure_sandbox_xlings_binary() a no-op. cfg.xlingsBinary = cfg.registryDir / "bin" / - (std::string("xlings") + std::string(mcpp::platform::exe_suffix)); + (std::string("xlings") + std::string(mcpplibs::os::exe_suffix)); cfg.bmiCacheDir = mcpp::home::cache_root(); cfg.metaCacheDir = cfg.mcppHome / "cache"; cfg.logDir = cfg.mcppHome / "log"; @@ -874,8 +871,8 @@ std::expected load_or_init( // configuration yet is not set up, and the caller said not to. if (initialization != Initialization::ReadOnly && !std::filesystem::exists(cfg.configFile, ec) - && mcpp::platform::env::effect_denied("init-mcpp-home")) { - mcpp::platform::env::note_effect_refused("init-mcpp-home", cfg.mcppHome.generic_string()); + && mcpp::effects::effect_denied("init-mcpp-home")) { + mcpp::effects::note_effect_refused("init-mcpp-home", cfg.mcppHome.generic_string()); return std::unexpected(ConfigError{std::format( "effect denied: init-mcpp-home -- {} is not set up yet, and the caller denied " "creating it (--deny init-mcpp-home)", cfg.mcppHome.generic_string())}); @@ -951,8 +948,8 @@ std::expected load_or_init( for (auto entry : declared.value_or(std::vector{})) { std::filesystem::path dir = entry; if (entry == "~" || entry.starts_with("~/")) - if (auto* h = std::getenv(mcpp::platform::is_windows ? "USERPROFILE" : "HOME"); h && *h) - dir = std::filesystem::path(h) / (entry.size() > 2 ? entry.substr(2) : std::string{}); + if (auto h = mcpp::invocation::get(mcpplibs::os::is_windows ? "USERPROFILE" : "HOME")) + dir = std::filesystem::path(*h) / (entry.size() > 2 ? entry.substr(2) : std::string{}); dir = dir.lexically_normal(); std::error_code ec; // A Luban system's /xlings is its store, wherever its xlings put it @@ -967,7 +964,7 @@ std::expected load_or_init( } // The trust boundary (§8.2): another user's home is code that // user can replace at any time. - if (auto why = mcpp::platform::untrusted_because(dir); !why.empty()) + if (auto why = mcpp::home::untrusted_because(dir); !why.empty()) return std::unexpected(ConfigError{std::format( "[xlings] upstream {}: not used, {}; an upstream belongs to this user or to the system " "and only its owner can change it", dir.string(), why)}); @@ -1087,10 +1084,10 @@ std::expected load_or_init( // The path budget (design 2026-10-16 §11): with Windows' long paths off, // a payload whose deepest file (gcc's libstdc++ headers: 140 characters // below , measured) would pass MAX_PATH cannot be installed. - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { constexpr std::size_t kDeepestPayloadSuffix = 140; const auto length = cfg.mcppHome.native().size(); - if (!mcpp::platform::windows::long_paths_enabled() && length + 1 + kDeepestPayloadSuffix > 259) + if (!mcpplibs::os::windows::long_paths_enabled() && length + 1 + kDeepestPayloadSuffix > 259) mcpp::diag::degraded("home/path-budget", std::format("MCPP_HOME is {} characters long and Windows long paths are off", length), "installing a toolchain may fail because a path is too long", @@ -1099,8 +1096,8 @@ std::expected load_or_init( if (initialization == Initialization::ReadOnly) { if (cfg.xlingsBinaryMode == "system") { - if (auto binary = mcpp::platform::fs::which( - std::string("xlings") + std::string(mcpp::platform::exe_suffix))) + if (auto binary = mcpp::invocation::which( + std::string("xlings") + std::string(mcpplibs::os::exe_suffix))) cfg.xlingsBinary = *binary; } else if (cfg.xlingsBinaryMode != "bundled") { cfg.xlingsBinary = mcpp::home::from_utf8(cfg.xlingsBinaryMode); @@ -1146,8 +1143,8 @@ std::expected load_or_init( cfg.metaCacheDir / "vendored-xlings.versions"); if (!xbin) return std::unexpected(ConfigError{xbin.error()}); } else if (cfg.xlingsBinaryMode == "system") { - auto sysPath = mcpp::platform::fs::which( - std::string("xlings") + std::string(mcpp::platform::exe_suffix)); + auto sysPath = mcpp::invocation::which( + std::string("xlings") + std::string(mcpplibs::os::exe_suffix)); if (!sysPath) return std::unexpected(ConfigError{"system xlings not found in PATH"}); cfg.xlingsBinary = *sysPath; @@ -1195,7 +1192,7 @@ std::expected load_or_init( // artefact exists, and the completion markers below are still written // from what is on disk. A fresh home stays un-bootstrapped, and the // commands that need the tools say so through check_base_init(). - const bool bootstrapAllowed = !mcpp::platform::env::offline_mode(); + const bool bootstrapAllowed = !mcpp::effects::offline_mode(); ensure_sandbox_xlings_binary(cfg, quiet); if (bootstrapAllowed) { if (!check_base_init(cfg).empty()) report_network(cfg, quiet); @@ -1211,11 +1208,11 @@ std::expected load_or_init( } } { -#if !defined(__APPLE__) && !defined(_WIN32) - // patchelf is ELF-only; macOS uses Mach-O and Windows uses PE. - if (bootstrapAllowed) - ensure_sandbox_patchelf(cfg, quiet, onBootstrapProgress); -#endif + if constexpr (!mcpplibs::os::is_macos && !mcpplibs::os::is_windows) { + // patchelf is ELF-only; macOS uses Mach-O and Windows uses PE. + if (bootstrapAllowed) + ensure_sandbox_patchelf(cfg, quiet, onBootstrapProgress); + } if (bootstrapAllowed) ensure_sandbox_ninja(cfg, quiet, onBootstrapProgress); } diff --git a/src/doctor.cppm b/src/doctor.cppm index 907e3d5f..81c422c9 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -26,9 +26,12 @@ import mcpp.fallback.xlings_binary; import mcpp.fetcher.progress; import mcpp.home; import mcpp.libs.json; -import mcpp.platform; -import mcpp.platform.process; -import mcpp.platform.env; +import mcpplibs.os.platform; +import mcpplibs.os.windows; +import mcpp.toolchain.platform; +import mcpp.invocation; +import mcpp.run; +import mcpp.home.files; import mcpp.runtime.elf; import mcpp.pm.index_refresh; // staleness_note for `mcpp why deps` import mcpp.pm.index_contract; // the floor each index declares @@ -43,6 +46,7 @@ import mcpp.toolchain.abi; import mcpp.ui; import mcpp.provider; import mcpp.provider.select; +import mcpplibs.os.fs; namespace mcpp::doctor { @@ -237,7 +241,7 @@ export int doctor_report() { bool prepareWouldInstall = false; auto pctx = [&]() -> decltype(mcpp::build::prepare_build(false)) { if (!inPackage) return std::unexpected(std::string("not inside a package")); - mcpp::platform::env::ScopedEnv offline("MCPP_OFFLINE", "1"); + mcpp::invocation::ScopedEnv offline("MCPP_OFFLINE", "1"); const bool wasQuiet = mcpp::ui::is_quiet(); mcpp::ui::set_quiet(true); (void)mcpp::build::refusal::take(); @@ -363,7 +367,7 @@ export int doctor_report() { // Windows: report the system MSVC (msvc@system). Absence is a warning, // not an error — mcpp works with LLVM/Clang without it, and mcpp never // installs MSVC itself. - if (mcpp::platform::is_windows) { + if (mcpplibs::os::is_windows) { mcpp::ui::status("Checking", "msvc (system)"); if (auto inst = mcpp::toolchain::msvc::detect_installation()) { ok(std::format("msvc {}{} (VC tools {})", @@ -444,7 +448,7 @@ export int doctor_report() { // Linux ELF. Same shape as the mingw probe above; the package is named // by triple, matching to_xim_package()'s `-gcc`. { - auto triple = std::string(mcpp::platform::host_arch) + "-linux-musl"; + auto triple = std::string(mcpp::toolchain::platform::host_arch) + "-linux-musl"; auto label = std::format("linux cross (xim:{}-gcc)", triple); mcpp::ui::status("Checking", label); auto pkgs = mcpp::provider::layout().tool_in( @@ -560,17 +564,17 @@ export int doctor_report() { if ((*cfg).upstreams.empty()) ok("self-contained: no upstream ([xlings] upstream)"); for (auto const& u : (*cfg).upstreams) ok(std::format("upstream {}", u.string())); - const auto caps = mcpp::platform::fs_caps((*cfg).mcppHome); + const auto caps = mcpplibs::os::fs::measure((*cfg).mcppHome); if (caps.native()) ok(std::format("{} on {}: every write is the filesystem's own", (*cfg).mcppHome.string(), caps.type.empty() ? "this filesystem" : caps.type)); else ok(std::format("{} on {}: {}{}{}", (*cfg).mcppHome.string(), caps.type.empty() ? "this filesystem" : caps.type, caps.rename_no_replace ? "" : "publishes by hard link or a rename to a free name; ", - caps.rename_exchange ? "" : "exchanges by moving aside; ", - caps.symlinks ? "" : "no symbolic links (no reuse from an upstream)")); - if constexpr (mcpp::platform::is_windows) - ok(std::format("Windows long paths: {}", mcpp::platform::windows::long_paths_enabled() + caps.exchange ? "" : "exchanges by moving aside; ", + caps.symbolic_links ? "" : "no symbolic links (no reuse from an upstream)")); + if constexpr (mcpplibs::os::is_windows) + ok(std::format("Windows long paths: {}", mcpplibs::os::windows::long_paths_enabled() ? "on (LongPathsEnabled = 1)" : "off (LongPathsEnabled = 0; mcpp does not change it)")); } @@ -598,7 +602,7 @@ export int doctor_report() { if (!stored) { ok("no linked ELF verdict stored yet (created on the next Linux link)"); } else { - using Status = mcpp::platform::elf::RuntimeVerdict::Status; + using Status = mcpp::runtime::elf::RuntimeVerdict::Status; auto detail = stored->verdict.explain(); auto subject = std::format("{} (RuntimeBinding {})", stored->artifact.string(), stored->contractHash); @@ -685,141 +689,141 @@ export int doctor_report() { } } -#if !defined(__APPLE__) && !defined(_WIN32) - // ─── Toolchain runtime dependencies (Linux/ELF only) ──────────────── - // - // Installed xim toolchains bake absolute RUNPATH entries into their - // compiler binaries (e.g. clang++ points at xim-x-zlib/.../lib for - // libz.so.1). If the providing xim package is later removed, the - // RUNPATH dir vanishes and `` dies at runtime with - // "libz.so.1: cannot open shared object" (exit 127) — the package - // builds fine but the produced binary can't run. We detect the broken - // state here before a build mysteriously fails. - // - // Two symptoms, both stemming from a deleted provider package: - // 1. a compiler RUNPATH entry pointing at a now-missing dir, and - // 2. dangling symlinks under /subos/default/lib - // (std::filesystem::exists follows symlinks → false for dangling). - mcpp::ui::status("Checking", "toolchain runtime deps"); - if (cfg) { - auto pkgsDir = mcpp::provider::open(mcpp::config::make_xlings_env(*cfg))->store(); - std::error_code ec; - bool sawAny = false; - bool anyMissing = false; - - if (std::filesystem::exists(pkgsDir, ec)) { - // Mirror `mcpp toolchain list`: each xim-x-//bin - // holds one installed toolchain frontend (clang++/g++/musl-gcc-…). - for (const auto& entry : mcpp::provider::layout().entries(pkgsDir)) { - if (entry.ns != "xim") continue; // toolchains only - auto id = mcpp::toolchain::identify_xim_payload(entry.name); - if (!id) continue; // not a compiler pkg - - { - const auto& vDir = entry.dir; - mcpp::toolchain::ToolchainSpec s; - s.family = id->family; - s.version = vDir.filename().string(); - s.target = id->target; - // payload_frontend, not toolchain_frontend(root/"bin"): - // cl.exe is four levels down at - // VC/Tools/MSVC//bin/Host//, so the `bin/` - // lookup finds nothing and `continue` drops every - // installed msvc toolset. That is the defect #436 fixed - // in `toolchain list`; doctor kept its own copy, so the - // two commands disagreed about the same machine. - auto binR = mcpp::toolchain::payload_frontend( - vDir, mcpp::toolchain::to_xim_package(s)); - // DOCTOR IS THE COMMAND FOR EXACTLY THIS. A payload whose - // own description does not parse is a finding, and - // skipping it would make the one command that reports on - // an installation silent about the thing that breaks it. - if (!binR) { warn(binR.error()); continue; } - if (binR->empty()) continue; - const auto& bin = *binR; - sawAny = true; - - auto label = s.display(); - - // readelf is part of binutils, always present in our sandbox. - auto cmd = std::format("readelf -d \"{}\"", bin.string()); - auto r = mcpp::platform::process::capture(cmd); - if (r.exit_code != 0) { - warn(std::format( - "{}: could not read RUNPATH from '{}' (readelf exit {})", - label, bin.string(), r.exit_code)); - continue; - } - for (auto& dir : parse_readelf_runpath(r.output)) { - // Only absolute paths name on-disk dirs we can verify; - // $ORIGIN-relative entries are resolved by the loader. - if (dir.empty() || dir.front() != '/') continue; - if (!std::filesystem::exists(dir, ec)) { - anyMissing = true; + if constexpr (!mcpplibs::os::is_macos && !mcpplibs::os::is_windows) { + // ─── Toolchain runtime dependencies (Linux/ELF only) ──────────────── + // + // Installed xim toolchains bake absolute RUNPATH entries into their + // compiler binaries (e.g. clang++ points at xim-x-zlib/.../lib for + // libz.so.1). If the providing xim package is later removed, the + // RUNPATH dir vanishes and `` dies at runtime with + // "libz.so.1: cannot open shared object" (exit 127) — the package + // builds fine but the produced binary can't run. We detect the broken + // state here before a build mysteriously fails. + // + // Two symptoms, both stemming from a deleted provider package: + // 1. a compiler RUNPATH entry pointing at a now-missing dir, and + // 2. dangling symlinks under /subos/default/lib + // (std::filesystem::exists follows symlinks → false for dangling). + mcpp::ui::status("Checking", "toolchain runtime deps"); + if (cfg) { + auto pkgsDir = mcpp::provider::open(mcpp::config::make_xlings_env(*cfg))->store(); + std::error_code ec; + bool sawAny = false; + bool anyMissing = false; + + if (std::filesystem::exists(pkgsDir, ec)) { + // Mirror `mcpp toolchain list`: each xim-x-//bin + // holds one installed toolchain frontend (clang++/g++/musl-gcc-…). + for (const auto& entry : mcpp::provider::layout().entries(pkgsDir)) { + if (entry.ns != "xim") continue; // toolchains only + auto id = mcpp::toolchain::identify_xim_payload(entry.name); + if (!id) continue; // not a compiler pkg + + { + const auto& vDir = entry.dir; + mcpp::toolchain::ToolchainSpec s; + s.family = id->family; + s.version = vDir.filename().string(); + s.target = id->target; + // payload_frontend, not toolchain_frontend(root/"bin"): + // cl.exe is four levels down at + // VC/Tools/MSVC//bin/Host//, so the `bin/` + // lookup finds nothing and `continue` drops every + // installed msvc toolset. That is the defect #436 fixed + // in `toolchain list`; doctor kept its own copy, so the + // two commands disagreed about the same machine. + auto binR = mcpp::toolchain::payload_frontend( + vDir, mcpp::toolchain::to_xim_package(s)); + // DOCTOR IS THE COMMAND FOR EXACTLY THIS. A payload whose + // own description does not parse is a finding, and + // skipping it would make the one command that reports on + // an installation silent about the thing that breaks it. + if (!binR) { warn(binR.error()); continue; } + if (binR->empty()) continue; + const auto& bin = *binR; + sawAny = true; + + auto label = s.display(); + + // readelf is part of binutils, always present in our sandbox. + auto r = mcpp::run::execute({ "readelf", "-d", bin.string() }, + { .streams = mcpp::run::Streams::output_error_shown }); + if (r.exit_code != 0) { warn(std::format( - "{}: RUNPATH dir missing: {} " - "(its providing xim package may have been removed — " - "reinstall the toolchain to repair)", - label, dir)); + "{}: could not read RUNPATH from '{}' (readelf exit {})", + label, bin.string(), r.exit_code)); + continue; + } + for (auto& dir : parse_readelf_runpath(r.output)) { + // Only absolute paths name on-disk dirs we can verify; + // $ORIGIN-relative entries are resolved by the loader. + if (dir.empty() || dir.front() != '/') continue; + if (!std::filesystem::exists(dir, ec)) { + anyMissing = true; + warn(std::format( + "{}: RUNPATH dir missing: {} " + "(its providing xim package may have been removed — " + "reinstall the toolchain to repair)", + label, dir)); + } } } } } - } - if (sawAny && !anyMissing) - ok("all installed toolchain RUNPATH dirs present"); - else if (!sawAny) - ok("no installed toolchains to check"); - - // The vendored xlings, against the version this mcpp expects. - // - // Nothing else surfaces this. `mcpp self env` prints both numbers and - // says nothing about the gap, and a home that acquired its xlings once - // never revisited it -- so a machine could sit years behind while every - // command looked healthy. What goes missing is silent by nature: - // features mcpp reads FROM xlings (the subos_info block, for one) - // simply never appear, and the code that consumes them degrades - // quietly because a missing block is also a legitimate state. - { - const auto have = mcpp::provider::describe(cfg->xlingsBinary).version; - const auto want = std::string(mcpp::config::kXlingsPinnedVersion); - if (have.empty()) { - warn(std::format("cannot read the vendored xlings version at {}", - (*cfg).xlingsBinary.string())); - } else if (mcpp::fallback::version_is_older(have, want)) { - warn(std::format( - "vendored xlings is {} but this mcpp expects {} — features " - "mcpp reads from xlings may be silently absent (the subos " - "self-description arrived in 2026.8.5.1). It is replaced " - "automatically on the next `mcpp self init`", - have, want)); - } else { - ok(std::format("vendored xlings {} (pinned {})", have, want)); + if (sawAny && !anyMissing) + ok("all installed toolchain RUNPATH dirs present"); + else if (!sawAny) + ok("no installed toolchains to check"); + + // The vendored xlings, against the version this mcpp expects. + // + // Nothing else surfaces this. `mcpp self env` prints both numbers and + // says nothing about the gap, and a home that acquired its xlings once + // never revisited it -- so a machine could sit years behind while every + // command looked healthy. What goes missing is silent by nature: + // features mcpp reads FROM xlings (the subos_info block, for one) + // simply never appear, and the code that consumes them degrades + // quietly because a missing block is also a legitimate state. + { + const auto have = mcpp::provider::describe(cfg->xlingsBinary).version; + const auto want = std::string(mcpp::config::kXlingsPinnedVersion); + if (have.empty()) { + warn(std::format("cannot read the vendored xlings version at {}", + (*cfg).xlingsBinary.string())); + } else if (mcpp::fallback::version_is_older(have, want)) { + warn(std::format( + "vendored xlings is {} but this mcpp expects {} — features " + "mcpp reads from xlings may be silently absent (the subos " + "self-description arrived in 2026.8.5.1). It is replaced " + "automatically on the next `mcpp self init`", + have, want)); + } else { + ok(std::format("vendored xlings {} (pinned {})", have, want)); + } } - } - // Dangling symlinks under the provider environment's lib — these - // point into payload lib dirs; a removed package leaves them broken. - auto subosLib = mcpp::provider::open(mcpp::config::make_xlings_env(*cfg))->sysroot() / "lib"; - if (std::filesystem::exists(subosLib, ec)) { - bool anyDangling = false; - for (auto& e : std::filesystem::directory_iterator(subosLib, ec)) { - if (!e.is_symlink(ec)) continue; - // exists() follows the link → false when the target is gone. - if (!std::filesystem::exists(e.path(), ec)) { - anyDangling = true; - auto target = std::filesystem::read_symlink(e.path(), ec); - warn(std::format( - "dangling subos symlink: {} -> {} " - "(target's xim package may have been removed)", - e.path().filename().string(), target.string())); + // Dangling symlinks under the provider environment's lib — these + // point into payload lib dirs; a removed package leaves them broken. + auto subosLib = mcpp::provider::open(mcpp::config::make_xlings_env(*cfg))->sysroot() / "lib"; + if (std::filesystem::exists(subosLib, ec)) { + bool anyDangling = false; + for (auto& e : std::filesystem::directory_iterator(subosLib, ec)) { + if (!e.is_symlink(ec)) continue; + // exists() follows the link → false when the target is gone. + if (!std::filesystem::exists(e.path(), ec)) { + anyDangling = true; + auto target = std::filesystem::read_symlink(e.path(), ec); + warn(std::format( + "dangling subos symlink: {} -> {} " + "(target's xim package may have been removed)", + e.path().filename().string(), target.string())); + } } + if (!anyDangling) + ok(std::format("subos lib symlinks all resolve ({})", subosLib.string())); } - if (!anyDangling) - ok(std::format("subos lib symlinks all resolve ({})", subosLib.string())); } } -#endif // ─── Fixincludes-frozen headers in installed GCC payloads (mcpp#687) ──── // @@ -1184,7 +1188,7 @@ export int why_toolchain_json(std::string_view target, std::string_view tcSpec) // inherit a compiler nobody asked it for. // `ScopedEnv` already exists for exactly this and restores the prior // value, including "there was none". - std::optional tcGuard; + std::optional tcGuard; if (!tcSpec.empty()) tcGuard.emplace("MCPP_TOOLCHAIN", std::string(tcSpec)); @@ -1819,7 +1823,7 @@ export int self_init(bool force, std::string_view target = {}) { if (!target.empty()) { // Hold the original home's lock from conflict inspection through // local initialization and atomic publication, never through a download. - auto lock = mcpp::platform::fs::FileLock::try_acquire(mcpp::home::original_root()); + auto lock = mcpp::home::FileLock::try_acquire(mcpp::home::original_root()); if (!lock) { mcpp::ui::error("cannot lock the original home for writing; no default was changed"); return 1; diff --git a/src/effects.cpp b/src/effects.cpp new file mode 100644 index 00000000..ecb290ef --- /dev/null +++ b/src/effects.cpp @@ -0,0 +1,76 @@ +// mcpp.effects: the knobs read through this invocation's environment. +module mcpp.effects; + +import std; +import mcpp.invocation; + +namespace mcpp::effects { + +bool offline_mode() { + // Not cached: `mcpp test` runs nested mcpp invocations in-process in some + // paths, and a test that flips the var must see the change. + const auto v = mcpp::invocation::get("MCPP_OFFLINE"); + return (v && *v != "0") || effect_denied("network"); +} + +const std::vector& deniable_effects() { + static const std::vector names{ + "init-mcpp-home", "write-project", "write-global-cache", "network", "exec-build-script"}; + return names; +} + +bool effect_denied(std::string_view effect) { + const auto v = mcpp::invocation::get("MCPP_DENY_EFFECTS"); + if (!v) return false; + std::string_view list(*v); + while (!list.empty()) { + auto comma = list.find(','); + auto item = list.substr(0, comma); + while (!item.empty() && item.front() == ' ') item.remove_prefix(1); + while (!item.empty() && item.back() == ' ') item.remove_suffix(1); + if (item == effect) return true; + if (comma == std::string_view::npos) break; + list.remove_prefix(comma + 1); + } + return false; +} + +namespace { +std::mutex g_refusedMutex; +std::optional g_refused; +} + +void note_effect_refused(std::string_view effect, std::string_view what) { + std::lock_guard lock(g_refusedMutex); + if (!g_refused) g_refused = what.empty() ? std::string(effect) + : std::format("{}: {}", effect, what); +} + +std::optional effect_refused() { + std::lock_guard lock(g_refusedMutex); + return g_refused; +} + +namespace { +std::atomic g_writesGlobalCache{false}; +} + +void note_writes_global_cache() { g_writesGlobalCache.store(true); } + +bool writes_global_cache() { + return g_writesGlobalCache.load() && !effect_denied("write-global-cache"); +} + +bool no_auto_install() { + const auto v = mcpp::invocation::get("MCPP_NO_AUTO_INSTALL"); + return v && *v != "0"; +} + +namespace { +std::atomic g_network_accessed{false}; +} + +void note_network_access() { g_network_accessed.store(true, std::memory_order_relaxed); } +bool network_accessed() { return g_network_accessed.load(std::memory_order_relaxed); } + +} // namespace mcpp::effects diff --git a/src/effects.cppm b/src/effects.cppm new file mode 100644 index 00000000..eceec084 --- /dev/null +++ b/src/effects.cppm @@ -0,0 +1,69 @@ +// mcpp.effects — what this run may do, and what it did (design 2026-10-26 +// §5.5, D8): offline mode, the effects a caller denies, the first refusal, and +// the network and global-cache effects a run had. +export module mcpp.effects; + +import std; + +export namespace mcpp::effects { + +// ── Network policy: offline mode ────────────────────────────────────────── +// +// One process-wide knob answering "may mcpp reach the network at all?", read +// at each point of use rather than threaded through a dozen signatures — the +// same idiom the older MCPP_NO_AUTO_INSTALL gate already uses. +// +// It lives in a leaf module on purpose: the consumers span pm (index refresh, +// package install) and build (toolchain auto-install), and a home inside one of +// those subsystems would make the other import it across a layer boundary. +// +// Set by `--offline` (cli pre-scan writes the env var) or by the user exporting +// MCPP_OFFLINE. "0" and the empty string mean off, matching MCPP_VERBOSE. +bool offline_mode(); + +// THE EFFECTS THIS RUN MAY NOT HAVE (design 2026-10-26 §5.5, D8). +// +// `--deny ` and MCPP_DENY_EFFECTS name effects of docs/50 §4 the +// caller does not allow: `network`, `write-project`, `write-global-cache`, +// `exec-build-script`, `init-mcpp-home` (`read-project` cannot be denied). +// The two are a union, and nothing a project declares widens them: the +// constraint is the caller's. `--offline` is `--deny network`, and both +// spellings answer `offline_mode()`. A run that stops before a denied effect +// records it here, and the process exits 5 (SPEC-003): a caller tells "my +// constraint stopped it" from "it failed" by the status alone. +bool effect_denied(std::string_view effect); +// The deniable effects, for the CLI's validation of `--deny`. +const std::vector& deniable_effects(); +void note_effect_refused(std::string_view effect, std::string_view what); +// "network: install xim:gcc@15.1.0" -- the first refusal of this run, if any. +std::optional effect_refused(); + +// WHETHER THIS RUN WRITES THE GLOBAL CACHE. Answers kept under the home (a +// compiler's probe, the provider's handshake) are read by every command and +// written only by a run whose effects include `write-global-cache`: one that +// plans or builds (it says so with `note_writes_global_cache`) and was not +// denied it. A query -- `self doctor`, `self env`, `toolchain list` -- declares +// no such effect, so it leaves the home exactly as it found it. +void note_writes_global_cache(); +bool writes_global_cache(); + +// The older, narrower spelling: MCPP_NO_AUTO_INSTALL gates ONLY the toolchain +// auto-install, nothing else. Kept working (CI and existing setups export it) +// and kept next to offline_mode() so the two network knobs cannot drift apart. +bool no_auto_install(); + +// WHAT THIS RUN DID WITH THE NETWORK, AS OPPOSED TO WHAT IT MAY DO. +// +// docs/50 defines an envelope's `effects` as what running the command did, +// and `--protocol-version` as what a command may do. The per-run list used to +// be assembled from constants, so a plan that refreshed the index reported no +// `network` (#648 A4). A launcher of a network-bound child (an index refresh, +// an install, a git remote operation) calls `note_network_access` before it +// starts the child; an attempt that fails or times out still counts, because +// the effect is the access. Offline, those launchers return before this call. +// Kept beside offline_mode() for the reason stated above: every layer can reach +// this module, and the record has writers in several of them. +void note_network_access(); +bool network_accessed(); + +} // namespace mcpp::effects diff --git a/src/fallback/probe_sysroot.cppm b/src/fallback/probe_sysroot.cppm index f8b3e72a..be854ba2 100644 --- a/src/fallback/probe_sysroot.cppm +++ b/src/fallback/probe_sysroot.cppm @@ -13,7 +13,7 @@ export module mcpp.fallback.probe_sysroot; import std; import mcpp.provider; import mcpp.provider.select; -import mcpp.platform; +import mcpp.toolchain.apple_sdk; import mcpp.log; export namespace mcpp::fallback { @@ -140,7 +140,7 @@ parse_clang_cfg_sysroot(const std::filesystem::path& compilerBin) { // macOS fallback: use xcrun to discover the SDK path. std::optional probe_macos_sdk_sysroot() { - auto sdk = mcpp::platform::macos::sdk_path(); + auto sdk = mcpp::toolchain::apple::sdk_path(); if (sdk) { mcpp::log::verbose("probe", std::format("sysroot (macOS SDK): {}", sdk->string())); } diff --git a/src/fallback/xlings_binary.cppm b/src/fallback/xlings_binary.cppm index 51e020f0..b4656643 100644 --- a/src/fallback/xlings_binary.cppm +++ b/src/fallback/xlings_binary.cppm @@ -15,7 +15,9 @@ module; export module mcpp.fallback.xlings_binary; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.invocation; +import mcpp.run; export namespace mcpp::fallback { @@ -225,7 +227,7 @@ std::string vendored_xlings_version(const std::filesystem::path& bin) { // system cannot find the path specified." in every command after the first, // did not run xlings, and so returned an empty version, which made the // pin comparison in acquire_xlings_binary return early forever. - auto r = mcpp::platform::process::capture_stdout({bin.string(), "--version"}); + auto r = mcpp::run::capture_stdout({bin.string(), "--version"}); if (r.exit_code != 0) return {}; // Output carries ANSI colour; take the first dotted-numeric run. std::string out; @@ -280,10 +282,10 @@ bool version_is_older(std::string_view have, std::string_view want) { std::filesystem::path released_xlings_source(const std::filesystem::path& destBin) { - auto exe = mcpp::platform::fs::self_exe_path(); + auto exe = mcpp::invocation::self_exe_path(); if (exe.empty() || exe.parent_path().filename() != "bin") return {}; auto released = exe.parent_path().parent_path() / "registry" / "bin" - / (std::string("xlings") + std::string(mcpp::platform::exe_suffix)); + / (std::string("xlings") + std::string(mcpplibs::os::exe_suffix)); std::error_code ec; if (!std::filesystem::is_regular_file(released, ec)) return {}; if (!destBin.empty() && std::filesystem::equivalent(released, destBin, ec)) @@ -306,8 +308,8 @@ std::optional select_xlings_source(const std::filesystem::path& de const std::filesystem::path& versionMemo) { std::optional override_, released, onPath; std::error_code ec; - if (const char* e = std::getenv("MCPP_VENDORED_XLINGS"); e && *e) { - std::filesystem::path p{e}; + if (auto e = mcpp::invocation::get("MCPP_VENDORED_XLINGS")) { + std::filesystem::path p{*e}; if (std::filesystem::exists(p, ec)) override_ = XlingsSource{p, known_xlings_version(p, versionMemo), "MCPP_VENDORED_XLINGS"}; } @@ -315,8 +317,8 @@ std::optional select_xlings_source(const std::filesystem::path& de if (auto r = released_xlings_source(destBin); !r.empty()) released = XlingsSource{r, known_xlings_version(r, versionMemo), "the release of this mcpp"}; - if (auto sys = mcpp::platform::fs::which( - std::string("xlings") + std::string(mcpp::platform::exe_suffix))) { + if (auto sys = mcpp::invocation::which( + std::string("xlings") + std::string(mcpplibs::os::exe_suffix))) { const bool isDest = !destBin.empty() && std::filesystem::equivalent(*sys, destBin, ec); if (!isDest && std::filesystem::exists(*sys, ec)) onPath = XlingsSource{*sys, known_xlings_version(*sys, versionMemo), "PATH"}; diff --git a/src/fetcher/progress.cppm b/src/fetcher/progress.cppm index f21574e3..725df93d 100644 --- a/src/fetcher/progress.cppm +++ b/src/fetcher/progress.cppm @@ -15,6 +15,7 @@ import mcpp.fetcher; import mcpp.libs.json; import mcpp.log; import mcpp.ui; +import mcpp.invocation; namespace mcpp::fetcher { @@ -166,7 +167,7 @@ export mcpp::ui::PathContext make_path_ctx(const mcpp::config::GlobalConfig* cfg mcpp::ui::PathContext ctx; ctx.project_root = std::move(project_root); if (cfg) ctx.mcpp_home = cfg->mcppHome; - if (auto* h = std::getenv("HOME"); h && *h) ctx.home = h; + if (auto h = mcpp::invocation::get("HOME")) ctx.home = *h; return ctx; } diff --git a/src/home.cppm b/src/home.cppm index eba47311..de197d67 100644 --- a/src/home.cppm +++ b/src/home.cppm @@ -18,7 +18,10 @@ module; export module mcpp.home; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.invocation; +import mcpp.build.command_line; +import mcpp.home.files; import mcpp.libs.toml; export namespace mcpp::home { @@ -92,12 +95,12 @@ namespace { std::filesystem::path default_home() { // Windows: %USERPROFILE%\.mcpp POSIX: $HOME/.mcpp - if constexpr (mcpp::platform::is_windows) { - if (auto* e = std::getenv("USERPROFILE"); e && *e) - return std::filesystem::path(e) / ".mcpp"; + if constexpr (mcpplibs::os::is_windows) { + if (auto e = mcpp::invocation::get("USERPROFILE")) + return std::filesystem::path(*e) / ".mcpp"; } - if (auto* e = std::getenv("HOME"); e && *e) - return std::filesystem::path(e) / ".mcpp"; + if (auto e = mcpp::invocation::get("HOME")) + return std::filesystem::path(*e) / ".mcpp"; return std::filesystem::current_path() / ".mcpp"; } @@ -114,7 +117,7 @@ std::filesystem::path default_home() { // 3. fallback to $HOME/.mcpp (%USERPROFILE%\.mcpp on Windows). namespace { std::filesystem::path portable_root() { - auto exe = mcpp::platform::fs::self_exe_path(); + auto exe = mcpp::invocation::self_exe_path(); if (exe.has_parent_path() && exe.parent_path().filename() == "bin") { auto candidate = exe.parent_path().parent_path(); @@ -143,13 +146,13 @@ std::filesystem::path portable_root() { } // namespace std::filesystem::path original_root() { - if (auto* e = std::getenv("MCPP_HOME"); e && *e) return std::filesystem::path(e); + if (auto e = mcpp::invocation::get("MCPP_HOME")) return std::filesystem::path(*e); const auto portable = portable_root(); return portable.empty() ? default_home() : portable; } std::string original_source() { - if (auto* e = std::getenv("MCPP_HOME"); e && *e) return "environment"; + if (mcpp::invocation::get("MCPP_HOME")) return "environment"; return portable_root().empty() ? "user" : "portable"; } @@ -176,7 +179,7 @@ std::expected absolute_home(const std::files bool contains_path(const std::filesystem::path& parent, const std::filesystem::path& child) { // Case folding matters even for a not-yet-created Windows destination. auto p = spelling(parent), c = spelling(child); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { // Existing ancestors use filesystem identity, including Unicode case // and directory aliases; ASCII folding alone does not cover those. for (auto ancestor = child; !ancestor.empty();) { @@ -255,11 +258,11 @@ bool initialized(const std::filesystem::path& path) { std::expected choose(const std::filesystem::path& target, bool force) { if (target.empty()) return std::unexpected("--home requires a non-empty directory"); if (original_source() == "user") { - const auto* user = std::getenv("HOME"); - if constexpr (mcpp::platform::is_windows) { - if (const auto* profile = std::getenv("USERPROFILE"); profile && *profile) user = profile; + auto user = mcpp::invocation::get("HOME"); + if constexpr (mcpplibs::os::is_windows) { + if (auto profile = mcpp::invocation::get("USERPROFILE")) user = profile; } - if (!user || !*user) + if (!user) return std::unexpected("cannot save a default home without a user directory; set MCPP_HOME explicitly"); } auto original = absolute_home(original_root()); @@ -273,7 +276,7 @@ std::expected choose(const std::filesystem::path& target // The command is for pasting into a shell, so the path in it is // quoted by the shell's rules (mcpp#832: a home under `/tmp/mc probe` // printed a command that selected `/tmp/mc` and passed `probe` on). - return std::unexpected(std::format("a home is already initialized at '{}'; use `mcpp self init --force --home {}` to select '{}'", spelling(before->effective), mcpp::platform::shell::paste_arg(spelling(*effective)), spelling(*effective))); + return std::unexpected(std::format("a home is already initialized at '{}'; use `mcpp self init --force --home {}` to select '{}'", spelling(before->effective), mcpp::build::command_line::paste_arg(spelling(*effective)), spelling(*effective))); if (!same_path(*original, *effective) && (contains_path(*original, *effective) || contains_path(*effective, *original))) return std::unexpected("the original and selected home must not contain each other"); Selection result{.original = *original, .effective = *effective, @@ -303,7 +306,7 @@ std::expected save(const Selection& selected) { return std::unexpected(std::format("cannot write home locator '{}'; the previous default was retained", spelling(locator))); } } - mcpp::platform::fs::replace_file(staged, locator, ec); + mcpp::home::replace_file(staged, locator, ec); if (ec) { std::error_code ignored; std::filesystem::remove(staged, ignored); } } if (ec) return std::unexpected(std::format("cannot save home locator '{}': {}", spelling(locator), ec.message())); @@ -312,10 +315,9 @@ std::expected save(const Selection& selected) { } std::filesystem::path system_config_file() { - if (auto* e = std::getenv("MCPP_SYSTEM_CONFIG"); e && *e) return std::filesystem::path(e); - if constexpr (mcpp::platform::is_windows) { - auto* data = std::getenv("ProgramData"); - return std::filesystem::path(data && *data ? data : "C:\\ProgramData") / "mcpp" / "config.toml"; + if (auto e = mcpp::invocation::get("MCPP_SYSTEM_CONFIG")) return std::filesystem::path(*e); + if constexpr (mcpplibs::os::is_windows) { + return std::filesystem::path(mcpp::invocation::get("ProgramData").value_or("C:\\ProgramData")) / "mcpp" / "config.toml"; } return "/etc/mcpp/config.toml"; } @@ -349,11 +351,10 @@ std::string toml_string(const std::filesystem::path& file, std::string_view tabl std::filesystem::path system_root() { if (auto h = toml_string(system_config_file(), "system", "home"); !h.empty()) return std::filesystem::path(h); - if constexpr (mcpp::platform::is_windows) { - auto* data = std::getenv("ProgramData"); - return std::filesystem::path(data && *data ? data : "C:\\ProgramData") / "mcpp"; + if constexpr (mcpplibs::os::is_windows) { + return std::filesystem::path(mcpp::invocation::get("ProgramData").value_or("C:\\ProgramData")) / "mcpp"; } - if constexpr (mcpp::platform::is_macos) return "/Library/Application Support/mcpp"; + if constexpr (mcpplibs::os::is_macos) return "/Library/Application Support/mcpp"; return "/var/lib/mcpp"; } diff --git a/src/home/files.cpp b/src/home/files.cpp new file mode 100644 index 00000000..28e5d76f --- /dev/null +++ b/src/home/files.cpp @@ -0,0 +1,109 @@ +// mcpp.home.files: the waiting, the owner record and the trust rule over +// mcpplibs.os; the lock itself is each system's (native/). +module mcpp.home.files; + +import std; +import mcpplibs.os.fs; + +namespace mcpp::home { + +std::optional FileLock::try_acquire(const std::filesystem::path& dir, std::string_view name) { + std::error_code ec; + std::filesystem::create_directories(dir, ec); + const auto handle = native::lock_file(dir / std::string(name)); + if (handle == -1) return std::nullopt; + return FileLock{handle}; +} + +FileLock::~FileLock() { if (native_ != -1) native::unlock_file(native_); } + +FileLock::FileLock(FileLock&& o) noexcept : native_(o.native_) { o.native_ = -1; } + +FileLock& FileLock::operator=(FileLock&& o) noexcept { + if (this != &o) { + if (native_ != -1) native::unlock_file(native_); + native_ = o.native_; + o.native_ = -1; + } + return *this; +} + +std::optional FileLock::acquire( + const std::filesystem::path& dir, std::string_view name, std::string_view owner, + std::chrono::milliseconds notice, + const std::function& waiting) { + namespace fs = std::filesystem; + const auto start = std::chrono::steady_clock::now(); + auto pause = std::chrono::milliseconds(20); + bool told = false; + for (;;) { + if (auto lock = try_acquire(dir, name)) { + if (!owner.empty()) { + const auto record = dir / (std::string(name) + ".owner"); + auto tmp = record; + tmp += std::format(".{:x}", std::random_device{}()); + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + out << owner; + } + std::error_code ec; + fs::rename(tmp, record, ec); + if (ec) fs::remove(tmp, ec); + } + return lock; + } + // try_acquire could not even create the file: nothing to wait for. + std::error_code ec; + if (!fs::exists(dir / std::string(name), ec)) return std::nullopt; + if (!told && std::chrono::steady_clock::now() - start >= notice) { + told = true; + if (waiting) waiting(holder(dir, name)); + } + std::this_thread::sleep_for(pause); + pause = (std::min)(pause * 2, std::chrono::milliseconds(200)); // parenthesised: defines min + } +} + +std::string FileLock::holder(const std::filesystem::path& dir, std::string_view name) { + std::ifstream in(dir / (std::string(name) + ".owner"), std::ios::binary); + return in ? std::string(std::istreambuf_iterator(in), {}) : std::string{}; +} + + +bool replace_file(const std::filesystem::path& source, const std::filesystem::path& destination, + std::error_code& ec) { + // An editor or a virus scanner may hold the destination for a moment + // (Windows refuses the rename with a sharing violation): a few tries with + // a growing pause, then the failure. + auto delay = std::chrono::milliseconds{50}; + for (int attempt = 0; attempt < 4; ++attempt) { + const auto r = mcpplibs::os::fs::rename(source, destination, true); + if (r) { ec.clear(); return true; } + if (attempt == 3 || (r.error().code != mcpplibs::os::errc::permission + && r.error().code != mcpplibs::os::errc::again)) { + ec = std::make_error_code(r.error().code == mcpplibs::os::errc::not_found + ? std::errc::no_such_file_or_directory + : std::errc::permission_denied); + return false; + } + std::this_thread::sleep_for(delay); + delay *= 3; + } + return false; +} + +std::string untrusted_because(const std::filesystem::path& dir) { + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) return "it is not a directory"; + switch (mcpplibs::os::fs::owner_of(dir)) { + case mcpplibs::os::fs::owner::this_user: return {}; + case mcpplibs::os::fs::owner::another_user: return "it belongs to another user"; + case mcpplibs::os::fs::owner::unknown: return "its owner cannot be read"; + case mcpplibs::os::fs::owner::system: + return mcpplibs::os::fs::writable(dir) ? "it belongs to the system and this user can write it" + : std::string{}; + } + return {}; +} + +} // namespace mcpp::home diff --git a/src/home/files.cppm b/src/home/files.cppm new file mode 100644 index 00000000..a0c95813 --- /dev/null +++ b/src/home/files.cppm @@ -0,0 +1,84 @@ +// mcpp.home.files — the files mcpp keeps under a home and a project that other +// processes share: a lock compatible with every release of mcpp, a file +// replaced without losing the last good copy, and whether a directory another +// person controls may be trusted. +export module mcpp.home.files; + +import std; + +export namespace mcpp::home { + +// ── FileLock ────────────────────────────────────────────────────────────── +// +// RAII exclusive non-blocking file lock. +// Windows: LockFileEx on a HANDLE +// POSIX: flock(2) on a file descriptor +// NOT mcpplibs::os::fs::lock, whose POSIX lock is an open file description's +// (F_OFD_SETLK): an flock and such a lock do not exclude each other, and every +// release of mcpp holds these files with flock -- two releases at work in one +// MCPP_HOME would each hold the lock at once. The lock is therefore mcpp's +// contract with its own earlier releases, and its one primitive per system is +// here (native//lock.cpp) and nowhere else in mcpp. +class FileLock { +public: + // Try to acquire an exclusive lock on /.lock (or /). + // Returns nullopt if another process holds the lock. + static std::optional try_acquire(const std::filesystem::path& dir, + std::string_view name = ".lock"); + + // WAITS for the lock on /, which `try_acquire` never does. + // + // Once held, `owner` is published beside the lock as /.owner + // (replaced atomically): who holds it, for a waiter to name. A sidecar + // rather than the lock file's own bytes, because a Windows byte-range lock + // is mandatory and would refuse that read. While the lock is taken by + // another process, `waiting` is called once, after `notice` has passed, + // with what the holder published (empty when it published nothing). + // + // The wait polls with a backoff from 20 ms to 200 ms rather than blocking + // in the kernel: a blocking flock/LockFileEx cannot report the wait, and a + // waiter killed by a signal holds nothing either way. nullopt only when + // the lock file cannot be created at all (an unwritable directory, whose + // build fails on its own terms). + static std::optional acquire( + const std::filesystem::path& dir, std::string_view name, std::string_view owner, + std::chrono::milliseconds notice, + const std::function& waiting); + + // What the current holder of / published (see acquire). + static std::string holder(const std::filesystem::path& dir, std::string_view name); + + ~FileLock(); + FileLock(FileLock&& o) noexcept; + FileLock& operator=(FileLock&& o) noexcept; + + FileLock(const FileLock&) = delete; + FileLock& operator=(const FileLock&) = delete; + +private: + // A descriptor, or a HANDLE; -1 is neither (INVALID_HANDLE_VALUE). + std::intptr_t native_ = -1; + explicit FileLock(std::intptr_t native) : native_(native) {} +}; + +// `source` renamed over `destination`, which is not removed first: a failed +// replacement leaves the last good copy (a compilation database an editor +// reads). A destination another program holds open is retried briefly. +bool replace_file(const std::filesystem::path& source, const std::filesystem::path& destination, + std::error_code& ec); + +// Whether a directory another program wrote may be read and run from: it +// belongs to this user or to the system (root; on Windows also Administrators +// and SYSTEM), and when it is not this user's, this user cannot write it. +// Empty: trusted; else why not. Using another person's home is running what +// that person can replace at any time. +std::string untrusted_because(const std::filesystem::path& dir); + +} // namespace mcpp::home + +// The one primitive per system (native//lock.cpp). Not exported. +namespace mcpp::home::native { +// An exclusive lock on the file, taken without waiting: the handle, or -1. +std::intptr_t lock_file(const std::filesystem::path& file); +void unlock_file(std::intptr_t handle); +} // namespace mcpp::home::native diff --git a/src/hooks.cppm b/src/hooks.cppm index 2e529e22..ddec2e1c 100644 --- a/src/hooks.cppm +++ b/src/hooks.cppm @@ -20,7 +20,7 @@ export module mcpp.hooks; import std; import mcpp.manifest; -import mcpp.platform.process; +import mcpp.run; import mcpp.ui; export namespace mcpp::hooks { @@ -74,7 +74,7 @@ public: private: void close(); - mcpp::platform::process::BackgroundCommand child_; + mcpp::run::BackgroundCommand child_; std::string command_; std::filesystem::path root_; bool inheritOutput_ = false; @@ -94,7 +94,7 @@ namespace mcpp::hooks { namespace { -namespace proc = mcpp::platform::process; +namespace proc = mcpp::run; // `loop` restarts a command that ended before its interval did. Two bounds, // fixed rather than configurable, because a knob whose wrong value is a spin diff --git a/src/main.cpp b/src/main.cpp index 8285dc27..5147635a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,12 +5,13 @@ import std; import mcpp.cli; import mcpp.ui; import mcpp.home; -import mcpp.platform; +import mcpp.run; +import mcpplibs.os.platform; int main(int argc, char* argv[]) { - // Before anything is written: `mcpp … | head` ends mcpp in order, and no - // program mcpp starts inherits SIGPIPE ignored (part 2 design, §6.4). - mcpp::platform::process::guard_broken_pipe(); + // Before anything is started or written: the requests to end are taken + // over by the watcher that ends mcpp in order (design 2026-10-26 §25.5). + mcpp::run::install(); // Line-buffer stdout even when it is not a TTY. // @@ -42,6 +43,13 @@ int main(int argc, char* argv[]) { } catch (const mcpp::home::Error& e) { mcpp::ui::error(e.what()); rc = 4; + } catch (const std::system_error& e) { + // A print to a standard output whose reader is gone: mcpp ends as the + // system ends such a program, in the order an interrupt does (design + // 2026-10-26 §25.5). Where the system has no such ending, mcpp goes on. + if (e.code() == std::errc::broken_pipe) mcpp::run::end_for_closed_output(); + mcpp::ui::error(std::format("internal: unhandled exception: {}", e.what())); + rc = 70; } catch (const std::exception& e) { // Last-resort boundary: without it an escaped exception is // std::terminate — on Windows a silent 0xC0000409 that git-bash @@ -57,16 +65,16 @@ int main(int argc, char* argv[]) { // region on the screen; the shell's prompt must not follow it on the // status line. mcpp::ui::close_region(); -#ifdef __APPLE__ + if (mcpp::ui::output_closed()) mcpp::run::end_for_closed_output(); // With statically linked libc++ (the macOS release linkage since // 0.0.50), static destruction can SIGABRT on exit — same issue xlings // guards against. A CLI tool needs no destructor-based cleanup; skip // static dtors entirely. _Exit bypasses atexit handlers too, so flush // the standard streams explicitly first. - std::cout.flush(); - std::cerr.flush(); - std::_Exit(rc); -#else + if constexpr (mcpplibs::os::is_macos) { + std::cout.flush(); + std::cerr.flush(); + std::_Exit(rc); + } return rc; -#endif } diff --git a/src/modgraph/p1689.cppm b/src/modgraph/p1689.cppm index c51d36e4..e11e0081 100644 --- a/src/modgraph/p1689.cppm +++ b/src/modgraph/p1689.cppm @@ -22,9 +22,8 @@ export module mcpp.modgraph.p1689; import std; import mcpp.modgraph.graph; -import mcpp.platform; +import mcpp.run; import mcpp.source_kind; -import mcpp.toolchain.detect; import mcpp.toolchain.model; import mcpp.toolchain.linkmodel; @@ -304,8 +303,10 @@ std::expected parse_ddi(std::string_view body) { namespace { +// The scan command is composed as text, with the flags the link model renders +// for a shell, as the ninja scan edge is. bool run_capturing(const std::string& cmd, std::string& out) { - auto r = mcpp::platform::process::capture(cmd); + auto r = mcpp::run::capture_shell(cmd); out = r.output; return r.exit_code == 0; } diff --git a/src/pack/library.cppm b/src/pack/library.cppm index 0acbef14..5c56a981 100644 --- a/src/pack/library.cppm +++ b/src/pack/library.cppm @@ -42,7 +42,9 @@ import mcpp.pack.relocate; import mcpp.pack.strip; import mcpp.source_kind; // builtin_extension_table — what needs no declaring import mcpp.pack.zip; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.build.command_line; +import mcpp.run; import mcpp.ui; export namespace mcpp::pack { @@ -138,11 +140,11 @@ std::expected run_library_pack(const Li // // Pinning the two constants in dialect.cppm is not enough: the order they are // assembled in is a third fact, and it lives here. -std::string archive_remove_command(const std::filesystem::path& tool, - const std::filesystem::path& archive, - const std::vector& members, - std::string_view removeArg, - bool archiveFirst); +std::vector archive_remove_command(const std::filesystem::path& tool, + const std::filesystem::path& archive, + const std::vector& members, + std::string_view removeArg, + bool archiveFirst); } // namespace mcpp::pack @@ -176,35 +178,33 @@ std::vector walk(const std::filesystem::path& root) { } // namespace -std::string archive_remove_command(const std::filesystem::path& tool, - const std::filesystem::path& archive, - const std::vector& members, - std::string_view removeArg, - bool archiveFirst) +std::vector archive_remove_command(const std::filesystem::path& tool, + const std::filesystem::path& archive, + const std::vector& members, + std::string_view removeArg, + bool archiveFirst) { - std::string cmd = mcpp::platform::shell::quote(tool.string()); + std::vector argv{ tool.string() }; // `{}` in removeArg means one flag per member (lib.exe /REMOVE:); its // absence means one verb followed by every member (ar d ...). const bool perMember = removeArg.find("{}") != std::string_view::npos; - auto member_words = [&] { - std::string w; + auto add_members = [&] { for (auto const& m : members) { - if (!perMember) { w += " " + mcpp::platform::shell::quote(m); continue; } + if (!perMember) { argv.push_back(m); continue; } std::string arg{ removeArg }; arg.replace(arg.find("{}"), 2, m); - w += " " + mcpp::platform::shell::quote(arg); + argv.push_back(std::move(arg)); } - return w; }; if (archiveFirst) { - if (!perMember) cmd += " " + std::string(removeArg); - cmd += " " + mcpp::platform::shell::quote(archive.string()); - cmd += member_words(); + if (!perMember) argv.emplace_back(removeArg); + argv.push_back(archive.string()); + add_members(); } else { - cmd += member_words(); - cmd += " " + mcpp::platform::shell::quote(archive.string()); + add_members(); + argv.push_back(archive.string()); } - return cmd; + return argv; } std::expected @@ -302,10 +302,11 @@ run_library_pack(const LibraryPackPlan& plan) leg.triple, name) }); } if (!leg.shared && !plan.dropObjects.empty()) { - const auto cmd = archive_remove_command( + const auto argv = archive_remove_command( leg.archiveTool, dst, plan.dropObjects, leg.removeArg, leg.removeArchiveFirst); - auto r = mcpp::platform::process::capture(cmd + " 2>&1"); + const auto cmd = mcpp::build::command_line::display(argv); + auto r = mcpp::run::execute(argv); if (r.exit_code != 0) { return std::unexpected(LibraryPackError{ std::format( "cannot drop published interface objects from '{}' (rc={}).\n" @@ -508,12 +509,12 @@ run_library_pack(const LibraryPackPlan& plan) // Reached only by a package with more than one leg: a single PE leg is // written as a zip, so a Windows host never ran this path until a fat // package existed. Same fix, same reason, in pack.cppm's make_tarball. - auto cmd = std::format("tar {}-czf {} -C {} {}", - mcpp::platform::is_windows ? "--force-local " : "", - mcpp::platform::shell::quote(plan.archivePath.string()), - mcpp::platform::shell::quote(plan.stagingRoot.parent_path().string()), - mcpp::platform::shell::quote(plan.stagingRoot.filename().string())); - auto r = mcpp::platform::process::capture(cmd + " 2>&1"); + std::vector argv{ "tar" }; + if constexpr (mcpplibs::os::is_windows) argv.push_back("--force-local"); + argv.insert(argv.end(), { "-czf", plan.archivePath.string(), + "-C", plan.stagingRoot.parent_path().string(), + plan.stagingRoot.filename().string() }); + auto r = mcpp::run::execute(argv); if (r.exit_code != 0) return std::unexpected(LibraryPackError{ std::format( "tar failed (rc={}): {}", r.exit_code, r.output) }); diff --git a/src/pack/library_pipeline.cppm b/src/pack/library_pipeline.cppm index 0f1ae3e0..58a3dcc6 100644 --- a/src/pack/library_pipeline.cppm +++ b/src/pack/library_pipeline.cppm @@ -40,7 +40,6 @@ import mcpp.pack.interface; import mcpp.pack.library; import mcpp.pack.strip; import mcpp.toolchain.model; -import mcpp.platform; import mcpp.toolchain.dialect; import mcpp.toolchain.registry; import mcpp.toolchain.triple; diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 68a0ff27..873cc30c 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -47,7 +47,11 @@ import mcpp.pack.relocate; import mcpp.pack.stage_tree; import mcpp.pack.strip; import mcpp.pack.zip; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; +import mcpp.invocation; +import mcpp.build.command_line; +import mcpp.run; import mcpp.provider; import mcpp.provider.select; import mcpp.manifest; @@ -731,20 +735,19 @@ make_plan(const mcpp::manifest::Manifest& manifest, namespace detail { -// Run a shell command, capturing stdout. Returns the captured text on +// A program's output and errors together. Returns the captured text on // success, or an error message on non-zero exit. std::expected -run_capture(const std::string& cmd) { - auto r = mcpp::platform::process::capture(cmd); +run_capture(const std::vector& argv, const mcpp::run::Env& env = {}) { + auto r = mcpp::run::execute(argv, { .env = env }); if (r.exit_code != 0) return std::unexpected(std::format( - "command exited with {}: {}", r.exit_code, cmd)); + "command exited with {}: {}", r.exit_code, mcpp::build::command_line::display(argv))); return r.output; } -// Run a shell command and discard stdout/stderr; return exit code. -int run_silent(const std::string& cmd) { - auto silent = cmd + " " + std::string(mcpp::platform::shell::silent_redirect); - return mcpp::platform::process::run_silent(silent); +// A program whose output is not read; its exit code. +int run_silent(const std::vector& argv) { + return mcpp::run::execute(argv, { .streams = mcpp::run::Streams::discard }).exit_code; } // ─── ldd parsing + manylinux skip-list ────────────────────────────── @@ -813,9 +816,7 @@ ldd_parse(const std::filesystem::path& binary) // executing main(). This routes through the binary's *own* // PT_INTERP so it works even when our sandbox's ldd wrapper is // broken or missing. - auto cmd = std::format( - "LD_TRACE_LOADED_OBJECTS=1 '{}' 2>&1", binary.string()); - auto out = run_capture(cmd); + auto out = run_capture({ binary.string() }, { { "LD_TRACE_LOADED_OBJECTS", "1" } }); if (!out) return std::unexpected(out.error()); LddClosure deps; @@ -896,13 +897,12 @@ set_search_path(const std::filesystem::path& object, { if (patchelf.empty() || !std::filesystem::exists(patchelf)) return std::unexpected("patchelf not available in sandbox"); - std::string extra; + std::vector argv{ patchelf.string(), "--set-rpath", std::string(rpath) }; if (auto flag = mcpp::build::loader::patchelf_flag( mcpp::build::loader::required_tag(form))) - extra = std::format(" {}", *flag); - auto cmd = std::format("'{}' --set-rpath '{}'{} '{}'", - patchelf.string(), rpath, extra, object.string()); - int rc = run_silent(cmd); + argv.emplace_back(*flag); + argv.push_back(object.string()); + int rc = run_silent(argv); if (rc != 0) return std::unexpected(std::format( "patchelf --set-rpath failed (exit {}): {}", rc, object.string())); return {}; @@ -918,9 +918,7 @@ set_interpreter(const std::filesystem::path& binary, { if (patchelf.empty() || !std::filesystem::exists(patchelf)) return std::unexpected("patchelf not available in sandbox"); - auto cmd = std::format("'{}' --set-interpreter '{}' '{}'", - patchelf.string(), interp, binary.string()); - int rc = run_silent(cmd); + int rc = run_silent({ patchelf.string(), "--set-interpreter", std::string(interp), binary.string() }); if (rc != 0) return std::unexpected(std::format( "patchelf --set-interpreter failed (exit {}): {}", rc, binary.string())); return {}; @@ -1412,13 +1410,11 @@ make_tarball(const std::filesystem::path& stagingRoot, // observed — a PE target is written as a zip — but it is the same command // with the same hazard as the library packer's, and the two should not // differ in whether they survive being run on Windows. - auto cmd = std::format( - "tar {}-czf '{}' -C '{}' '{}'", - mcpp::platform::is_windows ? "--force-local " : "", - tarballPath.string(), - stagingRoot.parent_path().string(), - stagingRoot.filename().string()); - auto r = run_capture(cmd); + std::vector argv{ "tar" }; + if constexpr (mcpplibs::os::is_windows) argv.push_back("--force-local"); + argv.insert(argv.end(), { "-czf", tarballPath.string(), "-C", stagingRoot.parent_path().string(), + stagingRoot.filename().string() }); + auto r = run_capture(argv); if (!r) return std::unexpected(Error{r.error()}); return {}; } @@ -2066,8 +2062,8 @@ okimage_build_selfcontained(const Plan& plan, const std::filesystem::path& image stem, runSpec); // The section, in the syntax of this machine's object format; the symbol // spelling carries Mach-O's leading underscore. - const bool macho = mcpp::platform::is_macos; - const bool coff = mcpp::platform::is_windows; + const bool macho = mcpplibs::os::is_macos; + const bool coff = mcpplibs::os::is_windows; const std::string sym = macho ? "_" : ""; const std::string section = macho ? "\t.section __OKE,__image\n" : coff ? "\t.section .oke,\"dr\"\n" @@ -2105,16 +2101,16 @@ okimage_build_selfcontained(const Plan& plan, const std::filesystem::path& image // `-o` with a bare name: the package directory is `target/dist/`. const auto out = dir / "target" / "dist" / "packed"; fs::remove_all(out, ec); - std::vector argv{ mcpp::platform::fs::self_exe_path().string(), "pack", + std::vector argv{ mcpp::invocation::self_exe_path().string(), "pack", "--format", "dir", "-o", "packed" }; - if constexpr (mcpp::platform::is_linux) { - argv.insert(argv.end(), { "--target", std::format("{}-linux-musl", mcpp::platform::host_arch), + if constexpr (mcpplibs::os::is_linux) { + argv.insert(argv.end(), { "--target", std::format("{}-linux-musl", mcpp::toolchain::platform::host_arch), "--mode", "static" }); } else { argv.insert(argv.end(), { "--mode", "vendored" }); } int spawnError = 0; - auto built = mcpp::platform::process::capture_exec(argv, {}, dir.string(), &spawnError); + auto built = mcpp::run::capture_exec(argv, {}, dir.string(), &spawnError); if (spawnError != 0 || built.exit_code != 0) return std::unexpected(Error{std::format( "packing the self-contained program failed (in {}):\n{}", dir.string(), built.output)}); @@ -2143,7 +2139,7 @@ run_okimage(const Plan& plan) // The images it names, found where the build placed them. std::vector needed; if (plan.opts.mode != Mode::None) { - auto facts = mcpp::platform::elf::inspect_elf_runtime(plan.builtBinary); + auto facts = mcpp::runtime::elf::inspect_elf_runtime(plan.builtBinary); if (!facts) return std::unexpected(Error{facts.error()}); const auto builtDir = plan.builtBinary.parent_path(); for (auto const& name : facts->needed) { @@ -2164,7 +2160,7 @@ run_okimage(const Plan& plan) fs::copy(*packed, plan.stagingRoot, fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); staged = plan.stagingRoot / "bin" - / (fs::path(plan.binaryName).stem().string() + std::string(mcpp::platform::exe_suffix)); + / (fs::path(plan.binaryName).stem().string() + std::string(mcpplibs::os::exe_suffix)); } else { staged = plan.stagingRoot / "bin" / plan.binaryName; fs::copy_file(plan.builtBinary, staged, fs::copy_options::overwrite_existing, ec); @@ -2414,7 +2410,7 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) in.object = plan.builtBinary; in.rule = ClosureRule::MachO; auto t = mcpp::toolchain::triple::parse(plan.triple); - in.arch = t ? t->arch : std::string(mcpp::platform::host_arch); + in.arch = t ? t->arch : std::string(mcpp::toolchain::platform::host_arch); const auto read = read_closure(in); if (auto r = stage_closure(plan, read, plan.stagingRoot / "bin", needs, unresolved, {}); !r) @@ -2437,364 +2433,364 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) return ClosureResult{true, {}, std::move(needs)}; } -#if defined(_WIN32) - // STEP 4, second mechanism: a NON-PE artifact on a Windows host — a cross - // build to Linux or macOS. The closure would be resolved by running the - // binary under its own dynamic linker, which this machine cannot do — so - // say that, rather than reporting a platform limitation that no longer - // exists for the case a Windows user is actually likely to hit. - // - // Also step 4's outcome now, for the same reason the Mach-O branch above - // is: the tree from `stage_declared` already exists when a dispatched - // format reaches this point. - (void)cfg; - return finish_without_closure(plan, std::format( - "cannot package a {} artifact from a Windows host.\n" - " The dependency closure for that format is resolved by running " - "the artifact under\n" - " the target's own dynamic linker, which this machine has no way " - "to do. Package it\n" - " on the target OS, or build for Windows (`--target " - "x86_64-pc-windows-msvc`), which\n" - " needs no such step.", - std::string(mcpp::pack::binfmt::format_name( - mcpp::pack::binfmt::identify(plan.builtBinary).format)))); -#else - std::error_code ec; - - // STEP 4, third mechanism, and STEP 5 (the format tail): library - // bundling for non-static modes. - // - // BundleProject (default) — drop all manylinux-allowed system libs - // (libc/libstdc++/ld-linux/...) and bundle the rest. The user can - // extend the skip list via [pack.bundle-project].also_skip / - // .force_bundle. - // - // BundleAll — ship every dep including the dynamic linker; entry - // point becomes `run.sh` which invokes the bundled ld with - // --library-path → fully portable across glibc versions. - // #634 A3: what step 4 resolved, for the stage manifest, and the refusal - // text when it is incomplete. Both stay empty under `--mode static`. - std::vector needs; - std::string unresolvedText; - // #649 E5: (staged, source) of every bundled library, for the strip step - // at the end, which must see the final images (after the search-path edits). - std::vector> bundledCopies; - std::string bundledLoader; - if (plan.opts.mode != Mode::Static) { - // THE BUILT BINARY, NOT THE STAGED COPY, and the difference is - // `$ORIGIN`. - // - // The closure is resolved by running the artifact under its own - // loader. A dependency that mcpp itself built as a shared library - // lives beside the artifact in `bin/` and is found through the - // `$ORIGIN` in its RUNPATH — but the copy in the staging directory has - // an empty `bin/` next to it, so that entry resolves to nothing, the - // library never appears in the closure, and it is therefore never - // bundled. The bundle builds, packs and uploads without complaint, and - // fails on the user's machine with - // `error while loading shared libraries: libfoo.so`. + if constexpr (mcpplibs::os::is_windows) { + // STEP 4, second mechanism: a NON-PE artifact on a Windows host — a cross + // build to Linux or macOS. The closure would be resolved by running the + // binary under its own dynamic linker, which this machine cannot do — so + // say that, rather than reporting a platform limitation that no longer + // exists for the case a Windows user is actually likely to hit. // - // Measured on mcpp 2026.8.26.1 with an author-declared - // `kind = "shared"` dependency, so this predates the consumer-side - // form axis (#519) — but that axis is what makes it reachable for - // EVERY package rather than the handful that declare themselves - // shared, which is why it is fixed here. + // Also step 4's outcome now, for the same reason the Mach-O branch above + // is: the tree from `stage_declared` already exists when a dispatched + // format reaches this point. + (void)cfg; + return finish_without_closure(plan, std::format( + "cannot package a {} artifact from a Windows host.\n" + " The dependency closure for that format is resolved by running " + "the artifact under\n" + " the target's own dynamic linker, which this machine has no way " + "to do. Package it\n" + " on the target OS, or build for Windows (`--target " + "x86_64-pc-windows-msvc`), which\n" + " needs no such step.", + std::string(mcpp::pack::binfmt::format_name( + mcpp::pack::binfmt::identify(plan.builtBinary).format)))); + } else { + std::error_code ec; + + // STEP 4, third mechanism, and STEP 5 (the format tail): library + // bundling for non-static modes. // - // Tracing the built binary is safe: the staged file is a byte copy of - // it and nothing has modified either one at this point (patchelf runs - // further down). What changes is only which directory `$ORIGIN` - // expands to while the loader is looking. + // BundleProject (default) — drop all manylinux-allowed system libs + // (libc/libstdc++/ld-linux/...) and bundle the rest. The user can + // extend the skip list via [pack.bundle-project].also_skip / + // .force_bundle. // - // A NON-ELF ARTIFACT REACHING THIS POINT ASSUMED ELF BY EXCLUSION. - // PE and Mach-O are refused by name above `run()`'s `#else`; nothing - // between there and here asks what is LEFT actually is ELF, because - // ELF used to be the only format left once those two were excluded. - // wasm32-emscripten is the first target where that assumption is - // false: `binfmt::identify` reports `Format::Unknown` for a `.wasm` - // module (it carries none of the three magics), and `ldd_parse` - // below runs the file through the same LD_TRACE_LOADED_OBJECTS - // mechanism the Mach-O branch above refuses by name rather than - // risk — on a host where `.wasm` is registered in `binfmt_misc`, - // that does not fail, it RUNS the module. - if (auto fmt = mcpp::pack::binfmt::identify(plan.builtBinary).format; - fmt != mcpp::pack::binfmt::Format::Elf) { - return finish_without_closure(plan, std::format( - "cannot package the {} artifact '{}' yet.\n" - " Its dependency closure is resolved by running the " - "artifact under its own\n" - " dynamic linker, and this file is neither ELF, PE nor " - "Mach-O -- there is no\n" - " such linker to ask.", - mcpp::pack::binfmt::format_name(fmt), plan.binaryName)); - } - auto deps = ldd_parse(plan.builtBinary); - if (!deps) return std::unexpected(Error{std::format( - "ldd failed on {}: {}", plan.builtBinary.string(), deps.error())}); - - auto skipped_by_mode = [&](std::string_view soname) { - bool skip = false; - if (plan.opts.mode == Mode::None) { - skip = true; // system: host provides every .so, bundle nothing - } else if (plan.opts.mode == Mode::BundleProject) { - if (is_system_lib(soname)) skip = true; - if (soname_matches(soname, plan.alsoSkipLibs)) skip = true; - if (soname_matches(soname, plan.forceBundleLibs)) skip = false; // override + // BundleAll — ship every dep including the dynamic linker; entry + // point becomes `run.sh` which invokes the bundled ld with + // --library-path → fully portable across glibc versions. + // #634 A3: what step 4 resolved, for the stage manifest, and the refusal + // text when it is incomplete. Both stay empty under `--mode static`. + std::vector needs; + std::string unresolvedText; + // #649 E5: (staged, source) of every bundled library, for the strip step + // at the end, which must see the final images (after the search-path edits). + std::vector> bundledCopies; + std::string bundledLoader; + if (plan.opts.mode != Mode::Static) { + // THE BUILT BINARY, NOT THE STAGED COPY, and the difference is + // `$ORIGIN`. + // + // The closure is resolved by running the artifact under its own + // loader. A dependency that mcpp itself built as a shared library + // lives beside the artifact in `bin/` and is found through the + // `$ORIGIN` in its RUNPATH — but the copy in the staging directory has + // an empty `bin/` next to it, so that entry resolves to nothing, the + // library never appears in the closure, and it is therefore never + // bundled. The bundle builds, packs and uploads without complaint, and + // fails on the user's machine with + // `error while loading shared libraries: libfoo.so`. + // + // Measured on mcpp 2026.8.26.1 with an author-declared + // `kind = "shared"` dependency, so this predates the consumer-side + // form axis (#519) — but that axis is what makes it reachable for + // EVERY package rather than the handful that declare themselves + // shared, which is why it is fixed here. + // + // Tracing the built binary is safe: the staged file is a byte copy of + // it and nothing has modified either one at this point (patchelf runs + // further down). What changes is only which directory `$ORIGIN` + // expands to while the loader is looking. + // + // A NON-ELF ARTIFACT REACHING THIS POINT ASSUMED ELF BY EXCLUSION. + // PE and Mach-O are refused by name above `run()`'s `#else`; nothing + // between there and here asks what is LEFT actually is ELF, because + // ELF used to be the only format left once those two were excluded. + // wasm32-emscripten is the first target where that assumption is + // false: `binfmt::identify` reports `Format::Unknown` for a `.wasm` + // module (it carries none of the three magics), and `ldd_parse` + // below runs the file through the same LD_TRACE_LOADED_OBJECTS + // mechanism the Mach-O branch above refuses by name rather than + // risk — on a host where `.wasm` is registered in `binfmt_misc`, + // that does not fail, it RUNS the module. + if (auto fmt = mcpp::pack::binfmt::identify(plan.builtBinary).format; + fmt != mcpp::pack::binfmt::Format::Elf) { + return finish_without_closure(plan, std::format( + "cannot package the {} artifact '{}' yet.\n" + " Its dependency closure is resolved by running the " + "artifact under its own\n" + " dynamic linker, and this file is neither ELF, PE nor " + "Mach-O -- there is no\n" + " such linker to ask.", + mcpp::pack::binfmt::format_name(fmt), plan.binaryName)); } - // Mode::BundleAll: skip nothing — we want the loader too. - return skip; - }; - - // #634 A3: the `needs` lines, from the same decisions that fill the - // tree. A mode that bundles nothing states no closure, as on every - // other row. - const bool bundling = plan.opts.mode != Mode::None; - std::vector toBundle; - for (auto& d : deps->found) { - if (skipped_by_mode(d.soname)) { - if (bundling) needs.push_back({d.soname, ClosureNeed::Kind::Platform, {}}); - continue; + auto deps = ldd_parse(plan.builtBinary); + if (!deps) return std::unexpected(Error{std::format( + "ldd failed on {}: {}", plan.builtBinary.string(), deps.error())}); + + auto skipped_by_mode = [&](std::string_view soname) { + bool skip = false; + if (plan.opts.mode == Mode::None) { + skip = true; // system: host provides every .so, bundle nothing + } else if (plan.opts.mode == Mode::BundleProject) { + if (is_system_lib(soname)) skip = true; + if (soname_matches(soname, plan.alsoSkipLibs)) skip = true; + if (soname_matches(soname, plan.forceBundleLibs)) skip = false; // override + } + // Mode::BundleAll: skip nothing — we want the loader too. + return skip; + }; + + // #634 A3: the `needs` lines, from the same decisions that fill the + // tree. A mode that bundles nothing states no closure, as on every + // other row. + const bool bundling = plan.opts.mode != Mode::None; + std::vector toBundle; + for (auto& d : deps->found) { + if (skipped_by_mode(d.soname)) { + if (bundling) needs.push_back({d.soname, ClosureNeed::Kind::Platform, {}}); + continue; + } + toBundle.push_back(d); + needs.push_back({d.soname, ClosureNeed::Kind::Staged, "lib/" + d.soname}); } - toBundle.push_back(d); - needs.push_back({d.soname, ClosureNeed::Kind::Staged, "lib/" + d.soname}); - } - // A name the loader found no file for is one the tree cannot carry. - // When the mode leaves it to the target anyway it is the target's; - // otherwise the closure is incomplete, and an archive that says - // `walked` would not start where it is installed. - std::vector unresolved; - for (auto const& name : deps->notFound) { - if (!bundling) continue; - // `also_skip` names the target's libraries in every bundling mode - // here, `self-contained` included: the tree cannot carry a file - // this machine does not have. - if (skipped_by_mode(name) || (soname_matches(name, plan.alsoSkipLibs) - && !soname_matches(name, plan.forceBundleLibs))) { - needs.push_back({name, ClosureNeed::Kind::Platform, {}}); - continue; + // A name the loader found no file for is one the tree cannot carry. + // When the mode leaves it to the target anyway it is the target's; + // otherwise the closure is incomplete, and an archive that says + // `walked` would not start where it is installed. + std::vector unresolved; + for (auto const& name : deps->notFound) { + if (!bundling) continue; + // `also_skip` names the target's libraries in every bundling mode + // here, `self-contained` included: the tree cannot carry a file + // this machine does not have. + if (skipped_by_mode(name) || (soname_matches(name, plan.alsoSkipLibs) + && !soname_matches(name, plan.forceBundleLibs))) { + needs.push_back({name, ClosureNeed::Kind::Platform, {}}); + continue; + } + needs.push_back({name, ClosureNeed::Kind::Unresolved, {}}); + unresolved.push_back(name + ": the loader finds no file for it"); } - needs.push_back({name, ClosureNeed::Kind::Unresolved, {}}); - unresolved.push_back(name + ": the loader finds no file for it"); - } - if (!unresolved.empty()) - unresolvedText = unresolved_reason(plan.binaryName, unresolved, - "The loader looks through the program's run-time search path " - "(DT_RPATH, DT_RUNPATH) and\n" - " its own default directories."); - // An archive refuses before any file is edited; a dispatched format - // still receives the tree the resolved names allow. - if (!unresolved.empty() && plan.opts.format != Format::Dispatched) - return finish_without_closure(plan, unresolvedText, needs); - - if (auto r = bundle_libs(toBundle, plan.stagingRoot); !r) - return std::unexpected(Error{r.error()}); - for (auto const& d : toBundle) - bundledCopies.emplace_back(plan.stagingRoot / "lib" / d.soname, d.path); - bundledLoader = find_loader_soname(toBundle); - - // Search path: point at bundled libs, or REMOVE THE TAG if there are none. - // - // The empty case used to be `patchelf --set-rpath ''`, which leaves the - // tag present with an empty string — and a present-but-empty DT_RUNPATH - // is not inert: it suppresses the inherited DT_RPATH chain exactly like - // a stale one does (measured — see mcpp.pack.relocate). Harmless on an - // executable, which is the top of that chain, but there is no reason to - // write a tag that says nothing. - // - // OUTSIDE the patchelf guard, because it no longer needs patchelf. That - // is the whole point of the in-process editor: `--mode system` on a host - // where the sandbox has no patchelf used to leave the build machine's - // store in the artifact and say nothing at all. - if (toBundle.empty()) { - if (auto r = mcpp::pack::relocate::strip_search_paths(bundledBinary); !r) + if (!unresolved.empty()) + unresolvedText = unresolved_reason(plan.binaryName, unresolved, + "The loader looks through the program's run-time search path " + "(DT_RPATH, DT_RUNPATH) and\n" + " its own default directories."); + // An archive refuses before any file is edited; a dispatched format + // still receives the tree the resolved names allow. + if (!unresolved.empty() && plan.opts.format != Format::Dispatched) + return finish_without_closure(plan, unresolvedText, needs); + + if (auto r = bundle_libs(toBundle, plan.stagingRoot); !r) return std::unexpected(Error{r.error()}); - } + for (auto const& d : toBundle) + bundledCopies.emplace_back(plan.stagingRoot / "lib" / d.soname, d.path); + bundledLoader = find_loader_soname(toBundle); - auto patchelf = sandbox_patchelf(cfg); - if (!patchelf.empty()) { - // non-empty bundle → "$ORIGIN/../lib" so the binary finds them - if (!toBundle.empty()) { - if (auto r = set_search_path(bundledBinary, "$ORIGIN/../lib", - mcpp::build::loader::Form::Executable, - patchelf); !r) - return std::unexpected(Error{r.error()}); - } - - // EVERY BUNDLED LIBRARY, not just the executable. - // - // A bundled .so keeps whatever RUNPATH it was built with, and on - // this ecosystem that is a set of ABSOLUTE paths into the BUILD - // MACHINE's xlings store. Measured on a graphics artifact: + // Search path: point at bundled libs, or REMOVE THE TAG if there are none. // - // /xim-x-glibc/2.44/lib64 : /xim-x-gcc/16.1.0/lib64 - // : /compat-x-glx-runtime/…/lib : $ORIGIN + // The empty case used to be `patchelf --set-rpath ''`, which leaves the + // tag present with an empty string — and a present-but-empty DT_RUNPATH + // is not inert: it suppresses the inherited DT_RPATH chain exactly like + // a stale one does (measured — see mcpp.pack.relocate). Harmless on an + // executable, which is the top of that chain, but there is no reason to + // write a tag that says nothing. // - // Those directories do not exist on the target, and worse, if the - // target happens to be another developer's machine they exist with - // DIFFERENT contents. "Depends on the xlings ecosystem" would be a - // design choice; "depends on this one machine's store" is a defect, - // and it is invisible because the bundle runs fine where it was - // built. $ORIGIN is where its siblings actually are. - // - // EXCEPT THE DYNAMIC LOADER. `ld-linux-*.so` is not a shared - // library that gets searched for; it is the program that DOES the - // searching, and it is loaded by the kernel from an absolute path - // (PT_INTERP, or `run.sh`'s explicit invocation). Rewriting its - // own search path is meaningless, and patchelf rewriting it is - // destructive: Mode `self-contained` then segfaults before main, - // because the thing that was supposed to resolve the process's - // libraries no longer loads. Found by `30_pack_modes`. - auto loaderSoname = find_loader_soname(toBundle); - for (auto const& dep : toBundle) { - if (!loaderSoname.empty() && dep.soname == loaderSoname) continue; - auto staged = plan.stagingRoot / "lib" / dep.soname; - std::error_code ec; - if (!std::filesystem::is_regular_file(staged, ec)) continue; - if (auto r = set_search_path( - staged, "$ORIGIN", - mcpp::build::loader::Form::SharedLibrary, patchelf); !r) + // OUTSIDE the patchelf guard, because it no longer needs patchelf. That + // is the whole point of the in-process editor: `--mode system` on a host + // where the sandbox has no patchelf used to leave the build machine's + // store in the artifact and say nothing at all. + if (toBundle.empty()) { + if (auto r = mcpp::pack::relocate::strip_search_paths(bundledBinary); !r) return std::unexpected(Error{r.error()}); } - // PT_INTERP handling differs by mode: - // BundleProject → repoint to the target distro's loader - // (LSB layout: /lib64/ on x86_64, - // /lib/ elsewhere), derived from the - // loader soname ldd resolved for THIS binary — - // arch-correct without hardcoding a name. - // BundleAll → leave PT_INTERP alone; the wrapper script - // ignores it and launches via the bundled - // loader directly. - if (plan.opts.mode == Mode::BundleProject || plan.opts.mode == Mode::None) { - if (auto soname = find_loader_soname(deps->found); !soname.empty()) { - auto distroLoader = - (soname == "ld-linux-x86-64.so.2" ? "/lib64/" : "/lib/") - + soname; - if (auto r = set_interpreter(bundledBinary, distroLoader, + auto patchelf = sandbox_patchelf(cfg); + if (!patchelf.empty()) { + // non-empty bundle → "$ORIGIN/../lib" so the binary finds them + if (!toBundle.empty()) { + if (auto r = set_search_path(bundledBinary, "$ORIGIN/../lib", + mcpp::build::loader::Form::Executable, patchelf); !r) return std::unexpected(Error{r.error()}); } + + // EVERY BUNDLED LIBRARY, not just the executable. + // + // A bundled .so keeps whatever RUNPATH it was built with, and on + // this ecosystem that is a set of ABSOLUTE paths into the BUILD + // MACHINE's xlings store. Measured on a graphics artifact: + // + // /xim-x-glibc/2.44/lib64 : /xim-x-gcc/16.1.0/lib64 + // : /compat-x-glx-runtime/…/lib : $ORIGIN + // + // Those directories do not exist on the target, and worse, if the + // target happens to be another developer's machine they exist with + // DIFFERENT contents. "Depends on the xlings ecosystem" would be a + // design choice; "depends on this one machine's store" is a defect, + // and it is invisible because the bundle runs fine where it was + // built. $ORIGIN is where its siblings actually are. + // + // EXCEPT THE DYNAMIC LOADER. `ld-linux-*.so` is not a shared + // library that gets searched for; it is the program that DOES the + // searching, and it is loaded by the kernel from an absolute path + // (PT_INTERP, or `run.sh`'s explicit invocation). Rewriting its + // own search path is meaningless, and patchelf rewriting it is + // destructive: Mode `self-contained` then segfaults before main, + // because the thing that was supposed to resolve the process's + // libraries no longer loads. Found by `30_pack_modes`. + auto loaderSoname = find_loader_soname(toBundle); + for (auto const& dep : toBundle) { + if (!loaderSoname.empty() && dep.soname == loaderSoname) continue; + auto staged = plan.stagingRoot / "lib" / dep.soname; + std::error_code ec; + if (!std::filesystem::is_regular_file(staged, ec)) continue; + if (auto r = set_search_path( + staged, "$ORIGIN", + mcpp::build::loader::Form::SharedLibrary, patchelf); !r) + return std::unexpected(Error{r.error()}); + } + + // PT_INTERP handling differs by mode: + // BundleProject → repoint to the target distro's loader + // (LSB layout: /lib64/ on x86_64, + // /lib/ elsewhere), derived from the + // loader soname ldd resolved for THIS binary — + // arch-correct without hardcoding a name. + // BundleAll → leave PT_INTERP alone; the wrapper script + // ignores it and launches via the bundled + // loader directly. + if (plan.opts.mode == Mode::BundleProject || plan.opts.mode == Mode::None) { + if (auto soname = find_loader_soname(deps->found); !soname.empty()) { + auto distroLoader = + (soname == "ld-linux-x86-64.so.2" ? "/lib64/" : "/lib/") + + soname; + if (auto r = set_interpreter(bundledBinary, distroLoader, + patchelf); !r) + return std::unexpected(Error{r.error()}); + } + } } - } - if (plan.opts.mode == Mode::BundleAll) { - auto loader = find_loader_soname(toBundle); - if (loader.empty()) { - // No ld-linux in deps means binary was statically linked, - // which the user typically wouldn't combine with --mode - // bundle-all. Skip wrapper, ship as-is. - } else { - // The runtime data of the C library the bundle carries - // (openxlings/xlings#621): its compiled locales and its - // character-set converters, from the payload the bundled - // loader came from (`/lib64/ld-linux-*` -> - // `/lib/{locale,gconv}`). A payload without them - // contributes nothing, and the program runs as before. - bool localeData = false, gconvData = false; - for (auto const& d : toBundle) { - if (d.soname != loader) continue; - std::error_code dec; - const auto payload = - std::filesystem::canonical(d.path, dec).parent_path().parent_path(); - if (dec) break; - auto copy_dir = [&](std::string_view sub) { - const auto from = payload / "lib" / std::string(sub); - std::error_code cec; - if (!std::filesystem::is_directory(from, cec)) return false; - const auto to = plan.stagingRoot / "lib" / std::string(sub); - std::filesystem::create_directories(to, cec); - std::filesystem::copy(from, to, - std::filesystem::copy_options::recursive - | std::filesystem::copy_options::overwrite_existing, cec); - return !cec; - }; - localeData = copy_dir("locale"); - gconvData = copy_dir("gconv"); - break; + if (plan.opts.mode == Mode::BundleAll) { + auto loader = find_loader_soname(toBundle); + if (loader.empty()) { + // No ld-linux in deps means binary was statically linked, + // which the user typically wouldn't combine with --mode + // bundle-all. Skip wrapper, ship as-is. + } else { + // The runtime data of the C library the bundle carries + // (openxlings/xlings#621): its compiled locales and its + // character-set converters, from the payload the bundled + // loader came from (`/lib64/ld-linux-*` -> + // `/lib/{locale,gconv}`). A payload without them + // contributes nothing, and the program runs as before. + bool localeData = false, gconvData = false; + for (auto const& d : toBundle) { + if (d.soname != loader) continue; + std::error_code dec; + const auto payload = + std::filesystem::canonical(d.path, dec).parent_path().parent_path(); + if (dec) break; + auto copy_dir = [&](std::string_view sub) { + const auto from = payload / "lib" / std::string(sub); + std::error_code cec; + if (!std::filesystem::is_directory(from, cec)) return false; + const auto to = plan.stagingRoot / "lib" / std::string(sub); + std::filesystem::create_directories(to, cec); + std::filesystem::copy(from, to, + std::filesystem::copy_options::recursive + | std::filesystem::copy_options::overwrite_existing, cec); + return !cec; + }; + localeData = copy_dir("locale"); + gconvData = copy_dir("gconv"); + break; + } + // Mode B writes BOTH `run.sh` and `` at the + // bundle root — same content, different names — so users + // can pick whichever entry point they prefer. + if (auto r = write_bundle_all_wrappers(plan.stagingRoot, + plan.binaryName, loader, localeData, gconvData); !r) + return std::unexpected(Error{r.error()}); + collect_licenses(toBundle, plan.stagingRoot); } - // Mode B writes BOTH `run.sh` and `` at the - // bundle root — same content, different names — so users - // can pick whichever entry point they prefer. - if (auto r = write_bundle_all_wrappers(plan.stagingRoot, - plan.binaryName, loader, localeData, gconvData); !r) + } else { + // Static / BundleProject: drop a thin shell wrapper at the + // root that exec's bin/, so `./hello` from the + // unpacked bundle runs the program directly. + if (auto r = write_topentry_wrapper(plan.stagingRoot, plan.binaryName); !r) return std::unexpected(Error{r.error()}); - collect_licenses(toBundle, plan.stagingRoot); } } else { - // Static / BundleProject: drop a thin shell wrapper at the - // root that exec's bin/, so `./hello` from the - // unpacked bundle runs the program directly. + // Mode::Static — also add the top-level entry-point wrapper. if (auto r = write_topentry_wrapper(plan.stagingRoot, plan.binaryName); !r) return std::unexpected(Error{r.error()}); } - } else { - // Mode::Static — also add the top-level entry-point wrapper. - if (auto r = write_topentry_wrapper(plan.stagingRoot, plan.binaryName); !r) - return std::unexpected(Error{r.error()}); - } - // The format tail's last two steps, run only because step 4 above - // produced a list (an early return took every path where it did not): - // debug information does not travel either. - // - // AFTER every byte-changing step above (patchelf's search path, PT_INTERP) - // and before the archive: strip must see the final image, and the archive - // must see the stripped one. Same ordering rule the library packer states - // at its leg loop. - if (auto r = strip_program(plan, bundledBinary); !r) return std::unexpected(r.error()); - // #649 E5: and the shared libraries this graph built. The dynamic loader - // is never one of them and is never edited, for the reason the - // search-path loop above gives. - for (auto const& [staged, source] : bundledCopies) { - if (!bundledLoader.empty() && staged.filename() == bundledLoader) continue; - if (!strips_staged_copy(plan, source, plan.graphSharedLibraries)) continue; - if (auto r = strip_staged_library(plan, staged, source, plan.debugDir); !r) - return std::unexpected(r.error()); - } + // The format tail's last two steps, run only because step 4 above + // produced a list (an early return took every path where it did not): + // debug information does not travel either. + // + // AFTER every byte-changing step above (patchelf's search path, PT_INTERP) + // and before the archive: strip must see the final image, and the archive + // must see the stripped one. Same ordering rule the library packer states + // at its leg loop. + if (auto r = strip_program(plan, bundledBinary); !r) return std::unexpected(r.error()); + // #649 E5: and the shared libraries this graph built. The dynamic loader + // is never one of them and is never edited, for the reason the + // search-path loop above gives. + for (auto const& [staged, source] : bundledCopies) { + if (!bundledLoader.empty() && staged.filename() == bundledLoader) continue; + if (!strips_staged_copy(plan, source, plan.graphSharedLibraries)) continue; + if (auto r = strip_staged_library(plan, staged, source, plan.debugDir); !r) + return std::unexpected(r.error()); + } - // A dispatched format reaches this point with an incomplete closure; an - // archive was refused before any file was edited. - if (!unresolvedText.empty()) - return finish_without_closure(plan, unresolvedText, std::move(needs)); - - // THE C LIBRARY THE MACHINE THAT RUNS IT MUST HAVE (design 2026-10-23 - // §4.11). A package that loads the target's own C library binds no base: - // the base it was built against becomes a FLOOR, the highest `GLIBC_x.y` - // the program and every library shipped beside it need. Said once on the - // terminal and written to HOST-REQUIREMENTS, where a reader of the bundle - // looks for what the target must provide. - // - // Only for a build that STATED its base: that is the case whose floor is a - // decision someone made (an old glibc, for distributions older than this - // machine). The ecosystem's own base packs exactly as it did. - if (plan.opts.baseStated - && (plan.opts.mode == Mode::BundleProject || plan.opts.mode == Mode::None)) { - std::vector needs; - auto consider = [&](const std::filesystem::path& p) { - if (auto f = mcpp::platform::elf::inspect_elf_runtime(p)) - needs.insert(needs.end(), f->requiredGlibcVersions.begin(), - f->requiredGlibcVersions.end()); - }; - consider(bundledBinary); - for (auto const& [staged, source] : bundledCopies) consider(staged); - if (auto floor = mcpp::toolchain::base::highest_glibc(needs); !floor.empty()) { - mcpp::ui::info("Requires", std::format( - "glibc >= {} on the machine that runs it (the highest GLIBC_ version " - "the program and the libraries packed with it need)", floor)); - auto reqs = plan.hostRequirements; - reqs.push_back({std::format("c-abi.glibc>={}", floor), "PT_INTERP", true}); - std::ofstream out(plan.stagingRoot / std::filesystem::path(kFileName)); - out << render(reqs); + // A dispatched format reaches this point with an incomplete closure; an + // archive was refused before any file was edited. + if (!unresolvedText.empty()) + return finish_without_closure(plan, unresolvedText, std::move(needs)); + + // THE C LIBRARY THE MACHINE THAT RUNS IT MUST HAVE (design 2026-10-23 + // §4.11). A package that loads the target's own C library binds no base: + // the base it was built against becomes a FLOOR, the highest `GLIBC_x.y` + // the program and every library shipped beside it need. Said once on the + // terminal and written to HOST-REQUIREMENTS, where a reader of the bundle + // looks for what the target must provide. + // + // Only for a build that STATED its base: that is the case whose floor is a + // decision someone made (an old glibc, for distributions older than this + // machine). The ecosystem's own base packs exactly as it did. + if (plan.opts.baseStated + && (plan.opts.mode == Mode::BundleProject || plan.opts.mode == Mode::None)) { + std::vector needs; + auto consider = [&](const std::filesystem::path& p) { + if (auto f = mcpp::runtime::elf::inspect_elf_runtime(p)) + needs.insert(needs.end(), f->requiredGlibcVersions.begin(), + f->requiredGlibcVersions.end()); + }; + consider(bundledBinary); + for (auto const& [staged, source] : bundledCopies) consider(staged); + if (auto floor = mcpp::toolchain::base::highest_glibc(needs); !floor.empty()) { + mcpp::ui::info("Requires", std::format( + "glibc >= {} on the machine that runs it (the highest GLIBC_ version " + "the program and the libraries packed with it need)", floor)); + auto reqs = plan.hostRequirements; + reqs.push_back({std::format("c-abi.glibc>={}", floor), "PT_INTERP", true}); + std::ofstream out(plan.stagingRoot / std::filesystem::path(kFileName)); + out << render(reqs); + } } - } - // Output. - if (plan.opts.format == Format::Tar) { - if (auto r = make_tarball(plan.stagingRoot, plan.archivePath); !r) - return std::unexpected(r.error()); + // Output. + if (plan.opts.format == Format::Tar) { + if (auto r = make_tarball(plan.stagingRoot, plan.archivePath); !r) + return std::unexpected(r.error()); + } + return ClosureResult{true, {}, std::move(needs)}; } - return ClosureResult{true, {}, std::move(needs)}; -#endif // !_WIN32 } } // namespace mcpp::pack diff --git a/src/pack/pipeline.cppm b/src/pack/pipeline.cppm index 6a30360a..88bb9806 100644 --- a/src/pack/pipeline.cppm +++ b/src/pack/pipeline.cppm @@ -25,8 +25,7 @@ import mcpp.manifest; import mcpp.pack; import mcpp.pack.stage_tree; import mcpp.pack.strip; -import mcpp.platform; -import mcpp.platform.env; +import mcpp.runtime.launch_env; import mcpp.toolchain.model; import mcpp.toolchain.probe; import mcpp.toolchain.registry; @@ -99,14 +98,17 @@ struct DriverLibraryDirs { DriverLibraryDirs driver_library_dirs(const mcpp::toolchain::Toolchain& tc) { DriverLibraryDirs out; - const auto base = std::format("{} {}", mcpp::platform::shell::quote(tc.binaryPath.string()), - tc.crossTargetFlag); - if (auto r = mcpp::toolchain::run_capture(std::format( - "{} -print-search-dirs {}", base, mcpp::platform::null_redirect))) { + auto query = [&](std::string_view flag) { + std::vector argv{ tc.binaryPath.string() }; + if (!tc.crossTargetFlag.empty()) argv.push_back(tc.crossTargetFlag); + argv.emplace_back(flag); + return mcpp::toolchain::run_capture(argv, mcpp::toolchain::compiler_env(tc)); + }; + if (auto r = query("-print-search-dirs")) { std::istringstream is{*r}; std::string line; const std::string_view key = "libraries: ="; - const auto sep = mcpp::platform::env::path_list_separator(); + const auto sep = mcpp::runtime::launch_env::path_list_separator(); while (std::getline(is, line)) { if (!line.starts_with(key)) continue; std::string rest = mcpp::toolchain::trim_line(line.substr(key.size())); @@ -121,8 +123,7 @@ DriverLibraryDirs driver_library_dirs(const mcpp::toolchain::Toolchain& tc) } } } - if (auto r = mcpp::toolchain::run_capture(std::format( - "{} -print-file-name=libc.so {}", base, mcpp::platform::null_redirect))) { + if (auto r = query("-print-file-name=libc.so")) { // A driver that cannot place the library echoes the bare name back. std::filesystem::path lib(mcpp::toolchain::trim_line(*r)); std::error_code ec; diff --git a/src/pack/strip.cppm b/src/pack/strip.cppm index 6707ebb5..81c89771 100644 --- a/src/pack/strip.cppm +++ b/src/pack/strip.cppm @@ -68,7 +68,8 @@ export module mcpp.pack.strip; import std; import mcpp.pack.binfmt; -import mcpp.platform; +import mcpp.build.command_line; +import mcpp.run; export namespace mcpp::pack { @@ -129,22 +130,22 @@ namespace mcpp::pack { namespace { -std::expected run_tool(const std::string& cmd) { - auto r = mcpp::platform::process::capture(cmd + " 2>&1"); +std::expected run_tool(const std::vector& argv) { + auto r = mcpp::run::execute(argv); if (r.exit_code != 0) return std::unexpected(std::format( - " command: {}\n output : {}", cmd, r.output)); + " command: {}\n output : {}", mcpp::build::command_line::display(argv), r.output)); return {}; } -std::string join_quoted(const std::filesystem::path& tool, - const std::vector& args, - const std::filesystem::path& file) +std::vector tool_argv(const std::filesystem::path& tool, + const std::vector& args, + const std::filesystem::path& file) { - std::string cmd = mcpp::platform::shell::quote(tool.string()); - for (auto const& a : args) cmd += " " + mcpp::platform::shell::quote(a); - cmd += " " + mcpp::platform::shell::quote(file.string()); - return cmd; + std::vector argv{ tool.string() }; + argv.insert(argv.end(), args.begin(), args.end()); + argv.push_back(file.string()); + return argv; } std::uintmax_t size_of(const std::filesystem::path& p) { @@ -241,27 +242,22 @@ strip_artifact(const std::filesystem::path& artifact, if (ec) return std::unexpected(std::format( "cannot create '{}': {}", debugDir.string(), ec.message())); out.debugFile = debugDir / (artifact.filename().string() + ".debug"); - auto cmd = std::format("{} --only-keep-debug {} {}", - mcpp::platform::shell::quote(tools.objcopy.string()), - mcpp::platform::shell::quote(artifact.string()), - mcpp::platform::shell::quote(out.debugFile.string())); - if (auto r = run_tool(cmd); !r) return std::unexpected(std::format( + if (auto r = run_tool({ tools.objcopy.string(), "--only-keep-debug", artifact.string(), + out.debugFile.string() }); !r) return std::unexpected(std::format( "cannot separate debug information from '{}'.\n{}", artifact.string(), r.error())); } // ── strip ───────────────────────────────────────────────────────── - if (auto r = run_tool(join_quoted(tools.strip, strip_args(shape), artifact)); !r) + if (auto r = run_tool(tool_argv(tools.strip, strip_args(shape), artifact)); !r) return std::unexpected(std::format( "cannot strip '{}'.\n{}", artifact.string(), r.error())); // ── and point the stripped artifact back at its symbols ─────────── if (separate) { - auto cmd = std::format("{} --add-gnu-debuglink={} {}", - mcpp::platform::shell::quote(tools.objcopy.string()), - mcpp::platform::shell::quote(out.debugFile.string()), - mcpp::platform::shell::quote(artifact.string())); - if (auto r = run_tool(cmd); !r) return std::unexpected(std::format( + if (auto r = run_tool({ tools.objcopy.string(), + "--add-gnu-debuglink=" + out.debugFile.string(), + artifact.string() }); !r) return std::unexpected(std::format( "cannot add the debug link to '{}'.\n{}", artifact.string(), r.error())); } diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index 712d8af4..ededc7cc 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -17,7 +17,7 @@ import mcpp.config; import mcpp.fetcher.progress; // bootstrap progress for load_or_init import mcpp.manifest; // kDefaultNamespace alias import mcpp.lockfile; // load / write (still via shim) -import mcpp.platform.axis; // HostPlatform for the published-version check +import mcpp.toolchain.platform; import mcpp.pm.dep_spec; // DependencyCoordinate import mcpp.pm.dependency_selector; // same candidates the manifest parser derives import mcpp.pm.index_route; // shared index routing (with mcpp.build.prepare) @@ -55,7 +55,7 @@ inline void warn_unpublished_version(std::string_view lua, const std::string& version) { if (mcpp::pm::is_version_constraint(version)) return; auto versions = mcpp::manifest::list_xpkg_versions( - lua, mcpp::platform::HostPlatform::current()); + lua, mcpp::toolchain::platform::HostPlatform::current()); if (versions.empty()) return; if (std::ranges::find(versions, version) != versions.end()) return; diff --git a/src/pm/index_contract.cppm b/src/pm/index_contract.cppm index 1cc42902..93833fa6 100644 --- a/src/pm/index_contract.cppm +++ b/src/pm/index_contract.cppm @@ -20,8 +20,9 @@ export module mcpp.pm.index_contract; import std; +import mcpplibs.os.platform; import mcpp.libs.toml; -import mcpp.platform.fs; // self_exe_path (distro-layout detection) +import mcpp.invocation; import mcpp.version_req; import mcpp.version; // MCPP_VERSION (leaf — see that module) @@ -186,13 +187,13 @@ std::string e0006_message(std::string violation, bool distroManaged) // installs keep the binary inside $MCPP_HOME (~/.mcpp) instead. bool distro_managed_install() { -#if defined(_WIN32) || defined(__APPLE__) - return false; -#else - std::error_code ec; - auto self = mcpp::platform::fs::self_exe_path(); - return self.string().find("/opt/mcpp/") != std::string::npos; -#endif + if constexpr (mcpplibs::os::is_windows || mcpplibs::os::is_macos) { + return false; + } else { + std::error_code ec; + auto self = mcpp::invocation::self_exe_path(); + return self.string().find("/opt/mcpp/") != std::string::npos; + } } std::string index_floor_upgrade_advice(std::string_view minMcpp) @@ -208,8 +209,7 @@ std::string index_floor_upgrade_advice(std::string_view minMcpp) bool index_usable(const std::filesystem::path& indexRoot) { - if (const char* v = std::getenv("MCPP_INDEX_FLOOR"); - v && std::string_view(v) == "ignore") + if (mcpp::invocation::get("MCPP_INDEX_FLOOR") == "ignore") return true; auto c = read_index_contract(indexRoot); if (!c) return true; // no contract → no constraint @@ -262,8 +262,7 @@ void reset_unusable_indexes_for_test() { unusable_registry().clear(); } std::optional check_index_floor(const std::filesystem::path& indexRoot) { - if (const char* v = std::getenv("MCPP_INDEX_FLOOR"); - v && std::string_view(v) == "ignore") + if (mcpp::invocation::get("MCPP_INDEX_FLOOR") == "ignore") return std::nullopt; auto c = read_index_contract(indexRoot); diff --git a/src/pm/index_management.cppm b/src/pm/index_management.cppm index cadbff12..558f8476 100644 --- a/src/pm/index_management.cppm +++ b/src/pm/index_management.cppm @@ -14,7 +14,7 @@ import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.lockfile; import mcpp.manifest; -import mcpp.platform; +import mcpp.effects; import mcpp.project; import mcpp.pm.index_route; import mcpp.ui; @@ -245,7 +245,7 @@ export int index_update(const std::string& filterName) { // Explicit command: offline must refuse audibly rather than no-op. Left to // this layer because update_index() itself is a silent skip by design (it // is called from paths where proceeding locally is the right answer). - if (mcpp::platform::env::offline_mode()) { + if (mcpp::effects::offline_mode()) { mcpp::ui::error("offline mode is on — cannot update the package index " "(unset MCPP_OFFLINE or drop --offline)"); return 1; diff --git a/src/pm/index_refresh.cppm b/src/pm/index_refresh.cppm index d417f684..6853aa55 100644 --- a/src/pm/index_refresh.cppm +++ b/src/pm/index_refresh.cppm @@ -38,8 +38,7 @@ export module mcpp.pm.index_refresh; import std; import mcpp.config; import mcpp.log; -import mcpp.platform; -import mcpp.platform.axis; +import mcpp.toolchain.platform; import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; // legacy_bare_candidates import mcpp.pm.index_contract; @@ -58,7 +57,7 @@ RefreshDecision decide_for_dependency(const IndexRoute& route, std::string_view depKey, const DependencySpec& spec, const mcpp::provider::Location& env, - const mcpp::platform::PlatformKey& platform, + const mcpp::toolchain::platform::PlatformKey& platform, const RefreshPolicy& policy); // Is this dependency served by the shared registry (as opposed to a project @@ -115,7 +114,7 @@ RefreshDecision decide_for_dependency(const IndexRoute& route, std::string_view depKey, const DependencySpec& spec, const mcpp::provider::Location& env, - const mcpp::platform::PlatformKey& platform, + const mcpp::toolchain::platform::PlatformKey& platform, const RefreshPolicy& policy) { RefreshDecision d; diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index b71dd6ef..cee5429b 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -15,7 +15,7 @@ export module mcpp.pm.package_fetcher; import std; import mcpp.config; import mcpp.log; -import mcpp.platform; // env::offline_mode +import mcpp.effects; import mcpp.manifest; // xpkg_lua_identity_matches — descriptor identity gate import mcpp.pm.compat; import mcpp.pm.dep_spec; @@ -1271,7 +1271,7 @@ Fetcher::resolve_xpkg_path(std::string_view target, // Offline is checked HERE, after the "already usable" fast path above: a // build whose packages are all usable stays functional offline. Only an // actual download is refused, naming the package. - if (mcpp::platform::env::offline_mode()) { + if (mcpp::effects::offline_mode()) { mcpp::build::refusal::record( mcpp::build::refusal::Code::OfflineDownloadRequired); return std::unexpected(CallError{ std::format( diff --git a/src/pm/publisher.cppm b/src/pm/publisher.cppm index 3cb438e0..9854ff60 100644 --- a/src/pm/publisher.cppm +++ b/src/pm/publisher.cppm @@ -11,7 +11,7 @@ import mcpp.pack.host_requirements; // J: one derivation, two projections import mcpp.manifest; import mcpp.diag; import mcpp.modgraph.graph; -import mcpp.platform; +import mcpp.run; // `cfgpred::os_only_platforms` (#630 item 7) — the standalone half of // `mcpp.build.prepare`, carrying no dependency on the rest of it (see that // module's header comment), which is what makes it importable from the @@ -382,7 +382,7 @@ std::string sha256_of_file(const std::filesystem::path& file) { if (!std::filesystem::exists(file)) return {}; // An argument vector rather than a `2>/dev/null` command string, which // cmd.exe cannot open on a Windows host. - auto r = mcpp::platform::process::capture_host_tool_stdout( + auto r = mcpp::run::capture_host_tool_stdout( {"sha256sum", file.string()}); if (r.exit_code != 0) return {}; // sha256sum format: "<64-hex> \n" @@ -400,14 +400,8 @@ std::string make_release_tarball(const std::filesystem::path& root, std::filesystem::create_directories(output.parent_path(), ec); auto prefix = std::format("{}-{}/", name, version); - auto cmd = std::format( - "git -C {} archive --format=tar.gz " - "--prefix={} " - "-o {} HEAD 2>&1", - mcpp::platform::shell::quote(root.string()), - mcpp::platform::shell::quote(prefix), - mcpp::platform::shell::quote(output.string())); - auto r = mcpp::platform::process::capture(cmd); + auto r = mcpp::run::execute({ "git", "-C", root.string(), "archive", "--format=tar.gz", + "--prefix=" + prefix, "-o", output.string(), "HEAD" }); if (r.exit_code != 0) { return std::format("git archive failed (rc={}): {}", r.exit_code, r.output); } @@ -425,7 +419,7 @@ std::string make_release_tarball(const std::filesystem::path& root, const std::vector& overlays) { if (overlays.empty()) return make_release_tarball(root, name, version, output); - namespace proc = mcpp::platform::process; + namespace proc = mcpp::run; using Env = std::vector>; const std::string dir = root.string(); auto trim = [](std::string s) { diff --git a/src/pm/refresh_policy.cppm b/src/pm/refresh_policy.cppm index b6bf660f..d193ca64 100644 --- a/src/pm/refresh_policy.cppm +++ b/src/pm/refresh_policy.cppm @@ -18,7 +18,7 @@ export module mcpp.pm.refresh_policy; import std; import mcpp.config; import mcpp.log; -import mcpp.platform; +import mcpp.effects; import mcpp.pm.index_contract; import mcpp.ui; import mcpp.provider; @@ -132,7 +132,7 @@ std::string_view reason_text(RefreshReason r) { RefreshPolicy policy_for(const mcpp::config::GlobalConfig& cfg) { RefreshPolicy p; - p.offline = mcpp::platform::env::offline_mode(); + p.offline = mcpp::effects::offline_mode(); p.autoRefresh = cfg.indexAutoRefresh; return p; } @@ -208,7 +208,7 @@ std::expected apply(const RefreshDecision& d, } std::expected force_refresh(const mcpp::provider::Location& env) { - if (mcpp::platform::env::offline_mode()) + if (mcpp::effects::offline_mode()) return std::unexpected( "offline mode is on — cannot refresh the package index " "(unset MCPP_OFFLINE or drop --offline)"); diff --git a/src/pm/resolver.cppm b/src/pm/resolver.cppm index 101894f8..d96d3c1a 100644 --- a/src/pm/resolver.cppm +++ b/src/pm/resolver.cppm @@ -33,8 +33,7 @@ export module mcpp.pm.resolver; import std; import mcpp.manifest; -import mcpp.platform; -import mcpp.platform.axis; +import mcpp.toolchain.platform; import mcpp.pm.compat; import mcpp.pm.dep_spec; import mcpp.pm.index_route; @@ -45,7 +44,7 @@ export namespace mcpp::pm { // xpkg.lua's `xpm.` uses these names. (Distinct from // `kCurrentPlatform` in cli.cppm, which is the [toolchain] table key — // "macos" vs "macosx".) -inline constexpr std::string_view kXpkgPlatform = mcpp::platform::xpkg_platform; +inline constexpr std::string_view kXpkgPlatform = mcpp::toolchain::platform::xpkg_platform; // Returns true if `v` is a SemVer constraint (caret, tilde, range, glob, // `*`, or empty) rather than a literal exact version. Empty counts as @@ -63,7 +62,7 @@ std::expected resolve_semver(std::string_view ns, std::string_view shortName, std::string_view constraint, const mcpp::pm::IndexRoute& route, - const mcpp::platform::PlatformKey& platform); + const mcpp::toolchain::platform::PlatformKey& platform); // Try to AND-merge two version constraints and resolve to a single // concrete version satisfying both. Uses structured (ns, shortName). @@ -72,7 +71,7 @@ try_merge_semver(std::string_view ns, std::string_view shortName, std::string_view a, std::string_view b, const mcpp::pm::IndexRoute& route, - const mcpp::platform::PlatformKey& platform); + const mcpp::toolchain::platform::PlatformKey& platform); // ─── Legacy overloads (COMPAT(internal:legacy-resolver-api)) ───────────────────── @@ -103,7 +102,7 @@ std::expected resolve_semver(std::string_view ns, std::string_view shortName, std::string_view constraint, const mcpp::pm::IndexRoute& route, - const mcpp::platform::PlatformKey& platform) + const mcpp::toolchain::platform::PlatformKey& platform) { namespace vr = mcpp::version_req; auto qname = mcpp::pm::compat::qualified_name(ns, shortName); @@ -261,7 +260,7 @@ try_merge_semver(std::string_view ns, std::string_view shortName, std::string_view a, std::string_view b, const mcpp::pm::IndexRoute& route, - const mcpp::platform::PlatformKey& platform) + const mcpp::toolchain::platform::PlatformKey& platform) { auto canon = [](std::string_view v) -> std::string { if (v.empty() || v == "*") return std::string{}; @@ -299,7 +298,7 @@ resolve_semver(std::string_view name, // axis explicitly rather than inheriting a silent default (#254). return resolve_semver(resolved.namespace_, resolved.shortName, constraint, route, - mcpp::platform::HostPlatform::current()); + mcpp::toolchain::platform::HostPlatform::current()); } std::expected @@ -312,7 +311,7 @@ try_merge_semver(std::string_view name, // Legacy overload — see the resolve_semver note above. return try_merge_semver(resolved.namespace_, resolved.shortName, a, b, route, - mcpp::platform::HostPlatform::current()); + mcpp::toolchain::platform::HostPlatform::current()); } } // namespace mcpp::pm diff --git a/src/provider/xlings/provider.cppm b/src/provider/xlings/provider.cppm index fce592e8..fc03d26d 100644 --- a/src/provider/xlings/provider.cppm +++ b/src/provider/xlings/provider.cppm @@ -10,7 +10,7 @@ import mcpp.provider.xlings; import mcpp.provider.xlings.subos_info; import mcpp.provider.xlings.legacy_dirs; import mcpp.libs.json; -import mcpp.platform; +import mcpplibs.os.fs; export namespace mcpp::provider::xlings { @@ -380,11 +380,11 @@ public: std::error_code ec; auto link = d.local; link += std::format(".mcpp-link-{:x}", std::random_device{}()); - if (!mcpp::platform::create_directory_link(d.upstream, link)) + if (!mcpplibs::os::fs::create_link(d.upstream, link, true).has_value()) return std::format("cannot link {} to {}", link.string(), d.upstream.string()); - if (auto swapped = mcpp::platform::exchange_paths(link, d.local); !swapped) { + if (auto swapped = mcpplibs::os::fs::exchange(link, d.local); !swapped) { std::filesystem::remove(link, ec); - return swapped.error(); + return swapped.error().message(); } // The copy this home installed, now at the scratch name. std::filesystem::remove_all(link, ec); @@ -414,9 +414,7 @@ public: return; mcpp::provider::Location global = env_; global.projectDir.clear(); - (void)run_capture(build_command_prefix(global) + " config --runtime " - + mcpp::platform::shell::quote(runtimeId) + " " - + std::string(mcpp::platform::shell::silent_redirect)); + select_runtime(global, runtimeId); } void ensure_patchelf(bool quiet, const mcpp::provider::BootstrapProgressCallback& cb) const override { xlings::ensure_patchelf(env_, quiet, cb); diff --git a/src/provider/xlings/subos_info.cppm b/src/provider/xlings/subos_info.cppm index 0207d32c..877f68d6 100644 --- a/src/provider/xlings/subos_info.cppm +++ b/src/provider/xlings/subos_info.cppm @@ -37,7 +37,7 @@ export module mcpp.provider.xlings.subos_info; import std; import mcpp.libs.json; -import mcpp.platform; +import mcpp.runtime.launch_env; import mcpp.provider; export namespace mcpp::provider::xlings::subos { @@ -277,7 +277,7 @@ resolve_env(const mcpp::provider::runtime_info::Info& info, const std::filesyste return v; }; - const std::string sep = mcpp::platform::env::path_list_separator(); + const std::string sep = mcpp::runtime::launch_env::path_list_separator(); // Does the list already contain `value` as a WHOLE element? Compared // element-wise rather than by substring: a plain `find` would consider diff --git a/src/provider/xlings/xlings.cpp b/src/provider/xlings/xlings.cpp new file mode 100644 index 00000000..b536ff4e --- /dev/null +++ b/src/provider/xlings/xlings.cpp @@ -0,0 +1,2043 @@ +// mcpp.provider.xlings — the implementation: how mcpp starts xlings, reads +// its events and keeps its home. +module; +#include // stderr +#include + +module mcpp.provider.xlings; + +import std; +import mcpp.provider; +import mcpp.pm.compat; +import mcpp.pm.index_contract; +import mcpplibs.os.platform; +import mcpp.invocation; +import mcpp.effects; +import mcpp.build.command_line; +import mcpp.run; +import mcpp.runtime.launch_env; +import mcpp.home.files; +import mcpp.log; +import mcpp.ui; // closing notices of the refresh guard +import mcpp.home; +import mcpp.xpkg_version; +import mcpp.libs.json; +import mcpplibs.os.fs; + +namespace mcpp::provider::xlings { +// How mcpp starts xlings: `args` after the binary, the environment +// `invocation_env` decides with the sandbox's `bin` in front of PATH, and the +// home as the working directory (#726). The environment is the started +// program's alone: mcpp's own is the same after the invocation as before it, +// so a build that installed a payload starts ninja with the environment a +// build that installed nothing does (the sandbox's `bin` holds the shims +// `xim:llvm` registers on Windows -- `cl`, `link`, `lib`, `rc` -- which +// vcpkg's compiler detection met in front of MSVC's tools when the PATH +// prefix outlived an invocation). +struct Invocation { + std::vector argv; + mcpp::run::Options options; +}; +Invocation invocation(const Env& env, std::vector args); + +} // namespace mcpp::provider::xlings + +// ═══════════════════════════════════════════════════════════════════════ +// Implementation +// ═══════════════════════════════════════════════════════════════════════ + +namespace mcpp::provider::xlings { + +namespace { + +// Right-pad a verb to 12 columns for bootstrap status lines, on standard +// error: the stream mcpp.ui narrates on. +void print_status(std::string_view verb, std::string_view msg) { + constexpr std::size_t W = 12; + if (verb.size() >= W) { + std::println(stderr, "{} {}", verb, msg); + } else { + std::println(stderr, "{}{} {}", std::string(W - verb.size(), ' '), verb, msg); + } +} + +std::filesystem::path default_index_dir(const Env& env) { + return paths::index_dir_of(env, "mcpplibs"); +} + +std::filesystem::path official_index_dir(const Env& env) { + return paths::index_data(env) / "xim-pkgindex"; +} + +std::filesystem::path index_pkgs_dir(const std::filesystem::path& indexDir) { + return indexDir / "pkgs"; +} + +std::filesystem::path index_refresh_marker(const std::filesystem::path& indexDir) { + return indexDir / ".mcpp-index-updated"; +} + +std::filesystem::path index_version_file(const std::filesystem::path& indexDir) { + return indexDir / ".xlings-index-version"; +} + +std::filesystem::path official_package_file(const Env& env, std::string_view packageName) { + if (packageName.empty()) return {}; + std::string name(packageName); + return official_index_dir(env) / "pkgs" / std::string(1, name[0]) / (name + ".lua"); +} + +std::string json_escaped_path_probe(std::filesystem::path path) { + auto value = path.string(); + std::string escaped; + escaped.reserve(value.size() * 2); + for (char c : value) { + if (c == '\\') escaped += "\\\\"; + else escaped.push_back(c); + } + return escaped; +} + +bool official_index_cache_matches_package_file(const Env& env, + std::string_view packageName) { + auto cache = official_index_dir(env) / ".xlings-index-cache.json"; + if (!std::filesystem::exists(cache)) return true; + + auto pkg = official_package_file(env, packageName); + if (pkg.empty()) return false; + + std::ifstream is(cache); + if (!is) return false; + std::string body((std::istreambuf_iterator(is)), {}); + auto rawPath = pkg.string(); + return body.find(rawPath) != std::string::npos + || body.find(json_escaped_path_probe(pkg)) != std::string::npos; +} + +void mark_index_refreshed(const std::filesystem::path& indexDir) { + if (!std::filesystem::exists(index_pkgs_dir(indexDir))) return; + std::error_code ec; + std::filesystem::create_directories(indexDir, ec); + auto marker = index_refresh_marker(indexDir); + { + std::ofstream os(marker, std::ios::trunc); + if (!os) return; + os << "ok\n"; + } + std::filesystem::last_write_time( + marker, std::filesystem::file_time_type::clock::now(), ec); +} + +void mark_known_indexes_refreshed(const Env& env) { + // Project-scoped envs used to return early here, which left the global + // indexes unmarked whenever a project with custom `[indices]` triggered the + // sync — so `mcpp index status` reported "unknown" forever on exactly the + // machines that refresh most. The marker is advisory (it debounces and it + // dates the status line; it is NOT what decides whether a refresh is + // needed), so marking it from either env is both safe and more accurate. + mark_index_refreshed(default_index_dir(env)); + mark_index_refreshed(official_index_dir(env)); +} + +bool is_index_dir_fresh(const std::filesystem::path& indexDir, std::int64_t ttlSeconds) { + std::error_code ec; + if (!std::filesystem::exists(index_pkgs_dir(indexDir))) return false; + + auto marker = index_refresh_marker(indexDir); + if (!std::filesystem::exists(marker)) return false; + + auto newest = std::filesystem::last_write_time(marker, ec); + if (ec) return false; + + auto now = std::filesystem::file_time_type::clock::now(); + auto age = std::chrono::duration_cast(now - newest); + // A marker stamped in the FUTURE yields a negative age, which the plain + // `age < ttl` test read as "fresh" — and stayed fresh until the wall clock + // caught up, potentially for years. Future timestamps are routine: clock + // skew in containers/VMs, a tar that preserved mtimes, a restored CI cache. + // An unusable timestamp means "unknown", and unknown must mean stale. + if (age.count() < 0) return false; + return age.count() < ttlSeconds; +} + +// Seconds since the index's refresh marker was last touched, or -1 if the +// marker is missing/unreadable. Read-only — no network, no side effects. +std::int64_t index_age_seconds(const std::filesystem::path& indexDir) { + std::error_code ec; + auto marker = index_refresh_marker(indexDir); + auto newest = std::filesystem::last_write_time(marker, ec); + if (ec) return -1; + auto now = std::filesystem::file_time_type::clock::now(); + auto age = std::chrono::duration_cast(now - newest).count(); + return age < 0 ? -1 : age; // future stamp → "unknown", same rule as above +} + +// Defined inside the anonymous namespace so `index_status_for` (also here) can +// use it; the exported `index_revision` below forwards to it. +std::optional read_index_revision(const std::filesystem::path& indexDir) { + std::ifstream is(index_version_file(indexDir), std::ios::binary); + if (!is) return std::nullopt; + std::string body((std::istreambuf_iterator(is)), {}); + // The observed files carry no trailing newline, but trim anyway: this value + // is compared for equality and printed, and a stray \r from a Windows-side + // writer would otherwise turn "same rev" into "changed rev" every run. + auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; }; + while (!body.empty() && isSpace(static_cast(body.back()))) + body.pop_back(); + std::size_t b = 0; + while (b < body.size() && isSpace(static_cast(body[b]))) ++b; + body.erase(0, b); + if (body.empty()) return std::nullopt; + return body; +} + +IndexStatus index_status_for(const std::filesystem::path& indexDir, + std::int64_t ttlSeconds) { + std::error_code ec; + bool present = std::filesystem::exists(index_pkgs_dir(indexDir), ec) && !ec; + return IndexStatus{ + .dir = indexDir, + .present = present, + .fresh = is_index_dir_fresh(indexDir, ttlSeconds), + .ageSeconds = index_age_seconds(indexDir), + .rev = read_index_revision(indexDir), + }; +} + +void write_file(const std::filesystem::path& p, std::string_view content) { + std::error_code ec; + std::filesystem::create_directories(p.parent_path(), ec); + std::ofstream os(p); + os << content; +} + +std::string json_escape(std::string_view value) { + std::string out; + out.reserve(value.size()); + for (unsigned char ch : value) { + switch (ch) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (ch < 0x20) { + out += std::format("\\u{:04x}", static_cast(ch)); + } else { + out.push_back(static_cast(ch)); + } + } + } + return out; +} + +// LineScan: cheap field extraction for bootstrap install progress lines. +// Handles flat JSON; no nested array/object — the keys we extract are +// all leaves. +struct LineScan { + std::string_view s; + std::string find_str(std::string_view key) const { + std::string n = std::format("\"{}\":\"", key); + auto p = s.find(n); + if (p == std::string_view::npos) return ""; + p += n.size(); + std::string out; + while (p < s.size() && s[p] != '"') { + if (s[p] == '\\' && p + 1 < s.size()) { + out.push_back(s[p+1]); p += 2; continue; + } + out.push_back(s[p++]); + } + return out; + } + double find_num(std::string_view key) const { + std::string n = std::format("\"{}\":", key); + auto p = s.find(n); + if (p == std::string_view::npos) return 0; + p += n.size(); + auto e = p; + while (e < s.size() + && (std::isdigit(static_cast(s[e])) + || s[e] == '.' || s[e] == '-' || s[e] == '+' + || s[e] == 'e' || s[e] == 'E')) ++e; + try { return std::stod(std::string(s.substr(p, e - p))); } + catch (...) { return 0; } + } + bool find_bool(std::string_view key) const { + std::string n = std::format("\"{}\":", key); + auto p = s.find(n); + if (p == std::string_view::npos) return false; + p += n.size(); + return s.size() - p >= 4 && s.substr(p, 4) == "true"; + } +}; + +} // anonymous namespace + +// ─── Index identity ───────────────────────────────────────────────── + +std::optional index_revision(const std::filesystem::path& indexDir) { + return read_index_revision(indexDir); +} + +// ─── The handshake ────────────────────────────────────────────────── + +mcpp::provider::ProviderInfo describe(const std::filesystem::path& binary, + std::chrono::milliseconds budget) { + using namespace std::chrono; + const auto deadline = steady_clock::now() + budget; + // Older clients can adopt or journal a home before answering a query. + // The handshake describes the binary, so ask it outside the user's home. + static std::mutex mutex; + static std::map, mcpp::provider::ProviderInfo> cache; + std::lock_guard lock(mutex); + mcpp::provider::ProviderInfo info; + std::error_code ec; + const auto executable = std::filesystem::absolute(binary, ec); + if (ec) return info; + const auto size = std::filesystem::file_size(executable, ec); + if (ec) return info; + const auto modified = std::filesystem::last_write_time(executable, ec); + if (ec) return info; + const auto key = std::tuple{executable, size, modified}; + if (auto it = cache.find(key); it != cache.end()) return it->second; + // AND ACROSS PROCESSES (design 2026-10-26 §8): the answer describes the + // binary, so it is kept under the home keyed as the in-process cache is, + // by the file's path, size and modification time. Every query (`emit + // build-database`, `toolchain list`) started a process for it. + const auto parse = [&](const nlohmann::json& j) { + const auto strings = [&](const char* field) { + std::vector values; + if (j.contains(field) && j[field].is_array()) + for (const auto& value : j[field]) if (value.is_string()) values.push_back(value.get()); + return values; + }; + info.version = j.value("version", std::string{}); + info.protocol = j.value("protocol_version", std::string{}); + info.capabilities = strings("capabilities"); + info.features = strings("features"); + info.homeLayouts.clear(); + if (j.contains("home_layouts") && j["home_layouts"].is_array()) + for (const auto& value : j["home_layouts"]) if (value.is_number_integer()) info.homeLayouts.push_back(value.get()); + info.answered = !info.version.empty(); + }; + const auto stamp = std::format("{}\t{}\t{}", executable.generic_string(), + static_cast(size), static_cast(modified.time_since_epoch().count())); + const auto persisted = mcpp::home::root() / "cache" / "provider-handshake" + / (std::format("{:016x}", std::hash{}(stamp)) + ".json"); + if (std::ifstream in(persisted, std::ios::binary); in) { + auto j = nlohmann::json::parse(in, nullptr, false); + if (j.is_object() && j.value("stamp", std::string{}) == stamp && j.contains("answer")) { + parse(j["answer"]); + if (info.answered) { + cache.emplace(key, info); + return info; + } + info = {}; + } + } + const auto temporary = std::filesystem::temp_directory_path(ec); + if (ec) return info; + const auto home = temporary / std::format("mcpp-provider-handshake-{}-{}", + steady_clock::now().time_since_epoch().count(), std::random_device{}()); + if (!std::filesystem::create_directory(home, ec) || ec) return info; + struct Cleanup { + std::filesystem::path home; + ~Cleanup() { std::error_code error; std::filesystem::remove_all(home, error); } + } cleanup{home}; + try { + const auto remaining = duration_cast(deadline - steady_clock::now()); + if (remaining <= milliseconds::zero()) return info; + auto inv = invocation(Env{executable, home, {}}, { "interface", "--version" }); + inv.argv.front() = mcpp::home::spelling(executable); + inv.options.cwd = mcpp::home::spelling(home); + inv.options.streams = mcpp::run::Streams::output; + inv.options.total = remaining; + inv.options.report_spawn_failure = false; + const auto output = mcpp::run::execute(inv.argv, inv.options); + const bool timedOut = output.timed_out; + const auto j = !timedOut && output.exit_code == 0 + ? nlohmann::json::parse(output.output, nullptr, false) : nlohmann::json{}; + if (j.is_object()) { + parse(j); + // Written only by a run that writes the global cache, for the + // reason the probe cache gives. + if (info.answered && mcpp::effects::writes_global_cache() + && std::filesystem::is_directory(mcpp::home::root(), ec)) { + std::filesystem::create_directories(persisted.parent_path(), ec); + auto tmp = persisted; + tmp += std::format(".{:x}", std::random_device{}()); + { std::ofstream(tmp, std::ios::binary) << nlohmann::json{{"stamp", stamp}, {"answer", j}}.dump(); } + std::filesystem::rename(tmp, persisted, ec); + if (ec) std::filesystem::remove(tmp, ec); + } + } + } catch (const std::exception&) { info = {}; } + cache.emplace(key, info); + return info; +} + +std::string unmet(const mcpp::provider::ProviderInfo& info) { + if (!info.answered) return "answers no handshake (an xlings before 2026.10.16.1)"; + if (mcpp::xpkg_version::compare_keys(info.version, pinned::kXlingsVersion) < 0) + return std::format("is {}, older than {}", info.version, pinned::kXlingsVersion); + for (auto feature : {"index_dir", "index_store", "upstreams", "payload_status", "sync_index", + "payload_currency", "binding_state", "network_diagnostics", + "download_socket_budgets", "read_only_queries"}) + if (std::ranges::find(info.features, feature) == info.features.end()) + return std::format("does not offer `{}`", feature); + if (std::ranges::find(info.capabilities, "install_packages") == info.capabilities.end()) + return "does not offer `install_packages`"; + return {}; +} + +// ─── Shell quoting ────────────────────────────────────────────────── + +std::string shq(std::string_view s) { + return mcpp::build::command_line::quote(s); +} + +// The same, for an argument that may carry a shell metacharacter -- which the +// JSON payloads below do, and which a version constraint spelled `>=` does. +// See `quote_windows_through_cmd` for what `shq` alone leaves unprotected. +std::string shq_meta(std::string_view s) { + return mcpp::build::command_line::quote_through_shell(s); +} + +// ─── Path helpers ─────────────────────────────────────────────────── + +namespace paths { + +std::filesystem::path xpkgs_base(const Env& env) { + return env.home / "data" / "xpkgs"; +} + +std::filesystem::path sandbox_bin(const Env& env) { + return env.home / "subos" / "default" / "bin"; +} + +std::filesystem::path sysroot(const Env& env) { + return env.home / "subos" / "default"; +} + +std::filesystem::path xim_tool_root(const Env& env, std::string_view tool) { + return xpkgs_base(env) / std::format("xim-x-{}", tool); +} + +std::filesystem::path xim_tool(const Env& env, std::string_view tool, + std::string_view version) { + return xpkgs_base(env) / std::format("xim-x-{}", tool) / std::string(version); +} + +// Parse `[:][@]` exactly as a manifest writes it. +XpkgRef parse_xpkg_ref(std::string_view spec) { return mcpp::provider::parse_ref(spec); } + +// Where that package's payload is, or nullopt if it is not installed. +// +// THE ANSWER IS XLINGS' ANSWER. The address was resolved by xlings, so the +// payload it selected is found with xlings' grammar (mcpp.xpkg_version), not +// with the Cargo grammar mcpp reads its own dependencies with. They disagree +// where it matters (#712): `libglvnd@1.7` installed `1.7.0.1`, and the Cargo +// reading of `1.7` could not see a four-segment directory at all, so +// `mcpp::xpkg_dir` answered "" for a payload that was on disk. +// +// The request selects among the installed directories exactly as xlings would +// among index keys (`select_installed`): a literal name first, then a bare +// version of three or more segments as written-prefix equality (1.8.12 matches +// 1.8.12.x and never 1.9.0), one or two segments as a prefix range, operators +// as ranges; with no version, the highest installed one. +std::optional +xpkg_payload(const Env& env, const XpkgRef& ref) { + if (auto recorded = recorded_payload(env, ref)) return recorded; + for (const auto& store : stores(env)) + if (auto found = xpkg_payload_at(store, ref)) return found; + return std::nullopt; +} + +std::optional +xpkg_payload_at(const std::filesystem::path& xpkgsBase, const XpkgRef& ref) { + if (ref.name.empty()) return std::nullopt; + const auto root = xpkgsBase / std::format("{}-x-{}", ref.ns, ref.name); + std::error_code ec; + if (!std::filesystem::is_directory(root, ec)) return std::nullopt; + std::vector installed; + for (auto const& e : std::filesystem::directory_iterator(root, ec)) + if (e.is_directory(ec)) installed.push_back(e.path().filename().string()); + auto pick = mcpp::xpkg_version::select_installed(installed, ref.version); + if (!pick) return std::nullopt; + return root / *pick; +} + + +// ─── Resolution records ──────────────────────────────────────────── + +namespace { + +// One file per (xpkgs base, address). The address is canonicalised through +// parse_xpkg_ref, so `libglvnd@1.7` and `xim:libglvnd@1.7` share a record, and +// the base is part of the key, so two homes never answer for each other. +std::filesystem::path record_path(const Env& env, const XpkgRef& ref) { + std::uint64_t h = 1469598103934665603ull; // FNV-1a + const auto key = std::format("{}\n{}:{}@{}", + xpkgs_base(env).generic_string(), ref.ns, ref.name, ref.version); + for (unsigned char ch : key) { h ^= ch; h *= 1099511628211ull; } + return mcpp::home::root() / "provisioned" / "resolved" + / std::format("{:016x}.json", h); +} + +} // namespace + +std::vector parse_install_targets(std::string_view payloadJson) { + std::vector out; + auto j = nlohmann::json::parse(payloadJson, nullptr, /*allow_exceptions=*/false); + if (!j.is_object() || !j.contains("targets") || !j["targets"].is_array()) return out; + auto str = [](const nlohmann::json& o, const char* k) { + return o.contains(k) && o[k].is_string() ? o[k].get() : std::string{}; + }; + for (auto const& t : j["targets"]) { + if (!t.is_object()) continue; + ResolvedTarget r; + r.request = str(t, "request"); + r.ns = str(t, "namespace"); + r.name = str(t, "name"); + r.version = str(t, "version"); + r.status = str(t, "status"); + r.payloadDir = str(t, "payload_dir"); + if (t.contains("revision") && t["revision"].is_number_integer()) + r.revision = t["revision"].get(); + if (!r.request.empty()) out.push_back(std::move(r)); + } + return out; +} + +void record_resolutions(const Env& env, std::span targets) { + for (auto const& t : targets) { + if (t.status == "failed" || t.payloadDir.empty()) continue; + std::error_code ec; + if (!std::filesystem::is_directory(t.payloadDir, ec)) continue; + const auto path = record_path(env, parse_xpkg_ref(t.request)); + std::filesystem::create_directories(path.parent_path(), ec); + nlohmann::json j; + j["request"] = t.request; + j["namespace"] = t.ns; + j["name"] = t.name; + j["version"] = t.version; + j["revision"] = t.revision; + j["payload_dir"] = t.payloadDir.generic_string(); + auto tmp = path; + tmp += std::format(".{}.tmp", + std::chrono::steady_clock::now().time_since_epoch().count()); + { + std::ofstream o{tmp, std::ios::binary | std::ios::trunc}; + if (!o) continue; + o << j.dump(); + } + std::filesystem::rename(tmp, path, ec); + if (ec) std::filesystem::remove(tmp, ec); + } +} + +std::optional installed_revision(const std::filesystem::path& payloadDir) { + std::ifstream in{payloadDir / ".xpkg-install.json", std::ios::binary}; + if (!in) return std::nullopt; + std::string text{std::istreambuf_iterator(in), {}}; + auto j = nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false); + if (!j.is_object()) return std::nullopt; + if (!j.contains("revision") || !j["revision"].is_number_integer()) return 0; + return j["revision"].get(); +} + +std::optional +recorded_payload(const Env& env, const XpkgRef& ref) { + if (ref.name.empty()) return std::nullopt; + std::ifstream in{record_path(env, ref), std::ios::binary}; + if (!in) return std::nullopt; + std::string text{std::istreambuf_iterator(in), {}}; + auto j = nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false); + if (!j.is_object() || !j.contains("payload_dir") || !j["payload_dir"].is_string()) + return std::nullopt; + const std::filesystem::path dir = j["payload_dir"].get(); + // A record answers only for a payload that is still there and still this + // package's, in this env's store. Anything else -- the payload was + // removed, the home moved -- falls through to the grammar, and the + // provisioning check treats "no answer" as not installed (#716). + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) return std::nullopt; + for (const auto& store : stores(env)) { + const auto root = store / std::format("{}-x-{}", ref.ns, ref.name); + if (dir.parent_path().lexically_normal() == root.lexically_normal()) return dir; + } + return std::nullopt; +} + +std::optional +xpkgs_from_compiler(const std::filesystem::path& compilerBin) { + for (auto p = compilerBin.parent_path(); + p.has_parent_path() && p != p.root_path(); + p = p.parent_path()) { + if (p.filename() == "xpkgs") return p; + } + return std::nullopt; +} + +std::optional +subos_dir_of(const std::filesystem::path& compilerBin) { + auto xpkgs = xpkgs_from_compiler(compilerBin); + if (!xpkgs) return std::nullopt; + // /data/xpkgs → /subos/default. Spelled from the xpkgs dir + // rather than from Env so a toolchain inherited from ANOTHER home resolves + // to that home's subos, which is the one whose payloads the binary was + // actually linked against. + auto home = xpkgs->parent_path().parent_path(); + if (home.empty()) return std::nullopt; + return home / "subos" / "default"; +} + +std::optional +find_sibling_tool(const std::filesystem::path& compilerBin, + std::string_view tool) { + auto xpkgs = xpkgs_from_compiler(compilerBin); + if (!xpkgs) return std::nullopt; + + auto root = *xpkgs / std::format("xim-x-{}", tool); + std::error_code ec; + if (!std::filesystem::exists(root, ec)) return std::nullopt; + + // The first version dir readdir yields. ORDER IS UNDEFINED -- this does + // NOT return the highest, and the comment that said so was wrong for as + // long as it stood. With two versions installed the answer varies by + // filesystem, so a caller that needs a SPECIFIC version must name it; + // see probe.cppm, where the version comes from the resolved runtime + // binding rather than from this scan. + // + // A test asserting the old "highest" claim was attempted and removed: it + // passed or failed by directory order, which makes both outcomes + // uninformative. + for (auto& v : std::filesystem::directory_iterator(root, ec)) { + if (v.is_directory(ec)) return v.path(); + } + return std::nullopt; +} + +std::optional active_home_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; +} + +namespace { + +// A version dir qualifies as a payload only if it has real content — +// dot-prefixed entries (.xim-installed, .xpkg.lua) are install metadata, +// and a dir holding nothing else is the husk a delegating index package +// leaves behind (the payload lives under another prefix; issue #120). +// When requiredRelPath is given, the dir must also contain that path. +bool payload_dir_qualifies(const std::filesystem::path& versionDir, + std::string_view requiredRelPath) { + std::error_code ec; + bool hasContent = false; + for (auto& f : std::filesystem::directory_iterator(versionDir, ec)) { + if (!f.path().filename().string().starts_with(".")) { + hasContent = true; + break; + } + } + if (!hasContent) return false; + if (!requiredRelPath.empty() + && !std::filesystem::exists(versionDir / requiredRelPath, ec)) + return false; + return true; +} + +// Scan an xpkgs root across index prefixes (xim-x-, scode-x-, compat-x-, …) +// for the first qualifying version dir of `packageName`. +std::optional +find_package_in_xpkgs(const std::filesystem::path& xpkgs, + std::string_view packageName, + std::string_view requiredRelPath) { + std::error_code ec; + std::string suffix = std::format("-x-{}", packageName); + for (auto& entry : std::filesystem::directory_iterator(xpkgs, ec)) { + if (!entry.is_directory(ec)) continue; + auto name = entry.path().filename().string(); + if (!name.ends_with(suffix)) continue; + for (auto& v : std::filesystem::directory_iterator(entry.path(), ec)) { + if (!v.is_directory(ec)) continue; + if (payload_dir_qualifies(v.path(), requiredRelPath)) + return v.path(); + } + } + return std::nullopt; +} + +} // namespace + +std::optional +find_home_tool(std::string_view tool, std::string_view requiredRelPath) { + auto xpkgs = active_home_xpkgs(); + if (!xpkgs) return std::nullopt; + return find_package_in_xpkgs(*xpkgs, tool, requiredRelPath); +} + +std::optional +find_sibling_binary(const std::filesystem::path& compilerBin, + std::string_view tool, + std::string_view binaryRelPath) { + auto xpkgs = xpkgs_from_compiler(compilerBin); + if (!xpkgs) return std::nullopt; + + auto root = *xpkgs / std::format("xim-x-{}", tool); + std::error_code ec; + if (!std::filesystem::exists(root, ec)) return std::nullopt; + + for (auto& v : std::filesystem::directory_iterator(root, ec)) { + auto candidate = v.path() / std::string(binaryRelPath); + if (std::filesystem::exists(candidate, ec)) + return candidate; + } + return std::nullopt; +} + +std::optional +find_sibling_package(const std::filesystem::path& compilerBin, + std::string_view packageName, + std::string_view requiredRelPath) { + auto xpkgs = xpkgs_from_compiler(compilerBin); + if (!xpkgs) return std::nullopt; + + // Search across index prefixes: xim-x-, scode-x-, compat-x-, etc. + if (auto found = find_package_in_xpkgs(*xpkgs, packageName, requiredRelPath)) + return found; + + // 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; +} + +std::filesystem::path index_data(const Env& env) { + return env.home / "data"; +} + +namespace { +std::vector declared_in(const std::filesystem::path& file, const std::filesystem::path& base, + const std::filesystem::path& dataRoot) { + std::vector out; + std::ifstream in(file); + if (!in) return out; + auto j = nlohmann::json::parse(in, nullptr, false); + if (!j.is_object() || !j.contains("index_repos") || !j["index_repos"].is_array()) return out; + const auto absolute = [&](const std::string& p) { + const std::filesystem::path path = p; + return path.is_absolute() ? path.lexically_normal() : (base / path).lexically_normal(); + }; + for (const auto& e : j["index_repos"]) { + if (!e.is_object() || !e.contains("name") || !e["name"].is_string()) continue; + DeclaredIndex d; + d.name = e["name"].get(); + if (e.contains("dir") && e["dir"].is_string() && !e["dir"].get().empty()) { + d.dir = absolute(e["dir"].get()); + d.owned = true; + } else { + d.dir = dataRoot / d.name; + } + if (e.contains("store") && e["store"].is_string() && !e["store"].get().empty()) + d.store = absolute(e["store"].get()); + out.push_back(std::move(d)); + } + return out; +} +} // namespace + +std::vector declared_indices(const Env& env) { + std::vector out; + if (!env.projectDir.empty()) + out = declared_in(env.projectDir / ".xlings.json", env.projectDir, env.projectDir / "data"); + for (auto& d : declared_in(env.home / ".xlings.json", env.home, index_data(env))) + if (std::ranges::none_of(out, [&](const DeclaredIndex& o) { return o.name == d.name; })) + out.push_back(std::move(d)); + return out; +} + +std::filesystem::path index_dir_of(const Env& env, std::string_view name) { + for (auto& d : declared_indices(env)) + if (d.name == name) return d.dir; + return index_data(env) / std::string(name); +} + +std::vector indices_of(const std::filesystem::path& home) { + std::error_code ec; + if (!std::filesystem::is_regular_file(home / ".xlings.json", ec)) return {}; + std::vector out{"xim"}; + for (auto& d : declared_in(home / ".xlings.json", home, home / "data")) + if (std::ranges::find(out, d.name) == out.end()) out.push_back(d.name); + return out; +} + +std::vector index_roots_of(const std::filesystem::path& home) { + const auto data = home / "data"; + std::vector out; + std::error_code ec; + const auto declared = declared_in(home / ".xlings.json", home, data); + // An owned index is read where its owner keeps it; a copy left at the + // provider's own place (written by a provider that did not know the + // owner's place) is not a second index. + const auto owned_name = [&](const std::filesystem::path& p) { + return std::ranges::any_of(declared, [&](const DeclaredIndex& d) { return d.owned && d.name == p.filename(); }); + }; + for (auto& e : std::filesystem::directory_iterator(data, ec)) + if (e.is_directory(ec) && !owned_name(e.path())) out.push_back(e.path()); + for (auto& d : declared) + if (d.owned && std::filesystem::is_directory(d.dir, ec) && std::ranges::find(out, d.dir) == out.end()) + out.push_back(d.dir); + std::ranges::sort(out); + return out; +} + +std::vector stores_of(const std::filesystem::path& home) { + std::vector out; + for (auto& d : declared_in(home / ".xlings.json", home, home / "data")) + if (!d.store.empty() && std::ranges::find(out, d.store) == out.end()) out.push_back(d.store); + if (auto own = home / "data" / "xpkgs"; std::ranges::find(out, own) == out.end()) out.push_back(own); + return out; +} + +std::vector index_roots(const Env& env) { + return index_roots_of(env.projectDir.empty() ? env.home : env.projectDir); +} + +void own_indices(const Env& env, std::span owned) { + const bool project = !env.projectDir.empty(); + const auto base = project ? env.projectDir : env.home; + const auto data = project ? env.projectDir / "data" : index_data(env); + const auto file = base / ".xlings.json"; + std::ifstream in(file); + if (!in) return; + auto j = nlohmann::json::parse(in, nullptr, false); + in.close(); + if (!j.is_object() || !j.contains("index_repos") || !j["index_repos"].is_array()) return; + bool changed = false; + for (const auto& o : owned) { + if (o.dir.empty()) continue; + for (auto& e : j["index_repos"]) { + if (!e.is_object() || e.value("name", std::string{}) != o.name) continue; + // Its data, from where the provider kept it, once: a rename on one + // filesystem, a copy and a removal across two. + std::error_code ec; + const auto old = data / o.name; + if (std::filesystem::is_directory(old, ec) && !std::filesystem::exists(o.dir, ec)) { + std::filesystem::create_directories(o.dir.parent_path(), ec); + std::filesystem::rename(old, o.dir, ec); + if (ec) { + ec.clear(); + std::filesystem::copy(old, o.dir, std::filesystem::copy_options::recursive + | std::filesystem::copy_options::copy_symlinks, ec); + if (!ec) std::filesystem::remove_all(old, ec); + else std::filesystem::remove_all(o.dir, ec); + } + } + if (e.value("dir", std::string{}) != o.dir.generic_string()) { + e["dir"] = o.dir.generic_string(); + changed = true; + } + if (!o.store.empty() && e.value("store", std::string{}) != o.store.generic_string()) { + e["store"] = o.store.generic_string(); + changed = true; + } + } + } + if (!changed) return; + auto tmp = file; + tmp += std::format(".mcpp-{:x}", std::random_device{}()); + write_file(tmp, j.dump(2) + "\n"); + std::error_code ec; + std::filesystem::rename(tmp, file, ec); + if (ec) std::filesystem::remove(tmp, ec); +} + +std::vector stores(const Env& env) { + std::vector out; + for (auto& d : declared_indices(env)) + if (!d.store.empty() && std::ranges::find(out, d.store) == out.end()) out.push_back(d.store); + if (auto home = xpkgs_base(env); std::ranges::find(out, home) == out.end()) out.push_back(home); + return out; +} + +std::filesystem::path sandbox_init_marker(const Env& env) { + return env.home / "subos" / "default" / ".xlings.json"; +} + +} // namespace paths + +// ─── Shell command builders ───────────────────────────────────────── + +std::vector invocation_env(const Env& env) { + auto cap = [](const char* key, int limit) { + auto value = mcpp::invocation::get(key); + int existing = 0; + if (value) { + const auto [end, ec] = std::from_chars(value->data(), value->data() + value->size(), existing); + if (ec == std::errc{} && end == value->data() + value->size() && existing > 0) + limit = std::min(limit, existing); + } + return std::to_string(limit); + }; + return { + {"XLINGS_HOME", env.home.string(), true}, + {"XLINGS_PROJECT_DIR", env.projectDir.string(), !env.projectDir.empty()}, + {"XLINGS_ACTIVE_SUBOS", "", false}, + {"XLINGS_DOWNLOAD_CONNECT_TIMEOUT", cap("XLINGS_DOWNLOAD_CONNECT_TIMEOUT", 8), true}, + {"XLINGS_DOWNLOAD_IDLE_TIMEOUT", cap("XLINGS_DOWNLOAD_IDLE_TIMEOUT", 30), true}, + {"XLINGS_DOWNLOAD_LOW_SPEED", mcpp::invocation::get("XLINGS_DOWNLOAD_LOW_SPEED").value_or("off"), true}, + }; +} + +Invocation invocation(const Env& env, std::vector args) { + Invocation inv; + inv.argv.reserve(args.size() + 1); + inv.argv.push_back(env.binary.string()); + for (auto& a : args) inv.argv.push_back(std::move(a)); + for (auto const& var : invocation_env(env)) { + if (var.present) inv.options.env.emplace_back(var.name, var.value); + else inv.options.unset.push_back(var.name); + } + auto path = paths::sandbox_bin(env).string(); + if (auto inherited = mcpp::invocation::get("PATH")) + path += std::string(mcpp::runtime::launch_env::path_list_separator()) + *inherited; + inv.options.env.emplace_back("PATH", std::move(path)); + inv.options.cwd = env.home.string(); + return inv; +} + +void select_runtime(const Env& env, std::string_view runtimeId) { + auto inv = invocation(env, { "config", "--runtime", std::string(runtimeId) }); + inv.options.streams = mcpp::run::Streams::discard; + (void)mcpp::run::execute(inv.argv, inv.options); +} + +std::string windows_command_prefix(const Env& env) { + return std::format("cd /d {} && {}", + mcpp::build::command_line::quote_windows(env.home.string()), + mcpp::build::command_line::quote_windows(env.binary.string())); +} + +std::string build_command_prefix(const Env& env) { + auto xvmBin = paths::sandbox_bin(env).string(); + if constexpr (mcpplibs::os::is_windows) { + return windows_command_prefix(env); + } else { + // `env` takes its `-u` operands before its assignments. + std::string unset, assign; + for (auto const& var : invocation_env(env)) { + if (var.present) assign += std::format(" {}={}", var.name, shq(var.value)); + else unset += std::format(" -u {}", var.name); + } + return std::format("cd {} && env{} PATH={}:\"$PATH\"{} {}", + shq(env.home.string()), unset, shq(xvmBin), assign, + shq(env.binary.string())); + } +} + +std::string build_interface_command(const Env& env, + std::string_view capability, + std::string_view argsJson) { + return std::format("{} interface {} --args {} {}", + build_command_prefix(env), capability, shq_meta(argsJson), + mcpp::build::command_line::null_redirect); +} + +// ─── JSON extraction helpers ──────────────────────────────────────── + +std::string extract_string(std::string_view text, std::string_view key) { + auto needle = std::string{"\""} + std::string(key) + "\":\""; + auto p = text.find(needle); + if (p == std::string_view::npos) return ""; + p += needle.size(); + std::string out; + while (p < text.size()) { + char c = text[p++]; + if (c == '\\' && p < text.size()) { + char nc = text[p++]; + switch (nc) { + case 'n': out.push_back('\n'); break; + case 't': out.push_back('\t'); break; + case 'r': out.push_back('\r'); break; + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + default: out.push_back(nc); + } + } else if (c == '"') { + return out; + } else { + out.push_back(c); + } + } + return out; +} + +std::optional extract_int(std::string_view text, std::string_view key) { + auto needle = std::string{"\""} + std::string(key) + "\":"; + auto p = text.find(needle); + if (p == std::string_view::npos) return std::nullopt; + p += needle.size(); + while (p < text.size() && text[p] == ' ') ++p; + bool neg = false; + if (p < text.size() && text[p] == '-') { neg = true; ++p; } + long long n = 0; + bool any = false; + while (p < text.size() && std::isdigit(static_cast(text[p]))) { + n = n * 10 + (text[p++] - '0'); + any = true; + } + if (!any) return std::nullopt; + return neg ? -n : n; +} + +std::optional extract_bool(std::string_view text, std::string_view key) { + auto needle = std::string{"\""} + std::string(key) + "\":"; + auto p = text.find(needle); + if (p == std::string_view::npos) return std::nullopt; + p += needle.size(); + while (p < text.size() && text[p] == ' ') ++p; + if (text.substr(p, 4) == "true") return true; + if (text.substr(p, 5) == "false") return false; + return std::nullopt; +} + +std::string extract_object(std::string_view text, std::string_view key) { + auto needle = std::string{"\""} + std::string(key) + "\":"; + auto p = text.find(needle); + if (p == std::string_view::npos) return ""; + p += needle.size(); + while (p < text.size() && text[p] == ' ') ++p; + if (p >= text.size() || (text[p] != '{' && text[p] != '[')) return ""; + char open = text[p]; + char close = (open == '{') ? '}' : ']'; + int depth = 0; + std::size_t start = p; + bool in_string = false; + while (p < text.size()) { + char c = text[p]; + if (in_string) { + if (c == '\\' && p + 1 < text.size()) { p += 2; continue; } + if (c == '"') in_string = false; + ++p; continue; + } + if (c == '"') { in_string = true; ++p; continue; } + if (c == open) { ++depth; } + else if (c == close) { + --depth; + if (depth == 0) return std::string(text.substr(start, p - start + 1)); + } + ++p; + } + return ""; +} + +// ─── NDJSON event parser ──────────────────────────────────────────── + +std::optional parse_event_line(std::string_view line) { + auto kind = extract_string(line, "kind"); + if (kind == "progress") { + ProgressEvent e; + e.phase = extract_string(line, "phase"); + e.percent = static_cast(extract_int(line, "percent").value_or(0)); + e.message = extract_string(line, "message"); + return e; + } + if (kind == "log") { + LogEvent e; + e.level = extract_string(line, "level"); + e.message = extract_string(line, "message"); + return e; + } + if (kind == "data") { + DataEvent e; + e.dataKind = extract_string(line, "dataKind"); + e.payloadJson= extract_object(line, "payload"); + return e; + } + if (kind == "error") { + ErrorEvent e; + e.code = extract_string(line, "code"); + e.message = extract_string(line, "message"); + e.hint = extract_string(line, "hint"); + e.recoverable = extract_bool(line, "recoverable").value_or(false); + return e; + } + if (kind == "result") { + ResultEvent e; + e.exitCode = static_cast(extract_int(line, "exitCode").value_or(0)); + return e; + } + // heartbeat and unknown kinds + return std::nullopt; +} + +// ─── Subprocess call ──────────────────────────────────────────────── + +namespace { +std::optional download_progress_of(std::string_view line); +} + +std::expected +call(const Env& env, std::string_view capability, + std::string_view argsJson, EventHandler* handler) +{ + if (capability == "system_status" || capability == "payload_status") { + const auto info = describe(env.binary); + if (std::ranges::find(info.features, "read_only_queries") == info.features.end()) + return std::unexpected("the provider does not offer read_only_queries; status was not queried"); + } + // STDERR IS KEPT APART, NOT DISCARDED AND NOT MERGED INTO STDOUT (#614). + // Stdout is parsed line by line as NDJSON, so merging stderr into it could + // split an event; discarding it lost the one line that names a rejection. + // It is read only when the call fails. + auto inv = invocation(env, { "interface", std::string(capability), "--args", std::string(argsJson) }); + const auto cmd = mcpp::build::command_line::display(inv.argv); + + // #238: under MCPP_VERBOSE=1 surface the exact xlings invocation so a + // failing install_packages can be reproduced/inspected by hand. The + // NDJSON child's own error/warn lines are surfaced by the caller's + // EventHandler (e.g. InstallProgressHandler). + mcpp::log::verbose("xlings", + std::format("interface {} exec: {}", capability, cmd)); + + // An install reaches the network; the query capabilities read local state. + if (capability == "install_packages" || capability == "update_packages") + mcpp::effects::note_network_access(); + + CallResult result; + const bool installing = capability == "install_packages"; + const bool syncing = capability == "sync_index" || capability == "update_packages"; + InstallDeadline deadline; + std::map sourceAttempts; + inv.options.streams = mcpp::run::Streams::output_error_kept; + inv.options.on_line = [&](std::string_view line) { + if (line.empty()) return; + if (installing) { + deadline.observe(line); + if (!mcpp::ui::is_quiet()) + if (const auto progress = download_progress_of(line)) + for (const auto& file : progress->files) + if (!file.sourceHost.empty() && sourceAttempts[file.name] != file.sourceAttempt) { + sourceAttempts[file.name] = file.sourceAttempt; + print_status("Source", std::format("{} {} (attempt {})", + file.name, file.sourceHost, file.sourceAttempt)); + } + } + + auto ev = parse_event_line(line); + if (!ev) return; + + std::visit([&](auto&& e) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + if (handler) handler->on_progress(e); + } else if constexpr (std::is_same_v) { + if (handler) handler->on_log(e); + } else if constexpr (std::is_same_v) { + result.dataEvents.push_back(e); + if (handler) handler->on_data(e); + } else if constexpr (std::is_same_v) { + // The FIRST error is the cause. A package whose + // dependency failed reports that after it (xlings + // 2026.10.10.2), and older clients followed a failure with + // its consequences ("registered none of its programs"). + if (!result.error) result.error = e; + if (handler) handler->on_error(e); + } else if constexpr (std::is_same_v) { + result.exitCode = e.exitCode; + result.resultJson = e.dataJson; + if (handler) handler->on_result(e); + } + }, *ev); + }; + inv.options.total = installing ? std::chrono::duration_cast(kDirectInstallTimeout) + : syncing ? std::chrono::duration_cast(index_refresh_timeout()) + : std::chrono::milliseconds{0}; + inv.options.idle = std::chrono::duration_cast(kInterfaceIdleTimeout); + inv.options.should_stop = [&] { return installing && deadline.expired(); }; + const auto ran = mcpp::run::execute(inv.argv, inv.options); + const int rc = ran.exit_code; + const bool timedOut = ran.timed_out; + if (timedOut) { + result.exitCode = result.exitCode != 0 ? result.exitCode : 124; + const auto message = std::format( + "provider {} exceeded its progress/process deadline and was stopped", capability); + if (!result.error) result.error = ErrorEvent{ + installing || syncing ? "E_NETWORK" : "E_PROVIDER_TIMEOUT", message, + installing || syncing ? "check proxy settings or use mcpp self config --mirror auto|CN|GLOBAL" + : "retry the local provider operation", true}; + result.stderrTail.push_back(message); + } + if (rc != 0 && result.exitCode == 0) result.exitCode = rc; + if (result.exitCode != 0) { + // Error-level lines only, the last 20: enough to name a rejection, and + // bounded so a verbose child cannot bury the diagnostic it is attached + // to. + auto tail = stderr_error_tail(ran.error_output); + result.stderrTail.insert(result.stderrTail.end(), tail.begin(), tail.end()); + } + return result; +} + +std::vector stderr_error_tail(std::string_view text, std::size_t limit) { + std::deque tail; + std::size_t pos = 0; + while (pos < text.size()) { + const auto nl = text.find('\n', pos); + std::string line(text.substr(pos, nl == std::string_view::npos + ? std::string_view::npos : nl - pos)); + pos = nl == std::string_view::npos ? text.size() : nl + 1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + std::string lower = line; + std::ranges::transform(lower, lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lower.find("error") == std::string::npos + && line.find("E_") == std::string::npos + && !line.starts_with("[xim]")) + continue; + tail.push_back(std::move(line)); + if (tail.size() > limit) tail.pop_front(); + } + return {tail.begin(), tail.end()}; +} + +namespace { +// The `files[]` of one NDJSON `download_progress` data event, in the order +// xlings reports them; nullopt when the line carries none. Shared by the +// sandbox bootstrap and the index refresh, which render the same event. +std::optional download_progress_of(std::string_view line) { + LineScan ls{line}; + auto p = line.find("\"files\":["); + if (p == std::string_view::npos) return std::nullopt; + p += 9; + + BootstrapProgress prog; + prog.elapsedSec = ls.find_num("elapsedSec"); + + while (p < line.size()) { + while (p < line.size() && (line[p] == ' ' || line[p] == '\n' + || line[p] == ',')) ++p; + if (p >= line.size() || line[p] == ']') break; + if (line[p] != '{') break; + int depth = 0; + auto start = p; + bool in_string = false; + for (; p < line.size(); ++p) { + char c = line[p]; + if (in_string) { + if (c == '\\' && p + 1 < line.size()) { ++p; continue; } + if (c == '"') in_string = false; + continue; + } + if (c == '"') in_string = true; + else if (c == '{') ++depth; + else if (c == '}') { if (--depth == 0) { ++p; break; } } + } + LineScan fl{line.substr(start, p - start)}; + BootstrapFile f; + f.name = fl.find_str("name"); + f.downloadedBytes = fl.find_num("downloadedBytes"); + f.totalBytes = fl.find_num("totalBytes"); + f.started = fl.find_bool("started"); + f.finished = fl.find_bool("finished"); + f.sourceHost = fl.find_str("sourceHost"); + f.sourceAttempt = static_cast(fl.find_num("sourceAttempt")); + if (!f.name.empty()) prog.files.push_back(std::move(f)); + } + if (prog.files.empty()) return std::nullopt; + return prog; +} + +// The events of one `xlings interface update_packages` run, rendered with the +// renderer every other acquisition uses (mcpp::ui::ProgressBar and +// DownloadProgress). A `progress` event carries a phase and a percentage; +// one bar is drawn per phase (xlings reports `index_sync`, one event per +// repository, and `index_rebuild`, one event per descriptor file), so a +// refresh prints a few lines off a terminal rather than one per file. A +// `download_progress` data event is an index artifact being fetched. A line +// that is not an event is not rendered: an xlings that predates structured +// index progress printed its own terminal text on this stream, and the bar +// is what replaces it. +class IndexRefreshRenderer { +public: + void line(std::string_view text) { + LineScan ls{text}; + const auto kind = ls.find_str("kind"); + if (kind == "result") { + resultExit_ = static_cast(ls.find_num("exitCode")); + } else if (kind == "progress") { + const auto phase = ls.find_str("phase"); + if (phase.empty()) return; + // A sync step names its repository at the end of its message + // (`syncing index repo 2/5: mcpplibs`); each repository is its + // own bar, labelled with that name. + std::string subject; + if (phase == "index_sync") { + const auto message = ls.find_str("message"); + if (auto colon = message.rfind(": "); colon != std::string::npos) + subject = message.substr(colon + 2); + } + const auto key = subject.empty() ? phase : phase + "/" + subject; + if (!bar_ || key != label_) { + if (bar_) bar_->finish(); + shown_ = subject.empty() ? phase_label(phase) + : std::format("package index {}", subject); + bar_.emplace("Updating", shown_); + label_ = key; + } + const auto pct = std::clamp(ls.find_num("percent"), 0.0, 100.0); + bar_->update(static_cast(pct)); + } else if (kind == "data" && ls.find_str("dataKind") == "download_progress") { + auto prog = download_progress_of(text); + if (!prog) return; + if (bar_) { bar_->finish(); bar_.reset(); label_.clear(); } + std::vector files; + for (auto const& f : prog->files) + files.push_back({f.name, + static_cast(f.downloadedBytes), + static_cast(f.totalBytes), + f.started, f.finished}); + download_.update(files, prog->elapsedSec); + } + } + // The exit code the result event carried, or -1 when none arrived. + int result_exit() const { return resultExit_; } + // The run failed or was stopped: the open bar says the step did not + // complete rather than reporting it done. + void fail() { failed_ = true; } + ~IndexRefreshRenderer() { + if (bar_) failed_ ? bar_->finish_failed(shown_) : bar_->finish(); + failed_ ? download_.finish_failed() : download_.finish(); + } + +private: + static std::string phase_label(std::string_view phase) { + if (phase == "index_sync") return "package index (sync)"; + if (phase == "index_rebuild") return "package index (rebuild)"; + return std::format("package index ({})", phase); + } + + std::optional bar_; + std::string label_; // the phase (and repository) drawn + std::string shown_; // the bar's label + bool failed_ = false; + mcpp::ui::DownloadProgress download_; + int resultExit_ = -1; +}; +} // namespace + +void InstallDeadline::observe(std::string_view line, std::chrono::steady_clock::time_point now) { + const auto event = parse_event_line(line); + if (!event) return; // heartbeats and unknown events never renew a budget + if (const auto* progress = std::get_if(&*event)) { + if (progress->phase == "extract" || progress->phase == "hook" || progress->phase == "configure") + localWork_ = true; + } else if (const auto* data = std::get_if(&*event); + data && data->dataKind == "download_progress") { + if (const auto p = download_progress_of(line)) { + downloads_.observe(*p, now); + downloadSeen_ |= std::ranges::any_of(p->files, [](const auto& f) { return f.started; }); + } + } +} + +mcpp::provider::NetworkReport network_check(const Env& env, std::chrono::milliseconds budget) { + using namespace std::chrono; + using json = nlohmann::json; + if (mcpp::effects::offline_mode()) return {"offline", {}}; + std::error_code fileError; + if (!std::filesystem::is_regular_file(env.binary, fileError)) return {"unavailable", {}}; + const auto deadline = steady_clock::now() + budget; + auto run = [&](std::vector args) -> std::optional { + const auto remaining = duration_cast(deadline - steady_clock::now()); + if (remaining <= milliseconds::zero()) return std::nullopt; + auto inv = invocation(env, std::move(args)); + inv.argv.front() = mcpp::home::spelling(env.binary); + inv.options.total = remaining; + inv.options.report_spawn_failure = false; + const auto r = mcpp::run::execute(inv.argv, inv.options); + if (r.timed_out || r.exit_code != 0) return std::nullopt; + return r.output; + }; + // Do not run an unknown capability on an older provider: its generic CLI + // may journal or initialize an operation before returning "unsupported". + const auto info = describe(env.binary, duration_cast(deadline - steady_clock::now())); + const auto has = [&](std::string_view feature) { + return std::ranges::find(info.features, feature) != info.features.end(); + }; + const bool supported = has("network_diagnostics") && has("read_only_queries"); + if (!supported) return {steady_clock::now() >= deadline ? "timeout" : "unsupported", {}}; + auto call = [&](std::string_view operation, const json& args, std::string_view kind) -> json { + try { + if (operation == "probe_url") mcpp::effects::note_network_access(); + const auto output = run({"interface", std::string(operation), "--args", args.dump()}); + if (!output) return nullptr; + std::istringstream lines(*output); + std::string line; + while (std::getline(lines, line)) { + auto event = json::parse(line, nullptr, false); + if (event.is_object() && event.value("kind", "") == "data" + && event.value("dataKind", "") == kind && event.contains("payload")) + return event["payload"]; + } + return nullptr; + } catch (const std::exception&) { return nullptr; } + }; + const auto discovered = call("network_endpoints", json::object(), "network_endpoints"); + if (!discovered.is_object() || !discovered.contains("endpoints") + || !discovered["endpoints"].is_array()) + return {steady_clock::now() >= deadline ? "timeout" : "unsupported", {}}; + const auto& endpoints = discovered["endpoints"]; + mcpp::provider::NetworkReport report{"checked", {}}; + report.endpoints.resize(endpoints.size()); + std::atomic next{0}; + auto worker = [&] { + for (;;) { + const auto i = next.fetch_add(1); + if (i >= endpoints.size()) return; + const auto& e = endpoints[i]; + auto& out = report.endpoints[i]; + try { + out.resource = e.value("resource", ""); + out.region = e.value("region", ""); + out.status = "timeout"; + if (!e.contains("url") || !e["url"].is_string()) { out.status = "invalid_endpoint"; continue; } + const auto data = call("probe_url", json{{"url", e["url"]}, {"socket_timeout_ms", 2000}}, "network_probe"); + if (!data.is_object()) continue; + out.host = data.value("host", ""); + out.status = data.value("status", "unknown"); + out.httpStatus = data.value("http_status", 0); + out.elapsedSeconds = data.value("elapsed_seconds", 0.0); + out.reachable = data.value("reachable", false); + out.proxy = data.value("proxy", false); + } catch (const std::exception&) { out.status = "invalid_response"; } + } + }; + std::vector workers; + for (std::size_t i = 0; i < std::min(4, endpoints.size()); ++i) + workers.emplace_back(worker); + for (auto& w : workers) w.join(); + return report; +} + +// ─── install_with_progress ────────────────────────────────────────── + +int install_with_progress(const Env& env, std::string_view target, + const BootstrapProgressCallback& cb, + bool quiet) +{ + const auto argsJson = nlohmann::json{{"targets", {std::string(target)}}, {"yes", true}}.dump(); + + // One structured install attempt. Repeating a failed direct install through + // the interface only retries the same operation and hides its first error. + if (!quiet) print_status("Installing", target); + mcpp::effects::note_network_access(); + auto inv = invocation(env, { "interface", "install_packages", "--args", argsJson }); + + int resultExitCode = -1; + std::map sourceAttempts; + mcpp::ui::DownloadProgress download; + InstallDeadline deadline; + + auto handle_line = [&](std::string_view line) { + deadline.observe(line); + LineScan ls{line}; + auto kind = ls.find_str("kind"); + if (kind == "result") { + resultExitCode = static_cast(ls.find_num("exitCode")); + return; + } + if (kind == "error") { + const auto message = ls.find_str("message"); + mcpp::log::warn("provider", message); + if (!quiet) print_status("Error", message); + return; + } + if (kind != "data" || ls.find_str("dataKind") != "download_progress") return; + const auto prog = download_progress_of(line); + if (!prog) return; + for (const auto& file : prog->files) { + if (!quiet && !file.sourceHost.empty() && sourceAttempts[file.name] != file.sourceAttempt) { + sourceAttempts[file.name] = file.sourceAttempt; + print_status("Source", std::format("{} {} (attempt {})", file.name, file.sourceHost, file.sourceAttempt)); + } + } + if (cb) cb(*prog); + else if (!quiet) { + std::vector files; + for (const auto& f : prog->files) + files.push_back({f.name, static_cast(std::max(0.0, f.downloadedBytes)), + static_cast(std::max(0.0, f.totalBytes)), f.started, f.finished}); + download.update(files, prog->elapsedSec); + } + }; + + inv.options.streams = mcpp::run::Streams::output; + inv.options.on_line = handle_line; + inv.options.total = std::chrono::duration_cast(kDirectInstallTimeout); + inv.options.idle = std::chrono::duration_cast(kInterfaceIdleTimeout); + inv.options.should_stop = [&] { return deadline.expired(); }; + const auto ran = mcpp::run::execute(inv.argv, inv.options); + const bool idleTimedOut = ran.timed_out; + const int closeRc = ran.exit_code; + if (!cb && !quiet) { + if (!idleTimedOut && resultExitCode == 0 && closeRc == 0) download.finish(); + else download.finish_failed(); + } + if (idleTimedOut) { + mcpp::log::warn("xlings", "package install exceeded its progress/process deadline and was stopped"); + if (!quiet) print_status("Network", "Download stopped: no byte progress or process deadline exceeded; check proxy/mirror settings"); + return resultExitCode > 0 ? resultExitCode : 124; + } + return (resultExitCode != -1) ? resultExitCode : closeRc; +} + +int install_direct(const Env& env, std::string_view target, bool quiet) { + auto inv = invocation(env, { "install", std::string(target), "-y" }); + mcpp::effects::note_network_access(); + // An empty standard input. Lines are passed through as the inherited + // terminal showed them before, unless the caller asked for quiet. They are + // xlings' own progress text, narration: standard error. + inv.options.streams = quiet ? mcpp::run::Streams::discard : mcpp::run::Streams::output_error_shown; + inv.options.on_line = [](std::string_view line) { std::println(stderr, "{}", line); }; + inv.options.total = std::chrono::duration_cast(kDirectInstallTimeout); + const auto ran = mcpp::run::execute(inv.argv, inv.options); + const bool timedOut = ran.timed_out; + const int rc = ran.exit_code; + if (timedOut) + mcpp::log::warn("xlings", std::format( + "`xlings install {}` did not finish within {} hours and was stopped", + target, kDirectInstallTimeout.count() / 3600)); + return rc; +} + +// ─── Sandbox lifecycle ────────────────────────────────────────────── + +void seed_xlings_json(const Env& env, + std::span repos, + std::string_view mirror, + const ProjectEnv& penv) +{ + auto path = env.home / ".xlings.json"; + std::string json = "{\n"; + json += " \"index_repos\": [\n"; + for (std::size_t i = 0; i < repos.size(); ++i) { + json += std::format(" {{ \"name\": \"{}\", \"url\": \"{}\"", + json_escape(repos[i].name), + json_escape(repos[i].url)); + if (!repos[i].artifact.empty() && !repos[i].artifactCn.empty()) + json += std::format(", \"artifact\": {{ \"GLOBAL\": \"{}\", \"CN\": \"{}\" }}", + json_escape(repos[i].artifact), + json_escape(repos[i].artifactCn)); + else if (!repos[i].artifact.empty()) + json += std::format(", \"artifact\": \"{}\"", + json_escape(repos[i].artifact)); + if (!repos[i].source.empty()) + json += std::format(", \"source\": \"{}\"", + json_escape(repos[i].source)); + if (!repos[i].dir.empty()) + json += std::format(", \"dir\": \"{}\"", json_escape(repos[i].dir.generic_string())); + if (!repos[i].store.empty()) + json += std::format(", \"store\": \"{}\"", json_escape(repos[i].store.generic_string())); + json += std::format(" }}{}\n", i + 1 == repos.size() ? "" : ","); + } + json += " ],\n"; + // [xlings] build environment (L-1): materialize deps/workspace/subos/envs + // verbatim into the keys xlings reads. Each is emitted only when non-empty. + auto emit_obj = [&](std::string_view key, + std::span> kv) { + json += std::format(" \"{}\": {{\n", key); + for (std::size_t i = 0; i < kv.size(); ++i) + json += std::format(" \"{}\": \"{}\"{}\n", + json_escape(kv[i].first), json_escape(kv[i].second), + i + 1 == kv.size() ? "" : ","); + json += " },\n"; + }; + if (!penv.deps.empty()) { + json += " \"deps\": ["; + for (std::size_t i = 0; i < penv.deps.size(); ++i) + json += std::format("{}\"{}\"", i ? ", " : "", json_escape(penv.deps[i])); + json += "],\n"; + } + if (!penv.workspace.empty()) emit_obj("workspace", penv.workspace); + if (!penv.subos.empty()) + json += std::format(" \"subos\": \"{}\",\n", json_escape(penv.subos)); + json += " \"lang\": \"en\",\n"; + json += std::format(" \"mirror\": \"{}\"\n", json_escape(mirror)); + json += "}\n"; + write_file(path, json); +} + +int config_show(const Env& env) { + std::ifstream file(env.home / ".xlings.json", std::ios::binary); + if (!file) { + std::println("mirror = auto (default; registry configuration is not initialized)"); + return 0; + } + std::cout << file.rdbuf(); + return 0; +} + +int config_set_mirror(const Env& env, std::string_view mirror, bool quiet) { + if (mirror.empty()) return 0; + // Configuration recovery must work before the first provider invocation. + // The adapter owns its format; asking the CLI first would bootstrap through + // precisely the failed mirror the user is trying to change. + const auto path = env.home / ".xlings.json"; + auto lock = mcpp::home::FileLock::try_acquire(env.home); + if (!lock) return 1; + std::ifstream input(path, std::ios::binary); + auto doc = nlohmann::json::parse(input, nullptr, false); + input.close(); // Windows cannot atomically replace a file still held open here. + if (doc.is_discarded() || !doc.is_object()) return 1; + if (doc.value("mirror", "") == mirror) return 0; + doc["mirror"] = mirror; + auto staged = path; + staged += ".tmp"; + { + std::ofstream output(staged, std::ios::binary); + output << doc.dump(2) << '\n'; + output.close(); + if (!output) return 1; + } + std::error_code ec; + mcpp::home::replace_file(staged, path, ec); + if (ec) { std::error_code ignored; std::filesystem::remove(staged, ignored); } + (void)quiet; + return ec ? 1 : 0; +} + +void ensure_init(const Env& env, bool quiet) { + auto marker = paths::sandbox_init_marker(env); + if (std::filesystem::exists(marker)) return; + + // Ensure the home directory exists before cd'ing into it. + std::error_code ec; + std::filesystem::create_directories(env.home, ec); + + if (!quiet) + print_status("Initialize", "mcpp sandbox layout (one-time)"); + mcpp::log::ScopedTimer _t_init("init", "sandbox layout (xlings self init)"); + // `self init` initialises the global sandbox, so it runs in global mode + // whatever the caller's project directory is. + Env globalEnv = env; + globalEnv.projectDir.clear(); + auto inv = invocation(globalEnv, { "self", "init" }); + inv.options.streams = mcpp::run::Streams::discard; + inv.options.total = std::chrono::seconds{120}; + const int rc = mcpp::run::execute(inv.argv, inv.options).exit_code; + if (rc != 0 && !quiet) { + std::println(stderr, + "warning: `xlings self init` failed for sandbox at '{}'", + env.home.string()); + } + + // The first real `xlings install` (the next bootstrap step) triggers + // xlings to fetch its package index — a one-time, network-bound step + // with no output of its own. Announce it so the user knows the silent + // wait that follows is expected. This runs once: ensure_init is gated + // by the sandbox-init marker above. + if (!quiet) + print_status("Fetching", "package index (one-time)"); +} + +void ensure_patchelf(const Env& env, bool quiet, + const BootstrapProgressCallback& cb) +{ + auto toolDir = paths::xim_tool(env, "patchelf", pinned::kPatchelfVersion); + auto binary = toolDir / "bin" / "patchelf"; + if (std::filesystem::exists(binary)) return; + + // Clean up incomplete installation residue (e.g. from Ctrl+C interrupt). + if (std::filesystem::exists(toolDir)) { + if (!quiet) + print_status("Repairing", "patchelf (incomplete installation, cleaning up)"); + std::error_code ec; + std::filesystem::remove_all(toolDir, ec); + } + + if (!quiet) + print_status("Bootstrap", "patchelf into mcpp sandbox (one-time)"); + mcpp::log::ScopedTimer _t_pe("init", std::format("bootstrap patchelf@{}", pinned::kPatchelfVersion)); + int rc = install_with_progress(env, + std::format("xim:patchelf@{}", pinned::kPatchelfVersion), cb, quiet); + if (rc != 0 && !quiet) { + std::println(stderr, + "warning: failed to bootstrap patchelf into mcpp sandbox; " + "subsequent xim installs may skip ELF rewriting"); + } +} + +void ensure_ninja(const Env& env, bool quiet, + const BootstrapProgressCallback& cb) +{ + auto root = paths::xim_tool_root(env, "ninja"); + auto ninja_name = std::string("ninja") + std::string(mcpplibs::os::exe_suffix); + if (std::filesystem::exists(root)) { + std::error_code ec; + for (auto& v : std::filesystem::directory_iterator(root, ec)) { + if (std::filesystem::exists(v.path() / ninja_name)) return; + } + // Directory exists but no version has a working binary — residue. + if (!quiet) + print_status("Repairing", "ninja (incomplete installation, cleaning up)"); + std::filesystem::remove_all(root, ec); + } + if (!quiet) + print_status("Bootstrap", "ninja into mcpp sandbox (one-time)"); + mcpp::log::ScopedTimer _t_ninja("init", std::format("bootstrap ninja@{}", pinned::kNinjaVersion)); + int rc = install_with_progress(env, + std::format("xim:ninja@{}", pinned::kNinjaVersion), cb, quiet); + if (rc != 0 && !quiet) { + std::println(stderr, + "warning: failed to bootstrap ninja into mcpp sandbox (exit {})", + rc); + } +} + +namespace { + +// "NASM version 2.16.03 compiled on ..." → true iff ≥ 2.16. A binary that +// fails to run or reports something unparseable is unusable — false. +bool nasm_version_ok(const std::filesystem::path& bin) { + auto r = mcpp::run::capture_exec({bin.string(), "-v"}); + if (r.exit_code != 0) return false; + auto pos = r.output.find("version "); + if (pos == std::string::npos) return false; + int major = 0, minor = 0; + const char* p = r.output.c_str() + pos + 8; + while (*p >= '0' && *p <= '9') major = major * 10 + (*p++ - '0'); + if (*p++ != '.') return false; + while (*p >= '0' && *p <= '9') minor = minor * 10 + (*p++ - '0'); + return major > 2 || (major == 2 && minor >= 16); +} + +} // namespace + +std::optional find_sandbox_nasm(const Env& env) { + auto root = paths::xim_tool_root(env, "nasm"); + auto nasm_name = std::string("nasm") + std::string(mcpplibs::os::exe_suffix); + std::error_code ec; + if (!std::filesystem::exists(root, ec)) return std::nullopt; + for (auto& v : std::filesystem::directory_iterator(root, ec)) { + for (auto cand : { v.path() / nasm_name, v.path() / "bin" / nasm_name }) { + if (std::filesystem::exists(cand, ec) && nasm_version_ok(cand)) + return cand; + } + } + return std::nullopt; +} + +// THE ECOSYSTEM COPY IS TRIED FIRST, AND IT USED TO BE TRIED SECOND. +// +// This function answered `which("nasm")` before looking in the sandbox, so a +// machine with an assembler on PATH assembled with that one and a machine +// without downloaded the pinned `xim:nasm`. Three machines could produce three +// different objects from one source tree with nothing in the build saying so, +// which is the property every other tool in this engine is arranged to avoid: +// the compiler, the linker, ninja and patchelf all come from the graph or the +// sandbox, and none of them asks PATH first. +// +// The host copy is kept, and only as a last resort: a machine that is offline +// and already has a usable assembler can still build, which is the one case +// the sandbox route cannot serve. When it is the one used, the caller says so +// in the build report rather than leaving it silent --- `nasm_is_from_host` +// below is how the caller tells the two apart. +std::optional find_usable_nasm(const Env& env) { + if (auto sandboxed = find_sandbox_nasm(env)) return sandboxed; + auto nasm_name = std::string("nasm") + std::string(mcpplibs::os::exe_suffix); + if (auto sys = mcpp::invocation::which(nasm_name); + sys && nasm_version_ok(*sys)) { + return sys; + } + return std::nullopt; +} + +// Whether a path `find_usable_nasm` returned is the host's rather than the +// sandbox's. Asked by the caller so that a host tool reaching a build is +// NAMED there; a host tool that reaches a build silently is the defect, not +// the host tool. +bool nasm_is_from_host(const Env& env, const std::filesystem::path& bin) { + auto root = paths::xim_tool_root(env, "nasm"); + std::error_code ec; + auto canonRoot = std::filesystem::weakly_canonical(root, ec); + auto canonBin = std::filesystem::weakly_canonical(bin, ec); + if (canonRoot.empty() || canonBin.empty()) return true; + auto r = canonRoot.string(); + auto b = canonBin.string(); + return b.rfind(r, 0) != 0; +} + +// ─── Index freshness ──────────────────────────────────────────────── + +bool is_index_fresh(const Env& env, std::int64_t ttlSeconds) { + return is_index_dir_fresh(default_index_dir(env), ttlSeconds); +} + +bool is_official_index_fresh(const Env& env, std::int64_t ttlSeconds) { + return is_index_dir_fresh(official_index_dir(env), ttlSeconds); +} + +IndexStatus default_index_status(const Env& env, std::int64_t ttlSeconds) { + return index_status_for(default_index_dir(env), ttlSeconds); +} + +IndexStatus official_index_status(const Env& env, std::int64_t ttlSeconds) { + return index_status_for(official_index_dir(env), ttlSeconds); +} + +bool is_official_package_index_fresh(const Env& env, + std::string_view packageName, + std::int64_t ttlSeconds) { + if (!is_official_index_fresh(env, ttlSeconds)) return false; + auto pkg = official_package_file(env, packageName); + return !pkg.empty() + && std::filesystem::exists(pkg) + && official_index_cache_matches_package_file(env, packageName); +} + +// The one entry point every refresh in this process passes through. +// +// an index-side change must never take mcpp from "works" to "does not work" +// +// A published index can raise its client-version floor (index.toml +// min_mcpp). An index mcpp owns (one whose data directory mcpp declared, +// design 2026-10-16 §6) is therefore fetched into a staging directory beside +// it (xlings' `sync_index` with a destination, X1), judged there, and swapped +// in only when this mcpp can read it -- or when the copy in use is no better. +// The rest (the provider's own indexes, which carry no floor of mcpp's) are +// refreshed by the provider in place; it leaves an owned index that is there +// to its owner. +namespace { +std::atomic g_index_refresh_timeout_s{kDefaultIndexRefreshTimeout.count()}; + +struct StagedOutcome { + std::vector kept; // the refreshed copy needs a newer mcpp; the old one stays + std::vector unusable; // no copy this mcpp can read + std::map requiredMcpp; +}; + +// One owned index: .staging- fetched, judged, then exchanged with +// (or renamed onto it when there is none yet); the staging directory +// holds the old copy afterwards and is removed. +int refresh_owned(const Env& env, const paths::DeclaredIndex& d, StagedOutcome& out) { + std::error_code ec; + auto staging = d.dir; + staging += std::format(".staging-{:x}", std::random_device{}()); + std::filesystem::create_directories(d.dir.parent_path(), ec); + const auto args = nlohmann::json{{"name", d.name}, {"dest", staging.generic_string()}}.dump(); + auto r = call(env, "sync_index", args, nullptr); + if (!r || r->exitCode != 0 || !std::filesystem::is_directory(staging, ec)) { + std::filesystem::remove_all(staging, ec); + return r ? (r->exitCode != 0 ? r->exitCode : 1) : 1; + } + const bool present = std::filesystem::exists(d.dir, ec); + const bool before = present && mcpp::pm::index_usable(d.dir); + if (!mcpp::pm::index_usable(staging)) { + if (auto c = mcpp::pm::read_index_contract(staging)) out.requiredMcpp[d.dir] = c->minMcpp; + if (before) { + std::filesystem::remove_all(staging, ec); + out.kept.push_back(d.dir); + return 0; + } + out.unusable.push_back(d.dir); + } + auto swapped = present ? mcpplibs::os::fs::exchange(staging, d.dir) + : mcpplibs::os::fs::place(staging, d.dir); + std::filesystem::remove_all(staging, ec); + if (!swapped) { + mcpp::log::warn("index", std::format("the refreshed index `{}` could not replace the one in use: {}", + d.name, swapped.error().message())); + return 1; + } + mark_index_refreshed(d.dir); + return 0; +} + +// The raw sync. Everything that makes a refresh SAFE lives in the wrapper +// below; this function's only job is to run the provider's refresh and report. +int update_index_unguarded(const Env& env, bool quiet); +} // namespace + +void set_index_refresh_timeout(std::chrono::seconds timeout) { + g_index_refresh_timeout_s.store( + timeout.count() > 0 ? timeout.count() : kDefaultIndexRefreshTimeout.count(), + std::memory_order_relaxed); +} + +std::chrono::seconds index_refresh_timeout() { + return std::chrono::seconds{g_index_refresh_timeout_s.load(std::memory_order_relaxed)}; +} + +int update_index(const Env& env, bool quiet) { + return update_index_unguarded(env, quiet); +} + +namespace { +int update_index_unguarded(const Env& env, bool quiet) { + // Offline is absolute: no caller gets to reach the network by going around + // the decision layer. Reported as success so a build that can still resolve + // everything locally proceeds — the caller that genuinely needed the data + // fails on its own missing answer, with a message that says so. + if (mcpp::effects::offline_mode()) { + mcpp::log::verbose("index", "offline mode: skipping index update"); + return 0; + } + + // Cross-process mutex. The sync rewrites a tree several concurrent mcpp + // processes read (parallel CI jobs on one MCPP_HOME, two terminals, a + // workspace fan-out), and it also writes the refresh markers. The BMI cache + // has had this guard for a while; the index never did. + // + // NON-BLOCKING BY DESIGN: whoever holds the lock is already doing the exact + // work we wanted, so waiting buys nothing and a queue of stalled builds is + // precisely the symptom this whole change exists to remove. Skipping is + // also why this must not be reported as failure. + std::error_code lockEc; + std::filesystem::create_directories(paths::index_data(env), lockEc); + auto lock = mcpp::home::FileLock::try_acquire(paths::index_data(env)); + if (!lock) { + mcpp::log::verbose("index", + "another process is refreshing the index — skipping this one"); + return 0; + } + + // The indices mcpp owns, each staged and judged before it replaces the + // copy in use. A floor is not an error of the run (an index is data; + // mcpp is the program): each case is one closing notice, printed after + // the command's own output, and only because this run refreshed. + StagedOutcome staged; + int ownedRc = 0; + for (const auto& d : paths::declared_indices(env)) + if (d.owned) ownedRc = std::max(ownedRc, refresh_owned(env, d, staged)); + auto advice = [&](const std::filesystem::path& dir) { + auto it = staged.requiredMcpp.find(dir); + return mcpp::pm::index_floor_upgrade_advice( + it == staged.requiredMcpp.end() ? std::string_view{} : std::string_view{it->second}); + }; + for (auto& dir : staged.kept) + mcpp::ui::add_closing_notice("MCPP_INDEX_REQUIRES_NEWER_MCPP", std::format( + "the refreshed package index `{}` requires a newer mcpp; this run " + "used the previous index. {}", dir.filename().string(), advice(dir))); + for (auto& dir : staged.unusable) + mcpp::ui::add_closing_notice("MCPP_INDEX_REQUIRES_NEWER_MCPP", std::format( + "the package index `{}` requires a newer mcpp and no earlier copy is " + "usable; packages it serves cannot be resolved. {}", dir.filename().string(), advice(dir))); + + // Through the NDJSON interface, as installs are (W11): the refresh reports + // its steps as events and is drawn by the same renderer, instead of the + // bare CLI's terminal text, which was either reprinted verbatim or, for + // the automatic refresh, discarded, so a refresh of many seconds showed + // nothing. The interface's `update_packages` and the CLI's `update` are + // one function in xlings (xim::cmd_update). + auto inv = invocation(env, { "interface", "update_packages", "--args", "{}" }); + inv.options.streams = mcpp::run::Streams::output; + inv.options.total = std::chrono::duration_cast(index_refresh_timeout()); + mcpp::effects::note_network_access(); + // The index sync is a network git operation; a single transient blip (DNS, + // TLS reset, a mirror hiccup) otherwise fails a cold `mcpp self env` / + // first-run init outright (e.g. CI's index/sandbox bootstrap). Retry with + // linear backoff. The success path returns on the FIRST attempt — zero + // added latency in steady state; only a genuine failure pays the backoff. + constexpr int kMaxAttempts = 3; + int rc = 0; + const auto refreshBound = index_refresh_timeout(); + for (int attempt = 1; attempt <= kMaxAttempts; ++attempt) { + bool timedOut = false; + { + IndexRefreshRenderer renderer; + inv.options.on_line = [&renderer](std::string_view line) { renderer.line(line); }; + const auto ran = mcpp::run::execute(inv.argv, inv.options); + rc = ran.exit_code; + timedOut = ran.timed_out; + if (rc == 0 && renderer.result_exit() > 0) rc = renderer.result_exit(); + if (rc != 0 || timedOut) renderer.fail(); + } + if (rc == 0 && !timedOut) { mark_known_indexes_refreshed(env); return ownedRc; } + // A refresh that exceeded its bound is not retried: the retries exist for + // a transient failure that ends, and a connection that never answers + // would only be waited on three times. The caller treats the refresh as + // failed and resolves from the local index, as it does for any failure. + if (timedOut) { + std::println(stderr, + "warning: the package index refresh did not finish within {} seconds and was " + "stopped; continuing with the local index " + "(the bound is [index] refresh_timeout in mcpp's config.toml)", + refreshBound.count()); + return rc != 0 ? rc : 124; + } + if (attempt < kMaxAttempts) { + int delay = attempt * 2; // 2s, then 4s + mcpp::log::verbose("index", std::format( + "index update attempt {}/{} failed (rc {}); retrying in {}s", + attempt, kMaxAttempts, rc, delay)); + std::this_thread::sleep_for(std::chrono::seconds(delay)); + } + } + // Said, not only logged, to a caller that refreshes on its own (`quiet`: + // the TTL refresh before a build), which continues with the local index; + // `mcpp index update` reports the failure itself. + if (quiet) + std::println(stderr, + "warning: the package index refresh failed after {} attempts (exit {}); " + "continuing with the local index", kMaxAttempts, rc); + return rc; +} +} // namespace + +void ensure_index_fresh(const Env& env, std::int64_t ttlSeconds, bool quiet) { + if (is_index_fresh(env, ttlSeconds)) return; + if (!quiet) + print_status("Updating", "package index (auto-refresh)"); + update_index(env, /*quiet=*/true); +} + +bool official_package_present(const Env& env, std::string_view packageName) { + auto pkg = official_package_file(env, packageName); + std::error_code ec; + return !pkg.empty() && std::filesystem::exists(pkg, ec); +} + +} // namespace mcpp::provider::xlings diff --git a/src/provider/xlings/xlings.cppm b/src/provider/xlings/xlings.cppm index 059d37e2..c92bcade 100644 --- a/src/provider/xlings/xlings.cppm +++ b/src/provider/xlings/xlings.cppm @@ -17,12 +17,17 @@ import std; import mcpp.provider; import mcpp.pm.compat; import mcpp.pm.index_contract; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.invocation; +import mcpp.effects; +import mcpp.build.command_line; +import mcpp.home.files; import mcpp.log; import mcpp.ui; // closing notices of the refresh guard import mcpp.home; import mcpp.xpkg_version; import mcpp.libs.json; +import mcpplibs.os.fs; export namespace mcpp::provider::xlings { @@ -375,16 +380,16 @@ std::string shq(std::string_view s); // See `shell::quote_windows_through_cmd`. std::string shq_meta(std::string_view s); -// ─── Shell command builders ───────────────────────────────────────── +// ─── One xlings invocation ────────────────────────────────────────── -// Build the standard xlings command prefix with proper env vars. +// The command line a person runs to repeat what mcpp ran (diagnostics, the +// commands a message names), POSIX: // cd '' && env -u XLINGS_PROJECT_DIR PATH=:"$PATH" // XLINGS_HOME='' '' std::string build_command_prefix(const Env& env); // The Windows spelling of that prefix, compiled on every host so that it is -// tested from any: `cd /d "" && ""`. The variables are applied -// by ScopedInvocationEnv instead. +// tested from any: `cd /d "" && ""`. // // THE WORKING DIRECTORY IS THE HOME ON BOTH PLATFORMS (#726). xlings enters // project mode by walking up from its working directory to a `.xlings.json`. @@ -397,8 +402,8 @@ std::string windows_command_prefix(const Env& env); // THE ENVIRONMENT OF ONE XLINGS INVOCATION (#614), decided once. Each entry is // a variable, its value, and whether it is present at all. Global mode is an // absent XLINGS_PROJECT_DIR, because xlings resolves its subos scope from that -// variable. POSIX renders the decision into the command prefix (`env -u` and -// `K=V`); Windows applies it to the process through ScopedInvocationEnv. +// variable. The started xlings receives the decision (`invocation`); the +// displayed command spells it (`env -u` and `K=V`). // // XLINGS_ACTIVE_SUBOS IS ALWAYS ABSENT. A shell that ran `xlings subos use // ` exports it, and xlings ranks it above the home's own `activeSubos`. @@ -415,37 +420,8 @@ struct InvocationVar { }; std::vector invocation_env(const Env& env); -// Applies `invocation_env` and the sandbox's `bin` in front of PATH to this -// process for the guard's lifetime on Windows, and restores every prior value -// when the guard ends. On POSIX the command prefix carries them and the guard -// does nothing. Every function that runs a command built by -// `build_command_prefix` holds one while the command runs. -// -// NOTHING OUTLIVES THE INVOCATION (#726). The process environment after an -// xlings invocation is the one before it, so a build that installed a payload -// starts ninja with the environment a build that installed nothing does. The -// PATH prefix used to stay: the sandbox's `bin` holds the shims `xim:llvm` -// registers on Windows (`cl`, `link`, `lib`, `rc`), and every action of a -// first build then met them in front of MSVC's tools -- vcpkg's compiler -// detection failed there while the second build passed. -class ScopedInvocationEnv { -public: - explicit ScopedInvocationEnv(const Env& env); - ~ScopedInvocationEnv(); - ScopedInvocationEnv(const ScopedInvocationEnv&) = delete; - ScopedInvocationEnv& operator=(const ScopedInvocationEnv&) = delete; - -private: - // One entry per scope variable the guard applied: its name and the value - // it had before, if any. Plain members, not `std::optional`, - // which clang on the MSVC ABI does not copy inside a vector element. - struct Saved { - std::string name; - bool hadPrevious = false; - std::string previous; - }; - std::vector saved_; -}; +// The runtime the home's `abi` link selects (`xlings config --runtime`). +void select_runtime(const Env& env, std::string_view runtimeId); // Build full xlings interface command. // interface --args '' 2>/dev/null @@ -643,9 +619,9 @@ inline constexpr std::int64_t kIndexRefreshDebounceSeconds = mcpp::provider::kIn // ─── Bounds on the xlings children mcpp starts (#648 A3) ─────────────── // -// Every xlings invocation on the planning path runs through -// `run_streaming_bounded`: the child owns a process group (a job object on -// Windows) that dies with mcpp, and it is bounded. Before, an `xlings update` +// Every xlings invocation on the planning path is started by mcpp.run in a +// unit of its own (a process group, a job object on Windows) that ends with +// mcpp, and it is bounded. Before, an `xlings update` // on a connection that never answered held `emit build-database` for eleven // minutes, and outlived the mcpp its caller had killed. // @@ -722,10 +698,6 @@ IndexStatus official_index_status(const Env& env, std::int64_t ttlSeconds); // `path` index has none) or blank; callers must degrade, never hard-fail. std::optional index_revision(const std::filesystem::path& indexDir); -// ─── run_capture utility ──────────────────────────────────────────── - -std::expected run_capture(const std::string& cmd); - // What the xlings at `binary` is (`xlings interface --version`, protocol // 1.10+): its version, protocol, capabilities, features and home layouts; // `answered` false for an xlings before the handshake. @@ -737,2042 +709,3 @@ mcpp::provider::ProviderInfo describe(const std::filesystem::path& binary, std::string unmet(const mcpp::provider::ProviderInfo& info); } // namespace mcpp::provider::xlings - -// ═══════════════════════════════════════════════════════════════════════ -// Implementation -// ═══════════════════════════════════════════════════════════════════════ - -namespace mcpp::provider::xlings { - -namespace { - -// Right-pad a verb to 12 columns for bootstrap status lines, on standard -// error: the stream mcpp.ui narrates on. -void print_status(std::string_view verb, std::string_view msg) { - constexpr std::size_t W = 12; - if (verb.size() >= W) { - std::println(stderr, "{} {}", verb, msg); - } else { - std::println(stderr, "{}{} {}", std::string(W - verb.size(), ' '), verb, msg); - } -} - -std::filesystem::path default_index_dir(const Env& env) { - return paths::index_dir_of(env, "mcpplibs"); -} - -std::filesystem::path official_index_dir(const Env& env) { - return paths::index_data(env) / "xim-pkgindex"; -} - -std::filesystem::path index_pkgs_dir(const std::filesystem::path& indexDir) { - return indexDir / "pkgs"; -} - -std::filesystem::path index_refresh_marker(const std::filesystem::path& indexDir) { - return indexDir / ".mcpp-index-updated"; -} - -std::filesystem::path index_version_file(const std::filesystem::path& indexDir) { - return indexDir / ".xlings-index-version"; -} - -std::filesystem::path official_package_file(const Env& env, std::string_view packageName) { - if (packageName.empty()) return {}; - std::string name(packageName); - return official_index_dir(env) / "pkgs" / std::string(1, name[0]) / (name + ".lua"); -} - -std::string json_escaped_path_probe(std::filesystem::path path) { - auto value = path.string(); - std::string escaped; - escaped.reserve(value.size() * 2); - for (char c : value) { - if (c == '\\') escaped += "\\\\"; - else escaped.push_back(c); - } - return escaped; -} - -bool official_index_cache_matches_package_file(const Env& env, - std::string_view packageName) { - auto cache = official_index_dir(env) / ".xlings-index-cache.json"; - if (!std::filesystem::exists(cache)) return true; - - auto pkg = official_package_file(env, packageName); - if (pkg.empty()) return false; - - std::ifstream is(cache); - if (!is) return false; - std::string body((std::istreambuf_iterator(is)), {}); - auto rawPath = pkg.string(); - return body.find(rawPath) != std::string::npos - || body.find(json_escaped_path_probe(pkg)) != std::string::npos; -} - -void mark_index_refreshed(const std::filesystem::path& indexDir) { - if (!std::filesystem::exists(index_pkgs_dir(indexDir))) return; - std::error_code ec; - std::filesystem::create_directories(indexDir, ec); - auto marker = index_refresh_marker(indexDir); - { - std::ofstream os(marker, std::ios::trunc); - if (!os) return; - os << "ok\n"; - } - std::filesystem::last_write_time( - marker, std::filesystem::file_time_type::clock::now(), ec); -} - -void mark_known_indexes_refreshed(const Env& env) { - // Project-scoped envs used to return early here, which left the global - // indexes unmarked whenever a project with custom `[indices]` triggered the - // sync — so `mcpp index status` reported "unknown" forever on exactly the - // machines that refresh most. The marker is advisory (it debounces and it - // dates the status line; it is NOT what decides whether a refresh is - // needed), so marking it from either env is both safe and more accurate. - mark_index_refreshed(default_index_dir(env)); - mark_index_refreshed(official_index_dir(env)); -} - -bool is_index_dir_fresh(const std::filesystem::path& indexDir, std::int64_t ttlSeconds) { - std::error_code ec; - if (!std::filesystem::exists(index_pkgs_dir(indexDir))) return false; - - auto marker = index_refresh_marker(indexDir); - if (!std::filesystem::exists(marker)) return false; - - auto newest = std::filesystem::last_write_time(marker, ec); - if (ec) return false; - - auto now = std::filesystem::file_time_type::clock::now(); - auto age = std::chrono::duration_cast(now - newest); - // A marker stamped in the FUTURE yields a negative age, which the plain - // `age < ttl` test read as "fresh" — and stayed fresh until the wall clock - // caught up, potentially for years. Future timestamps are routine: clock - // skew in containers/VMs, a tar that preserved mtimes, a restored CI cache. - // An unusable timestamp means "unknown", and unknown must mean stale. - if (age.count() < 0) return false; - return age.count() < ttlSeconds; -} - -// Seconds since the index's refresh marker was last touched, or -1 if the -// marker is missing/unreadable. Read-only — no network, no side effects. -std::int64_t index_age_seconds(const std::filesystem::path& indexDir) { - std::error_code ec; - auto marker = index_refresh_marker(indexDir); - auto newest = std::filesystem::last_write_time(marker, ec); - if (ec) return -1; - auto now = std::filesystem::file_time_type::clock::now(); - auto age = std::chrono::duration_cast(now - newest).count(); - return age < 0 ? -1 : age; // future stamp → "unknown", same rule as above -} - -// Defined inside the anonymous namespace so `index_status_for` (also here) can -// use it; the exported `index_revision` below forwards to it. -std::optional read_index_revision(const std::filesystem::path& indexDir) { - std::ifstream is(index_version_file(indexDir), std::ios::binary); - if (!is) return std::nullopt; - std::string body((std::istreambuf_iterator(is)), {}); - // The observed files carry no trailing newline, but trim anyway: this value - // is compared for equality and printed, and a stray \r from a Windows-side - // writer would otherwise turn "same rev" into "changed rev" every run. - auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; }; - while (!body.empty() && isSpace(static_cast(body.back()))) - body.pop_back(); - std::size_t b = 0; - while (b < body.size() && isSpace(static_cast(body[b]))) ++b; - body.erase(0, b); - if (body.empty()) return std::nullopt; - return body; -} - -IndexStatus index_status_for(const std::filesystem::path& indexDir, - std::int64_t ttlSeconds) { - std::error_code ec; - bool present = std::filesystem::exists(index_pkgs_dir(indexDir), ec) && !ec; - return IndexStatus{ - .dir = indexDir, - .present = present, - .fresh = is_index_dir_fresh(indexDir, ttlSeconds), - .ageSeconds = index_age_seconds(indexDir), - .rev = read_index_revision(indexDir), - }; -} - -void write_file(const std::filesystem::path& p, std::string_view content) { - std::error_code ec; - std::filesystem::create_directories(p.parent_path(), ec); - std::ofstream os(p); - os << content; -} - -std::string json_escape(std::string_view value) { - std::string out; - out.reserve(value.size()); - for (unsigned char ch : value) { - switch (ch) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (ch < 0x20) { - out += std::format("\\u{:04x}", static_cast(ch)); - } else { - out.push_back(static_cast(ch)); - } - } - } - return out; -} - -// LineScan: cheap field extraction for bootstrap install progress lines. -// Handles flat JSON; no nested array/object — the keys we extract are -// all leaves. -struct LineScan { - std::string_view s; - std::string find_str(std::string_view key) const { - std::string n = std::format("\"{}\":\"", key); - auto p = s.find(n); - if (p == std::string_view::npos) return ""; - p += n.size(); - std::string out; - while (p < s.size() && s[p] != '"') { - if (s[p] == '\\' && p + 1 < s.size()) { - out.push_back(s[p+1]); p += 2; continue; - } - out.push_back(s[p++]); - } - return out; - } - double find_num(std::string_view key) const { - std::string n = std::format("\"{}\":", key); - auto p = s.find(n); - if (p == std::string_view::npos) return 0; - p += n.size(); - auto e = p; - while (e < s.size() - && (std::isdigit(static_cast(s[e])) - || s[e] == '.' || s[e] == '-' || s[e] == '+' - || s[e] == 'e' || s[e] == 'E')) ++e; - try { return std::stod(std::string(s.substr(p, e - p))); } - catch (...) { return 0; } - } - bool find_bool(std::string_view key) const { - std::string n = std::format("\"{}\":", key); - auto p = s.find(n); - if (p == std::string_view::npos) return false; - p += n.size(); - return s.size() - p >= 4 && s.substr(p, 4) == "true"; - } -}; - -} // anonymous namespace - -// ─── Index identity ───────────────────────────────────────────────── - -std::optional index_revision(const std::filesystem::path& indexDir) { - return read_index_revision(indexDir); -} - -// ─── The handshake ────────────────────────────────────────────────── - -mcpp::provider::ProviderInfo describe(const std::filesystem::path& binary, - std::chrono::milliseconds budget) { - using namespace std::chrono; - const auto deadline = steady_clock::now() + budget; - // Older clients can adopt or journal a home before answering a query. - // The handshake describes the binary, so ask it outside the user's home. - static std::mutex mutex; - static std::map, mcpp::provider::ProviderInfo> cache; - std::lock_guard lock(mutex); - mcpp::provider::ProviderInfo info; - std::error_code ec; - const auto executable = std::filesystem::absolute(binary, ec); - if (ec) return info; - const auto size = std::filesystem::file_size(executable, ec); - if (ec) return info; - const auto modified = std::filesystem::last_write_time(executable, ec); - if (ec) return info; - const auto key = std::tuple{executable, size, modified}; - if (auto it = cache.find(key); it != cache.end()) return it->second; - // AND ACROSS PROCESSES (design 2026-10-26 §8): the answer describes the - // binary, so it is kept under the home keyed as the in-process cache is, - // by the file's path, size and modification time. Every query (`emit - // build-database`, `toolchain list`) started a process for it. - const auto parse = [&](const nlohmann::json& j) { - const auto strings = [&](const char* field) { - std::vector values; - if (j.contains(field) && j[field].is_array()) - for (const auto& value : j[field]) if (value.is_string()) values.push_back(value.get()); - return values; - }; - info.version = j.value("version", std::string{}); - info.protocol = j.value("protocol_version", std::string{}); - info.capabilities = strings("capabilities"); - info.features = strings("features"); - info.homeLayouts.clear(); - if (j.contains("home_layouts") && j["home_layouts"].is_array()) - for (const auto& value : j["home_layouts"]) if (value.is_number_integer()) info.homeLayouts.push_back(value.get()); - info.answered = !info.version.empty(); - }; - const auto stamp = std::format("{}\t{}\t{}", executable.generic_string(), - static_cast(size), static_cast(modified.time_since_epoch().count())); - const auto persisted = mcpp::home::root() / "cache" / "provider-handshake" - / (std::format("{:016x}", std::hash{}(stamp)) + ".json"); - if (std::ifstream in(persisted, std::ios::binary); in) { - auto j = nlohmann::json::parse(in, nullptr, false); - if (j.is_object() && j.value("stamp", std::string{}) == stamp && j.contains("answer")) { - parse(j["answer"]); - if (info.answered) { - cache.emplace(key, info); - return info; - } - info = {}; - } - } - const auto temporary = std::filesystem::temp_directory_path(ec); - if (ec) return info; - const auto home = temporary / std::format("mcpp-provider-handshake-{}-{}", - steady_clock::now().time_since_epoch().count(), std::random_device{}()); - if (!std::filesystem::create_directory(home, ec) || ec) return info; - struct Cleanup { - std::filesystem::path home; - ~Cleanup() { std::error_code error; std::filesystem::remove_all(home, error); } - } cleanup{home}; - try { - std::vector> vars; - for (const auto& v : invocation_env(Env{binary, home, {}})) - vars.emplace_back(v.name, v.present ? v.value : ""); - const auto remaining = duration_cast(deadline - steady_clock::now()); - if (remaining <= milliseconds::zero()) return info; - bool timedOut = false; - const auto command = std::format("{} interface --version {}", - mcpp::platform::shell::quote_through_shell(mcpp::home::spelling(executable)), - mcpp::platform::null_redirect); - const auto output = mcpp::platform::process::capture_shell_deadline( - command, vars, remaining, &timedOut, mcpp::home::spelling(home)); - const auto j = !timedOut && output.exit_code == 0 - ? nlohmann::json::parse(output.output, nullptr, false) : nlohmann::json{}; - if (j.is_object()) { - parse(j); - // Written only by a run that writes the global cache, for the - // reason the probe cache gives. - if (info.answered && mcpp::platform::env::writes_global_cache() - && std::filesystem::is_directory(mcpp::home::root(), ec)) { - std::filesystem::create_directories(persisted.parent_path(), ec); - auto tmp = persisted; - tmp += std::format(".{:x}", std::random_device{}()); - { std::ofstream(tmp, std::ios::binary) << nlohmann::json{{"stamp", stamp}, {"answer", j}}.dump(); } - std::filesystem::rename(tmp, persisted, ec); - if (ec) std::filesystem::remove(tmp, ec); - } - } - } catch (const std::exception&) { info = {}; } - cache.emplace(key, info); - return info; -} - -std::string unmet(const mcpp::provider::ProviderInfo& info) { - if (!info.answered) return "answers no handshake (an xlings before 2026.10.16.1)"; - if (mcpp::xpkg_version::compare_keys(info.version, pinned::kXlingsVersion) < 0) - return std::format("is {}, older than {}", info.version, pinned::kXlingsVersion); - for (auto feature : {"index_dir", "index_store", "upstreams", "payload_status", "sync_index", - "payload_currency", "binding_state", "network_diagnostics", - "download_socket_budgets", "read_only_queries"}) - if (std::ranges::find(info.features, feature) == info.features.end()) - return std::format("does not offer `{}`", feature); - if (std::ranges::find(info.capabilities, "install_packages") == info.capabilities.end()) - return "does not offer `install_packages`"; - return {}; -} - -// ─── run_capture ──────────────────────────────────────────────────── - -std::expected run_capture(const std::string& cmd) { - auto r = mcpp::platform::process::capture(cmd); - if (r.exit_code != 0 && r.output.empty()) - return std::unexpected("command failed: " + cmd); - return r.output; -} - -// ─── Shell quoting ────────────────────────────────────────────────── - -std::string shq(std::string_view s) { - return mcpp::platform::shell::quote(s); -} - -// The same, for an argument that may carry a shell metacharacter -- which the -// JSON payloads below do, and which a version constraint spelled `>=` does. -// See `quote_windows_through_cmd` for what `shq` alone leaves unprotected. -std::string shq_meta(std::string_view s) { - return mcpp::platform::shell::quote_through_shell(s); -} - -// ─── Path helpers ─────────────────────────────────────────────────── - -namespace paths { - -std::filesystem::path xpkgs_base(const Env& env) { - return env.home / "data" / "xpkgs"; -} - -std::filesystem::path sandbox_bin(const Env& env) { - return env.home / "subos" / "default" / "bin"; -} - -std::filesystem::path sysroot(const Env& env) { - return env.home / "subos" / "default"; -} - -std::filesystem::path xim_tool_root(const Env& env, std::string_view tool) { - return xpkgs_base(env) / std::format("xim-x-{}", tool); -} - -std::filesystem::path xim_tool(const Env& env, std::string_view tool, - std::string_view version) { - return xpkgs_base(env) / std::format("xim-x-{}", tool) / std::string(version); -} - -// Parse `[:][@]` exactly as a manifest writes it. -XpkgRef parse_xpkg_ref(std::string_view spec) { return mcpp::provider::parse_ref(spec); } - -// Where that package's payload is, or nullopt if it is not installed. -// -// THE ANSWER IS XLINGS' ANSWER. The address was resolved by xlings, so the -// payload it selected is found with xlings' grammar (mcpp.xpkg_version), not -// with the Cargo grammar mcpp reads its own dependencies with. They disagree -// where it matters (#712): `libglvnd@1.7` installed `1.7.0.1`, and the Cargo -// reading of `1.7` could not see a four-segment directory at all, so -// `mcpp::xpkg_dir` answered "" for a payload that was on disk. -// -// The request selects among the installed directories exactly as xlings would -// among index keys (`select_installed`): a literal name first, then a bare -// version of three or more segments as written-prefix equality (1.8.12 matches -// 1.8.12.x and never 1.9.0), one or two segments as a prefix range, operators -// as ranges; with no version, the highest installed one. -std::optional -xpkg_payload(const Env& env, const XpkgRef& ref) { - if (auto recorded = recorded_payload(env, ref)) return recorded; - for (const auto& store : stores(env)) - if (auto found = xpkg_payload_at(store, ref)) return found; - return std::nullopt; -} - -std::optional -xpkg_payload_at(const std::filesystem::path& xpkgsBase, const XpkgRef& ref) { - if (ref.name.empty()) return std::nullopt; - const auto root = xpkgsBase / std::format("{}-x-{}", ref.ns, ref.name); - std::error_code ec; - if (!std::filesystem::is_directory(root, ec)) return std::nullopt; - std::vector installed; - for (auto const& e : std::filesystem::directory_iterator(root, ec)) - if (e.is_directory(ec)) installed.push_back(e.path().filename().string()); - auto pick = mcpp::xpkg_version::select_installed(installed, ref.version); - if (!pick) return std::nullopt; - return root / *pick; -} - - -// ─── Resolution records ──────────────────────────────────────────── - -namespace { - -// One file per (xpkgs base, address). The address is canonicalised through -// parse_xpkg_ref, so `libglvnd@1.7` and `xim:libglvnd@1.7` share a record, and -// the base is part of the key, so two homes never answer for each other. -std::filesystem::path record_path(const Env& env, const XpkgRef& ref) { - std::uint64_t h = 1469598103934665603ull; // FNV-1a - const auto key = std::format("{}\n{}:{}@{}", - xpkgs_base(env).generic_string(), ref.ns, ref.name, ref.version); - for (unsigned char ch : key) { h ^= ch; h *= 1099511628211ull; } - return mcpp::home::root() / "provisioned" / "resolved" - / std::format("{:016x}.json", h); -} - -} // namespace - -std::vector parse_install_targets(std::string_view payloadJson) { - std::vector out; - auto j = nlohmann::json::parse(payloadJson, nullptr, /*allow_exceptions=*/false); - if (!j.is_object() || !j.contains("targets") || !j["targets"].is_array()) return out; - auto str = [](const nlohmann::json& o, const char* k) { - return o.contains(k) && o[k].is_string() ? o[k].get() : std::string{}; - }; - for (auto const& t : j["targets"]) { - if (!t.is_object()) continue; - ResolvedTarget r; - r.request = str(t, "request"); - r.ns = str(t, "namespace"); - r.name = str(t, "name"); - r.version = str(t, "version"); - r.status = str(t, "status"); - r.payloadDir = str(t, "payload_dir"); - if (t.contains("revision") && t["revision"].is_number_integer()) - r.revision = t["revision"].get(); - if (!r.request.empty()) out.push_back(std::move(r)); - } - return out; -} - -void record_resolutions(const Env& env, std::span targets) { - for (auto const& t : targets) { - if (t.status == "failed" || t.payloadDir.empty()) continue; - std::error_code ec; - if (!std::filesystem::is_directory(t.payloadDir, ec)) continue; - const auto path = record_path(env, parse_xpkg_ref(t.request)); - std::filesystem::create_directories(path.parent_path(), ec); - nlohmann::json j; - j["request"] = t.request; - j["namespace"] = t.ns; - j["name"] = t.name; - j["version"] = t.version; - j["revision"] = t.revision; - j["payload_dir"] = t.payloadDir.generic_string(); - auto tmp = path; - tmp += std::format(".{}.tmp", - std::chrono::steady_clock::now().time_since_epoch().count()); - { - std::ofstream o{tmp, std::ios::binary | std::ios::trunc}; - if (!o) continue; - o << j.dump(); - } - std::filesystem::rename(tmp, path, ec); - if (ec) std::filesystem::remove(tmp, ec); - } -} - -std::optional installed_revision(const std::filesystem::path& payloadDir) { - std::ifstream in{payloadDir / ".xpkg-install.json", std::ios::binary}; - if (!in) return std::nullopt; - std::string text{std::istreambuf_iterator(in), {}}; - auto j = nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false); - if (!j.is_object()) return std::nullopt; - if (!j.contains("revision") || !j["revision"].is_number_integer()) return 0; - return j["revision"].get(); -} - -std::optional -recorded_payload(const Env& env, const XpkgRef& ref) { - if (ref.name.empty()) return std::nullopt; - std::ifstream in{record_path(env, ref), std::ios::binary}; - if (!in) return std::nullopt; - std::string text{std::istreambuf_iterator(in), {}}; - auto j = nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false); - if (!j.is_object() || !j.contains("payload_dir") || !j["payload_dir"].is_string()) - return std::nullopt; - const std::filesystem::path dir = j["payload_dir"].get(); - // A record answers only for a payload that is still there and still this - // package's, in this env's store. Anything else -- the payload was - // removed, the home moved -- falls through to the grammar, and the - // provisioning check treats "no answer" as not installed (#716). - std::error_code ec; - if (!std::filesystem::is_directory(dir, ec)) return std::nullopt; - for (const auto& store : stores(env)) { - const auto root = store / std::format("{}-x-{}", ref.ns, ref.name); - if (dir.parent_path().lexically_normal() == root.lexically_normal()) return dir; - } - return std::nullopt; -} - -std::optional -xpkgs_from_compiler(const std::filesystem::path& compilerBin) { - for (auto p = compilerBin.parent_path(); - p.has_parent_path() && p != p.root_path(); - p = p.parent_path()) { - if (p.filename() == "xpkgs") return p; - } - return std::nullopt; -} - -std::optional -subos_dir_of(const std::filesystem::path& compilerBin) { - auto xpkgs = xpkgs_from_compiler(compilerBin); - if (!xpkgs) return std::nullopt; - // /data/xpkgs → /subos/default. Spelled from the xpkgs dir - // rather than from Env so a toolchain inherited from ANOTHER home resolves - // to that home's subos, which is the one whose payloads the binary was - // actually linked against. - auto home = xpkgs->parent_path().parent_path(); - if (home.empty()) return std::nullopt; - return home / "subos" / "default"; -} - -std::optional -find_sibling_tool(const std::filesystem::path& compilerBin, - std::string_view tool) { - auto xpkgs = xpkgs_from_compiler(compilerBin); - if (!xpkgs) return std::nullopt; - - auto root = *xpkgs / std::format("xim-x-{}", tool); - std::error_code ec; - if (!std::filesystem::exists(root, ec)) return std::nullopt; - - // The first version dir readdir yields. ORDER IS UNDEFINED -- this does - // NOT return the highest, and the comment that said so was wrong for as - // long as it stood. With two versions installed the answer varies by - // filesystem, so a caller that needs a SPECIFIC version must name it; - // see probe.cppm, where the version comes from the resolved runtime - // binding rather than from this scan. - // - // A test asserting the old "highest" claim was attempted and removed: it - // passed or failed by directory order, which makes both outcomes - // uninformative. - for (auto& v : std::filesystem::directory_iterator(root, ec)) { - if (v.is_directory(ec)) return v.path(); - } - return std::nullopt; -} - -std::optional active_home_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; -} - -namespace { - -// A version dir qualifies as a payload only if it has real content — -// dot-prefixed entries (.xim-installed, .xpkg.lua) are install metadata, -// and a dir holding nothing else is the husk a delegating index package -// leaves behind (the payload lives under another prefix; issue #120). -// When requiredRelPath is given, the dir must also contain that path. -bool payload_dir_qualifies(const std::filesystem::path& versionDir, - std::string_view requiredRelPath) { - std::error_code ec; - bool hasContent = false; - for (auto& f : std::filesystem::directory_iterator(versionDir, ec)) { - if (!f.path().filename().string().starts_with(".")) { - hasContent = true; - break; - } - } - if (!hasContent) return false; - if (!requiredRelPath.empty() - && !std::filesystem::exists(versionDir / requiredRelPath, ec)) - return false; - return true; -} - -// Scan an xpkgs root across index prefixes (xim-x-, scode-x-, compat-x-, …) -// for the first qualifying version dir of `packageName`. -std::optional -find_package_in_xpkgs(const std::filesystem::path& xpkgs, - std::string_view packageName, - std::string_view requiredRelPath) { - std::error_code ec; - std::string suffix = std::format("-x-{}", packageName); - for (auto& entry : std::filesystem::directory_iterator(xpkgs, ec)) { - if (!entry.is_directory(ec)) continue; - auto name = entry.path().filename().string(); - if (!name.ends_with(suffix)) continue; - for (auto& v : std::filesystem::directory_iterator(entry.path(), ec)) { - if (!v.is_directory(ec)) continue; - if (payload_dir_qualifies(v.path(), requiredRelPath)) - return v.path(); - } - } - return std::nullopt; -} - -} // namespace - -std::optional -find_home_tool(std::string_view tool, std::string_view requiredRelPath) { - auto xpkgs = active_home_xpkgs(); - if (!xpkgs) return std::nullopt; - return find_package_in_xpkgs(*xpkgs, tool, requiredRelPath); -} - -std::optional -find_sibling_binary(const std::filesystem::path& compilerBin, - std::string_view tool, - std::string_view binaryRelPath) { - auto xpkgs = xpkgs_from_compiler(compilerBin); - if (!xpkgs) return std::nullopt; - - auto root = *xpkgs / std::format("xim-x-{}", tool); - std::error_code ec; - if (!std::filesystem::exists(root, ec)) return std::nullopt; - - for (auto& v : std::filesystem::directory_iterator(root, ec)) { - auto candidate = v.path() / std::string(binaryRelPath); - if (std::filesystem::exists(candidate, ec)) - return candidate; - } - return std::nullopt; -} - -std::optional -find_sibling_package(const std::filesystem::path& compilerBin, - std::string_view packageName, - std::string_view requiredRelPath) { - auto xpkgs = xpkgs_from_compiler(compilerBin); - if (!xpkgs) return std::nullopt; - - // Search across index prefixes: xim-x-, scode-x-, compat-x-, etc. - if (auto found = find_package_in_xpkgs(*xpkgs, packageName, requiredRelPath)) - return found; - - // 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; -} - -std::filesystem::path index_data(const Env& env) { - return env.home / "data"; -} - -namespace { -std::vector declared_in(const std::filesystem::path& file, const std::filesystem::path& base, - const std::filesystem::path& dataRoot) { - std::vector out; - std::ifstream in(file); - if (!in) return out; - auto j = nlohmann::json::parse(in, nullptr, false); - if (!j.is_object() || !j.contains("index_repos") || !j["index_repos"].is_array()) return out; - const auto absolute = [&](const std::string& p) { - const std::filesystem::path path = p; - return path.is_absolute() ? path.lexically_normal() : (base / path).lexically_normal(); - }; - for (const auto& e : j["index_repos"]) { - if (!e.is_object() || !e.contains("name") || !e["name"].is_string()) continue; - DeclaredIndex d; - d.name = e["name"].get(); - if (e.contains("dir") && e["dir"].is_string() && !e["dir"].get().empty()) { - d.dir = absolute(e["dir"].get()); - d.owned = true; - } else { - d.dir = dataRoot / d.name; - } - if (e.contains("store") && e["store"].is_string() && !e["store"].get().empty()) - d.store = absolute(e["store"].get()); - out.push_back(std::move(d)); - } - return out; -} -} // namespace - -std::vector declared_indices(const Env& env) { - std::vector out; - if (!env.projectDir.empty()) - out = declared_in(env.projectDir / ".xlings.json", env.projectDir, env.projectDir / "data"); - for (auto& d : declared_in(env.home / ".xlings.json", env.home, index_data(env))) - if (std::ranges::none_of(out, [&](const DeclaredIndex& o) { return o.name == d.name; })) - out.push_back(std::move(d)); - return out; -} - -std::filesystem::path index_dir_of(const Env& env, std::string_view name) { - for (auto& d : declared_indices(env)) - if (d.name == name) return d.dir; - return index_data(env) / std::string(name); -} - -std::vector indices_of(const std::filesystem::path& home) { - std::error_code ec; - if (!std::filesystem::is_regular_file(home / ".xlings.json", ec)) return {}; - std::vector out{"xim"}; - for (auto& d : declared_in(home / ".xlings.json", home, home / "data")) - if (std::ranges::find(out, d.name) == out.end()) out.push_back(d.name); - return out; -} - -std::vector index_roots_of(const std::filesystem::path& home) { - const auto data = home / "data"; - std::vector out; - std::error_code ec; - const auto declared = declared_in(home / ".xlings.json", home, data); - // An owned index is read where its owner keeps it; a copy left at the - // provider's own place (written by a provider that did not know the - // owner's place) is not a second index. - const auto owned_name = [&](const std::filesystem::path& p) { - return std::ranges::any_of(declared, [&](const DeclaredIndex& d) { return d.owned && d.name == p.filename(); }); - }; - for (auto& e : std::filesystem::directory_iterator(data, ec)) - if (e.is_directory(ec) && !owned_name(e.path())) out.push_back(e.path()); - for (auto& d : declared) - if (d.owned && std::filesystem::is_directory(d.dir, ec) && std::ranges::find(out, d.dir) == out.end()) - out.push_back(d.dir); - std::ranges::sort(out); - return out; -} - -std::vector stores_of(const std::filesystem::path& home) { - std::vector out; - for (auto& d : declared_in(home / ".xlings.json", home, home / "data")) - if (!d.store.empty() && std::ranges::find(out, d.store) == out.end()) out.push_back(d.store); - if (auto own = home / "data" / "xpkgs"; std::ranges::find(out, own) == out.end()) out.push_back(own); - return out; -} - -std::vector index_roots(const Env& env) { - return index_roots_of(env.projectDir.empty() ? env.home : env.projectDir); -} - -void own_indices(const Env& env, std::span owned) { - const bool project = !env.projectDir.empty(); - const auto base = project ? env.projectDir : env.home; - const auto data = project ? env.projectDir / "data" : index_data(env); - const auto file = base / ".xlings.json"; - std::ifstream in(file); - if (!in) return; - auto j = nlohmann::json::parse(in, nullptr, false); - in.close(); - if (!j.is_object() || !j.contains("index_repos") || !j["index_repos"].is_array()) return; - bool changed = false; - for (const auto& o : owned) { - if (o.dir.empty()) continue; - for (auto& e : j["index_repos"]) { - if (!e.is_object() || e.value("name", std::string{}) != o.name) continue; - // Its data, from where the provider kept it, once: a rename on one - // filesystem, a copy and a removal across two. - std::error_code ec; - const auto old = data / o.name; - if (std::filesystem::is_directory(old, ec) && !std::filesystem::exists(o.dir, ec)) { - std::filesystem::create_directories(o.dir.parent_path(), ec); - std::filesystem::rename(old, o.dir, ec); - if (ec) { - ec.clear(); - std::filesystem::copy(old, o.dir, std::filesystem::copy_options::recursive - | std::filesystem::copy_options::copy_symlinks, ec); - if (!ec) std::filesystem::remove_all(old, ec); - else std::filesystem::remove_all(o.dir, ec); - } - } - if (e.value("dir", std::string{}) != o.dir.generic_string()) { - e["dir"] = o.dir.generic_string(); - changed = true; - } - if (!o.store.empty() && e.value("store", std::string{}) != o.store.generic_string()) { - e["store"] = o.store.generic_string(); - changed = true; - } - } - } - if (!changed) return; - auto tmp = file; - tmp += std::format(".mcpp-{:x}", std::random_device{}()); - write_file(tmp, j.dump(2) + "\n"); - std::error_code ec; - std::filesystem::rename(tmp, file, ec); - if (ec) std::filesystem::remove(tmp, ec); -} - -std::vector stores(const Env& env) { - std::vector out; - for (auto& d : declared_indices(env)) - if (!d.store.empty() && std::ranges::find(out, d.store) == out.end()) out.push_back(d.store); - if (auto home = xpkgs_base(env); std::ranges::find(out, home) == out.end()) out.push_back(home); - return out; -} - -std::filesystem::path sandbox_init_marker(const Env& env) { - return env.home / "subos" / "default" / ".xlings.json"; -} - -} // namespace paths - -// ─── Shell command builders ───────────────────────────────────────── - -std::vector invocation_env(const Env& env) { - auto cap = [](const char* key, int limit) { - auto value = mcpp::platform::env::get(key); - int existing = 0; - if (value) { - const auto [end, ec] = std::from_chars(value->data(), value->data() + value->size(), existing); - if (ec == std::errc{} && end == value->data() + value->size() && existing > 0) - limit = std::min(limit, existing); - } - return std::to_string(limit); - }; - return { - {"XLINGS_HOME", env.home.string(), true}, - {"XLINGS_PROJECT_DIR", env.projectDir.string(), !env.projectDir.empty()}, - {"XLINGS_ACTIVE_SUBOS", "", false}, - {"XLINGS_DOWNLOAD_CONNECT_TIMEOUT", cap("XLINGS_DOWNLOAD_CONNECT_TIMEOUT", 8), true}, - {"XLINGS_DOWNLOAD_IDLE_TIMEOUT", cap("XLINGS_DOWNLOAD_IDLE_TIMEOUT", 30), true}, - {"XLINGS_DOWNLOAD_LOW_SPEED", mcpp::platform::env::get("XLINGS_DOWNLOAD_LOW_SPEED").value_or("off"), true}, - }; -} - -ScopedInvocationEnv::ScopedInvocationEnv(const Env& env) { - if constexpr (mcpp::platform::is_windows) { - auto save = [this](const std::string& name) { - Saved s; - s.name = name; - if (auto prior = mcpp::platform::env::get(name)) { - s.hadPrevious = true; - s.previous = *prior; - } - saved_.push_back(s); - }; - for (auto const& var : invocation_env(env)) { - save(var.name); - if (var.present) mcpp::platform::env::set(var.name, var.value); - else mcpp::platform::env::unset(var.name); - } - save("PATH"); - mcpp::platform::windows::prepend_path(paths::sandbox_bin(env).string()); - } -} - -ScopedInvocationEnv::~ScopedInvocationEnv() { - // Newest first, so a variable saved twice ends at its oldest value. - for (auto it = saved_.rbegin(); it != saved_.rend(); ++it) { - if (it->hadPrevious) mcpp::platform::env::set(it->name, it->previous); - else mcpp::platform::env::unset(it->name); - } -} - -std::string windows_command_prefix(const Env& env) { - return std::format("cd /d {} && {}", - mcpp::platform::shell::quote_windows(env.home.string()), - mcpp::platform::shell::quote_windows(env.binary.string())); -} - -std::string build_command_prefix(const Env& env) { - auto xvmBin = paths::sandbox_bin(env).string(); - if constexpr (mcpp::platform::is_windows) { - // The environment is applied by the caller's ScopedInvocationEnv and - // restored after the command; building a command changes nothing. - return windows_command_prefix(env); - } else { - // `env` takes its `-u` operands before its assignments. - std::string unset, assign; - for (auto const& var : invocation_env(env)) { - if (var.present) assign += std::format(" {}={}", var.name, shq(var.value)); - else unset += std::format(" -u {}", var.name); - } - return std::format("cd {} && env{} PATH={}:\"$PATH\"{} {}", - shq(env.home.string()), unset, shq(xvmBin), assign, - shq(env.binary.string())); - } -} - -std::string build_interface_command(const Env& env, - std::string_view capability, - std::string_view argsJson) { - return std::format("{} interface {} --args {} {}", - build_command_prefix(env), capability, shq_meta(argsJson), - mcpp::platform::null_redirect); -} - -// ─── JSON extraction helpers ──────────────────────────────────────── - -std::string extract_string(std::string_view text, std::string_view key) { - auto needle = std::string{"\""} + std::string(key) + "\":\""; - auto p = text.find(needle); - if (p == std::string_view::npos) return ""; - p += needle.size(); - std::string out; - while (p < text.size()) { - char c = text[p++]; - if (c == '\\' && p < text.size()) { - char nc = text[p++]; - switch (nc) { - case 'n': out.push_back('\n'); break; - case 't': out.push_back('\t'); break; - case 'r': out.push_back('\r'); break; - case '"': out.push_back('"'); break; - case '\\': out.push_back('\\'); break; - default: out.push_back(nc); - } - } else if (c == '"') { - return out; - } else { - out.push_back(c); - } - } - return out; -} - -std::optional extract_int(std::string_view text, std::string_view key) { - auto needle = std::string{"\""} + std::string(key) + "\":"; - auto p = text.find(needle); - if (p == std::string_view::npos) return std::nullopt; - p += needle.size(); - while (p < text.size() && text[p] == ' ') ++p; - bool neg = false; - if (p < text.size() && text[p] == '-') { neg = true; ++p; } - long long n = 0; - bool any = false; - while (p < text.size() && std::isdigit(static_cast(text[p]))) { - n = n * 10 + (text[p++] - '0'); - any = true; - } - if (!any) return std::nullopt; - return neg ? -n : n; -} - -std::optional extract_bool(std::string_view text, std::string_view key) { - auto needle = std::string{"\""} + std::string(key) + "\":"; - auto p = text.find(needle); - if (p == std::string_view::npos) return std::nullopt; - p += needle.size(); - while (p < text.size() && text[p] == ' ') ++p; - if (text.substr(p, 4) == "true") return true; - if (text.substr(p, 5) == "false") return false; - return std::nullopt; -} - -std::string extract_object(std::string_view text, std::string_view key) { - auto needle = std::string{"\""} + std::string(key) + "\":"; - auto p = text.find(needle); - if (p == std::string_view::npos) return ""; - p += needle.size(); - while (p < text.size() && text[p] == ' ') ++p; - if (p >= text.size() || (text[p] != '{' && text[p] != '[')) return ""; - char open = text[p]; - char close = (open == '{') ? '}' : ']'; - int depth = 0; - std::size_t start = p; - bool in_string = false; - while (p < text.size()) { - char c = text[p]; - if (in_string) { - if (c == '\\' && p + 1 < text.size()) { p += 2; continue; } - if (c == '"') in_string = false; - ++p; continue; - } - if (c == '"') { in_string = true; ++p; continue; } - if (c == open) { ++depth; } - else if (c == close) { - --depth; - if (depth == 0) return std::string(text.substr(start, p - start + 1)); - } - ++p; - } - return ""; -} - -// ─── NDJSON event parser ──────────────────────────────────────────── - -std::optional parse_event_line(std::string_view line) { - auto kind = extract_string(line, "kind"); - if (kind == "progress") { - ProgressEvent e; - e.phase = extract_string(line, "phase"); - e.percent = static_cast(extract_int(line, "percent").value_or(0)); - e.message = extract_string(line, "message"); - return e; - } - if (kind == "log") { - LogEvent e; - e.level = extract_string(line, "level"); - e.message = extract_string(line, "message"); - return e; - } - if (kind == "data") { - DataEvent e; - e.dataKind = extract_string(line, "dataKind"); - e.payloadJson= extract_object(line, "payload"); - return e; - } - if (kind == "error") { - ErrorEvent e; - e.code = extract_string(line, "code"); - e.message = extract_string(line, "message"); - e.hint = extract_string(line, "hint"); - e.recoverable = extract_bool(line, "recoverable").value_or(false); - return e; - } - if (kind == "result") { - ResultEvent e; - e.exitCode = static_cast(extract_int(line, "exitCode").value_or(0)); - return e; - } - // heartbeat and unknown kinds - return std::nullopt; -} - -// ─── Subprocess call ──────────────────────────────────────────────── - -namespace { -std::optional download_progress_of(std::string_view line); -} - -std::expected -call(const Env& env, std::string_view capability, - std::string_view argsJson, EventHandler* handler) -{ - if (capability == "system_status" || capability == "payload_status") { - const auto info = describe(env.binary); - if (std::ranges::find(info.features, "read_only_queries") == info.features.end()) - return std::unexpected("the provider does not offer read_only_queries; status was not queried"); - } - ScopedInvocationEnv scope(env); // #614 - // STDERR GOES TO A FILE, NOT TO THE NULL DEVICE AND NOT INTO STDOUT (#614). - // Stdout is parsed line by line as NDJSON, so merging stderr into it could - // split an event; discarding it lost the one line that names a rejection. - // The file is read only when the call fails, and removed either way. - const auto stderrFile = std::filesystem::temp_directory_path() - / std::format("mcpp-xlings-{}.stderr", - std::chrono::steady_clock::now().time_since_epoch().count()); - auto cmd = std::format("{} interface {} --args {} 2>{}", - build_command_prefix(env), capability, shq_meta(argsJson), - mcpp::platform::is_windows - ? std::format("\"{}\"", stderrFile.string()) - : shq(stderrFile.string())); - - // #238: under MCPP_VERBOSE=1 surface the exact xlings invocation so a - // failing install_packages can be reproduced/inspected by hand. The - // NDJSON child's own error/warn lines are surfaced by the caller's - // EventHandler (e.g. InstallProgressHandler). - mcpp::log::verbose("xlings", - std::format("interface {} exec: {}", capability, cmd)); - - // An install reaches the network; the query capabilities read local state. - if (capability == "install_packages" || capability == "update_packages") - mcpp::platform::env::note_network_access(); - - CallResult result; - const bool installing = capability == "install_packages"; - const bool syncing = capability == "sync_index" || capability == "update_packages"; - InstallDeadline deadline; - std::map sourceAttempts; - bool timedOut = false; - int rc = mcpp::platform::process::run_streaming_bounded(cmd, - [&](std::string_view line) { - if (line.empty()) return; - if (installing) { - deadline.observe(line); - if (!mcpp::ui::is_quiet()) - if (const auto progress = download_progress_of(line)) - for (const auto& file : progress->files) - if (!file.sourceHost.empty() && sourceAttempts[file.name] != file.sourceAttempt) { - sourceAttempts[file.name] = file.sourceAttempt; - print_status("Source", std::format("{} {} (attempt {})", - file.name, file.sourceHost, file.sourceAttempt)); - } - } - - auto ev = parse_event_line(line); - if (!ev) return; - - std::visit([&](auto&& e) { - using T = std::decay_t; - if constexpr (std::is_same_v) { - if (handler) handler->on_progress(e); - } else if constexpr (std::is_same_v) { - if (handler) handler->on_log(e); - } else if constexpr (std::is_same_v) { - result.dataEvents.push_back(e); - if (handler) handler->on_data(e); - } else if constexpr (std::is_same_v) { - // The FIRST error is the cause. A package whose - // dependency failed reports that after it (xlings - // 2026.10.10.2), and older clients followed a failure with - // its consequences ("registered none of its programs"). - if (!result.error) result.error = e; - if (handler) handler->on_error(e); - } else if constexpr (std::is_same_v) { - result.exitCode = e.exitCode; - result.resultJson = e.dataJson; - if (handler) handler->on_result(e); - } - }, *ev); - }, - installing ? std::chrono::duration_cast(kDirectInstallTimeout) - : syncing ? std::chrono::duration_cast(index_refresh_timeout()) - : std::chrono::milliseconds{0}, - std::chrono::duration_cast(kInterfaceIdleTimeout), - &timedOut, false, [&] { return installing && deadline.expired(); }); - if (timedOut) { - result.exitCode = result.exitCode != 0 ? result.exitCode : 124; - const auto message = std::format( - "provider {} exceeded its progress/process deadline and was stopped", capability); - if (!result.error) result.error = ErrorEvent{ - installing || syncing ? "E_NETWORK" : "E_PROVIDER_TIMEOUT", message, - installing || syncing ? "check proxy settings or use mcpp self config --mirror auto|CN|GLOBAL" - : "retry the local provider operation", true}; - result.stderrTail.push_back(message); - } - if (rc != 0 && result.exitCode == 0) result.exitCode = rc; - if (result.exitCode != 0) { - // Error-level lines only, the last 20: enough to name a rejection, and - // bounded so a verbose child cannot bury the diagnostic it is attached - // to. - std::ifstream in(stderrFile); - std::stringstream text; - text << in.rdbuf(); - auto tail = stderr_error_tail(text.str()); - result.stderrTail.insert(result.stderrTail.end(), tail.begin(), tail.end()); - } - std::error_code rmEc; - std::filesystem::remove(stderrFile, rmEc); - return result; -} - -std::vector stderr_error_tail(std::string_view text, std::size_t limit) { - std::deque tail; - std::size_t pos = 0; - while (pos < text.size()) { - const auto nl = text.find('\n', pos); - std::string line(text.substr(pos, nl == std::string_view::npos - ? std::string_view::npos : nl - pos)); - pos = nl == std::string_view::npos ? text.size() : nl + 1; - if (!line.empty() && line.back() == '\r') line.pop_back(); - std::string lower = line; - std::ranges::transform(lower, lower.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (lower.find("error") == std::string::npos - && line.find("E_") == std::string::npos - && !line.starts_with("[xim]")) - continue; - tail.push_back(std::move(line)); - if (tail.size() > limit) tail.pop_front(); - } - return {tail.begin(), tail.end()}; -} - -namespace { -// The `files[]` of one NDJSON `download_progress` data event, in the order -// xlings reports them; nullopt when the line carries none. Shared by the -// sandbox bootstrap and the index refresh, which render the same event. -std::optional download_progress_of(std::string_view line) { - LineScan ls{line}; - auto p = line.find("\"files\":["); - if (p == std::string_view::npos) return std::nullopt; - p += 9; - - BootstrapProgress prog; - prog.elapsedSec = ls.find_num("elapsedSec"); - - while (p < line.size()) { - while (p < line.size() && (line[p] == ' ' || line[p] == '\n' - || line[p] == ',')) ++p; - if (p >= line.size() || line[p] == ']') break; - if (line[p] != '{') break; - int depth = 0; - auto start = p; - bool in_string = false; - for (; p < line.size(); ++p) { - char c = line[p]; - if (in_string) { - if (c == '\\' && p + 1 < line.size()) { ++p; continue; } - if (c == '"') in_string = false; - continue; - } - if (c == '"') in_string = true; - else if (c == '{') ++depth; - else if (c == '}') { if (--depth == 0) { ++p; break; } } - } - LineScan fl{line.substr(start, p - start)}; - BootstrapFile f; - f.name = fl.find_str("name"); - f.downloadedBytes = fl.find_num("downloadedBytes"); - f.totalBytes = fl.find_num("totalBytes"); - f.started = fl.find_bool("started"); - f.finished = fl.find_bool("finished"); - f.sourceHost = fl.find_str("sourceHost"); - f.sourceAttempt = static_cast(fl.find_num("sourceAttempt")); - if (!f.name.empty()) prog.files.push_back(std::move(f)); - } - if (prog.files.empty()) return std::nullopt; - return prog; -} - -// The events of one `xlings interface update_packages` run, rendered with the -// renderer every other acquisition uses (mcpp::ui::ProgressBar and -// DownloadProgress). A `progress` event carries a phase and a percentage; -// one bar is drawn per phase (xlings reports `index_sync`, one event per -// repository, and `index_rebuild`, one event per descriptor file), so a -// refresh prints a few lines off a terminal rather than one per file. A -// `download_progress` data event is an index artifact being fetched. A line -// that is not an event is not rendered: an xlings that predates structured -// index progress printed its own terminal text on this stream, and the bar -// is what replaces it. -class IndexRefreshRenderer { -public: - void line(std::string_view text) { - LineScan ls{text}; - const auto kind = ls.find_str("kind"); - if (kind == "result") { - resultExit_ = static_cast(ls.find_num("exitCode")); - } else if (kind == "progress") { - const auto phase = ls.find_str("phase"); - if (phase.empty()) return; - // A sync step names its repository at the end of its message - // (`syncing index repo 2/5: mcpplibs`); each repository is its - // own bar, labelled with that name. - std::string subject; - if (phase == "index_sync") { - const auto message = ls.find_str("message"); - if (auto colon = message.rfind(": "); colon != std::string::npos) - subject = message.substr(colon + 2); - } - const auto key = subject.empty() ? phase : phase + "/" + subject; - if (!bar_ || key != label_) { - if (bar_) bar_->finish(); - shown_ = subject.empty() ? phase_label(phase) - : std::format("package index {}", subject); - bar_.emplace("Updating", shown_); - label_ = key; - } - const auto pct = std::clamp(ls.find_num("percent"), 0.0, 100.0); - bar_->update(static_cast(pct)); - } else if (kind == "data" && ls.find_str("dataKind") == "download_progress") { - auto prog = download_progress_of(text); - if (!prog) return; - if (bar_) { bar_->finish(); bar_.reset(); label_.clear(); } - std::vector files; - for (auto const& f : prog->files) - files.push_back({f.name, - static_cast(f.downloadedBytes), - static_cast(f.totalBytes), - f.started, f.finished}); - download_.update(files, prog->elapsedSec); - } - } - // The exit code the result event carried, or -1 when none arrived. - int result_exit() const { return resultExit_; } - // The run failed or was stopped: the open bar says the step did not - // complete rather than reporting it done. - void fail() { failed_ = true; } - ~IndexRefreshRenderer() { - if (bar_) failed_ ? bar_->finish_failed(shown_) : bar_->finish(); - failed_ ? download_.finish_failed() : download_.finish(); - } - -private: - static std::string phase_label(std::string_view phase) { - if (phase == "index_sync") return "package index (sync)"; - if (phase == "index_rebuild") return "package index (rebuild)"; - return std::format("package index ({})", phase); - } - - std::optional bar_; - std::string label_; // the phase (and repository) drawn - std::string shown_; // the bar's label - bool failed_ = false; - mcpp::ui::DownloadProgress download_; - int resultExit_ = -1; -}; -} // namespace - -void InstallDeadline::observe(std::string_view line, std::chrono::steady_clock::time_point now) { - const auto event = parse_event_line(line); - if (!event) return; // heartbeats and unknown events never renew a budget - if (const auto* progress = std::get_if(&*event)) { - if (progress->phase == "extract" || progress->phase == "hook" || progress->phase == "configure") - localWork_ = true; - } else if (const auto* data = std::get_if(&*event); - data && data->dataKind == "download_progress") { - if (const auto p = download_progress_of(line)) { - downloads_.observe(*p, now); - downloadSeen_ |= std::ranges::any_of(p->files, [](const auto& f) { return f.started; }); - } - } -} - -mcpp::provider::NetworkReport network_check(const Env& env, std::chrono::milliseconds budget) { - using namespace std::chrono; - using json = nlohmann::json; - if (mcpp::platform::env::offline_mode()) return {"offline", {}}; - std::error_code fileError; - if (!std::filesystem::is_regular_file(env.binary, fileError)) return {"unavailable", {}}; - const auto deadline = steady_clock::now() + budget; - std::vector> vars; - for (const auto& v : invocation_env(env)) vars.emplace_back(v.name, v.present ? v.value : ""); - auto run = [&](std::vector args) -> std::optional { - const auto remaining = duration_cast(deadline - steady_clock::now()); - if (remaining <= milliseconds::zero()) return std::nullopt; - args.insert(args.begin(), mcpp::home::spelling(env.binary)); - bool timeout = false; - const auto r = mcpp::platform::process::capture_exec_deadline( - args, vars, remaining, &timeout, {}, nullptr, /*ownGroup=*/true); - if (timeout || r.exit_code != 0) return std::nullopt; - return r.output; - }; - // Do not run an unknown capability on an older provider: its generic CLI - // may journal or initialize an operation before returning "unsupported". - const auto info = describe(env.binary, duration_cast(deadline - steady_clock::now())); - const auto has = [&](std::string_view feature) { - return std::ranges::find(info.features, feature) != info.features.end(); - }; - const bool supported = has("network_diagnostics") && has("read_only_queries"); - if (!supported) return {steady_clock::now() >= deadline ? "timeout" : "unsupported", {}}; - auto call = [&](std::string_view operation, const json& args, std::string_view kind) -> json { - try { - if (operation == "probe_url") mcpp::platform::env::note_network_access(); - const auto output = run({"interface", std::string(operation), "--args", args.dump()}); - if (!output) return nullptr; - std::istringstream lines(*output); - std::string line; - while (std::getline(lines, line)) { - auto event = json::parse(line, nullptr, false); - if (event.is_object() && event.value("kind", "") == "data" - && event.value("dataKind", "") == kind && event.contains("payload")) - return event["payload"]; - } - return nullptr; - } catch (const std::exception&) { return nullptr; } - }; - const auto discovered = call("network_endpoints", json::object(), "network_endpoints"); - if (!discovered.is_object() || !discovered.contains("endpoints") - || !discovered["endpoints"].is_array()) - return {steady_clock::now() >= deadline ? "timeout" : "unsupported", {}}; - const auto& endpoints = discovered["endpoints"]; - mcpp::provider::NetworkReport report{"checked", {}}; - report.endpoints.resize(endpoints.size()); - std::atomic next{0}; - auto worker = [&] { - for (;;) { - const auto i = next.fetch_add(1); - if (i >= endpoints.size()) return; - const auto& e = endpoints[i]; - auto& out = report.endpoints[i]; - try { - out.resource = e.value("resource", ""); - out.region = e.value("region", ""); - out.status = "timeout"; - if (!e.contains("url") || !e["url"].is_string()) { out.status = "invalid_endpoint"; continue; } - const auto data = call("probe_url", json{{"url", e["url"]}, {"socket_timeout_ms", 2000}}, "network_probe"); - if (!data.is_object()) continue; - out.host = data.value("host", ""); - out.status = data.value("status", "unknown"); - out.httpStatus = data.value("http_status", 0); - out.elapsedSeconds = data.value("elapsed_seconds", 0.0); - out.reachable = data.value("reachable", false); - out.proxy = data.value("proxy", false); - } catch (const std::exception&) { out.status = "invalid_response"; } - } - }; - std::vector workers; - for (std::size_t i = 0; i < std::min(4, endpoints.size()); ++i) - workers.emplace_back(worker); - for (auto& w : workers) w.join(); - return report; -} - -// ─── install_with_progress ────────────────────────────────────────── - -int install_with_progress(const Env& env, std::string_view target, - const BootstrapProgressCallback& cb, - bool quiet) -{ - ScopedInvocationEnv scope(env); // #614 - const auto argsJson = nlohmann::json{{"targets", {std::string(target)}}, {"yes", true}}.dump(); - - // One structured install attempt. Repeating a failed direct install through - // the interface only retries the same operation and hides its first error. - if (!quiet) print_status("Installing", target); - mcpp::platform::env::note_network_access(); - auto cmd = std::format("{} interface install_packages --args {} {} {}", - build_command_prefix(env), shq_meta(argsJson), mcpp::platform::null_redirect, - mcpp::platform::is_windows ? " sourceAttempts; - mcpp::ui::DownloadProgress download; - InstallDeadline deadline; - - auto handle_line = [&](std::string_view line) { - deadline.observe(line); - LineScan ls{line}; - auto kind = ls.find_str("kind"); - if (kind == "result") { - resultExitCode = static_cast(ls.find_num("exitCode")); - return; - } - if (kind == "error") { - const auto message = ls.find_str("message"); - mcpp::log::warn("provider", message); - if (!quiet) print_status("Error", message); - return; - } - if (kind != "data" || ls.find_str("dataKind") != "download_progress") return; - const auto prog = download_progress_of(line); - if (!prog) return; - for (const auto& file : prog->files) { - if (!quiet && !file.sourceHost.empty() && sourceAttempts[file.name] != file.sourceAttempt) { - sourceAttempts[file.name] = file.sourceAttempt; - print_status("Source", std::format("{} {} (attempt {})", file.name, file.sourceHost, file.sourceAttempt)); - } - } - if (cb) cb(*prog); - else if (!quiet) { - std::vector files; - for (const auto& f : prog->files) - files.push_back({f.name, static_cast(std::max(0.0, f.downloadedBytes)), - static_cast(std::max(0.0, f.totalBytes)), f.started, f.finished}); - download.update(files, prog->elapsedSec); - } - }; - - bool idleTimedOut = false; - int closeRc = mcpp::platform::process::run_streaming_bounded( - cmd, handle_line, std::chrono::duration_cast(kDirectInstallTimeout), - std::chrono::duration_cast(kInterfaceIdleTimeout), - &idleTimedOut, false, [&] { return deadline.expired(); }); - if (!cb && !quiet) { - if (!idleTimedOut && resultExitCode == 0 && closeRc == 0) download.finish(); - else download.finish_failed(); - } - if (idleTimedOut) { - mcpp::log::warn("xlings", "package install exceeded its progress/process deadline and was stopped"); - if (!quiet) print_status("Network", "Download stopped: no byte progress or process deadline exceeded; check proxy/mirror settings"); - return resultExitCode > 0 ? resultExitCode : 124; - } - return (resultExitCode != -1) ? resultExitCode : closeRc; -} - -int install_direct(const Env& env, std::string_view target, bool quiet) { - ScopedInvocationEnv scope(env); // #614 - auto cmd = build_command_prefix(env) - + std::format(" install {} -y", shq(target)); - if (quiet) { - cmd += " "; - cmd += std::string(mcpp::platform::shell::silent_redirect); - } - mcpp::platform::env::note_network_access(); - bool timedOut = false; - // The streaming runner seals stdin itself. Lines are passed through as the - // inherited terminal showed them before, unless the caller asked for quiet. - // They are xlings' own progress text, narration: standard error. - int rc = mcpp::platform::process::run_streaming_bounded(cmd, - [quiet](std::string_view line) { if (!quiet) std::println(stderr, "{}", line); }, - std::chrono::duration_cast(kDirectInstallTimeout), - std::chrono::milliseconds{0}, &timedOut); - if (timedOut) - mcpp::log::warn("xlings", std::format( - "`xlings install {}` did not finish within {} hours and was stopped", - target, kDirectInstallTimeout.count() / 3600)); - return rc; -} - -// ─── Sandbox lifecycle ────────────────────────────────────────────── - -void seed_xlings_json(const Env& env, - std::span repos, - std::string_view mirror, - const ProjectEnv& penv) -{ - auto path = env.home / ".xlings.json"; - std::string json = "{\n"; - json += " \"index_repos\": [\n"; - for (std::size_t i = 0; i < repos.size(); ++i) { - json += std::format(" {{ \"name\": \"{}\", \"url\": \"{}\"", - json_escape(repos[i].name), - json_escape(repos[i].url)); - if (!repos[i].artifact.empty() && !repos[i].artifactCn.empty()) - json += std::format(", \"artifact\": {{ \"GLOBAL\": \"{}\", \"CN\": \"{}\" }}", - json_escape(repos[i].artifact), - json_escape(repos[i].artifactCn)); - else if (!repos[i].artifact.empty()) - json += std::format(", \"artifact\": \"{}\"", - json_escape(repos[i].artifact)); - if (!repos[i].source.empty()) - json += std::format(", \"source\": \"{}\"", - json_escape(repos[i].source)); - if (!repos[i].dir.empty()) - json += std::format(", \"dir\": \"{}\"", json_escape(repos[i].dir.generic_string())); - if (!repos[i].store.empty()) - json += std::format(", \"store\": \"{}\"", json_escape(repos[i].store.generic_string())); - json += std::format(" }}{}\n", i + 1 == repos.size() ? "" : ","); - } - json += " ],\n"; - // [xlings] build environment (L-1): materialize deps/workspace/subos/envs - // verbatim into the keys xlings reads. Each is emitted only when non-empty. - auto emit_obj = [&](std::string_view key, - std::span> kv) { - json += std::format(" \"{}\": {{\n", key); - for (std::size_t i = 0; i < kv.size(); ++i) - json += std::format(" \"{}\": \"{}\"{}\n", - json_escape(kv[i].first), json_escape(kv[i].second), - i + 1 == kv.size() ? "" : ","); - json += " },\n"; - }; - if (!penv.deps.empty()) { - json += " \"deps\": ["; - for (std::size_t i = 0; i < penv.deps.size(); ++i) - json += std::format("{}\"{}\"", i ? ", " : "", json_escape(penv.deps[i])); - json += "],\n"; - } - if (!penv.workspace.empty()) emit_obj("workspace", penv.workspace); - if (!penv.subos.empty()) - json += std::format(" \"subos\": \"{}\",\n", json_escape(penv.subos)); - json += " \"lang\": \"en\",\n"; - json += std::format(" \"mirror\": \"{}\"\n", json_escape(mirror)); - json += "}\n"; - write_file(path, json); -} - -int config_show(const Env& env) { - std::ifstream file(env.home / ".xlings.json", std::ios::binary); - if (!file) { - std::println("mirror = auto (default; registry configuration is not initialized)"); - return 0; - } - std::cout << file.rdbuf(); - return 0; -} - -int config_set_mirror(const Env& env, std::string_view mirror, bool quiet) { - if (mirror.empty()) return 0; - // Configuration recovery must work before the first provider invocation. - // The adapter owns its format; asking the CLI first would bootstrap through - // precisely the failed mirror the user is trying to change. - const auto path = env.home / ".xlings.json"; - auto lock = mcpp::platform::fs::FileLock::try_acquire(env.home); - if (!lock) return 1; - std::ifstream input(path, std::ios::binary); - auto doc = nlohmann::json::parse(input, nullptr, false); - input.close(); // Windows cannot atomically replace a file still held open here. - if (doc.is_discarded() || !doc.is_object()) return 1; - if (doc.value("mirror", "") == mirror) return 0; - doc["mirror"] = mirror; - auto staged = path; - staged += ".tmp"; - { - std::ofstream output(staged, std::ios::binary); - output << doc.dump(2) << '\n'; - output.close(); - if (!output) return 1; - } - std::error_code ec; - mcpp::platform::fs::replace_file(staged, path, ec); - if (ec) { std::error_code ignored; std::filesystem::remove(staged, ignored); } - (void)quiet; - return ec ? 1 : 0; -} - -void ensure_init(const Env& env, bool quiet) { - auto marker = paths::sandbox_init_marker(env); - if (std::filesystem::exists(marker)) return; - - // Ensure the home directory exists before cd'ing into it. - std::error_code ec; - std::filesystem::create_directories(env.home, ec); - - if (!quiet) - print_status("Initialize", "mcpp sandbox layout (one-time)"); - mcpp::log::ScopedTimer _t_init("init", "sandbox layout (xlings self init)"); - // `self init` initialises the global sandbox, so it runs in global mode - // whatever the caller's project directory is. - Env globalEnv = env; - globalEnv.projectDir.clear(); - ScopedInvocationEnv scope(globalEnv); // #614 - std::string cmd = build_command_prefix(globalEnv) + " self init " - + std::string(mcpp::platform::shell::silent_redirect); - bool initTimedOut = false; - int rc = mcpp::platform::process::run_streaming_bounded( - cmd, [](std::string_view) {}, - std::chrono::seconds{120}, - std::chrono::milliseconds{0}, &initTimedOut); - if (rc != 0 && !quiet) { - std::println(stderr, - "warning: `xlings self init` failed for sandbox at '{}'", - env.home.string()); - } - - // The first real `xlings install` (the next bootstrap step) triggers - // xlings to fetch its package index — a one-time, network-bound step - // with no output of its own. Announce it so the user knows the silent - // wait that follows is expected. This runs once: ensure_init is gated - // by the sandbox-init marker above. - if (!quiet) - print_status("Fetching", "package index (one-time)"); -} - -void ensure_patchelf(const Env& env, bool quiet, - const BootstrapProgressCallback& cb) -{ - auto toolDir = paths::xim_tool(env, "patchelf", pinned::kPatchelfVersion); - auto binary = toolDir / "bin" / "patchelf"; - if (std::filesystem::exists(binary)) return; - - // Clean up incomplete installation residue (e.g. from Ctrl+C interrupt). - if (std::filesystem::exists(toolDir)) { - if (!quiet) - print_status("Repairing", "patchelf (incomplete installation, cleaning up)"); - std::error_code ec; - std::filesystem::remove_all(toolDir, ec); - } - - if (!quiet) - print_status("Bootstrap", "patchelf into mcpp sandbox (one-time)"); - mcpp::log::ScopedTimer _t_pe("init", std::format("bootstrap patchelf@{}", pinned::kPatchelfVersion)); - int rc = install_with_progress(env, - std::format("xim:patchelf@{}", pinned::kPatchelfVersion), cb, quiet); - if (rc != 0 && !quiet) { - std::println(stderr, - "warning: failed to bootstrap patchelf into mcpp sandbox; " - "subsequent xim installs may skip ELF rewriting"); - } -} - -void ensure_ninja(const Env& env, bool quiet, - const BootstrapProgressCallback& cb) -{ - auto root = paths::xim_tool_root(env, "ninja"); - auto ninja_name = std::string("ninja") + std::string(mcpp::platform::exe_suffix); - if (std::filesystem::exists(root)) { - std::error_code ec; - for (auto& v : std::filesystem::directory_iterator(root, ec)) { - if (std::filesystem::exists(v.path() / ninja_name)) return; - } - // Directory exists but no version has a working binary — residue. - if (!quiet) - print_status("Repairing", "ninja (incomplete installation, cleaning up)"); - std::filesystem::remove_all(root, ec); - } - if (!quiet) - print_status("Bootstrap", "ninja into mcpp sandbox (one-time)"); - mcpp::log::ScopedTimer _t_ninja("init", std::format("bootstrap ninja@{}", pinned::kNinjaVersion)); - int rc = install_with_progress(env, - std::format("xim:ninja@{}", pinned::kNinjaVersion), cb, quiet); - if (rc != 0 && !quiet) { - std::println(stderr, - "warning: failed to bootstrap ninja into mcpp sandbox (exit {})", - rc); - } -} - -namespace { - -// "NASM version 2.16.03 compiled on ..." → true iff ≥ 2.16. A binary that -// fails to run or reports something unparseable is unusable — false. -bool nasm_version_ok(const std::filesystem::path& bin) { - auto r = mcpp::platform::process::capture_exec({bin.string(), "-v"}); - if (r.exit_code != 0) return false; - auto pos = r.output.find("version "); - if (pos == std::string::npos) return false; - int major = 0, minor = 0; - const char* p = r.output.c_str() + pos + 8; - while (*p >= '0' && *p <= '9') major = major * 10 + (*p++ - '0'); - if (*p++ != '.') return false; - while (*p >= '0' && *p <= '9') minor = minor * 10 + (*p++ - '0'); - return major > 2 || (major == 2 && minor >= 16); -} - -} // namespace - -std::optional find_sandbox_nasm(const Env& env) { - auto root = paths::xim_tool_root(env, "nasm"); - auto nasm_name = std::string("nasm") + std::string(mcpp::platform::exe_suffix); - std::error_code ec; - if (!std::filesystem::exists(root, ec)) return std::nullopt; - for (auto& v : std::filesystem::directory_iterator(root, ec)) { - for (auto cand : { v.path() / nasm_name, v.path() / "bin" / nasm_name }) { - if (std::filesystem::exists(cand, ec) && nasm_version_ok(cand)) - return cand; - } - } - return std::nullopt; -} - -// THE ECOSYSTEM COPY IS TRIED FIRST, AND IT USED TO BE TRIED SECOND. -// -// This function answered `which("nasm")` before looking in the sandbox, so a -// machine with an assembler on PATH assembled with that one and a machine -// without downloaded the pinned `xim:nasm`. Three machines could produce three -// different objects from one source tree with nothing in the build saying so, -// which is the property every other tool in this engine is arranged to avoid: -// the compiler, the linker, ninja and patchelf all come from the graph or the -// sandbox, and none of them asks PATH first. -// -// The host copy is kept, and only as a last resort: a machine that is offline -// and already has a usable assembler can still build, which is the one case -// the sandbox route cannot serve. When it is the one used, the caller says so -// in the build report rather than leaving it silent --- `nasm_is_from_host` -// below is how the caller tells the two apart. -std::optional find_usable_nasm(const Env& env) { - if (auto sandboxed = find_sandbox_nasm(env)) return sandboxed; - auto nasm_name = std::string("nasm") + std::string(mcpp::platform::exe_suffix); - if (auto sys = mcpp::platform::fs::which(nasm_name); - sys && nasm_version_ok(*sys)) { - return sys; - } - return std::nullopt; -} - -// Whether a path `find_usable_nasm` returned is the host's rather than the -// sandbox's. Asked by the caller so that a host tool reaching a build is -// NAMED there; a host tool that reaches a build silently is the defect, not -// the host tool. -bool nasm_is_from_host(const Env& env, const std::filesystem::path& bin) { - auto root = paths::xim_tool_root(env, "nasm"); - std::error_code ec; - auto canonRoot = std::filesystem::weakly_canonical(root, ec); - auto canonBin = std::filesystem::weakly_canonical(bin, ec); - if (canonRoot.empty() || canonBin.empty()) return true; - auto r = canonRoot.string(); - auto b = canonBin.string(); - return b.rfind(r, 0) != 0; -} - -// ─── Index freshness ──────────────────────────────────────────────── - -bool is_index_fresh(const Env& env, std::int64_t ttlSeconds) { - return is_index_dir_fresh(default_index_dir(env), ttlSeconds); -} - -bool is_official_index_fresh(const Env& env, std::int64_t ttlSeconds) { - return is_index_dir_fresh(official_index_dir(env), ttlSeconds); -} - -IndexStatus default_index_status(const Env& env, std::int64_t ttlSeconds) { - return index_status_for(default_index_dir(env), ttlSeconds); -} - -IndexStatus official_index_status(const Env& env, std::int64_t ttlSeconds) { - return index_status_for(official_index_dir(env), ttlSeconds); -} - -bool is_official_package_index_fresh(const Env& env, - std::string_view packageName, - std::int64_t ttlSeconds) { - if (!is_official_index_fresh(env, ttlSeconds)) return false; - auto pkg = official_package_file(env, packageName); - return !pkg.empty() - && std::filesystem::exists(pkg) - && official_index_cache_matches_package_file(env, packageName); -} - -// The one entry point every refresh in this process passes through. -// -// an index-side change must never take mcpp from "works" to "does not work" -// -// A published index can raise its client-version floor (index.toml -// min_mcpp). An index mcpp owns (one whose data directory mcpp declared, -// design 2026-10-16 §6) is therefore fetched into a staging directory beside -// it (xlings' `sync_index` with a destination, X1), judged there, and swapped -// in only when this mcpp can read it -- or when the copy in use is no better. -// The rest (the provider's own indexes, which carry no floor of mcpp's) are -// refreshed by the provider in place; it leaves an owned index that is there -// to its owner. -namespace { -std::atomic g_index_refresh_timeout_s{kDefaultIndexRefreshTimeout.count()}; - -struct StagedOutcome { - std::vector kept; // the refreshed copy needs a newer mcpp; the old one stays - std::vector unusable; // no copy this mcpp can read - std::map requiredMcpp; -}; - -// One owned index: .staging- fetched, judged, then exchanged with -// (or renamed onto it when there is none yet); the staging directory -// holds the old copy afterwards and is removed. -int refresh_owned(const Env& env, const paths::DeclaredIndex& d, StagedOutcome& out) { - std::error_code ec; - auto staging = d.dir; - staging += std::format(".staging-{:x}", std::random_device{}()); - std::filesystem::create_directories(d.dir.parent_path(), ec); - const auto args = nlohmann::json{{"name", d.name}, {"dest", staging.generic_string()}}.dump(); - auto r = call(env, "sync_index", args, nullptr); - if (!r || r->exitCode != 0 || !std::filesystem::is_directory(staging, ec)) { - std::filesystem::remove_all(staging, ec); - return r ? (r->exitCode != 0 ? r->exitCode : 1) : 1; - } - const bool present = std::filesystem::exists(d.dir, ec); - const bool before = present && mcpp::pm::index_usable(d.dir); - if (!mcpp::pm::index_usable(staging)) { - if (auto c = mcpp::pm::read_index_contract(staging)) out.requiredMcpp[d.dir] = c->minMcpp; - if (before) { - std::filesystem::remove_all(staging, ec); - out.kept.push_back(d.dir); - return 0; - } - out.unusable.push_back(d.dir); - } - auto swapped = present ? mcpp::platform::exchange_paths(staging, d.dir) - : mcpp::platform::rename_no_replace(staging, d.dir); - std::filesystem::remove_all(staging, ec); - if (!swapped) { - mcpp::log::warn("index", std::format("the refreshed index `{}` could not replace the one in use: {}", - d.name, swapped.error())); - return 1; - } - mark_index_refreshed(d.dir); - return 0; -} - -// The raw sync. Everything that makes a refresh SAFE lives in the wrapper -// below; this function's only job is to run the provider's refresh and report. -int update_index_unguarded(const Env& env, bool quiet); -} // namespace - -void set_index_refresh_timeout(std::chrono::seconds timeout) { - g_index_refresh_timeout_s.store( - timeout.count() > 0 ? timeout.count() : kDefaultIndexRefreshTimeout.count(), - std::memory_order_relaxed); -} - -std::chrono::seconds index_refresh_timeout() { - return std::chrono::seconds{g_index_refresh_timeout_s.load(std::memory_order_relaxed)}; -} - -int update_index(const Env& env, bool quiet) { - return update_index_unguarded(env, quiet); -} - -namespace { -int update_index_unguarded(const Env& env, bool quiet) { - ScopedInvocationEnv scope(env); // #614 - // Offline is absolute: no caller gets to reach the network by going around - // the decision layer. Reported as success so a build that can still resolve - // everything locally proceeds — the caller that genuinely needed the data - // fails on its own missing answer, with a message that says so. - if (mcpp::platform::env::offline_mode()) { - mcpp::log::verbose("index", "offline mode: skipping index update"); - return 0; - } - - // Cross-process mutex. The sync rewrites a tree several concurrent mcpp - // processes read (parallel CI jobs on one MCPP_HOME, two terminals, a - // workspace fan-out), and it also writes the refresh markers. The BMI cache - // has had this guard for a while; the index never did. - // - // NON-BLOCKING BY DESIGN: whoever holds the lock is already doing the exact - // work we wanted, so waiting buys nothing and a queue of stalled builds is - // precisely the symptom this whole change exists to remove. Skipping is - // also why this must not be reported as failure. - std::error_code lockEc; - std::filesystem::create_directories(paths::index_data(env), lockEc); - auto lock = mcpp::platform::fs::FileLock::try_acquire(paths::index_data(env)); - if (!lock) { - mcpp::log::verbose("index", - "another process is refreshing the index — skipping this one"); - return 0; - } - - // The indices mcpp owns, each staged and judged before it replaces the - // copy in use. A floor is not an error of the run (an index is data; - // mcpp is the program): each case is one closing notice, printed after - // the command's own output, and only because this run refreshed. - StagedOutcome staged; - int ownedRc = 0; - for (const auto& d : paths::declared_indices(env)) - if (d.owned) ownedRc = std::max(ownedRc, refresh_owned(env, d, staged)); - auto advice = [&](const std::filesystem::path& dir) { - auto it = staged.requiredMcpp.find(dir); - return mcpp::pm::index_floor_upgrade_advice( - it == staged.requiredMcpp.end() ? std::string_view{} : std::string_view{it->second}); - }; - for (auto& dir : staged.kept) - mcpp::ui::add_closing_notice("MCPP_INDEX_REQUIRES_NEWER_MCPP", std::format( - "the refreshed package index `{}` requires a newer mcpp; this run " - "used the previous index. {}", dir.filename().string(), advice(dir))); - for (auto& dir : staged.unusable) - mcpp::ui::add_closing_notice("MCPP_INDEX_REQUIRES_NEWER_MCPP", std::format( - "the package index `{}` requires a newer mcpp and no earlier copy is " - "usable; packages it serves cannot be resolved. {}", dir.filename().string(), advice(dir))); - - // Through the NDJSON interface, as installs are (W11): the refresh reports - // its steps as events and is drawn by the same renderer, instead of the - // bare CLI's terminal text, which was either reprinted verbatim or, for - // the automatic refresh, discarded, so a refresh of many seconds showed - // nothing. The interface's `update_packages` and the CLI's `update` are - // one function in xlings (xim::cmd_update). - std::string cmd = std::format("{} interface update_packages --args {} {} {}", - build_command_prefix(env), shq_meta("{}"), mcpp::platform::null_redirect, - mcpp::platform::is_windows ? "(refreshBound), - std::chrono::milliseconds{0}, &timedOut); - if (rc == 0 && renderer.result_exit() > 0) rc = renderer.result_exit(); - if (rc != 0 || timedOut) renderer.fail(); - } - if (rc == 0 && !timedOut) { mark_known_indexes_refreshed(env); return ownedRc; } - // A refresh that exceeded its bound is not retried: the retries exist for - // a transient failure that ends, and a connection that never answers - // would only be waited on three times. The caller treats the refresh as - // failed and resolves from the local index, as it does for any failure. - if (timedOut) { - std::println(stderr, - "warning: the package index refresh did not finish within {} seconds and was " - "stopped; continuing with the local index " - "(the bound is [index] refresh_timeout in mcpp's config.toml)", - refreshBound.count()); - return rc != 0 ? rc : 124; - } - if (attempt < kMaxAttempts) { - int delay = attempt * 2; // 2s, then 4s - mcpp::log::verbose("index", std::format( - "index update attempt {}/{} failed (rc {}); retrying in {}s", - attempt, kMaxAttempts, rc, delay)); - std::this_thread::sleep_for(std::chrono::seconds(delay)); - } - } - // Said, not only logged, to a caller that refreshes on its own (`quiet`: - // the TTL refresh before a build), which continues with the local index; - // `mcpp index update` reports the failure itself. - if (quiet) - std::println(stderr, - "warning: the package index refresh failed after {} attempts (exit {}); " - "continuing with the local index", kMaxAttempts, rc); - return rc; -} -} // namespace - -void ensure_index_fresh(const Env& env, std::int64_t ttlSeconds, bool quiet) { - if (is_index_fresh(env, ttlSeconds)) return; - if (!quiet) - print_status("Updating", "package index (auto-refresh)"); - update_index(env, /*quiet=*/true); -} - -bool official_package_present(const Env& env, std::string_view packageName) { - auto pkg = official_package_file(env, packageName); - std::error_code ec; - return !pkg.empty() && std::filesystem::exists(pkg, ec); -} - -} // namespace mcpp::provider::xlings diff --git a/src/publish/pipeline.cppm b/src/publish/pipeline.cppm index b4623a52..4707cd6b 100644 --- a/src/publish/pipeline.cppm +++ b/src/publish/pipeline.cppm @@ -12,7 +12,7 @@ export module mcpp.publish.pipeline; import std; import mcpp.manifest; import mcpp.modgraph.scanner; -import mcpp.platform; +import mcpp.run; import mcpp.project; import mcpp.publish.normalize; import mcpp.publish.xpkg_emit; @@ -98,9 +98,7 @@ export int publish_package(bool dry_run, bool allow_dirty) { // Sanity: working tree clean (best-effort via git status). if (!allow_dirty && std::filesystem::exists(*root / ".git")) { - auto gitStatus = mcpp::platform::process::capture( - std::format("git -C {} status --porcelain 2>&1", - mcpp::platform::shell::quote(root->string()))); + auto gitStatus = mcpp::run::execute({ "git", "-C", root->string(), "status", "--porcelain" }); std::string out = gitStatus.output; if (!out.empty()) { mcpp::ui::error("working tree has uncommitted changes; pass --allow-dirty to skip this check"); diff --git a/src/run.cppm b/src/run.cppm new file mode 100644 index 00000000..47d1804d --- /dev/null +++ b/src/run.cppm @@ -0,0 +1,199 @@ +// mcpp.run — every program mcpp starts, and how mcpp ends when it is asked to. +// +// One launcher over mcpplibs.os (design 2026-10-26 §25.5): a program is named +// by an argument vector, receives this invocation's environment (mcpp.invocation) +// with the caller's additions, runs in a unit of its own that ends with mcpp, +// and is read, bounded and stopped as the caller asks. A command interpreter is +// started only for a command that is one by nature -- a `[hooks]` command, a +// toolchain's environment script -- through `shell`. +// +// THERE ARE NO SIGNAL HANDLERS. mcpplibs.os takes the requests to end over +// (SIGINT, SIGTERM, SIGHUP; a console's control events) and sets a word; a +// watcher thread waits on it, ends the units of every program mcpp is running, +// writes the files an interrupt leaves, runs the registered cleanups and then +// ends mcpp as the request would have. A write to a closed standard output is +// refused rather than fatal; `end_for_closed_output` ends mcpp in the same +// order and then as the system ends such a program (SIGPIPE, 141 to a shell). +export module mcpp.run; + +import std; + +export namespace mcpp::run { + +// Variables added to a started program's environment (a value replaces +// whatever the variable held). +using Env = std::vector>; + +struct RunResult { + int exit_code = 0; + std::string output; + std::string error_output; // Streams::output_error_kept: its standard error + bool timed_out = false; // a bound or `should_stop` ended it + int spawn_error = 0; // errno-style when it could not be started, else 0 +}; + +// ─── This process ──────────────────────────────────────────────────────── + +// Called once, first thing in main: the requests to end are taken over and +// the watcher is started. +void install(); + +// The files an interrupt leaves (design 2026-10-26 §5.4): when a request ends +// mcpp, each `(path, content)` replaces its file, through a temporary name +// beside it and a rename, after the programs mcpp started are ended. Setting +// replaces the previous set. +void set_interrupt_files(const std::vector>& files); +void clear_interrupt_files(); + +// A cleanup the watcher runs before mcpp ends at a request (a terminal's mode +// restored). Returns its handle for `remove_cleanup`. +int add_cleanup(std::function cleanup); +void remove_cleanup(int handle); + +// mcpp's own standard output has no reader: the programs it started are ended, +// the interrupt files written, the cleanups run, and mcpp ends as the system +// ends such a program. Returns only where the system has no such ending +// (Windows), where mcpp goes on as its writes are refused. +void end_for_closed_output(); + +// ─── Starting a program ────────────────────────────────────────────────── +// +// A SPAWN FAILURE IS REPORTED EXACTLY ONCE AND NEVER DROPPED (#544). When the +// program could not be started a launcher returns 127 and either stores an +// errno-style code in `*spawn_error` (ENOENT, EACCES, ENOEXEC, EIO) and prints +// nothing, because a caller that asked owns the report, or, with +// `spawn_error == nullptr`, reports it itself -- on stderr for the launchers +// that inherit it, into `output` for the capturing ones. `*spawn_error` is 0 +// whenever the program was started, whatever it then did. + +// The program on mcpp's standard streams, in a unit of its own; its exit code +// (128 + the signal that ended it, as a shell reports it). +int run_exec(const std::vector& argv, const Env& extraEnv = {}, + int* spawn_error = nullptr); + +// THE PROGRAM A USER RUNS (`mcpp run`): mcpp becomes it (POSIX: its image +// replaced), so it owns the terminal, its signals and its exit status as it +// would if the shell had started it. Returns only when it could not be +// started: 127, under the contract above. Elsewhere the program is started in +// a unit bound to mcpp and mcpp ends with its status. +int run_foreground(const std::vector& argv, const Env& extraEnv = {}, + int* spawn_error = nullptr); + +// Its standard output and standard error together, in the order written. +RunResult capture_exec(const std::vector& argv, const Env& extraEnv = {}, + std::string_view cwd = {}, int* spawn_error = nullptr); + +// A PROGRAM PROBE: its standard output only, an empty standard input, its +// standard error discarded. A program that cannot be started yields 127 and +// EMPTY output: a probe parses what it captured. +RunResult capture_stdout(const std::vector& argv, const Env& extraEnv = {}, + int* spawn_error = nullptr); +// capture_stdout for a host tool: the target's loader search variable is not +// given to it (a target program's LD_LIBRARY_PATH poisons system tools). +RunResult capture_host_tool_stdout(const std::vector& argv); +// capture_exec for a host tool. +RunResult capture_host_tool(const std::vector& argv, const Env& extraEnv = {}); + +// Bounded: the unit is ended once `deadline` passes and `*timed_out` is set. A +// zero deadline is no bound. +int run_exec_deadline(const std::vector& argv, const Env& extraEnv, + std::chrono::milliseconds deadline, bool* timed_out, + int* spawn_error = nullptr); +RunResult capture_exec_deadline(const std::vector& argv, const Env& extraEnv, + std::chrono::milliseconds deadline, bool* timed_out, + std::string_view cwd = {}, int* spawn_error = nullptr); + +// Each line of the program's output (both streams, without the line end) to +// `on_line` as it arrives; a line the program did not end arrives after it +// does. Its input is empty, and `deadline` bounds the run (zero: no bound). +// Returns the exit code. The general form, with an idle bound, a stop +// condition and carriage returns as line ends, is `execute`. +int stream_exec(const std::vector& argv, const Env& extraEnv, + std::chrono::milliseconds deadline, + const std::function& on_line, bool* timed_out, + int* spawn_error = nullptr); + +// ─── Any program, as the caller states ─────────────────────────────────── + +// What a started program reads. +enum class Input { none, inherit }; +// Where its two output streams go: mcpp's own (inherit); read together +// (merged); its standard output read and its standard error discarded +// (output: a probe); its standard output read and its standard error on +// mcpp's (output_error_shown); both read, apart (output_error_kept: an output +// parsed as records, whose errors only a failure reports); nowhere (discard). +enum class Streams { inherit, merged, output, output_error_shown, output_error_kept, discard }; + +struct Options { + Env env; // additions; a value replaces + std::vector unset; // variables it does not receive + std::string cwd; // empty: mcpp's + bool host_tool = false; // the target's loader search variable withheld + Input input = Input::none; + Streams streams = Streams::merged; + std::chrono::milliseconds total{0}; // zero: no bound + std::chrono::milliseconds idle{0}; // zero: no bound + // Each line of the read output as it arrives, instead of `output`. + std::function on_line; + bool split_on_cr = false; + std::function should_stop; + // A program that could not be started is reported (into `output` when it + // is read with its errors, else on stderr) unless the caller owns the + // report; `spawn_error` is set either way. + bool report_spawn_failure = true; + // The arguments given as one string after the program's name (Windows): + // `Shell::one_string`, for the command interpreter. + bool one_string = false; +}; +RunResult execute(const std::vector& argv, const Options& options = {}); + +// ─── A command for the command interpreter ─────────────────────────────── + +// The argument vector that runs `command` through the host's interpreter: +// ` -c ` on POSIX (/bin/sh, else Android's /system/bin/sh, else +// $PREFIX/bin/sh), `cmd.exe /d /s /c ""` on Windows, given to it as one +// string (mcpplibs.os `one_string`) so that its own grammar reads the command. +struct Shell { + std::vector argv; + bool one_string = false; +}; +Shell shell(std::string_view command); + +// A command line mcpp composed as text and records as such (a std module's +// build commands, kept in its metadata): its standard output read, its own +// redirections deciding the rest, an empty standard input. +RunResult capture_shell(std::string_view command, const Env& extraEnv = {}); + +// A user-authored command (`[hooks]`) on mcpp's streams, in `cwd`, bounded. +// 127 when the interpreter could not be started. +int run_shell_deadline(std::string_view command, std::string_view cwd, + std::chrono::milliseconds deadline, bool* timed_out); +// A command captured with additions to its environment, bounded: a toolchain's +// environment script, which only the interpreter can run. +RunResult capture_shell_deadline(std::string_view command, const Env& extraEnv, + std::chrono::milliseconds deadline, bool* timed_out, + std::string_view cwd = {}); + +// ─── A command mcpp owns for longer than one call (#496) ───────────────── +// +// A project `[hooks] during_build` command is owned for the length of the +// BUILD: started here, polled while the build runs, stopped afterwards, and +// ended with mcpp if a request ends mcpp first. Its unit is the command and +// everything it starts: the command is user-authored shell, so the thing that +// must end is a tree. +struct BackgroundCommand { + bool ok = false; + std::shared_ptr state; // opaque +}; +BackgroundCommand start_shell_background(std::string_view command, std::string_view cwd, + bool inheritStdio); +// True while it runs. When it has ended and `exitCode` is given, the code is +// written there. +bool background_running(const BackgroundCommand& child, int* exitCode = nullptr); +// Asked to end, `grace` given, then its unit ended. +void stop_background(const BackgroundCommand& child, std::chrono::milliseconds grace); +// Ended with mcpp at a request (the watcher's registry), from now on / no more. +void guard_background_on_signal(const BackgroundCommand& child); +void clear_background_guard(const BackgroundCommand& child); + +} // namespace mcpp::run diff --git a/src/run/run.cpp b/src/run/run.cpp new file mode 100644 index 00000000..ec8ebdd9 --- /dev/null +++ b/src/run/run.cpp @@ -0,0 +1,591 @@ +// mcpp.run over mcpplibs.os: the launcher, the registry of running programs, +// and the watcher that ends mcpp at a request. +module; +#include // stdout, stderr: macros, which `import std` does not carry + +module mcpp.run; + +import std; +import mcpplibs.os.platform; +import mcpplibs.os.error; +import mcpplibs.os.env; +import mcpplibs.os.fs; +import mcpplibs.os.process; +import mcpplibs.os.interrupt; +import mcpp.invocation; +import mcpp.build.command_line; +import mcpp.runtime.launch_env; + +namespace mcpp::run { + +namespace { + +namespace os = mcpplibs::os; +using Process = os::process::process; + +// ─── What the watcher ends, writes and runs ────────────────────────────── + +std::mutex g_live; +std::set& live() { + static std::set s; + return s; +} + +std::mutex g_files; +std::vector>& interrupt_files() { + static std::vector> f; + return f; +} + +std::mutex g_cleanups; +int g_next_cleanup = 0; +std::map>& cleanups() { + static std::map> c; + return c; +} + +// A started program, ended with mcpp at a request for as long as this lives. +struct Registered { + Process* p; + explicit Registered(Process* q) : p(q) { std::lock_guard g(g_live); live().insert(p); } + ~Registered() { std::lock_guard g(g_live); live().erase(p); } + Registered(const Registered&) = delete; + Registered& operator=(const Registered&) = delete; +}; + +// Everything mcpp owes before it ends at a request or a closed output, once. +std::atomic g_finishing{false}; +void finish() { + if (g_finishing.exchange(true)) return; + { + std::lock_guard g(g_live); + for (auto* p : live()) { p->end_unit(); p->kill(); } + } + { + std::lock_guard g(g_files); + for (auto const& [path, content] : interrupt_files()) + (void)os::fs::write_atomic(path, content); + } + std::vector> todo; + { + std::lock_guard g(g_cleanups); + for (auto& [k, f] : cleanups()) todo.push_back(f); + } + for (auto& f : todo) f(); +} + +// ─── The launcher ──────────────────────────────────────────────────────── + +enum class Output { inherit, merged, stdout_only, stdout_err_shown, stdout_err_kept, discard }; + +struct Spec { + std::vector argv; + Env env; + std::vector unset; + bool hostTool = false; + std::string cwd; + Output out = Output::inherit; + bool nullInput = false; + std::chrono::milliseconds total{0}, idle{0}; + const std::function* onLine = nullptr; + bool splitOnCr = false; + const std::function* shouldStop = nullptr; + bool oneString = false; +}; + +struct Outcome { + int exit_code = 127; + std::string output; + std::string error_output; + bool timed_out = false; + int spawn_error = 0; // errno-style; 0 when started + std::string spawn_message; +}; + +// The errno a caller classifies a refused start by (ENOENT, EACCES, ENOEXEC). +int errno_of(os::errc c) { + switch (c) { + case os::errc::not_found: return 2; + case os::errc::permission: return 13; + case os::errc::not_program: return 8; + default: return 5; + } +} + +std::string spawn_failure(std::string_view program, int error) { + return std::format("cannot start '{}' (error {}): {}\n", program, error, + std::generic_category().message(error)); +} + +// The environment a started program receives: this invocation's statements, +// the inherited loader search paths without mcpp's private glibc (an outer +// `mcpp run` points them at a payload a host tool must not load), and the +// caller's additions, which win. +std::vector>> child_env(const Env& extra, bool hostTool, + const std::vector& unset = {}) { + auto env = mcpp::invocation::overrides(); + auto stated = [&](std::string_view key) { + for (auto& [k, v] : env) if (k == key) return true; + for (auto& [k, v] : extra) if (k == key) return true; + return false; + }; + for (std::string_view key : { std::string_view("LD_LIBRARY_PATH"), std::string_view("DYLD_LIBRARY_PATH") }) { + if (stated(key)) continue; + if (auto v = os::env::get(key)) { + auto cleaned = mcpp::runtime::launch_env::strip_private_glibc(*v); + env.emplace_back(std::string(key), cleaned.empty() ? std::nullopt + : std::optional(cleaned)); + } + } + if (hostTool) + if (auto key = mcpp::runtime::launch_env::host_tool_runtime_library_path_key(); !key.empty()) + env.emplace_back(std::move(key), std::nullopt); + for (auto& [k, v] : extra) env.emplace_back(k, v); + for (auto& k : unset) env.emplace_back(k, std::nullopt); + return env; +} + +// A bare name looked for on this invocation's PATH, which mcpp may have +// stated; a name with a separator is taken as written. +std::optional program_of(const std::string& name) { + if (os::fs::names_a_place(name)) return std::filesystem::path(name); + return mcpp::invocation::which(name); +} + +// Lines of `acc` up to its last line end, to `on_line`, without the ends. +void deliver_lines(std::string& acc, const std::function& on_line, bool splitOnCr, + bool all) { + std::size_t start = 0; + for (std::size_t i = 0; i < acc.size(); ++i) { + const bool lf = acc[i] == '\n'; + const bool cr = splitOnCr && acc[i] == '\r' && i + 1 < acc.size() && acc[i + 1] != '\n'; + if (!lf && !cr) continue; + std::string_view line(acc.data() + start, i - start); + while (!line.empty() && line.back() == '\r') line.remove_suffix(1); + on_line(line); + start = i + 1; + } + acc.erase(0, start); + if (all && !acc.empty()) { + std::string_view line(acc); + while (!line.empty() && line.back() == '\r') line.remove_suffix(1); + if (!line.empty()) on_line(line); + acc.clear(); + } +} + +Outcome launch(const Spec& s) { + Outcome out; + if (s.argv.empty()) return out; + const auto program = program_of(s.argv.front()); + if (!program) { + out.spawn_error = 2; + out.spawn_message = spawn_failure(s.argv.front(), 2); + return out; + } + os::process::spawn_options o; + o.program = *program; + o.argv0 = s.argv.front(); + o.args.assign(s.argv.begin() + 1, s.argv.end()); + o.cwd = s.cwd; + o.env = child_env(s.env, s.hostTool, s.unset); + o.in = s.nullInput ? os::process::stdio::null : os::process::stdio::inherit; + switch (s.out) { + case Output::inherit: o.out = os::process::stdio::inherit; o.err = os::process::stdio::inherit; break; + case Output::merged: o.out = os::process::stdio::pipe; o.err = os::process::stdio::output; break; + case Output::stdout_only: o.out = os::process::stdio::pipe; o.err = os::process::stdio::null; break; + case Output::stdout_err_shown: o.out = os::process::stdio::pipe; o.err = os::process::stdio::inherit; break; + case Output::stdout_err_kept: o.out = os::process::stdio::pipe; o.err = os::process::stdio::pipe; break; + case Output::discard: o.out = os::process::stdio::null; o.err = os::process::stdio::null; break; + } + o.own_unit = true; + o.bound_lifetime = true; + o.one_string = s.oneString; + auto p = Process::spawn(o); + if (!p) { + out.spawn_error = errno_of(p.error().code); + out.spawn_message = spawn_failure(s.argv.front(), out.spawn_error); + return out; + } + Registered guard(&*p); + using clock = std::chrono::steady_clock; + const auto start = clock::now(); + auto lastOutput = start; + bool stop = false; + const bool keepErr = s.out == Output::stdout_err_kept; + bool errDone = !keepErr; + // The error stream, read as it arrives so that a program writing much of + // it is never stopped on a full pipe while its output is read. + auto drain_error = [&](std::chrono::milliseconds wait) { + if (errDone) return; + auto e = p->read_error_for(wait); + if (!e) { errDone = true; return; } + if (!e->has_value()) return; + if ((*e)->empty()) { errDone = true; return; } + out.error_output += **e; + }; + if (s.out == Output::merged || s.out == Output::stdout_only || s.out == Output::stdout_err_shown + || keepErr) { + std::string acc; + std::optional endedAt; + for (;;) { + const auto now = clock::now(); + if ((s.total.count() > 0 && now - start >= s.total) + || (s.idle.count() > 0 && now - lastOutput >= s.idle) + || (s.shouldStop && *s.shouldStop && (*s.shouldStop)())) { + stop = true; + break; + } + // A program that has ended may leave one behind holding its + // output open; what arrives in the next moment is read, then the + // rest of its unit is ended. + if (!endedAt) { + if (auto w = p->try_wait(); w && w->has_value()) endedAt = now; + } else if (now - *endedAt > std::chrono::milliseconds(500)) { + p->end_unit(); + } + drain_error(std::chrono::milliseconds(0)); + auto chunk = p->read_output_for(std::chrono::milliseconds(keepErr ? 20 : 100)); + if (!chunk) break; + if (!chunk->has_value()) continue; + if ((*chunk)->empty()) break; // the end of the output + lastOutput = clock::now(); + if (s.onLine) { + acc += **chunk; + deliver_lines(acc, *s.onLine, s.splitOnCr, false); + } else { + out.output += **chunk; + } + } + if (s.onLine && !stop) deliver_lines(acc, *s.onLine, s.splitOnCr, true); + // The rest of the error stream, for as long as the program and the + // bounds allow. + while (!stop && !errDone) { + const auto now = clock::now(); + if (s.total.count() > 0 && now - start >= s.total) { stop = true; break; } + if (!endedAt) { + if (auto w = p->try_wait(); w && w->has_value()) endedAt = now; + } else if (now - *endedAt > std::chrono::milliseconds(500)) { + p->end_unit(); + break; + } + drain_error(std::chrono::milliseconds(100)); + } + } else if (s.total.count() > 0) { + auto w = p->wait_for(s.total); + if (w && !w->has_value()) stop = true; + } + if (stop) { + p->end_unit(); + p->kill(); + out.timed_out = true; + } + auto st = p->wait(); + out.exit_code = st ? st->shell_code() : 127; + return out; +} + +RunResult result_of(Outcome&& o, int* spawn_error, bool messageIntoOutput) { + RunResult r; + r.exit_code = o.exit_code; + r.output = std::move(o.output); + if (o.spawn_error != 0) { + r.exit_code = 127; + if (spawn_error) *spawn_error = o.spawn_error; + else if (messageIntoOutput) r.output = o.spawn_message; + } + return r; +} + +int status_of(const Outcome& o, int* spawn_error) { + if (o.spawn_error == 0) return o.exit_code; + if (spawn_error) *spawn_error = o.spawn_error; + else std::fputs(o.spawn_message.c_str(), stderr); + return 127; +} + +const std::string& posix_shell() { + static const std::string sh = [] { + std::error_code ec; + if (std::filesystem::exists("/bin/sh", ec)) return std::string("/bin/sh"); + if (std::filesystem::exists("/system/bin/sh", ec)) return std::string("/system/bin/sh"); + if (auto prefix = mcpp::invocation::get("PREFIX")) + if (std::filesystem::exists(std::filesystem::path(*prefix) / "bin" / "sh", ec)) + return (std::filesystem::path(*prefix) / "bin" / "sh").string(); + return std::string("/bin/sh"); + }(); + return sh; +} + +struct Background { + std::optional process; + std::optional guard; +}; + +} // namespace + +// ─── This process ──────────────────────────────────────────────────────── + +void install() { + (void)os::interrupt::requested(); // the requests to end are taken over + if (!os::interrupt::available()) return; + std::thread([] { + while (!os::interrupt::wait()) {} + finish(); + os::interrupt::end_as_requested(); + }).detach(); +} + +void set_interrupt_files(const std::vector>& files) { + std::lock_guard g(g_files); + interrupt_files() = files; +} + +void clear_interrupt_files() { + std::lock_guard g(g_files); + interrupt_files().clear(); +} + +int add_cleanup(std::function cleanup) { + std::lock_guard g(g_cleanups); + const int handle = g_next_cleanup++; + cleanups().emplace(handle, std::move(cleanup)); + return handle; +} + +void remove_cleanup(int handle) { + std::lock_guard g(g_cleanups); + cleanups().erase(handle); +} + +void end_for_closed_output() { + if constexpr (os::is_windows) return; // the write is refused and mcpp goes on + finish(); + os::interrupt::end_as_output_closed(); +} + +// ─── Starting a program ────────────────────────────────────────────────── + +int run_exec(const std::vector& argv, const Env& extraEnv, int* spawn_error) { + if (spawn_error) *spawn_error = 0; + return status_of(launch({ .argv = argv, .env = extraEnv }), spawn_error); +} + +int run_foreground(const std::vector& argv, const Env& extraEnv, int* spawn_error) { + if (spawn_error) *spawn_error = 0; + if (argv.empty()) return 127; + const auto program = program_of(argv.front()); + int error = 2; + if (program) { + os::process::spawn_options o; + o.program = *program; + o.argv0 = argv.front(); + o.args.assign(argv.begin() + 1, argv.end()); + o.env = child_env(extraEnv, false); + o.in = o.out = o.err = os::process::stdio::inherit; + std::fflush(stdout); + std::fflush(stderr); + auto r = os::process::exec(o); // returns only when the program could not be started + error = r ? 5 : errno_of(r.error().code); + } + if (spawn_error) *spawn_error = error; + else std::fputs(spawn_failure(argv.front(), error).c_str(), stderr); + return 127; +} + +RunResult capture_exec(const std::vector& argv, const Env& extraEnv, + std::string_view cwd, int* spawn_error) { + if (spawn_error) *spawn_error = 0; + return result_of(launch({ .argv = argv, .env = extraEnv, .cwd = std::string(cwd), .out = Output::merged }), + spawn_error, true); +} + +RunResult capture_stdout(const std::vector& argv, const Env& extraEnv, int* spawn_error) { + if (spawn_error) *spawn_error = 0; + auto o = launch({ .argv = argv, .env = extraEnv, .out = Output::stdout_only, .nullInput = true }); + return result_of(std::move(o), spawn_error, false); +} + +RunResult capture_host_tool_stdout(const std::vector& argv) { + auto o = launch({ .argv = argv, .hostTool = true, .out = Output::stdout_only, .nullInput = true }); + return result_of(std::move(o), nullptr, false); +} + +RunResult capture_host_tool(const std::vector& argv, const Env& extraEnv) { + auto o = launch({ .argv = argv, .env = extraEnv, .hostTool = true, .out = Output::merged, .nullInput = true }); + return result_of(std::move(o), nullptr, true); +} + +int run_exec_deadline(const std::vector& argv, const Env& extraEnv, + std::chrono::milliseconds deadline, bool* timed_out, int* spawn_error) { + if (spawn_error) *spawn_error = 0; + auto o = launch({ .argv = argv, .env = extraEnv, .total = deadline }); + if (timed_out) *timed_out = o.timed_out; + return status_of(o, spawn_error); +} + +RunResult capture_exec_deadline(const std::vector& argv, const Env& extraEnv, + std::chrono::milliseconds deadline, bool* timed_out, + std::string_view cwd, int* spawn_error) { + if (spawn_error) *spawn_error = 0; + auto o = launch({ .argv = argv, .env = extraEnv, .cwd = std::string(cwd), .out = Output::merged, + .total = deadline }); + if (timed_out) *timed_out = o.timed_out; + return result_of(std::move(o), spawn_error, true); +} + +int stream_exec(const std::vector& argv, const Env& extraEnv, + std::chrono::milliseconds deadline, + const std::function& on_line, bool* timed_out, + int* spawn_error) { + if (spawn_error) *spawn_error = 0; + auto o = launch({ .argv = argv, .env = extraEnv, .out = Output::merged, .nullInput = true, + .total = deadline, .onLine = &on_line }); + if (timed_out) *timed_out = o.timed_out; + if (o.spawn_error != 0) { + if (spawn_error) *spawn_error = o.spawn_error; + else on_line(std::string_view(o.spawn_message).substr(0, o.spawn_message.size() - 1)); + return 127; + } + return o.exit_code; +} + +// ─── Any program, as the caller states ─────────────────────────────────── + +RunResult execute(const std::vector& argv, const Options& opt) { + Spec s{ .argv = argv, .env = opt.env, .unset = opt.unset, .hostTool = opt.host_tool, .cwd = opt.cwd, + .nullInput = opt.input == Input::none, .total = opt.total, .idle = opt.idle, + .onLine = opt.on_line ? &opt.on_line : nullptr, .splitOnCr = opt.split_on_cr, + .shouldStop = opt.should_stop ? &opt.should_stop : nullptr, .oneString = opt.one_string }; + switch (opt.streams) { + case Streams::inherit: s.out = Output::inherit; break; + case Streams::merged: s.out = Output::merged; break; + case Streams::output: s.out = Output::stdout_only; break; + case Streams::output_error_shown: s.out = Output::stdout_err_shown; break; + case Streams::output_error_kept: s.out = Output::stdout_err_kept; break; + case Streams::discard: s.out = Output::discard; break; + } + auto o = launch(s); + RunResult r; + r.exit_code = o.exit_code; + r.output = std::move(o.output); + r.error_output = std::move(o.error_output); + r.timed_out = o.timed_out; + r.spawn_error = o.spawn_error; + if (o.spawn_error != 0) { + r.exit_code = 127; + if (opt.report_spawn_failure) { + if (opt.streams == Streams::merged) { + if (opt.on_line) opt.on_line(std::string_view(o.spawn_message).substr(0, o.spawn_message.size() - 1)); + else r.output = o.spawn_message; + } else { + std::fputs(o.spawn_message.c_str(), stderr); + } + } + } + return r; +} + +// ─── A command for the command interpreter ─────────────────────────────── + +Shell shell(std::string_view command) { + if constexpr (os::is_windows) { + Shell sh{ { "cmd.exe" }, true }; + for (auto& a : mcpp::build::command_line::windows_shell_arguments(command)) sh.argv.push_back(std::move(a)); + return sh; + } else { + return { { posix_shell(), "-c", std::string(command) }, false }; + } +} + +RunResult capture_shell(std::string_view command, const Env& extraEnv) { + auto sh = shell(command); + auto o = launch({ .argv = sh.argv, .env = extraEnv, .out = Output::stdout_err_shown, .nullInput = true, + .oneString = sh.one_string }); + return result_of(std::move(o), nullptr, true); +} + +int run_shell_deadline(std::string_view command, std::string_view cwd, + std::chrono::milliseconds deadline, bool* timed_out) { + if (timed_out) *timed_out = false; + if (command.empty()) return 127; + auto sh = shell(command); + auto o = launch({ .argv = sh.argv, .cwd = std::string(cwd), .total = deadline, .oneString = sh.one_string }); + if (timed_out) *timed_out = o.timed_out; + return o.spawn_error != 0 ? 127 : o.exit_code; +} + +RunResult capture_shell_deadline(std::string_view command, const Env& extraEnv, + std::chrono::milliseconds deadline, bool* timed_out, + std::string_view cwd) { + if (timed_out) *timed_out = false; + auto sh = shell(command); + auto o = launch({ .argv = sh.argv, .env = extraEnv, .cwd = std::string(cwd), .out = Output::merged, + .nullInput = true, .total = deadline, .oneString = sh.one_string }); + if (timed_out) *timed_out = o.timed_out; + return result_of(std::move(o), nullptr, true); +} + +// ─── A command mcpp owns for longer than one call ──────────────────────── + +BackgroundCommand start_shell_background(std::string_view command, std::string_view cwd, bool inheritStdio) { + BackgroundCommand out; + if (command.empty()) return out; + auto sh = shell(command); + const auto program = program_of(sh.argv.front()); + if (!program) return out; + os::process::spawn_options o; + o.program = *program; + o.argv0 = sh.argv.front(); + o.args.assign(sh.argv.begin() + 1, sh.argv.end()); + o.cwd = std::string(cwd); + o.env = child_env({}, false); + o.in = os::process::stdio::null; + o.out = o.err = inheritStdio ? os::process::stdio::inherit : os::process::stdio::null; + o.own_unit = true; + o.bound_lifetime = true; + o.one_string = sh.one_string; + auto p = Process::spawn(o); + if (!p) return out; + auto state = std::make_shared(); + state->process = std::move(*p); + out.ok = true; + out.state = state; + return out; +} + +bool background_running(const BackgroundCommand& child, int* exitCode) { + if (!child.ok) return false; + auto* b = static_cast(child.state.get()); + auto w = b->process->try_wait(); + if (!w || !w->has_value()) return w.has_value(); + if (exitCode) *exitCode = (*w)->shell_code(); + return false; +} + +void stop_background(const BackgroundCommand& child, std::chrono::milliseconds grace) { + if (!child.ok) return; + auto* b = static_cast(child.state.get()); + b->process->terminate(); + auto w = b->process->wait_for(grace); + if (!w || !w->has_value()) { + b->process->end_unit(); + b->process->kill(); + } else { + b->process->end_unit(); + } + (void)b->process->wait(); +} + +void guard_background_on_signal(const BackgroundCommand& child) { + if (!child.ok) return; + auto* b = static_cast(child.state.get()); + if (!b->guard) b->guard.emplace(&*b->process); +} + +void clear_background_guard(const BackgroundCommand& child) { + if (!child.ok) return; + static_cast(child.state.get())->guard.reset(); +} + +} // namespace mcpp::run diff --git a/src/runtime/binding.cppm b/src/runtime/binding.cppm index de8349de..e09d71c3 100644 --- a/src/runtime/binding.cppm +++ b/src/runtime/binding.cppm @@ -13,12 +13,13 @@ import std; // package manager -- which is what kept `src/platform` from being a package of // its own. The caller passes the path; see subos_path. import mcpp.libs.json; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.provider; import mcpp.provider.select; import mcpp.provider.runtime_selection; -export namespace mcpp::platform::runtime { +export namespace mcpp::runtime { struct RuntimeBinding { int schema = 0; @@ -62,7 +63,7 @@ struct RuntimeBinding { // // Deliberately a SECOND field rather than more entries in `libraryDirs`: // merging them discards the immutability distinction, and that - // distinction is the whole of `mcpp.platform.runtime_search`'s ordering + // distinction is the whole of `mcpp.runtime.search`'s ordering // rule. A payload directory and a farm directory are not interchangeable // even when they currently resolve to the same file. std::vector searchDirs; @@ -415,8 +416,8 @@ resolve_runtime_binding( out.schema = info.schema; out.providerId = "xlings"; - out.platform = std::string(mcpp::platform::name); - out.arch = std::string(mcpp::platform::host_arch); + out.platform = std::string(mcpp::toolchain::platform::name); + out.arch = std::string(mcpp::toolchain::platform::host_arch); out.runtimeId = info.runtime; out.provenance = selection.mode == mcpp::provider::runtime::RuntimeSelection::Mode::McppDefault @@ -424,7 +425,7 @@ resolve_runtime_binding( // libc/ELF are Linux concepts. macOS and Windows still carry the same // provider/environment contract without inventing a glibc field. - if constexpr (mcpp::platform::is_linux) { + if constexpr (mcpplibs::os::is_linux) { out.libc = info.runtime; if (!info.hostGlibc.empty()) out.hostLibc = info.hostGlibc; @@ -733,4 +734,4 @@ resolve_runtime_environment( return mcpp::provider::resolve_env(info, binding.subosDir, ambient); } -} // namespace mcpp::platform::runtime +} // namespace mcpp::runtime diff --git a/src/runtime/elf.cppm b/src/runtime/elf.cppm index 93988b91..12d38d13 100644 --- a/src/runtime/elf.cppm +++ b/src/runtime/elf.cppm @@ -8,10 +8,11 @@ export module mcpp.runtime.elf; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.runtime.binding; -export namespace mcpp::platform::elf { +export namespace mcpp::runtime::elf { // WHICH dynamic tag carried the search path, kept separately from the path // list itself. @@ -225,7 +226,7 @@ inspect_dynamic_symbols(const std::filesystem::path& object); RuntimeResolution resolve_runtime_closure( const std::filesystem::path& artifact, - const mcpp::platform::runtime::RuntimeBinding& binding, + const mcpp::runtime::RuntimeBinding& binding, std::span additionalSearchDirs = {}); // `hostLibsAllowed` mirrors `[build] allow_host_libs` (and @@ -238,7 +239,7 @@ RuntimeResolution resolve_runtime_closure( // One declaration, one meaning, both phases. RuntimeVerdict validate_runtime_artifact( const std::filesystem::path& artifact, - const mcpp::platform::runtime::RuntimeBinding& binding, + const mcpp::runtime::RuntimeBinding& binding, const RuntimeResolution& resolution, bool hostLibsAllowed = false); @@ -287,13 +288,13 @@ struct DlopenSurfaceReport { // this twice and only the second call saw the alias. DlopenSurfaceReport inspect_dlopen_surface( std::span surfaceDirs, - const mcpp::platform::runtime::RuntimeBinding& binding, + const mcpp::runtime::RuntimeBinding& binding, std::span searchDirs, std::span alsoProvided = {}); -} // namespace mcpp::platform::elf +} // namespace mcpp::runtime::elf -namespace mcpp::platform::elf { +namespace mcpp::runtime::elf { namespace detail { constexpr std::uint32_t kPtLoad = 1; @@ -490,7 +491,7 @@ void append_unique_path(std::vector& out, std::vector host_library_dirs() { std::vector out; - const auto arch = std::string(mcpp::platform::host_arch); + const auto arch = std::string(mcpp::toolchain::platform::host_arch); std::string triple; if (arch == "x86_64") triple = "x86_64-linux-gnu"; else if (arch == "aarch64") triple = "aarch64-linux-gnu"; @@ -508,7 +509,7 @@ std::vector host_library_dirs() { std::optional resolve_needed( std::string_view soname, const ElfRuntimeFacts& requester, - const mcpp::platform::runtime::RuntimeBinding& binding, + const mcpp::runtime::RuntimeBinding& binding, std::span additionalSearchDirs, std::span inheritedRpaths) { std::filesystem::path named(soname); @@ -1186,7 +1187,7 @@ inspect_elf_runtime(const std::filesystem::path& artifact) { RuntimeResolution resolve_runtime_closure( const std::filesystem::path& artifact, - const mcpp::platform::runtime::RuntimeBinding& binding, + const mcpp::runtime::RuntimeBinding& binding, std::span additionalSearchDirs) { RuntimeResolution resolution; auto root = inspect_elf_runtime(artifact); @@ -1294,7 +1295,7 @@ RuntimeResolution resolve_runtime_closure( RuntimeVerdict validate_runtime_artifact( const std::filesystem::path& artifact, - const mcpp::platform::runtime::RuntimeBinding& binding, + const mcpp::runtime::RuntimeBinding& binding, const RuntimeResolution& resolution, bool hostLibsAllowed) { RuntimeVerdict verdict; @@ -1303,8 +1304,8 @@ RuntimeVerdict validate_runtime_artifact( // not a glibc that is missing, it is a different provider whose runtime // this validator has no rules for. const bool isGlibc = - mcpp::platform::runtime::runtime_provider(binding.runtimeId) == "glibc"; - if constexpr (!mcpp::platform::is_linux) { + mcpp::runtime::runtime_provider(binding.runtimeId) == "glibc"; + if constexpr (!mcpplibs::os::is_linux) { verdict.diagnostics.push_back( "runtime physics: non-Linux platform; ELF/glibc rules are not applicable"); return verdict; @@ -1531,7 +1532,7 @@ RuntimeVerdict validate_runtime_artifact( DlopenSurfaceReport inspect_dlopen_surface( std::span surfaceDirs, - const mcpp::platform::runtime::RuntimeBinding& binding, + const mcpp::runtime::RuntimeBinding& binding, std::span searchDirs, std::span alsoProvided) { DlopenSurfaceReport report; @@ -1613,4 +1614,4 @@ DlopenSurfaceReport inspect_dlopen_surface( return report; } -} // namespace mcpp::platform::elf +} // namespace mcpp::runtime::elf diff --git a/modules/platform/src/runtime_env_contract.cppm b/src/runtime/env_contract.cppm similarity index 94% rename from modules/platform/src/runtime_env_contract.cppm rename to src/runtime/env_contract.cppm index 456e85c1..03503396 100644 --- a/modules/platform/src/runtime_env_contract.cppm +++ b/src/runtime/env_contract.cppm @@ -1,4 +1,4 @@ -// mcpp.platform.runtime_env_contract — where a runtime search directory is +// mcpp.runtime.env_contract — where a runtime search directory is // allowed to be published. // // There are two ways to tell a dynamic loader where to look, and they have @@ -41,11 +41,11 @@ // stumbles onto them is at worst confused, not killed. They keep their // existing environment scope. -export module mcpp.platform.runtime_env_contract; +export module mcpp.runtime.env_contract; import std; -export namespace mcpp::platform { +export namespace mcpp::runtime { enum class RuntimeSearchScope { Binary, // DT_RUNPATH on the objects mcpp links @@ -66,4 +66,4 @@ constexpr bool publishes_via_binary(RuntimeSearchScope scope) { return scope == RuntimeSearchScope::Binary; } -} // namespace mcpp::platform +} // namespace mcpp::runtime diff --git a/src/runtime/launch_env.cpp b/src/runtime/launch_env.cpp new file mode 100644 index 00000000..6a7225e1 --- /dev/null +++ b/src/runtime/launch_env.cpp @@ -0,0 +1,63 @@ +// mcpp.runtime.launch_env over mcpplibs.os's lists of directories (the host's +// separator, an entry quoted whole on Windows) and this invocation's +// environment. +module mcpp.runtime.launch_env; + +import std; +import mcpplibs.os.platform; +import mcpplibs.os.env; +import mcpp.invocation; + +namespace mcpp::runtime::launch_env { + +namespace os = mcpplibs::os; + +std::string path_list_separator() { return std::string(1, os::path_list_separator); } + +const std::string& inherited_path() { + static const std::string path = os::env::get("PATH").value_or(""); + return path; +} + +std::string runtime_library_path_key() { + if constexpr (os::is_windows) return "PATH"; + else if constexpr (os::is_linux) return "LD_LIBRARY_PATH"; + else return ""; +} + +std::string host_tool_runtime_library_path_key() { + if constexpr (os::is_macos) return "DYLD_LIBRARY_PATH"; + else if constexpr (os::is_linux) return "LD_LIBRARY_PATH"; + else return ""; +} + +std::string strip_private_glibc(std::string_view paths) { + std::vector kept; + for (auto& item : os::env::split_list(paths)) { + const auto text = item.generic_string(); + if (text.find("/xim-x-glibc/") == std::string::npos) kept.push_back(std::move(item)); + } + return os::env::join_list(kept); +} + +std::string prepend_path_list(std::string_view key, std::span dirs) { + if (key.empty() || dirs.empty()) return ""; + std::vector given; + for (auto& dir : dirs) if (!dir.empty()) given.push_back(dir); + std::string value = os::env::join_list(given); + if (value.empty()) return ""; + if (auto existing = mcpp::invocation::get(key)) { + // A loader path's inherited tail may carry an OUTER mcpp's private-glibc + // entry, and the composed value is given to the child as stated -- + // skipping the sanitation mcpp.run applies to inherited variables. + std::string tail = (key == "LD_LIBRARY_PATH" || key == "DYLD_LIBRARY_PATH") + ? strip_private_glibc(*existing) : *existing; + if (!tail.empty()) { + value += path_list_separator(); + value += tail; + } + } + return value; +} + +} // namespace mcpp::runtime::launch_env diff --git a/src/runtime/launch_env.cppm b/src/runtime/launch_env.cppm new file mode 100644 index 00000000..95554796 --- /dev/null +++ b/src/runtime/launch_env.cppm @@ -0,0 +1,45 @@ +// mcpp.runtime.launch_env — the loader's search path a program mcpp starts is +// given: which variable a system reads, and the list composed for it. +export module mcpp.runtime.launch_env; + +import std; + +export namespace mcpp::runtime::launch_env { + +// The separator of a list of directories on this host. +std::string path_list_separator(); + +// The PATH this invocation was started with, before anything mcpp stated: a +// value that must not depend on what was stated since -- an environment a +// build program's run is keyed on -- starts from this one. +const std::string& inherited_path(); + +// The variable the system's loader searches for a program's libraries: PATH on +// Windows, LD_LIBRARY_PATH on Linux, none on macOS (DYLD_LIBRARY_PATH reaches +// every executable ninja starts, ninja included, and can make the system's +// frameworks load an incompatible private libc++; macOS keeps toolchain rpaths). +std::string runtime_library_path_key(); +// The same for a host tool, which on macOS is DYLD_LIBRARY_PATH. +std::string host_tool_runtime_library_path_key(); + +// Drop mcpp's private-glibc payload entries from a loader search path. +// +// An outer `mcpp run`/`mcpp test` points LD_LIBRARY_PATH at the payload so ITS +// child (a sandbox-linked binary) can load. Anything further down the process +// tree that is a HOST binary — /bin/sh, or a payload tool patched against a +// DIFFERENT payload version — then resolves a mismatched libc.so.6 against the +// host ld.so and dies inside the dynamic linker before main (signature: a bare +// `__vdso_time` line, then SIGSEGV). libc and ld.so are version-locked through +// GLIBC_PRIVATE, so this never reproduces when the two happen to match. +// +// Both mcpp.run (which sanitizes the inherited variable for every child) and +// prepend_path_list (which sanitizes the inherited tail it carries into a +// composed override) use it; directories a caller passes are always kept. +std::string strip_private_glibc(std::string_view paths); + +// `dirs` before the variable's current value, in the host's spelling: the +// value a started program receives for `key`. "" when there is nothing to put. +std::string prepend_path_list(std::string_view key, + std::span dirs); + +} // namespace mcpp::runtime::launch_env diff --git a/modules/platform/src/runtime_search.cppm b/src/runtime/search.cppm similarity index 97% rename from modules/platform/src/runtime_search.cppm rename to src/runtime/search.cppm index 2ae81cbc..2a6bcf03 100644 --- a/modules/platform/src/runtime_search.cppm +++ b/src/runtime/search.cppm @@ -1,4 +1,4 @@ -// mcpp.platform.runtime_search — where a produced artifact looks for its +// mcpp.runtime.search — where a produced artifact looks for its // shared libraries at RUN time, in what order, and which of those directories // may not travel with it. // @@ -31,11 +31,11 @@ // it imports only `std`, so it can be unit-tested on every platform including // the ones where none of it applies. -export module mcpp.platform.runtime_search; +export module mcpp.runtime.search; import std; -export namespace mcpp::platform::search { +export namespace mcpp::runtime::search { // Where a runtime search directory came from. // @@ -203,4 +203,4 @@ inline constexpr std::string_view kLinkerPathInjectionOptOut = "XLINGS_SUBOS_LD_PATHS"; inline constexpr std::string_view kLinkerPathInjectionOptOutValue = "0"; -} // namespace mcpp::platform::search +} // namespace mcpp::runtime::search diff --git a/src/scaffold/create.cppm b/src/scaffold/create.cppm index c47a76a4..33dde9a0 100644 --- a/src/scaffold/create.cppm +++ b/src/scaffold/create.cppm @@ -15,7 +15,7 @@ import mcpp.manifest; import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; import mcpp.pm.index_route; -import mcpp.platform.axis; +import mcpp.toolchain.platform; import mcpp.pm.resolver; import mcpp.scaffold; import mcpp.scaffold.project_name; @@ -102,14 +102,14 @@ fetch_template_package(const mcpp::scaffold::TemplateSpec& spec) { : std::string{"*"}; auto resolved = mcpp::pm::resolve_semver( ns, shortName, constraint, registryOnly, - mcpp::platform::HostPlatform::current()); + mcpp::toolchain::platform::HostPlatform::current()); if (!resolved) return std::unexpected(resolved.error()); std::string version = std::move(*resolved); std::string payloadDigest; bool resolvedAlias = false; for (auto const& entry : mcpp::manifest::list_xpkg_version_entries( - lua, mcpp::platform::HostPlatform::current())) { + lua, mcpp::toolchain::platform::HostPlatform::current())) { if (entry.version == version) { payloadDigest = entry.sha256; resolvedAlias = entry.alias; diff --git a/src/scaffold/project_name.cppm b/src/scaffold/project_name.cppm index d548a819..0e1726ca 100644 --- a/src/scaffold/project_name.cppm +++ b/src/scaffold/project_name.cppm @@ -3,11 +3,37 @@ export module mcpp.scaffold.project_name; import std; -import mcpp.platform.project_name; import mcpp.pm.dependency_selector; export namespace mcpp::scaffold { +// A name Windows reserves for a device, whatever the host: a project created +// on Linux under it cannot be checked out on Windows. +bool is_windows_reserved_project_name(std::string_view name) { + // Windows reserves the device basename even when an extension is present + // (`CON.txt`, `LPT1.log`). Comparison is ASCII case-insensitive. + auto dot = name.find('.'); + auto base = name.substr(0, dot); + std::string upper; + upper.reserve(base.size()); + for (unsigned char ch : base) { + upper.push_back(ch >= 'a' && ch <= 'z' + ? static_cast(ch - 'a' + 'A') + : static_cast(ch)); + } + if (upper == "CON" || upper == "PRN" || upper == "AUX" + || upper == "NUL") { + return true; + } + if (upper.size() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && upper[3] >= '1' && upper[3] <= '9') { + return true; + } + return false; +} + + struct PortableProjectName { std::string directoryName; std::string namespace_; @@ -65,7 +91,7 @@ validate_project_name(std::string_view spelling) { "project name '{}' must be one relative directory component", spelling)); } - if (mcpp::platform::is_windows_reserved_project_name(spelling)) { + if (is_windows_reserved_project_name(spelling)) { return fail(std::format( "project name '{}' is a reserved Windows device name", spelling)); } diff --git a/src/scaffold/template.cppm b/src/scaffold/template.cppm index a6f121f0..967e1891 100644 --- a/src/scaffold/template.cppm +++ b/src/scaffold/template.cppm @@ -17,8 +17,8 @@ export module mcpp.scaffold; import std; import mcpp.libs.toml; import mcpp.manifest; -import mcpp.platform.scaffold_fs; import mcpp.pm.dependency_selector; +import mcpplibs.os.fs; export namespace mcpp::scaffold { @@ -443,19 +443,16 @@ public: std::expected commit() { if (committed_) return {}; - if (auto synced = mcpp::platform::sync_directory(stagingPath_); - !synced) { - return synced; - } - auto renamed = mcpp::platform::atomic_rename_directory_no_replace( - stagingPath_, finalPath_); - if (!renamed) return renamed; + if (auto synced = mcpplibs::os::fs::sync(stagingPath_); !synced) + return std::unexpected(synced.error().message()); + if (auto placed = mcpplibs::os::fs::place(stagingPath_, finalPath_); !placed) + return std::unexpected(placed.error().message()); committed_ = true; // Once the no-replace rename commits, the operation has succeeded. // Persist its parent entry where supported; an exotic filesystem that // refuses this post-commit sync must not turn a visible project into a // reported failure/partial-output contradiction. - (void)mcpp::platform::sync_directory(finalPath_.parent_path()); + (void)mcpplibs::os::fs::sync(finalPath_.parent_path()); return {}; } }; @@ -478,7 +475,9 @@ write_text_file(const std::filesystem::path& path, std::string_view content) { return std::unexpected(std::format( "close '{}' failed", path.string())); } - return mcpp::platform::sync_regular_file(path); + if (auto synced = mcpplibs::os::fs::sync(path); !synced) + return std::unexpected(synced.error().message()); + return {}; } // Instantiate templateDir into destDir: `.in` files are rendered (suffix @@ -576,8 +575,8 @@ instantiate(const std::filesystem::path& templateDir, return std::unexpected(std::format( "copy '{}' failed: {}", rel.string(), ec.message())); } - if (auto synced = mcpp::platform::sync_regular_file(dest); !synced) - return synced; + if (auto synced = mcpplibs::os::fs::sync(dest); !synced) + return std::unexpected(synced.error().message()); } iterator.increment(ec); if (ec) { diff --git a/src/toolchain/base_probe.cppm b/src/toolchain/base_probe.cppm index fbbf01e5..e21ceb64 100644 --- a/src/toolchain/base_probe.cppm +++ b/src/toolchain/base_probe.cppm @@ -11,13 +11,15 @@ export module mcpp.toolchain.base_probe; import std; -import mcpp.platform; -import mcpp.platform.macos; +import mcpplibs.os.platform; +import mcpp.run; import mcpp.toolchain.base; import mcpp.toolchain.linkmodel; import mcpp.toolchain.model; import mcpp.toolchain.triple; import mcpp.runtime.elf; +import mcpp.toolchain.apple_sdk; +import mcpp.invocation; export namespace mcpp::toolchain::base { @@ -68,7 +70,7 @@ inline std::string trim(std::string s) { inline fs::path print_file_name(const std::vector& base, std::string_view name) { auto argv = base; argv.push_back(std::format("-print-file-name={}", name)); - auto r = mcpp::platform::process::capture_stdout(argv); + auto r = mcpp::run::capture_stdout(argv); if (r.exit_code != 0) return {}; fs::path p(trim(r.output)); std::error_code ec; @@ -149,7 +151,7 @@ inline GnuMeasurement measure_gnu(const std::vector& driverBase, auto pp = base; for (auto const& e : extra) pp.push_back(e); pp.insert(pp.end(), {"-E", "-dM", "-x", "c", probe.string()}); - auto r = mcpp::platform::process::capture_stdout(pp); + auto r = mcpp::run::capture_stdout(pp); if (r.exit_code == 0) m.fact = classify_macros(parse_defines(r.output)); if (!m.fact && m.cAbiFound) { // musl defines nothing that names it; a musl loader or a `libc.a` @@ -221,7 +223,7 @@ inline RuntimeFloor compiler_runtime_glibc_floor(const std::vector& std::error_code ec; auto real = fs::canonical(p, ec); if (ec) continue; - auto facts = mcpp::platform::elf::inspect_elf_runtime(real); + auto facts = mcpp::runtime::elf::inspect_elf_runtime(real); if (!facts) continue; auto v = highest_glibc(facts->requiredGlibcVersions); if (!v.empty() && (best.version.empty() || compare_versions(v, best.version) > 0)) @@ -237,7 +239,7 @@ inline RuntimeFloor compiler_runtime_glibc_floor(const std::vector& // developer directory only exist on an Apple one (inside `sdk_path`). inline std::expected locate_apple(const Statement& s, std::string_view name) { - namespace mac = mcpp::platform::macos; + namespace mac = mcpp::toolchain::apple; AppleSdk sdk; sdk.name = std::string(name); std::error_code ec; @@ -252,10 +254,10 @@ locate_apple(const Statement& s, std::string_view name) { } else { auto found = mac::sdk_path(name); if (!found) { - const char* env = std::getenv("SDKROOT"); + const auto env = mcpp::invocation::get("SDKROOT"); return std::unexpected(std::format( - "looked in: SDKROOT ({}){}", env && *env ? env : "unset", - mcpp::platform::is_macos ? ", xcrun, xcode-select and the standard locations" : "")); + "looked in: SDKROOT ({}){}", env.value_or("unset"), + mcpplibs::os::is_macos ? ", xcrun, xcode-select and the standard locations" : "")); } sdk.root = *found; } diff --git a/src/toolchain/cenv_probe.cppm b/src/toolchain/cenv_probe.cppm index a9fa0977..cbf26b1e 100644 --- a/src/toolchain/cenv_probe.cppm +++ b/src/toolchain/cenv_probe.cppm @@ -30,7 +30,7 @@ export module mcpp.toolchain.cenv_probe; import std; import mcpp.home; -import mcpp.platform; +import mcpp.run; import mcpp.toolchain.fingerprint; // hash_string — the same 64-bit FNV the std cache keys with export namespace mcpp::toolchain::cenv_probe { @@ -238,7 +238,7 @@ std::expected verify( std::vector cmd{ compilerBin.string() }; cmd.insert(cmd.end(), argv.begin(), argv.end()); cmd.insert(cmd.end(), { "-x", "c++", "-E", "-dM", "-" }); - auto r = mcpp::platform::process::capture_stdout(cmd); + auto r = mcpp::run::capture_stdout(cmd); if (r.exit_code != 0) return std::unexpected(std::format( "the [c-abi] verification probe could not be compiled " diff --git a/src/toolchain/clang.cpp b/src/toolchain/clang.cpp new file mode 100644 index 00000000..04b556ce --- /dev/null +++ b/src/toolchain/clang.cpp @@ -0,0 +1,557 @@ +// mcpp.toolchain.clang — the implementation, apart from the interface so that its +// instantiations are not carried by every module that imports it. +module mcpp.toolchain.clang; + +import std; +import mcpp.toolchain.model; +import mcpp.toolchain.msvc; +import mcpp.toolchain.probe; +import mcpp.provider; +import mcpp.provider.select; +import mcpplibs.os.platform; +import mcpp.build.command_line; +import mcpp.run; + +namespace mcpp::toolchain::clang { + +namespace { + +std::optional +json_string_value_after(std::string_view body, std::size_t start, std::string_view key) { + auto keyToken = std::string{"\""} + std::string(key) + "\""; + auto keyPos = body.find(keyToken, start); + if (keyPos == std::string_view::npos) return std::nullopt; + + auto colon = body.find(':', keyPos + keyToken.size()); + if (colon == std::string_view::npos) return std::nullopt; + + auto quote = body.find('"', colon + 1); + if (quote == std::string_view::npos) return std::nullopt; + + std::string out; + for (std::size_t i = quote + 1; i < body.size(); ++i) { + char c = body[i]; + if (c == '"') return out; + if (c == '\\' && i + 1 < body.size()) { + out.push_back(body[++i]); + } else { + out.push_back(c); + } + } + return std::nullopt; +} + +} // namespace + +bool matches_version_output(std::string_view firstLineLower, + std::string_view fullOutputLower) { + return firstLineLower.find("clang version") != std::string::npos + || firstLineLower.find("apple clang version") != std::string::npos + || fullOutputLower.find("clang") != std::string::npos; +} + +std::optional find_libcxx_std_module_source( + const std::filesystem::path& cxx_binary, + const mcpp::run::Env& env) +{ + auto manifest_r = mcpp::toolchain::run_capture( + { cxx_binary.string(), "-print-library-module-manifest-path" }, env); + if (manifest_r) { + auto manifestPath = std::filesystem::path( + mcpp::toolchain::trim_line(*manifest_r)); + if (!manifestPath.empty() && std::filesystem::exists(manifestPath)) { + std::ifstream is(manifestPath); + std::stringstream ss; + ss << is.rdbuf(); + auto body = ss.str(); + + std::size_t cursor = 0; + while (true) { + auto logical = body.find("\"logical-name\"", cursor); + if (logical == std::string::npos) break; + auto name = json_string_value_after(body, logical, "logical-name"); + if (name && *name == "std") { + auto src = json_string_value_after(body, logical, "source-path"); + if (src) { + std::filesystem::path p = *src; + if (p.is_relative()) + p = manifestPath.parent_path() / p; + std::error_code ec; + auto canon = std::filesystem::weakly_canonical(p, ec); + if (!ec) p = canon; + if (std::filesystem::exists(p)) return p; + } + } + cursor = logical + 1; + } + } + } + + // SECOND PROBE: ASK WHICH libc++ WILL BE LINKED, THEN LOOK BESIDE IT. + // + // `-print-library-module-manifest-path` is the primary answer and the one + // llvm's own payload gives. `em++` does not forward it -- it answers + // `em++: error: no input files` -- so a toolchain that ships the surface + // was reported as shipping none, and `import std` was refused on a target + // where it demonstrably works. + // + // `--print-file-name=libc++.a` IS forwarded, and what it names is exactly + // the library the link will use, so the surface beside it is the surface + // that matches. Measured 2026-09-11: + // + // xim:llvm 22.1.8 /bin/../lib/x86_64-unknown-linux-gnu/libc++.a + // surface at /bin/../share/libc++/v1/ (3 up) + // xim:emsdk 6.0.9 /emscripten/cache/sysroot/lib/wasm32-emscripten/libc++.a + // surface at /.../sysroot/share/libc++/v1/ (2 up) + // + // THE TWO DEPTHS ARE WHY THIS WALKS RATHER THAN INDEXES. A fixed `up 3` + // would have been written against llvm, passed, and then been a guessed + // layout for the next payload -- which is the mistake the line this + // replaces made with `bin/../share`. + if (auto lib_r = mcpp::toolchain::run_capture( + { cxx_binary.string(), "--print-file-name=libc++.a" }, env)) { + std::filesystem::path lib(mcpp::toolchain::trim_line(*lib_r)); + // A driver that cannot place the library echoes the bare name back. + if (lib.has_parent_path()) { + std::error_code ec; + auto dir = std::filesystem::weakly_canonical(lib.parent_path(), ec); + if (ec) dir = lib.parent_path(); + for (int up = 0; up < 4 && !dir.empty(); ++up) { + auto cand = dir / "share" / "libc++" / "v1" / "std.cppm"; + if (std::filesystem::exists(cand)) return cand; + if (!dir.has_relative_path()) break; + dir = dir.parent_path(); + } + } + } + + // THIRD: the layout guess, kept for a driver that answers neither probe. + auto root = cxx_binary.parent_path().parent_path(); + auto fallback = root / "share" / "libc++" / "v1" / "std.cppm"; + if (std::filesystem::exists(fallback)) return fallback; + return std::nullopt; +} + +void enrich_toolchain(Toolchain& tc, const mcpp::run::Env& env) { + // Clang targeting MSVC uses MSVC STL, not libc++. + bool msvTarget = is_msvc_target(tc); + tc.stdlibId = msvTarget ? "msvc-stl" : "libc++"; + tc.stdlibVersion = tc.version.empty() ? "unknown" : tc.version; + tc.linkRuntimeDirs = mcpp::toolchain::discover_link_runtime_dirs( + tc.binaryPath, tc.targetTriple); + + if (auto p = find_libcxx_std_module_source(tc.binaryPath, env)) { + tc.set_std_modules(*p, find_libcxx_std_compat_source(tc.binaryPath, env) + .value_or(std::filesystem::path{})); + // libc++ documents the std module for C++20 and later, and its + // std.cppm carries no __cplusplus guard. Verified on clang 22.1.8 + + // libc++ (std and std.compat) at -std=c++20 (design §2.2). + tc.importStdMinLevel = 20; + } + + if constexpr (mcpplibs::os::is_windows) { + // Fallback: if libc++ std.cppm not found, look for MSVC STL's std.ixx. + // This is the machine's default toolset by the one selection both rows + // use (msvc::select_system_toolset, selector `system`). A build replaces it + // with the toolset its row names (`[target.].sysroot`, see + // prepare's bind_msvc_sysroot); this answer serves what only detects, such + // as `mcpp toolchain list`. + if (!tc.hasImportStd && msvTarget) { + if (auto p = mcpp::toolchain::msvc::find_std_module_source()) { + // The STL's own pair: `std.compat.ixx` beside `std.ixx`. + std::error_code ec; + const auto compat = p->parent_path() / "std.compat.ixx"; + tc.set_std_modules(*p, std::filesystem::exists(compat, ec) + ? compat : std::filesystem::path{}); + // This is MSVC STL's std.ixx, so the STL's own C++20 policy + // applies rather than libc++'s. `tc.version` is clang's and cannot + // answer it -- which is a reason to change the input, not to assume + // the worst. The toolset version is in the path of the file just + // selected, and that is the STL that will be compiled. + tc.importStdMinLevel = + mcpp::toolchain::msvc::std_module_min_level_for_stl(*p); + } + } + } +} + +std::filesystem::path std_bmi_path(const std::filesystem::path& cacheDir) { + return cacheDir / "pcm.cache" / "std.pcm"; +} + +std::filesystem::path staged_std_bmi_path(const std::filesystem::path& outputDir) { + return outputDir / "pcm.cache" / "std.pcm"; +} + +// THE LANGUAGE OF A STANDARD LIBRARY MODULE SOURCE, FROM ITS NAME, ON EVERY HOST. +// +// MSVC STL names its module sources `.ixx`, which clang does not infer as a +// module interface: given one without `-x c++-module`, `--precompile` treats +// it as linker input, writes nothing and exits 0. It also includes headers in +// the module purview, which clang reports. One answer for `std` and +// `std.compat`, decided by the file and not by the machine running the build; +// a libc++ `.cppm` receives nothing, so its commands are unchanged. +// The language of a `std` module source clang does not know by its name. +// `.cppm` (libc++'s) and the other module-interface extensions are +// recognised; the MSVC STL's `.ixx` and libstdc++'s `bits/std.cc` are not -- +// the second precompiled as an ordinary translation unit, and the object step +// then answered `unable to load precompiled file` (design 2026-10-26 §6.4: a +// graph-supplied libstdc++ under clang). The `.ixx` spelling is unchanged, so +// the std module caches built from it keep their identity. +static std::string module_source_language_flags(const std::filesystem::path& source) { + const auto ext = source.extension().string(); + if (ext == ".ixx") return " -x c++-module -Wno-include-angled-in-module-purview"; + if (ext == ".cppm" || ext == ".ccm" || ext == ".cxxm" || ext == ".c++m") return ""; + return " -x c++-module"; +} + +std::vector std_module_build_commands(const Toolchain& tc, + const std::filesystem::path& cacheDir, + const std::filesystem::path& bmiPath, + std::string_view sysrootFlag, + std::string_view cppStandardFlag, + std::string_view crtFlag) { + auto relBmi = std::filesystem::relative(bmiPath, cacheDir).string(); + // #718: rendered as its own token, with a leading space, so an empty + // `crtFlag` (every non-MSVC-ABI row) leaves both commands byte-identical + // to what they were before this parameter existed. + const std::string crtToken = + crtFlag.empty() ? std::string{} : std::format(" {}", crtFlag); + // A PACKAGE-PROVIDED std MODULE REPLACES THE TOOLCHAIN'S SYSROOT FLAGS + // RATHER THAN BEING APPENDED TO THEM. + // + // Those flags describe the standard library the COMPILER ships and the C + // library the HOST has, and they lead with `-isystem' — so appending to them + // puts the host's headers ahead of the package's, and no later flag can + // undo it. Measured: the module then compiles the host C library's + // and stops on names that library expects the host compiler to + // have supplied. + // + // The triple has to be restated for the same reason: it was in the flags + // being replaced, and without it the module is built for whatever machine + // is doing the building. + // The replacement is complete: whoever set stdModuleFlags stated the target + // as well, because the triple was in the string being replaced and a module + // built without it is built for whatever machine is doing the building. + if (!tc.stdModuleFlags.empty()) sysrootFlag = {}; + const std::string& extraFlags = tc.stdModuleFlags; + // The codegen step compiles a BMI, which already carries what the + // headers contributed; only the machine has to be restated. See + // Toolchain::stdModuleTargetFlags. + const std::string& codegenFlags = tc.stdModuleTargetFlags; + // AND THE PRECOMPILE NEEDS THE MACHINE TOO, FROM WHICHEVER SOURCE HAS IT. + // + // `stdModuleTargetFlags` reached only the CODEGEN command, on the reading + // that the first step needs headers and the second needs the machine. The + // first step needs both: a `--precompile` that does not say which target + // resolves the standard library's own `#include <__config>` against the + // BUILDING machine. + // + // It was invisible while exactly two kinds of toolchain existed. A payload + // whose compiler IS its target needs no flag, and a PACKAGE-provided module + // carries the target inside `stdModuleFlags` -- which is why the comment + // above insists that whoever sets that string states the target as well. A + // payload whose compiler serves several targets is a third kind, and it has + // neither: the NDK's one clang++ compiles for both Android arches and is + // told which by `--target` alone. Measured on `aarch64-linux-android`: + // + // clang++ -std=c++23 -Wno-reserved-module-identifier \ + // --precompile .../share/libc++/v1/std.cppm -o pcm.cache/std.pcm + // std.cppm:16:10: fatal error: '__config' file not found + // + // Five tokens, and the same error text this file already records from a + // Windows host in 2026-08 -- same cause, reached by a different route. + // + // The two sources are never both needed: `stdModuleFlags` is a SUPERSET of + // `stdModuleTargetFlags` when it is set at all (the producer builds the + // machine part first and appends the include part), so taking it in + // preference keeps `--target` off the command line twice. + const std::string& precompileFlags = + extraFlags.empty() ? codegenFlags : extraFlags; + if constexpr (mcpplibs::os::is_windows) { + // Windows: use absolute paths, raw binary path as first token + // (cmd.exe strips leading quotes), shq for args with spaces. + // -x c++-module is needed for MSVC STL's .ixx files (Clang doesn't + // recognize the .ixx extension as a module source by default). + auto absBmi = (cacheDir / relBmi).string(); + std::string ixxFlags = module_source_language_flags(tc.stdModuleSource); + // AND THE RESERVED-NAME WARNING UNCONDITIONALLY, WHICH IS WHAT THE OTHER + // BRANCH DOES. + // + // `export module std;` is a reserved identifier and every standard library + // that ships one triggers the warning; the non-Windows command has carried + // the suppression since it was written. This branch tied it to `.ixx`, + // which was correct while the only `std` module a Windows host ever saw was + // the MSVC STL's — and stopped being correct when a package could supply + // its own. Measured 2026-08-23, a Windows host building the openkal + // runtime's `llvm-generated/std.cppm`: + // + // std.cppm:167:15: warning: 'std' is a reserved name for a module + // [-Wreserved-module-identifier] + // + // A warning that is correct, unavoidable, and printed on every build is + // noise of the kind that hides the next one. + ixxFlags += " -Wno-reserved-module-identifier"; + // `extraFlags` IS ON BOTH COMMANDS HERE, AND IT WAS ON NEITHER. + // + // This branch was written when a Windows host built for itself against the + // MSVC STL, and `stdModuleFlags` did not exist — so the omission was not + // visible: there was nothing to omit. It became a defect when a package + // could supply its own `std` module, because that string is where the + // package's own headers, `-nostdinc` and the target triple live. + // + // Measured 2026-08-23, a Windows host cross-building for + // `x86_64-linux-gnu` over openkal — the command it produced carried FIVE + // tokens: + // + // clang++.exe -std=c++23 --precompile "…/std.cppm" -o "…/std.pcm" + // …/llvm-generated/std.cppm:16:10: fatal error: '__config' file not found + // + // The same build from a Linux host had `--target=`, `--no-default-config`, + // `-nostdinc`, `-nostdinc++` and eight `-I`s. The error names a header, and + // the cause is a branch keyed on which machine is doing the building. + return { + std::format( + "{} {}{}{}{}{} " + "--precompile {} -o {}", + tc.binaryPath.string(), + cppStandardFlag, + crtToken, + ixxFlags, + sysrootFlag, + precompileFlags, + mcpp::build::command_line::quote(tc.stdModuleSource.string()), + mcpp::build::command_line::quote(absBmi)), + std::format( + "{} {}{}{}{} " + "{} -c -o {}", + tc.binaryPath.string(), + cppStandardFlag, + crtToken, + sysrootFlag, + codegenFlags, + mcpp::build::command_line::quote(absBmi), + mcpp::build::command_line::quote((cacheDir / "std.o").string())) + }; + } else { + return { + std::format( + "cd {} && {}{} {}{} -Wno-reserved-module-identifier{}{}{} " + "--precompile {} -o {} 2>&1", + mcpp::build::command_line::quote(cacheDir.string()), + mcpp::toolchain::compiler_env_prefix(tc), + mcpp::build::command_line::quote(tc.binaryPath.string()), + cppStandardFlag, + crtToken, + sysrootFlag, + precompileFlags, + module_source_language_flags(tc.stdModuleSource), + mcpp::build::command_line::quote(tc.stdModuleSource.string()), + mcpp::build::command_line::quote(relBmi)), + std::format( + "cd {} && {}{} {}{} -Wno-reserved-module-identifier{}{} " + "{} -c -o std.o 2>&1", + mcpp::build::command_line::quote(cacheDir.string()), + mcpp::toolchain::compiler_env_prefix(tc), + mcpp::build::command_line::quote(tc.binaryPath.string()), + cppStandardFlag, + crtToken, + sysrootFlag, + codegenFlags, + mcpp::build::command_line::quote(relBmi)) + }; + } +} + +std::optional find_scan_deps(const Toolchain& tc) { + auto p = tc.binaryPath.parent_path() / + (std::string("clang-scan-deps") + std::string(mcpplibs::os::exe_suffix)); + if (std::filesystem::exists(p)) return p; + return std::nullopt; +} + +// THE DRIVER KNOWS WHERE ITS ARCHIVES ARE FOR A TARGET, AND A DIRECTORY +// SEARCH ONLY KNOWS ONE LAYOUT. +// +// The self-contained contract used to look for `libc++.a` under the LLVM +// payload's `lib/` alone. The Android NDK keeps its archives in the sysroot, +// per API level, so the search found nothing, the contract degraded to +// toolchain-coupled, and every test program on an Android row needed +// `libc++_shared.so`, which a device does not have (#634 A6, measured on an +// API 34 emulator: `CANNOT LINK EXECUTABLE ... library "libc++_shared.so" not +// found` for every test). The drivers answer directly, measured 2026-09-14: +// +// xim:android-ndk 30 --target=x86_64-linux-android24 +// libc++.a /usr/lib/x86_64-linux-android/24/libc++.a +// (a linker script: INPUT(-lc++_static -lc++abi)) +// libc++abi.a /usr/lib/x86_64-linux-android/libc++abi.a +// libunwind.a /lib/clang/21/lib/linux/x86_64/libunwind.a +// xim:llvm 22.1.8 libc++.a /bin/../lib/x86_64-unknown-linux-gnu/libc++.a +// +// THE TARGET IN THE QUESTION IS THE ONE THE LINK NAMES. The same NDK asked +// with `--target=x86_64-linux-android`, no API level, answers +// `/lib/clang/21/../../libc++.a`, a symlink to the HOST's +// `x86_64-unknown-linux-gnu/libc++.a`. The caller passes the flags its link +// line carries, API level included. +// +// A driver that cannot place a file echoes the bare name back, which is a +// miss rather than a relative path. The answer is made lexically normal: the +// `bin/..` in it is a spelling of the driver's, and a link line that spells +// one file two ways is two commands to ninja. +std::filesystem::path runtime_archive_path(const Toolchain& tc, + const std::vector& driverFlags, + std::string_view archive) { + static std::mutex mu; + static std::map answered; + std::vector argv{ tc.binaryPath.string() }; + argv.insert(argv.end(), driverFlags.begin(), driverFlags.end()); + argv.push_back(std::format("--print-file-name={}", archive)); + std::string key; + for (auto const& a : argv) key += a + "\n"; + { + std::scoped_lock lock(mu); + if (auto it = answered.find(key); it != answered.end()) return it->second; + } + std::filesystem::path out; + if (auto r = mcpp::toolchain::run_capture(argv, mcpp::toolchain::compiler_env(tc))) { + std::filesystem::path lib(mcpp::toolchain::trim_line(*r)); + std::error_code ec; + if (lib.has_parent_path() && lib.is_absolute() + && std::filesystem::is_regular_file(lib, ec)) + out = lib.lexically_normal(); + } + std::scoped_lock lock(mu); + answered.emplace(std::move(key), out); + return out; +} + +std::optional find_libcxx_std_compat_source( + const std::filesystem::path& cxx_binary, + const mcpp::run::Env& env) +{ + // DERIVED FROM THE SIBLING, NOT SEARCHED FOR SEPARATELY. + // + // The comment here used to say "same search strategy as + // find_libcxx_std_module_source", and it was not: that function has three + // probes and this one had the last of them, so on any payload the layout + // guess does not reach -- emsdk, for one -- `std` was found and + // `std.compat` was not, from one directory. + // + // `std.compat.cppm` sits beside `std.cppm` in every libc++ layout, because + // the same install rule places both. Deriving it makes the two answers + // structurally consistent rather than two searches that can disagree, + // which is what the comment claimed all along. + if (auto std_src = find_libcxx_std_module_source(cxx_binary, env)) { + auto p = std_src->parent_path() / "std.compat.cppm"; + if (std::filesystem::exists(p)) return p; + } + return std::nullopt; +} + +std::filesystem::path std_compat_bmi_path(const std::filesystem::path& cacheDir) { + return cacheDir / "pcm.cache" / "std.compat.pcm"; +} + +std::filesystem::path staged_std_compat_bmi_path(const std::filesystem::path& outputDir) { + return outputDir / "pcm.cache" / "std.compat.pcm"; +} + +std::vector std_compat_build_commands(const Toolchain& tc, + const std::filesystem::path& cacheDir, + const std::filesystem::path& bmiPath, + const std::filesystem::path& stdBmiPath, + std::string_view sysrootFlag, + std::string_view cppStandardFlag, + std::string_view crtFlag) +{ + auto relBmi = std::filesystem::relative(bmiPath, cacheDir).string(); + auto relStdBmi = std::filesystem::relative(stdBmiPath, cacheDir).string(); + // #718: see `std_module_build_commands` — empty keeps both commands + // byte-identical off the MSVC ABI. + const std::string crtToken = + crtFlag.empty() ? std::string{} : std::format(" {}", crtFlag); + // THE SAME REPLACEMENT THE `std` BUILDER MAKES, FOR THE SAME REASON. + // + // `std.compat` is a second module over the SAME library, and it therefore + // needs the same headers, the same target and the same configuration. This + // used to take `sysrootFlag` unconditionally while its sibling above + // replaced it — so a package-provided pair had one module built against its + // own libc++ and the other against the toolchain's. + // + // It does not fail where the two are chosen. Measured on a macOS cross: + // + // error: std module precompile failed (rc=1): + // …/openkal-llvm-runtime/llvm-generated/std.compat.cppm:16 + // …/xim-x-llvm/22.1.8/include/c++/v1/__config:13 + // fatal error: '__config_site' file not found + // + // The SOURCE named is the package's; the header it opened is the + // toolchain's, whose per-installation configuration was never generated for + // this target. Reading that message, the mixture is invisible. + if (!tc.stdModuleFlags.empty()) sysrootFlag = {}; + const std::string& extraFlags = tc.stdModuleFlags; + // Same split as the `std` builder above: the second command compiles a BMI + // and needs the machine restated, not the include paths. + const std::string& codegenFlags = tc.stdModuleTargetFlags; + // Same third kind of toolchain as the `std` builder above, same reason. + const std::string& precompileFlags = + extraFlags.empty() ? codegenFlags : extraFlags; + // std.compat depends on std, so we need -fmodule-file=std= + // Note: the path after = must NOT be shell-quoted separately; the + // entire -fmodule-file flag is a single token to the compiler. + // + // ABSOLUTE PATHS AND NO `cd`, AND ONE FORM RATHER THAN TWO. + // + // This used to be `cd && … pcm.cache/std.pcm …`. `cd X && …` + // DOES NOT CHANGE THE DRIVE in cmd.exe — the build cache lives under the + // user's profile and a checkout lives wherever the runner put it, so on CI + // those are `C:` and `D:`. The `cd` succeeds, the drive stays where it was, + // and every relative path resolves against the wrong root. Measured + // 2026-08-23, a Windows host cross-building for `x86_64-linux-gnu`: + // + // std.compat.cppm:84:8: fatal error: module file 'pcm.cache\std.pcm' + // not found: module file not found + // + // — and `std.pcm` had been built successfully one command earlier. + // + // The obvious repair was a `#if defined(_WIN32)` branch, which is what + // the `std` builder above has. It was written and then withdrawn: a branch + // that only compiles on one platform is a branch this machine cannot check, + // and every defect this session found in the host dimension had exactly + // that shape — code shaped by which machine was doing the building. Naming + // absolute paths is correct everywhere, so there is one form. + auto absBmi = (cacheDir / relBmi).string(); + auto absStdBmi = (cacheDir / relStdBmi).string(); + auto absObj = (cacheDir / "std.compat.o").string(); + // Applied when parsing the source, never when compiling the BMI. + std::string ixxFlags = module_source_language_flags(tc.stdCompatSource); + return { + std::format("{}{} {}{} -Wno-reserved-module-identifier{}{}{} " + "-fmodule-file=std={} " + "--precompile {} -o {} 2>&1", + mcpp::toolchain::compiler_env_prefix(tc), + mcpp::build::command_line::quote(tc.binaryPath.string()), + cppStandardFlag, + crtToken, + sysrootFlag, + precompileFlags, + ixxFlags, + absStdBmi, + mcpp::build::command_line::quote(tc.stdCompatSource.string()), + mcpp::build::command_line::quote(absBmi)), + std::format("{}{} {}{} -Wno-reserved-module-identifier{}{} " + "-fmodule-file=std={} " + "{} -c -o {} 2>&1", + mcpp::toolchain::compiler_env_prefix(tc), + mcpp::build::command_line::quote(tc.binaryPath.string()), + cppStandardFlag, + crtToken, + sysrootFlag, + codegenFlags, + absStdBmi, + mcpp::build::command_line::quote(absBmi), + mcpp::build::command_line::quote(absObj)) + }; +} + +} // namespace mcpp::toolchain::clang diff --git a/src/toolchain/clang.cppm b/src/toolchain/clang.cppm index 9c39ef79..a68b994d 100644 --- a/src/toolchain/clang.cppm +++ b/src/toolchain/clang.cppm @@ -4,11 +4,7 @@ export module mcpp.toolchain.clang; import std; import mcpp.toolchain.model; -import mcpp.toolchain.msvc; -import mcpp.toolchain.probe; -import mcpp.provider; -import mcpp.provider.select; -import mcpp.platform; +import mcpp.run; export namespace mcpp::toolchain::clang { @@ -17,9 +13,9 @@ bool matches_version_output(std::string_view firstLineLower, std::optional find_libcxx_std_module_source( const std::filesystem::path& cxx_binary, - const std::string& envPrefix); + const mcpp::run::Env& env); -void enrich_toolchain(Toolchain& tc, const std::string& envPrefix); +void enrich_toolchain(Toolchain& tc, const mcpp::run::Env& env); std::filesystem::path std_bmi_path(const std::filesystem::path& cacheDir); std::filesystem::path staged_std_bmi_path(const std::filesystem::path& outputDir); @@ -38,7 +34,7 @@ std::vector std_module_build_commands(const Toolchain& tc, std::optional find_libcxx_std_compat_source( const std::filesystem::path& cxx_binary, - const std::string& envPrefix); + const mcpp::run::Env& env); std::filesystem::path std_compat_bmi_path(const std::filesystem::path& cacheDir); std::filesystem::path staged_std_compat_bmi_path(const std::filesystem::path& outputDir); @@ -60,556 +56,7 @@ std::optional find_scan_deps(const Toolchain& tc); // Empty when the driver cannot place it. Asked once per process for each // (compiler, flags, archive) triple. std::filesystem::path runtime_archive_path(const Toolchain& tc, - std::string_view driverFlags, + const std::vector& driverFlags, std::string_view archive); } // namespace mcpp::toolchain::clang - -namespace mcpp::toolchain::clang { - -namespace { - -std::optional -json_string_value_after(std::string_view body, std::size_t start, std::string_view key) { - auto keyToken = std::string{"\""} + std::string(key) + "\""; - auto keyPos = body.find(keyToken, start); - if (keyPos == std::string_view::npos) return std::nullopt; - - auto colon = body.find(':', keyPos + keyToken.size()); - if (colon == std::string_view::npos) return std::nullopt; - - auto quote = body.find('"', colon + 1); - if (quote == std::string_view::npos) return std::nullopt; - - std::string out; - for (std::size_t i = quote + 1; i < body.size(); ++i) { - char c = body[i]; - if (c == '"') return out; - if (c == '\\' && i + 1 < body.size()) { - out.push_back(body[++i]); - } else { - out.push_back(c); - } - } - return std::nullopt; -} - -} // namespace - -bool matches_version_output(std::string_view firstLineLower, - std::string_view fullOutputLower) { - return firstLineLower.find("clang version") != std::string::npos - || firstLineLower.find("apple clang version") != std::string::npos - || fullOutputLower.find("clang") != std::string::npos; -} - -std::optional find_libcxx_std_module_source( - const std::filesystem::path& cxx_binary, - const std::string& envPrefix) -{ - auto manifest_r = mcpp::toolchain::run_capture(std::format( - "{}{} -print-library-module-manifest-path {}", - envPrefix, - mcpp::platform::shell::quote(cxx_binary.string()), - mcpp::platform::null_redirect)); - if (manifest_r) { - auto manifestPath = std::filesystem::path( - mcpp::toolchain::trim_line(*manifest_r)); - if (!manifestPath.empty() && std::filesystem::exists(manifestPath)) { - std::ifstream is(manifestPath); - std::stringstream ss; - ss << is.rdbuf(); - auto body = ss.str(); - - std::size_t cursor = 0; - while (true) { - auto logical = body.find("\"logical-name\"", cursor); - if (logical == std::string::npos) break; - auto name = json_string_value_after(body, logical, "logical-name"); - if (name && *name == "std") { - auto src = json_string_value_after(body, logical, "source-path"); - if (src) { - std::filesystem::path p = *src; - if (p.is_relative()) - p = manifestPath.parent_path() / p; - std::error_code ec; - auto canon = std::filesystem::weakly_canonical(p, ec); - if (!ec) p = canon; - if (std::filesystem::exists(p)) return p; - } - } - cursor = logical + 1; - } - } - } - - // SECOND PROBE: ASK WHICH libc++ WILL BE LINKED, THEN LOOK BESIDE IT. - // - // `-print-library-module-manifest-path` is the primary answer and the one - // llvm's own payload gives. `em++` does not forward it -- it answers - // `em++: error: no input files` -- so a toolchain that ships the surface - // was reported as shipping none, and `import std` was refused on a target - // where it demonstrably works. - // - // `--print-file-name=libc++.a` IS forwarded, and what it names is exactly - // the library the link will use, so the surface beside it is the surface - // that matches. Measured 2026-09-11: - // - // xim:llvm 22.1.8 /bin/../lib/x86_64-unknown-linux-gnu/libc++.a - // surface at /bin/../share/libc++/v1/ (3 up) - // xim:emsdk 6.0.9 /emscripten/cache/sysroot/lib/wasm32-emscripten/libc++.a - // surface at /.../sysroot/share/libc++/v1/ (2 up) - // - // THE TWO DEPTHS ARE WHY THIS WALKS RATHER THAN INDEXES. A fixed `up 3` - // would have been written against llvm, passed, and then been a guessed - // layout for the next payload -- which is the mistake the line this - // replaces made with `bin/../share`. - if (auto lib_r = mcpp::toolchain::run_capture(std::format( - "{}{} --print-file-name=libc++.a {}", - envPrefix, mcpp::platform::shell::quote(cxx_binary.string()), - mcpp::platform::null_redirect))) { - std::filesystem::path lib(mcpp::toolchain::trim_line(*lib_r)); - // A driver that cannot place the library echoes the bare name back. - if (lib.has_parent_path()) { - std::error_code ec; - auto dir = std::filesystem::weakly_canonical(lib.parent_path(), ec); - if (ec) dir = lib.parent_path(); - for (int up = 0; up < 4 && !dir.empty(); ++up) { - auto cand = dir / "share" / "libc++" / "v1" / "std.cppm"; - if (std::filesystem::exists(cand)) return cand; - if (!dir.has_relative_path()) break; - dir = dir.parent_path(); - } - } - } - - // THIRD: the layout guess, kept for a driver that answers neither probe. - auto root = cxx_binary.parent_path().parent_path(); - auto fallback = root / "share" / "libc++" / "v1" / "std.cppm"; - if (std::filesystem::exists(fallback)) return fallback; - return std::nullopt; -} - -void enrich_toolchain(Toolchain& tc, const std::string& envPrefix) { - // Clang targeting MSVC uses MSVC STL, not libc++. - bool msvTarget = is_msvc_target(tc); - tc.stdlibId = msvTarget ? "msvc-stl" : "libc++"; - tc.stdlibVersion = tc.version.empty() ? "unknown" : tc.version; - tc.linkRuntimeDirs = mcpp::toolchain::discover_link_runtime_dirs( - tc.binaryPath, tc.targetTriple); - - if (auto p = find_libcxx_std_module_source(tc.binaryPath, envPrefix)) { - tc.set_std_modules(*p, find_libcxx_std_compat_source(tc.binaryPath, envPrefix) - .value_or(std::filesystem::path{})); - // libc++ documents the std module for C++20 and later, and its - // std.cppm carries no __cplusplus guard. Verified on clang 22.1.8 + - // libc++ (std and std.compat) at -std=c++20 (design §2.2). - tc.importStdMinLevel = 20; - } - -#if defined(_WIN32) - // Fallback: if libc++ std.cppm not found, look for MSVC STL's std.ixx. - // This is the machine's default toolset by the one selection both rows - // use (msvc::select_system_toolset, selector `system`). A build replaces it - // with the toolset its row names (`[target.].sysroot`, see - // prepare's bind_msvc_sysroot); this answer serves what only detects, such - // as `mcpp toolchain list`. - if (!tc.hasImportStd && msvTarget) { - if (auto p = mcpp::toolchain::msvc::find_std_module_source()) { - // The STL's own pair: `std.compat.ixx` beside `std.ixx`. - std::error_code ec; - const auto compat = p->parent_path() / "std.compat.ixx"; - tc.set_std_modules(*p, std::filesystem::exists(compat, ec) - ? compat : std::filesystem::path{}); - // This is MSVC STL's std.ixx, so the STL's own C++20 policy - // applies rather than libc++'s. `tc.version` is clang's and cannot - // answer it -- which is a reason to change the input, not to assume - // the worst. The toolset version is in the path of the file just - // selected, and that is the STL that will be compiled. - tc.importStdMinLevel = - mcpp::toolchain::msvc::std_module_min_level_for_stl(*p); - } - } -#endif -} - -std::filesystem::path std_bmi_path(const std::filesystem::path& cacheDir) { - return cacheDir / "pcm.cache" / "std.pcm"; -} - -std::filesystem::path staged_std_bmi_path(const std::filesystem::path& outputDir) { - return outputDir / "pcm.cache" / "std.pcm"; -} - -// THE LANGUAGE OF A STANDARD LIBRARY MODULE SOURCE, FROM ITS NAME, ON EVERY HOST. -// -// MSVC STL names its module sources `.ixx`, which clang does not infer as a -// module interface: given one without `-x c++-module`, `--precompile` treats -// it as linker input, writes nothing and exits 0. It also includes headers in -// the module purview, which clang reports. One answer for `std` and -// `std.compat`, decided by the file and not by the machine running the build; -// a libc++ `.cppm` receives nothing, so its commands are unchanged. -// The language of a `std` module source clang does not know by its name. -// `.cppm` (libc++'s) and the other module-interface extensions are -// recognised; the MSVC STL's `.ixx` and libstdc++'s `bits/std.cc` are not -- -// the second precompiled as an ordinary translation unit, and the object step -// then answered `unable to load precompiled file` (design 2026-10-26 §6.4: a -// graph-supplied libstdc++ under clang). The `.ixx` spelling is unchanged, so -// the std module caches built from it keep their identity. -static std::string module_source_language_flags(const std::filesystem::path& source) { - const auto ext = source.extension().string(); - if (ext == ".ixx") return " -x c++-module -Wno-include-angled-in-module-purview"; - if (ext == ".cppm" || ext == ".ccm" || ext == ".cxxm" || ext == ".c++m") return ""; - return " -x c++-module"; -} - -std::vector std_module_build_commands(const Toolchain& tc, - const std::filesystem::path& cacheDir, - const std::filesystem::path& bmiPath, - std::string_view sysrootFlag, - std::string_view cppStandardFlag, - std::string_view crtFlag) { - auto relBmi = std::filesystem::relative(bmiPath, cacheDir).string(); - // #718: rendered as its own token, with a leading space, so an empty - // `crtFlag` (every non-MSVC-ABI row) leaves both commands byte-identical - // to what they were before this parameter existed. - const std::string crtToken = - crtFlag.empty() ? std::string{} : std::format(" {}", crtFlag); - // A PACKAGE-PROVIDED std MODULE REPLACES THE TOOLCHAIN'S SYSROOT FLAGS - // RATHER THAN BEING APPENDED TO THEM. - // - // Those flags describe the standard library the COMPILER ships and the C - // library the HOST has, and they lead with `-isystem' — so appending to them - // puts the host's headers ahead of the package's, and no later flag can - // undo it. Measured: the module then compiles the host C library's - // and stops on names that library expects the host compiler to - // have supplied. - // - // The triple has to be restated for the same reason: it was in the flags - // being replaced, and without it the module is built for whatever machine - // is doing the building. - // The replacement is complete: whoever set stdModuleFlags stated the target - // as well, because the triple was in the string being replaced and a module - // built without it is built for whatever machine is doing the building. - if (!tc.stdModuleFlags.empty()) sysrootFlag = {}; - const std::string& extraFlags = tc.stdModuleFlags; - // The codegen step compiles a BMI, which already carries what the - // headers contributed; only the machine has to be restated. See - // Toolchain::stdModuleTargetFlags. - const std::string& codegenFlags = tc.stdModuleTargetFlags; - // AND THE PRECOMPILE NEEDS THE MACHINE TOO, FROM WHICHEVER SOURCE HAS IT. - // - // `stdModuleTargetFlags` reached only the CODEGEN command, on the reading - // that the first step needs headers and the second needs the machine. The - // first step needs both: a `--precompile` that does not say which target - // resolves the standard library's own `#include <__config>` against the - // BUILDING machine. - // - // It was invisible while exactly two kinds of toolchain existed. A payload - // whose compiler IS its target needs no flag, and a PACKAGE-provided module - // carries the target inside `stdModuleFlags` -- which is why the comment - // above insists that whoever sets that string states the target as well. A - // payload whose compiler serves several targets is a third kind, and it has - // neither: the NDK's one clang++ compiles for both Android arches and is - // told which by `--target` alone. Measured on `aarch64-linux-android`: - // - // clang++ -std=c++23 -Wno-reserved-module-identifier \ - // --precompile .../share/libc++/v1/std.cppm -o pcm.cache/std.pcm - // std.cppm:16:10: fatal error: '__config' file not found - // - // Five tokens, and the same error text this file already records from a - // Windows host in 2026-08 -- same cause, reached by a different route. - // - // The two sources are never both needed: `stdModuleFlags` is a SUPERSET of - // `stdModuleTargetFlags` when it is set at all (the producer builds the - // machine part first and appends the include part), so taking it in - // preference keeps `--target` off the command line twice. - const std::string& precompileFlags = - extraFlags.empty() ? codegenFlags : extraFlags; -#if defined(_WIN32) - // Windows: use absolute paths, raw binary path as first token - // (cmd.exe strips leading quotes), shq for args with spaces. - // -x c++-module is needed for MSVC STL's .ixx files (Clang doesn't - // recognize the .ixx extension as a module source by default). - auto absBmi = (cacheDir / relBmi).string(); - std::string ixxFlags = module_source_language_flags(tc.stdModuleSource); - // AND THE RESERVED-NAME WARNING UNCONDITIONALLY, WHICH IS WHAT THE OTHER - // BRANCH DOES. - // - // `export module std;` is a reserved identifier and every standard library - // that ships one triggers the warning; the non-Windows command has carried - // the suppression since it was written. This branch tied it to `.ixx`, - // which was correct while the only `std` module a Windows host ever saw was - // the MSVC STL's — and stopped being correct when a package could supply - // its own. Measured 2026-08-23, a Windows host building the openkal - // runtime's `llvm-generated/std.cppm`: - // - // std.cppm:167:15: warning: 'std' is a reserved name for a module - // [-Wreserved-module-identifier] - // - // A warning that is correct, unavoidable, and printed on every build is - // noise of the kind that hides the next one. - ixxFlags += " -Wno-reserved-module-identifier"; - // `extraFlags` IS ON BOTH COMMANDS HERE, AND IT WAS ON NEITHER. - // - // This branch was written when a Windows host built for itself against the - // MSVC STL, and `stdModuleFlags` did not exist — so the omission was not - // visible: there was nothing to omit. It became a defect when a package - // could supply its own `std` module, because that string is where the - // package's own headers, `-nostdinc` and the target triple live. - // - // Measured 2026-08-23, a Windows host cross-building for - // `x86_64-linux-gnu` over openkal — the command it produced carried FIVE - // tokens: - // - // clang++.exe -std=c++23 --precompile "…/std.cppm" -o "…/std.pcm" - // …/llvm-generated/std.cppm:16:10: fatal error: '__config' file not found - // - // The same build from a Linux host had `--target=`, `--no-default-config`, - // `-nostdinc`, `-nostdinc++` and eight `-I`s. The error names a header, and - // the cause is a branch keyed on which machine is doing the building. - return { - std::format( - "{} {}{}{}{}{} " - "--precompile {} -o {}", - tc.binaryPath.string(), - cppStandardFlag, - crtToken, - ixxFlags, - sysrootFlag, - precompileFlags, - mcpp::platform::shell::quote(tc.stdModuleSource.string()), - mcpp::platform::shell::quote(absBmi)), - std::format( - "{} {}{}{}{} " - "{} -c -o {}", - tc.binaryPath.string(), - cppStandardFlag, - crtToken, - sysrootFlag, - codegenFlags, - mcpp::platform::shell::quote(absBmi), - mcpp::platform::shell::quote((cacheDir / "std.o").string())) - }; -#else - return { - std::format( - "cd {} && {}{} {}{} -Wno-reserved-module-identifier{}{}{} " - "--precompile {} -o {} 2>&1", - mcpp::platform::shell::quote(cacheDir.string()), - mcpp::toolchain::compiler_env_prefix(tc), - mcpp::platform::shell::quote(tc.binaryPath.string()), - cppStandardFlag, - crtToken, - sysrootFlag, - precompileFlags, - module_source_language_flags(tc.stdModuleSource), - mcpp::platform::shell::quote(tc.stdModuleSource.string()), - mcpp::platform::shell::quote(relBmi)), - std::format( - "cd {} && {}{} {}{} -Wno-reserved-module-identifier{}{} " - "{} -c -o std.o 2>&1", - mcpp::platform::shell::quote(cacheDir.string()), - mcpp::toolchain::compiler_env_prefix(tc), - mcpp::platform::shell::quote(tc.binaryPath.string()), - cppStandardFlag, - crtToken, - sysrootFlag, - codegenFlags, - mcpp::platform::shell::quote(relBmi)) - }; -#endif -} - -std::optional find_scan_deps(const Toolchain& tc) { - auto p = tc.binaryPath.parent_path() / - (std::string("clang-scan-deps") + std::string(mcpp::platform::exe_suffix)); - if (std::filesystem::exists(p)) return p; - return std::nullopt; -} - -// THE DRIVER KNOWS WHERE ITS ARCHIVES ARE FOR A TARGET, AND A DIRECTORY -// SEARCH ONLY KNOWS ONE LAYOUT. -// -// The self-contained contract used to look for `libc++.a` under the LLVM -// payload's `lib/` alone. The Android NDK keeps its archives in the sysroot, -// per API level, so the search found nothing, the contract degraded to -// toolchain-coupled, and every test program on an Android row needed -// `libc++_shared.so`, which a device does not have (#634 A6, measured on an -// API 34 emulator: `CANNOT LINK EXECUTABLE ... library "libc++_shared.so" not -// found` for every test). The drivers answer directly, measured 2026-09-14: -// -// xim:android-ndk 30 --target=x86_64-linux-android24 -// libc++.a /usr/lib/x86_64-linux-android/24/libc++.a -// (a linker script: INPUT(-lc++_static -lc++abi)) -// libc++abi.a /usr/lib/x86_64-linux-android/libc++abi.a -// libunwind.a /lib/clang/21/lib/linux/x86_64/libunwind.a -// xim:llvm 22.1.8 libc++.a /bin/../lib/x86_64-unknown-linux-gnu/libc++.a -// -// THE TARGET IN THE QUESTION IS THE ONE THE LINK NAMES. The same NDK asked -// with `--target=x86_64-linux-android`, no API level, answers -// `/lib/clang/21/../../libc++.a`, a symlink to the HOST's -// `x86_64-unknown-linux-gnu/libc++.a`. The caller passes the flags its link -// line carries, API level included. -// -// A driver that cannot place a file echoes the bare name back, which is a -// miss rather than a relative path. The answer is made lexically normal: the -// `bin/..` in it is a spelling of the driver's, and a link line that spells -// one file two ways is two commands to ninja. -std::filesystem::path runtime_archive_path(const Toolchain& tc, - std::string_view driverFlags, - std::string_view archive) { - static std::mutex mu; - static std::map answered; - auto key = std::format("{}\n{}\n{}", tc.binaryPath.string(), driverFlags, archive); - { - std::scoped_lock lock(mu); - if (auto it = answered.find(key); it != answered.end()) return it->second; - } - std::filesystem::path out; - if (auto r = mcpp::toolchain::run_capture(std::format( - "{}{}{} --print-file-name={} {}", - mcpp::toolchain::compiler_env_prefix(tc), - mcpp::platform::shell::quote(tc.binaryPath.string()), - driverFlags, archive, mcpp::platform::null_redirect))) { - std::filesystem::path lib(mcpp::toolchain::trim_line(*r)); - std::error_code ec; - if (lib.has_parent_path() && lib.is_absolute() - && std::filesystem::is_regular_file(lib, ec)) - out = lib.lexically_normal(); - } - std::scoped_lock lock(mu); - answered.emplace(std::move(key), out); - return out; -} - -std::optional find_libcxx_std_compat_source( - const std::filesystem::path& cxx_binary, - const std::string& envPrefix) -{ - // DERIVED FROM THE SIBLING, NOT SEARCHED FOR SEPARATELY. - // - // The comment here used to say "same search strategy as - // find_libcxx_std_module_source", and it was not: that function has three - // probes and this one had the last of them, so on any payload the layout - // guess does not reach -- emsdk, for one -- `std` was found and - // `std.compat` was not, from one directory. - // - // `std.compat.cppm` sits beside `std.cppm` in every libc++ layout, because - // the same install rule places both. Deriving it makes the two answers - // structurally consistent rather than two searches that can disagree, - // which is what the comment claimed all along. - if (auto std_src = find_libcxx_std_module_source(cxx_binary, envPrefix)) { - auto p = std_src->parent_path() / "std.compat.cppm"; - if (std::filesystem::exists(p)) return p; - } - return std::nullopt; -} - -std::filesystem::path std_compat_bmi_path(const std::filesystem::path& cacheDir) { - return cacheDir / "pcm.cache" / "std.compat.pcm"; -} - -std::filesystem::path staged_std_compat_bmi_path(const std::filesystem::path& outputDir) { - return outputDir / "pcm.cache" / "std.compat.pcm"; -} - -std::vector std_compat_build_commands(const Toolchain& tc, - const std::filesystem::path& cacheDir, - const std::filesystem::path& bmiPath, - const std::filesystem::path& stdBmiPath, - std::string_view sysrootFlag, - std::string_view cppStandardFlag, - std::string_view crtFlag) -{ - auto relBmi = std::filesystem::relative(bmiPath, cacheDir).string(); - auto relStdBmi = std::filesystem::relative(stdBmiPath, cacheDir).string(); - // #718: see `std_module_build_commands` — empty keeps both commands - // byte-identical off the MSVC ABI. - const std::string crtToken = - crtFlag.empty() ? std::string{} : std::format(" {}", crtFlag); - // THE SAME REPLACEMENT THE `std` BUILDER MAKES, FOR THE SAME REASON. - // - // `std.compat` is a second module over the SAME library, and it therefore - // needs the same headers, the same target and the same configuration. This - // used to take `sysrootFlag` unconditionally while its sibling above - // replaced it — so a package-provided pair had one module built against its - // own libc++ and the other against the toolchain's. - // - // It does not fail where the two are chosen. Measured on a macOS cross: - // - // error: std module precompile failed (rc=1): - // …/openkal-llvm-runtime/llvm-generated/std.compat.cppm:16 - // …/xim-x-llvm/22.1.8/include/c++/v1/__config:13 - // fatal error: '__config_site' file not found - // - // The SOURCE named is the package's; the header it opened is the - // toolchain's, whose per-installation configuration was never generated for - // this target. Reading that message, the mixture is invisible. - if (!tc.stdModuleFlags.empty()) sysrootFlag = {}; - const std::string& extraFlags = tc.stdModuleFlags; - // Same split as the `std` builder above: the second command compiles a BMI - // and needs the machine restated, not the include paths. - const std::string& codegenFlags = tc.stdModuleTargetFlags; - // Same third kind of toolchain as the `std` builder above, same reason. - const std::string& precompileFlags = - extraFlags.empty() ? codegenFlags : extraFlags; - // std.compat depends on std, so we need -fmodule-file=std= - // Note: the path after = must NOT be shell-quoted separately; the - // entire -fmodule-file flag is a single token to the compiler. - // - // ABSOLUTE PATHS AND NO `cd`, AND ONE FORM RATHER THAN TWO. - // - // This used to be `cd && … pcm.cache/std.pcm …`. `cd X && …` - // DOES NOT CHANGE THE DRIVE in cmd.exe — the build cache lives under the - // user's profile and a checkout lives wherever the runner put it, so on CI - // those are `C:` and `D:`. The `cd` succeeds, the drive stays where it was, - // and every relative path resolves against the wrong root. Measured - // 2026-08-23, a Windows host cross-building for `x86_64-linux-gnu`: - // - // std.compat.cppm:84:8: fatal error: module file 'pcm.cache\std.pcm' - // not found: module file not found - // - // — and `std.pcm` had been built successfully one command earlier. - // - // The obvious repair was a `#if defined(_WIN32)` branch, which is what - // the `std` builder above has. It was written and then withdrawn: a branch - // that only compiles on one platform is a branch this machine cannot check, - // and every defect this session found in the host dimension had exactly - // that shape — code shaped by which machine was doing the building. Naming - // absolute paths is correct everywhere, so there is one form. - auto absBmi = (cacheDir / relBmi).string(); - auto absStdBmi = (cacheDir / relStdBmi).string(); - auto absObj = (cacheDir / "std.compat.o").string(); - // Applied when parsing the source, never when compiling the BMI. - std::string ixxFlags = module_source_language_flags(tc.stdCompatSource); - return { - std::format("{}{} {}{} -Wno-reserved-module-identifier{}{}{} " - "-fmodule-file=std={} " - "--precompile {} -o {} 2>&1", - mcpp::toolchain::compiler_env_prefix(tc), - mcpp::platform::shell::quote(tc.binaryPath.string()), - cppStandardFlag, - crtToken, - sysrootFlag, - precompileFlags, - ixxFlags, - absStdBmi, - mcpp::platform::shell::quote(tc.stdCompatSource.string()), - mcpp::platform::shell::quote(absBmi)), - std::format("{}{} {}{} -Wno-reserved-module-identifier{}{} " - "-fmodule-file=std={} " - "{} -c -o {} 2>&1", - mcpp::toolchain::compiler_env_prefix(tc), - mcpp::platform::shell::quote(tc.binaryPath.string()), - cppStandardFlag, - crtToken, - sysrootFlag, - codegenFlags, - absStdBmi, - mcpp::platform::shell::quote(absBmi), - mcpp::platform::shell::quote(absObj)) - }; -} - -} // namespace mcpp::toolchain::clang diff --git a/src/toolchain/compat.cppm b/src/toolchain/compat.cppm index 92065ca7..99fa4309 100644 --- a/src/toolchain/compat.cppm +++ b/src/toolchain/compat.cppm @@ -28,7 +28,7 @@ module; export module mcpp.toolchain.compat; import std; -import mcpp.platform; +import mcpp.toolchain.platform; import mcpp.toolchain.triple; export namespace mcpp::toolchain::compat { @@ -78,7 +78,7 @@ std::string_view strip_namespace(std::string_view compiler) { triple::Triple host_musl_triple() { triple::Triple t; - t.arch = std::string(mcpp::platform::host_arch); + t.arch = std::string(mcpp::toolchain::platform::host_arch); t.os = "linux"; t.env = "musl"; return t; diff --git a/src/toolchain/detect.cppm b/src/toolchain/detect.cppm index bc77c6f6..34854c82 100644 --- a/src/toolchain/detect.cppm +++ b/src/toolchain/detect.cppm @@ -6,12 +6,12 @@ export import mcpp.toolchain.model; export import mcpp.toolchain.probe; import std; +import mcpplibs.os.platform; import mcpp.toolchain.clang; import mcpp.toolchain.gcc; import mcpp.toolchain.msvc; import mcpp.provider; import mcpp.provider.select; -import mcpp.platform; import mcpp.fallback.probe_sysroot; export namespace mcpp::toolchain { @@ -62,11 +62,9 @@ detect(const std::filesystem::path& explicit_compiler, } tc.compilerRuntimeDirs = discover_compiler_runtime_dirs(tc.binaryPath); - auto envPrefix = compiler_env_prefix(tc); + const auto env = compiler_env(tc); - auto ver_r = run_capture(std::format("{}{} --version 2>&1", - envPrefix, - mcpp::platform::shell::quote(tc.binaryPath.string()))); + auto ver_r = run_capture({ tc.binaryPath.string(), "--version" }, env, /*withErrors=*/true); if (!ver_r) return std::unexpected(ver_r.error()); const auto& vstr = *ver_r; @@ -98,37 +96,34 @@ detect(const std::filesystem::path& explicit_compiler, std::format("unrecognized compiler output:\n{}", vstr)}); } - if (auto triple = probe_target_triple(tc.binaryPath, envPrefix)) { + if (auto triple = probe_target_triple(tc.binaryPath, env)) { tc.targetTriple = *triple; } -#if defined(_WIN32) - // On Windows, Clang targeting MSVC auto-detects the MSVC version at - // compile time and bakes it into the module AST. The -dumpmachine triple - // doesn't include this version, so fingerprints don't change when MSVC - // patches (e.g. 19.44.35226 → 35227), causing stale BMI cache hits. - // Query the effective triple which includes the actual MSVC version. - if (tc.compiler == CompilerId::Clang - && is_msvc_target(tc)) { - auto vr = run_capture(std::format( - "{}{} -print-effective-triple 2>NUL", - envPrefix, - mcpp::platform::shell::quote(tc.binaryPath.string()))); - if (vr) { - auto effective = trim_line(*vr); - if (!effective.empty() && effective != tc.targetTriple) - tc.driverIdent += "\neffective-triple: " + effective; + if constexpr (mcpplibs::os::is_windows) { + // On Windows, Clang targeting MSVC auto-detects the MSVC version at + // compile time and bakes it into the module AST. The -dumpmachine triple + // doesn't include this version, so fingerprints don't change when MSVC + // patches (e.g. 19.44.35226 → 35227), causing stale BMI cache hits. + // Query the effective triple which includes the actual MSVC version. + if (tc.compiler == CompilerId::Clang + && is_msvc_target(tc)) { + auto vr = run_capture({ tc.binaryPath.string(), "-print-effective-triple" }, env); + if (vr) { + auto effective = trim_line(*vr); + if (!effective.empty() && effective != tc.targetTriple) + tc.driverIdent += "\neffective-triple: " + effective; + } } } -#endif if (tc.compiler == CompilerId::GCC) { mcpp::toolchain::gcc::enrich_toolchain(tc); } else if (tc.compiler == CompilerId::Clang) { - mcpp::toolchain::clang::enrich_toolchain(tc, envPrefix); + mcpp::toolchain::clang::enrich_toolchain(tc, env); } - tc.base.root = probe_sysroot(tc.binaryPath, envPrefix); + tc.base.root = probe_sysroot(tc.binaryPath, env); // Probe fine-grained payload paths from sibling xpkgs (glibc, linux-headers). // When available, flags are assembled from these paths instead of --sysroot. diff --git a/src/toolchain/gcc.cppm b/src/toolchain/gcc.cppm index f44f4ddf..b711fbf6 100644 --- a/src/toolchain/gcc.cppm +++ b/src/toolchain/gcc.cppm @@ -3,7 +3,9 @@ export module mcpp.toolchain.gcc; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.build.command_line; +import mcpp.run; import mcpp.toolchain.model; import mcpp.toolchain.probe; import mcpp.provider; @@ -135,7 +137,7 @@ std::optional find_std_module_source( // An argument vector: the command-string form was written for /bin/sh // (single quotes and `2>/dev/null`), and cmd.exe reads neither. - auto r = mcpp::platform::process::capture_host_tool_stdout( + auto r = mcpp::run::capture_host_tool_stdout( {cxx_binary.string(), "-print-file-name=libstdc++.so"}); if (r.exit_code == 0) { auto trimmed = mcpp::toolchain::trim_line(r.output); @@ -211,18 +213,18 @@ static std::string module_source_command(const Toolchain& tc, std::string_view object) { std::string bFlag; if (auto binutilsBin = binutils_prefix_dir(tc); !binutilsBin.empty()) - bFlag = std::format(" -B{}", mcpp::platform::shell::quote(binutilsBin.string())); - const char* cd = mcpp::platform::is_windows ? "cd /d" : "cd"; + bFlag = std::format(" -B{}", mcpp::build::command_line::quote(binutilsBin.string())); + const char* cd = mcpplibs::os::is_windows ? "cd /d" : "cd"; return std::format( "{} {} && {}{} {} -fmodules -O2{}{} -c {} -o {} 2>&1", cd, - mcpp::platform::shell::quote(cacheDir.string()), + mcpp::build::command_line::quote(cacheDir.string()), mcpp::toolchain::compiler_env_prefix(tc), - mcpp::platform::shell::quote(tc.binaryPath.string()), + mcpp::build::command_line::quote(tc.binaryPath.string()), cppStandardFlag, sysrootFlag, bFlag, - mcpp::platform::shell::quote(source.string()), + mcpp::build::command_line::quote(source.string()), object); } @@ -245,21 +247,21 @@ std::string std_module_build_command(const Toolchain& tc, std::string_view cppStandardFlag) { std::string bFlag; if (auto binutilsBin = binutils_prefix_dir(tc); !binutilsBin.empty()) - bFlag = std::format(" -B{}", mcpp::platform::shell::quote(binutilsBin.string())); + bFlag = std::format(" -B{}", mcpp::build::command_line::quote(binutilsBin.string())); // Windows (MinGW): cmd.exe needs `/d` to change DRIVE (project on D:, // BMI cache on C: is the real CI layout — same-drive runs masked this). - const char* cd = mcpp::platform::is_windows ? "cd /d" : "cd"; + const char* cd = mcpplibs::os::is_windows ? "cd /d" : "cd"; return std::format( "{} {} && {}{} {} -fmodules -O2{}{} -c {} -o std.o 2>&1", cd, - mcpp::platform::shell::quote(cacheDir.string()), + mcpp::build::command_line::quote(cacheDir.string()), mcpp::toolchain::compiler_env_prefix(tc), - mcpp::platform::shell::quote(tc.binaryPath.string()), + mcpp::build::command_line::quote(tc.binaryPath.string()), cppStandardFlag, sysrootFlag, bFlag, - mcpp::platform::shell::quote(tc.stdModuleSource.string())); + mcpp::build::command_line::quote(tc.stdModuleSource.string())); } std::vector std_module_build_commands( diff --git a/src/toolchain/hostflags.cppm b/src/toolchain/hostflags.cppm index d1c646a6..4f35871f 100644 --- a/src/toolchain/hostflags.cppm +++ b/src/toolchain/hostflags.cppm @@ -25,7 +25,8 @@ export module mcpp.toolchain.hostflags; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.toolchain.model; import mcpp.toolchain.linkmodel; import mcpp.toolchain.registry; @@ -363,7 +364,7 @@ std::vector host_compile_tokens(const Toolchain& tc, const bool bypassCfg = dm.hasCfg && (opt.cfgBypass == HostFlagOptions::CfgBypass::Always || (opt.cfgBypass == HostFlagOptions::CfgBypass::LinuxOnly - && mcpp::platform::is_linux)); + && mcpplibs::os::is_linux)); // Trusting the cfg means contributing no include paths, stdlib selection // or runtime choices — it already carries them. It does NOT mean @@ -516,7 +517,7 @@ std::vector host_compile_tokens(const Toolchain& tc, // which was the same question while macOS was the only Apple target // mcpp could build for AND the only way to reach it was to build ON one. // Cross-compiling `aarch64-macos` from Linux (#685) is a macOS TARGET on - // a non-Apple HOST, and the old `mcpp::platform::is_macos` (a + // a non-Apple HOST, and the old `mcpplibs::os::is_macos` (a // compile-time constant naming the machine mcpp itself was built for) // answered false there — so the flag, and the deployment target it // carries, were silently dropped on every non-Apple host. The @@ -601,7 +602,7 @@ void append_runtime_lib_dirs(const Toolchain& tc, const HostFlagOptions& opt, if (!opt.runtimeLibDirs) return; for (auto& d : tc.linkRuntimeDirs) { out.push_back("-L" + esc(d)); - if constexpr (mcpp::platform::supports_rpath) + if constexpr (mcpp::toolchain::platform::supports_rpath) out.push_back("-Wl,-rpath," + esc(d)); } } @@ -622,7 +623,7 @@ std::vector host_link_tokens(const Toolchain& tc, const bool bypassCfg = dm.hasCfg && (opt.cfgBypass == HostFlagOptions::CfgBypass::Always || (opt.cfgBypass == HostFlagOptions::CfgBypass::LinuxOnly - && mcpp::platform::is_linux)); + && mcpplibs::os::is_linux)); if (bypassCfg) { for (auto& t : dm.link_tokens(esc)) out.push_back(t); @@ -649,7 +650,7 @@ std::vector host_link_tokens(const Toolchain& tc, // // lld ships with the very toolchain doing the compile, so it cannot // be diverted to a libc++ it was not built against. - if constexpr (mcpp::platform::is_macos) out.push_back("-fuse-ld=lld"); + if constexpr (mcpplibs::os::is_macos) out.push_back("-fuse-ld=lld"); // AND DELIBERATELY NOT THE TOOLCHAIN'S RUNTIME DIRECTORIES. THIS WAS // TRIED, AND WHAT IT COSTS IS RECORDED HERE RATHER THAN REDISCOVERED. // diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index 689a3e65..a1809bf6 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -13,8 +13,8 @@ import mcpp.config; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.manifest; -import mcpp.platform; -import mcpp.platform.axis; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.project; import mcpp.toolchain.detect; import mcpp.toolchain.msvc; @@ -141,7 +141,7 @@ list_available_xpkg_versions(const mcpp::config::GlobalConfig& cfg, // and partial-version resolution empty on Windows/macOS for any // package, e.g. mingw-gcc has no linux block.) return mcpp::manifest::list_xpkg_versions( - body, mcpp::platform::HostPlatform::current()); + body, mcpp::toolchain::platform::HostPlatform::current()); }; auto data = mcpp::provider::open(mcpp::config::make_xlings_env(cfg))->data_root(); @@ -407,7 +407,7 @@ EffectiveDefault effective_default_toolchain(const mcpp::config::GlobalConfig& c // the toolchain the build resolves rather than the global default. if (auto e = mcpp::project::load_effective_manifest(mpath.parent_path())) { auto const* m = &e->manifest; - if (auto t = m->toolchain.for_platform(mcpp::platform::name); + if (auto t = m->toolchain.for_platform(mcpp::toolchain::platform::name); t && !t->empty()) return { *t, true }; } @@ -575,7 +575,7 @@ export int toolchain_list(const mcpp::config::GlobalConfig& cfg, // section of its own: to a client, "a compiler I can select" is one list, // and where mcpp found it is a property of the entry, not a reason for a // second array. - if (mcpp::platform::is_windows && json) { + if (mcpplibs::os::is_windows && json) { if (auto inst = mcpp::toolchain::msvc::detect_installation()) // `version` MUST BE WHAT `--toolchain` ACCEPTS, AND THIS EMITTED // WHAT cl.exe REPORTS. @@ -600,7 +600,7 @@ export int toolchain_list(const mcpp::config::GlobalConfig& cfg, && defSpec->family == mcpp::toolchain::Family::Msvc}, {"source", "system"}, }); - } else if (mcpp::platform::is_windows) { + } else if (mcpplibs::os::is_windows) { if (auto inst = mcpp::toolchain::msvc::detect_installation()) { bool isDefault = defSpec && defSpec->family == mcpp::toolchain::Family::Msvc; @@ -900,9 +900,9 @@ int attach_target_arg(mcpp::toolchain::ToolchainSpec& spec, // changed. export std::expected ensure_declared_runtime(const mcpp::config::GlobalConfig& cfg, - mcpp::platform::runtime::RuntimeBinding& binding) { - namespace rt = mcpp::platform::runtime; - if constexpr (!mcpp::platform::is_linux) return false; + mcpp::runtime::RuntimeBinding& binding) { + namespace rt = mcpp::runtime; + if constexpr (!mcpplibs::os::is_linux) return false; if (rt::runtime_provider(binding.runtimeId) != "glibc") return false; // The toolchains the provider installs run on this runtime (design // 2026-10-16 §7.2), not on whatever it installs newest; pinned once its @@ -967,7 +967,7 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, // (msvc@ is NOT a system spec and falls through to the xim // package path below, like every other family.) if (mcpp::toolchain::is_system_toolchain(*spec)) { - if (!mcpp::platform::is_windows) return msvc_wrong_host(); + if (!mcpplibs::os::is_windows) return msvc_wrong_host(); if (auto inst = mcpp::toolchain::msvc::detect_installation()) { msvc_print_detected(*inst); mcpp::ui::line(""); @@ -1106,7 +1106,7 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, // here, so the selection is mcpp's default environment, which is what // `RuntimeSelection{}` means. if (!mcpp::toolchain::runtime_payload_kind(pkg).empty()) { - if (auto rb = mcpp::platform::runtime::resolve_runtime_binding( + if (auto rb = mcpp::runtime::resolve_runtime_binding( mcpp::provider::runtime::RuntimeSelection{}, {}, cfg.xlingsHome())) if (auto provided = ensure_declared_runtime(cfg, *rb); !provided) { mcpp::ui::error(provided.error()); @@ -1153,7 +1153,7 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, // stable spec (never a concrete version — config survives VS // updates). A versioned msvc spec is a payload and falls through. if (mcpp::toolchain::is_system_toolchain(*spec)) { - if (!mcpp::platform::is_windows) return msvc_wrong_host(); + if (!mcpplibs::os::is_windows) return msvc_wrong_host(); auto inst = mcpp::toolchain::msvc::detect_installation(); if (!inst) { mcpp::ui::error(mcpp::toolchain::msvc::install_guidance()); @@ -1184,7 +1184,7 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, // A PINNED TOOLSET THIS MACHINE HAS NEEDS NO PACKAGE, which is what the // same spelling means to a build (`msvc@` takes an installed // toolset first). `xim:` asks for the package and skips this. - if (mcpp::platform::is_windows + if (mcpplibs::os::is_windows && spec->family == mcpp::toolchain::Family::Msvc && !spec->ecosystemOnly) { if (auto inst = mcpp::toolchain::msvc::system_installation_matching( diff --git a/src/toolchain/llvm.cppm b/src/toolchain/llvm.cppm index e56744bc..1ad87118 100644 --- a/src/toolchain/llvm.cppm +++ b/src/toolchain/llvm.cppm @@ -3,7 +3,7 @@ export module mcpp.toolchain.llvm; import std; -import mcpp.platform; +import mcpplibs.os.platform; export namespace mcpp::toolchain::llvm { @@ -25,7 +25,7 @@ std::string package_name() { } std::vector frontend_candidates() { - return {std::format("clang++{}", mcpp::platform::exe_suffix)}; + return {std::format("clang++{}", mcpplibs::os::exe_suffix)}; } std::vector list_aliases() { diff --git a/src/toolchain/msvc.cppm b/src/toolchain/msvc.cppm index 78472a47..101faeac 100644 --- a/src/toolchain/msvc.cppm +++ b/src/toolchain/msvc.cppm @@ -29,7 +29,13 @@ module; export module mcpp.toolchain.msvc; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; +import mcpp.invocation; +import mcpplibs.os.windows; +import mcpp.runtime.launch_env; +import mcpp.build.command_line; +import mcpp.run; import mcpp.toolchain.model; import mcpp.toolchain.probe; import mcpp.toolchain.triple; @@ -491,16 +497,14 @@ std::string_view msvc_arch_dir(std::string_view archGnu) { return arch.empty() ? std::string_view{"x64"} : arch; } -#if defined(_WIN32) - // Strategy 1: VSINSTALLDIR — someone SAID which install to use. // // Set by a developer command prompt, by a CI step that ran vcvarsall, or by // a tool that exported an environment on purpose. It is an answer, not a // guess, which is why it now outranks vswhere. std::optional find_vs_via_vsinstalldir() { - if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { - std::filesystem::path p{dir}; + if (auto dir = mcpp::invocation::get("VSINSTALLDIR")) { + std::filesystem::path p{*dir}; if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) return p; } @@ -513,9 +517,9 @@ std::optional find_vs_via_vsinstalldir() { std::optional find_vs_via_comntools() { // VS*COMNTOOLS: VS170COMNTOOLS (2022), VS160COMNTOOLS (2019), VS150COMNTOOLS (2017) for (auto* var : {"VS170COMNTOOLS", "VS160COMNTOOLS", "VS150COMNTOOLS"}) { - if (auto* val = std::getenv(var); val && *val) { + if (auto val = mcpp::invocation::get(var)) { // Common7/Tools/ → go up two levels to VS root - std::filesystem::path p{val}; + std::filesystem::path p{*val}; auto root = p.parent_path().parent_path(); if (std::filesystem::exists(root / "VC" / "Tools" / "MSVC")) return root; @@ -530,7 +534,7 @@ std::optional find_vs_via_paths() { // `C:` on every machine (D15). std::vector bases; for (const char* var : {"ProgramFiles", "ProgramFiles(x86)"}) - if (auto b = mcpp::platform::windows::env_directory(var)) + if (auto b = mcpp::invocation::env_directory(var)) bases.push_back(*b / "Microsoft Visual Studio"); bases.emplace_back("C:\\Program Files\\Microsoft Visual Studio"); bases.emplace_back("C:\\Program Files (x86)\\Microsoft Visual Studio"); @@ -555,8 +559,6 @@ std::optional find_vs_via_paths() { return std::nullopt; } -#endif // _WIN32 - } // namespace // The three below answer from the same selection every row uses @@ -564,7 +566,6 @@ std::optional find_vs_via_paths() { // directory and the `std.ixx` they report are one toolset rather than the // results of three searches. `cl` is not required: the clang row reads them // too, and a toolset is complete for it without `cl.exe`. -#if defined(_WIN32) namespace { std::optional default_system_choice() { ToolsetNeeds needs; @@ -573,44 +574,43 @@ std::optional default_system_choice() { "system", needs); } } // namespace -#endif std::optional find_vs_install_path() { -#if defined(_WIN32) - if (auto c = default_system_choice()) return c->vsRoot; -#endif + if constexpr (mcpplibs::os::is_windows) { + if (auto c = default_system_choice()) return c->vsRoot; + } return std::nullopt; } std::optional find_msvc_tools_dir() { -#if defined(_WIN32) - if (auto c = default_system_choice()) return c->toolsDir; -#endif + if constexpr (mcpplibs::os::is_windows) { + if (auto c = default_system_choice()) return c->toolsDir; + } return std::nullopt; } std::optional find_std_module_source() { -#if defined(_WIN32) - auto tools = find_msvc_tools_dir(); - if (!tools) return std::nullopt; - - auto stdIxx = *tools / "modules" / "std.ixx"; - if (std::filesystem::exists(stdIxx)) - return stdIxx; -#endif + if constexpr (mcpplibs::os::is_windows) { + auto tools = find_msvc_tools_dir(); + if (!tools) return std::nullopt; + + auto stdIxx = *tools / "modules" / "std.ixx"; + if (std::filesystem::exists(stdIxx)) + return stdIxx; + } return std::nullopt; } std::optional find_cl() { -#if defined(_WIN32) - auto tools = find_msvc_tools_dir(); - if (!tools) return std::nullopt; - - // cl.exe is at /bin/Hostx64/x64/cl.exe - auto cl = *tools / "bin" / "Hostx64" / "x64" / "cl.exe"; - if (std::filesystem::exists(cl)) - return cl; -#endif + if constexpr (mcpplibs::os::is_windows) { + auto tools = find_msvc_tools_dir(); + if (!tools) return std::nullopt; + + // cl.exe is at /bin/Hostx64/x64/cl.exe + auto cl = *tools / "bin" / "Hostx64" / "x64" / "cl.exe"; + if (std::filesystem::exists(cl)) + return cl; + } return std::nullopt; } @@ -728,9 +728,7 @@ installation_from_tools_dir(const std::filesystem::path& vsRoot, // Capture cl.exe's banner. cl prints it (plus a usage complaint) when run // bare; the exit status is irrelevant — parse whatever came out. std::string capture_cl_banner(const std::filesystem::path& cl) { - auto r = mcpp::platform::process::capture( - "\"" + cl.string() + "\" 2>&1"); - return r.output; + return mcpp::run::execute({ cl.string() }, { .report_spawn_failure = false }).output; } std::optional @@ -745,8 +743,8 @@ installation_from_tools_dir(const std::filesystem::path& vsRoot, // Host-native bin dir first (arm64 hosts run arm64 cl; everything else // x64), with the remaining pairs as fallback. std::vector> pairs; - if (mcpp::platform::host_arch == std::string_view("aarch64") - || mcpp::platform::host_arch == std::string_view("arm64")) { + if (mcpp::toolchain::platform::host_arch == std::string_view("aarch64") + || mcpp::toolchain::platform::host_arch == std::string_view("arm64")) { pairs = {{"Hostarm64", "arm64"}, {"Hostx64", "x64"}, {"Hostx86", "x86"}}; } else { pairs = {{"Hostx64", "x64"}, {"Hostarm64", "arm64"}, {"Hostx86", "x86"}}; @@ -1089,67 +1087,68 @@ std::vector parse_vswhere_text(std::string_view text) { std::vector enumerate_vs_instances() { std::vector out; -#if defined(_WIN32) - auto add = [&](VsInstance inst) { - for (auto const& e : out) - if (same_root(e.root, inst.root)) return; - out.push_back(std::move(inst)); - }; - // Where the Visual Studio Installer keeps it: %ProgramFiles(x86)%, which - // is not `C:` on every machine (D15). - const std::filesystem::path vswhere = - mcpp::platform::windows::env_directory("ProgramFiles(x86)") - .value_or(std::filesystem::path("C:\\Program Files (x86)")) - / "Microsoft Visual Studio" / "Installer" / "vswhere.exe"; - bool listed = false; - if (std::filesystem::exists(vswhere)) { - // `-all -prerelease`: every instance, Insiders included, complete or - // not; completeness is judged per toolset below. `-utf8` because an - // installation path may carry characters the console code page cannot. - auto r = mcpp::platform::process::capture( - "\"" + vswhere.string() + "\" -all -prerelease -products * " - "-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 " - "-format text -utf8 2>nul"); - // vswhere ran: an empty list is an answer (no instance has the C++ - // tools), so the conventional paths are not consulted behind it. - if (r.exit_code == 0) { - listed = true; - for (auto& inst : parse_vswhere_text(r.output)) add(std::move(inst)); + if constexpr (mcpplibs::os::is_windows) { + auto add = [&](VsInstance inst) { + for (auto const& e : out) + if (same_root(e.root, inst.root)) return; + out.push_back(std::move(inst)); + }; + // Where the Visual Studio Installer keeps it: %ProgramFiles(x86)%, which + // is not `C:` on every machine (D15). + const std::filesystem::path vswhere = + mcpp::invocation::env_directory("ProgramFiles(x86)") + .value_or(std::filesystem::path("C:\\Program Files (x86)")) + / "Microsoft Visual Studio" / "Installer" / "vswhere.exe"; + bool listed = false; + if (std::filesystem::exists(vswhere)) { + // `-all -prerelease`: every instance, Insiders included, complete or + // not; completeness is judged per toolset below. `-utf8` because an + // installation path may carry characters the console code page cannot. + auto r = mcpp::run::execute( + { vswhere.string(), "-all", "-prerelease", "-products", "*", + "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-format", "text", "-utf8" }, + { .streams = mcpp::run::Streams::output, .report_spawn_failure = false }); + // vswhere ran: an empty list is an answer (no instance has the C++ + // tools), so the conventional paths are not consulted behind it. + if (r.exit_code == 0) { + listed = true; + for (auto& inst : parse_vswhere_text(r.output)) add(std::move(inst)); + } + } + if (auto p = find_vs_via_vsinstalldir()) add(VsInstance{*p, {}, {}}); + if (!listed) { + if (auto p = find_vs_via_comntools()) add(VsInstance{*p, {}, {}}); + if (auto p = find_vs_via_paths()) add(VsInstance{*p, {}, {}}); } } - if (auto p = find_vs_via_vsinstalldir()) add(VsInstance{*p, {}, {}}); - if (!listed) { - if (auto p = find_vs_via_comntools()) add(VsInstance{*p, {}, {}}); - if (auto p = find_vs_via_paths()) add(VsInstance{*p, {}, {}}); - } -#endif return out; } MsvcEnvSnapshot msvc_env_snapshot() { MsvcEnvSnapshot env; -#if defined(_WIN32) - if (auto* v = std::getenv("VCToolsInstallDir"); v && *v) env.vcToolsInstallDir = v; - if (auto* v = std::getenv("VSINSTALLDIR"); v && *v) { - env.vsInstallDir = v; - } else if (auto* v2 = std::getenv("VCINSTALLDIR"); v2 && *v2) { - env.vsInstallDir = without_trailing_separator(v2).parent_path(); - } - if (auto* path = std::getenv("PATH"); path && *path) { - std::string_view rest = path; - while (!rest.empty()) { - auto semi = rest.find(';'); - auto dir = rest.substr(0, semi); - if (!dir.empty()) { - std::filesystem::path cl = std::filesystem::path(std::string(dir)) / "cl.exe"; - std::error_code ec; - if (std::filesystem::exists(cl, ec)) { env.clOnPath = cl; break; } + if constexpr (mcpplibs::os::is_windows) { + if (auto v = mcpp::invocation::get("VCToolsInstallDir")) env.vcToolsInstallDir = *v; + if (auto v = mcpp::invocation::get("VSINSTALLDIR")) { + env.vsInstallDir = *v; + } else if (auto v2 = mcpp::invocation::get("VCINSTALLDIR")) { + env.vsInstallDir = without_trailing_separator(*v2).parent_path(); + } + if (auto path = mcpp::invocation::get("PATH")) { + std::string_view rest = *path; + while (!rest.empty()) { + auto semi = rest.find(';'); + auto dir = rest.substr(0, semi); + if (!dir.empty()) { + std::filesystem::path cl = std::filesystem::path(std::string(dir)) / "cl.exe"; + std::error_code ec; + if (std::filesystem::exists(cl, ec)) { env.clOnPath = cl; break; } + } + if (semi == std::string_view::npos) break; + rest.remove_prefix(semi + 1); } - if (semi == std::string_view::npos) break; - rest.remove_prefix(semi + 1); } } -#endif return env; } @@ -1164,14 +1163,14 @@ system_installation_matching(std::string_view version, const ToolsetNeeds& needs } std::optional detect_installation() { -#if defined(_WIN32) - auto choice = select_system_toolset(enumerate_vs_instances(), msvc_env_snapshot(), - "system", ToolsetNeeds{}); - if (!choice) return std::nullopt; - return installation_from_tools_dir(choice->vsRoot, choice->toolsDir); -#else - return std::nullopt; -#endif + if constexpr (mcpplibs::os::is_windows) { + auto choice = select_system_toolset(enumerate_vs_instances(), msvc_env_snapshot(), + "system", ToolsetNeeds{}); + if (!choice) return std::nullopt; + return installation_from_tools_dir(choice->vsRoot, choice->toolsDir); + } else { + return std::nullopt; + } } std::vector @@ -1195,11 +1194,8 @@ sibling_sdk_roots(const std::filesystem::path& clPath) { // target: a cross-compiling link is the caller's business (`build_env_for_cl` // takes an arch), but a root with no host-arch libs at all is not an SDK this // machine can link against. The names are the SDK's own. -#if defined(_M_ARM64) || defined(__aarch64__) -constexpr std::string_view sdk_lib_arch = "arm64"; -#else -constexpr std::string_view sdk_lib_arch = "x64"; -#endif +constexpr std::string_view sdk_lib_arch = + mcpplibs::os::ARCH == mcpplibs::os::architecture::aarch64 ? "arm64" : "x64"; namespace { @@ -1256,8 +1252,8 @@ std::optional pick_sdk_in(const std::filesystem::path& root, // is not part of the directory name). Empty when unset. std::string declared_sdk_version() { std::string want; - if (auto* v = std::getenv("WindowsSdkVersion"); v && *v) { - want = v; + if (auto v = mcpp::invocation::get("WindowsSdkVersion")) { + want = *v; while (!want.empty() && (want.back() == '\\' || want.back() == '/')) want.pop_back(); } @@ -1325,8 +1321,8 @@ std::optional find_windows_sdk( // is removed so that one SDK has one spelling, and therefore one cache // key, inside and outside a developer environment. const std::string want = declared_sdk_version(); - if (auto* dir = std::getenv("WindowsSdkDir"); dir && *dir) { - std::filesystem::path root{dir}; + if (auto dir = mcpp::invocation::get("WindowsSdkDir")) { + std::filesystem::path root{*dir}; if (!root.has_filename() && root.has_relative_path()) root = root.parent_path(); if (auto s = pick_sdk_in(root, want)) return s; } @@ -1350,11 +1346,11 @@ std::vector machine_sdk_roots() { if (e.lexically_normal() == n) return; out.push_back(std::move(n)); }; - namespace win = mcpp::platform::windows; - add(win::machine_registry_path(L"SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots", - L"KitsRoot10")); + if constexpr (mcpplibs::os::is_windows) + add(mcpplibs::os::windows::machine_registry_path(L"SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots", + L"KitsRoot10")); for (const char* var : {"ProgramFiles(x86)", "ProgramFiles"}) - if (auto base = win::env_directory(var)) add(*base / "Windows Kits" / "10"); + if (auto base = mcpp::invocation::env_directory(var)) add(*base / "Windows Kits" / "10"); add(std::filesystem::path("C:\\Program Files (x86)\\Windows Kits\\10")); add(std::filesystem::path("C:\\Program Files\\Windows Kits\\10")); return out; @@ -1362,12 +1358,12 @@ std::vector machine_sdk_roots() { MsvcProbe probe_msvc() { MsvcProbe p; -#if defined(_WIN32) - for (auto const& inst : enumerate_vs_instances()) p.visualStudio.push_back(inst.root); - p.stl = find_std_module_source().has_value(); - p.sdk = find_windows_sdk(); - p.sdkSearched = machine_sdk_roots(); -#endif + if constexpr (mcpplibs::os::is_windows) { + for (auto const& inst : enumerate_vs_instances()) p.visualStudio.push_back(inst.root); + p.stl = find_std_module_source().has_value(); + p.sdk = find_windows_sdk(); + p.sdkSearched = machine_sdk_roots(); + } return p; } @@ -1431,16 +1427,16 @@ SdkChoice resolve_sdk_for(const std::filesystem::path& clPath) { } if (out.sdk) { - const char* dir = std::getenv("WindowsSdkDir"); - const char* ver = std::getenv("WindowsSdkVersion"); - if ((dir && *dir) || (ver && *ver)) { + const auto dir = mcpp::invocation::get("WindowsSdkDir"); + const auto ver = mcpp::invocation::get("WindowsSdkVersion"); + if (dir || ver) { out.note = std::format( "WindowsSdkDir/WindowsSdkVersion in the environment " "({}) is ignored: this build pins a managed MSVC toolset, and " "the Windows SDK it was installed with ({} at {}) is part of " "that pin. Use msvc@system if the machine's SDK is what you " "want.", - dir && *dir ? dir : ver, + dir ? *dir : *ver, out.sdk->version, out.sdk->root.string()); } return out; @@ -1462,35 +1458,35 @@ SdkChoice resolve_sdk_for(const std::filesystem::path& clPath) { } bool has_usable_msvc() { -#if defined(_WIN32) - // Both, deliberately — see the declaration for why either half alone is - // a trap. Order matters only for cost: the STL probe short-circuits the - // SDK directory scan on machines with no Visual Studio at all. - return find_std_module_source().has_value() && find_windows_sdk().has_value(); -#else - return false; -#endif + if constexpr (mcpplibs::os::is_windows) { + // Both, deliberately — see the declaration for why either half alone is + // a trap. Order matters only for cost: the STL probe short-circuits the + // SDK directory scan on machines with no Visual Studio at all. + return find_std_module_source().has_value() && find_windows_sdk().has_value(); + } else { + return false; + } } bool msvc_available_here([[maybe_unused]] const std::filesystem::path& pkgsDir) { -#if defined(_WIN32) - if (has_usable_msvc()) return true; - // A managed toolset is just as usable, and asking the machine about it - // gets the wrong answer. Any installed version whose cl.exe resolves - // counts; `installation_at` is the same resolution install and build use. - std::error_code ec; - auto root = mcpp::provider::layout().tool_in(pkgsDir, "msvc"); - if (!std::filesystem::is_directory(root, ec)) return false; - for (auto& v : std::filesystem::directory_iterator(root, ec)) { - if (!v.is_directory(ec)) continue; - // identifyVersion=false: this asks IF a toolset is here, never which. - if (installation_at(v.path(), v.path().filename().string(), - /*identifyVersion=*/false)) return true; + if constexpr (mcpplibs::os::is_windows) { + if (has_usable_msvc()) return true; + // A managed toolset is just as usable, and asking the machine about it + // gets the wrong answer. Any installed version whose cl.exe resolves + // counts; `installation_at` is the same resolution install and build use. + std::error_code ec; + auto root = mcpp::provider::layout().tool_in(pkgsDir, "msvc"); + if (!std::filesystem::is_directory(root, ec)) return false; + for (auto& v : std::filesystem::directory_iterator(root, ec)) { + if (!v.is_directory(ec)) continue; + // identifyVersion=false: this asks IF a toolset is here, never which. + if (installation_at(v.path(), v.path().filename().string(), + /*identifyVersion=*/false)) return true; + } + return false; + } else { + return false; } - return false; -#else - return false; -#endif } std::vector build_env_for_cl(const std::filesystem::path& clPath, @@ -1526,7 +1522,7 @@ std::vector build_env_for_cl(const std::filesystem::path& clPath, // The PATH mcpp was started with: the process's own has been given to the // children launched so far, this value among them. std::string path = clDir.string(); - if (auto const& p = mcpp::platform::env::inherited_path(); !p.empty()) { + if (auto const& p = mcpp::runtime::launch_env::inherited_path(); !p.empty()) { path += ';'; path += p; } @@ -1582,7 +1578,7 @@ std::string compiler_version_in_tools_dir(const std::filesystem::path& toolsDir, // another build, and the version said must be the one the driver would // have chosen, or the flag would change what a build compiles for. const std::string_view host = - mcpp::platform::host_arch == std::string_view("x86_64") ? "Hostx64" : "Hostx86"; + mcpp::toolchain::platform::host_arch == std::string_view("x86_64") ? "Hostx64" : "Hostx86"; const auto target = msvc_arch_dir(archGnu); const auto cl = toolsDir / "bin" / host / target / "cl.exe"; std::error_code ec; @@ -1608,13 +1604,13 @@ std::string cl_stage_command(const Toolchain& tc, // cache on C: is the real CI layout). return std::format( "cd /d {} && {} /nologo {}{} /EHsc /O2 /W0{} /c {} /ifcOutput {} /Fo:{} 2>&1", - mcpp::platform::shell::quote(cacheDir.string()), - mcpp::platform::shell::quote(tc.binaryPath.string()), + mcpp::build::command_line::quote(cacheDir.string()), + mcpp::build::command_line::quote(tc.binaryPath.string()), cppStandardFlag, crtFlag.empty() ? std::string{} : std::format(" {}", crtFlag), extraRef, - mcpp::platform::shell::quote(source.string()), - mcpp::platform::shell::quote(ifcOut.string()), + mcpp::build::command_line::quote(source.string()), + mcpp::build::command_line::quote(ifcOut.string()), objName); } @@ -1682,7 +1678,7 @@ std::vector std_compat_build_commands( std::string_view cppStandardFlag, std::string_view crtFlag) { // std.compat imports std — reference the freshly staged std.ifc. auto ref = std::format(" /reference std={}", - mcpp::platform::shell::quote(std_bmi_path(cacheDir).string())); + mcpp::build::command_line::quote(std_bmi_path(cacheDir).string())); return { cl_stage_command(tc, cacheDir, cppStandardFlag, tc.stdCompatSource, std_compat_bmi_path(cacheDir), "std.compat.obj", diff --git a/src/toolchain/msvc_coroutines.cppm b/src/toolchain/msvc_coroutines.cppm index 405171a9..31217601 100644 --- a/src/toolchain/msvc_coroutines.cppm +++ b/src/toolchain/msvc_coroutines.cppm @@ -35,7 +35,7 @@ export module mcpp.toolchain.msvc_coroutines; import std; -import mcpp.platform; +import mcpp.run; import mcpp.toolchain.triple; export namespace mcpp::toolchain::msvc_coroutines { @@ -304,7 +304,7 @@ std::optional run_probe(const FailedCommand& cmd) { if (!cmd.standard.empty()) argv.push_back("-std=" + cmd.standard); for (std::string_view a : {"-dM", "-E", "-x", "c++"}) argv.emplace_back(a); argv.push_back(empty.string()); - auto r = mcpp::platform::process::capture_host_tool_stdout(argv); + auto r = mcpp::run::capture_host_tool_stdout(argv); std::filesystem::remove(empty, ec); if (r.exit_code != 0 || r.output.empty()) return std::nullopt; diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index ecc7ab8a..eb454202 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -19,7 +19,8 @@ import std; import mcpp.config; import mcpp.libs.json; import mcpp.log; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.run; import mcpp.runtime.binding; import mcpp.toolchain.linkmodel; import mcpp.toolchain.registry; @@ -142,7 +143,7 @@ select_glibc_payload_lib(const std::filesystem::path& glibcRoot, // that this fixup has nothing to do with (there is no ucrt payload to // bind to — it is an OS component), and "not a glibc payload // identity" reads as "malformed" for it. - auto provider = mcpp::platform::runtime::runtime_provider(runtimeId); + auto provider = mcpp::runtime::runtime_provider(runtimeId); return std::unexpected(provider.empty() ? std::format( "selected RuntimeBinding '{}' is not a glibc payload identity " @@ -189,7 +190,7 @@ select_glibc_payload_lib(const std::filesystem::path& glibcRoot, // that once lived here are gone). Empty on every other payload and on // Windows, where a PE toolchain has no C runtime payload. export std::string_view runtime_payload_kind(const XimToolchainPackage& pkg) { - if constexpr (mcpp::platform::is_windows) return {}; + if constexpr (mcpplibs::os::is_windows) return {}; if (pkg.needsGccPostInstallFixup) return "gcc"; if (pkg.ximName == "llvm") return "llvm"; return {}; @@ -402,7 +403,7 @@ std::filesystem::path write_clean_link_specs( // An argument vector: the command-string form carried `2>/dev/null`, which // cmd.exe cannot open, so on a Windows host this probe printed a spurious // path error in every prepared GCC build and never produced the file. - auto r = mcpp::platform::process::capture_stdout( + auto r = mcpp::run::capture_stdout( {compilerBin.string(), "-dumpspecs"}); if (r.exit_code != 0 || r.output.empty()) return {}; diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm index db14ee1a..df24801d 100644 --- a/src/toolchain/probe.cppm +++ b/src/toolchain/probe.cppm @@ -1,14 +1,8 @@ // mcpp.toolchain.probe - common compiler probing helpers. // -// NOTE: This file contains its own run_capture() helper that returns -// std::expected — a different signature from -// mcpp::process::run_capture() (which returns RunResult). Do NOT migrate -// existing callers here without care. For new process invocations that do -// not need DetectError propagation, prefer mcpp::process::run_capture from -// the mcpp.process module. - -module; -#include // getenv +// A compiler is asked by argument vector, with the environment it needs as +// additions (mcpp.run): no command interpreter stands between mcpp and the +// compiler it queries. export module mcpp.toolchain.probe; @@ -17,7 +11,12 @@ import mcpp.toolchain.model; import mcpp.toolchain.triple; import mcpp.provider; import mcpp.provider.select; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.apple_sdk; +import mcpp.invocation; +import mcpp.effects; +import mcpp.build.command_line; +import mcpp.run; import mcpp.log; import mcpp.home; // the probe cache lives under the home's cache import mcpp.toolchain.fingerprint; // hash_string @@ -26,7 +25,12 @@ import mcpp.fallback.probe_sysroot; export namespace mcpp::toolchain { -std::expected run_capture(const std::string& cmd); +// A compiler query: `argv` with `env` added, its standard output (with its +// standard error when `withErrors`), answered from the probe cache when the +// same query of the same files was answered before. +std::expected run_capture(const std::vector& argv, + const mcpp::run::Env& env = {}, + bool withErrors = false); std::string extract_version(std::string_view s); std::string first_line_of(std::string_view s); @@ -41,6 +45,11 @@ std::vector discover_link_runtime_dirs(const std::filesystem::path& compilerBin, std::string_view targetTriple); +// What a compiler of this toolchain needs added to its environment: the +// directories of its own runtime libraries on the loader search path (Linux). +mcpp::run::Env compiler_env(const Toolchain& tc); +// The same, as the prefix of a command line composed as text (a std module's +// build commands, recorded in its metadata). std::string compiler_env_prefix(const Toolchain& tc); std::expected @@ -48,11 +57,11 @@ probe_compiler_binary(const std::filesystem::path& explicit_compiler = {}); std::expected probe_target_triple(const std::filesystem::path& compilerBin, - const std::string& envPrefix); + const mcpp::run::Env& env); std::filesystem::path probe_sysroot(const std::filesystem::path& compilerBin, - const std::string& envPrefix); + const mcpp::run::Env& env); // Resolve the payload paths for an EXPLICIT runtime binding ("glibc@2.39"). // @@ -76,6 +85,25 @@ namespace mcpp::toolchain { namespace { +// The directories an LLVM payload keeps its runtime libraries in beside lib/: +// the triple-named ones on Linux, darwin's on macOS. +std::vector llvm_runtime_lib_dirs(const std::filesystem::path& root) { + std::vector dirs; + std::error_code ec; + auto add = [&](const std::filesystem::path& p) { + if (std::filesystem::exists(p, ec)) dirs.push_back(p); + }; + if constexpr (mcpplibs::os::is_linux) { + add(root / "lib" / "x86_64-unknown-linux-gnu"); + add(root / "lib" / "aarch64-unknown-linux-gnu"); + add(root / "lib" / "aarch64-linux-gnu"); + } else if constexpr (mcpplibs::os::is_macos) { + add(root / "lib" / "aarch64-apple-darwin"); + add(root / "lib" / "darwin"); + } + return dirs; +} + void append_existing_unique(std::vector& out, const std::filesystem::path& p) { std::error_code ec; @@ -95,9 +123,6 @@ std::string join_colon_paths(const std::vector& dirs) { return joined; } -std::string env_prefix_for_dirs(const std::vector& dirs) { - return mcpp::platform::linux_::build_clean_ld_library_path_prefix(dirs); -} } // namespace @@ -110,13 +135,13 @@ namespace { // file the command names (the compiler, its configuration), so a payload // replaced in place, a different environment prefix or another compiler is a // different key. Only a successful answer is kept; a failed query runs again. -std::string probe_cache_key(const std::string& cmd) { - std::string key = cmd; - std::size_t pos = 0; - while ((pos = cmd.find('\'', pos)) != std::string::npos) { - const auto end = cmd.find('\'', pos + 1); - if (end == std::string::npos) break; - const std::filesystem::path p(cmd.substr(pos + 1, end - pos - 1)); +std::string probe_cache_key(const std::vector& argv, const mcpp::run::Env& env, + bool withErrors) { + std::string key = withErrors ? "merged" : "output"; + for (auto const& [k, v] : env) key += std::format("\x1e{}={}", k, v); + for (auto const& a : argv) key += "\x1f" + a; + for (auto const& a : argv) { + const std::filesystem::path p(a); std::error_code ec; if (p.is_absolute() && std::filesystem::is_regular_file(p, ec)) { const auto size = std::filesystem::file_size(p, ec); @@ -125,7 +150,6 @@ std::string probe_cache_key(const std::string& cmd) { static_cast(size), static_cast(time.time_since_epoch().count())); } - pos = end + 1; } return key; } @@ -134,20 +158,25 @@ std::filesystem::path probe_cache_file(const std::string& key) { } } // namespace -std::expected run_capture(const std::string& cmd) { - const auto key = probe_cache_key(cmd); +std::expected run_capture(const std::vector& argv, + const mcpp::run::Env& env, bool withErrors) { + const auto key = probe_cache_key(argv, env, withErrors); const auto cached = probe_cache_file(key); if (std::ifstream in(cached, std::ios::binary); in) { std::string recordedKey; std::getline(in, recordedKey, '\0'); if (recordedKey == key) return std::string(std::istreambuf_iterator(in), {}); } - auto r = mcpp::platform::process::capture_host_tool(cmd); + auto r = mcpp::run::execute(argv, { .env = env, .host_tool = true, + .streams = withErrors ? mcpp::run::Streams::merged + : mcpp::run::Streams::output, + .report_spawn_failure = false }); + const auto cmd = mcpp::build::command_line::display(argv); // Written only by a run that writes the global cache: a query (`self // doctor`, `toolchain list`) reads the answer and leaves the home as it // found it, and a cold home is never created for it. std::error_code homeEc; - if (r.exit_code == 0 && mcpp::platform::env::writes_global_cache() + if (r.exit_code == 0 && mcpp::effects::writes_global_cache() && std::filesystem::is_directory(mcpp::home::root(), homeEc)) { std::error_code ec; std::filesystem::create_directories(cached.parent_path(), ec); @@ -256,9 +285,7 @@ discover_compiler_runtime_dirs(const std::filesystem::path& compilerBin) { || exe.find("clang") != std::string::npos; if (looksLikeLlvm) { append_existing_unique(dirs, root / "lib"); - for (auto& d : mcpp::platform::linux_::runtime_lib_dirs(root)) - append_existing_unique(dirs, d); - for (auto& d : mcpp::platform::macos::runtime_lib_dirs(root)) + for (auto& d : llvm_runtime_lib_dirs(root)) append_existing_unique(dirs, d); } @@ -301,13 +328,11 @@ discover_link_runtime_dirs(const std::filesystem::path& compilerBin, auto root = compilerBin.parent_path().parent_path(); if (!targetTriple.empty()) append_existing_unique(dirs, root / "lib" / std::string(targetTriple)); - for (auto& d : mcpp::platform::linux_::runtime_lib_dirs(root)) - append_existing_unique(dirs, d); - for (auto& d : mcpp::platform::macos::runtime_lib_dirs(root)) + for (auto& d : llvm_runtime_lib_dirs(root)) append_existing_unique(dirs, d); append_existing_unique(dirs, root / "lib"); - if constexpr (mcpp::platform::is_linux) { + if constexpr (mcpplibs::os::is_linux) { if (auto rt = mcpp::provider::beside_tool(compilerBin, "gcc-runtime")) { append_existing_unique(dirs, *rt / "lib64"); append_existing_unique(dirs, *rt / "lib"); @@ -316,8 +341,19 @@ discover_link_runtime_dirs(const std::filesystem::path& compilerBin, return dirs; } +mcpp::run::Env compiler_env(const Toolchain& tc) { + if constexpr (!mcpplibs::os::is_linux) return {}; + if (tc.compilerRuntimeDirs.empty()) return {}; + std::string joined; + for (auto const& d : tc.compilerRuntimeDirs) { + if (!joined.empty()) joined += ':'; + joined += d.string(); + } + return { { "LD_LIBRARY_PATH", joined } }; +} + std::string compiler_env_prefix(const Toolchain& tc) { - return env_prefix_for_dirs(tc.compilerRuntimeDirs); + return mcpp::build::command_line::clean_ld_library_path_prefix(tc.compilerRuntimeDirs); } std::expected @@ -333,13 +369,13 @@ probe_compiler_binary(const std::filesystem::path& explicit_compiler) { } std::string cxx; - if (auto* e = std::getenv("CXX"); e && *e) { - cxx = e; + if (auto e = mcpp::invocation::get("CXX")) { + cxx = *e; } else { cxx = "g++"; } - auto found = mcpp::platform::fs::which(cxx); + auto found = mcpp::invocation::which(cxx); if (!found) { return std::unexpected(DetectError{std::format("compiler '{}' not found in PATH", cxx)}); } @@ -349,18 +385,15 @@ probe_compiler_binary(const std::filesystem::path& explicit_compiler) { std::expected probe_target_triple(const std::filesystem::path& compilerBin, - const std::string& envPrefix) { - auto triple_r = run_capture(std::format("{}{} -dumpmachine {}", - envPrefix, - mcpp::platform::shell::quote(compilerBin.string()), - mcpp::platform::null_redirect)); + const mcpp::run::Env& env) { + auto triple_r = run_capture({ compilerBin.string(), "-dumpmachine" }, env); if (!triple_r) return std::unexpected(triple_r.error()); return trim_line(*triple_r); } std::filesystem::path probe_sysroot(const std::filesystem::path& compilerBin, - const std::string& envPrefix) { + const mcpp::run::Env& env) { // A sysroot is only usable if it actually carries the C library headers. // A merely-existing directory (e.g. a partially-bootstrapped sandbox // subos) would silently shadow the payload -isystem fallback and produce @@ -371,10 +404,7 @@ probe_sysroot(const std::filesystem::path& compilerBin, }; // 1. Ask the compiler directly (works for GCC; Clang often doesn't support it). - auto r = run_capture(std::format("{}{} -print-sysroot {}", - envPrefix, - mcpp::platform::shell::quote(compilerBin.string()), - mcpp::platform::null_redirect)); + auto r = run_capture({ compilerBin.string(), "-print-sysroot" }, env); if (r) { auto s = trim_line(*r); diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index 21b4b791..49b90753 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -18,7 +18,8 @@ export module mcpp.toolchain.registry; import std; import mcpp.libs.json; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.provider; import mcpp.provider.select; import mcpp.toolchain.clang; @@ -471,7 +472,7 @@ bool ends_with(std::string_view s, std::string_view suf) { triple::Triple host_musl_triple() { - return { std::string(mcpp::platform::host_arch), "linux", "musl" }; + return { std::string(mcpp::toolchain::platform::host_arch), "linux", "musl" }; } } // namespace @@ -711,8 +712,8 @@ bool gcc_native_payload_is_musl(std::string_view hostArch, bool isLinux, // `windows-x86_64`. Not the target -- a Linux x86_64 machine building for // aarch64 still reads `linux-x86_64`. std::string ndk_host_tag() { - if constexpr (mcpp::platform::is_windows) return "windows-x86_64"; - else if constexpr (mcpp::platform::is_macos) return "darwin-x86_64"; + if constexpr (mcpplibs::os::is_windows) return "windows-x86_64"; + else if constexpr (mcpplibs::os::is_macos) return "darwin-x86_64"; else return "linux-x86_64"; } @@ -853,7 +854,7 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { pkg.ximName = "android-ndk"; pkg.frontendSubdir = std::format("toolchains/llvm/prebuilt/{}/bin", ndk_host_tag()); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { pkg.frontendCandidates = { "clang++.exe", "clang++" }; } else { pkg.frontendCandidates = { "clang++", "clang" }; @@ -895,8 +896,8 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { // arch, else the triple-named cross package. Canonical linux-musl // triples coincide with the GNU tool spelling, so `-g++` is // the frontend either way. - bool native = mcpp::platform::is_linux - && t.arch == mcpp::platform::host_arch; + bool native = mcpplibs::os::is_linux + && t.arch == mcpp::toolchain::platform::host_arch; pkg.ximName = native ? "musl-gcc" : t.str() + "-gcc"; // Frontend candidates are resolved with filesystem::exists, so on a // Windows host the bare name never matches — the file on disk is @@ -905,7 +906,7 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { // windows-hosted musl payload install fine and then be unusable. // `.exe` first: on a case-insensitive filesystem both would match, and // the executable is the one we want. - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { pkg.frontendCandidates = { t.str() + "-g++.exe", t.str() + "-g++", "g++.exe", "g++" }; } else { @@ -915,14 +916,14 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { } if (t.is_windows_gnu() - || (t.empty() && mcpp::platform::is_windows)) { + || (t.empty() && mcpplibs::os::is_windows)) { // GCC targeting Windows PE (GNU CRT) — ONE user-facing identity, // host-split at the distribution layer only: // Windows host → native winlibs UCRT build (PE frontend g++.exe) // other hosts → Linux-hosted MSVCRT cross (ELF frontend, triple- // prefixed so a cross build never silently falls // back to a native g++) - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { pkg.ximName = "mingw-gcc"; pkg.frontendCandidates = {"g++.exe", "g++"}; } else { @@ -948,8 +949,8 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { // its half the same way ("same target, two payload shapes"). The knowledge // belongs here rather than in every manifest: a user should not have to // encode which architectures a toolchain package was built for. - if (gcc_native_payload_is_musl(mcpp::platform::host_arch, - mcpp::platform::is_linux, t)) { + if (gcc_native_payload_is_musl(mcpp::toolchain::platform::host_arch, + mcpplibs::os::is_linux, t)) { const auto mt = host_musl_triple(); pkg.ximName = "musl-gcc"; pkg.frontendCandidates = { mt.str() + "-g++", "g++" }; @@ -1316,7 +1317,7 @@ bool host_can_serve(const triple::Triple& target) { // The first version returned true unconditionally, which is the same // mistake as the branches it sits above: a predicate correct about the // objects its author had in mind. It was then narrowed to - // `mcpp::platform::is_linux`, because `xim:emsdk` and `xim:android-ndk` + // `mcpplibs::os::is_linux`, because `xim:emsdk` and `xim:android-ndk` // both declared ONLY an `xpm.linux` table -- so on macOS or Windows there // was no payload to install and the honest answer was the same // `host-cannot-serve` every other unpublished combination gets. That @@ -1344,7 +1345,7 @@ bool host_can_serve(const triple::Triple& target) { if (target.has_own_sysroot()) return true; if (target.os == "linux") { - if constexpr (mcpp::platform::is_linux) { + if constexpr (mcpplibs::os::is_linux) { // "SELF-CONTAINED" IS ABOUT THE PAYLOAD'S CONTENTS, NOT ABOUT // WHICH HOSTS IT IS PUBLISHED FOR — and this line read it as both. // @@ -1361,9 +1362,9 @@ bool host_can_serve(const triple::Triple& target) { // // The native row stays reachable on every arch: `musl-gcc` // publishes both, which is why `aarch64-linux-musl` is `ok` there. - const bool crossArch = target.arch != mcpp::platform::host_arch; + const bool crossArch = target.arch != mcpp::toolchain::platform::host_arch; if (target.is_musl()) - return !crossArch || mcpp::platform::host_arch == "x86_64"; + return !crossArch || mcpp::toolchain::platform::host_arch == "x86_64"; return !crossArch; } // Non-Linux host: only the self-contained musl payloads can work at @@ -1371,18 +1372,18 @@ bool host_can_serve(const triple::Triple& target) { // payload exists — the windows-hosted canadian cross, built per host // arch — so an arch-crossing combination stays unserviceable until one // is published. macOS has no Linux-targeting payload at all. - return mcpp::platform::is_windows + return mcpplibs::os::is_windows && target.is_musl() - && target.arch == mcpp::platform::host_arch; + && target.arch == mcpp::toolchain::platform::host_arch; } // THE mingw CROSS IS PUBLISHED FOR ONE HOST ARCH. `mingw-cross-gcc` // declares `archs = { "x86_64" }`, so a Linux host that is not x86_64 // cannot obtain it — measured on ubuntu-24.04-arm, where the row was // listed and its refusal carried no reason at all (`unsupported / other`). if (target.is_windows_gnu()) { - if constexpr (mcpp::platform::is_windows) return true; - return mcpp::platform::is_linux - && mcpp::platform::host_arch == "x86_64"; + if constexpr (mcpplibs::os::is_windows) return true; + return mcpplibs::os::is_linux + && mcpp::toolchain::platform::host_arch == "x86_64"; } // PE + musl HAS NO PAYLOAD ON ANY HOST, INCLUDING WINDOWS. // @@ -1407,7 +1408,7 @@ bool host_can_serve(const triple::Triple& target) { // when nothing supplies the target's system, and `graph × windows-musl` is // `ok` on both hosts. if (target.is_pe() && target.is_musl()) return false; - if (target.os == "windows") return bool(mcpp::platform::is_windows); + if (target.os == "windows") return bool(mcpplibs::os::is_windows); // APPLE, AND ONE ARM FOR BOTH OF ITS PLATFORMS. // // This read `os == "macos"`, which was the same question while macOS was @@ -1425,7 +1426,7 @@ bool host_can_serve(const triple::Triple& target) { // // `is_apple()` is the predicate the table already carries, and until now // it had no reader at all. - if (target.is_apple()) return bool(mcpp::platform::is_macos); + if (target.is_apple()) return bool(mcpplibs::os::is_macos); // Bare metal: every host can serve it, and that is a property of the // toolchain rather than a claim about payload coverage. clang and lld are @@ -1449,9 +1450,9 @@ bool host_can_serve(const triple::Triple& target) { std::vector available_toolchain_indexes() { // LLVM payloads are published for Linux x86_64 and aarch64. Other // Linux architectures retain the musl GCC default and omit LLVM. - const bool linuxWithoutLlvm = mcpp::platform::is_linux - && mcpp::platform::host_arch != "x86_64" - && mcpp::platform::host_arch != "aarch64"; + const bool linuxWithoutLlvm = mcpplibs::os::is_linux + && mcpp::toolchain::platform::host_arch != "x86_64" + && mcpp::toolchain::platform::host_arch != "aarch64"; std::vector out{ { "gcc", Family::Gcc }, @@ -1461,7 +1462,7 @@ std::vector available_toolchain_indexes() { out.push_back({ mcpp::toolchain::llvm::package_name(), Family::Llvm }); // The Windows-PE gcc payload is host-split at the distribution layer // (§4.3); each host lists the package it would actually install. - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { // Pinned MSVC toolsets. Listing them is what makes the managed origin // discoverable at all: without a row here `toolchain list --available` // says gcc and llvm can be pinned and msvc cannot, which stopped being @@ -1471,11 +1472,11 @@ std::vector available_toolchain_indexes() { // The windows-hosted canadian cross to Linux. Named by triple, exactly // as to_xim_package() derives it (`-gcc`), so the Available // listing and the install path cannot disagree about the package name. - out.push_back({ std::string(mcpp::platform::host_arch) + "-linux-musl-gcc", + out.push_back({ std::string(mcpp::toolchain::platform::host_arch) + "-linux-musl-gcc", Family::Gcc }); - } else if constexpr (mcpp::platform::is_linux) { + } else if constexpr (mcpplibs::os::is_linux) { // Same gate: `mingw-cross-gcc` publishes x86_64 only. - if (mcpp::platform::host_arch == "x86_64") + if (mcpp::toolchain::platform::host_arch == "x86_64") out.push_back({ "mingw-cross-gcc", Family::Gcc }); } return out; @@ -1501,7 +1502,7 @@ std::filesystem::path binutils_tool(const Toolchain& tc, std::string_view name) for (auto const& cand : {tc.toolPrefix + std::string(name), std::string("llvm-") + std::string(name), std::string(name)}) { - auto p = dir / (cand + std::string(mcpp::platform::exe_suffix)); + auto p = dir / (cand + std::string(mcpplibs::os::exe_suffix)); if (std::filesystem::exists(p, ec)) return p; } return {}; @@ -1510,7 +1511,7 @@ std::filesystem::path binutils_tool(const Toolchain& tc, std::string_view name) // Clang ships the whole family as `llvm-` beside the frontend. if (is_clang(tc)) { auto llvmTool = dir / (std::string("llvm-") + std::string(name) - + std::string(mcpp::platform::exe_suffix)); + + std::string(mcpplibs::os::exe_suffix)); if (std::filesystem::exists(llvmTool, ec)) return llvmTool; return {}; } @@ -1542,7 +1543,7 @@ std::filesystem::path binutils_tool(const Toolchain& tc, std::string_view name) // Same `.exe` reasoning as the frontend candidates above: a windows-hosted // musl cross payload ships `-ar.exe`. Try it first, then the bare // name (which is what every ELF host has). - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { auto muslExe = dir / (crossName + ".exe"); if (std::filesystem::exists(muslExe, ec)) return muslExe; } diff --git a/src/toolchain/stdmod.cppm b/src/toolchain/stdmod.cppm index 9ac7da14..564be382 100644 --- a/src/toolchain/stdmod.cppm +++ b/src/toolchain/stdmod.cppm @@ -33,7 +33,9 @@ export module mcpp.toolchain.stdmod; import std; import mcpp.home; import mcpp.libs.json; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.build.command_line; +import mcpp.run; import mcpp.toolchain.clang; import mcpp.toolchain.detect; import mcpp.toolchain.fingerprint; @@ -118,9 +120,7 @@ namespace { std::expected run_capture_command( const std::string& cmd, const std::vector>& env) { - auto r = env.empty() - ? mcpp::platform::process::capture(cmd) - : mcpp::platform::process::capture_with_env(cmd, env); + auto r = mcpp::run::capture_shell(cmd, env); if (r.exit_code != 0) { // Include the command: its --sysroot/-isystem flags are the first // thing needed to diagnose header-resolution failures. @@ -252,8 +252,8 @@ std::expected write_metadata(const std::filesystem::path& pat } // Toolchain-declared env (MSVC's INCLUDE/LIB/PATH) applies to every std -// module build command; empty for GCC/Clang (their LD_LIBRARY_PATH need is -// carried as an in-command `env` prefix on POSIX for now). +// module build command; empty for GCC/Clang, whose LD_LIBRARY_PATH need is +// part of the recorded command text (an `env` prefix on Linux). std::expected run_commands( const std::vector& commands, const Toolchain& tc) { std::vector> env; @@ -309,8 +309,8 @@ std::expected derive_std_module( // quotes: a path with a space -- the MSVC toolset of the clang row, always // under `C:\Program Files` -- has to arrive in double quotes. const PathEscape shellEsc = [](const std::filesystem::path& p) { - if constexpr (mcpp::platform::is_windows) - return mcpp::platform::shell::quote(p.string()); + if constexpr (mcpplibs::os::is_windows) + return mcpp::build::command_line::quote(p.string()); else return std::format("'{}'", p.string()); }; diff --git a/src/ui.cppm b/src/ui.cppm index fafc589c..a119a8d4 100644 --- a/src/ui.cppm +++ b/src/ui.cppm @@ -25,13 +25,16 @@ // when the log has been silent for a minute. module; +#include // EPIPE: a closed standard output #include // fileno, stdout, stderr export module mcpp.ui; import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.ui.terminal; import mcpp.log; +import mcpp.invocation; export namespace mcpp::ui { @@ -47,7 +50,7 @@ bool is_color_enabled(); // The stream that narrates: standard error. The live region, the progress // bars, the status row and every line below that is not a result are written // to it, and the region's terminal test, colour and size follow it. -mcpp::platform::terminal::Stream narration_stream(); +mcpp::ui::terminal::Stream narration_stream(); // Verb-style status ("Compiling foo v0.1.0" pattern), on the narration stream. // verb verb word, padded right-aligned in 12-char column @@ -153,6 +156,10 @@ void plain(std::string_view message); // when its last lines matter most. void flush(); +// Whether mcpp's standard output has lost its reader (`mcpp … | head`): a +// write to it was refused, and it is a pipe. +bool output_closed(); + // Make stdout line-buffered. Call once, before any output. See main() for why // this is not left to the libc default: the default block size is a different // number on every platform (musl 1024, Apple libc st_blksize = 65536 on a pipe, @@ -398,7 +405,7 @@ namespace mcpp::ui { namespace { -namespace term = mcpp::platform::terminal; +namespace term = mcpp::ui::terminal; // Colour is decided per stream, by what that stream is: a line for a pipe must // not carry escape sequences because the OTHER stream is a terminal. @@ -436,8 +443,8 @@ bool colored(term::Stream s) { return color_flag(s); } bool narration_colored() { return colored(kNarration); } bool detect_color(term::Stream s) { - if (auto* e = std::getenv("MCPP_NO_COLOR"); e && *e == '1') return false; - if (auto* e = std::getenv("NO_COLOR"); e && *e) return false; + if (auto e = mcpp::invocation::get("MCPP_NO_COLOR"); e && e->front() == '1') return false; + if (mcpp::invocation::get("NO_COLOR")) return false; // On Windows this also turns the console's escape processing on, without // which the colour sequences would be printed as text. const bool terminal = term::can_move_cursor(s); @@ -447,9 +454,8 @@ bool detect_color(term::Stream s) { // `FORCE_COLOR` (any value but "0" or "false") are the two conventions // such hosts set; NO_COLOR and MCPP_NO_COLOR, above, still win. Colour // only: the status row needs a terminal and stays off in a pipe. - if (auto* e = std::getenv("CLICOLOR_FORCE"); e && *e && std::string_view(e) != "0") return true; - if (auto* e = std::getenv("FORCE_COLOR"); e && *e && std::string_view(e) != "0" - && std::string_view(e) != "false") return true; + if (auto e = mcpp::invocation::get("CLICOLOR_FORCE"); e && *e != "0") return true; + if (auto e = mcpp::invocation::get("FORCE_COLOR"); e && *e != "0" && *e != "false") return true; return terminal; } @@ -735,8 +741,18 @@ void flush() { std::fflush(stderr); } +bool output_closed() { + errno = 0; + if (std::fflush(stdout) != 0 && errno == EPIPE) return true; + if (!std::ferror(stdout)) return false; + // An earlier write failed and its reason is gone with its errno. A write + // to a pipe is refused only when the pipe has no reader, so a standard + // output that is a pipe and has refused a write has lost its reader. + std::error_code ec; + return std::filesystem::status("/dev/stdout", ec).type() == std::filesystem::file_type::fifo; +} + void set_line_buffered() { -#if defined(_WIN32) // Not on Windows, and not as a preference. The UCRT documents setvbuf's // size as `2 <= size <= INT_MAX`, and a zero goes through the // invalid-parameter handler, whose default action terminates the process: @@ -749,9 +765,7 @@ void set_line_buffered() { // function here calls, so the platform that cannot do this cheaply is also // the one that does not need it: its 4096-byte block is the smallest of the // three anyway, and `mcpp test` routes its result lines through ui::plain. -#else - std::setvbuf(stdout, nullptr, _IOLBF, 0); -#endif + if constexpr (!mcpplibs::os::is_windows) std::setvbuf(stdout, nullptr, _IOLBF, 0); } void status(std::string_view verb, std::string_view message) { diff --git a/src/ui/terminal.cpp b/src/ui/terminal.cpp new file mode 100644 index 00000000..bf6a0747 --- /dev/null +++ b/src/ui/terminal.cpp @@ -0,0 +1,260 @@ +// mcpp.ui.terminal over mcpplibs.os. +module; +#include +#include // stdout, stderr: macros, which `import std` does not carry + +module mcpp.ui.terminal; + +import std; +import mcpplibs.os.platform; +import mcpplibs.os.terminal; +import mcpplibs.os.stdio; +import mcpplibs.os.windows; +import mcpp.invocation; +import mcpp.run; + +namespace mcpp::ui::terminal { + +namespace os = mcpplibs::os; + +namespace { + +std::FILE* file_of(Stream s) { return s == Stream::Out ? stdout : stderr; } +os::terminal::stream stream_of(Stream s) { + return s == Stream::Out ? os::terminal::stream::out : os::terminal::stream::err; +} + +} // namespace + +bool is_terminal(Stream s) { + return os::terminal::interactive(stream_of(s)); +} + +bool input_is_terminal() { + return os::terminal::interactive(os::terminal::stream::in); +} + +bool is_tty() { return is_terminal(Stream::Out); } + +bool can_move_cursor(Stream s) { + if (!is_terminal(s)) return false; + if (mcpp::invocation::get("TERM") == "dumb") return false; + // A POSIX terminal is taken to obey them; a console is asked, and is + // switched to virtual terminal processing by the asking. + if constexpr (os::is_windows) return os::terminal::sequences(stream_of(s)); + else return true; +} + +namespace { +std::size_t from_env(const char* name, std::size_t fallback) { + if (auto e = mcpp::invocation::get(name)) { + try { auto n = std::stoul(*e); if (n > 0) return n; } catch (...) {} + } + return fallback; +} +} // namespace + +std::size_t cols(Stream s) { + if (const auto size = os::terminal::size(stream_of(s)); size && size->columns > 0) + return size->columns; + return from_env("COLUMNS", 80); +} + +std::size_t rows(Stream s) { + if (const auto size = os::terminal::size(stream_of(s)); size && size->rows > 0) + return size->rows; + return from_env("LINES", 24); +} + +namespace { +// The whole of `bytes` to the stream through mcpplibs.os, which gives a +// console UTF-16 (openkal K10): text outside ASCII appears as written +// whatever the console's code page is. +bool write_through_os(Stream s, std::string_view bytes) { + return (s == Stream::Out ? os::stdio::write_output(bytes) : os::stdio::write_error(bytes)).has_value(); +} +} // namespace + +void write(Stream s, std::string_view text) { + if (text.empty()) return; + if constexpr (os::is_windows) { + if (is_terminal(s)) { + std::fflush(file_of(s)); + if (write_through_os(s, text)) return; + } + } + errno = 0; + std::fwrite(text.data(), 1, text.size(), file_of(s)); + // A standard output that has lost its reader (`mcpp … | head`) ends mcpp + // in order at the first line it refuses (design 2026-10-26 §25.5). + if (s == Stream::Out && std::ferror(stdout) && errno == EPIPE) mcpp::run::end_for_closed_output(); +} + +void write_frame(Stream s, std::string_view bytes) { + if (bytes.empty()) return; + std::fflush(stdout); + std::fflush(stderr); + // One write of the whole frame (repeated by the library only for a + // partial write or an interruption); on Windows only to a console, the + // C runtime's text mode keeping the line ends of anything else. + if (!os::is_windows || is_terminal(s)) + if (write_through_os(s, bytes)) return; + std::fwrite(bytes.data(), 1, bytes.size(), file_of(s)); + std::fflush(file_of(s)); +} + +bool same_terminal() { + return is_terminal(Stream::Out) && is_terminal(Stream::Err); +} + +bool ambiguous_wide(Stream s) { + if constexpr (os::is_windows) { + const unsigned cp = os::windows::console_output_code_page(s == Stream::Err); + if (cp == 932 || cp == 936 || cp == 949 || cp == 950) return true; + } + for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { + const auto v = mcpp::invocation::get(name); + if (!v) continue; + const std::string_view l(*v); + return l.starts_with("zh") || l.starts_with("ja") || l.starts_with("ko"); + } + return false; +} + +bool unicode_capable() { + if constexpr (os::is_windows) { + for (const char* name : {"WT_SESSION", "TERM_PROGRAM"}) + if (mcpp::invocation::get(name)) return true; + return false; + } else { + for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { + const auto v = mcpp::invocation::get(name); + if (!v) continue; + std::string l(*v); + for (auto& c : l) c = static_cast(std::tolower(static_cast(c))); + return l.find("utf-8") != std::string::npos || l.find("utf8") != std::string::npos; + } + return false; + } +} + +std::vector decode_keys(std::string& pending) { + std::vector keys; + std::size_t i = 0; + while (i < pending.size()) { + const char c = pending[i]; + if (c == '\x1b') { + if (i + 1 >= pending.size()) break; // wait for the rest + if (pending[i + 1] != '[' && pending[i + 1] != 'O') { ++i; continue; } + // Parameter and intermediate bytes (0x20 to 0x3F), then one final + // byte (0x40 to 0x7E) that names the key. + std::size_t end = i + 2; + while (end < pending.size() + && static_cast(pending[end]) >= 0x20 + && static_cast(pending[end]) <= 0x3F) + ++end; + if (end >= pending.size()) break; // wait for the final byte + switch (pending[end]) { + case 'A': keys.push_back(Key::Up); break; + case 'B': keys.push_back(Key::Down); break; + case 'C': keys.push_back(Key::Right); break; + case 'D': keys.push_back(Key::Left); break; + default: break; + } + i = end + 1; + continue; + } + switch (c) { + case ' ': keys.push_back(Key::Space); break; + case 'w': case 'W': keys.push_back(Key::Up); break; + case 's': case 'S': keys.push_back(Key::Down); break; + case 'a': case 'A': keys.push_back(Key::Left); break; + case 'd': case 'D': keys.push_back(Key::Right); break; + default: break; + } + ++i; + } + pending.erase(0, i); + return keys; +} + +// ─── Keys for a game, over mcpplibs.os ─────────────────────────────────── +// +// The terminal in raw mode that keeps the interrupting keystroke, and reads that +// never wait. On Windows the arrows arrive as the same sequences, a console +// in raw mode reporting virtual-terminal input. + +struct KeyInput::State { + std::optional mode; + int cleanup = -1; +}; + +KeyInput::KeyInput(Stream display) : state_(std::make_unique()) { + if (!is_terminal(display) || !os::terminal::foreground()) return; + auto mode = os::terminal::raw_mode::enter({ .keep_interrupt = true }); + if (!mode) return; + state_->mode = std::move(*mode); + // A request to end mcpp while the game plays gives the terminal back first. + State* s = state_.get(); + state_->cleanup = mcpp::run::add_cleanup([s] { s->mode.reset(); }); + active_ = true; +} + +KeyInput::~KeyInput() { + if (!state_) return; + if (state_->cleanup >= 0) mcpp::run::remove_cleanup(state_->cleanup); + state_->mode.reset(); +} + +std::vector KeyInput::read() { + std::vector keys; + if (!active_) return keys; + for (;;) { + auto chunk = os::stdio::read_input_for(std::chrono::milliseconds(0), 64); + if (!chunk || !chunk->has_value() || (*chunk)->empty()) break; + pending_ += **chunk; + } + return decode_keys(pending_); +} + +// ─── Standard output sent to standard error ────────────────────────────── + +namespace { +std::mutex g_protocolLock; +os::stdio::output_to_error* g_protocol = nullptr; // the active diversion, if any +} // namespace + +struct StdoutToStderr::State { + std::optional diversion; +}; + +void write_protocol_stdout(std::string_view text) { + std::lock_guard g(g_protocolLock); + if (g_protocol) { + (void)g_protocol->write_original(text); + return; + } + std::fwrite(text.data(), 1, text.size(), stdout); + std::fflush(stdout); +} + +StdoutToStderr::StdoutToStderr() : state_(std::make_unique()) { + std::fflush(stdout); + auto d = os::stdio::output_to_error::begin(); + if (!d) return; + state_->diversion = std::move(*d); + std::lock_guard g(g_protocolLock); + if (!g_protocol) g_protocol = &*state_->diversion; +} + +StdoutToStderr::~StdoutToStderr() { + std::fflush(stdout); + if (!state_ || !state_->diversion) return; + { + std::lock_guard g(g_protocolLock); + if (g_protocol == &*state_->diversion) g_protocol = nullptr; + } + state_->diversion.reset(); +} + +} // namespace mcpp::ui::terminal diff --git a/src/ui/terminal.cppm b/src/ui/terminal.cppm new file mode 100644 index 00000000..7d15560a --- /dev/null +++ b/src/ui/terminal.cppm @@ -0,0 +1,160 @@ +// mcpp.ui.terminal — the terminal mcpp draws on, over mcpplibs.os: detection, +// size, UTF-8 output, a live display's frames, keys for a game, and standard +// output sent to standard error while a document is prepared. +// +// Provides: +// is_tty() — whether stdout is a terminal +// is_terminal(s) — whether a standard stream is a terminal +// can_move_cursor(s)— whether a live display may be drawn on it +// cols(s), rows(s) — the size of the terminal a stream reaches +// write(s, text) — UTF-8 text to a standard stream +// write_frame(s, b) — a frame of a live display, in one write +// same_terminal() — whether stdout and stderr reach one terminal +// KeyInput — keys read from the terminal while a game plays + +export module mcpp.ui.terminal; + +import std; + +export namespace mcpp::ui::terminal { + +// The two standard streams a human reads. +enum class Stream { Out, Err }; + +// Returns true if stdout is connected to a terminal (TTY). +bool is_tty(); +bool input_is_terminal(); + +// Whether the stream is a terminal: `isatty` on POSIX, macOS included, and a +// console on Windows. Until 2026.9.29.5 this was compiled only under +// `__unix__`, which Apple's compilers do not define, so macOS and Windows were +// never terminals and drew neither colour nor a live line. A Windows program +// under mintty writes to a pipe, not a console, and is not a terminal here. +bool is_terminal(Stream s); + +// Whether a display that moves the cursor may be drawn on the stream: a +// terminal, not `TERM=dumb`, and on Windows a console that accepted virtual +// terminal processing. The first call on Windows enables that processing, so +// that the escape sequences mcpp writes are interpreted rather than printed. +bool can_move_cursor(Stream s); + +// The width in columns of the terminal the stream reaches. Tries that terminal +// first (TIOCGWINSZ on the stream's descriptor, or the console's window on +// Windows), falls back to $COLUMNS, then to 80. The stream is a parameter +// because the display is drawn on one stream while the other may be a pipe: a +// width asked of a descriptor that is not a terminal is always the fallback. +std::size_t cols(Stream s); + +// The terminal's height in rows, by the same order, with $LINES and 24. +std::size_t rows(Stream s); + +// Writes UTF-8 text to the stream. A Windows console receives it as UTF-16 +// through WriteConsoleW, so that text outside ASCII (`·`, `→`, a path in +// Chinese) appears as written whatever the console's code page is; the stdio +// buffer is flushed first, so the two paths keep their order. Everything else +// receives the bytes through stdio, unflushed. +void write(Stream s, std::string_view text); + +// Writes a frame of a live display in one operation. The stdio buffers of +// both streams are flushed first, so that everything written before arrives +// before; the frame then leaves in one write(2) on POSIX (repeated only for a +// partial write or an interruption) and in one WriteConsoleW on a Windows +// console. Written through stdio, a frame whose last row has no line end was +// flushed in two parts on a line-buffered terminal, and a terminal that +// painted between them showed the display half-drawn (measured: 184 of 202 +// frames of one build, .agents/docs/2026-09-30-build-output-refinement-design.md +// F1). +void write_frame(Stream s, std::string_view bytes); + +// Whether standard error reaches the terminal standard output reaches: both +// are terminals (a process has at most one console on Windows, and on POSIX +// two terminals at once is a redirection to another one, which is left out). A line for the stream a live +// display is not drawn on can then travel in the frame written to the stream it +// is drawn on. +bool same_terminal(); + +// Whether the terminal the stream reaches is likely to draw East Asian +// ambiguous-width characters (`·`, `…`, `→`, the box-drawing block) two columns +// wide: a Windows console whose output code page is 932, 936, 949 or 950, or, +// on POSIX, a locale (LC_ALL, LC_CTYPE, then LANG) for Chinese, Japanese or +// Korean. The answer is a likelihood; a live display that budgets its width +// by it is never wider than the terminal either way. +bool ambiguous_wide(Stream s); + +// Whether the terminal can be expected to draw characters beyond ASCII from +// its font or a fallback, braille included: on POSIX a UTF-8 locale (LC_ALL, +// LC_CTYPE, then LANG); on Windows a terminal that names itself (Windows +// Terminal sets WT_SESSION, VS Code and others TERM_PROGRAM). The console +// host alone has no glyph fallback, and its long-standing default font lacks +// the braille block. +bool unicode_capable(); + +// The keys a game reads (build output design revision 3, §5.14): the arrows, +// the space bar, and `wasd` as a second set of arrows. +enum class Key { Up, Down, Left, Right, Space }; + +// The keys in `pending`, the bytes a POSIX terminal sent: an arrow is +// `ESC [ A` to `ESC [ D`, `ESC O A` to `ESC O D` in application mode, or +// `ESC [ 1 ; 5 A` and the like with a modifier; a sequence for any other key +// is skipped whole. An incomplete sequence at the end is left in `pending`. +std::vector decode_keys(std::string& pending); + +// KEYS READ FROM THE TERMINAL FOR THE LIFETIME OF THE OBJECT, without echo and +// without waiting for a line end; Ctrl-C still interrupts. Active only when +// standard input and the stream the display is drawn on (`display`) are both +// terminals and, on POSIX, mcpp is in the terminal's foreground process group: +// a background job that changed the terminal's mode would be stopped by +// SIGTTOU. The mode the terminal had is restored when the object is destroyed, +// and before mcpp ends if a request to end it arrives first (mcpp.run's +// watcher runs the restoration among its cleanups). +class KeyInput { +public: + explicit KeyInput(Stream display); + ~KeyInput(); + KeyInput(const KeyInput&) = delete; + KeyInput& operator=(const KeyInput&) = delete; + bool active() const { return active_; } + // The keys pressed since the last call; never blocks. + std::vector read(); +private: + struct State; + std::unique_ptr state_; + bool active_ = false; + std::string pending_; // an escape sequence not yet complete +}; + +// EVERYTHING WRITTEN TO STANDARD OUTPUT GOES TO STANDARD ERROR UNTIL THIS IS +// DESTROYED. +// +// The redirection is made at the file descriptor, so a child process that +// inherits standard output follows it, and so does narration that prints to +// stdout without consulting any quiet flag. A command whose standard output is +// a document (`mcpp emit build-database`) plans under one of these and prints +// the document after it is gone: people still see the progress, on stderr, and +// the document arrives alone. +// +// THE SAVED OUTPUT IS NOT INHERITED (mcpplibs.os `output_to_error`). It is the +// caller's pipe, and a child started during the redirection (a build program, +// an xlings refresh) that inherited it would keep the caller from reading +// end-of-file for as long as the child lives, however long after mcpp itself +// exited. Measured (mcpp-community/mcpp#648): a build program started by +// `emit build-database` held the caller's pipe as its descriptor 3. +// Writes `text` to the process's standard output as it was before any +// StdoutToStderr took it: a machine-readable stream (the build's NDJSON +// events) keeps the channel the protocol owns while the narration around it is +// redirected. +void write_protocol_stdout(std::string_view text); + +class StdoutToStderr { +public: + StdoutToStderr(); + ~StdoutToStderr(); + StdoutToStderr(const StdoutToStderr&) = delete; + StdoutToStderr& operator=(const StdoutToStderr&) = delete; +private: + struct State; + std::unique_ptr state_; +}; + +} // namespace mcpp::ui::terminal + diff --git a/src/wire.cppm b/src/wire.cppm index b14b5931..8e1d871c 100644 --- a/src/wire.cppm +++ b/src/wire.cppm @@ -50,7 +50,7 @@ export module mcpp.wire; import std; import mcpp.version; import mcpp.libs.json; -import mcpp.platform; // env::network_accessed (#648 A4) +import mcpp.effects; export namespace mcpp::wire { @@ -264,7 +264,7 @@ inline nlohmann::json to_json(const Envelope& e) { // `effects` states what running the command did (docs/50 section 2). Network // access is observed where it happens rather than declared by each command, // so every enveloped command reports it the same way. - if (mcpp::platform::env::network_accessed() + if (mcpp::effects::network_accessed() && std::ranges::find(e.effects, Effect::Network) == e.effects.end()) effects.push_back(std::string(effect_name(Effect::Network))); diff --git a/tests/unit/test_artifact_naming.cpp b/tests/unit/test_artifact_naming.cpp index 65698685..e14b539a 100644 --- a/tests/unit/test_artifact_naming.cpp +++ b/tests/unit/test_artifact_naming.cpp @@ -1,13 +1,14 @@ #include import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.toolchain.triple; // How a built artifact is NAMED is a property of the TARGET, never of the // machine doing the build. // -// mcpp used to answer it with mcpp::platform::{exe_suffix,lib_prefix, +// mcpp used to answer it with the host's {exe_suffix,lib_prefix, // static_lib_ext,shared_lib_ext} — host constants selected by // `#if defined(_WIN32)/__APPLE__`. On a host build the two questions coincide, // which is why this survived; it only diverges once host != target. @@ -157,17 +158,17 @@ TEST(ArtifactNaming, EmscriptenSharedIsUnsupported) { // what the old code produced on this machine. TEST(ArtifactNaming, RealHostConstantsRoundTrip) { const tr::ArtifactNaming host{ - .exeSuffix = mcpp::platform::exe_suffix, - .libPrefix = mcpp::platform::lib_prefix, - .staticLibExt = mcpp::platform::static_lib_ext, - .sharedLibExt = mcpp::platform::shared_lib_ext, - .sharedNeedsImportLib = mcpp::platform::is_windows, + .exeSuffix = mcpplibs::os::exe_suffix, + .libPrefix = mcpp::toolchain::platform::lib_prefix, + .staticLibExt = mcpp::toolchain::platform::static_lib_ext, + .sharedLibExt = mcpp::toolchain::platform::shared_lib_ext, + .sharedNeedsImportLib = mcpplibs::os::is_windows, }; auto n = tr::artifact_naming(tr::Triple{}, host); - EXPECT_EQ(n.exeSuffix, mcpp::platform::exe_suffix); - EXPECT_EQ(n.libPrefix, mcpp::platform::lib_prefix); - EXPECT_EQ(n.staticLibExt, mcpp::platform::static_lib_ext); - EXPECT_EQ(n.sharedLibExt, mcpp::platform::shared_lib_ext); + EXPECT_EQ(n.exeSuffix, mcpplibs::os::exe_suffix); + EXPECT_EQ(n.libPrefix, mcpp::toolchain::platform::lib_prefix); + EXPECT_EQ(n.staticLibExt, mcpp::toolchain::platform::static_lib_ext); + EXPECT_EQ(n.sharedLibExt, mcpp::toolchain::platform::shared_lib_ext); } // ── #622 A3: the link form of `kind = "app"` is a property of the row ─────── diff --git a/tests/unit/test_build_database.cpp b/tests/unit/test_build_database.cpp index fa00df15..66a7a730 100644 --- a/tests/unit/test_build_database.cpp +++ b/tests/unit/test_build_database.cpp @@ -10,7 +10,7 @@ import std; import mcpp.build.build_database; import mcpp.modgraph.graph; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.toolchain.clang; import mcpp.toolchain.gcc; import mcpp.toolchain.model; @@ -22,7 +22,7 @@ using namespace mcpp::toolchain; namespace { -const bool kWindows = mcpp::platform::is_windows; +const bool kWindows = mcpplibs::os::is_windows; std::filesystem::path host_path(std::string_view posix, std::string_view windows) { return std::filesystem::path{std::string(kWindows ? windows : posix)}; diff --git a/tests/unit/test_build_profile.cpp b/tests/unit/test_build_profile.cpp index cf7899b9..e2e7fae3 100644 --- a/tests/unit/test_build_profile.cpp +++ b/tests/unit/test_build_profile.cpp @@ -15,8 +15,10 @@ #include import std; +import mcpp.invocation; import mcpp.build.prepare; import mcpp.manifest; +import mcpplibs.os.platform; namespace { @@ -33,24 +35,10 @@ mcpp::manifest::Manifest base() { // already in the environment) cannot leak into a sibling test in this binary. class ScopedDeploymentTargetEnv { public: - explicit ScopedDeploymentTargetEnv(const char* value) { - if (const char* old = std::getenv(kName); old) { had_ = true; old_ = old; } - apply(value); - } - ~ScopedDeploymentTargetEnv() { apply(had_ ? old_.c_str() : nullptr); } - ScopedDeploymentTargetEnv(const ScopedDeploymentTargetEnv&) = delete; - ScopedDeploymentTargetEnv& operator=(const ScopedDeploymentTargetEnv&) = delete; + explicit ScopedDeploymentTargetEnv(const char* value) + : inner_("MACOSX_DEPLOYMENT_TARGET", value ? std::optional(value) : std::nullopt) {} private: - static constexpr const char* kName = "MACOSX_DEPLOYMENT_TARGET"; - void apply(const char* v) { -#if defined(_WIN32) - ::_putenv_s(kName, v ? v : ""); -#else - if (v) ::setenv(kName, v, 1); else ::unsetenv(kName); -#endif - } - bool had_ = false; - std::string old_; + mcpp::invocation::ScopedEnv inner_; // stated for this invocation, as mcpp states its own }; } // namespace @@ -148,7 +136,7 @@ TEST(BuildProfile, PackageFlagsAreNotInTheConfiguration) { // ── macos_deployment_target: TARGET-keyed, not HOST-keyed (mcpp#685) ──────── // // The fingerprint's flag string used to fold this value in only -// `if constexpr (mcpp::platform::is_macos)` -- the platform mcpp ITSELF was +// `if constexpr (mcpplibs::os::is_macos)` -- the platform mcpp ITSELF was // built for -- so `mcpp build --target aarch64-macos` on a Linux host kept // the exact same fingerprint (hence the exact same `target///` // directory) no matter what `[build] macos_deployment_target` was edited to, diff --git a/tests/unit/test_build_resources.cpp b/tests/unit/test_build_resources.cpp index d3f6e768..6d836990 100644 --- a/tests/unit/test_build_resources.cpp +++ b/tests/unit/test_build_resources.cpp @@ -3,7 +3,7 @@ import std; import mcpp.manifest; import mcpp.build.resources; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.toolchain.detect; namespace res = mcpp::build::resources; @@ -67,7 +67,7 @@ struct TempDir { // ─── Which windres: decided where the tool is found ─────────────────────── TEST(BuildResources, LlvmWindresIsRecognisedThroughTheSymlinkThatNamesIt) { - if constexpr (mcpp::platform::is_windows) + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "creating a symlink needs a privilege a Windows runner may lack"; TempDir d; const auto real = d.write("llvm-rc", ""); @@ -96,7 +96,7 @@ TEST(BuildResources, LlvmWindresIsRecognisedThroughTheSymlinkThatNamesIt) { // ─── The build program's manifest object: the command is part of it ─────── TEST(BuildResources, TheManifestObjectIsRemadeWhenItsCommandChanges) { - if constexpr (mcpp::platform::is_windows) + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "the stand-in resource compiler is a POSIX shell script"; TempDir d; // A windres that writes the words it was given into its output. diff --git a/tests/unit/test_capacity_jobs.cpp b/tests/unit/test_capacity_jobs.cpp index 34d01a9b..391f71f5 100644 --- a/tests/unit/test_capacity_jobs.cpp +++ b/tests/unit/test_capacity_jobs.cpp @@ -1,4 +1,4 @@ -// mcpp.platform.capacity::recommended_jobs — the `--jobs auto` formula. +// mcpp.build.capacity::recommended_jobs — the `--jobs auto` formula. // // Asserted against SYNTHETIC capacities, not the machine running the test: a // test that asked the host how many cores it has would assert nothing (it would @@ -6,10 +6,10 @@ // runner. #include -import mcpp.platform.capacity; +import mcpp.build.capacity; -using mcpp::platform::capacity::HostCapacity; -using mcpp::platform::capacity::recommended_jobs; +using mcpp::build::capacity::HostCapacity; +using mcpp::build::capacity::recommended_jobs; namespace { constexpr unsigned long long GiB = 1024ull * 1024 * 1024; @@ -75,7 +75,7 @@ TEST(RecommendedJobs, PerJobEstimateIsAParameter) { // The real machine must at least produce something sane — a weak assertion on // purpose, since the value is host-dependent. TEST(RecommendedJobs, HostQueryIsPlausible) { - const auto cap = mcpp::platform::capacity::host_capacity(); + const auto cap = mcpp::build::capacity::host_capacity(); EXPECT_GE(cap.logicalCores, 1); EXPECT_GE(cap.physicalCores, 1); EXPECT_LE(cap.physicalCores, cap.logicalCores); diff --git a/tests/unit/test_cenv_probe.cpp b/tests/unit/test_cenv_probe.cpp index 946b50c5..ab6e4014 100644 --- a/tests/unit/test_cenv_probe.cpp +++ b/tests/unit/test_cenv_probe.cpp @@ -37,7 +37,7 @@ import std; import mcpp.toolchain.cenv_probe; -import mcpp.platform; +import mcpp.run; namespace cp = mcpp::toolchain::cenv_probe; @@ -79,7 +79,7 @@ std::filesystem::path cxx() { // tests below name. A clang that answers for neither is not a smaller version // of one that does; it is a different tool for this purpose. bool answers_for_a_cross_target(const std::filesystem::path& bin) { - auto r = mcpp::platform::process::capture_stdout( + auto r = mcpp::run::capture_stdout( {bin.string(), "--target=riscv64-none-elf", "-x", "c++", "-E", "-dM", "-"}); return r.exit_code == 0 && r.output.find("#define __riscv ") != std::string::npos; diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index d72e3be8..fbbe0df4 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -7,7 +7,7 @@ import mcpp.build.flags; import mcpp.build.plan; import mcpp.libs.json; import mcpp.manifest.flag_words; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.toolchain.model; using namespace mcpp::build; @@ -295,7 +295,7 @@ bool has_edge_quote(std::string_view s) { // The host's own reader: single quotes are quoting for `sh` and literal // characters for the MSVCRT rules, so this row holds on POSIX hosts only. TEST(CompileCommandsArgs, StripsPosixQuotingFromAToken) { - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { GTEST_SKIP() << "single quotes are literal under the MSVCRT rules"; } auto out = mcpp::build::split_flags("-O2 '-fprebuilt-module-path=/a/b' -g"); @@ -318,7 +318,7 @@ TEST(CompileCommandsArgs, StripsWindowsQuotingFromAToken) { TEST(CompileCommandsArgs, AQuotedPathWithASpaceStaysOneToken) { // The quotes are the host's: flags.cppm quotes with `'` on POSIX and `"` // under the MSVCRT rules. - const std::string_view line = mcpp::platform::is_windows + const std::string_view line = mcpplibs::os::is_windows ? "-O2 \"-fmodule-file=std=/tmp/my$ project/std.pcm\" -g" : "-O2 '-fmodule-file=std=/tmp/my$ project/std.pcm' -g"; auto out = mcpp::build::split_flags(line); @@ -418,7 +418,7 @@ TEST(CompileCommandsArgs, AWordOnAnEdgeReadsBackAsTheWord) { // is available, so the model is checked against it: `/bin/sh` receives the // quoted words and prints each one back. TEST(CompileCommandsArgs, ShellReceivesTheQuotedWordsAsWritten) { - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { GTEST_SKIP() << "the POSIX shell is the reader measured here"; } TempDir temp; diff --git a/tests/unit/test_elf_runtime.cpp b/tests/unit/test_elf_runtime.cpp index 6bbfe8ad..fee1db49 100644 --- a/tests/unit/test_elf_runtime.cpp +++ b/tests/unit/test_elf_runtime.cpp @@ -1,14 +1,14 @@ #include import std; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.runtime.elf; import mcpp.runtime.binding; import mcpp.toolchain.post_install; import mcpp.build.symbol_provision; -namespace elf = mcpp::platform::elf; -namespace runtime = mcpp::platform::runtime; +namespace elf = mcpp::runtime::elf; +namespace runtime = mcpp::runtime; namespace tc = mcpp::toolchain; namespace { @@ -225,7 +225,7 @@ TEST(RuntimePayload, MissingOrMalformedBindingIsAnError) { } TEST(ElfRuntime, ParsesProgramDynamicAndGnuVersionTablesInternally) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF table inspection is exercised on native Linux"; Tmp t; auto parsed = elf::inspect_elf_runtime(write_elf_fixture(t.path / "fixture")); @@ -242,7 +242,7 @@ TEST(ElfRuntime, ParsesProgramDynamicAndGnuVersionTablesInternally) { } TEST(ElfRuntime, RejectsUnsupportedOrTruncatedElfWithoutGuessing) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF table inspection is exercised on native Linux"; Tmp t; std::ofstream(t.path / "text") << "not an ELF"; @@ -257,7 +257,7 @@ TEST(ElfRuntime, RejectsUnsupportedOrTruncatedElfWithoutGuessing) { } TEST(ElfRuntime, ReusesAnAlreadyLoadedSonameAcrossDependencyRunpaths) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto payload = t.path / "store"; @@ -305,7 +305,7 @@ TEST(ElfRuntime, ReusesAnAlreadyLoadedSonameAcrossDependencyRunpaths) { // seven of them -- failing only on the one that belongs to a driver the // machine does not have. TEST(ElfRuntime, ADependencyInheritsTheExecutablesRpath) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto payload = t.path / "store"; @@ -338,7 +338,7 @@ TEST(ElfRuntime, ADependencyInheritsTheExecutablesRpath) { // so a test with only the leg above would also pass on an implementation that // inherited unconditionally. TEST(ElfRuntime, ADependencyWithItsOwnRunpathDoesNotInheritOne) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto payload = t.path / "store"; @@ -368,7 +368,7 @@ TEST(ElfRuntime, ADependencyWithItsOwnRunpathDoesNotInheritOne) { } TEST(RuntimePhysics, RuleBRejectsInterpreterAndLibcFromDifferentPayloads) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -385,7 +385,7 @@ TEST(RuntimePhysics, RuleBRejectsInterpreterAndLibcFromDifferentPayloads) { } TEST(RuntimePhysics, RuleBAcceptsSameSelectedPayload) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -401,7 +401,7 @@ TEST(RuntimePhysics, RuleBAcceptsSameSelectedPayload) { } TEST(RuntimePhysics, RuleBRejectsTwoLibcsAcrossTheResolvedClosure) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -422,7 +422,7 @@ TEST(RuntimePhysics, RuleBRejectsTwoLibcsAcrossTheResolvedClosure) { } TEST(RuntimePhysics, RuleARejectsRequiredFloorAboveSelectedLibcExports) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store", "2.39"); @@ -441,7 +441,7 @@ TEST(RuntimePhysics, RuleARejectsRequiredFloorAboveSelectedLibcExports) { } TEST(RuntimePhysics, RuleAAcceptsEqualOrLowerFloor) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -466,7 +466,7 @@ TEST(RuntimePhysics, RuleAAcceptsEqualOrLowerFloor) { // `inconclusive` reports a proven failure as an absence of one, and it is how a // GL program that exited 127 was shipped as `validation: pass`. TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -495,7 +495,7 @@ TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { // it does not block. (Without this, e2e 206's deliberate host-DSO control // failed its own build.) TEST(RuntimePhysics, AllowHostLibsDowngradesTheProofToAReport) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -528,7 +528,7 @@ TEST(RuntimePhysics, AllowHostLibsDowngradesTheProofToAReport) { // ("not an ELF file") landed in `unresolved`, and the artifact was failed for a // missing shared library it could not possibly have. CI caught it; this pins it. TEST(RuntimePhysics, CrossBuiltNonElfArtifactIsNotJudgedByElfRules) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -551,7 +551,7 @@ TEST(RuntimePhysics, CrossBuiltNonElfArtifactIsNotJudgedByElfRules) { // CHECK — a check that could not look has proven nothing, so it stays // inconclusive no matter how hermetic the binding is. TEST(RuntimePhysics, UnreadableObjectIsInconclusiveEvenWhenHermetic) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -573,7 +573,7 @@ TEST(RuntimePhysics, UnreadableObjectIsInconclusiveEvenWhenHermetic) { // deliberately does not parse. There, unresolved really is unknown, and // claiming otherwise would fail builds that work. TEST(RuntimePhysics, UnresolvedNeededWithoutAPrivateLoaderStaysInconclusive) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -596,7 +596,7 @@ TEST(RuntimePhysics, UnresolvedNeededWithoutAPrivateLoaderStaysInconclusive) { // second as the first sends the reader looking for a runtime they did not // select, and quietly counts "unknown" as "fine". TEST(RuntimePhysics, UndeclaredBindingIsInconclusiveNotNotApplicable) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto b = binding_for(t.path / "store"); @@ -614,7 +614,7 @@ TEST(RuntimePhysics, UndeclaredBindingIsInconclusiveNotNotApplicable) { } TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { - if constexpr (mcpp::platform::is_linux) + if constexpr (mcpplibs::os::is_linux) GTEST_SKIP() << "the non-Linux boundary is exercised on native runners"; Tmp t; auto b = binding_for(t.path / "store"); @@ -639,7 +639,7 @@ TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { // wrong twice before it was measured. TEST(ElfRuntime, ReadsThisBinarysOwnDynamicSymbols) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF dynamic symbol tables only exist on Linux here"; auto self = std::filesystem::path("/proc/self/exe"); @@ -658,7 +658,7 @@ TEST(ElfRuntime, ReadsThisBinarysOwnDynamicSymbols) { } TEST(ElfRuntime, ThisBinaryExportsNothingOfItsOwn) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF dynamic symbol tables only exist on Linux here"; auto self = std::filesystem::path("/proc/self/exe"); @@ -709,7 +709,7 @@ TEST(ElfRuntime, ThisBinaryExportsNothingOfItsOwn) { // driver, and a check that failed there would turn a correct CPU-only // configuration into a failed build. TEST(DlopenSurface, SeparatesAPackagingGapFromAMachineWithoutTheDriver) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto payload = t.path / "store"; @@ -765,7 +765,7 @@ TEST(DlopenSurface, SeparatesAPackagingGapFromAMachineWithoutTheDriver) { // entries reports every finding twice and calls thirteen libraries twenty-six, // which is what the first published record did. TEST(DlopenSurface, CountsALibraryOnceWhenTwoNamesLinkToIt) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; Tmp t; auto payload = t.path / "store"; diff --git a/tests/unit/test_hermetic_graph_link.cpp b/tests/unit/test_hermetic_graph_link.cpp index 06e4eac9..f633179e 100644 --- a/tests/unit/test_hermetic_graph_link.cpp +++ b/tests/unit/test_hermetic_graph_link.cpp @@ -10,7 +10,7 @@ import std; import mcpp.build.hermetic; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.toolchain.model; namespace { @@ -53,7 +53,7 @@ mcpp::toolchain::Toolchain fake_driver(const std::filesystem::path& root, } // namespace TEST(HermeticGraphLink, AHostSearchDirectoryIsRefusedAndNamed) { - if constexpr (!mcpp::platform::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; Tmp t; auto tc = fake_driver(t.path, {"/usr/lib", (t.path / "xpkgs" / "musl" / "lib").string()}); auto r = mcpp::build::verify_hermetic_link(tc, "", t.path / "out", false, @@ -66,7 +66,7 @@ TEST(HermeticGraphLink, AHostSearchDirectoryIsRefusedAndNamed) { } TEST(HermeticGraphLink, AllowHostLibsDowngradesTheRefusal) { - if constexpr (!mcpp::platform::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; Tmp t; auto tc = fake_driver(t.path, {"/usr/lib"}); auto r = mcpp::build::verify_hermetic_link(tc, "", t.path / "out", true, @@ -77,7 +77,7 @@ TEST(HermeticGraphLink, AllowHostLibsDowngradesTheRefusal) { // The build directory holds the empty graph sysroot, and a package may add a // package-relative `-L` whose root is anywhere (a path dependency). TEST(HermeticGraphLink, TheBuildAndTheGraphsPackagesAreAllowed) { - if constexpr (!mcpp::platform::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; Tmp t; const auto out = t.path / "out"; const auto pathDep = t.path / "elsewhere" / "openkal-musl"; @@ -92,7 +92,7 @@ TEST(HermeticGraphLink, TheBuildAndTheGraphsPackagesAreAllowed) { // A payload link keeps its payload's directories; the `-L` rule is for the // isolated graph link only. TEST(HermeticGraphLink, APayloadLinkIsNotHeldToTheGraphRule) { - if constexpr (!mcpp::platform::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; Tmp t; auto tc = fake_driver(t.path, {"/usr/lib"}); auto r = mcpp::build::verify_hermetic_link(tc, "", t.path / "out", false, diff --git a/tests/unit/test_home.cpp b/tests/unit/test_home.cpp index bbb2ff09..872cb479 100644 --- a/tests/unit/test_home.cpp +++ b/tests/unit/test_home.cpp @@ -2,8 +2,10 @@ #include import std; +import mcpp.invocation; import mcpp.home; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpplibs.os.windows; import mcpp.toolchain.stdmod; import mcpp.config; import mcpp.libs.toml; @@ -13,32 +15,10 @@ namespace { // Set/restore an environment variable for the duration of a test. class ScopedEnv { public: - ScopedEnv(std::string name, const char* value) : name_(std::move(name)) { - if (const char* old = std::getenv(name_.c_str()); old) { - had_ = true; - old_ = old; - } - apply(value); - } - ~ScopedEnv() { apply(had_ ? old_.c_str() : nullptr); } - - ScopedEnv(const ScopedEnv&) = delete; - ScopedEnv& operator=(const ScopedEnv&) = delete; - + ScopedEnv(std::string name, const char* value) + : inner_(std::move(name), value ? std::optional(value) : std::nullopt) {} private: - void apply(const char* value) { -#if defined(_WIN32) - // _putenv_s with "" removes the variable on Windows. - ::_putenv_s(name_.c_str(), value ? value : ""); -#else - if (value) ::setenv(name_.c_str(), value, 1); - else ::unsetenv(name_.c_str()); -#endif - } - - std::string name_; - bool had_ = false; - std::string old_; + mcpp::invocation::ScopedEnv inner_; // stated for this invocation, as mcpp states its own }; } // namespace @@ -173,7 +153,7 @@ TEST_F(HomeSelection, ExistingHomeNeedsForceAndChangingItKeepsBothDataTrees) { ASSERT_TRUE(mcpp::home::save(*selected)); EXPECT_TRUE(std::filesystem::exists(original / "config.toml")); EXPECT_TRUE(std::filesystem::exists(target / "sentinel")); - EXPECT_EQ(std::string(std::getenv("MCPP_HOME")), mcpp::home::spelling(original)); + EXPECT_EQ(mcpp::invocation::get("MCPP_HOME").value_or(""), mcpp::home::spelling(original)); const auto restored = mcpp::home::inspect(original); ASSERT_TRUE(restored); EXPECT_EQ(restored->previous, std::filesystem::weakly_canonical(original)); @@ -225,8 +205,8 @@ TEST_F(HomeSelection, MissingUserDirectoryCannotPublishALocatorInTheWorkingDirec TEST_F(HomeSelection, UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath) { - if constexpr (mcpp::platform::is_windows) { - const auto acp = mcpp::platform::windows::active_code_page(); + if constexpr (mcpplibs::os::is_windows) { + const auto acp = mcpplibs::os::windows::code_page(); ASSERT_NE(acp, 0u); if (acp == 65001) GTEST_SKIP() << "UTF-8 ACP cannot prove the legacy-code-page path conversion boundary; " diff --git a/tests/unit/test_hostflags.cpp b/tests/unit/test_hostflags.cpp index acc504fd..d8ab38e1 100644 --- a/tests/unit/test_hostflags.cpp +++ b/tests/unit/test_hostflags.cpp @@ -2,7 +2,7 @@ #include import std; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.toolchain.dialect; import mcpp.toolchain.hostflags; import mcpp.toolchain.linkmodel; @@ -256,7 +256,7 @@ TEST(HostFlags, CfgBypassLinuxOnlyDiffersFromAlwaysOffLinux) { // Without a real clang payload there is no cfg to bypass, so both are // empty here; the assertion that matters is that the option exists and is // honoured identically on Linux, where the host helper does bypass. - if constexpr (mcpp::platform::is_linux) { + if constexpr (mcpplibs::os::is_linux) { EXPECT_EQ(mcpp::toolchain::host_compile_tokens(tc, always, mcpp::toolchain::no_escape), mcpp::toolchain::host_compile_tokens(tc, linuxOnly, mcpp::toolchain::no_escape)); } @@ -337,7 +337,7 @@ TEST(HostFlags, OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs) { } // TARGET-KEYED, NOT HOST-KEYED (mcpp#685). This used to read -// `EXPECT_EQ(found, mcpp::platform::is_macos)` — the flag's presence +// `EXPECT_EQ(found, mcpplibs::os::is_macos)` — the flag's presence // followed the machine RUNNING this test rather than the `Toolchain`'s own // `targetTriple`, so it could not distinguish "building for macOS" from // "building on macOS" and stayed green while `--target aarch64-macos` from a diff --git a/tests/unit/test_loader_contract.cpp b/tests/unit/test_loader_contract.cpp index 1862b219..d6f513a6 100644 --- a/tests/unit/test_loader_contract.cpp +++ b/tests/unit/test_loader_contract.cpp @@ -9,7 +9,7 @@ import mcpp.runtime.elf; import mcpp.build.runtime_validation; import mcpp.build.plan; import mcpp.runtime.binding; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.libs.json; namespace { @@ -304,7 +304,7 @@ TEST(ArtifactIdentity, VersionMatchIsAPathComponent) { // one output directory where the SECOND one is the one that does not apply. TEST(DlopenSurfaceRecord, ANonAnswerRepublishesTheAnswerAlreadyOnFile) { namespace fs = std::filesystem; - if constexpr (!mcpp::platform::is_linux) { + if constexpr (!mcpplibs::os::is_linux) { SUCCEED() << "the record is ELF-shaped and this host links no ELF"; return; } diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 6f732b66..acafd8ee 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -5,8 +5,8 @@ import mcpp.manifest; import mcpp.targetside; import mcpp.libs.toml; import mcpp.pm.dep_spec; -import mcpp.platform.axis; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.build.prepare; // merge_conditional_config: a row's statement replaces TEST(Manifest, PeAutoExportDefaultsOnAndAcceptsAnExplicitOptOut) { @@ -36,7 +36,7 @@ package = {{ xpm = {{ windows = {{ ["0.1.0"] = {{ url = "u", sha256 = "h" }} }} }}, mcpp = {{ sources = {{ "*/api.cpp" }}, targets = {{ ["dll"] = {{ kind = "shared", {} }} }} }}, }} -)", declaration), "dll", "0.1.0", mcpp::platform::HostPlatform::current()); +)", declaration), "dll", "0.1.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->targets.size(), 1u); EXPECT_EQ(m->targets.front().windows_auto_export(), std::string_view(declaration) != "windows_auto_export = false,"); @@ -48,7 +48,7 @@ package = { xpm = { windows = { ["0.1.0"] = { url = "u", sha256 = "h" } } }, mcpp = { sources = { "*/api.cpp" }, targets = { ["dll"] = { kind = "shared", windows_auto_export = "false" } } }, } -)", "dll", "0.1.0", mcpp::platform::HostPlatform::current()); +)", "dll", "0.1.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_FALSE(bad.has_value()); EXPECT_NE(bad.error().format().find("windows_auto_export must be a boolean"), std::string::npos); } @@ -495,23 +495,23 @@ package = { }, } )"; - auto linux = mcpp::manifest::list_xpkg_versions(src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + auto linux = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); ASSERT_EQ(linux.size(), 3u); EXPECT_EQ(linux[0], "0.1.0"); EXPECT_EQ(linux[1], "0.2.0"); EXPECT_EQ(linux[2], "1.0.0"); - auto mac = mcpp::manifest::list_xpkg_versions(src, mcpp::platform::TargetPlatform::for_lint_of("macosx")); + auto mac = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("macosx")); ASSERT_EQ(mac.size(), 1u); EXPECT_EQ(mac[0], "0.1.0"); - auto win = mcpp::manifest::list_xpkg_versions(src, mcpp::platform::TargetPlatform::for_lint_of("windows")); + auto win = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("windows")); EXPECT_TRUE(win.empty()); } TEST(ListXpkgVersions, MissingXpmReturnsEmpty) { constexpr auto src = R"(package = { name = "foo" })"; - EXPECT_TRUE(mcpp::manifest::list_xpkg_versions(src, mcpp::platform::TargetPlatform::for_lint_of("linux")).empty()); + EXPECT_TRUE(mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")).empty()); } // #363: `["25.0.4"] = { ref = "25.0.4.7.1" }` is a POINTER at another entry, @@ -532,7 +532,7 @@ package = { } )"; auto e = mcpp::manifest::list_xpkg_version_entries( - src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); ASSERT_EQ(e.size(), 3u); EXPECT_EQ(e[0].version, "latest"); EXPECT_TRUE (e[0].alias); EXPECT_EQ(e[1].version, "25.0.4"); EXPECT_TRUE (e[1].alias); @@ -543,7 +543,7 @@ package = { // The keys-only view is unchanged for every existing caller. auto keys = mcpp::manifest::list_xpkg_versions( - src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); ASSERT_EQ(keys.size(), 3u); EXPECT_EQ(keys[2], "25.0.4.7.1"); } @@ -569,7 +569,7 @@ package = { } )"; auto e = mcpp::manifest::list_xpkg_version_entries( - src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); ASSERT_EQ(e.size(), 6u); EXPECT_EQ(e[0].revision, 0); // an alias carries none EXPECT_EQ(e[1].revision, 0); @@ -598,7 +598,7 @@ package = { } )"; auto keys = mcpp::manifest::list_xpkg_versions( - src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); ASSERT_EQ(keys.size(), 2u); EXPECT_EQ(keys[0], "1.0.0"); EXPECT_EQ(keys[1], "1.1.0"); @@ -1265,7 +1265,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.eigen", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.eigen", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->provides.size(), 1u); @@ -1380,7 +1380,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.opencv", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.opencv", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); EXPECT_TRUE(m->xpkgUnknownKeys.empty()); @@ -1407,7 +1407,7 @@ package = { }, } )"; - auto mb = mcpp::manifest::synthesize_from_xpkg_lua(bad, "compat.opencv", "1.0.0", mcpp::platform::HostPlatform::current()); + auto mb = mcpp::manifest::synthesize_from_xpkg_lua(bad, "compat.opencv", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_FALSE(mb.has_value()); EXPECT_NE(mb.error().message.find("features.dnn.flags"), std::string::npos) << mb.error().message; @@ -1451,7 +1451,7 @@ package = { )"; // linux leg: neutral payload + linux delta, macosx section invisible. auto ml = mcpp::manifest::synthesize_from_xpkg_lua( - lua, "compat.opencv", "1.0.0", mcpp::platform::TargetPlatform::for_lint_of("linux")); + lua, "compat.opencv", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); ASSERT_TRUE(ml.has_value()) << ml.error().format(); ASSERT_TRUE(ml->buildConfig.featureSources.contains("dnn")); { @@ -1469,7 +1469,7 @@ package = { // macosx leg: NEON delta instead, no mlas flags, no vaapi. auto mm = mcpp::manifest::synthesize_from_xpkg_lua( - lua, "compat.opencv", "1.0.0", mcpp::platform::TargetPlatform::for_lint_of("macosx")); + lua, "compat.opencv", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_lint_of("macosx")); ASSERT_TRUE(mm.has_value()) << mm.error().format(); { auto& srcs = mm->buildConfig.featureSources["dnn"]; @@ -1526,7 +1526,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.eigen", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.eigen", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); // implies recorded in featuresMap ASSERT_TRUE(m->featuresMap.contains("backend-openblas")); @@ -1622,7 +1622,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "opencv", "0.0.3", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "opencv", "0.0.3", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); // The forward token left featuresMap for featureForwards. EXPECT_TRUE(m->featuresMap["dnn"].empty()); @@ -1660,7 +1660,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "x", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "x", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->xpkgUnknownKeys.size(), 1u); EXPECT_EQ(m->xpkgUnknownKeys[0], "dependencies"); @@ -1687,7 +1687,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "x", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "x", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->xpkgUnknownKeys.size(), 1u); EXPECT_EQ(m->xpkgUnknownKeys[0], "features.opt.include_dirs"); @@ -1763,7 +1763,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->buildConfig.cflags.size(), 2u); EXPECT_EQ(m->buildConfig.cflags[0], "-Wall"); @@ -1789,7 +1789,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyshared", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyshared", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->targets.size(), 1u); EXPECT_EQ(m->targets[0].kind, mcpp::manifest::Target::SharedLibrary); @@ -1814,7 +1814,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "gtestlike", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "gtestlike", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); // base sources keep both (old mcpp ignores `features` → no regression) ASSERT_EQ(m->buildConfig.sources.size(), 2u); @@ -1842,7 +1842,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "glfw", "3.4", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "glfw", "3.4", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->runtimeConfig.libraryDirs.size(), 1u); EXPECT_EQ(m->runtimeConfig.libraryDirs[0], "mcpp_generated/runtime/lib"); @@ -1887,19 +1887,19 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); std::string expectedSource = "*/src/linux.c"; std::string expectedInclude = "*/src/linux"; std::string expectedCflag = "-DLINUX=1"; std::string expectedDep = "compat.x11"; - if constexpr (mcpp::platform::is_macos) { + if constexpr (mcpplibs::os::is_macos) { expectedSource = "*/src/cocoa.m"; expectedInclude = "*/src/macos"; expectedCflag = "-DMACOS=1"; expectedDep = "compat.cocoa"; - } else if constexpr (mcpp::platform::is_windows) { + } else if constexpr (mcpplibs::os::is_windows) { expectedSource = "*/src/win32.c"; expectedInclude = "*/src/win32"; expectedCflag = "-DWINDOWS=1"; @@ -1921,7 +1921,7 @@ package = { // osOverride seam (`mcpp xpkg parse --all-os`): a foreign OS section is // spliced on request, independent of the running host — the build path // (empty override) above stays host-selected. Reuses this fixture. - auto win = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::platform::TargetPlatform::for_lint_of("windows")); + auto win = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_lint_of("windows")); ASSERT_TRUE(win.has_value()) << win.error().format(); ASSERT_EQ(win->modules.sources.size(), 2u); EXPECT_EQ(win->modules.sources[1], "*/src/win32.c"); @@ -1946,7 +1946,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->buildConfig.generatedFiles.size(), 1u); auto it = m->buildConfig.generatedFiles.find("mcpp_generated/include/config.h"); @@ -1971,7 +1971,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->buildConfig.includeDirs.size(), 1u); EXPECT_EQ(m->buildConfig.includeDirs[0], "*/include"); @@ -2171,7 +2171,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "consumer", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "consumer", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->dependencies.size(), 2u); @@ -2203,7 +2203,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "consumer", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "consumer", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->dependencies.size(), 3u); @@ -2248,7 +2248,7 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua( - src, "consumer", "1.0.0", mcpp::platform::HostPlatform::current()); + src, "consumer", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_FALSE(m.has_value()); EXPECT_NE(m.error().message.find("invalid package selector"), std::string::npos) << m.error().format(); @@ -2918,7 +2918,7 @@ package = { }, } )"; - auto v = mcpp::manifest::list_xpkg_versions(src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + auto v = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); ASSERT_EQ(v.size(), 1u); EXPECT_EQ(v[0], "0.1.0"); } @@ -3284,7 +3284,7 @@ int f() { return "x]]y"[0]; } }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "lbtest", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "lbtest", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_TRUE(m->buildConfig.generatedFiles.contains("mcpp_generated/gen.cc")); EXPECT_EQ(m->buildConfig.generatedFiles["mcpp_generated/gen.cc"], @@ -3310,7 +3310,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "cmt", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "cmt", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->modules.sources.size(), 1u); EXPECT_EQ(m->modules.sources[0], "src/*.cpp"); @@ -3330,7 +3330,7 @@ TEST(XpkgLongBracket, NoLeadingNewlineKeptVerbatim) { " targets = { [\"nl\"] = { kind = \"lib\" } },\n" " },\n" "}\n"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "nl", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "nl", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); EXPECT_EQ(m->buildConfig.generatedFiles["g.cc"], "inline"); EXPECT_EQ(m->buildConfig.generatedFiles["h.cc"], "crlf"); @@ -3351,7 +3351,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "uk", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "uk", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->xpkgUnknownKeys.size(), 2u); EXPECT_EQ(m->xpkgUnknownKeys[0], "bogus_key"); @@ -3378,7 +3378,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "fmtlib.fmt", "12.2.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "fmtlib.fmt", "12.2.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->modules.scanOverrides.size(), 1u); auto& ov = m->modules.scanOverrides.at("*/src/fmt.cc"); @@ -3402,7 +3402,7 @@ package = { }, } )"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "so", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "so", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_FALSE(m.has_value()); EXPECT_NE(m.error().message.find("neither provides nor imports"), std::string::npos) << m.error().format(); @@ -3513,7 +3513,7 @@ mcpp = { )"; auto mt = mcpp::manifest::parse_string(toml); ASSERT_TRUE(mt.has_value()) << mt.error().format(); - auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "sym", "1.0.0", mcpp::platform::HostPlatform::current()); + auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "sym", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(mx.has_value()) << mx.error().format(); EXPECT_EQ(mt->buildConfig.featureSources, mx->buildConfig.featureSources); @@ -3557,7 +3557,7 @@ mcpp = { }, } )"; - auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "condsrc", "1.0.0", mcpp::platform::HostPlatform::current()); + auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "condsrc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(mx.has_value()) << mx.error().format(); ASSERT_EQ(mx->conditionalConfigs.size(), 2u); EXPECT_EQ(mx->conditionalConfigs[0].predicate, "cfg(arch = \"x86_64\")"); @@ -3624,7 +3624,7 @@ mcpp = { }, } )"; - auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "globf", "1.0.0", mcpp::platform::HostPlatform::current()); + auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "globf", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(mx.has_value()) << mx.error().format(); ASSERT_EQ(mx->buildConfig.globFlags.size(), 2u); EXPECT_EQ(mx->buildConfig.globFlags[0].glob, "third_party/**"); @@ -3794,7 +3794,7 @@ mcpp = { }, } )LUA"; - auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "p", "1.0.0", mcpp::platform::HostPlatform::current()); + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "p", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m) << (m ? "" : m.error().message); ASSERT_EQ(m->conditionalConfigs.size(), 1u); ASSERT_EQ(m->conditionalConfigs[0].inputs.globFlags.size(), 1u); @@ -3806,7 +3806,7 @@ mcpp = { target_cfg = { ["cfg(windows)"] = { linkage = { "static" } } }, } )LUA"; - auto bm = mcpp::manifest::synthesize_from_xpkg_lua(bad, "p", "1.0.0", mcpp::platform::HostPlatform::current()); + auto bm = mcpp::manifest::synthesize_from_xpkg_lua(bad, "p", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); EXPECT_FALSE(bm) << "unknown target_cfg key must stay a hard error"; } @@ -3817,8 +3817,8 @@ mcpp = { // compile-time HOST constant while [target.'cfg(...)'] was evaluated against // the resolved TARGET. TEST(PlatformAxis, HostAndTargetAreNotInterchangeable) { - using mcpp::platform::HostPlatform; - using mcpp::platform::TargetPlatform; + using mcpp::toolchain::platform::HostPlatform; + using mcpp::toolchain::platform::TargetPlatform; static_assert(!std::is_convertible_v, "the host axis must not silently become the target axis"); @@ -3862,7 +3862,7 @@ mcpp = { {"macos", "macos_only.cpp"}, {"windows", "windows_only.cpp"}}) { auto m = mcpp::manifest::synthesize_from_xpkg_lua( - lua, "p", "1.0.0", mcpp::platform::TargetPlatform::for_os(tripleOs)); + lua, "p", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_os(tripleOs)); ASSERT_TRUE(m) << (m ? "" : m.error().message); EXPECT_TRUE(has(*m, expected)) << "target os " << tripleOs << " must splice its own section"; @@ -3889,11 +3889,11 @@ mcpp = { } )LUA"; auto host = mcpp::manifest::synthesize_from_xpkg_lua( - lua, "p", "1.0.0", mcpp::platform::HostPlatform::current()); + lua, "p", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); auto nativeTarget = mcpp::manifest::synthesize_from_xpkg_lua( lua, "p", "1.0.0", - mcpp::platform::TargetPlatform::for_lint_of( - mcpp::platform::HostPlatform::current().key())); + mcpp::toolchain::platform::TargetPlatform::for_lint_of( + mcpp::toolchain::platform::HostPlatform::current().key())); ASSERT_TRUE(host); ASSERT_TRUE(nativeTarget); EXPECT_EQ(host->modules.sources, nativeTarget->modules.sources); @@ -5300,7 +5300,7 @@ OPENBLAS_NUM_THREADS = "1" // `macos` and `macosx` are one platform under two vocabularies — mcpp's triple // says the first, a descriptor and xlings' project file say the second. The -// rule lives in mcpp.platform.axis; this asserts the manifest reaches it, +// rule lives in mcpp.toolchain.platform; this asserts the manifest reaches it, // on every host, by resolving explicitly rather than against this one. TEST(Manifest, XlingsWorkspaceAcceptsBothMacosSpellings) { auto with_macos = mcpp::manifest::parse_string(R"( @@ -6793,7 +6793,7 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua( - lua, "openkal-windows", "0.8.0", mcpp::platform::HostPlatform::current()); + lua, "openkal-windows", "0.8.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); EXPECT_EQ(m->cEnvironment, "platform"); } @@ -6902,7 +6902,7 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tomlpp", "3.4.0", - mcpp::platform::HostPlatform::current()); + mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); EXPECT_EQ(m->lib.path.generic_string(), "*/modules/tomlplusplus.cppm"); EXPECT_TRUE(m->xpkgUnknownKeys.empty()); diff --git a/tests/unit/test_network.cpp b/tests/unit/test_network.cpp index 219fc16d..46f67788 100644 --- a/tests/unit/test_network.cpp +++ b/tests/unit/test_network.cpp @@ -3,7 +3,9 @@ import std; import mcpp.provider; import mcpp.provider.xlings; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.invocation; +import mcpp.run; namespace { struct NetworkFixture { @@ -27,24 +29,24 @@ fi } TEST(NetworkCheck, OfflineNeverInvokesTheProvider) { - if constexpr (mcpp::platform::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; NetworkFixture f(R"SH(touch "$XLINGS_HOME/invoked" exit 1 )SH"); { - mcpp::platform::env::ScopedEnv online("MCPP_OFFLINE", "0"); + mcpp::invocation::ScopedEnv online("MCPP_OFFLINE", "0"); EXPECT_EQ(mcpp::provider::xlings::network_check(f.env, std::chrono::seconds(1)).status, "unsupported"); ASSERT_TRUE(std::filesystem::exists(f.dir / "invoked")); std::filesystem::remove(f.dir / "invoked"); } - mcpp::platform::env::ScopedEnv offline("MCPP_OFFLINE", "1"); + mcpp::invocation::ScopedEnv offline("MCPP_OFFLINE", "1"); const auto result = mcpp::provider::xlings::network_check(f.env, std::chrono::milliseconds(100)); EXPECT_EQ(result.status, "offline"); EXPECT_FALSE(std::filesystem::exists(f.dir / "invoked")); } TEST(NetworkCheck, DiscoveryAndProbesShareOneDeadline) { - if constexpr (mcpp::platform::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; NetworkFixture f(R"SH( if [ "$2" = "network_endpoints" ]; then echo '{"kind":"data","dataKind":"network_endpoints","payload":{"endpoints":[{"url":"https://one.invalid"},{"url":"https://two.invalid"},{"url":"https://three.invalid"},{"url":"https://four.invalid"},{"url":"https://five.invalid"}]}}' @@ -52,7 +54,7 @@ else sleep 1000 fi )SH"); - mcpp::platform::env::ScopedEnv offline("MCPP_OFFLINE", "0"); + mcpp::invocation::ScopedEnv offline("MCPP_OFFLINE", "0"); const auto start = std::chrono::steady_clock::now(); const auto result = mcpp::provider::xlings::network_check(f.env, std::chrono::milliseconds(400)); EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(2)); @@ -61,7 +63,7 @@ fi } TEST(NetworkCheck, MalformedProviderDataDoesNotEscapeDiagnosis) { - if constexpr (mcpp::platform::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; NetworkFixture f(R"SH( if [ "$2" = "network_endpoints" ]; then echo '{"kind":"data","dataKind":"network_endpoints","payload":{"endpoints":[{"url":"https://one.invalid"}]}}' @@ -69,7 +71,7 @@ else echo '{"kind":"data","dataKind":"network_probe","payload":{"reachable":"wrong-type"}}' fi )SH"); - mcpp::platform::env::ScopedEnv offline("MCPP_OFFLINE", "0"); + mcpp::invocation::ScopedEnv offline("MCPP_OFFLINE", "0"); const auto result = mcpp::provider::xlings::network_check(f.env, std::chrono::seconds(1)); ASSERT_EQ(result.endpoints.size(), 1u); EXPECT_EQ(result.endpoints.front().status, "invalid_response"); @@ -99,14 +101,17 @@ TEST(NetworkCheck, HeartbeatsCannotKeepAByteStalledDownloadAlive) { TEST(NetworkProcess, ProgressCancellationIsPolledWhileOutputContinues) { using namespace std::chrono; const auto start = steady_clock::now(); - const auto cmd = mcpp::platform::is_windows + const auto cmd = mcpplibs::os::is_windows ? "for /L %i in (1,1,10000000) do @echo heartbeat" : "while :; do echo heartbeat; done"; int lines = 0; bool stopped = false; - mcpp::platform::process::run_streaming_bounded(cmd, - [&](std::string_view) { ++lines; }, seconds(5), seconds(2), &stopped, false, - [&] { return steady_clock::now() - start >= milliseconds(200); }); + const auto sh = mcpp::run::shell(cmd); + stopped = mcpp::run::execute(sh.argv, { + .total = seconds(5), .idle = seconds(2), + .on_line = [&](std::string_view) { ++lines; }, + .should_stop = [&] { return steady_clock::now() - start >= milliseconds(200); }, + .one_string = sh.one_string }).timed_out; EXPECT_TRUE(stopped); EXPECT_GT(lines, 0); EXPECT_LT(steady_clock::now() - start, seconds(3)); @@ -135,10 +140,10 @@ TEST(NetworkCheck, LegacyStatusAndHandshakeDoNotWriteTheSelectedHome) { } cleanup{dir, std::filesystem::current_path()}; const auto home = dir / "home"; std::filesystem::create_directories(home); - const auto binary = dir / (mcpp::platform::is_windows ? "provider.cmd" : "provider"); + const auto binary = dir / (mcpplibs::os::is_windows ? "provider.cmd" : "provider"); { std::ofstream script(binary, std::ios::binary); - if constexpr (mcpp::platform::is_windows) + if constexpr (mcpplibs::os::is_windows) script << "@echo off\r\necho invoked>>\"%XLINGS_HOME%\\invoked\"\r\necho invoked>metadata-cwd\r\n" "echo {\"version\":\"9999.1.1.1\",\"features\":[]}\r\n"; else @@ -158,7 +163,7 @@ TEST(NetworkCheck, LegacyStatusAndHandshakeDoNotWriteTheSelectedHome) { ASSERT_FALSE(result); EXPECT_NE(result.error().find("read_only_queries"), std::string::npos); } - mcpp::platform::env::ScopedEnv online("MCPP_OFFLINE", "0"); + mcpp::invocation::ScopedEnv online("MCPP_OFFLINE", "0"); EXPECT_EQ(mcpp::provider::xlings::network_check(env, std::chrono::seconds(1)).status, "unsupported"); EXPECT_TRUE(std::filesystem::is_empty(home)) << "legacy metadata or status touched the selected home"; EXPECT_TRUE(std::filesystem::is_empty(project)) << "legacy metadata touched the current project"; @@ -180,7 +185,7 @@ TEST(NetworkCheck, BootstrapRequiresDownloadBudgetsAndReadOnlyQueries) { TEST(NetworkProcess, MetadataAndResourceDeadlinesStopDescendants) { - if constexpr (mcpp::platform::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; const std::string writer = R"SH( (file="${0%/*}/ticks"; i=0 while [ "$i" -lt 50 ]; do @@ -190,7 +195,7 @@ while [ "$i" -lt 50 ]; do done) >/dev/null 2>&1 & wait )SH"; - mcpp::platform::env::ScopedEnv online("MCPP_OFFLINE", "0"); + mcpp::invocation::ScopedEnv online("MCPP_OFFLINE", "0"); for (bool metadata : {true, false}) { NetworkFixture f("if [ \"$2\" = \"network_endpoints\" ]; then\n" "echo '{\"kind\":\"data\",\"dataKind\":\"network_endpoints\",\"payload\":{\"endpoints\":[{\"url\":\"https://one.invalid\"}]}}'\n" diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 92e6b966..574d81fa 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -1,6 +1,7 @@ #include import std; +import mcpp.invocation; import mcpp.source_kind; import mcpp.build.compile_commands; import mcpp.build.flags; @@ -12,8 +13,8 @@ import mcpp.modgraph.scanner; // PackageRoot, for trim_map import mcpp.toolchain.dialect; import mcpp.toolchain.model; import mcpp.toolchain.triple; -import mcpp.platform; -import mcpp.platform.runtime_search; +import mcpplibs.os.platform; +import mcpp.runtime.search; import mcpp.targetside; using namespace mcpp::build; @@ -78,7 +79,7 @@ BuildPlan minimal_plan() { TEST(NinjaBackend, WindowsDriverLinksRetainTheSelectedTargetForCAndCxx) { // This regression covers the Windows-host PE driver link branch. - if constexpr (!mcpp::platform::is_windows) + if constexpr (!mcpplibs::os::is_windows) GTEST_SKIP() << "the PE driver link branch is selected on Windows hosts"; for (const auto arch : {"i686", "x86_64", "aarch64"}) { auto plan = minimal_plan(); @@ -112,7 +113,7 @@ TEST(NinjaBackend, BitcodeExportToolsComeFromTheSelectedLlvmAndAreQuotedAsWords) plan.linkUnits.push_back(dll); auto text = emit_ninja_string(plan); const auto nm = plan.toolchain.binaryPath.parent_path() - / (mcpp::platform::is_windows ? "llvm-nm.exe" : "llvm-nm"); + / (mcpplibs::os::is_windows ? "llvm-nm.exe" : "llvm-nm"); EXPECT_NE(text.find("--llvm-cxx " + ninja_command_word(plan.toolchain.binaryPath.generic_string())), std::string::npos); EXPECT_NE(text.find("--llvm-nm " + ninja_command_word(nm.generic_string())), std::string::npos); EXPECT_NE(text.find("--llvm-target x86_64-pc-windows-msvc"), std::string::npos); @@ -181,7 +182,7 @@ TEST(NinjaBackend, CxxModuleAndCxxObjectRulesTrackHeaderDepsViaGccDepfile) { // The raw compiler depfile (with GCC's module-specific reversed // rules) must never be bound directly as ninja's depfile. EXPECT_EQ(rule.find("depfile = $out.d.raw"), std::string::npos) << ninja; - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { EXPECT_NE(rule.find("$mcpp depfile-filter --raw $out.d.raw --out $out.d -- "), std::string::npos) << ninja; EXPECT_EQ(rule.find("awk"), std::string::npos) << ninja; @@ -381,7 +382,7 @@ TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { // The quote character is the host shell's, not a fixed one: POSIX sh // wants single quotes, cmd.exe double. Hardcoding `'` passed on Linux // and failed on the Windows runner for a difference that is correct. - const std::string q = mcpp::platform::is_windows ? "\"" : "'"; + const std::string q = mcpplibs::os::is_windows ? "\"" : "'"; EXPECT_NE(line.find(q + "-I/opt/my$ dep/include" + q), std::string::npos) << line; EXPECT_NE(line.find(q + "-idirafter/opt/my$ dep/after" + q), std::string::npos) << line; } @@ -405,7 +406,7 @@ TEST(NinjaBackend, LocalIncludeTokensUseGenericSeparators) { // True on every host: the generic form never uses the native separator. EXPECT_NE(tok.find("src/inc"), std::string::npos) << tok; - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { auto abs = mcpp::build::include_token( gnu, std::filesystem::path("C:\\src\\inc"), {}, mcpp::build::PathForm::Generic); @@ -569,7 +570,7 @@ TEST(NinjaBackend, QuotesFlagValueWithSpace) { // shell_quote_arg wraps in single quotes on POSIX, double quotes on // Windows — assert the platform-appropriate spelling. - const std::string quoted = mcpp::platform::is_windows + const std::string quoted = mcpplibs::os::is_windows ? "unit_cflags = \"-DT=long long\"" : "unit_cflags = '-DT=long long'"; EXPECT_NE(ninja.find(quoted), std::string::npos) << ninja; @@ -608,13 +609,13 @@ TEST(NinjaBackend, PlainFlagsPassThroughUnquoted) { // rule expands it), not on either variable alone: checking one of them is what // made the original mistake invisible. TEST(NinjaBackend, SubosFarmRpathFollowsTheArtifactsOwnDirectory) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the SubOS farm rpath is emitted for ELF targets only"; const std::string farm = "/tmp/mcpp-ninja-test-farm/subos/default/lib"; auto plan = minimal_plan(); plan.runtimeSearch.push_back( - {farm, mcpp::platform::search::Origin::SubosFarm}); + {farm, mcpp::runtime::search::Origin::SubosFarm}); plan.compileUnits.push_back({ .source = "src/main.cpp", .kind = mcpp::SourceKind::Cxx, @@ -659,13 +660,13 @@ TEST(NinjaBackend, SubosFarmRpathFollowsTheArtifactsOwnDirectory) { // An archive is produced by `ar`, whose rule never expands `$unit_ldflags`. // Emitting run-time search flags there would be dead bytes in every graph. TEST(NinjaBackend, StaticLibraryCarriesNoRuntimeFallback) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the SubOS farm rpath is emitted for ELF targets only"; const std::string farm = "/tmp/mcpp-ninja-test-farm/subos/default/lib"; auto plan = minimal_plan(); plan.runtimeSearch.push_back( - {farm, mcpp::platform::search::Origin::SubosFarm}); + {farm, mcpp::runtime::search::Origin::SubosFarm}); plan.compileUnits.push_back({ .source = "src/lib.cpp", .kind = mcpp::SourceKind::Cxx, @@ -890,7 +891,7 @@ TEST(NinjaBackend, CompileAndScanRulesRouteFlagsThroughRspfileUnderMsvcDialect) // reachable on a Windows host; c_object and cxx_scan have no such // compile-time branch and are assertable everywhere. std::vector rules{"rule c_object\n", "rule cxx_scan\n"}; - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { rules.push_back("rule cxx_module\n"); rules.push_back("rule cxx_object\n"); } @@ -929,7 +930,7 @@ TEST(NinjaBackend, CompileAndScanRulesRouteFlagsThroughRspfileUnderMsvcDialect) // far easier to re-run by hand. This is the byte-identity guard for the // #261 change on the platform where it must be a no-op. TEST(NinjaBackend, CompileRulesStayInlineOnPosixDrivers) { - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { GTEST_SKIP() << "inline form is Windows-exempt by design"; } else { auto plan = minimal_plan(); // GCC → gnu dialect, non-msvc deps @@ -1028,7 +1029,7 @@ TEST(NinjaBackend, CAndAsmRulesAlsoTrackHeaderDeps) { // `deps = gcc` treats an absent depfile as an error — so the two cases need // separate rules. TEST(NinjaBackend, LowercaseAsmHasNoDepfileAndItsOwnRule) { - if constexpr (mcpp::platform::is_windows) + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX depfile shape only"; auto plan = minimal_plan(); @@ -1101,7 +1102,7 @@ TEST(NinjaBackend, RuntimeAliasCopiesThroughStageOnWindowsAndSymlinksElsewhere) ASSERT_NE(rulePos, std::string::npos) << ninja; auto body = ninja.substr(rulePos, ninja.find("\nrule ", rulePos + 1) - rulePos); - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { // PE has no soname symlink: the alias is a copy of a freshly built DLL, // the same hazard class as BMI staging. EXPECT_NE(body.find("$mcpp stage --output $out $in"), std::string::npos) << body; @@ -1116,18 +1117,12 @@ TEST(NinjaBackend, RuntimeAliasCopiesThroughStageOnWindowsAndSymlinksElsewhere) TEST(NinjaBackend, McppBinaryIsBoundEvenWithoutDyndep) { // dyndep off ⇒ no scan/collect rules, but stage_file still needs $mcpp. -#if defined(_WIN32) - ::_putenv_s("MCPP_NINJA_DYNDEP", "0"); -#else - ::setenv("MCPP_NINJA_DYNDEP", "0", 1); -#endif - auto plan = minimal_plan(); - auto ninja = emit_ninja_string(plan); -#if defined(_WIN32) - ::_putenv_s("MCPP_NINJA_DYNDEP", ""); -#else - ::unsetenv("MCPP_NINJA_DYNDEP"); -#endif + std::string ninja; + { + mcpp::invocation::ScopedEnv dyndep("MCPP_NINJA_DYNDEP", "0"); + auto plan = minimal_plan(); + ninja = emit_ninja_string(plan); + } EXPECT_EQ(ninja.find("rule cxx_scan"), std::string::npos) << ninja; EXPECT_NE(ninja.find("\nmcpp = "), std::string::npos) << ninja; @@ -1654,7 +1649,7 @@ TEST(Plan, ExpandManifestIncludeEntryNativeSpelling) { // exactly what the old flat refusal did. // // Runs on every host. The format now comes from the TARGET TRIPLE rather than -// from `mcpp::platform::is_windows`, which is what makes a Windows contract +// from `mcpplibs::os::is_windows`, which is what makes a Windows contract // assertable on the Linux runner that reviews most of these changes. namespace { @@ -1896,7 +1891,7 @@ TEST(NinjaBackendPeRuntime, CrtWordIsOneWordEqualOnCompileAndLink) { // whatever the plan's TARGET triple says, so the link-line half // of this property is checkable only on a Windows-built mcpp — // see the Windows e2e leg (814) for that half. - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { if (row.compiler == mcpp::toolchain::CompilerId::Clang) { EXPECT_EQ(count_occurrences(flags.ld, want), 1u) << flags.ld; EXPECT_EQ(flags.ld.find(other), std::string::npos); diff --git a/tests/unit/test_pack_archive_remove.cpp b/tests/unit/test_pack_archive_remove.cpp index 464276c7..a3a71870 100644 --- a/tests/unit/test_pack_archive_remove.cpp +++ b/tests/unit/test_pack_archive_remove.cpp @@ -2,11 +2,10 @@ import std; import mcpp.pack.library; -import mcpp.platform.shell; import mcpp.toolchain.dialect; using mcpp::pack::archive_remove_command; -using mcpp::platform::shell::quote; +using Argv = std::vector; using mcpp::toolchain::gnu_dialect; using mcpp::toolchain::msvc_dialect; @@ -26,10 +25,8 @@ using mcpp::toolchain::msvc_dialect; // in is a third fact, and it had no test because it had no seam. So the assembly // was given one. // -// Host-independent by construction: the expectations are built with the same -// `quote` these commands are built with, because what is under test is the -// spelling and the ORDER — shell quoting has its own tests in test_shell.cpp, -// and duplicating its rules here would only pin them twice, differently. +// Host-independent by construction: the command is an argument vector, so +// what is under test is the spelling and the ORDER and nothing else. namespace { @@ -41,18 +38,14 @@ TEST(ArchiveRemoveCommand, GnuTakesOneVerbThenTheArchiveThenEveryMember) { auto cmd = archive_remove_command("/usr/bin/ar", "bin/libmathkit.a", kMembers, gnu_dialect().archiveRemoveArg, gnu_dialect().archiveRemoveTakesArchiveFirst); - EXPECT_EQ(cmd, quote("/usr/bin/ar") + " d " + quote("bin/libmathkit.a") - + " " + quote("mathkit.m.o") + " " + quote("api.m.o")); + EXPECT_EQ(cmd, (Argv{ "/usr/bin/ar", "d", "bin/libmathkit.a", "mathkit.m.o", "api.m.o" })); } TEST(ArchiveRemoveCommand, MsvcTakesOneFlagPerMemberAndTheArchiveLast) { auto cmd = archive_remove_command("lib.exe", "bin/mathkit.lib", kMembers, msvc_dialect().archiveRemoveArg, msvc_dialect().archiveRemoveTakesArchiveFirst); - EXPECT_EQ(cmd, quote("lib.exe") - + " " + quote("/REMOVE:mathkit.m.o") - + " " + quote("/REMOVE:api.m.o") - + " " + quote("bin/mathkit.lib")); + EXPECT_EQ(cmd, (Argv{ "lib.exe", "/REMOVE:mathkit.m.o", "/REMOVE:api.m.o", "bin/mathkit.lib" })); } // The two rows in dialect.cppm are the input to the assembly above. Pinned here @@ -73,8 +66,8 @@ TEST(ArchiveRemoveCommand, TheMsvcFormCarriesNoArVerbAndNoLeadingArchive) { auto cmd = archive_remove_command("lib.exe", "bin/mathkit.lib", kMembers, msvc_dialect().archiveRemoveArg, msvc_dialect().archiveRemoveTakesArchiveFirst); - EXPECT_EQ(cmd.find(" d "), std::string::npos); - EXPECT_LT(cmd.find("/REMOVE:mathkit.m.o"), cmd.find("mathkit.lib")); + EXPECT_EQ(std::ranges::find(cmd, "d"), cmd.end()); + EXPECT_LT(std::ranges::find(cmd, "/REMOVE:mathkit.m.o"), std::ranges::find(cmd, "bin/mathkit.lib")); } TEST(ArchiveRemoveCommand, ASingleMemberIsSpelledTheSameWayAsMany) { @@ -82,9 +75,8 @@ TEST(ArchiveRemoveCommand, ASingleMemberIsSpelledTheSameWayAsMany) { // "and-then-the-rest" bug hides. auto gnu = archive_remove_command("ar", "libx.a", { "only.m.o" }, gnu_dialect().archiveRemoveArg, true); - EXPECT_EQ(gnu, quote("ar") + " d " + quote("libx.a") + " " + quote("only.m.o")); + EXPECT_EQ(gnu, (Argv{ "ar", "d", "libx.a", "only.m.o" })); auto msvc = archive_remove_command("lib.exe", "x.lib", { "only.m.o" }, msvc_dialect().archiveRemoveArg, false); - EXPECT_EQ(msvc, quote("lib.exe") + " " + quote("/REMOVE:only.m.o") - + " " + quote("x.lib")); + EXPECT_EQ(msvc, (Argv{ "lib.exe", "/REMOVE:only.m.o", "x.lib" })); } diff --git a/tests/unit/test_platform_apple_sdk.cpp b/tests/unit/test_platform_apple_sdk.cpp index 00d892f7..7933dbb9 100644 --- a/tests/unit/test_platform_apple_sdk.cpp +++ b/tests/unit/test_platform_apple_sdk.cpp @@ -2,10 +2,11 @@ #include import std; -import mcpp.platform; -import mcpp.platform.macos; +import mcpp.invocation; +import mcpplibs.os.platform; +import mcpp.toolchain.apple_sdk; -namespace mac = mcpp::platform::macos; +namespace mac = mcpp::toolchain::apple; namespace { @@ -15,24 +16,10 @@ namespace { // one. class ScopedDeploymentTargetEnv { public: - explicit ScopedDeploymentTargetEnv(const char* value) { - if (const char* old = std::getenv(kName); old) { had_ = true; old_ = old; } - apply(value); - } - ~ScopedDeploymentTargetEnv() { apply(had_ ? old_.c_str() : nullptr); } - ScopedDeploymentTargetEnv(const ScopedDeploymentTargetEnv&) = delete; - ScopedDeploymentTargetEnv& operator=(const ScopedDeploymentTargetEnv&) = delete; + explicit ScopedDeploymentTargetEnv(const char* value) + : inner_("MACOSX_DEPLOYMENT_TARGET", value ? std::optional(value) : std::nullopt) {} private: - static constexpr const char* kName = "MACOSX_DEPLOYMENT_TARGET"; - void apply(const char* v) { -#if defined(_WIN32) - ::_putenv_s(kName, v ? v : ""); -#else - if (v) ::setenv(kName, v, 1); else ::unsetenv(kName); -#endif - } - bool had_ = false; - std::string old_; + mcpp::invocation::ScopedEnv inner_; // stated for this invocation, as mcpp states its own }; } // namespace @@ -139,7 +126,7 @@ TEST(AppleSdk, AnUnnamedOverrideAnswersTheDefaultQuestionOnly) { // for. TEST(AppleSdk, TheDefaultRequestIsTheMacosSdk) { EXPECT_EQ(mac::sdk_path(), mac::sdk_path(mac::sdk_macos)); - if constexpr (!mcpp::platform::is_macos) { + if constexpr (!mcpplibs::os::is_macos) { EXPECT_FALSE(mac::sdk_path().has_value()) << "an Apple SDK on a host that has none is a located path that " "does not exist"; @@ -155,7 +142,7 @@ TEST(AppleSdk, TheDefaultRequestIsTheMacosSdk) { // `macos_deployment_target` entirely. These run on whatever host is building // mcpp itself (this repository's CI includes non-Apple runners), which is // exactly the property under test: nothing here branches on -// `mcpp::platform::is_macos`, and a reintroduced `#if defined(__APPLE__)` +// `mcpplibs::os::is_macos`, and a reintroduced `#if defined(__APPLE__)` // guard would make `TargetIsMacosEnvBeatsManifestBeatsDefault` fail on any // non-Apple host. diff --git a/tests/unit/test_platform_env.cpp b/tests/unit/test_platform_env.cpp index fe6adf7f..7822fc7e 100644 --- a/tests/unit/test_platform_env.cpp +++ b/tests/unit/test_platform_env.cpp @@ -2,33 +2,20 @@ #include import std; -import mcpp.platform.env; +import mcpp.invocation; +import mcpp.runtime.launch_env; namespace { class ScopedVar { public: - ScopedVar(std::string name, const char* value) : name_(std::move(name)) { - if (const char* old = std::getenv(name_.c_str()); old) { had_ = true; old_ = old; } - apply(value); - } - ~ScopedVar() { apply(had_ ? old_.c_str() : nullptr); } - ScopedVar(const ScopedVar&) = delete; - ScopedVar& operator=(const ScopedVar&) = delete; + ScopedVar(std::string name, const char* value) + : inner_(std::move(name), value ? std::optional(value) : std::nullopt) {} private: - void apply(const char* v) { -#if defined(_WIN32) - ::_putenv_s(name_.c_str(), v ? v : ""); -#else - if (v) ::setenv(name_.c_str(), v, 1); else ::unsetenv(name_.c_str()); -#endif - } - std::string name_; - bool had_ = false; - std::string old_; + mcpp::invocation::ScopedEnv inner_; // stated for this invocation, as mcpp states its own }; -std::string sep() { return mcpp::platform::env::path_list_separator(); } +std::string sep() { return mcpp::runtime::launch_env::path_list_separator(); } std::string join(std::initializer_list parts) { std::string out; @@ -51,14 +38,14 @@ std::string host(std::string_view p) { TEST(PlatformEnv, StripPrivateGlibcDropsOnlyPayloadEntries) { auto in = join({"/usr/lib", "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64", "/opt/mine"}); - EXPECT_EQ(mcpp::platform::env::strip_private_glibc(in), + EXPECT_EQ(mcpp::runtime::launch_env::strip_private_glibc(in), join({host("/usr/lib"), host("/opt/mine")})); } TEST(PlatformEnv, StripPrivateGlibcCanEmptyTheList) { - EXPECT_EQ(mcpp::platform::env::strip_private_glibc( + EXPECT_EQ(mcpp::runtime::launch_env::strip_private_glibc( "/x/xpkgs/xim-x-glibc/2.42/lib64"), ""); - EXPECT_EQ(mcpp::platform::env::strip_private_glibc(""), ""); + EXPECT_EQ(mcpp::runtime::launch_env::strip_private_glibc(""), ""); } // The regression this guards (mcpp#311 investigation): the composed value @@ -75,7 +62,7 @@ TEST(PlatformEnv, PrependPathListSanitizesTheInheritedLoaderTail) { ScopedVar ld("LD_LIBRARY_PATH", inherited.c_str()); std::vector dirs{"/payload/lib"}; - EXPECT_EQ(mcpp::platform::env::prepend_path_list("LD_LIBRARY_PATH", dirs), + EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), join({host("/payload/lib"), host("/first"), host("/last")})); } @@ -87,7 +74,7 @@ TEST(PlatformEnv, PrependPathListKeepsExplicitPayloadDirs) { std::vector dirs{ "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64"}; // Explicit dir kept, inherited (older, mismatched) entry dropped. - EXPECT_EQ(mcpp::platform::env::prepend_path_list("LD_LIBRARY_PATH", dirs), + EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), host("/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64")); } @@ -97,13 +84,13 @@ TEST(PlatformEnv, PrependPathListLeavesPathUntouched) { ScopedVar path("PATH", inherited.c_str()); std::vector dirs{"/tools/bin"}; - EXPECT_EQ(mcpp::platform::env::prepend_path_list("PATH", dirs), + EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("PATH", dirs), join({host("/tools/bin"), "/bin", "/x/xpkgs/xim-x-glibc/2.39/lib64"})); } TEST(PlatformEnv, PrependPathListWithNoInheritedValueIsJustTheDirs) { ScopedVar ld("LD_LIBRARY_PATH", nullptr); std::vector dirs{"/a", "/b"}; - EXPECT_EQ(mcpp::platform::env::prepend_path_list("LD_LIBRARY_PATH", dirs), + EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), join({host("/a"), host("/b")})); } diff --git a/tests/unit/test_platform_fs.cpp b/tests/unit/test_platform_fs.cpp index e11b4417..14386994 100644 --- a/tests/unit/test_platform_fs.cpp +++ b/tests/unit/test_platform_fs.cpp @@ -1,8 +1,10 @@ #include import std; -import mcpp.platform.fs; -import mcpp.platform.common; +import mcpplibs.os.platform; +import mcpplibs.os.fs; +import mcpp.invocation; +import mcpp.home.files; namespace { @@ -37,7 +39,7 @@ TEST(PlatformFs, ReplaceFileAtomicallyReplacesExistingFile) { write_file(destination, "old"); std::error_code ec; - EXPECT_TRUE(mcpp::platform::fs::replace_file(source, destination, ec)) + EXPECT_TRUE(mcpp::home::replace_file(source, destination, ec)) << ec.message(); EXPECT_FALSE(std::filesystem::exists(source)); EXPECT_EQ(read_file(destination), "new"); @@ -49,7 +51,7 @@ TEST(PlatformFs, ReplaceFailureKeepsExistingDestination) { write_file(destination, "last-known-good"); std::error_code ec; - EXPECT_FALSE(mcpp::platform::fs::replace_file( + EXPECT_FALSE(mcpp::home::replace_file( temp.path / "missing.tmp", destination, ec)); EXPECT_TRUE(ec); EXPECT_EQ(read_file(destination), "last-known-good"); @@ -62,46 +64,46 @@ TEST(PlatformFs, ReplaceFailureKeepsExistingDestination) { // Outside Windows there is no path limit of this kind, so the helper is the // identity: a relative path stays relative and nothing is spelled differently. TEST(PlatformFs, ExtendedLengthIsTheIdentityOutsideWindows) { - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { GTEST_SKIP() << "the Windows branch is covered by the spelling tests"; } else { const std::filesystem::path rel{"obj/pkg/__pkg/dep/a.cpp.ddi"}; - EXPECT_EQ(mcpp::platform::fs::extended_length(rel), rel); + EXPECT_EQ(mcpplibs::os::fs::extended(rel), rel); const std::filesystem::path abs{"/tmp/x/../y"}; - EXPECT_EQ(mcpp::platform::fs::extended_length(abs), abs); - EXPECT_TRUE(mcpp::platform::fs::extended_length({}).empty()); + EXPECT_EQ(mcpplibs::os::fs::extended(abs), abs); + EXPECT_TRUE(mcpplibs::os::fs::extended({}).empty()); } } // The Windows rule, as a pure function, so every host tests it. TEST(PlatformFs, WindowsExtendedLengthSpellsADrivePath) { - using mcpp::platform::fs::windows_extended_length_spelling; - EXPECT_EQ(windows_extended_length_spelling("C:/Users/runner/.mcpp/obj/a.ddi"), + using mcpplibs::os::fs::windows_extended; + EXPECT_EQ(mcpplibs::os::fs::windows_extended("C:/Users/runner/.mcpp/obj/a.ddi"), "\\\\?\\C:\\Users\\runner\\.mcpp\\obj\\a.ddi"); // `.` and `..` are resolved lexically, because the prefix turns their // processing off; `..` does not climb above the drive. - EXPECT_EQ(windows_extended_length_spelling("C:\\a\\.\\b\\..\\c\\"), + EXPECT_EQ(mcpplibs::os::fs::windows_extended("C:\\a\\.\\b\\..\\c\\"), "\\\\?\\C:\\a\\c"); - EXPECT_EQ(windows_extended_length_spelling("C:/../../x"), "\\\\?\\C:\\x"); - EXPECT_EQ(windows_extended_length_spelling("D:/"), "\\\\?\\D:\\"); + EXPECT_EQ(mcpplibs::os::fs::windows_extended("C:/../../x"), "\\\\?\\C:\\x"); + EXPECT_EQ(mcpplibs::os::fs::windows_extended("D:/"), "\\\\?\\D:\\"); } TEST(PlatformFs, WindowsExtendedLengthSpellsAUncPath) { - using mcpp::platform::fs::windows_extended_length_spelling; - EXPECT_EQ(windows_extended_length_spelling("//server/share/dir/../f.o"), + using mcpplibs::os::fs::windows_extended; + EXPECT_EQ(mcpplibs::os::fs::windows_extended("//server/share/dir/../f.o"), "\\\\?\\UNC\\server\\share\\f.o"); // `..` does not climb above the share. - EXPECT_EQ(windows_extended_length_spelling("\\\\server\\share\\..\\..\\f"), + EXPECT_EQ(mcpplibs::os::fs::windows_extended("\\\\server\\share\\..\\..\\f"), "\\\\?\\UNC\\server\\share\\f"); } TEST(PlatformFs, WindowsExtendedLengthLeavesPrefixedAndRelativePathsAlone) { - using mcpp::platform::fs::windows_extended_length_spelling; + using mcpplibs::os::fs::windows_extended; // Idempotent: a path handed on to a child process is converted again there. - EXPECT_EQ(windows_extended_length_spelling("\\\\?\\C:\\a\\b"), "\\\\?\\C:\\a\\b"); - EXPECT_EQ(windows_extended_length_spelling("\\\\.\\pipe\\x"), "\\\\.\\pipe\\x"); - EXPECT_EQ(windows_extended_length_spelling("obj/a.ddi"), "obj/a.ddi"); - EXPECT_EQ(windows_extended_length_spelling(""), ""); + EXPECT_EQ(mcpplibs::os::fs::windows_extended("\\\\?\\C:\\a\\b"), "\\\\?\\C:\\a\\b"); + EXPECT_EQ(mcpplibs::os::fs::windows_extended("\\\\.\\pipe\\x"), "\\\\.\\pipe\\x"); + EXPECT_EQ(mcpplibs::os::fs::windows_extended("obj/a.ddi"), "obj/a.ddi"); + EXPECT_EQ(mcpplibs::os::fs::windows_extended(""), ""); } // A NAME THE SHELL ANSWERS FOR ITSELF still resolves to the program on PATH. @@ -117,7 +119,7 @@ TEST(PlatformFs, WhichResolvesANameThatIsAlsoAShellBuiltin) { if (!std::filesystem::is_regular_file("/usr/bin/true", ec) && !std::filesystem::is_regular_file("/bin/true", ec)) GTEST_SKIP() << "this host has no `true` program to find"; - const auto found = mcpp::platform::fs::which("true"); + const auto found = mcpp::invocation::which("true"); ASSERT_TRUE(found.has_value()); EXPECT_EQ(found->filename(), "true"); EXPECT_TRUE(std::filesystem::is_regular_file(*found, ec)) << found->string(); @@ -125,5 +127,5 @@ TEST(PlatformFs, WhichResolvesANameThatIsAlsoAShellBuiltin) { } TEST(PlatformFs, WhichStillAnswersNothingForANameNoHostHas) { - EXPECT_FALSE(mcpp::platform::fs::which("mcpp-no-such-program-7c25d9f6").has_value()); + EXPECT_FALSE(mcpp::invocation::which("mcpp-no-such-program-7c25d9f6").has_value()); } diff --git a/tests/unit/test_platform_terminal.cpp b/tests/unit/test_platform_terminal.cpp index 9c361be3..9dc788b0 100644 --- a/tests/unit/test_platform_terminal.cpp +++ b/tests/unit/test_platform_terminal.cpp @@ -1,15 +1,15 @@ #include import std; -import mcpp.platform.terminal; +import mcpp.ui.terminal; // The keys a game reads from a POSIX terminal // (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.14): the // decoder turns the bytes the terminal sent into keys, skips a sequence for // any other key whole, and keeps an incomplete sequence for the next read. -using mcpp::platform::terminal::Key; -using mcpp::platform::terminal::decode_keys; +using mcpp::ui::terminal::Key; +using mcpp::ui::terminal::decode_keys; TEST(TerminalKeys, ArrowsInBothModesAndTheSecondSet) { std::string in = "\x1b[A\x1b[B\x1bOC\x1bOD wasd"; diff --git a/tests/unit/test_pm_index_refresh.cpp b/tests/unit/test_pm_index_refresh.cpp index f8a7a2d3..69668bec 100644 --- a/tests/unit/test_pm_index_refresh.cpp +++ b/tests/unit/test_pm_index_refresh.cpp @@ -20,7 +20,7 @@ import mcpp.pm.dep_spec; import mcpp.pm.index_refresh; import mcpp.pm.index_route; import mcpp.pm.index_spec; -import mcpp.platform.axis; +import mcpp.toolchain.platform; import mcpp.provider.xlings; namespace { @@ -121,7 +121,7 @@ RefreshReason decide(const mcpp::pm::IndexRoute& route, mcpp::pm::RefreshPolicy policy = {}) { return mcpp::pm::decide_for_dependency( route, spec.shortName, spec, env, - mcpp::platform::TargetPlatform::for_os("linux"), policy).reason; + mcpp::toolchain::platform::TargetPlatform::for_os("linux"), policy).reason; } bool refreshes(const mcpp::pm::IndexRoute& route, @@ -130,7 +130,7 @@ bool refreshes(const mcpp::pm::IndexRoute& route, mcpp::pm::RefreshPolicy policy = {}) { return mcpp::pm::decide_for_dependency( route, spec.shortName, spec, env, - mcpp::platform::TargetPlatform::for_os("linux"), policy).shouldRefresh; + mcpp::toolchain::platform::TargetPlatform::for_os("linux"), policy).shouldRefresh; } constexpr std::string_view kOneVersion = R"(["1.2.0"] = { url = "u", sha256 = "s" },)"; @@ -381,6 +381,6 @@ TEST(PmIndexRefresh, SubjectEchoesWhatTheUserWrote) { auto spec = version_dep("mcpplibs.xim", "nasm", "2.16.03"); auto d = mcpp::pm::decide_for_dependency( route, "xim.nasm", spec, reg.env(), - mcpp::platform::TargetPlatform::for_os("linux"), {}); + mcpp::toolchain::platform::TargetPlatform::for_os("linux"), {}); EXPECT_EQ(d.subject, "xim.nasm@2.16.03"); } diff --git a/tests/unit/test_post_install.cpp b/tests/unit/test_post_install.cpp index 2d90e476..62e74a1f 100644 --- a/tests/unit/test_post_install.cpp +++ b/tests/unit/test_post_install.cpp @@ -6,7 +6,6 @@ import mcpp.toolchain.post_install; import mcpp.provider.xlings; import mcpp.config; import mcpp.toolchain.registry; -import mcpp.platform; // detect_baked_loader parses gcc SPECS GRAMMAR, not plain text. The baked // loader path is embedded inside %-spec conditionals, e.g. diff --git a/tests/unit/test_process_run_exec.cpp b/tests/unit/test_process_run_exec.cpp index 36db0d4f..2abb0b58 100644 --- a/tests/unit/test_process_run_exec.cpp +++ b/tests/unit/test_process_run_exec.cpp @@ -1,12 +1,12 @@ #include #include -#include #include import std; -import mcpp.platform.process; +import mcpp.run; +import mcpp.invocation; -using namespace mcpp::platform; +namespace process = mcpp::run; // These exercise the POSIX launch path with POSIX program paths (/bin/true, // /bin/sh, /bin/echo) — Linux and macOS share the direct posix_spawn path @@ -16,18 +16,17 @@ using namespace mcpp::platform; #if !defined(_WIN32) // The regression that matters: launching with an injected loader var must NOT -// mutate the parent (mcpp) environment — that mutation is exactly what leaked -// into /bin/sh and crashed it on newer-glibc hosts. +// change mcpp's own environment — that mutation is exactly what leaked into +// /bin/sh and crashed it on newer-glibc hosts. The addition is the started +// program's alone; this invocation's statement is what it was. TEST(RunExec, DoesNotMutateParentEnvironment) { - ::setenv("MCPP_TEST_LEAK", "sentinel", 1); + mcpp::invocation::ScopedEnv leak("MCPP_TEST_LEAK", "sentinel"); // Use /bin/sh (present on Linux AND macOS) — /bin/true lives at // /usr/bin/true on macOS, so it is not a portable launch target. - int rc = process::run_exec({"/bin/sh", "-c", "exit 0"}, + int rc = process::run_exec({"/bin/sh", "-c", "[ \"$MCPP_TEST_LEAK\" = injected ]"}, {{"MCPP_TEST_LEAK", "injected"}}); EXPECT_EQ(rc, 0); - const char* v = ::getenv("MCPP_TEST_LEAK"); - ASSERT_NE(v, nullptr); - EXPECT_STREQ(v, "sentinel"); // parent unchanged → no leak + EXPECT_EQ(mcpp::invocation::get("MCPP_TEST_LEAK"), "sentinel"); // unchanged → no leak } TEST(RunExec, ChildSeesInjectedEnv) { @@ -266,11 +265,3 @@ TEST(RunExec, WindowsCoveredByIntegration) { #endif -// Host-independent: the command line capture_stdout gives cmd.exe names cmd's -// own null device, never a POSIX path cmd would try to open as `\dev\null`. -TEST(CaptureStdout, WindowsCommandNamesCmdNullDevice) { - auto line = process::windows_stdout_probe_command( - {"C:\\Program Files\\xlings\\xlings.exe", "--version"}); - EXPECT_EQ(line, "\"C:\\Program Files\\xlings\\xlings.exe\" \"--version\" 2>nul"); - EXPECT_EQ(line.find("/dev/null"), std::string::npos) << line; -} diff --git a/tests/unit/test_process_seal_stdin.cpp b/tests/unit/test_process_seal_stdin.cpp index f564bede..62097b77 100644 --- a/tests/unit/test_process_seal_stdin.cpp +++ b/tests/unit/test_process_seal_stdin.cpp @@ -2,163 +2,77 @@ // git child processes blocked on terminal stdin, forcing the user to press // Enter repeatedly to advance bootstrap / toolchain install. // -// `mcpp::platform::process::{capture, run_silent, run_streaming}` MUST seal -// stdin so any child reading stdin gets immediate EOF, not a blocking read. -// POSIX: appends " -#include - -#if defined(_WIN32) -#include -#include -#include -#else -#include -#include -#endif import std; -import mcpp.platform.process; +import mcpp.run; +import mcpp.invocation; +import mcpplibs.os.platform; +import mcpplibs.os.process; namespace { -// Maximum seconds a sealed-stdin command may take before we declare it -// "hung". Real child runs (cat / more reading from EOF stdin) complete in -// well under 100ms on any modern machine, so 5s is a very generous bound. -constexpr int kMaxSealedSeconds = 5; +namespace os = mcpplibs::os; -// RAII: rebind STDIN to an open, empty, never-closing pipe for the duration -// of one test. Restores the original stdin on destruction. -class OpenEmptyStdinScope { -public: - OpenEmptyStdinScope() { -#if defined(_WIN32) - SECURITY_ATTRIBUTES sa{}; - sa.nLength = sizeof(sa); - sa.bInheritHandle = TRUE; - if (!CreatePipe(&hRead_, &hWrite_, &sa, 0)) { - std::abort(); - } - // Make the read end inheritable (already is via sa, but be explicit). - SetHandleInformation(hRead_, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); +// A reading child completes in well under 100 ms on any machine; 5 s is a +// very generous bound. +constexpr auto kMaxSealed = std::chrono::seconds(5); - // Save the original stdin (both Win32 handle + CRT fd) so we can - // restore in the destructor. - origStdinHandle_ = GetStdHandle(STD_INPUT_HANDLE); - origStdinFd_ = _dup(0); +// A program that reads its standard input to the end. +std::vector reader() { + if constexpr (os::is_windows) return { "more" }; + else return { "cat" }; +} - // Bind the pipe-read-end as our process's stdin at both layers. - SetStdHandle(STD_INPUT_HANDLE, hRead_); - int newFd = _open_osfhandle(reinterpret_cast(hRead_), - _O_RDONLY | _O_BINARY); - if (newFd >= 0) { - _dup2(newFd, 0); - _close(newFd); // _dup2 keeps a reference; we're done with newFd - } -#else - if (::pipe(pipeFds_) != 0) std::abort(); - origStdinFd_ = ::dup(0); - ::dup2(pipeFds_[0], 0); -#endif +std::optional child_mode() { return mcpp::invocation::get("MCPP_TEST_STDIN_CHILD"); } + +} // namespace + +// The copy started by the test below: starts the reader through the launcher +// it is named, with this process's input the pipe nobody writes. +TEST(ProcessSealStdin, Child) { + const auto mode = child_mode(); + if (!mode) GTEST_SKIP() << "run by AProgramReadsAnEmptyInput"; + if (*mode == "execute") { + (void)mcpp::run::execute(reader(), { .streams = mcpp::run::Streams::discard }); + } else if (*mode == "stream") { + bool timedOut = false; + (void)mcpp::run::stream_exec(reader(), {}, {}, [](std::string_view) {}, &timedOut); + } else if (*mode == "probe") { + (void)mcpp::run::capture_stdout(reader()); + } else if (*mode == "shell") { + (void)mcpp::run::capture_shell(os::is_windows ? "more >nul 2>&1" : "cat >/dev/null"); } +} - ~OpenEmptyStdinScope() { -#if defined(_WIN32) - // Restore original stdin. - if (origStdinFd_ >= 0) { - _dup2(origStdinFd_, 0); - _close(origStdinFd_); +TEST(ProcessSealStdin, AProgramReadsAnEmptyInput) { + if (child_mode()) GTEST_SKIP(); + for (std::string mode : { "execute", "stream", "probe", "shell" }) { + os::process::spawn_options o; + o.program = mcpp::invocation::self_exe_path(); + o.args = { "--gtest_filter=ProcessSealStdin.Child" }; + o.env = { { "MCPP_TEST_STDIN_CHILD", mode } }; + o.in = os::process::stdio::pipe; // held open, never written + o.out = o.err = os::process::stdio::null; + o.own_unit = true; + auto p = os::process::process::spawn(o); + ASSERT_TRUE(p) << mode; + auto w = p->wait_for(kMaxSealed); + if (!w || !w->has_value()) { + p->end_unit(); + p->kill(); + FAIL() << mode << ": the started program waited on mcpp's input"; } - SetStdHandle(STD_INPUT_HANDLE, origStdinHandle_); - if (hWrite_) CloseHandle(hWrite_); - if (hRead_) CloseHandle(hRead_); -#else - if (origStdinFd_ >= 0) { - ::dup2(origStdinFd_, 0); - ::close(origStdinFd_); - } - ::close(pipeFds_[0]); - ::close(pipeFds_[1]); -#endif + EXPECT_TRUE((*w)->success()) << mode; } - - OpenEmptyStdinScope(const OpenEmptyStdinScope&) = delete; - OpenEmptyStdinScope& operator=(const OpenEmptyStdinScope&) = delete; - -private: -#if defined(_WIN32) - HANDLE hRead_ = nullptr; - HANDLE hWrite_ = nullptr; // intentionally never written → reader blocks - HANDLE origStdinHandle_ = nullptr; - int origStdinFd_ = -1; -#else - int pipeFds_[2] = {-1, -1}; // intentionally never written → reader blocks - int origStdinFd_ = -1; -#endif -}; - -// A child command that reads stdin to EOF and exits. -// With seal_stdin in effect → stdin is NUL / /dev/null → child exits immediately. -// Without seal_stdin AND with an open-empty parent stdin → child blocks forever. -constexpr std::string_view kStdinReaderCmd = -#if defined(_WIN32) - "more >nul 2>&1" -#else - "cat >/dev/null" -#endif - ; - -template -double time_seconds(F&& fn) { - auto t0 = std::chrono::steady_clock::now(); - fn(); - auto t1 = std::chrono::steady_clock::now(); - return std::chrono::duration(t1 - t0).count(); -} - -} // namespace - -// run_silent: must seal stdin so the child does not inherit our pipe stdin -// and block forever. -TEST(ProcessSealStdin, RunSilentDoesNotHangWhenParentStdinIsOpenPipe) { - OpenEmptyStdinScope scope; - double elapsed = time_seconds([] { - (void)mcpp::platform::process::run_silent(kStdinReaderCmd); - }); - EXPECT_LT(elapsed, static_cast(kMaxSealedSeconds)) - << "run_silent took " << elapsed - << "s — child blocked on stdin → seal_stdin is broken or not applied"; -} - -// capture: must also seal stdin (it shares seal_stdin with run_silent). -TEST(ProcessSealStdin, CaptureDoesNotHangWhenParentStdinIsOpenPipe) { - OpenEmptyStdinScope scope; - double elapsed = time_seconds([] { - (void)mcpp::platform::process::capture(kStdinReaderCmd); - }); - EXPECT_LT(elapsed, static_cast(kMaxSealedSeconds)) - << "capture took " << elapsed - << "s — child blocked on stdin → seal_stdin is broken or not applied"; -} - -// run_streaming: same property — children spawned via popen-streaming must -// not inherit a live stdin. -TEST(ProcessSealStdin, RunStreamingDoesNotHangWhenParentStdinIsOpenPipe) { - OpenEmptyStdinScope scope; - double elapsed = time_seconds([] { - (void)mcpp::platform::process::run_streaming( - kStdinReaderCmd, - [](std::string_view) {}); - }); - EXPECT_LT(elapsed, static_cast(kMaxSealedSeconds)) - << "run_streaming took " << elapsed - << "s — child blocked on stdin → seal_stdin is broken or not applied"; } diff --git a/tests/unit/test_progress_render.cpp b/tests/unit/test_progress_render.cpp index 7cb2e0fc..aaf6d09b 100644 --- a/tests/unit/test_progress_render.cpp +++ b/tests/unit/test_progress_render.cpp @@ -3,7 +3,8 @@ import std; import mcpp.ui; import mcpp.fetcher.progress; -import mcpp.platform.process; +import mcpp.run; +import mcpplibs.os.platform; // W11: one renderer for every acquisition, with a terminal mode (redrawn in // place) and a plain mode; either prints one line per item (#734). The renderer @@ -89,19 +90,14 @@ TEST(ProgressBarLive, RedrawsInPlace) { EXPECT_NE(out.find('\r'), std::string::npos) << out; } -#if !defined(_WIN32) TEST(StreamingBounded, ACarriageReturnEndsALineOnlyWhenAsked) { - const auto cmd = std::string("printf 'a\\rb\\r\\nc\\n'"); + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "printf is a POSIX program"; + const std::vector argv{ "printf", "a\\rb\\r\\nc\\n" }; std::vector plain, split; - bool timedOut = false; - mcpp::platform::process::run_streaming_bounded(cmd, - [&](std::string_view l) { plain.emplace_back(l); }, - std::chrono::milliseconds{0}, std::chrono::seconds{30}, &timedOut); - mcpp::platform::process::run_streaming_bounded(cmd, - [&](std::string_view l) { split.emplace_back(l); }, - std::chrono::milliseconds{0}, std::chrono::seconds{30}, &timedOut, - /*split_on_cr=*/true); + (void)mcpp::run::execute(argv, { .idle = std::chrono::seconds{30}, + .on_line = [&](std::string_view l) { plain.emplace_back(l); } }); + (void)mcpp::run::execute(argv, { .idle = std::chrono::seconds{30}, + .on_line = [&](std::string_view l) { split.emplace_back(l); }, .split_on_cr = true }); EXPECT_EQ(plain, (std::vector{"a\rb", "c"})); EXPECT_EQ(split, (std::vector{"a", "b", "c"})); } -#endif diff --git a/modules/platform/tests/test_process_concurrent_children.cpp b/tests/unit/test_run_concurrent_children.cpp similarity index 82% rename from modules/platform/tests/test_process_concurrent_children.cpp rename to tests/unit/test_run_concurrent_children.cpp index 69f2d33d..b2cd42d7 100644 --- a/modules/platform/tests/test_process_concurrent_children.cpp +++ b/tests/unit/test_run_concurrent_children.cpp @@ -1,13 +1,7 @@ #include -#if !defined(_WIN32) -#include -#include -#endif - import std; -import mcpp.platform.process; -import mcpp.platform.unix.bounded_process; +import mcpp.run; // SUBSYSTEM-LEVEL, and the contract is B2-0 of the member-selection and // build-program-cost plan (#748): the launcher is safe for concurrent callers. @@ -19,7 +13,7 @@ import mcpp.platform.unix.bounded_process; // open for as long as it lives. The other thread's reader then waits for end of // file until an unrelated child exits. -namespace proc = mcpp::platform::process; +namespace proc = mcpp::run; namespace { @@ -89,17 +83,3 @@ TEST(ConcurrentChildren, AReaderReturnsWhenItsOwnChildExits) { << "it waited for a child another thread started"; } } - -#if !defined(_WIN32) -// The mechanism behind the test above, stated directly. A pipe the launcher -// creates is close-on-exec on both ends, so the only descriptors a child can -// inherit are the ones `posix_spawn` duplicates onto its standard streams. -TEST(ConcurrentChildren, APipeTheLauncherCreatesClosesOnExec) { - int fds[2] = {-1, -1}; - ASSERT_EQ(mcpp::platform::unixproc::make_pipe(fds), 0); - EXPECT_TRUE(::fcntl(fds[0], F_GETFD) & FD_CLOEXEC) << "the read end is inheritable"; - EXPECT_TRUE(::fcntl(fds[1], F_GETFD) & FD_CLOEXEC) << "the write end is inheritable"; - ::close(fds[0]); - ::close(fds[1]); -} -#endif diff --git a/tests/unit/test_runtime_contract.cpp b/tests/unit/test_runtime_contract.cpp index 405ea8b2..5b89c3ea 100644 --- a/tests/unit/test_runtime_contract.cpp +++ b/tests/unit/test_runtime_contract.cpp @@ -6,10 +6,10 @@ import mcpp.manifest.toml; import mcpp.manifest.types; import mcpp.manifest.xpkg; import mcpp.modgraph.scanner; -import mcpp.platform; -import mcpp.platform.axis; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; +import mcpp.runtime.search; import mcpp.runtime.binding; -import mcpp.platform.runtime_search; import mcpp.provider.xlings.subos_info; namespace build = mcpp::build; @@ -133,7 +133,7 @@ package = { } )"; auto parsed = mf::synthesize_from_xpkg_lua( - lua, "backend", "2.0.0", mcpp::platform::HostPlatform::current()); + lua, "backend", "2.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(parsed) << parsed.error().format(); ASSERT_EQ(parsed->runtimeConfig.requirements.size(), 1u); EXPECT_EQ(parsed->runtimeConfig.requirements[0].value, "display.present"); @@ -260,7 +260,7 @@ TEST(RuntimeContract, XlingsSelectedFactsPrecedeDescriptorFallbacks) { .provider = {.namespace_ = "mcpplibs", .name = "fallback", .version = "1.0.0", .sourceProvenance = "index+mcpplibs"}, }); - mcpp::platform::runtime::RuntimeBinding binding; + mcpp::runtime::RuntimeBinding binding; binding.runtimeProviders.push_back({ .capability = "render.demo", .provider = {.namespace_ = "xim", .name = "selected", @@ -306,8 +306,8 @@ build::BuildPlan plan_for(std::string targetTriple) { return plan; } -mcpp::platform::runtime::RuntimeBinding host_binding_with_farm() { - mcpp::platform::runtime::RuntimeBinding b; +mcpp::runtime::RuntimeBinding host_binding_with_farm() { + mcpp::runtime::RuntimeBinding b; b.platform = "linux"; b.arch = "x86_64"; b.declared = true; @@ -316,9 +316,9 @@ mcpp::platform::runtime::RuntimeBinding host_binding_with_farm() { return b; } -std::size_t farm_entries(const std::vector& dirs) { +std::size_t farm_entries(const std::vector& dirs) { return static_cast(std::ranges::count_if(dirs, [](auto const& d) { - return d.origin == mcpp::platform::search::Origin::SubosFarm; + return d.origin == mcpp::runtime::search::Origin::SubosFarm; })); } @@ -333,7 +333,7 @@ TEST(RuntimeSearchClosure, HostTargetGetsTheFarm) { // An EMPTY triple means "this host", so the answer is the host's format — // and that is the point of the ELF guard, not an exception to it. Asserting // 1 unconditionally is what failed on the macOS and Windows runners. - const std::size_t expectedForHost = mcpp::platform::is_linux ? 1u : 0u; + const std::size_t expectedForHost = mcpplibs::os::is_linux ? 1u : 0u; EXPECT_EQ(farm_entries(build::runtime_search_closure( plan_for(""), host_binding_with_farm())), expectedForHost) << "an empty triple must resolve to this host's format"; @@ -380,7 +380,7 @@ TEST(RuntimeSearchClosure, UndeclaredBindingStillGetsItsOwnFarm) { // existing one layer down. TEST(RuntimeIdentity, ProviderIsDispatchedOnNotPatternMatched) { - namespace rt = mcpp::platform::runtime; + namespace rt = mcpp::runtime; EXPECT_EQ(rt::runtime_provider("glibc@2.39"), "glibc"); EXPECT_EQ(rt::runtime_provider("ucrt@10.0.26100.0"), "ucrt"); EXPECT_EQ(rt::runtime_provider("macos_sdk@14.0"), "macos_sdk"); @@ -392,7 +392,7 @@ TEST(RuntimeIdentity, ProviderIsDispatchedOnNotPatternMatched) { } TEST(RuntimeIdentity, TheSdkVersionReachesTheContractHash) { - namespace rt = mcpp::platform::runtime; + namespace rt = mcpp::runtime; rt::RuntimeBinding a; a.platform = "windows"; a.contractHash = "unset"; @@ -410,7 +410,7 @@ TEST(RuntimeIdentity, TheSdkVersionReachesTheContractHash) { } TEST(RuntimeIdentity, BindingUcrtIsIdempotentAndSkipsAnEmptyVersion) { - namespace rt = mcpp::platform::runtime; + namespace rt = mcpp::runtime; rt::RuntimeBinding b; b.platform = "windows"; rt::bind_windows_ucrt(b, "10.0.26100.0"); @@ -438,7 +438,7 @@ TEST(RuntimeIdentity, UcrtIsAFloorDeclarationNotAPrivatePayload) { // of ucrt (headers + import libraries). Projecting it into `libc` would // send the private-libc machinery looking for a payload that was never // supposed to exist. - namespace rt = mcpp::platform::runtime; + namespace rt = mcpp::runtime; rt::RuntimeBinding b; b.platform = "windows"; rt::bind_windows_ucrt(b, "10.0.26100.0"); @@ -470,7 +470,7 @@ package = { } )"; auto parsed = mf::synthesize_from_xpkg_lua( - lua, "icd", "1.0.0", mcpp::platform::HostPlatform::current()); + lua, "icd", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_TRUE(parsed) << parsed.error().format(); auto const& d = parsed->runtimeConfig.linkIntent.deploy; ASSERT_EQ(d.size(), 2u); @@ -495,7 +495,7 @@ package = { } )"; auto parsed = mf::synthesize_from_xpkg_lua( - lua, "icd", "1.0.0", mcpp::platform::HostPlatform::current()); + lua, "icd", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_FALSE(parsed); EXPECT_NE(parsed.error().message.find("runtime.deploy[1]: `to` has a `.` or `..` component"), std::string::npos) << parsed.error().message; diff --git a/tests/unit/test_runtime_search.cpp b/tests/unit/test_runtime_search.cpp index 0aeac2b2..e978aef9 100644 --- a/tests/unit/test_runtime_search.cpp +++ b/tests/unit/test_runtime_search.cpp @@ -9,9 +9,9 @@ #include import std; -import mcpp.platform.runtime_search; +import mcpp.runtime.search; -namespace search = mcpp::platform::search; +namespace search = mcpp::runtime::search; using search::Origin; namespace { diff --git a/tests/unit/test_runtime_selection.cpp b/tests/unit/test_runtime_selection.cpp index dd1bb435..0b845520 100644 --- a/tests/unit/test_runtime_selection.cpp +++ b/tests/unit/test_runtime_selection.cpp @@ -3,7 +3,7 @@ import std; import mcpp.config; import mcpp.manifest; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.runtime.binding; import mcpp.provider.runtime_selection; @@ -151,12 +151,12 @@ TEST(RuntimeBinding, DefaultAlwaysUsesConfiguredMcppHomeDefault) { mcpp::manifest::Manifest root; auto selection = runtime::select_runtime(root, std::nullopt, h.dir / "repo"); ASSERT_TRUE(selection); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()) << binding.error(); EXPECT_EQ(binding->subosDir, expected); EXPECT_EQ(binding->runtimeId, "glibc@2.39"); - if constexpr (mcpp::platform::is_linux) + if constexpr (mcpplibs::os::is_linux) EXPECT_EQ(binding->libc, "glibc@2.39"); else EXPECT_TRUE(binding->libc.empty()); @@ -176,7 +176,7 @@ TEST(RuntimeBinding, ExplicitDefaultUsesGlobalDefaultButKeepsNamedIdentity) { auto manifest = named("default"); auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); ASSERT_TRUE(selection); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()) << binding.error(); EXPECT_EQ(binding->subosDir, expected); @@ -185,7 +185,7 @@ TEST(RuntimeBinding, ExplicitDefaultUsesGlobalDefaultButKeepsNamedIdentity) { } TEST(RuntimeBinding, PhysicalSubosViewReconcilesAStaleDeclaredGlibcIdentity) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "glibc SubOS views only exist on Linux"; RuntimeHome h; @@ -196,7 +196,7 @@ TEST(RuntimeBinding, PhysicalSubosViewReconcilesAStaleDeclaredGlibcIdentity) { mcpp::manifest::Manifest root; auto selection = runtime::select_runtime(root, std::nullopt, h.dir / "repo"); ASSERT_TRUE(selection); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()) << binding.error(); @@ -209,7 +209,7 @@ TEST(RuntimeBinding, PhysicalSubosViewReconcilesAStaleDeclaredGlibcIdentity) { } TEST(RuntimeBinding, BrokenSubosViewFallsBackOnlyToTheExactDeclaredPayload) { - if constexpr (!mcpp::platform::is_linux) + if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "glibc SubOS views only exist on Linux"; RuntimeHome h; @@ -225,7 +225,7 @@ TEST(RuntimeBinding, BrokenSubosViewFallsBackOnlyToTheExactDeclaredPayload) { mcpp::manifest::Manifest root; auto selection = runtime::select_runtime(root, std::nullopt, h.dir / "repo"); ASSERT_TRUE(selection); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()) << binding.error(); @@ -243,14 +243,14 @@ TEST(RuntimeBinding, NamedSubosIsOwnedByTheRootAndMissingIsHardError) { auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); ASSERT_TRUE(selection); - auto missing = mcpp::platform::runtime::resolve_runtime_binding( + auto missing = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_FALSE(missing.has_value()); EXPECT_NE(missing.error().find("el8"), std::string::npos); const auto expected = h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "el8"; h.write(expected); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()) << binding.error(); EXPECT_EQ(binding->subosDir, expected); @@ -269,19 +269,19 @@ TEST(RuntimeBinding, NamedEnvironmentsHaveDistinctContractsAndRoundTrip) { h.write(h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "el8"); h.write(h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "dev"); - auto el8 = mcpp::platform::runtime::resolve_runtime_binding( + auto el8 = mcpp::runtime::resolve_runtime_binding( *el8Selection, {}, h.cfg.xlingsHome()); - auto dev = mcpp::platform::runtime::resolve_runtime_binding( + auto dev = mcpp::runtime::resolve_runtime_binding( *devSelection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(el8); ASSERT_TRUE(dev); EXPECT_NE(el8->contractHash, dev->contractHash); - auto encoded = mcpp::platform::runtime::serialize_runtime_binding(*el8); - auto decoded = mcpp::platform::runtime::deserialize_runtime_binding(encoded); + auto encoded = mcpp::runtime::serialize_runtime_binding(*el8); + auto decoded = mcpp::runtime::deserialize_runtime_binding(encoded); ASSERT_TRUE(decoded.has_value()) << decoded.error(); EXPECT_EQ(decoded->contractHash, el8->contractHash); - if constexpr (mcpp::platform::is_linux) { + if constexpr (mcpplibs::os::is_linux) { ASSERT_FALSE(decoded->hostLibc.empty()); EXPECT_EQ(decoded->hostLibc, "2.43"); } else { @@ -318,7 +318,7 @@ TEST(RuntimeBindingDegradation, UndeclaredSubosStillResolves) { std::filesystem::create_directories( h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "bare"); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()) << "absence must degrade, not fail: " << binding.error(); @@ -341,12 +341,12 @@ TEST(RuntimeBindingDegradation, UndeclaredBindingRoundTrips) { ASSERT_TRUE(selection); std::filesystem::create_directories( h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "bare"); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()); - auto decoded = mcpp::platform::runtime::deserialize_runtime_binding( - mcpp::platform::runtime::serialize_runtime_binding(*binding)); + auto decoded = mcpp::runtime::deserialize_runtime_binding( + mcpp::runtime::serialize_runtime_binding(*binding)); ASSERT_TRUE(decoded.has_value()) << decoded.error(); EXPECT_EQ(decoded->contractHash, binding->contractHash); EXPECT_FALSE(decoded->declared); @@ -374,7 +374,7 @@ TEST(RuntimeBindingDegradation, NewerSchemaIsReadNotRefused) { } })"; - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_TRUE(binding.has_value()) << "a newer contract must not invalidate an older mcpp: " @@ -394,7 +394,7 @@ TEST(RuntimeBindingDegradation, MissingSubosStillFails) { auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); ASSERT_TRUE(selection); - auto binding = mcpp::platform::runtime::resolve_runtime_binding( + auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); ASSERT_FALSE(binding.has_value()) << "a contradiction must be reported, not degraded"; diff --git a/tests/unit/test_scaffold.cpp b/tests/unit/test_scaffold.cpp index 222ce45a..9d346b47 100644 --- a/tests/unit/test_scaffold.cpp +++ b/tests/unit/test_scaffold.cpp @@ -1,10 +1,10 @@ #include import std; -import mcpp.platform.scaffold_fs; import mcpp.scaffold; import mcpp.scaffold.project_name; import mcpp.pm.dependency_selector; +import mcpplibs.os.fs; namespace { @@ -260,16 +260,16 @@ TEST(ScaffoldFilesystem, AtomicRenameNeverReplacesExistingTarget) { std::ofstream(source / "payload.txt") << "payload"; std::ofstream(target / "owner.txt") << "owner"; - auto collision = mcpp::platform::atomic_rename_directory_no_replace( + auto collision = mcpplibs::os::fs::place( source, target); ASSERT_FALSE(collision.has_value()); EXPECT_TRUE(std::filesystem::exists(source / "payload.txt")); EXPECT_TRUE(std::filesystem::exists(target / "owner.txt")); std::filesystem::remove_all(target); - auto committed = mcpp::platform::atomic_rename_directory_no_replace( + auto committed = mcpplibs::os::fs::place( source, target); - ASSERT_TRUE(committed.has_value()) << committed.error(); + ASSERT_TRUE(committed.has_value()) << committed.error().message(); EXPECT_FALSE(std::filesystem::exists(source)); EXPECT_TRUE(std::filesystem::exists(target / "payload.txt")); std::filesystem::remove_all(parent); diff --git a/tests/unit/test_schedule_policy.cpp b/tests/unit/test_schedule_policy.cpp index c0c1abdb..92893bf3 100644 --- a/tests/unit/test_schedule_policy.cpp +++ b/tests/unit/test_schedule_policy.cpp @@ -8,6 +8,7 @@ #include import std; +import mcpp.invocation; import mcpp.build.schedule.policy; import mcpp.toolchain.model; import mcpp.manifest; @@ -29,24 +30,10 @@ Toolchain with(CompilerId id) { // shell would decide these tests instead of the code under test. class ScopedVar { public: - ScopedVar(std::string name, const char* value) : name_(std::move(name)) { - if (const char* old = std::getenv(name_.c_str()); old) { had_ = true; old_ = old; } - apply(value); - } - ~ScopedVar() { apply(had_ ? old_.c_str() : nullptr); } - ScopedVar(const ScopedVar&) = delete; - ScopedVar& operator=(const ScopedVar&) = delete; + ScopedVar(std::string name, const char* value) + : inner_(std::move(name), value ? std::optional(value) : std::nullopt) {} private: - void apply(const char* v) { -#if defined(_WIN32) - ::_putenv_s(name_.c_str(), v ? v : ""); -#else - if (v) ::setenv(name_.c_str(), v, 1); else ::unsetenv(name_.c_str()); -#endif - } - std::string name_; - bool had_ = false; - std::string old_; + mcpp::invocation::ScopedEnv inner_; // stated for this invocation, as mcpp states its own }; mcpp::manifest::Manifest with_schedule(std::string v) { diff --git a/tests/unit/test_subos_info.cpp b/tests/unit/test_subos_info.cpp index 9aee8f15..66daef29 100644 --- a/tests/unit/test_subos_info.cpp +++ b/tests/unit/test_subos_info.cpp @@ -10,7 +10,7 @@ #include import std; -import mcpp.platform; +import mcpp.runtime.launch_env; import mcpp.provider.xlings.subos_info; namespace su = mcpp::provider::xlings::subos; @@ -139,7 +139,7 @@ TEST(SubosInfo, PrependJoinsProvidersInOrder) { "b-vendor@1":[{"var":"V","op":"prepend","value":"${subosdir}/two"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); ASSERT_EQ(env.size(), 1u); - const auto sep = mcpp::platform::env::path_list_separator(); + const auto sep = mcpp::runtime::launch_env::path_list_separator(); EXPECT_EQ(env[0].second, t.dir.string() + "/two" + sep + t.dir.string() + "/one") << "both providers must survive, later binding front-most"; @@ -158,7 +158,7 @@ TEST(SubosInfo, PrependDeduplicates) { // list separator: keyed on ':' it would cut "C:\\x" apart on Windows, // match nothing, and grow the list on every nested invocation. EXPECT_EQ(env[0].second, t.dir.string() + "/x"); - EXPECT_EQ(env[0].second.find(mcpp::platform::env::path_list_separator()), + EXPECT_EQ(env[0].second.find(mcpp::runtime::launch_env::path_list_separator()), std::string::npos); } @@ -306,7 +306,7 @@ TEST(SubosInfo, RealXlingsCapture) { std::map byVar; for (auto& [k, v] : env) byVar[k] = v; - const auto sep = mcpp::platform::env::path_list_separator(); + const auto sep = mcpp::runtime::launch_env::path_list_separator(); EXPECT_EQ(byVar["LIBGL_DRIVERS_PATH"], t.dir.string() + "/usr/lib/dri"); EXPECT_EQ(byVar["XDG_DATA_DIRS"], t.dir.string() + "/share"); // Both providers name the same vendor directory; de-duplication must @@ -349,7 +349,7 @@ TEST(SubosResolveEnv, PrependKeepsTheExportedValue) { return std::nullopt; }); ASSERT_EQ(out.size(), 1u); - const auto sep = mcpp::platform::env::path_list_separator(); + const auto sep = mcpp::runtime::launch_env::path_list_separator(); EXPECT_EQ(out[0].second, std::string("/sub/lib") + sep + "/user/lib"); } @@ -359,7 +359,7 @@ TEST(SubosResolveEnv, PrependIsIdempotentAgainstTheExportedValue) { t.write(R"({"workspace":{},"subos_info":{"schema_version":1,"runtime":"glibc@2.39", "envs":{"glibc@2.39":[{"var":"LD_LIBRARY_PATH","op":"prepend","value":"/sub/lib"}]}}})"); auto info = su::read(t.dir); - const auto sep = mcpp::platform::env::path_list_separator(); + const auto sep = mcpp::runtime::launch_env::path_list_separator(); const auto existing = std::string("/sub/lib") + sep + "/user/lib"; auto out = su::resolve_env( info, t.dir, [&](std::string_view v) -> std::optional { diff --git a/tests/unit/test_symbol_provision.cpp b/tests/unit/test_symbol_provision.cpp index a0db4eeb..f31f4483 100644 --- a/tests/unit/test_symbol_provision.cpp +++ b/tests/unit/test_symbol_provision.cpp @@ -18,7 +18,7 @@ import mcpp.build.symbol_provision; import mcpp.runtime.elf; namespace sp = mcpp::build::symbol_provision; -namespace elf = mcpp::platform::elf; +namespace elf = mcpp::runtime::elf; namespace { diff --git a/tests/unit/test_target_base.cpp b/tests/unit/test_target_base.cpp index 9a6eb42e..c2ed0bc1 100644 --- a/tests/unit/test_target_base.cpp +++ b/tests/unit/test_target_base.cpp @@ -6,8 +6,10 @@ import mcpp.toolchain.base_probe; import mcpp.toolchain.linkmodel; import mcpp.toolchain.model; import mcpp.toolchain.triple; -import mcpp.platform; -import mcpp.platform.macos; +import mcpplibs.os.platform; +import mcpp.toolchain.apple_sdk; +import mcpp.invocation; +import mcpp.build.command_line; // The target base (design 2026-10-23, "target base and execution host"): the // string grammar of `sysroot`, which spellings each row takes, the @@ -195,7 +197,7 @@ TEST(TargetBaseApple, SdkrootOnEveryHost) { touch(sdk / "usr" / "include" / "stdio.h"); touch(sdk / "usr" / "lib" / "libSystem.tbd", "--- !tapi-tbd\ntbd-version: 4\ntargets: [ x86_64-ios-simulator, arm64-ios-simulator ]\n"); - mcpp::platform::env::ScopedEnv env("SDKROOT", sdk.string()); + mcpp::invocation::ScopedEnv env("SDKROOT", sdk.string()); auto found = tb::locate_apple(*tb::parse_text("iphonesimulator@system"), "iphonesimulator"); ASSERT_TRUE(found) << found.error(); EXPECT_EQ(found->root, sdk); @@ -203,10 +205,11 @@ TEST(TargetBaseApple, SdkrootOnEveryHost) { EXPECT_FALSE(tb::locate_apple(*tb::parse_text("iphonesimulator@17.0"), "iphonesimulator")); // SDKROOT names one SDK and does not answer for another. (On an Apple // host `xcrun` answers next, and Xcode's iPhoneOS SDK is found there.) - if (!mcpp::platform::is_macos) + if (!mcpplibs::os::is_macos) EXPECT_FALSE(tb::locate_apple(*tb::parse_text("iphoneos@system"), "iphoneos")); - EXPECT_EQ(mcpp::platform::macos::sdk_has_target(sdk, "x86_64-ios-simulator"), true); - EXPECT_EQ(mcpp::platform::macos::sdk_has_target(sdk, "x86_64-ios"), false); + using mcpp::toolchain::apple::Slice; + EXPECT_EQ(mcpp::toolchain::apple::sdk_has_target(sdk, "x86_64-ios-simulator"), Slice::carried); + EXPECT_EQ(mcpp::toolchain::apple::sdk_has_target(sdk, "x86_64-ios"), Slice::absent); } TEST(TargetBaseApple, PathMustBeAnSdk) { @@ -270,7 +273,7 @@ TEST(TargetBaseLinkModel, MultiarchForADriverThatDoesNotKnowIt) { } TEST(PasteArg, QuotesOnlyWhatTheShellWouldSplit) { - EXPECT_EQ(mcpp::platform::shell::paste_arg("/tmp/home"), "/tmp/home"); - EXPECT_NE(mcpp::platform::shell::paste_arg("/tmp/mc probe/home"), "/tmp/mc probe/home"); - EXPECT_NE(mcpp::platform::shell::paste_arg("/tmp/a'b"), "/tmp/a'b"); + EXPECT_EQ(mcpp::build::command_line::paste_arg("/tmp/home"), "/tmp/home"); + EXPECT_NE(mcpp::build::command_line::paste_arg("/tmp/mc probe/home"), "/tmp/mc probe/home"); + EXPECT_NE(mcpp::build::command_line::paste_arg("/tmp/a'b"), "/tmp/a'b"); } diff --git a/tests/unit/test_target_static.cpp b/tests/unit/test_target_static.cpp index 78465886..921c710c 100644 --- a/tests/unit/test_target_static.cpp +++ b/tests/unit/test_target_static.cpp @@ -2,11 +2,12 @@ import std; import mcpp.toolchain.model; +import mcpp.toolchain.platform; // Whether the produced artifact can be fully statically linked (`-static`) is // a property of the TARGET, never of the machine doing the build. // -// mcpp used to answer it with `mcpp::platform::supports_full_static`, a host +// mcpp used to answer it with `mcpp::toolchain::platform::supports_full_static`, a host // constant defined as `is_linux`. On a Linux host that happens to give the // right answer for every Linux target, so the bug stayed invisible — it only // surfaces once a non-Linux host cross-compiles to Linux, where `-static` diff --git a/tests/unit/test_toolchain_compat.cpp b/tests/unit/test_toolchain_compat.cpp index 023665c1..9ea01d53 100644 --- a/tests/unit/test_toolchain_compat.cpp +++ b/tests/unit/test_toolchain_compat.cpp @@ -1,13 +1,13 @@ #include import std; -import mcpp.platform; +import mcpp.toolchain.platform; import mcpp.toolchain.compat; using namespace mcpp::toolchain::compat; static std::string host_musl() { - return std::string(mcpp::platform::host_arch) + "-linux-musl"; + return std::string(mcpp::toolchain::platform::host_arch) + "-linux-musl"; } // ── canonical spellings pass through unchanged ─────────────────────────────── diff --git a/tests/unit/test_toolchain_descriptor.cpp b/tests/unit/test_toolchain_descriptor.cpp index 4ab1cc0e..8e62d4fb 100644 --- a/tests/unit/test_toolchain_descriptor.cpp +++ b/tests/unit/test_toolchain_descriptor.cpp @@ -1,7 +1,7 @@ #include import std; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.toolchain.model; import mcpp.toolchain.registry; import mcpp.toolchain.triple; @@ -53,7 +53,7 @@ XimToolchainPackage bin_shaped_pkg() { // executable suffix exists -- a test that was about the descriptor failing for // a reason that had nothing to do with it. std::string host_frontend_name() { - return std::string("clang++") + std::string(mcpp::platform::exe_suffix); + return std::string("clang++") + std::string(mcpplibs::os::exe_suffix); } } // namespace diff --git a/tests/unit/test_toolchain_detect.cpp b/tests/unit/test_toolchain_detect.cpp index eac7407f..9cedb79c 100644 --- a/tests/unit/test_toolchain_detect.cpp +++ b/tests/unit/test_toolchain_detect.cpp @@ -1,7 +1,7 @@ #include import std; -import mcpp.platform.env; +import mcpp.invocation; import mcpp.toolchain.detect; import mcpp.toolchain.probe; @@ -86,7 +86,7 @@ TEST(ToolchainDetect, IgnoresTargetRuntimeLibraryPathDuringProbe) { auto hostile = make_hostile_ld_dir(); TempDirGuard cleanup_ld{hostile}; - mcpp::platform::env::ScopedEnv ld("LD_LIBRARY_PATH", hostile.string()); + mcpp::invocation::ScopedEnv ld("LD_LIBRARY_PATH", hostile.string()); auto tc = detect(clang); ASSERT_TRUE(tc.has_value()) << tc.error().message; diff --git a/tests/unit/test_toolchain_lifecycle.cpp b/tests/unit/test_toolchain_lifecycle.cpp index 27c6b5a8..297feb57 100644 --- a/tests/unit/test_toolchain_lifecycle.cpp +++ b/tests/unit/test_toolchain_lifecycle.cpp @@ -2,7 +2,7 @@ import std; import mcpp.config; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.runtime.binding; import mcpp.toolchain.lifecycle; import mcpp.toolchain.post_install; @@ -164,7 +164,7 @@ mcpp::config::GlobalConfig unreachable_cfg() { TEST(DeclaredRuntime, ABindingThatLocatesItsPayloadIsLeftAlone) { auto cfg = unreachable_cfg(); - mcpp::platform::runtime::RuntimeBinding binding; + mcpp::runtime::RuntimeBinding binding; binding.runtimeId = "glibc@2.44.3"; binding.libraryDirs = {"/store/xim-x-glibc/2.44.3/lib64"}; const auto before = binding.libraryDirs; @@ -178,7 +178,7 @@ TEST(DeclaredRuntime, ABindingThatLocatesItsPayloadIsLeftAlone) { TEST(DeclaredRuntime, OnlyAGlibcPayloadIsInstalled) { auto cfg = unreachable_cfg(); for (auto id : {"", "ucrt@10.0.26100.0", "macos_sdk@15.0", "glibc"}) { - mcpp::platform::runtime::RuntimeBinding binding; + mcpp::runtime::RuntimeBinding binding; binding.runtimeId = id; EXPECT_EQ(ensure_declared_runtime(cfg, binding).value_or(true), false) << id; EXPECT_TRUE(binding.libraryDirs.empty()) << id; @@ -187,10 +187,10 @@ TEST(DeclaredRuntime, OnlyAGlibcPayloadIsInstalled) { // Off Linux there is no glibc payload to provide, whatever the identity says. TEST(DeclaredRuntime, NothingIsInstalledOffLinux) { - if constexpr (mcpp::platform::is_linux) + if constexpr (mcpplibs::os::is_linux) GTEST_SKIP() << "the Linux path is exercised by e2e 700"; auto cfg = unreachable_cfg(); - mcpp::platform::runtime::RuntimeBinding binding; + mcpp::runtime::RuntimeBinding binding; binding.runtimeId = "glibc@2.44.3"; EXPECT_EQ(ensure_declared_runtime(cfg, binding).value_or(true), false); } @@ -202,7 +202,7 @@ TEST(DeclaredRuntime, OnlyToolchainsOnARuntimePayloadNeedIt) { XimToolchainPackage llvm; llvm.ximName = "llvm"; XimToolchainPackage musl; musl.ximName = "musl-gcc"; XimToolchainPackage msvc; msvc.ximName = "msvc"; - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { for (auto const* p : {&gcc, &llvm, &musl, &msvc}) EXPECT_TRUE(runtime_payload_kind(*p).empty()) << p->ximName; } else { diff --git a/tests/unit/test_toolchain_msvc.cpp b/tests/unit/test_toolchain_msvc.cpp index c57026fc..0f2953d9 100644 --- a/tests/unit/test_toolchain_msvc.cpp +++ b/tests/unit/test_toolchain_msvc.cpp @@ -1,7 +1,7 @@ #include -#include // setenv / _putenv_s — not part of `import std;` import std; +import mcpp.invocation; import mcpp.toolchain.model; import mcpp.toolchain.msvc; import mcpp.toolchain.registry; @@ -256,34 +256,14 @@ struct FakeToolset { } }; -void put_env(const char* name, const std::optional& value) { -#if defined(_WIN32) - // An empty value REMOVES the variable on Windows, which is what nullopt - // has to mean here. - ::_putenv_s(name, value ? value->c_str() : ""); -#else - if (value) ::setenv(name, value->c_str(), 1); - else ::unsetenv(name); -#endif -} - // RAII for an environment variable. nullopt = unset it. // // Unsetting matters as much as setting: these tests run on a Windows CI // runner that may have vcvars exported, and an ambient WindowsSdkDir would // otherwise answer before the fixture ever got a turn — a green test proving // nothing about the code under it. -struct ScopedEnv { - const char* name; - std::optional old; - ScopedEnv(const char* n, std::optional value) : name(n) { - if (auto* v = std::getenv(name)) old = v; - put_env(name, value); - } - ~ScopedEnv() { put_env(name, old); } - ScopedEnv(const ScopedEnv&) = delete; - ScopedEnv& operator=(const ScopedEnv&) = delete; -}; +// Stated for this invocation, as mcpp states its own (mcpp.invocation). +using ScopedEnv = mcpp::invocation::ScopedEnv; // Every SDK test starts from "nothing declared", then declares what it means // to test. diff --git a/tests/unit/test_toolchain_registry.cpp b/tests/unit/test_toolchain_registry.cpp index e3bd2855..6bfcb9df 100644 --- a/tests/unit/test_toolchain_registry.cpp +++ b/tests/unit/test_toolchain_registry.cpp @@ -1,7 +1,8 @@ #include import std; -import mcpp.platform; +import mcpplibs.os.platform; +import mcpp.toolchain.platform; import mcpp.toolchain.lifecycle; import mcpp.toolchain.model; import mcpp.toolchain.registry; @@ -10,14 +11,14 @@ import mcpp.toolchain.triple; using namespace mcpp::toolchain; static std::string host_musl() { - return std::string(mcpp::platform::host_arch) + "-linux-musl"; + return std::string(mcpp::toolchain::platform::host_arch) + "-linux-musl"; } // The host-native `musl-gcc` package only exists for Linux hosts; on other // hosts the payload mapping resolves the triple-named package (a linux-musl // target from macOS/Windows is cross by definition). static std::string expected_musl_xim() { - if constexpr (mcpp::platform::is_linux) return "musl-gcc"; + if constexpr (mcpplibs::os::is_linux) return "musl-gcc"; else return host_musl() + "-gcc"; } @@ -26,7 +27,7 @@ static std::string expected_musl_xim() { // disk is `-g++.exe`. The .exe spelling comes first so it wins on a // case-insensitive filesystem where both would match. static std::string expected_musl_frontend(const std::string& triple) { - if constexpr (mcpp::platform::is_windows) return triple + "-g++.exe"; + if constexpr (mcpplibs::os::is_windows) return triple + "-g++.exe"; else return triple + "-g++"; } @@ -47,8 +48,8 @@ TEST(ToolchainRegistry, MapsGccSpecToGccPackage) { EXPECT_EQ(pkg.ximName, "mingw-gcc"); #else ASSERT_FALSE(pkg.frontendCandidates.empty()); - if constexpr (mcpp::platform::is_linux - && mcpp::platform::host_arch != std::string_view("x86_64")) { + if constexpr (mcpplibs::os::is_linux + && mcpp::toolchain::platform::host_arch != std::string_view("x86_64")) { // Non-x86 Linux native GCC payloads are musl-backed even when the host // process uses glibc. Its driver needs no glibc specs fixup. EXPECT_EQ(pkg.ximName, "musl-gcc"); @@ -88,7 +89,7 @@ TEST(ToolchainRegistry, CrossArchMuslTargetPicksTripleNamedPackage) { spec.family = Family::Gcc; spec.version = "16.1.0"; spec.target = { "aarch64", "linux", "musl" }; - if (mcpp::platform::host_arch == std::string_view("aarch64")) + if (mcpp::toolchain::platform::host_arch == std::string_view("aarch64")) spec.target.arch = "riscv64"; // stay cross on any host auto pkg = to_xim_package(spec); @@ -431,7 +432,7 @@ TEST(SdkPayloads, TheSearchedDirectoryIsAvailableForTheDiagnostic) { TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { // THIS ASSERTION USED TO BE TRUE BY ARITHMETIC ON ONE HOST. // - // It read `EXPECT_EQ(host_can_serve(*wasm), mcpp::platform::is_linux)`, + // It read `EXPECT_EQ(host_can_serve(*wasm), mcpplibs::os::is_linux)`, // which was the right claim while `xim:emsdk` and `xim:android-ndk` // declared only `xpm.linux`. Both now publish for all three hosts, and the // engine's constant was the stale half -- but the assertion kept passing @@ -451,7 +452,7 @@ TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { // paragraph above from being a tautology. macOS's SDK and MSVC are // host-only and no package substitutes for either, so a Linux host cannot // serve them -- the exclusion this function exists to make. - if constexpr (mcpp::platform::is_linux) { + if constexpr (mcpplibs::os::is_linux) { auto mac = mcpp::toolchain::triple::parse("aarch64-macos"); ASSERT_TRUE(mac.has_value()); EXPECT_FALSE(mcpp::toolchain::host_can_serve(*mac)); @@ -478,7 +479,7 @@ TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { ASSERT_TRUE(t.has_value()) << name; EXPECT_TRUE(t->is_apple()) << name; EXPECT_EQ(mcpp::toolchain::host_can_serve(*t), - bool(mcpp::platform::is_macos)) << name; + bool(mcpplibs::os::is_macos)) << name; } } diff --git a/tests/unit/test_ui_streams.cpp b/tests/unit/test_ui_streams.cpp index 80f52925..aa5f9ccd 100644 --- a/tests/unit/test_ui_streams.cpp +++ b/tests/unit/test_ui_streams.cpp @@ -2,7 +2,7 @@ import std; import mcpp.ui; -import mcpp.platform; +import mcpp.ui.terminal; // Two streams, one rule (output streams plan 2026-10-01, R3): a line that // narrates what the command is doing is written to standard error, and a line @@ -37,7 +37,7 @@ void plain_mode() { } // namespace TEST(UiStreams, NarrationIsStandardError) { - EXPECT_EQ(mcpp::ui::narration_stream(), mcpp::platform::terminal::Stream::Err); + EXPECT_EQ(mcpp::ui::narration_stream(), mcpp::ui::terminal::Stream::Err); } TEST(UiStreams, AStepIsNarration) { diff --git a/tests/unit/test_windows_command_line.cpp b/tests/unit/test_windows_command_line.cpp index 656a1d72..7a004df1 100644 --- a/tests/unit/test_windows_command_line.cpp +++ b/tests/unit/test_windows_command_line.cpp @@ -1,8 +1,7 @@ #include import std; -import mcpp.platform.process; -import mcpp.platform.shell; +import mcpp.build.command_line; // The cmd.exe quoting rules are the easiest thing in mcpp to get wrong and // the hardest to notice: on Linux and macOS the Windows branch is not even @@ -10,59 +9,13 @@ import mcpp.platform.shell; // tests drive the host-independent shapers directly, so a regression fails // on every platform instead of only on a Windows runner. -namespace proc = mcpp::platform::process; - -// #331: argv[0] used to be emitted RAW to survive cmd.exe's /c stripping. -// That made every payload under `C:\Program Files\...` — and every machine -// whose user name has a space — fail with `'C:\Program' is not recognized`. -TEST(WindowsCommandLine, ProgramPathWithSpacesIsQuoted) { - auto cmd = proc::windows_command_from_argv( - {"C:\\Program Files\\mcpp\\g++.exe", "-c", "main.cpp"}); - EXPECT_TRUE(cmd.starts_with("\"C:\\Program Files\\mcpp\\g++.exe\"")) << cmd; -} - -TEST(WindowsCommandLine, EveryArgumentIsQuoted) { - auto cmd = proc::windows_command_from_argv( - {"g++.exe", "-I", "C:\\my dir\\inc", "src\\main.cpp"}); - EXPECT_NE(cmd.find("\"C:\\my dir\\inc\""), std::string::npos) << cmd; - EXPECT_NE(cmd.find("\"src\\main.cpp\""), std::string::npos) << cmd; -} - -TEST(WindowsCommandLine, EmptyArgvIsEmpty) { - EXPECT_EQ(proc::windows_command_from_argv({}), ""); -} - -// The outer pair is what cmd.exe consumes under its "strip the first -// character and the last quote character" rule, so the inner quoting is -// what actually reaches the program. Without it, quoting argv[0] makes -// things worse rather than better. -TEST(WindowsCommandLine, WrapAddsTheOuterPairCmdConsumes) { - auto inner = proc::windows_command_from_argv( - {"C:\\Program Files\\mcpp\\g++.exe", "-c", "main.cpp"}); - auto wrapped = proc::windows_wrap_for_cmd_c(inner); - ASSERT_GE(wrapped.size(), inner.size() + 2); - EXPECT_EQ(wrapped.front(), '"'); - EXPECT_EQ(wrapped.back(), '"'); - EXPECT_EQ(wrapped.substr(1, wrapped.size() - 2), inner); - - // Simulate what cmd.exe does with `/c `: drop the first - // character and the last quote character. What remains must be exactly - // the command we meant to run. - auto stripped = wrapped.substr(1); - stripped.erase(stripped.rfind('"'), 1); - EXPECT_EQ(stripped, inner); -} - -// Redirects appended after the command must end up INSIDE the wrap, so cmd -// still parses them once it has stripped the outer pair. -TEST(WindowsCommandLine, RedirectStaysInsideTheWrap) { - auto wrapped = proc::windows_wrap_for_cmd_c( - proc::windows_command_from_argv({"prog.exe", "arg"}) + " ` in the version floor as a redirection"; @@ -251,8 +204,8 @@ TEST(WindowsCommandLine, NothingToEscapeMeansByteIdenticalToPlainQuoting) { R"({"targets":["mcpplibs:tpl-demo@1.0.0"],"yes":true})", R"({"targets":["compat:widget@1.38.1"],"yes":true})", R"(a plain path C:\Program Files\x)" }) { - EXPECT_EQ(mcpp::platform::shell::quote_windows_through_cmd(plain), - mcpp::platform::shell::quote_windows(plain)) + EXPECT_EQ(mcpp::build::command_line::quote_windows_through_cmd(plain), + mcpp::build::command_line::quote_windows(plain)) << "a payload with no metacharacter acquired an escape: " << plain; } } @@ -261,8 +214,8 @@ TEST(WindowsCommandLine, MetacharacterQuotingIsUnchangedForPlainText) { // A payload with nothing to escape must not acquire carets, so the common // case stays legible in a log. constexpr std::string_view plain = R"({"targets":["xim:shaderc@2026.3"]})"; - auto quoted = mcpp::platform::shell::quote_windows_through_cmd(plain); - auto parsed = simulate_cmd_c(proc::windows_wrap_for_cmd_c("prog.exe --args " + quoted)); + auto quoted = mcpp::build::command_line::quote_windows_through_cmd(plain); + auto parsed = simulate_cmd_c(wrapped_once("prog.exe --args " + quoted)); auto argv = msvcrt_argv(parsed.passedOn); ASSERT_EQ(argv.size(), 3u) << parsed.passedOn; EXPECT_EQ(argv[2], plain); @@ -311,12 +264,12 @@ TEST(WindowsCommandLine, EveryArgumentRoundTripsThroughTheArgvRules) { "C:\\dir with space\\file.cpp", }; std::string line; - for (auto const& a : args) line += mcpp::platform::shell::quote_windows(a) + " "; + for (auto const& a : args) line += mcpp::build::command_line::quote_windows(a) + " "; EXPECT_EQ(argv_of(line), args) << line; } TEST(WindowsCommandLine, AnArgumentEndingInABackslashDoesNotSwallowTheNext) { - const auto line = mcpp::platform::shell::quote_windows("C:\\Kits\\10\\") + " " - + mcpp::platform::shell::quote_windows("-o"); + const auto line = mcpp::build::command_line::quote_windows("C:\\Kits\\10\\") + " " + + mcpp::build::command_line::quote_windows("-o"); EXPECT_EQ(argv_of(line), (std::vector{"C:\\Kits\\10\\", "-o"})) << line; } diff --git a/tests/unit/test_windows_defaults.cpp b/tests/unit/test_windows_defaults.cpp index 00202230..aa6d1890 100644 --- a/tests/unit/test_windows_defaults.cpp +++ b/tests/unit/test_windows_defaults.cpp @@ -1,7 +1,7 @@ #include import std; -import mcpp.platform; +import mcpplibs.os.platform; import mcpp.toolchain.msvc; import mcpp.toolchain.triple; import mcpp.toolchain.registry; @@ -15,7 +15,7 @@ import mcpp.build.prepare; // Windows SDK, so a default that targets the MSVC ABI there can never build. TEST(WindowsDefaults, HasUsableMsvcIsFalseOffWindows) { - if constexpr (mcpp::platform::is_windows) { + if constexpr (mcpplibs::os::is_windows) { GTEST_SKIP() << "windows behavior is covered by e2e 182"; } else { EXPECT_FALSE(mcpp::toolchain::msvc::has_usable_msvc()); diff --git a/tests/unit/test_xlings.cpp b/tests/unit/test_xlings.cpp index 27c910b3..0101c25c 100644 --- a/tests/unit/test_xlings.cpp +++ b/tests/unit/test_xlings.cpp @@ -1,8 +1,9 @@ #include import std; +import mcpplibs.os.platform; import mcpp.provider.xlings; -import mcpp.platform.env; +import mcpp.invocation; namespace { @@ -63,7 +64,7 @@ std::filesystem::path make_fake_nasm(const std::filesystem::path& dir, // directory — only remove the entries that would shadow the fake binary // under test. std::string path_without_real_nasm() { - auto path = mcpp::platform::env::get("PATH").value_or(""); + auto path = mcpp::invocation::get("PATH").value_or(""); std::vector kept; std::size_t start = 0; while (start <= path.size()) { @@ -93,7 +94,7 @@ TEST(FindUsableNasm, FindsOnPathWhenVersionIsAdequate) { // system nasm ahead of it, but `env`/`bash` must stay resolvable for // the fake script's own shebang + the version-check subprocess. auto base = path_without_real_nasm(); - mcpp::platform::env::ScopedEnv path("PATH", pathDir.string() + ":" + base); + mcpp::invocation::ScopedEnv path("PATH", pathDir.string() + ":" + base); auto home = make_tempdir("mcpp-nasm-home"); // no sandbox nasm present mcpp::provider::xlings::Env env{.home = home}; @@ -111,7 +112,7 @@ TEST(FindUsableNasm, RejectsPathNasmBelowMinVersionAndFindsNoSandboxFallback) { make_fake_nasm(pathDir, "NASM version 2.10.00 compiled on Jan 1 2020"); auto base = path_without_real_nasm(); - mcpp::platform::env::ScopedEnv path("PATH", pathDir.string() + ":" + base); + mcpp::invocation::ScopedEnv path("PATH", pathDir.string() + ":" + base); auto home = make_tempdir("mcpp-nasm-home-empty"); // no sandbox nasm mcpp::provider::xlings::Env env{.home = home}; @@ -125,7 +126,7 @@ TEST(FindUsableNasm, RejectsPathNasmBelowMinVersionAndFindsNoSandboxFallback) { TEST(FindUsableNasm, FallsBackToSandboxWhenPathHasNoNasm) { // PATH has the usual system directories (so the sandbox script's own // shebang still resolves) but none of them contain a real `nasm`. - mcpp::platform::env::ScopedEnv path("PATH", path_without_real_nasm()); + mcpp::invocation::ScopedEnv path("PATH", path_without_real_nasm()); auto home = make_tempdir("mcpp-nasm-home-sandbox"); auto sandboxDir = home / "data" / "xpkgs" / "xim-x-nasm" / "3.02"; @@ -335,10 +336,11 @@ TEST(XlingsSiblingPackage, MetadataOnlyHuskIsNotContent) { touch(husk / ".xpkg.lua", "package = {}"); // Isolate from the host's ~/.xlings fallback. - const char* oldHome = std::getenv("HOME"); - mcpp::platform::env::set("HOME", tmp.string()); - auto found = mcpp::provider::xlings::paths::find_sibling_package(gccBin, "linux-headers"); - mcpp::platform::env::set("HOME", oldHome ? oldHome : ""); + std::optional found; + { + mcpp::invocation::ScopedEnv home("HOME", tmp.string()); + found = mcpp::provider::xlings::paths::find_sibling_package(gccBin, "linux-headers"); + } EXPECT_FALSE(found.has_value()); @@ -397,11 +399,12 @@ TEST(XlingsHomeTool, FindsPayloadUnderNonXimPrefix) { auto real = xpkgs / "scode-x-linux-headers" / "5.11.1"; touch(real / "include" / "linux" / "limits.h"); - const char* oldMcppHome = std::getenv("MCPP_HOME"); - mcpp::platform::env::set("MCPP_HOME", tmp.string()); - auto found = mcpp::provider::xlings::paths::find_home_tool( - "linux-headers", "include/linux/limits.h"); - mcpp::platform::env::set("MCPP_HOME", oldMcppHome ? oldMcppHome : ""); + std::optional found; + { + mcpp::invocation::ScopedEnv home("MCPP_HOME", tmp.string()); + found = mcpp::provider::xlings::paths::find_home_tool( + "linux-headers", "include/linux/limits.h"); + } ASSERT_TRUE(found.has_value()); // The original home is normalized; macOS spells /var's target /private/var. @@ -649,7 +652,7 @@ TEST(XlingsStderrTail, KeepsTheLastLinesUpToTheLimit) { (std::vector{"error without a newline"})); } -// ─── invocation_env / ScopedInvocationEnv (#614) ────────────────────── +// ─── invocation_env and the started xlings (#614) ───────────────────── // // One decision for the environment of an xlings invocation, rendered per // platform. Global mode is an absent XLINGS_PROJECT_DIR on both platforms, and @@ -691,63 +694,59 @@ TEST(XlingsInvocationEnv, GlobalModeIsAnAbsentProjectDirectory) { EXPECT_EQ(projectScope.value, project.projectDir.string()); } -TEST(XlingsInvocationEnv, TheProcessEnvironmentIsUnchangedAfterwards) { - namespace env = mcpp::platform::env; - env::ScopedEnv keepPath("PATH", env::get("PATH")); - env::ScopedEnv keepHome("XLINGS_HOME", env::get("XLINGS_HOME")); - env::ScopedEnv prior("XLINGS_PROJECT_DIR", std::string("prior-project")); - - { - auto global = xlings_env(""); - mcpp::provider::xlings::ScopedInvocationEnv scope(global); - (void)mcpp::provider::xlings::build_command_prefix(global); -#if defined(_WIN32) - EXPECT_FALSE(env::get("XLINGS_PROJECT_DIR").has_value()); -#endif - } - EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), std::optional("prior-project")); - - { - auto project = xlings_env("proj-dir"); - mcpp::provider::xlings::ScopedInvocationEnv scope(project); - (void)mcpp::provider::xlings::build_command_prefix(project); -#if defined(_WIN32) - EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), - std::optional(project.projectDir.string())); -#endif - } - EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), std::optional("prior-project")); -} - -// #726: the sandbox's `bin` in front of PATH and XLINGS_HOME reach the xlings -// child only. They used to stay in the process on Windows, so every action of a +// What a started xlings receives, recorded by a stub in place of the binary: +// its working directory, then its environment. +struct Seen { + std::string cwd; + std::map env; +}; +Seen started_with(const mcpp::provider::xlings::Env& e) { + std::filesystem::create_directories(e.binary.parent_path()); + const auto record = e.home / "seen"; + std::filesystem::remove(record); + { std::ofstream(e.binary) << "#!/bin/sh\n{ pwd; env; } > \"" << record.string() << "\"\n"; } + std::filesystem::permissions(e.binary, std::filesystem::perms::owner_all); + (void)mcpp::provider::xlings::install_direct(e, "nothing", /*quiet=*/true); + Seen seen; + std::ifstream in(record); + std::getline(in, seen.cwd); + for (std::string line; std::getline(in, line);) + if (auto eq = line.find('='); eq != std::string::npos) + seen.env[line.substr(0, eq)] = line.substr(eq + 1); + return seen; +} + +// #614, #726: the decision reaches the started xlings, in its home, and this +// invocation's environment is the same afterwards. The sandbox's `bin` in +// front of PATH used to stay in the process on Windows, so every action of a // build that had installed a payload found `xim:llvm`'s `cl`, `link` and `lib` // shims in front of MSVC's tools, while a build that installed nothing did not. -TEST(XlingsInvocationEnv, NeitherPathNorTheHomeOutlivesTheInvocation) { - namespace env = mcpp::platform::env; - env::ScopedEnv keepPath("PATH", env::get("PATH")); - env::ScopedEnv keepHome("XLINGS_HOME", std::string("shell-home")); +TEST(XlingsInvocationEnv, TheDecisionReachesTheStartedXlingsOnly) { + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "a POSIX stub records what xlings received"; + namespace env = mcpp::invocation; + env::ScopedEnv home("XLINGS_HOME", std::string("shell-home")); + env::ScopedEnv subos("XLINGS_ACTIVE_SUBOS", std::string("shell-subos")); + env::ScopedEnv project("XLINGS_PROJECT_DIR", std::string("prior-project")); const auto pathBefore = env::get("PATH"); - auto e = xlings_env(""); - for (int i = 0; i < 3; ++i) { - mcpp::provider::xlings::ScopedInvocationEnv scope(e); - (void)mcpp::provider::xlings::build_command_prefix(e); -#if defined(_WIN32) - EXPECT_EQ(env::get("XLINGS_HOME"), std::optional(e.home.string())); - const auto path = env::get("PATH"); - ASSERT_TRUE(path.has_value()); - const auto bin = (e.home / "subos" / "default" / "bin").string(); - EXPECT_EQ(path->substr(0, bin.size()), bin) << *path; -#endif - } - EXPECT_EQ(env::get("PATH"), pathBefore); - EXPECT_EQ(env::get("XLINGS_HOME"), std::optional("shell-home")); + auto global = xlings_env(""); + auto g = started_with(global); + EXPECT_EQ(std::filesystem::path(g.cwd), std::filesystem::weakly_canonical(global.home)); + EXPECT_EQ(g.env["XLINGS_HOME"], global.home.string()); + EXPECT_FALSE(g.env.contains("XLINGS_PROJECT_DIR")); + EXPECT_FALSE(g.env.contains("XLINGS_ACTIVE_SUBOS")); + const auto bin = (global.home / "subos" / "default" / "bin").string(); + EXPECT_EQ(g.env["PATH"].substr(0, bin.size()), bin) << g.env["PATH"]; + + auto inProject = xlings_env("proj-dir"); + auto p = started_with(inProject); + EXPECT_EQ(p.env["XLINGS_PROJECT_DIR"], inProject.projectDir.string()); + EXPECT_FALSE(p.env.contains("XLINGS_ACTIVE_SUBOS")); - // Building a command alone changes nothing on any platform. - (void)mcpp::provider::xlings::build_command_prefix(e); EXPECT_EQ(env::get("PATH"), pathBefore); EXPECT_EQ(env::get("XLINGS_HOME"), std::optional("shell-home")); + EXPECT_EQ(env::get("XLINGS_ACTIVE_SUBOS"), std::optional("shell-subos")); + EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), std::optional("prior-project")); } // #726: on Windows too the registry's xlings starts in its home, never in the @@ -762,8 +761,8 @@ TEST(XlingsInvocationEnv, TheWindowsPrefixStartsInTheHome) { "\"C:\\Users\\a b\\.mcpp\\registry\\bin\\xlings.exe\""); } -#if !defined(_WIN32) TEST(XlingsInvocationEnv, ThePosixPrefixRendersTheDecision) { + if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "the POSIX spelling"; auto global = mcpp::provider::xlings::build_command_prefix(xlings_env("")); EXPECT_NE(global.find("env -u XLINGS_PROJECT_DIR -u XLINGS_ACTIVE_SUBOS PATH="), std::string::npos) << global; @@ -775,7 +774,6 @@ TEST(XlingsInvocationEnv, ThePosixPrefixRendersTheDecision) { EXPECT_NE(project.find("XLINGS_PROJECT_DIR="), std::string::npos) << project; EXPECT_NE(project.find("/work/proj"), std::string::npos) << project; } -#endif // A shell that ran `xlings subos use ` exports XLINGS_ACTIVE_SUBOS, which // names a SubOS of the shell's xlings home and not of mcpp's registry; no @@ -788,17 +786,4 @@ TEST(XlingsInvocationEnv, TheShellsActiveSubosIsNeverInherited) { ASSERT_EQ(project.name, "XLINGS_ACTIVE_SUBOS"); EXPECT_FALSE(project.present); - namespace env = mcpp::platform::env; - env::ScopedEnv keepPath("PATH", env::get("PATH")); - env::ScopedEnv keepHome("XLINGS_HOME", env::get("XLINGS_HOME")); - env::ScopedEnv prior("XLINGS_ACTIVE_SUBOS", std::string("shell-subos")); - { - auto e = xlings_env(""); - mcpp::provider::xlings::ScopedInvocationEnv scope(e); - (void)mcpp::provider::xlings::build_command_prefix(e); -#if defined(_WIN32) - EXPECT_FALSE(env::get("XLINGS_ACTIVE_SUBOS").has_value()); -#endif - } - EXPECT_EQ(env::get("XLINGS_ACTIVE_SUBOS"), std::optional("shell-subos")); } diff --git a/tests/unit/test_xlings_version_pin.cpp b/tests/unit/test_xlings_version_pin.cpp index 3df8c727..6a39f0b4 100644 --- a/tests/unit/test_xlings_version_pin.cpp +++ b/tests/unit/test_xlings_version_pin.cpp @@ -17,6 +17,7 @@ #include import std; +import mcpp.invocation; import mcpp.fallback.xlings_binary; namespace fb = mcpp::fallback; @@ -246,25 +247,10 @@ TEST(XlingsVersionMemo, ANewFileIsAskedAgain) { namespace { class ScopedEnv { public: - ScopedEnv(std::string name, const char* value) : name_(std::move(name)) { - if (const char* old = std::getenv(name_.c_str()); old) { had_ = true; old_ = old; } - apply(value); - } - ~ScopedEnv() { apply(had_ ? old_.c_str() : nullptr); } - ScopedEnv(const ScopedEnv&) = delete; - ScopedEnv& operator=(const ScopedEnv&) = delete; + ScopedEnv(std::string name, const char* value) + : inner_(std::move(name), value ? std::optional(value) : std::nullopt) {} private: - void apply(const char* value) { -#if defined(_WIN32) - ::_putenv_s(name_.c_str(), value ? value : ""); -#else - if (value) ::setenv(name_.c_str(), value, 1); - else ::unsetenv(name_.c_str()); -#endif - } - std::string name_; - bool had_ = false; - std::string old_; + mcpp::invocation::ScopedEnv inner_; // stated for this invocation, as mcpp states its own }; } // namespace diff --git a/tests/unit/test_xpkg_emit.cpp b/tests/unit/test_xpkg_emit.cpp index 74eb1d5c..d335724c 100644 --- a/tests/unit/test_xpkg_emit.cpp +++ b/tests/unit/test_xpkg_emit.cpp @@ -3,7 +3,7 @@ import std; import mcpp.manifest; import mcpp.modgraph.graph; -import mcpp.platform.env; +import mcpp.invocation; import mcpp.publish.xpkg_emit; import mcpp.diag; @@ -136,7 +136,7 @@ TEST(XpkgEmit, Sha256OfFileIgnoresTargetRuntimeLibraryPath) { { std::ofstream(p).close(); } { - mcpp::platform::env::ScopedEnv ld("LD_LIBRARY_PATH", hostile.string()); + mcpp::invocation::ScopedEnv ld("LD_LIBRARY_PATH", hostile.string()); EXPECT_EQ(sha256_of_file(p), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); } diff --git a/tests/unit/test_xpkg_host_tools.cpp b/tests/unit/test_xpkg_host_tools.cpp index 50b695e6..8524b69a 100644 --- a/tests/unit/test_xpkg_host_tools.cpp +++ b/tests/unit/test_xpkg_host_tools.cpp @@ -3,8 +3,7 @@ import std; import mcpp.manifest; import mcpp.manifest.xpkg; -import mcpp.platform; -import mcpp.platform.axis; +import mcpp.toolchain.platform; // #355 needed two things from the Form B (xpkg .lua) descriptor parser that it // simply did not read, and neither failed loudly: @@ -24,7 +23,7 @@ namespace { mcpp::manifest::Manifest parse_or_fail(std::string_view lua) { auto m = mcpp::manifest::synthesize_from_xpkg_lua( - lua, "compat.demo", "1.0.0", mcpp::platform::HostPlatform::current()); + lua, "compat.demo", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); EXPECT_TRUE(m.has_value()) << (m ? "" : m.error().message); return m.value_or(mcpp::manifest::Manifest{}); } @@ -154,7 +153,7 @@ package = { }, }, } -)LUA", "compat.demo", "1.0.0", mcpp::platform::HostPlatform::current()); +)LUA", "compat.demo", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); ASSERT_FALSE(refused.has_value()); EXPECT_NE(refused.error().message.find("states both `kind = \"shared\"`"), std::string::npos) << refused.error().message; From 9381442cb0080549bc88648aa703329c49f86694 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 11:44:13 +0900 Subject: [PATCH 34/43] launch_env: the private glibc is what the provider's layout says a path is, not a spelling of its store Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/runtime/launch_env.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/runtime/launch_env.cpp b/src/runtime/launch_env.cpp index 6a7225e1..b556f561 100644 --- a/src/runtime/launch_env.cpp +++ b/src/runtime/launch_env.cpp @@ -7,6 +7,8 @@ import std; import mcpplibs.os.platform; import mcpplibs.os.env; import mcpp.invocation; +import mcpp.provider; +import mcpp.provider.select; namespace mcpp::runtime::launch_env { @@ -34,8 +36,9 @@ std::string host_tool_runtime_library_path_key() { std::string strip_private_glibc(std::string_view paths) { std::vector kept; for (auto& item : os::env::split_list(paths)) { - const auto text = item.generic_string(); - if (text.find("/xim-x-glibc/") == std::string::npos) kept.push_back(std::move(item)); + // A directory of the store's glibc payload, whatever the provider + // spells the store: what the provider's layout says the path is. + if (mcpp::provider::layout().entry_of(item).name != "glibc") kept.push_back(std::move(item)); } return os::env::join_list(kept); } From 5de84791b3b8945b3391987d9f74c3d073ac6cd3 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 12:45:57 +0900 Subject: [PATCH 35/43] The lock's per-system units in src/native, each system's row leaving out the others'; one evaluation of a sources list Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...ntract-runtime-and-ide-ecosystem-design.md | 6 +- CHANGELOG.md | 2 +- docs/90-build-from-source.md | 5 +- docs/zh/90-build-from-source.md | 5 +- mcpp.toml | 10 +-- src/build/prepare/features.cpp | 13 +--- src/build/prepare/graph.cpp | 15 +--- src/home/files.cpp | 2 +- src/home/files.cppm | 4 +- src/modgraph/scanner.cppm | 72 ++++++++++--------- {native => src/native}/posix/lock.cpp | 0 {native => src/native}/windows/lock.cpp | 0 tests/unit/test_select_sources.cpp | 54 ++++++++++++++ 13 files changed, 115 insertions(+), 73 deletions(-) rename {native => src/native}/posix/lock.cpp (100%) rename {native => src/native}/windows/lock.cpp (100%) create mode 100644 tests/unit/test_select_sources.cpp diff --git a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md index d783f046..81e0c38f 100644 --- a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md +++ b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md @@ -78,7 +78,7 @@ mcpp-index(mcpplibs)本机快照。 在本批 mcpp PR(#842)中实现。原议的 `entry = "<依赖>"` 加 feature 声明的写法不采纳(§24.6)。 - rev 6(2026-10-27):增加 §25"拆除 `modules/platform`"与 D25,在本批 mcpp PR(#842)中实现,mcpplibs.os 发布 0.2.0。 - rev 7(2026-10-27,§25 实施收口):§25.3 的去向按实现修订(`mcpp.invocation` 为独立包 `modules/invocation`,锁的 - 实现单元在 `native/`);§25.5 第 2 条的命令解释器范围按实现写明(mcpp 以文本组成并记录的命令行同 ninja 规则一样 + 实现单元在 `src/native/`);§25.5 第 2 条的命令解释器范围按实现写明(mcpp 以文本组成并记录的命令行同 ninja 规则一样 由解释器执行);增加 §25.7(GCC 16 的模块接口约束,实测)与 §25.8(openkal 补丁发布中实测发现的两处行为)。 关联:**mcpp#838**(sycl lane 下 `std::exception` 匹配不到)、**mcpp#839**(同配置并发构建在同一构建目录竞争、双方失败)、 @@ -1029,7 +1029,7 @@ mcpplibs.os。结果是两个操作系统层并存:`modules/platform` 约 6200 | shell 引号(`quote*`、`paste_arg`、`silent_redirect`、`null_redirect`) | `mcpp.build.command_line`:ninja 规则、钩子命令与供人复制的命令的拼写(`display(argv)`),以及 Windows 命令解释器的参数(`windows_shell_arguments`)。以 shell 字符串启动程序所需的 `windows_command_from_argv`、`windows_wrap_for_cmd_c` 删除 | | 终端检测(`is_terminal`、尺寸、颜色) | `mcpplibs::os::terminal`;画面输出、按键解码、`StdoutToStderr`、协议输出、Unicode 判断属于界面,归入 `src/ui`(`mcpp.ui.terminal`) | | 文件系统(`self_exe_path`、`which`、`replace_file`、`append_atomically`、`extended_length`、`fs_caps`、无替换改名、交换、目录链接、`sync_*`) | `mcpplibs::os::fs` 与 `os::env`(`image`、`find_executable`) | -| `FileLock` | `mcpp.home.files`:与早先发布的 mcpp 共用同一把锁(POSIX 上为 `flock`,与 openkal 的记录锁互不排斥),因此是 mcpp 对自身旧版本的兼容契约。按系统选择的实现单元位于 `native/posix`、`native/windows`(`src/` 之外,由 `[target..build] sources` 选入);Windows 上与 `os::fs::lock` 同为 `LockFileEx` | +| `FileLock` | `mcpp.home.files`:与早先发布的 mcpp 共用同一把锁(POSIX 上为 `flock`,与 openkal 的记录锁互不排斥),因此是 mcpp 对自身旧版本的兼容契约。按系统选择的实现单元位于 `src/native/posix`、`src/native/windows`(基础 `sources` 排除,由 `[target..build] sources` 按系统选入);Windows 上与 `os::fs::lock` 同为 `LockFileEx` | | 信任检查(`untrusted_because`,目录属主) | `mcpp.home`,基于 `os::fs` 的属主查询 | | `capacity` | `mcpplibs::os::machine`(处理器、内存);并行度策略 `recommended_jobs` 归入 `mcpp.build` | | Windows 事实(`active_code_page`、`long_paths_enabled`、注册表路径、`env_directory`、`writable_fixed_drive`) | `mcpplibs::os::windows`(代码页、长路径);home 的默认位置选择归入 `mcpp.home` | @@ -1082,7 +1082,7 @@ mcpplibs.os 与 openkal 没有异步信号:结束请求(SIGINT、SIGTERM、S ### 25.6 验收 `modules/platform` 与 `mcpp.platform*` 模块不存在;`mcpp::platform` 命名空间不存在;`src/` 与 `modules/` 中不出现操作系统原生头文件 -(只在 `native/` 的锁实现单元中),平台差异不以预处理条件表达(`if constexpr` 与按系统选入的实现单元)。全部单元测试与 e2e 通过;取消、退出码、SIGPIPE(941)、并发构建、 +(只在 `src/native/` 的锁实现单元中),平台差异不以预处理条件表达(`if constexpr` 与按系统选入的实现单元)。全部单元测试与 e2e 通过;取消、退出码、SIGPIPE(941)、并发构建、 `mcpp run` 退出码、Windows 长路径、宿主工具加载器路径的 e2e 不变地通过。 ### 25.7 GCC 16 的模块接口约束(实测) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3484838c..46b452f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ platform keys and the Apple SDK in `toolchain-model`, the terminal and key input in `src/ui`, the home's lock and trust check in `mcpp.home.files`. The one primitive kept per system is the lock every release of mcpp takes - (`native/posix`, `native/windows`). + (`src/native/posix`, `src/native/windows`). - **A program mcpp starts is named by its argument vector.** Compiler probes, git, xlings, tar, strip, patchelf, readelf and vswhere are started without a command interpreter, with their additions to the environment given to them diff --git a/docs/90-build-from-source.md b/docs/90-build-from-source.md index 275623d5..4b829596 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -46,7 +46,7 @@ mcpp build --target x86_64-linux-musl mcpp is a **workspace**. `modules/` holds ten packages that the engine imports; `src/` is the engine itself. The operating system is reached through [mcpplibs.os](https://github.com/mcpplibs/os); the one primitive mcpp keeps -per system, the lock every release of mcpp takes, is in `native/`. +per system, the lock every release of mcpp takes, is in `src/native/`. ``` modules/ workspace members, imported by src/ @@ -61,10 +61,9 @@ modules/ workspace members, imported by src/ ├── libs/ vendored text-format parsers └── log/ leveled logging -native/ the lock, per system (flock, LockFileEx) - src/ ├── main.cpp entry point +├── native/ the lock, per system (flock, LockFileEx) ├── run.cppm run/ every program mcpp starts, and how mcpp ends at a request ├── effects.cppm the constraints of a run (offline, denied effects) ├── home.cppm home/ the home, its lock and its trust check diff --git a/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index b2d4b1f5..b0bbef54 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -48,7 +48,7 @@ mcpp build --target x86_64-linux-musl mcpp 是一个**工作空间**。`modules/` 下是引擎 import 的十个包;`src/` 是 引擎本身。操作系统经由 [mcpplibs.os](https://github.com/mcpplibs/os) 访问; -mcpp 按系统保留的唯一原语(各版本 mcpp 共用的锁)位于 `native/`。 +mcpp 按系统保留的唯一原语(各版本 mcpp 共用的锁)位于 `src/native/`。 ``` modules/ workspace members, imported by src/ @@ -63,10 +63,9 @@ modules/ workspace members, imported by src/ ├── libs/ vendored text-format parsers └── log/ leveled logging -native/ the lock, per system (flock, LockFileEx) - src/ ├── main.cpp entry point +├── native/ the lock, per system (flock, LockFileEx) ├── run.cppm run/ every program mcpp starts, and how mcpp ends at a request ├── effects.cppm the constraints of a run (offline, denied effects) ├── home.cppm home/ the home, its lock and its trust check diff --git a/mcpp.toml b/mcpp.toml index 85ce3b42..43883c36 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -35,8 +35,8 @@ default-profile = "release" # Revert is this one line if anything here is ever unexplained. bmi_schedule = "on" # mcpp's sources. The one primitive each system has of its own -- the lock -# every release of mcpp takes (native/, design 2026-10-26 §25.3) -- -# lives outside src/ and is selected per system below. +# every release of mcpp takes (src/native/, design 2026-10-26 §25.3) -- +# is selected per system below, each system's row excluding the others'. sources = ["src/**/*.cppm", "src/**/*.cpp"] # mcpp.exe declares the UTF-8 code page (mcpp#693); see docs/res/mcpp.rc. Only a PE @@ -94,13 +94,13 @@ mcpplibs.cmdline = "0.0.1" os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } [target.linux.build] -sources = ["native/posix/*.cpp"] +sources = ["!src/native/windows/**"] [target.macos.build] -sources = ["native/posix/*.cpp"] +sources = ["!src/native/windows/**"] [target.windows.build] -sources = ["native/windows/*.cpp"] +sources = ["!src/native/posix/**"] # `modules/` holds what gets linked into this binary; `src/` is what has not # been separated yet, and shrinking it is the direction. Each entry is an diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 92761ce1..8bd7e9c6 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -975,20 +975,13 @@ static std::expected step6_device_extensions_and_rules(Prepar pkg.manifest.modules.sources.push_back("!" + g); } // The device-kind files the EFFECTIVE set matches, for the - // build program. Exclusions are honoured the way the scanner - // honours them: positives first, then `!` entries removed. + // build program, selected as the scanner selects them. const auto extTable = mcpp::extension_table_for(bc.moduleExtensions, bc.deviceExtensions); - std::set matched, dropped; - for (auto const& g : pkg.manifest.modules.sources) { - if (g.empty()) continue; - if (g[0] == '!') { for (auto& f : mcpp::modgraph::expand_glob(pkg.root, g.substr(1))) dropped.insert(f); } - else if (!std::filesystem::path(g).is_absolute()) - for (auto& f : mcpp::modgraph::expand_glob(pkg.root, g)) matched.insert(f); - } + const auto matched = mcpp::modgraph::select_sources(pkg.root, pkg.manifest.modules.sources, + /*literals=*/false); std::vector device; for (auto const& f : matched) { - if (dropped.contains(f)) continue; if (mcpp::classify(f, extTable) != mcpp::SourceKind::Device) continue; device.push_back(f.lexically_relative(pkg.root).generic_string()); } diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index cd6410f0..771d6774 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -380,19 +380,8 @@ static std::expected, std::string> package_sourc mcpp::extension_table_for(depManifest.buildConfig.moduleExtensions, depManifest.buildConfig.deviceExtensions)); } - // Glob exclusion (same as scan_one_into): `!` prefix removes. - std::set sourceFiles; - std::set excluded; - for (auto const& g : globs) { - if (!g.empty() && g[0] == '!') { - for (auto& p : mcpp::modgraph::expand_glob(srcRoot, g.substr(1))) - excluded.insert(p); - } else { - for (auto& p : mcpp::modgraph::expand_glob(srcRoot, g)) - sourceFiles.insert(p); - } - } - for (auto& p : excluded) sourceFiles.erase(p); + // As the scanner selects (`select_sources`). + auto sourceFiles = mcpp::modgraph::select_sources(srcRoot, globs); if (sourceFiles.empty()) { return std::unexpected(std::format( "stage: no source files found under '{}' (globs={})", diff --git a/src/home/files.cpp b/src/home/files.cpp index 28e5d76f..fea364db 100644 --- a/src/home/files.cpp +++ b/src/home/files.cpp @@ -1,5 +1,5 @@ // mcpp.home.files: the waiting, the owner record and the trust rule over -// mcpplibs.os; the lock itself is each system's (native/). +// mcpplibs.os; the lock itself is each system's (src/native/). module mcpp.home.files; import std; diff --git a/src/home/files.cppm b/src/home/files.cppm index a0c95813..c074f81f 100644 --- a/src/home/files.cppm +++ b/src/home/files.cppm @@ -18,7 +18,7 @@ export namespace mcpp::home { // release of mcpp holds these files with flock -- two releases at work in one // MCPP_HOME would each hold the lock at once. The lock is therefore mcpp's // contract with its own earlier releases, and its one primitive per system is -// here (native//lock.cpp) and nowhere else in mcpp. +// here (src/native//lock.cpp) and nowhere else in mcpp. class FileLock { public: // Try to acquire an exclusive lock on /.lock (or /). @@ -76,7 +76,7 @@ std::string untrusted_because(const std::filesystem::path& dir); } // namespace mcpp::home -// The one primitive per system (native//lock.cpp). Not exported. +// The one primitive per system (src/native//lock.cpp). Not exported. namespace mcpp::home::native { // An exclusive lock on the file, taken without waiting: the handle, or -1. std::intptr_t lock_file(const std::filesystem::path& file); diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 0e8a7048..711b6914 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -35,6 +35,16 @@ struct ScanError { std::vector expand_glob(const std::filesystem::path& root, std::string_view glob); +// The files a `sources` list selects: every pattern's files, less the files +// any `!` pattern matches, whichever pattern selected them (the rows of +// `[target..build] sources` are appended to the list). An absolute entry +// is a literal file (a build program's generated source, outside the package) +// when `literals`, and is skipped otherwise. The one evaluation the scanner, +// the staging of a dependency and the device sources of a build program share. +std::set select_sources(const std::filesystem::path& root, + const std::vector& globs, + bool literals = true); + // M6.x: same as expand_glob but matches DIRECTORIES (used for include_dirs // like "*/include"). Always returns absolute paths under `root`. std::vector expand_dir_glob(const std::filesystem::path& root, @@ -1103,42 +1113,40 @@ local_include_dirs_after_for(const std::filesystem::path& root, } // namespace -// The files a package's `sources` select, every one of which becomes a unit. -// -// Glob exclusion: patterns starting with `!` remove files from the include set -// (like .gitignore). -// sources = ["src/**/*.cpp", "!src/**/*_test.cpp"] -// All positive patterns are expanded first, then all `!`-prefixed patterns are -// expanded and the resulting paths are removed. +// The files a package's `sources` select (`select_sources`), every one of +// which becomes a unit. +std::set select_sources(const std::filesystem::path& root, + const std::vector& globs, + bool literals) { + std::set selected, excluded; + for (auto const& g : globs) { + if (g.empty()) continue; + if (g[0] == '!') { + for (auto& p : expand_glob(root, std::string_view(g).substr(1))) excluded.insert(p); + continue; + } + // Literal absolute entry — e.g. a dependency build.mcpp's OUT_DIR + // generated source, which lives OUTSIDE the (possibly read-only) + // package root. No glob expansion; taken as-is when it exists. + // Native spelling: a raw `C:/abs/x.cppm` would stay mixed on MSVC + // (see native_path_from_generic) and leak into the CDB. + auto gp = native_path_from_generic(g); + if (gp.is_absolute()) { + std::error_code aec; + if (literals && std::filesystem::is_regular_file(gp, aec)) selected.insert(gp); + continue; + } + for (auto& p : expand_glob(root, g)) selected.insert(p); + } + for (auto& p : excluded) selected.erase(p); + return selected; +} + std::set package_source_files(const std::filesystem::path& root, const mcpp::manifest::Manifest& manifest) { - std::set all_files; - std::set excluded; - for (auto const& g : manifest.modules.sources) { - if (!g.empty() && g[0] == '!') { - for (auto& p : expand_glob(root, g.substr(1))) { - excluded.insert(p); - } - } else { - // Literal absolute entry — e.g. a dependency build.mcpp's OUT_DIR - // generated source, which lives OUTSIDE the (possibly read-only) - // package root. No glob expansion; taken as-is when it exists. - // Native spelling: a raw `C:/abs/x.cppm` would stay mixed on MSVC - // (see native_path_from_generic) and leak into the CDB. - auto gp = native_path_from_generic(g); - if (gp.is_absolute()) { - std::error_code aec; - if (std::filesystem::is_regular_file(gp, aec)) all_files.insert(gp); - continue; - } - for (auto& p : expand_glob(root, g)) { - all_files.insert(p); - } - } - } - for (auto& p : excluded) all_files.erase(p); + auto all_files = select_sources(root, manifest.modules.sources); // A unit an action generates is a source whether or not the action has // run yet (D6): it is scanned from its declaration, not read. for (auto const& [path, _] : manifest.modules.declaredUnits) diff --git a/native/posix/lock.cpp b/src/native/posix/lock.cpp similarity index 100% rename from native/posix/lock.cpp rename to src/native/posix/lock.cpp diff --git a/native/windows/lock.cpp b/src/native/windows/lock.cpp similarity index 100% rename from native/windows/lock.cpp rename to src/native/windows/lock.cpp diff --git a/tests/unit/test_select_sources.cpp b/tests/unit/test_select_sources.cpp new file mode 100644 index 00000000..b39a90c1 --- /dev/null +++ b/tests/unit/test_select_sources.cpp @@ -0,0 +1,54 @@ +#include + +import std; +import mcpp.modgraph.scanner; + +// A `sources` list selects every pattern's files less the files any `!` +// pattern matches, whichever pattern selected them. The rows of +// `[target..build] sources` are appended to the list, so a system's row +// leaves out the other systems' directories (mcpp's own `src/native`). + +namespace fs = std::filesystem; + +namespace { +struct Tree { + fs::path root; + Tree() { + root = fs::temp_directory_path() / std::format("mcpp-select-{:08x}", std::random_device{}()); + for (auto rel : { "src/a.cpp", "src/b_test.cpp", "src/native/posix/lock.cpp", + "src/native/windows/lock.cpp" }) { + fs::create_directories((root / rel).parent_path()); + std::ofstream(root / rel) << "\n"; + } + } + ~Tree() { std::error_code ec; fs::remove_all(root, ec); } + std::vector names(const std::set& files) const { + std::vector out; + for (auto const& f : files) out.push_back(f.lexically_relative(root).generic_string()); + return out; + } +}; +} // namespace + +TEST(SelectSources, AnExclusionRemovesWhatThePatternsSelected) { + Tree t; + auto got = t.names(mcpp::modgraph::select_sources(t.root, { "src/**/*.cpp", "!src/**/*_test.cpp", + "!src/native/**" })); + EXPECT_EQ(got, (std::vector{ "src/a.cpp" })); +} + +TEST(SelectSources, ASystemsRowLeavesOutTheOtherSystemsDirectory) { + Tree t; + // The unconditional list, then the linux row. + auto got = t.names(mcpp::modgraph::select_sources(t.root, { "src/**/*.cpp", "!src/native/windows/**" })); + EXPECT_EQ(got, (std::vector{ "src/a.cpp", "src/b_test.cpp", "src/native/posix/lock.cpp" })); +} + +TEST(SelectSources, AnExclusionRemovesWhicheverPatternSelected) { + Tree t; + // An exclusion written before the pattern that selects the file still + // removes it, as published manifests rely on. + auto got = t.names(mcpp::modgraph::select_sources(t.root, { "!src/native/**", "src/**/*.cpp", + "src/native/posix/*.cpp" })); + EXPECT_EQ(got, (std::vector{ "src/a.cpp", "src/b_test.cpp" })); +} From ef8db4a194c5fcfa1282854db19715dcbf6a46bb Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 12:46:14 +0900 Subject: [PATCH 36/43] =?UTF-8?q?design=20=C2=A725.3:=20how=20the=20lock's?= =?UTF-8?q?=20units=20are=20selected=20per=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...6-10-26-control-contract-runtime-and-ide-ecosystem-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md index 81e0c38f..b2c334f5 100644 --- a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md +++ b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md @@ -1029,7 +1029,7 @@ mcpplibs.os。结果是两个操作系统层并存:`modules/platform` 约 6200 | shell 引号(`quote*`、`paste_arg`、`silent_redirect`、`null_redirect`) | `mcpp.build.command_line`:ninja 规则、钩子命令与供人复制的命令的拼写(`display(argv)`),以及 Windows 命令解释器的参数(`windows_shell_arguments`)。以 shell 字符串启动程序所需的 `windows_command_from_argv`、`windows_wrap_for_cmd_c` 删除 | | 终端检测(`is_terminal`、尺寸、颜色) | `mcpplibs::os::terminal`;画面输出、按键解码、`StdoutToStderr`、协议输出、Unicode 判断属于界面,归入 `src/ui`(`mcpp.ui.terminal`) | | 文件系统(`self_exe_path`、`which`、`replace_file`、`append_atomically`、`extended_length`、`fs_caps`、无替换改名、交换、目录链接、`sync_*`) | `mcpplibs::os::fs` 与 `os::env`(`image`、`find_executable`) | -| `FileLock` | `mcpp.home.files`:与早先发布的 mcpp 共用同一把锁(POSIX 上为 `flock`,与 openkal 的记录锁互不排斥),因此是 mcpp 对自身旧版本的兼容契约。按系统选择的实现单元位于 `src/native/posix`、`src/native/windows`(基础 `sources` 排除,由 `[target..build] sources` 按系统选入);Windows 上与 `os::fs::lock` 同为 `LockFileEx` | +| `FileLock` | `mcpp.home.files`:与早先发布的 mcpp 共用同一把锁(POSIX 上为 `flock`,与 openkal 的记录锁互不排斥),因此是 mcpp 对自身旧版本的兼容契约。按系统选择的实现单元位于 `src/native/posix`、`src/native/windows`(`src/**` 选入,每个系统的 `[target..build] sources` 排除其他系统的目录);Windows 上与 `os::fs::lock` 同为 `LockFileEx` | | 信任检查(`untrusted_because`,目录属主) | `mcpp.home`,基于 `os::fs` 的属主查询 | | `capacity` | `mcpplibs::os::machine`(处理器、内存);并行度策略 `recommended_jobs` 归入 `mcpp.build` | | Windows 事实(`active_code_page`、`long_paths_enabled`、注册表路径、`env_directory`、`writable_fixed_drive`) | `mcpplibs::os::windows`(代码页、长路径);home 的默认位置选择归入 `mcpp.home` | From a6d2f55323a42f3fe80b4d93fae489cdfb21ade7 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 13:44:19 +0900 Subject: [PATCH 37/43] Ending at a request: the interrupted record is written first and no later record replaces it; network checks run in mcpp's directory Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/result.cppm | 2 ++ src/provider/xlings/xlings.cpp | 4 ++++ src/run.cppm | 5 +++++ src/run/run.cpp | 13 +++++++++---- tests/unit/test_run_concurrent_children.cpp | 4 ++-- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/build/result.cppm b/src/build/result.cppm index 3f9af60a..bb8f5f83 100644 --- a/src/build/result.cppm +++ b/src/build/result.cppm @@ -262,6 +262,8 @@ std::string render_last(const Record& r) { } // namespace void write(const Record& r) { + // mcpp is ending at a request: the record it leaves is already written. + if (mcpp::run::ending()) return; write_atomically(r.outputDir / "result.json", render(r)); const auto last = r.projectRoot / "target" / ".last-result.json"; if (!r.projectRoot.empty()) write_atomically(last, render_last(r)); diff --git a/src/provider/xlings/xlings.cpp b/src/provider/xlings/xlings.cpp index b536ff4e..9d7f3d4c 100644 --- a/src/provider/xlings/xlings.cpp +++ b/src/provider/xlings/xlings.cpp @@ -1395,6 +1395,10 @@ mcpp::provider::NetworkReport network_check(const Env& env, std::chrono::millise if (remaining <= milliseconds::zero()) return std::nullopt; auto inv = invocation(env, std::move(args)); inv.argv.front() = mcpp::home::spelling(env.binary); + // In mcpp's working directory, not the home: started in the home, + // xlings reads the home's `.xlings.json` as a project's and leaves + // the configured official index out of the endpoints it names. + inv.options.cwd.clear(); inv.options.total = remaining; inv.options.report_spawn_failure = false; const auto r = mcpp::run::execute(inv.argv, inv.options); diff --git a/src/run.cppm b/src/run.cppm index 47d1804d..ecc08dd0 100644 --- a/src/run.cppm +++ b/src/run.cppm @@ -43,6 +43,11 @@ void install(); // beside it and a rename, after the programs mcpp started are ended. Setting // replaces the previous set. void set_interrupt_files(const std::vector>& files); + +// True once mcpp has begun to end at a request or a closed output. A record +// written from then on would replace the one the request leaves, so a writer +// of the files above writes nothing while it is true. +bool ending(); void clear_interrupt_files(); // A cleanup the watcher runs before mcpp ends at a request (a terminal's mode diff --git a/src/run/run.cpp b/src/run/run.cpp index ec8ebdd9..350be835 100644 --- a/src/run/run.cpp +++ b/src/run/run.cpp @@ -57,15 +57,18 @@ struct Registered { std::atomic g_finishing{false}; void finish() { if (g_finishing.exchange(true)) return; - { - std::lock_guard g(g_live); - for (auto* p : live()) { p->end_unit(); p->kill(); } - } + // The files first: what the request leaves is the state the build was in + // when it arrived. A program ended below wakes the thread that waited on + // it, and what that thread then records is not written (`ending`). { std::lock_guard g(g_files); for (auto const& [path, content] : interrupt_files()) (void)os::fs::write_atomic(path, content); } + { + std::lock_guard g(g_live); + for (auto* p : live()) { p->end_unit(); p->kill(); } + } std::vector> todo; { std::lock_guard g(g_cleanups); @@ -336,6 +339,8 @@ void install() { }).detach(); } +bool ending() { return g_finishing.load(); } + void set_interrupt_files(const std::vector>& files) { std::lock_guard g(g_files); interrupt_files() = files; diff --git a/tests/unit/test_run_concurrent_children.cpp b/tests/unit/test_run_concurrent_children.cpp index b2cd42d7..f22e0e4e 100644 --- a/tests/unit/test_run_concurrent_children.cpp +++ b/tests/unit/test_run_concurrent_children.cpp @@ -68,8 +68,8 @@ TEST(ConcurrentChildren, AReaderReturnsWhenItsOwnChildExits) { tQuick.join(); tSlow.join(); - ASSERT_EQ(quick.exit_code, 0) << "round " << round; - ASSERT_EQ(slow.exit_code, 0) << "round " << round; + ASSERT_EQ(quick.exit_code, 0) << "round " << round << ": " << quick.output; + ASSERT_EQ(slow.exit_code, 0) << "round " << round << ": " << slow.output; // Each reader is handed its own child's output and nothing else. EXPECT_NE(quick.output.find("quick"), std::string::npos) << quick.output; // The quick child was gone long before the slow one. Half the slow From 9e87a45c331ed00819b1dc5e562050bf51917e82 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 14:47:22 +0900 Subject: [PATCH 38/43] =?UTF-8?q?design=20=C2=A725.8=E2=80=9325.9:=20what?= =?UTF-8?q?=20the=20patch=20round=20measured,=20and=20the=20order=20mcpp?= =?UTF-8?q?=20ends=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...ontract-runtime-and-ide-ecosystem-design.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md index b2c334f5..d12b304a 100644 --- a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md +++ b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md @@ -1112,6 +1112,24 @@ data`、`failed to load pendings for 'std::vector'`)。以最小化的导入 2. **其他处理器的映像(openkal-macos 0.15.1)。** EBADARCH 与 EBADMACHO 此前译为输入输出错误,现与 ENOEXEC 同译为 `kal_err_not_program`;mcpp 的 `runner_lookup` 据此只需识别 ENOEXEC 一种"本机不能装载"。测试 `foreign_image_not_program`。 +3. **结束字的唤醒(openkal-linux 0.18.2)。** 信号处理器以共享 futex 唤醒结束字,`kal_task_wait` 以私有 futex + 等待,内核把二者分开记录:等待结束字的线程从未被唤醒,mcpp 的守望线程因此对 SIGINT、SIGTERM 无反应,构建照常 + 以 0 结束(e2e 845 G3、936 H)。0.18.2 以私有唤醒。测试 `stop_word_take_over` 增加无界等待的情形。 +4. **有界生命期程序的有界等待(openkal-windows 0.13.2)。** `kal_timeout_wait_process` 把进程字当作句柄读取, + 而以有界生命期启动的程序由其记录命名:对它的每次有界等待都失败,`[hooks] timeout_seconds` 在 Windows 上不生效 + (e2e 317),对运行中程序的轮询也看不到它结束。测试 `bounded_wait_of_a_bound_program`。 +5. **零时限(mcpplibs.os 0.2.0)。** openkal 把零时限读作无时限;`stdio::read_input_for(0)` 因此无限等待按键, + `--play-game` 的构建停在第一帧(e2e 845)。os 把零时限读作不等待的询问,与读取子进程输出流一致。 +6. **精确版本固定的连带发布。** openkal 系列按精确版本固定依赖。含 mcpplibs.os 0.2.0 的 ok image 图若同时含 + openkal-musl 0.22.0(openkal 0.17.0),图中出现两个 openkal,mcpp 改名其中一份,被改名的 openkal-musl 的构建程序 + 生成的头文件不可见。openkal-musl(0.22.1、0.22.2)与 openkal-llvm-runtime(0.16.1、0.16.2)因此随这一轮发布。 + +### 25.9 结束的顺序(实现中修订) + +守望线程先写中断文件,再结束子进程单元,再执行清理:子进程被结束会唤醒等待它的线程,该线程随后写出的记录 +(`failed`)此前会覆盖中断文件写下的 `cancelled`(macOS 的 e2e 936 H)。`mcpp::run::ending()` 在结束开始后为真, +构建记录的写入方此时不再写。xlings 的网络检查在 mcpp 的工作目录启动:在 home 中启动时,xlings 把 home 的 +`.xlings.json` 当作项目文件读取,不再列出配置的官方索引端点(e2e 920)。 ## 附录 A:讨论收敛记录(摘要) | 问题 | 结论 | From bc56963d159e036d8458d8a0f8a82f236242e790 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 17:18:21 +0900 Subject: [PATCH 39/43] Staging compares a readable destination by content: an upgraded dependency replaces the previous version's same-size files; a status file with no number is a failed unit Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 15 +++ docs/90-build-from-source.md | 4 +- docs/zh/90-build-from-source.md | 4 +- src/build/ninja_backend.cppm | 43 +++--- src/build/schedule/detach_codegen.cppm | 10 +- src/build/stage.cppm | 32 +++-- ...dependency_is_restaged_at_the_same_size.sh | 125 ++++++++++++++++++ tests/unit/test_build_stage.cpp | 30 ++++- tests/unit/test_detach_status.cpp | 42 ++++++ 9 files changed, 262 insertions(+), 43 deletions(-) create mode 100755 tests/e2e/948_an_upgraded_dependency_is_restaged_at_the_same_size.sh create mode 100644 tests/unit/test_detach_status.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b452f2..b3d67773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,21 @@ ### Fixed +- **A dependency upgraded to a cached version replaces the previous version's + files.** A package's BMI and objects are staged from the build cache at paths + named by the module and the source, not by the cache entry, and staging under + `--verify size` took a destination of the source's size as already staged. + After an upgrade the previous version's files are at those paths, and a + change that keeps their sizes (a constant) left them in place: the program + ran the old code, and a unit compiled against the new graph failed with + `CRC mismatch` (GCC). A destination that can be read is now compared by + content under every mode; an equal size is accepted only for one that cannot + be opened (a language server holding it). e2e 948. +- **A compile whose status could not be written fails at once.** A detached + module compile reports its status in `.rc`, which appears by a rename + after the number is written. On a full disk the file appeared empty and the + build waited for a number until its two-hour limit; an `.rc` with no number is + now a failed unit, said as such. - **A WebAssembly link writes its `.wasm` again.** 2026.10.26.1 published every POSIX link through a temporary name, and Emscripten names the file it writes beside the output after the output: a link to `app.js.tmp` wrote no diff --git a/docs/90-build-from-source.md b/docs/90-build-from-source.md index 4b829596..9af55bba 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -81,7 +81,7 @@ src/ └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 152 C++ tests, discovered by `mcpp test` +├── unit/ 154 C++ tests, discovered by `mcpp test` └── e2e/ 635 shell scripts against a real binary ``` @@ -89,7 +89,7 @@ tests/ Two layers, and they answer different questions. -**Unit tests** (`tests/unit/`, 152 files) are C++ programs `mcpp test` +**Unit tests** (`tests/unit/`, 154 files) are C++ programs `mcpp test` discovers. They exercise a module's contract with no binary and no filesystem state. diff --git a/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index b0bbef54..cbce28e3 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -83,7 +83,7 @@ src/ └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 152 C++ tests, discovered by `mcpp test` +├── unit/ 154 C++ tests, discovered by `mcpp test` └── e2e/ 635 shell scripts against a real binary ``` @@ -91,7 +91,7 @@ tests/ 两层,回答的是不同的问题。 -**单元测试**(`tests/unit/`,152 个文件)是 `mcpp test` 发现的 C++ +**单元测试**(`tests/unit/`,154 个文件)是 `mcpp test` 发现的 C++ 程序。它们检验一个模块的契约,不需要二进制、也不需要文件系统状态。 **端到端测试**(`tests/e2e/NN_.sh`,635 个文件)拿真实的 `mcpp` diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 58681d25..71d59701 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -1708,17 +1708,16 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // stage must not dirty every importer of the staged BMI. // $verify is per-edge: empty (⇒ content comparison, the safe default) for // payloads whose bytes can change under an unchanged name — a rebuilt DLL - // keeps its size, PE sections are page-padded — and `--verify size` for the - // std artifacts, which are fingerprint-scoped: the cache dir and this build - // dir share the fp that covers compiler identity, target triple, stdlib, - // std source hash and the dialect flags, so equal size IS equivalence. + // keeps its size, PE sections are page-padded — and `--verify size` for + // BMIs and objects, which a language server may hold open. // - // That distinction is not a micro-optimization: reading the destination - // needs to OPEN it, and a holder may deny even that (Windows CI: a - // MemoryMappedFile opened with FileShare.None → ERROR_SHARING_VIOLATION on - // open, so content can't be compared and staging fails). Size comes from - // directory metadata, which stays readable — so the fingerprint-scoped - // edges survive a lock that would otherwise be unsurvivable. + // Reading the destination needs to OPEN it, and a holder may deny even that + // (Windows CI: a MemoryMappedFile opened with FileShare.None → + // ERROR_SHARING_VIOLATION on open, so content can't be compared and the + // file can't be replaced either). Size comes from directory metadata, which + // stays readable, so under `--verify size` a destination that cannot be + // opened and has the source's size is taken as staged. One that can be + // opened is compared by content under every mode (mcpp.build.stage). append("rule stage_file\n"); append(" command = $mcpp stage $verify --output $out $in\n"); append(" description = STAGE $out\n"); @@ -2520,11 +2519,11 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, } // Stage prebuilt std artifacts into the compiler-specific BMI cache. - // These four are fingerprint-scoped (see the stage_file rule comment), so - // they carry the size-only equivalence check. + // A language server may hold these open, so they carry `--verify size` + // (see the stage_file rule comment). // (ninja trims trailing whitespace in variable values, hence the space // lives in the rule's command string, not here) - static constexpr std::string_view kFpScopedVerify = " verify = --verify size\n"; + static constexpr std::string_view kHeldOpenVerify = " verify = --verify size\n"; auto std_bmi_dst = mcpp::toolchain::staged_std_bmi_path(plan.toolchain, {}); auto std_o_dst = std::filesystem::path("obj") / std::format("std{}", dial.objExt); @@ -2638,10 +2637,10 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, if (has_std_artifacts) { append(std::format("build {} : stage_file {}\n", escape_ninja_path(std_bmi_dst), escape_ninja_path(plan.stdBmiPath))); - append(std::string(kFpScopedVerify)); + append(std::string(kHeldOpenVerify)); append(std::format("build {} : stage_file {}\n", escape_ninja_path(std_o_dst), escape_ninja_path(plan.stdObjectPath))); - append(std::string(kFpScopedVerify)); + append(std::string(kHeldOpenVerify)); append("\n"); } @@ -2659,10 +2658,10 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, append(std::format("build {} : stage_file {} | {}\n", escape_ninja_path(compat_bmi_dst), escape_ninja_path(plan.stdCompatBmiPath), escape_ninja_path(std_bmi_dst))); - append(std::string(kFpScopedVerify)); + append(std::string(kHeldOpenVerify)); append(std::format("build {} : stage_file {}\n", escape_ninja_path(compat_o_dst), escape_ninja_path(plan.stdCompatObjectPath))); - append(std::string(kFpScopedVerify)); + append(std::string(kHeldOpenVerify)); append("\n"); } @@ -2759,11 +2758,11 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // dir recompiled every one of them while the CLI printed "Cached". Making // the staging an edge is what gives ninja a record to compare against. // - // `--verify size` for the same reason the std artifacts use it: the entry - // directory is named by a key covering the toolchain, dialect, profile, - // this package's own config and its dependencies' keys, so for a given key - // equal size IS equivalence — and size comes from directory metadata, which - // stays readable even when a holder denies opening the file. + // `--verify size` for the same reason the std artifacts use it: a holder + // may deny opening a staged BMI, and its size stays readable. The + // destination is named by the module, not by the entry, so a readable one + // is still compared by content: after an upgrade it holds the previous + // version's BMI, often at the same size. // // ORDERING. Replacing a package's compile edges with stage edges also // removes the ordering those compile edges carried. A module partition is diff --git a/src/build/schedule/detach_codegen.cppm b/src/build/schedule/detach_codegen.cppm index f818a375..6ec0e2fe 100644 --- a/src/build/schedule/detach_codegen.cppm +++ b/src/build/schedule/detach_codegen.cppm @@ -152,11 +152,19 @@ bool file_exists(const std::filesystem::path& p) { return std::filesystem::exists(p, ec); } +// The status a supervisor left. `.rc` appears by a rename once its number is +// written, so one that holds no number is a write that failed (a full disk): +// the supervisor's status is lost, and the unit is taken as failed rather than +// waited for until the hard limit. std::optional read_rc(const std::filesystem::path& slot) { std::ifstream in(suffixed(slot, ".rc")); if (!in) return std::nullopt; int rc = 0; - if (!(in >> rc)) return std::nullopt; + if (!(in >> rc)) { + std::println(std::cerr, "mcpp: the status of the compiler for {} was not written " + "(is the disk full?)", slot.string()); + return 1; + } return rc; } diff --git a/src/build/stage.cppm b/src/build/stage.cppm index b9b175f3..34350c26 100644 --- a/src/build/stage.cppm +++ b/src/build/stage.cppm @@ -18,15 +18,19 @@ // // 1. Never write bytes that are already there. Equivalence is decided by // COMPARING CONTENT, which is unconditionally correct — a destination -// identical to the source needs no write, whatever the reason. Size alone -// is only a heuristic and is available as `--verify size` for the paths -// where the caller knows better (a std BMI is fingerprint-scoped: the -// cache dir and the build dir share the fp that covers compiler identity, -// target triple, stdlib, std source hash and the dialect flags). It is -// NOT the default, because staging also carries .dll payloads whose PE -// section padding makes "same size, different bytes" ordinary. -// Skipping mirrors what the dep BMI cache has always done -// (bmi_cache.cppm: "Existing project outputs are left untouched"). +// identical to the source needs no write, whatever the reason. A +// destination that cannot be read is not compared: a holder may deny +// opening it (Windows: a MemoryMappedFile opened with FileShare.None), +// and then it cannot be replaced either. `--verify size` accepts an equal +// size for such a destination, and only for it. A destination that can be +// read is compared under every mode: an equal size does not make a staged +// file the source's. A package's BMI lands at a path named by the module, +// not by the cache entry, so the BMI of the version before an upgrade +// sits where the new one goes, and GCC writes an interface whose source +// did not change at the same size with a different checksum; importers +// then fail with "CRC mismatch". The size-only shortcut is never the +// default, because staging also carries .dll payloads whose PE section +// padding makes "same size, different bytes" ordinary. // 2. When we do write, write out of place and rename — atomic for readers, // and it survives a transient sharing violation (antivirus, indexer) // that an in-place overwrite would lose to. @@ -53,7 +57,8 @@ export namespace mcpp::build::stage { // How hard to look before declaring the destination already-staged. enum class Verify { Content, // default: byte-for-byte compare — always correct - Size, // size match only (MCPP_STAGE_VERIFY=size / --verify size) + Size, // also an equal size where the destination cannot be read + // (MCPP_STAGE_VERIFY=size / --verify size) }; struct StageOptions { @@ -365,15 +370,16 @@ std::expected stage_file(const std::filesystem::path& } } - // Already staged? Content by default — size is the caller-opt-in shortcut - // (see module comment). + // Already staged? Equal content; under `Verify::Size` also an equal size + // when the destination cannot be read (see module comment). std::error_code sec; if (std::filesystem::is_regular_file(dst, sec)) { std::error_code se1, se2; auto sizeDst = std::filesystem::file_size(dst, se1); auto sizeSrc = std::filesystem::file_size(src, se2); bool equivalent = !se1 && !se2 && sizeDst == sizeSrc - && (opts.verify == Verify::Size || same_content(src, dst)); + && (same_content(src, dst) + || (opts.verify == Verify::Size && !std::ifstream(dst, std::ios::binary).is_open())); if (equivalent) { // Timestamps left untouched on purpose — see module comment. return StageOutcome{.copied = false}; diff --git a/tests/e2e/948_an_upgraded_dependency_is_restaged_at_the_same_size.sh b/tests/e2e/948_an_upgraded_dependency_is_restaged_at_the_same_size.sh new file mode 100755 index 00000000..d71422d3 --- /dev/null +++ b/tests/e2e/948_an_upgraded_dependency_is_restaged_at_the_same_size.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# requires: gcc fresh-sandbox +# 948_an_upgraded_dependency_is_restaged_at_the_same_size.sh — a dependency +# upgraded to a version the build cache already holds is staged over the +# previous version's files, whatever their size. +# +# A package's BMI and objects land at paths named by the module and the source, +# not by the cache entry, so after an upgrade the previous version's files are +# where the new ones go. Staging under `--verify size` took an equal size for an +# equal file. A change of a constant keeps both sizes, so the previous BMI and +# object stayed: the program printed the old value, and a unit compiled against +# the new graph met the old BMI ("CRC mismatch"). +set -e +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +INDEX_DIR="$TMP/local-index" +INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" +mkdir -p "$INDEX_DIR/pkgs/c" +cat > "$INDEX_DIR/pkgs/c/constant.lua" <<'EOF' +package = { + spec = "1", + name = "constant", + description = "One constant", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { + ["1.0.0"] = { + url = "https://example.invalid/constant-1.0.0.tar.gz", + sha256 = "0000000000000000000000000000000000000000000000000000000000000000", + }, + ["1.0.1"] = { + url = "https://example.invalid/constant-1.0.1.tar.gz", + sha256 = "0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + }, + mcpp = { + language = "c++23", + import_std = true, + sources = { "src/**/*.cppm" }, + targets = { ["constant"] = { kind = "lib" } }, + deps = {}, + }, +} +EOF + +# `make_project `: a program printing the constant of `version`, +# whose payloads hold 41 (1.0.0) and 42 (1.0.1) --- one BMI size, one object size. +make_project() { + local dir="$1" version="$2" + mkdir -p "$TMP/$dir/src" + for v in 1.0.0:41 1.0.1:42; do + local d="$TMP/$dir/.mcpp/.xlings/data/xpkgs/local-dev.constant/${v%%:*}/src" + mkdir -p "$d" + cat > "$d/constant.cppm" < "$TMP/$dir/src/main.cpp" <<'EOF' +import std; +import constant; +int main() { std::println("{} {}", value, stated()); return 0; } +EOF + cat > "$TMP/$dir/mcpp.toml" < build.log 2>&1) || { cat "$TMP/omega/build.log"; exit 1; } +run_prints omega "42 42" + +# This project builds 1.0.0, then moves to 1.0.1: the staged files replace the +# ones 1.0.0 left at the same paths. +make_project alpha 1.0.0 +cd "$TMP/alpha" +"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } +run_prints alpha "41 41" +bmi="$(find target -path '*gcm.cache*' -name 'constant.gcm' | head -1)" +[[ -n "$bmi" ]] || { echo "FAIL: no BMI of the dependency in the build directory"; exit 1; } +before="$(wc -c < "$bmi")" + +sed -i.bak 's/"local-dev.constant" = "1.0.0"/"local-dev.constant" = "1.0.1"/' mcpp.toml +rm -f mcpp.toml.bak +"$MCPP" build > upgrade.log 2>&1 || { cat upgrade.log; exit 1; } +grep -qE 'Cached local-dev\.constant v1\.0\.1' upgrade.log || { + echo "FAIL: 1.0.1 was not served from the cache, so nothing here is staged" + cat upgrade.log; exit 1 +} +[[ "$(wc -c < "$bmi")" == "$before" ]] || { echo "FAIL: the BMI's size changed; the case is not the same-size one"; exit 1; } +run_prints alpha "42 42" +cmp -s "$bmi" "$(find "$MCPP_HOME/build-cache" -path '*constant@1.0.1*' -name 'constant.gcm' | head -1)" || { + echo "FAIL: the staged BMI is not 1.0.1's"; exit 1 +} + +echo "OK" diff --git a/tests/unit/test_build_stage.cpp b/tests/unit/test_build_stage.cpp index 1eb9229f..8823129e 100644 --- a/tests/unit/test_build_stage.cpp +++ b/tests/unit/test_build_stage.cpp @@ -92,9 +92,10 @@ TEST(BuildStage, SameSizeDifferentBytesIsCopiedByDefault) { EXPECT_EQ(read_file(dst), "AAAA"); } -// `--verify size` is the opt-in shortcut for callers that know the source is -// fingerprint-scoped; it accepts the same-size destination as already staged. -TEST(BuildStage, SameSizeDifferentBytesIsSkippedUnderSizeVerify) { +// `--verify size` does not make a readable destination the source's: a +// package's BMI is staged at a path named by the module, so after an upgrade +// the previous version's BMI is there, often at the same size. +TEST(BuildStage, SameSizeDifferentBytesIsCopiedUnderSizeVerify) { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "dst.bin"; @@ -103,6 +104,29 @@ TEST(BuildStage, SameSizeDifferentBytesIsSkippedUnderSizeVerify) { auto r = stage_file(src, dst, no_retry(Verify::Size)); ASSERT_TRUE(r.has_value()); + EXPECT_TRUE(r->copied); + EXPECT_EQ(read_file(dst), "AAAA"); +} + +// A destination that cannot be opened is taken as staged under `--verify size` +// when its size is the source's: a holder that denies reading it denies +// replacing it as well. +TEST(BuildStage, AnUnreadableSameSizeDestinationIsSkippedUnderSizeVerify) { + Tmp tmp; + auto src = tmp.path / "src.bin"; + auto dst = tmp.path / "dst.bin"; + write_file(src, "AAAA"); + write_file(dst, "BBBB"); + std::filesystem::permissions(dst, std::filesystem::perms::none); + if (std::ifstream(dst, std::ios::binary).is_open()) { + std::filesystem::permissions(dst, std::filesystem::perms::owner_all); + GTEST_SKIP() << "permissions do not deny reading here"; + } + + auto r = stage_file(src, dst, no_retry(Verify::Size)); + auto content = r.has_value() ? std::string() : r.error().message; + std::filesystem::permissions(dst, std::filesystem::perms::owner_all); + ASSERT_TRUE(r.has_value()) << content; EXPECT_FALSE(r->copied); EXPECT_EQ(read_file(dst), "BBBB"); } diff --git a/tests/unit/test_detach_status.cpp b/tests/unit/test_detach_status.cpp new file mode 100644 index 00000000..43df4a2b --- /dev/null +++ b/tests/unit/test_detach_status.cpp @@ -0,0 +1,42 @@ +#include + +import std; +import mcpp.build.schedule.detach_codegen; + +// A detached compile reports its status in `.rc`, which appears by a +// rename once the number is written. One that holds no number is a write that +// failed (a full disk); the waiter takes the unit as failed at once instead of +// waiting for a number that will never come. + +namespace fs = std::filesystem; +namespace detach = mcpp::build::schedule::detach; + +namespace { +struct Slot { + fs::path dir, slot; + Slot() { + dir = fs::temp_directory_path() / std::format("mcpp-detach-{:08x}", std::random_device{}()); + fs::create_directories(dir); + slot = dir / "unit.m.o.sched"; + std::ofstream(slot.string() + ".log") << ""; + } + ~Slot() { std::error_code ec; fs::remove_all(dir, ec); } +}; +} // namespace + +TEST(DetachStatus, AStatusFileWithNoNumberIsAFailedUnit) { + Slot s; + std::ofstream(s.slot.string() + ".rc") << ""; + const auto started = std::chrono::steady_clock::now(); + EXPECT_NE(detach::await_unit(s.slot, {}), 0); + EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(5)); +} + +TEST(DetachStatus, AWrittenStatusIsTheUnits) { + Slot s; + std::ofstream(s.slot.string() + ".rc") << "3\n"; + EXPECT_EQ(detach::await_unit(s.slot, {}), 3); + Slot t; + std::ofstream(t.slot.string() + ".rc") << "0\n"; + EXPECT_EQ(detach::await_unit(t.slot, {}), 0); +} From 4c8a1aa3c1ec6c26c91f0c79748c02d1fa9b1746 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 17:44:56 +0900 Subject: [PATCH 40/43] tests: the concurrent-children launcher test runs on Windows too, its quick child printing what the reader checks Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- tests/unit/test_run_concurrent_children.cpp | 23 ++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/unit/test_run_concurrent_children.cpp b/tests/unit/test_run_concurrent_children.cpp index f22e0e4e..8dafbed1 100644 --- a/tests/unit/test_run_concurrent_children.cpp +++ b/tests/unit/test_run_concurrent_children.cpp @@ -1,6 +1,7 @@ #include import std; +import mcpplibs.os; import mcpp.run; // SUBSYSTEM-LEVEL, and the contract is B2-0 of the member-selection and @@ -19,19 +20,17 @@ namespace { using Clock = std::chrono::steady_clock; -#if defined(_WIN32) -// `hostname` exits at once on every Windows; `ping -n 2` answers the second +// `cmd /c echo` exits at once on every Windows; `ping -n 2` answers the second // time one second after the first, which is the slowest child this needs. -const std::vector kQuick{"hostname"}; -const std::vector kSlow{"ping", "-n", "2", "127.0.0.1"}; -constexpr auto kSlowFor = std::chrono::milliseconds(1000); -constexpr int kRounds = 6; -#else -const std::vector kQuick{"/bin/sh", "-c", "echo quick"}; -const std::vector kSlow{"/bin/sh", "-c", "sleep 0.5"}; -constexpr auto kSlowFor = std::chrono::milliseconds(500); -constexpr int kRounds = 12; -#endif +constexpr bool kWindows = mcpplibs::os::is_windows; +const std::vector kQuick = kWindows + ? std::vector{"cmd.exe", "/d", "/c", "echo quick"} + : std::vector{"/bin/sh", "-c", "echo quick"}; +const std::vector kSlow = kWindows + ? std::vector{"ping", "-n", "2", "127.0.0.1"} + : std::vector{"/bin/sh", "-c", "sleep 0.5"}; +constexpr auto kSlowFor = std::chrono::milliseconds(kWindows ? 1000 : 500); +constexpr int kRounds = kWindows ? 6 : 12; struct Outcome { Clock::time_point end{}; From 427e61200d77e9eb9e4e3dc3ae8c2ae8f0125f4d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 17:54:52 +0900 Subject: [PATCH 41/43] mcpplibs.os 0.2.0 from the index Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- mcpp.toml | 2 +- modules/invocation/mcpp.toml | 2 +- modules/manifest/mcpp.toml | 2 +- modules/toolchain-model/mcpp.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mcpp.toml b/mcpp.toml index 43883c36..a863fe0d 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -91,7 +91,7 @@ mcpplibs.cmdline = "0.0.1" # The operating system is mcpplibs.os's (design 2026-10-26 §25): mcpp keeps no # layer of its own. [dependencies.mcpplibs] -os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } +os = "0.2.0" [target.linux.build] sources = ["!src/native/windows/**"] diff --git a/modules/invocation/mcpp.toml b/modules/invocation/mcpp.toml index 25a02259..12bbfb6d 100644 --- a/modules/invocation/mcpp.toml +++ b/modules/invocation/mcpp.toml @@ -13,4 +13,4 @@ description = "The environment of one invocation of mcpp: statements over the st license = "Apache-2.0" [dependencies.mcpplibs] -os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } +os = "0.2.0" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index a718d4c0..efaf5008 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -28,7 +28,7 @@ versioning = { path = "../versioning" } # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dependencies.mcpplibs] -os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } +os = "0.2.0" [dev-dependencies.compat] gtest = "1.15.2" diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index f4ad4dda..6db08da8 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -27,7 +27,7 @@ versioning = { path = "../versioning" } # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dependencies.mcpplibs] -os = { git = "https://github.com/mcpplibs/os.git", branch = "os-0.2.0" } +os = "0.2.0" [dev-dependencies.compat] gtest = "1.15.2" From 886346472152fb68b4f7b74b62226f7d5ce872be Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 17:59:03 +0900 Subject: [PATCH 42/43] =?UTF-8?q?design=20=C2=A725.8:=20openkal-windows=20?= =?UTF-8?q?0.13.3,=20openkal-image=200.1.1,=20and=20staging=20after=20an?= =?UTF-8?q?=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...ol-contract-runtime-and-ide-ecosystem-design.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md index d12b304a..8f73480e 100644 --- a/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md +++ b/.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md @@ -1122,7 +1122,19 @@ data`、`failed to load pendings for 'std::vector'`)。以最小化的导入 `--play-game` 的构建停在第一帧(e2e 845)。os 把零时限读作不等待的询问,与读取子进程输出流一致。 6. **精确版本固定的连带发布。** openkal 系列按精确版本固定依赖。含 mcpplibs.os 0.2.0 的 ok image 图若同时含 openkal-musl 0.22.0(openkal 0.17.0),图中出现两个 openkal,mcpp 改名其中一份,被改名的 openkal-musl 的构建程序 - 生成的头文件不可见。openkal-musl(0.22.1、0.22.2)与 openkal-llvm-runtime(0.16.1、0.16.2)因此随这一轮发布。 + 生成的头文件不可见。openkal-musl(0.22.1、0.22.2、0.22.3)与 openkal-llvm-runtime(0.16.1、0.16.2、0.16.3)因此随 + 这一轮发布;openkal-image 的三个包(openkal-ld、openkal-run、openkal-tools)同一归档、同一版本,随之到 0.1.1。 +7. **环境表的首次查询与管道的有界读(openkal-windows 0.13.3)。** 环境表在首次查询时填写,两个执行上下文同时首次 + 查询时,一个标记表已就绪并填写,另一个读到空表,把已设置的 `PATH` 报为不存在(单元测试 + `ConcurrentChildren.AReaderReturnsWhenItsOwnChildExits` 在 Windows 上的失败);0.13.3 在一把锁下填写一次。管道的 + 有界读把每次 1 ms 的睡眠计为 1 ms,而默认计时器下一次睡眠约 15.6 ms,100 ms 的时限约等待 1.5 s;0.13.3 以性能 + 计数器计量。测试 `environment_read_from_two_threads`、`bounded_read_of_an_idle_pipe`。 +8. **升级到已缓存版本的依赖(mcpp)。** 包的 BMI 与目标文件从构建缓存暂存到由模块名与源文件命名的路径,而非由缓存 + 条目命名;`--verify size` 把与源同尺寸的目的文件视为已暂存。升级后上一版本的文件位于这些路径,保持尺寸的改动 + (一个常量)使它们留在原处:程序运行旧代码,按新图编译的单元报 `CRC mismatch`(GCC)。在 mcpp-ide 从 os 0.1.0 + 升级到 0.2.0 时观察到。可读的目的文件现在在任何模式下按内容比较;尺寸相等只对打不开的目的文件(语言服务器持有) + 成立。e2e 948。同一次观察中磁盘写满,分离编译的状态文件为空,构建等待到两小时上限;无数字的状态文件现在即为 + 失败的单元。 ### 25.9 结束的顺序(实现中修订) From 6e4b5690b0fbb2cb1979b35555f9db80e30dd16d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Tue, 27 Oct 2026 18:38:09 +0900 Subject: [PATCH 43/43] CHANGELOG: one Changed list for 2026.10.26.2; the superseded platform entry merged into the dissolution's Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d67773..cc7425fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,12 @@ platform keys and the Apple SDK in `toolchain-model`, the terminal and key input in `src/ui`, the home's lock and trust check in `mcpp.home.files`. The one primitive kept per system is the lock every release of mcpp takes - (`src/native/posix`, `src/native/windows`). + (`src/native/posix`, `src/native/windows`). A program is found on PATH + without a shell, with PATHEXT's suffixes on Windows, and the current + directory is not searched on Windows. A list of directories in an + environment variable (`PATH`, the run-time library path) is read and written + with `mcpplibs.os`'s `split_list` and `join_list`: an empty entry is + skipped, and a quoted entry on Windows is unquoted. - **A program mcpp starts is named by its argument vector.** Compiler probes, git, xlings, tar, strip, patchelf, readelf and vswhere are started without a command interpreter, with their additions to the environment given to them @@ -69,7 +74,9 @@ watcher thread, which ends the units of the running programs, writes the interrupt files, restores the terminal and ends mcpp as the request would have; the exit codes of §5 are unchanged. A closed standard output ends mcpp - in the same order (141 to a shell on POSIX). + in the same order (`mcpp … | head`: 141 to a shell on POSIX). Every program + mcpp starts begins with SIGPIPE at its default, although openkal ignores it + for its own writes. - **mcpp does not write its own process environment.** Variables mcpp states (its home, `--offline`, a hook's variables, xlings' home and PATH) are this invocation's statements, read by every lookup mcpp makes and given to the @@ -163,27 +170,6 @@ the common packages each time. A unit's map is now the fixed roots and the roots of its own package's dependency closure. -### Changed - -- **The operating-system layer is `mcpplibs.os` over openkal.** The platform - constants and the architecture, the executable's own name, PATH lookup, - directory links, renames that refuse an existing name, volume identities, - terminals and console output come from `mcpplibs.os`; what that library - does not carry (the launchers and their signal guard, file locks shared with - earlier releases, durable sync) is one implementation unit per system, - selected by the package manifest. `modules/platform` contains no - preprocessor conditional. `which` no longer starts a shell: a program is - found on PATH, with PATHEXT's suffixes on Windows, and the current - directory is not searched on Windows. A list of directories in an - environment variable (`PATH`, the run-time library path) is read and - written with `mcpplibs.os`'s `split_list` and `join_list`: an empty entry - is skipped, and a quoted entry on Windows is unquoted. -- **A closed reader ends mcpp in order.** `mcpp … | head` stops the guarded - child process groups, writes the interrupt files and restores the terminal, - and then ends by SIGPIPE, with the status a shell has always seen (141). - Every program mcpp starts begins with SIGPIPE at its default, although - openkal ignores it for its own writes. - ## [2026.10.26.1] - 2026-10-26 ### Added