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 c67618a2b..8f73480ef 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,12 @@ 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)。 +- 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`,锁的 + 实现单元在 `src/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(快速路径的拒绝清单); @@ -125,6 +131,8 @@ 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) | +| D25 | **mcpp 不保留操作系统层**:`modules/platform` 删除。操作系统能力直接调用 mcpplibs.os,缺少的能力加入 mcpplibs.os(0.2.0);`platform` 名下属于 mcpp 自身语义的代码(副作用约束、调用环境、运行时搜索与加载器环境、命令行拼写、平台键、Apple SDK、脚手架、终端交互)归入各自所属的 mcpp 模块(§25) | ## 1. 问题与证据 @@ -869,6 +877,271 @@ 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 渲染。 + +## 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 对自身旧版本的兼容契约。按系统选择的实现单元位于 `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` | +| 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/` 中不出现操作系统原生头文件 +(只在 `src/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`。 +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、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 结束的顺序(实现中修订) + +守望线程先写中断文件,再结束子进程单元,再执行清理:子进程被结束会唤醒等待它的线程,该线程随后写出的记录 +(`failed`)此前会覆盖中断文件写下的 `cancelled`(macOS 的 e2e 936 H)。`mcpp::run::ending()` 在结束开始后为真, +构建记录的写入方此时不再写。xlings 的网络检查在 mcpp 的工作目录启动:在 home 中启动时,xlings 把 home 的 +`.xlings.json` 当作项目文件读取,不再列出配置的官方索引端点(e2e 920)。 ## 附录 A:讨论收敛记录(摘要) | 问题 | 结论 | 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 000000000..42764a49b --- /dev/null +++ b/.agents/docs/2026-10-26-ecosystem-part2-os-library-over-openkal-design.md @@ -0,0 +1,699 @@ +--- +subject: ecosystem +status: active +--- + +# 生态设计 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。 + 取消的可观察语义:清理、写 `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 的修改 + +### 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 | +| 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,各实现随之) + +接纳准则:(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 以借用安排构建,宿主 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 拦截进入映像的间接调用)。 + +### 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 运行 | + +实现(#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 --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 + 不另行实现。 +- **运行**:`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`(编译目标 +不是任何一个 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` 的平台差异只以两种形式表达:`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`) + +缺陷(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 | +| 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 | +| 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 关闭该特性(一个构建一个编译器运行时) | +| B10 | 链接 openkal 后,mcpp 与 openkal 启动的程序都以 SIGPIPE 忽略开始;mcpp 的 `std::print` 写入无读端的标准输出时抛出异常 | openkal 为自身写入忽略 SIGPIPE,忽略跨 `exec` 保留(E20 未计及继承) | K12;mcpp 的 SIGPIPE 守护(§6.4、§10.1);e2e 941 | + +## 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 7abf7a5a7..e6d722b61 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) — active - [控制契约、并发与性能、单一 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) — 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 diff --git a/.github/tools/install_pinned_mcpp.sh b/.github/tools/install_pinned_mcpp.sh index 017f74e11..df8c2451c 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 -- +# 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/tools/upgrade_lane.sh b/.github/tools/upgrade_lane.sh index fce1287fb..be23c19da 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-aarch64-fresh-install.yml b/.github/workflows/ci-aarch64-fresh-install.yml index d72b44e27..90da06984 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-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 93e1a342c..6ce1284cd 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/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 668c0d47c..64de87fa6 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/.github/workflows/ci-upgrade.yml b/.github/workflows/ci-upgrade.yml index 277f4bcdc..3aa0b0a78 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 fd383be0e..cc7425fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,172 @@ > 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 + +### 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. + 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. + `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]`. + +- **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. + +### 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 + (`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 + 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 (`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 + programs it starts; an xlings invocation's environment is the started + program's, on Windows as elsewhere. + +### 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 + `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. +- **`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 + ("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 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 + 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 + 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 + 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 + 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 ### Added diff --git a/README.md b/README.md index f72360789..262681b8b 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 063c29928..b11b7542b 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 ea3ade7de..9351f82a0 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). | @@ -1308,6 +1382,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 @@ -1505,7 +1585,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 +1614,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 @@ -1969,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/05-dependencies.md b/docs/05-dependencies.md index f2f6e0f40..eaf4fa245 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 @@ -168,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 828d18893..43afd0442 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/08-testing.md b/docs/08-testing.md index 9a4a4d392..e6f93e32c 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/10-pack-and-release.md b/docs/10-pack-and-release.md index 45ef4d284..f9d667aa3 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 6163b16a2..adc35e630 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 15fbe41ea..7db7130ea 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 -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 | + +**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/30-build-mcpp.md b/docs/30-build-mcpp.md index 60007333d..29f13264a 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 93280ea38..bf7ff4143 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 734179a26..19d9c4e4e 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.** @@ -871,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 @@ -882,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/90-build-from-source.md b/docs/90-build-from-source.md index 766633925..9af55bba1 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -43,23 +43,31 @@ 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 `src/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 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 +├── 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 +76,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/ 154 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/`, 154 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 98963a122..40e94ac1f 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/specs/build-plugins.md b/docs/specs/build-plugins.md index 7aff95cb9..538e15430 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`,镜像入口)。 | diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md index 293c70126..2d2e4fe43 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 818ceb5bd..d6848ee66 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--静态还是动态由消费者决定)。 | @@ -1218,6 +1279,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` 适用于每一个精确三元组,不只是裸机。一个 @@ -1400,7 +1466,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 +1495,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` 可保留以前的产物;升级后第一次发布档构建会提示一次。 @@ -1830,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/05-dependencies.md b/docs/zh/05-dependencies.md index a1a87c111..9c4225b54 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 @@ -154,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 8d3d401e3..81fdafeb8 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 diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 5f9cb7cec..e309fdd3a 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/10-pack-and-release.md b/docs/zh/10-pack-and-release.md index 199b6df51..94919095e 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 583f1c7b7..62b5334eb 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 fe1f5ffd4..97498cdd8 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 -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 实现,映像不携带实现 | + +**内核接口固定,其上每一层来自依赖图。** 报告显示 `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/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 6757adb4c..62e950cc7 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 2e7e74c9f..cd4307ebd 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 8d9729d59..bcd88fbf5 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` 会 @@ -784,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` 只含 @@ -793,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/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index a990f457f..cbce28e3e 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -46,23 +46,30 @@ mcpp build --target x86_64-linux-musl ## 源码结构 -mcpp 是一个**工作空间**。`modules/` 下是引擎 import 的九个包;`src/` 是 -引擎本身。 +mcpp 是一个**工作空间**。`modules/` 下是引擎 import 的十个包;`src/` 是 +引擎本身。操作系统经由 [mcpplibs.os](https://github.com/mcpplibs/os) 访问; +mcpp 按系统保留的唯一原语(各版本 mcpp 共用的锁)位于 `src/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 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 +├── 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 +78,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/ 154 C++ tests, discovered by `mcpp test` +└── e2e/ 635 shell scripts against a real binary ``` ## 测试组织 两层,回答的是不同的问题。 -**单元测试**(`tests/unit/`,108 个文件)是 `mcpp test` 发现的 C++ +**单元测试**(`tests/unit/`,154 个文件)是 `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 4e47bb24d..bb7077571 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 fe5c96036..a863fe0dd 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"] @@ -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 (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 # 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 = "0.2.0" + +[target.linux.build] +sources = ["!src/native/windows/**"] + +[target.macos.build] +sources = ["!src/native/windows/**"] + +[target.windows.build] +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 # 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 e1d58249c..01ae7b6ad 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/directives.cppm b/modules/buildmcpp/src/directives.cppm index 30bdb1681..fea054399 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 0a792074b..b4b8c59e2 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 { @@ -118,7 +119,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 ───────────────────────────────────────────────────── // @@ -194,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 a7c4078c9..c5a0c5b15 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/dyndep/src/dyndep.cppm b/modules/dyndep/src/dyndep.cppm index 145602d80..4143bf3ce 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/modules/invocation/mcpp.toml b/modules/invocation/mcpp.toml new file mode 100644 index 000000000..12bbfb6db --- /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 = "0.2.0" diff --git a/modules/invocation/src/invocation.cpp b/modules/invocation/src/invocation.cpp new file mode 100644 index 000000000..50cf2d75b --- /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 000000000..f52f2ac71 --- /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 d8433168d..8077c0f94 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 cd0ece100..141c23021 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 30fea923e..efaf50086 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 = "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 54b7360fe..793fedc2e 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/targetside_model.cppm b/modules/manifest/src/targetside_model.cppm index b448dc171..ad46b4e60 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/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index dc48cfc0c..5f4ae7f97 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; @@ -1955,8 +1956,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 +1984,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 +2181,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 +2194,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 +2225,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; @@ -2918,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); @@ -3442,10 +3518,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 +4146,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 +4210,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 +5123,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 +5318,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 05c75d622..5e720e176 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; @@ -149,6 +148,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 +193,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 +328,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 +1354,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 +2167,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/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index 6a1523471..338e7a9ee 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 efb3ee446..000000000 --- a/modules/platform/mcpp.toml +++ /dev/null @@ -1,33 +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" diff --git a/modules/platform/src/capacity.cppm b/modules/platform/src/capacity.cppm deleted file mode 100644 index ba35b224d..000000000 --- a/modules/platform/src/capacity.cppm +++ /dev/null @@ -1,272 +0,0 @@ -// mcpp.platform.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: -// -// MEMORY. A C++23 module compile is not cheap in RAM. Measured on this -// repository with GCC 16.1 at -O2: -// src/build/prepare.cppm peak RSS 1,057 MB -// src/build/plan.cppm peak RSS 561 MB -// ninja's default job count is `nproc + 2`. On a 64-core / 32 GB machine that -// is 66 concurrent compiles against ~0.5-1 GB each — the machine swaps, and a -// swapping build is far slower than a smaller job count would have been. The -// default is not merely un-tuned there; it is actively harmful. -// -// HETEROGENEITY. An i9-13900K reports 32 logical CPUs, but they are 8 P-cores -// (SMT, 16 threads) plus 16 E-cores. E-cores deliver roughly 40% of a P-core's -// compile throughput and SMT siblings roughly 25%. Treating 32 threads as 32 -// equal workers overestimates usable parallelism by more than 2x. -// -// 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 { - -struct HostCapacity { - int logicalCores = 1; - int physicalCores = 1; - // True when the CPU mixes core classes (Intel P/E, Apple performance + - // efficiency). Recorded rather than inferred: every parallelism figure has - // to be read against it. - bool heterogeneous = false; - unsigned long long totalBytes = 0; - unsigned long long availableBytes = 0; // falls back to total when unknown -}; - -HostCapacity host_capacity(); - -// Job count for `auto`. See the module comment for why this is not `nproc`. -// -// cpu_budget = heterogeneous ? physicalCores : logicalCores -// mem_budget = (available - reserve) / per_job -// jobs = clamp(min(cpu, mem), 1, ceiling) -// -// `perJobBytes` and `reserveBytes` are parameters rather than constants so a -// project whose translation units are heavier (or lighter) than mcpp's can say -// so without patching this file. -int recommended_jobs(const HostCapacity& cap, - unsigned long long perJobBytes = 768ull * 1024 * 1024, - unsigned long long reserveBytes = 2ull * 1024 * 1024 * 1024, - int ceiling = 64); - -} // namespace mcpp::platform::capacity - -// ─── Implementation ──────────────────────────────────────────────────────── - -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, - // 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 diff --git a/modules/platform/src/common.cppm b/modules/platform/src/common.cppm deleted file mode 100644 index 2c6f241bf..000000000 --- a/modules/platform/src/common.cppm +++ /dev/null @@ -1,114 +0,0 @@ -// mcpp.platform.common — centralized platform-specific constants. -// -// 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; - -export module mcpp.platform.common; - -import std; - -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 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 daea25695..000000000 --- a/modules/platform/src/env.cppm +++ /dev/null @@ -1,403 +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. - -module; -#include -#if defined(_WIN32) -#include // _putenv_s -#else -#include // setenv / unsetenv -extern char** environ; -#endif - -export module mcpp.platform.env; - -import std; - -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); - -} // namespace mcpp::platform::env - -// ─── 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; -#if defined(_WIN32) - char** env = _environ; -#else - char** env = environ; -#endif - for (; 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) { -#if defined(_WIN32) - _putenv_s(key.c_str(), value.c_str()); -#else - setenv(key.c_str(), value.c_str(), 1); -#endif -} - -void unset(const std::string& key) { -#if defined(_WIN32) - _putenv_s(key.c_str(), ""); -#else - unsetenv(key.c_str()); -#endif -} - -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 path_list_separator() { -#if defined(_WIN32) - return ";"; -#else - return ":"; -#endif -} - -std::string runtime_library_path_key() { -#if defined(_WIN32) - return "PATH"; -#elif defined(__APPLE__) - // 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 -} - -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 -} - -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) -{ -#if defined(_WIN32) - for (auto& [k, v] : vars) - _putenv_s(k.c_str(), v.c_str()); - return ""; -#else - 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; -#endif -} - -} // namespace mcpp::platform::env diff --git a/modules/platform/src/fs.cppm b/modules/platform/src/fs.cppm deleted file mode 100644 index ed9dbd2e2..000000000 --- a/modules/platform/src/fs.cppm +++ /dev/null @@ -1,473 +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) - -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 - -export module mcpp.platform.fs; - -import std; -import mcpp.platform.common; - -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 -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. -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 -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: -#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 -}; - -} // namespace mcpp::platform::fs - -// ─── Implementation ────────────────────────────────────────────────────── - -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; - } -#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); -} - -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 defined(_WIN32) - 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())); -#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 ────────────────────────────────────────────────────────────── - -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{}; -} - -#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 deleted file mode 100644 index 82953dc67..000000000 --- a/modules/platform/src/fs_caps.cppm +++ /dev/null @@ -1,378 +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. - -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; - -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 { - -#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 {}; -} - -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 {}; -} - -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 - -} // 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 int e = native::rename_no_replace(scratch / "a", scratch / "b"); e != 0) { - caps.rename_no_replace = !native::unsupported(e); - 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 device = native::device_of(dir); - { - 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 int e = native::rename_no_replace(from, to); - if (e == 0) return {}; - if (!native::unsupported(e)) return failed(std::error_code(e, native::category())); - // 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) { - 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 -} - -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/linux.cppm b/modules/platform/src/linux/linux.cppm deleted file mode 100644 index c660be2d5..000000000 --- a/modules/platform/src/linux/linux.cppm +++ /dev/null @@ -1,115 +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 - -module; - -export module mcpp.platform.linux; - -import std; -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 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 -} - -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 -} - -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 - 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 -} - -} // namespace mcpp::platform::linux_ diff --git a/modules/platform/src/macos/macos.cppm b/modules/platform/src/macos/macos.cppm deleted file mode 100644 index 96e1ffee8..000000000 --- a/modules/platform/src/macos/macos.cppm +++ /dev/null @@ -1,352 +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) - -module; -#include -#include -#if defined(_WIN32) -#define popen _popen -#define pclose _pclose -#endif - -export module mcpp.platform.macos; - -import std; - -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) { - std::array buf{}; - std::string out; -#if defined(__APPLE__) - std::string full = cmd + " (buf.size()), fp) != nullptr) - out += buf.data(); - ::pclose(fp); - 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 -} - -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 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 -} - -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 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; - } - } - // 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; -#endif - 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 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 - return dirs; -} - -} // namespace mcpp::platform::macos diff --git a/modules/platform/src/platform.cppm b/modules/platform/src/platform.cppm deleted file mode 100644 index 7be8fc197..000000000 --- a/modules/platform/src/platform.cppm +++ /dev/null @@ -1,67 +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/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. -// -// 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. -// -// ─── HOW PLATFORM DIFFERENCES ARE EXPRESSED ──────────────────────────────── -// -// Preferred, in order: -// -// 1. `if constexpr (mcpp::platform::is_windows)` in the facade layer, calling -// into one implementation module per platform. 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 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. -// -// 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/process.cppm b/modules/platform/src/process.cppm deleted file mode 100644 index e2b3d7efa..000000000 --- a/modules/platform/src/process.cppm +++ /dev/null @@ -1,1534 +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; -#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 - -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. -inline void set_umask_022() { -#if !defined(_WIN32) - ::umask(022); -#endif -} - -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 defined(_WIN32) - mcpp::platform::winproc::set_interrupt_files(paths, temps, contents); -#else - mcpp::platform::unixproc::set_interrupt_files(paths, temps, contents); -#endif -} - -void clear_interrupt_files() { -#if defined(_WIN32) - mcpp::platform::winproc::clear_interrupt_files(); -#else - mcpp::platform::unixproc::clear_interrupt_files(); -#endif -} - -namespace { - -// 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 platform's module defines a class on every platform and -// empties the one that does not apply, so the uses below need no branch. -#if defined(_WIN32) -using LaunchSection = mcpp::platform::winproc::LaunchSection; -#else -using LaunchSection = mcpp::platform::unixproc::LaunchSection; -#endif - -// 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 defined(_WIN32) - return windows_wrap_for_cmd_c(cmd); -#else - return std::string(cmd); -#endif -} - -// 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)); -} - -int normalize_exit_code(int rc) { -#if defined(_WIN32) - return rc; -#else - if (WIFEXITED(rc)) - return WEXITSTATUS(rc); - // Shell convention for signaled children: 128 + signal number. The raw - // wait-status word only *happens* to look right when the core-dump bit - // is set (SIGSEGV+core → 0x8B = 139); without it a SIGTERM death would - // surface as "exit 15" and be indistinguishable from a normal exit code. - if (WIFSIGNALED(rc)) - return 128 + WTERMSIG(rc); - return rc; -#endif -} - -// 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 defined(_WIN32) - 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)); -#endif -} - -#if defined(__linux__) || defined(__APPLE__) -// Portable accessor for the host environment block. On Apple, `environ` is -// only linkable from executables (not dylibs), so _NSGetEnviron() is the -// sanctioned spelling; Linux keeps the plain `environ` symbol. -char** host_environ() { -#if defined(__APPLE__) - return *::_NSGetEnviron(); -#else - return environ; -#endif -} - -// 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; -} -#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) { - 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 = ::popen(cmd.c_str(), "r"); - } - 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(::pclose(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 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()); - } - } - 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); -#endif -} - -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 = ::popen(cmd.c_str(), "r"); - } - 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(::pclose(fp)); -} - -int run_passthrough(std::string_view command, std::string* output) { - auto cmd = finalize_shell_command(command); - std::FILE* fp = nullptr; - { - LaunchSection launch; - fp = ::popen(cmd.c_str(), "r"); - } - 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(::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 -} - -// 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_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; - 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 5d7e940b8..000000000 --- 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 dc731890b..000000000 --- a/modules/platform/src/scaffold_fs.cppm +++ /dev/null @@ -1,174 +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. - -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( - 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. -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 -} - -// 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 -} - -} // namespace mcpp::platform diff --git a/modules/platform/src/shell.cppm b/modules/platform/src/shell.cppm deleted file mode 100644 index 5807a7576..000000000 --- a/modules/platform/src/shell.cppm +++ /dev/null @@ -1,195 +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. - -module; - -export module mcpp.platform.shell; - -import std; - -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. -#if defined(_WIN32) -constexpr std::string_view silent_redirect = ">nul 2>&1"; -#else -constexpr std::string_view silent_redirect = ">/dev/null 2>&1"; -#endif - -} // 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) { -#if defined(_WIN32) - constexpr bool is_windows = true; -#else - constexpr bool is_windows = false; -#endif - 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 defined(_WIN32) - return quote_windows(s); -#else - return quote_posix(s); -#endif -} - -std::string quote_through_shell(std::string_view s) { -#if defined(_WIN32) - return quote_windows_through_cmd(s); -#else - return quote_posix(s); -#endif -} - -} // namespace mcpp::platform::shell diff --git a/modules/platform/src/terminal.cppm b/modules/platform/src/terminal.cppm deleted file mode 100644 index fcdc7060f..000000000 --- a/modules/platform/src/terminal.cppm +++ /dev/null @@ -1,581 +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 -#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 - -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; - -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 - -namespace mcpp::platform::terminal { - -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; -} -#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 -} - -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(); -} - -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; -#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 -} - -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 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 - 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 - return from_env("LINES", 24); -} - -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; - } - } -#endif - 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); -#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 - 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 -} - -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 - 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 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; - } - 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; - 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::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; -} - -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; - } - 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); - } -} - -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 - 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); -#if defined(_WIN32) - ::_dup2(saved_, 1); - ::_close(saved_); -#else - ::dup2(saved_, 1); - ::close(saved_); -#endif -} - -} // namespace mcpp::platform::terminal diff --git a/modules/platform/src/unix/bounded_process.cppm b/modules/platform/src/unix/bounded_process.cppm deleted file mode 100644 index 1177015e0..000000000 --- a/modules/platform/src/unix/bounded_process.cppm +++ /dev/null @@ -1,765 +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. - -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_* -#include // kill, SIGKILL -#include // errno, EINTR -#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; - -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 - -namespace mcpp::platform::unixproc { - -#if defined(__linux__) || defined(__APPLE__) - -namespace { - -char** current_environ() { -#if defined(__APPLE__) - return *::_NSGetEnviron(); -#else - return environ; -#endif -} - -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` 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 - -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 -} - -LaunchSection::LaunchSection() { -#if !defined(__linux__) - g_launchMutex.lock(); - held_ = true; -#endif -} - -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 - -#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/unix/terminal.cppm b/modules/platform/src/unix/terminal.cppm deleted file mode 100644 index 1bf097585..000000000 --- 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/bounded_process.cppm b/modules/platform/src/windows/bounded_process.cppm deleted file mode 100644 index f061ed6f9..000000000 --- a/modules/platform/src/windows/bounded_process.cppm +++ /dev/null @@ -1,824 +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`. - -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 - -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 - -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/windows.cppm b/modules/platform/src/windows/windows.cppm deleted file mode 100644 index 679b21fe5..000000000 --- a/modules/platform/src/windows/windows.cppm +++ /dev/null @@ -1,169 +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. - -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; - -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; - 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; -#else - (void)drive; - (void)child; - return false; -#endif -} -} - -// ─── Implementation ────────────────────────────────────────────────────── - -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; - 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; -#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) { - if (const char* v = std::getenv(name); v && *v) return std::filesystem::path(v); - return std::nullopt; -} - -unsigned active_code_page() { -#if defined(_WIN32) - return static_cast(GetACP()); -#else - return 0; -#endif -} - -} // 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 725a2cf68..000000000 --- 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 b6cf04212..6db08da8a 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 = "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 000000000..ce93dff73 --- /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 000000000..3a9fdd067 --- /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 c97ed9cf0..9f2dc1a60 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 b92d2272e..23870f190 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 000000000..36e48dd39 --- /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 0d6e38e02..bd47178ef 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 4517022d0..8807e4a28 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 { @@ -227,6 +228,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 +313,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 +371,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 +884,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; } @@ -1043,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. @@ -1116,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 @@ -1129,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. // @@ -1159,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; @@ -1189,6 +1210,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 +1306,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 +1437,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 +1498,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/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 c7497936f..bb0aa272a 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/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index ff99a01c2..77cb6edf1 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/bmi_cache.cppm b/src/bmi_cache.cppm index 2aace2001..f5c40ea36 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 3e4d3041c..41cf45e5b 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 fbdcdabb8..baaf0353d 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 f263620a4..a95553768 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/cache_key.cppm b/src/build/cache_key.cppm index 96b7f48e9..2e3047fd8 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/capacity.cpp b/src/build/capacity.cpp new file mode 100644 index 000000000..48fa2b699 --- /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/src/build/capacity.cppm b/src/build/capacity.cppm new file mode 100644 index 000000000..69f713dff --- /dev/null +++ b/src/build/capacity.cppm @@ -0,0 +1,54 @@ +// 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: +// +// MEMORY. A C++23 module compile is not cheap in RAM. Measured on this +// repository with GCC 16.1 at -O2: +// src/build/prepare.cppm peak RSS 1,057 MB +// src/build/plan.cppm peak RSS 561 MB +// ninja's default job count is `nproc + 2`. On a 64-core / 32 GB machine that +// is 66 concurrent compiles against ~0.5-1 GB each — the machine swaps, and a +// swapping build is far slower than a smaller job count would have been. The +// default is not merely un-tuned there; it is actively harmful. +// +// HETEROGENEITY. An i9-13900K reports 32 logical CPUs, but they are 8 P-cores +// (SMT, 16 threads) plus 16 E-cores. E-cores deliver roughly 40% of a P-core's +// compile throughput and SMT siblings roughly 25%. Treating 32 threads as 32 +// equal workers overestimates usable parallelism by more than 2x. +// +// 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.build.capacity; + +export namespace mcpp::build::capacity { + +struct HostCapacity { + int logicalCores = 1; + int physicalCores = 1; + // True when the CPU mixes core classes (Intel P/E, Apple performance + + // efficiency). Recorded rather than inferred: every parallelism figure has + // to be read against it. + bool heterogeneous = false; + unsigned long long totalBytes = 0; + unsigned long long availableBytes = 0; // falls back to total when unknown +}; + +HostCapacity host_capacity(); + +// Job count for `auto`. See the module comment for why this is not `nproc`. +// +// cpu_budget = heterogeneous ? physicalCores : logicalCores +// mem_budget = (available - reserve) / per_job +// jobs = clamp(min(cpu, mem), 1, ceiling) +// +// `perJobBytes` and `reserveBytes` are parameters rather than constants so a +// project whose translation units are heavier (or lighter) than mcpp's can say +// so without patching this file. +int recommended_jobs(const HostCapacity& cap, + unsigned long long perJobBytes = 768ull * 1024 * 1024, + unsigned long long reserveBytes = 2ull * 1024 * 1024 * 1024, + int ceiling = 64); + +} // 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 000000000..2ee1aca2b --- /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 000000000..8a99f580d --- /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 0811f5cb0..facde555d 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 957488510..71e1689aa 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 d3e6cc7bf..17de27a8f 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/engine_package.cppm b/src/build/engine_package.cppm index 9d4b10a18..012189f79 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 4c1692ca2..dc4f143ed 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; @@ -48,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; @@ -142,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 @@ -168,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); @@ -275,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 @@ -361,6 +366,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, @@ -398,6 +411,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); @@ -473,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)); @@ -573,6 +595,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 @@ -624,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, @@ -644,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); @@ -680,6 +706,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. @@ -726,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'; @@ -756,6 +783,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'; } @@ -830,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}; } @@ -855,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); }); } @@ -1043,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 " @@ -1073,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( @@ -1206,6 +1231,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 @@ -1227,19 +1258,24 @@ 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( + 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); @@ -1281,7 +1317,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(); @@ -1758,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; @@ -1822,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; } @@ -1842,6 +1880,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 @@ -2214,6 +2256,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/. @@ -2266,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"); @@ -2372,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; @@ -2396,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); @@ -2512,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"); @@ -2641,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)); @@ -2656,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(); @@ -2801,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( @@ -2899,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()) @@ -3638,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); @@ -3688,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(); }); @@ -3706,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 " @@ -3829,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( @@ -4017,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 345bff092..bf2acdd6c 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -20,11 +20,14 @@ 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; -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; @@ -68,6 +71,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 @@ -256,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; } @@ -492,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 @@ -530,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('$'); @@ -644,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); } @@ -716,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; @@ -752,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 @@ -1092,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; @@ -1124,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())); @@ -1183,8 +1190,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)); + } } } @@ -1345,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) ------------------- @@ -1454,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 @@ -1537,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; @@ -1768,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); @@ -2084,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. @@ -2451,6 +2467,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/hermetic.cppm b/src/build/hermetic.cppm index 27ee65942..20bddb846 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/hostprogram.cppm b/src/build/hostprogram.cppm index 1e197f123..18509cc30 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/loader_contract.cppm b/src/build/loader_contract.cppm index 6f8a392f3..bf608efcc 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 6da2340b8..71d597016 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -44,10 +44,16 @@ 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; -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; @@ -135,6 +141,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 @@ -159,6 +176,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. @@ -312,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 { @@ -571,14 +597,16 @@ 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 ""; 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; } @@ -589,9 +617,10 @@ 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(); } @@ -679,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"); } @@ -796,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)}; } @@ -877,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; @@ -894,6 +913,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 @@ -1341,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; @@ -1421,6 +1480,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 @@ -1457,6 +1528,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 @@ -1508,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))); @@ -1607,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"); @@ -1776,12 +1876,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 = [&] { @@ -1828,7 +1928,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 @@ -1870,7 +1970,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" @@ -1966,7 +2066,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", @@ -1998,7 +2098,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, @@ -2016,7 +2116,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, @@ -2153,15 +2253,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(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 (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"); @@ -2221,6 +2327,25 @@ 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 + // `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", + "$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. // @@ -2291,7 +2416,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; @@ -2324,7 +2449,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 @@ -2336,7 +2461,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"); @@ -2394,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); @@ -2440,11 +2565,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); @@ -2513,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"); } @@ -2534,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"); } @@ -2634,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 @@ -2843,7 +2967,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 @@ -2888,8 +3012,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) { @@ -2989,7 +3112,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 @@ -3054,7 +3177,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)); }; @@ -3094,10 +3217,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)); @@ -3147,10 +3270,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 @@ -3225,6 +3348,14 @@ 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); + // 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. @@ -3341,7 +3472,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) @@ -3378,7 +3509,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()) @@ -3396,8 +3527,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; @@ -3452,6 +3583,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 @@ -3497,6 +3633,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 @@ -4158,7 +4312,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: @@ -4387,7 +4541,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( @@ -4508,15 +4668,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}); } @@ -4537,12 +4697,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; @@ -4576,9 +4736,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)); @@ -4600,7 +4760,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) @@ -4640,7 +4800,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"); } @@ -4672,9 +4832,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)); @@ -4693,7 +4853,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); @@ -4720,7 +4880,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; @@ -4767,7 +4927,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 @@ -4893,6 +5053,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. @@ -4924,7 +5090,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 ad10be6dd..51acb5a50 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 fc4968715..161f0c187 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; @@ -200,6 +201,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 @@ -404,6 +413,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 @@ -481,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 @@ -521,6 +539,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). @@ -568,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 @@ -581,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 @@ -698,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? // @@ -810,6 +832,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 { @@ -939,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, }; } @@ -1049,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()); @@ -1282,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. // @@ -1334,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). @@ -1388,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_, @@ -1567,7 +1599,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 +1616,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; } @@ -1623,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{}; @@ -1637,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 {}; @@ -1678,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 @@ -1764,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 = {}) { @@ -1857,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); @@ -1986,7 +2022,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; @@ -2313,17 +2349,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); } } @@ -2563,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 @@ -2802,6 +2842,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 @@ -2813,6 +3095,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); @@ -2876,12 +3161,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"` @@ -2982,6 +3278,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). @@ -3108,6 +3432,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; @@ -3237,6 +3583,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) { @@ -3342,7 +3689,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; @@ -3553,8 +3925,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.cppm b/src/build/prepare.cppm index 6ccfcb5bb..186b999e1 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 c5ee49503..3750bd914 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 c4705c3aa..f2345818a 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; @@ -416,6 +415,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()) { @@ -476,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) { @@ -521,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 447108744..0e0ec7bee 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 fee3e1e3a..8bd7e9c60 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 { @@ -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) @@ -967,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()); } @@ -1206,6 +1207,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; + // 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))) { @@ -1347,8 +1352,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; @@ -1410,7 +1415,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); @@ -1511,7 +1516,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) @@ -1573,8 +1578,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 eed014cc9..17da94dec 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 f58dd5cd7..771d6774f 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; @@ -381,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={})", @@ -757,8 +745,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 +783,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 +857,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{ @@ -1437,13 +1461,20 @@ 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; 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 @@ -1459,11 +1490,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{}", @@ -1528,29 +1556,48 @@ 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. // - // `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. - auto cloneCmd = (spec.gitRefKind == "branch" && !fromLock) - ? std::format( - "git clone --progress --depth 1 --branch {} {} {} && " - "git -C {} checkout --quiet {} 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)) - : 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)); + // 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 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( @@ -1559,19 +1606,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; @@ -1840,13 +1894,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/graph_load.cpp b/src/build/prepare/graph_load.cpp index b4e004c19..ee28f0e9c 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; @@ -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) { @@ -644,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; " @@ -923,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 " @@ -1012,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 51fc852a2..86056eeeb 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 0ebc841bf..4623c720d 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 85869f776..c852a002f 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 7f67949d3..2807fceea 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 b9ff7cff0..7dce6c016 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; @@ -17,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; @@ -45,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; @@ -62,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; @@ -225,6 +225,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 @@ -445,6 +453,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 @@ -493,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 " @@ -582,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) { @@ -653,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, @@ -740,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( @@ -1005,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; @@ -1155,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 @@ -1448,6 +1482,36 @@ 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::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. + 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) { + 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)) { if (auto sd = mcpp::toolchain::clang::find_scan_deps(*state.tc)) { @@ -1601,6 +1665,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 +1688,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 +1758,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( @@ -2083,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 1ea35be26..7830d89e5 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; @@ -192,6 +190,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. @@ -275,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( @@ -416,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(); @@ -454,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 4e4a9e5e7..53e4081a9 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); @@ -399,6 +398,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 @@ -489,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. // @@ -525,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 @@ -567,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; } @@ -713,6 +718,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())); @@ -843,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); @@ -859,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); @@ -897,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); @@ -919,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 aaf265f55..4e0919103 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 1bda1fa11..b36b70f3a 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; @@ -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; @@ -776,6 +780,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; @@ -964,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 c6b8fdd75..380c4cc78 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) @@ -262,6 +260,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 +462,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 +626,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 +1202,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" @@ -1456,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; @@ -2369,7 +2379,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/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index bfbbe11af..13a46e644 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 " @@ -1061,6 +1064,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" @@ -1200,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 @@ -1377,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()) @@ -1399,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)); } @@ -1642,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( @@ -1662,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" @@ -1719,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)); @@ -2100,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()); } } } @@ -2255,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; } @@ -2310,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)); } @@ -2418,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 ba710238e..7981be6dd 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 70e9861b5..e17a95e49 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 098fde0d1..7988f52c4 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 bc18afbeb..4445f3380 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 2ed984355..059856118 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 0875d89f8..77b637dd5 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 d148e8866..d384f06ae 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 { @@ -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"; @@ -270,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 1aced3407..e8403ae9e 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 a84b4f839..bb8f5f838 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 { @@ -41,11 +42,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 +148,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 +213,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; @@ -236,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)); @@ -248,9 +276,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(); } } @@ -343,7 +371,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; } @@ -355,7 +383,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 09c1d61f7..7b9b0587b 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 3df3c0d6f..4659edb9c 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 32ce5da07..6ec0e2fed 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; @@ -164,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; } @@ -329,147 +325,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 +398,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 +554,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 428080c55..8fe445cef 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/stage.cppm b/src/build/stage.cppm index b9b175f3b..34350c263 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/src/build/symbol_provision.cppm b/src/build/symbol_provision.cppm index 4cf48da48..dd99a8437 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/build/test_targets.cppm b/src/build/test_targets.cppm index f872780b4..7b0b37c3e 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/cli.cppm b/src/cli.cppm index cae0453c1..e9a7fb9fb 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 ──────────────────── @@ -1207,6 +1212,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")) @@ -1343,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; @@ -1427,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; }); @@ -1437,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; @@ -1455,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)) @@ -1493,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. @@ -1516,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)) { @@ -1547,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)) { @@ -1676,7 +1687,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; @@ -1720,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 d52898678..d0d729de5 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,62 @@ 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 -- […]` +// (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::run::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) { @@ -1481,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) { @@ -1504,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 @@ -1536,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; @@ -1568,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. @@ -1603,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; @@ -1661,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); @@ -1714,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"); @@ -1742,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 @@ -1761,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 78d2b2020..89509e2f8 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 8419dca67..bbdad5985 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 01e08ca1c..9cbcb44d5 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 e2dec5b8b..f834615b3 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 907e3d5f3..81c422c9a 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 000000000..ecb290efb --- /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 000000000..eceec0842 --- /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 f8b3e72a7..be854ba23 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 51e020f09..b4656643b 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 f21574e34..725df93db 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 eba473112..de197d673 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 000000000..fea364db7 --- /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 (src/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 000000000..c074f81f9 --- /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 (src/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 (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); +void unlock_file(std::intptr_t handle); +} // namespace mcpp::home::native diff --git a/src/hooks.cppm b/src/hooks.cppm index 2e529e22a..ddec2e1cb 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 648dc0462..5147635ac 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,8 +5,14 @@ import std; import mcpp.cli; import mcpp.ui; import mcpp.home; +import mcpp.run; +import mcpplibs.os.platform; int main(int argc, char* argv[]) { + // 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. // // Without this, progress output is block-buffered and the block SIZE is @@ -37,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 @@ -52,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 c51d36e46..e11e0081a 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/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 0e8a7048e..711b69148 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/src/native/posix/lock.cpp b/src/native/posix/lock.cpp new file mode 100644 index 000000000..3f8c7fbb5 --- /dev/null +++ b/src/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/src/native/windows/lock.cpp b/src/native/windows/lock.cpp new file mode 100644 index 000000000..88e81752f --- /dev/null +++ b/src/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/okimage/okimage.cppm b/src/okimage/okimage.cppm new file mode 100644 index 000000000..4f9490900 --- /dev/null +++ b/src/okimage/okimage.cppm @@ -0,0 +1,124 @@ +// 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; +// -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. +// -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. +// -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", + "-Xclang", "-fstack-clash-protection" }; + 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, 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" + " --gc-sections --eh-frame-hdr"; +} + +// 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/library.cppm b/src/pack/library.cppm index 0acbef14f..5c56a9811 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 0f1ae3e06..58a3dcc62 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 79fbbd2f6..873cc30c3 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -47,10 +47,16 @@ 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; +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; @@ -245,6 +251,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 +661,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 @@ -724,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 ────────────────────────────── @@ -806,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; @@ -889,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 {}; @@ -911,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 {}; @@ -1405,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 {}; } @@ -1970,6 +1973,211 @@ 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. + // + // 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 its [build-dependencies] declare 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())}); + // 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); + 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 = 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" + : "\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_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), &options, 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::invocation::self_exe_path().string(), "pack", + "--format", "dir", "-o", "packed" }; + 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::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)}); + 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::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) { + 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(mcpplibs::os::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 +2367,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 @@ -2195,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) @@ -2218,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: - // - // /xim-x-glibc/2.44/lib64 : /xim-x-gcc/16.1.0/lib64 - // : /compat-x-glx-runtime/…/lib : $ORIGIN + // Search path: point at bundled libs, or REMOVE THE TAG if there are none. // - // 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. + // 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. // - // 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 6a30360a8..88bb98060 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/route.cppm b/src/pack/route.cppm index 6cb3810b9..5c6fc72f0 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/src/pack/strip.cppm b/src/pack/strip.cppm index 6707ebb51..81c897714 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 712d8af4f..ededc7cc1 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 1cc429028..93833fa6b 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 cadbff123..558f84764 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 d417f684a..6853aa553 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 b71dd6efe..cee5429bf 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 3cb438e0e..9854ff609 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 b6bf660f0..d193ca644 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 101894f8d..d96d3c1a8 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/project.cppm b/src/project.cppm index d8c1cd603..e3eb80741 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" @@ -761,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/src/provider/xlings/provider.cppm b/src/provider/xlings/provider.cppm index fce592e8a..fc03d26db 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 0207d32cb..877f68d6e 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 000000000..9d7f3d4c9 --- /dev/null +++ b/src/provider/xlings/xlings.cpp @@ -0,0 +1,2047 @@ +// 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); + // 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); + 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 059d37e20..c92bcadea 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 b4623a52b..4707cd6b5 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 000000000..ecc08dd04 --- /dev/null +++ b/src/run.cppm @@ -0,0 +1,204 @@ +// 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); + +// 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 +// 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 000000000..350be835f --- /dev/null +++ b/src/run/run.cpp @@ -0,0 +1,596 @@ +// 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; + // 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); + 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(); +} + +bool ending() { return g_finishing.load(); } + +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 de8349de7..e09d71c3b 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 93988b911..12d38d139 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 456e85c10..035033965 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 000000000..b556f561a --- /dev/null +++ b/src/runtime/launch_env.cpp @@ -0,0 +1,66 @@ +// 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; +import mcpp.provider; +import mcpp.provider.select; + +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)) { + // 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); +} + +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 000000000..95554796e --- /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 2ae81cbc1..2a6bcf030 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 c47a76a4f..33dde9a0a 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 d548a8194..0e1726ca8 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 a6f121f0e..967e18918 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 fbbf01e5b..e21ceb64a 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 a9fa0977d..cbf26b1e6 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 000000000..04b556ce1 --- /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 9c39ef79f..a68b994d4 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 92065ca73..99fa43096 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 bc77c6f69..34854c82a 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 f44f4ddf8..b711fbf64 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 d1c646a6b..4f35871f2 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 689a3e657..a1809bf61 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 e56744bc0..1ad87118e 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 78472a477..101faeacc 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 405171a99..312176018 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 ecc7ab8af..eb454202a 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 db14ee1a3..df24801da 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 d95b54461..49b907536 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 @@ -1439,6 +1440,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; } @@ -1446,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 }, @@ -1458,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 @@ -1468,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; @@ -1498,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 {}; @@ -1507,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 {}; } @@ -1539,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 9ac7da141..564be3826 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 fafc589c9..a119a8d46 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 000000000..bf6a0747b --- /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 000000000..7d15560af --- /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 b14b59315..8e1d871c6 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/e2e/652_an_application_is_a_kind.sh b/tests/e2e/652_an_application_is_a_kind.sh index 084d978cc..0c7052fe1 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 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 a061d927a..f6775a932 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/872_a_member_compiles_once_whatever_the_selection.sh b/tests/e2e/872_a_member_compiles_once_whatever_the_selection.sh index 8463922c5..e5505278b 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 000000000..1240b9c5e --- /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/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 000000000..1c0e807e7 --- /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" 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 000000000..c6d64f5ee --- /dev/null +++ b/tests/e2e/942_an_ok_image_is_built_once_and_run_by_okrun.sh @@ -0,0 +1,228 @@ +#!/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, and bin/ holds +# no unchecked image. +# B. `mcpp run` on the row of this machine's architecture runs the image +# 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. +# 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), 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). +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 = { version = "0.1.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); +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); +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"); + put(kal_env_var_count() > 0 ? "image: an environment\n" : "image: no environment\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(' /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 +"$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" + +# 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 +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 +before=$(cksum < "$(ls target/"$HOST_ARCH"-openkal/*/bin/app.oke | head -1)" ) +# 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*); +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 +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" + +# 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" 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 000000000..feb819a1b --- /dev/null +++ b/tests/e2e/943_a_transitive_git_branch_is_locked.sh @@ -0,0 +1,104 @@ +#!/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.) +# 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) +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" + +# 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" 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 000000000..dd1b5b07b --- /dev/null +++ b/tests/e2e/944_a_root_checkout_answers_a_version_the_index_lacks.sh @@ -0,0 +1,100 @@ +#!/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 +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 + 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_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_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_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 +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" 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 000000000..0b5d11dd6 --- /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" 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 000000000..db3329f08 --- /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 000000000..0bc7b652f --- /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/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 000000000..d71422d3e --- /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/matrix/expected.tsv b/tests/matrix/expected.tsv index bfc955c55..e8b671ebf 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 diff --git a/tests/unit/test_artifact_naming.cpp b/tests/unit/test_artifact_naming.cpp index fa8b24c63..e14b539a0 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 ─────── @@ -245,5 +246,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_build_database.cpp b/tests/unit/test_build_database.cpp index fa00df157..66a7a7306 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_directives.cpp b/tests/unit/test_build_directives.cpp index eb3c353fc..1cfc561ce 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_build_profile.cpp b/tests/unit/test_build_profile.cpp index cf7899b9e..e2e7fae3c 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 d3f6e7682..6d8369907 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_build_stage.cpp b/tests/unit/test_build_stage.cpp index 1eb9229f4..8823129e2 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_cache_key.cpp b/tests/unit/test_cache_key.cpp index 5d7b33f8c..cf10c9c6b 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_capacity_jobs.cpp b/tests/unit/test_capacity_jobs.cpp index 34d01a9bc..391f71f54 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 946b50c5a..ab6e40147 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 d72e3be89..fbbe0df47 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_detach_status.cpp b/tests/unit/test_detach_status.cpp new file mode 100644 index 000000000..43df4a2b4 --- /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); +} diff --git a/tests/unit/test_dyndep.cpp b/tests/unit/test_dyndep.cpp index 59abe5644..1a9280e12 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_elf_runtime.cpp b/tests/unit/test_elf_runtime.cpp index 6bbfe8ad9..fee1db493 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 06e4eac96..f633179eb 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 bbb2ff09a..872cb479a 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 acc504fd9..d8ab38e14 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 1862b219d..d6f513a64 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 13a137094..acafd8eeb 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(); @@ -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; } @@ -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()); @@ -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_module_address.cpp b/tests/unit/test_module_address.cpp index 4cec2dd12..cc9341970 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_network.cpp b/tests/unit/test_network.cpp index 219fc16d3..46f677880 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 a836c69ee..574d81fa7 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; @@ -8,11 +9,12 @@ 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; -import mcpp.platform; -import mcpp.platform.runtime_search; +import mcpplibs.os.platform; +import mcpp.runtime.search; import mcpp.targetside; using namespace mcpp::build; @@ -77,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(); @@ -111,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); @@ -180,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; @@ -380,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; } @@ -404,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); @@ -568,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; @@ -607,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, @@ -658,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, @@ -889,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"); } @@ -928,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 @@ -1027,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(); @@ -1100,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; @@ -1115,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; @@ -1653,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 { @@ -1895,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); @@ -2971,3 +2967,96 @@ 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) { + // 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 = 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"; + + 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()); +} + +// 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", {}), ""); +} diff --git a/tests/unit/test_pack_archive_remove.cpp b/tests/unit/test_pack_archive_remove.cpp index 464276c70..a3a718703 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 00d892f70..7933dbb9c 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 572316e9b..7822fc7e0 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; @@ -39,19 +26,26 @@ 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"})); + 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 @@ -68,8 +62,8 @@ 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), - join({"/payload/lib", "/first", "/last"})); + EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), + join({host("/payload/lib"), host("/first"), host("/last")})); } // A payload dir the CALLER passed is the entry the sandbox binary needs — it @@ -80,8 +74,8 @@ 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), - "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64"); + 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")); } // PATH is not a loader search path: leave its inherited value alone. @@ -90,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), - join({"/tools/bin", "/bin", "/x/xpkgs/xim-x-glibc/2.39/lib64"})); + 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), - join({"/a", "/b"})); + 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 e11b4417f..14386994a 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 9c361be3b..9dc788b01 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 f8a7a2d37..69668bec4 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 2d90e4767..62e74a1fe 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 36db0d4f6..2abb0b583 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 f564bedee..62097b77a 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 7cb2e0fc3..aaf6d09b5 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 67% rename from modules/platform/tests/test_process_concurrent_children.cpp rename to tests/unit/test_run_concurrent_children.cpp index 69f2d33d6..8dafbed17 100644 --- a/modules/platform/tests/test_process_concurrent_children.cpp +++ b/tests/unit/test_run_concurrent_children.cpp @@ -1,13 +1,8 @@ #include -#if !defined(_WIN32) -#include -#include -#endif - import std; -import mcpp.platform.process; -import mcpp.platform.unix.bounded_process; +import mcpplibs.os; +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,25 +14,23 @@ 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 { 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{}; @@ -74,8 +67,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 @@ -89,17 +82,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 405ea8b23..5b89c3ea3 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 0aeac2b2d..e978aef92 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 dd1bb435d..0b845520b 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 222ce45ad..9d346b472 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 c0c1abdba..92893bf3e 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_select_sources.cpp b/tests/unit/test_select_sources.cpp new file mode 100644 index 000000000..b39a90c17 --- /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" })); +} diff --git a/tests/unit/test_subos_info.cpp b/tests/unit/test_subos_info.cpp index 9aee8f159..66daef297 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 a0db4eeb7..f31f44832 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 9a6eb42e9..c2ed0bc1b 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 784658869..921c710ce 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 023665c14..9ea01d539 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 4ab1cc0e6..8e62d4fba 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 eac7407f2..9cedb79cd 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 27c6b5a83..297feb579 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 c57026fc8..0f2953d94 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 e3bd28550..6bfcb9dfb 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_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index d6a8114da..b0adb5524 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"); +} diff --git a/tests/unit/test_ui_streams.cpp b/tests/unit/test_ui_streams.cpp index 80f529257..aa5f9ccd0 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 656a1d72a..7a004df1b 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 002022302..aa6d18905 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_workspace_inheritance.cpp b/tests/unit/test_workspace_inheritance.cpp index 74f4d5a52..a22ae5249 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; +} diff --git a/tests/unit/test_xlings.cpp b/tests/unit/test_xlings.cpp index 27c910b3e..0101c25ca 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 3df8c7272..6a39f0b4e 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 74eb1d5c6..d335724c7 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 50b695e6b..8524b69a4 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;