diff --git a/.agents/docs/2026-10-19-first-run-home-and-network-design.md b/.agents/docs/2026-10-19-first-run-home-and-network-design.md new file mode 100644 index 000000000..78f768702 --- /dev/null +++ b/.agents/docs/2026-10-19-first-run-home-and-network-design.md @@ -0,0 +1,698 @@ +--- +subject: home +status: active +--- + +# 首次初始化、home 重定向与网络诊断优化设计 + +状态:按最新反馈修订;R2、R3、R4、R5、R7 已获用户认可,R1 与 R6 的两阶段优先级及 Windows 排版已明确。设计语义与实现验收分开记录,具体进展见第 12 节。 + +执行状态:用户已授权完整实现、单功能 PR、全 CI、自我 review 后 squash 合入和发布。以下为实施中的验收账本,不将草稿或发布版探针作为新功能已完成的证据。 + +| 任务 | 前置依赖 | 完成证据 | +|---|---|---| +| T1 home 两阶段解析与单跳、原子入口更新 | 现有路径/文件替换/锁能力 | 三平台解析与事务单测;真实 CLI 重启与 force 哨兵 | +| T2 只读配置与本地初始化、bootstrap 分离 | T1;provider 配置接口不联网 | 冷 doctor/env/list/config 无下载;缺 provider 继续诊断 | +| T3 init --home、Windows 两项菜单与来源报告 | T1、T2;驱动器/输入终端能力 | Windows 原生终端与非交互对照,Linux/macOS 无菜单 | +| T4 有界资源探测与下载进展/取消 | T2;核实 xlings 现有接口和 low-speed 能力 | 可控端点覆盖 DNS/代理/TLS/停滞/持续进展与进程树清理 | +| T5 文档、规范、升级与版本 | T1—T4 行为稳定 | 中英文规范/手册一致;版本 pin 与兼容检查 | +| T6 单 PR 完整 CI 与综合 review | T1—T5、适用单测/E2E 通过 | 当前 PR head 的实际 CI 结果及 review 记录 | +| T7 squash、release 与 GitCode 补齐 | T6 全部通过 | 合入 SHA、tag、四平台签名校验资源、GitCode GET 核验 | +| T8 xlings 生态真实验证 | T7 资源与索引传播 | 精确版本和 latest 安装、冷/暖/离线使用及各分发验证 | + +T1 与 T2 为当前实施入口;T3/T4 共享底层但不互相依赖。版本在发布前按当日空闲序号确定,bootstrap pin 不领先于实际已发布并入索引的版本。尽量在一个 mcpp 功能 PR 中完成;如 provider 缺少必要接口,记录证据后处理上游依赖,不伪造 mcpp 已实现下载层能力。 + +读者:实现和评审首次运行、home 选择、网络诊断及跨平台 CLI 行为的维护者。 + +本文回答:如何记住自定义 home、保留已有路径选择和初始化语义,并使首次联网等待可诊断、有界且不破坏离线能力。 + +相关文档:[home、索引与 store](../../docs/zh/25-home-indices-and-store.md)、[home 解析实现](../../src/home.cppm)、[配置初始化实现](../../src/config.cppm)。 + +## 1. 范围与证据 + +代码基线为 main `c0690412e04b59e424c84fe01f171b8e84e4f748`。运行探针使用校验 SHA-256 的 `2026.10.17.1` release,其 tag commit 为 `684a306bced037014da08b038412f57c203d0bc9`。release 与 main 不是同一个 commit;探针不能冒充 main 的 fresh build 验证。 + +追踪:[issue #822](https://github.com/mcpp-community/mcpp/issues/822)。测试载体:[临时 draft PR #823](https://github.com/mcpp-community/mcpp/pull/823),仅包含探针与执行它们的工作流,不含产品实现,不计划合入。[最终 Actions 运行](https://github.com/mcpp-community/mcpp/actions/runs/39184402622),测试 commit 为 `a736c64ca94fc013aed39ffb10d8346c569c45ad`。 + +本设计覆盖普通用户 home 的持久选择、Windows 首次目录选择、初始化阶段、网络检查、下载停滞处理与 doctor。`--system` 不采用用户重定向文件;实际 home 继续由系统配置决定,`--system self init --home` 拒绝,避免更改用户默认位置。 + +CI benchmark 与 #816 是同轮调查发现的独立缺陷,见第 10 节;不混入 home 与网络功能实现。 + +### 1.1 源码事实 + +| 位置 | 当前行为 | 影响 | +|---|---|---| +| `src/home.cppm::root` | 环境变量、自包含目录、用户目录回退 | 尚无 locator 与 `self init --home` | +| `src/cli/cmd_self.cppm::env_data_readonly` | JSON env 只读解析路径 | 已有可复用的只读方向;human env 仍初始化 | +| `src/config.cppm::load_or_init` | 读取配置时同时初始化 sandbox、安装 ninja;ELF 平台还安装 patchelf | 首次查询、修改配置与诊断可进入联网流程 | +| `write_default_xlings_json` | 新配置默认 `auto`,已有配置保留原 mirror | 自动选择已有入口;不能把改默认值当成完整修复 | +| `src/doctor.cppm::doctor_report` | 初始 `load_or_init` 可联网;后续构建规划已有离线约束 | 后续只读规划不能阻止入口 bootstrap | +| `src/doctor.cppm::self_config` | 先 `load_or_init`,再更新已有 mirror | 用户切源前可能被旧源初始化阻塞 | +| `src/provider/xlings/xlings.cppm::install_with_progress` | 直接安装优先,输出丢弃,外层总上限 3 小时;之后再走接口回退 | 看不到等待原因;切换安装接口不等于切换源 | +| 同文件接口安装 | 300 秒输出静默限制,接口会发 heartbeat | heartbeat 不能证明下载字节仍在进展 | +| 同文件 `ensure_init` | sandbox 初始化子进程总上限 600 秒 | 名义本地操作也可能因首次索引获取等待;不能只在 ninja 下载前探测 | +| `src/doctor.cppm::self_init` | 裸 `--force` 清理 `registry/cache/build-cache/bmi` 后初始化 | 保持现有破坏性语义,新增路径选择不能意外继承删除 | +| `install.sh` | 使用 `MCPP_PREFIX`;若干请求缺少连接或无进展上限 | 安装脚本也有等待缺口;现有入口不能静默改名 | + +### 1.2 运行结果 + +本地 Linux 探针已完成:显式 home、自包含 home、dev 布局回退及 JSON env 不写入通过;离线 doctor 0.615 秒完成并到达后续 build policy 检查;离线 force init 返回 1,但四类目录中的测试哨兵均被删除,其他目录保留。故初始化失败并不撤销已有 force 清理。 + +注入 `127.0.0.1:9` 的 HTTP/HTTPS/ALL_PROXY 后,冷 doctor 和冷 `self config --mirror GLOBAL` 均进入 bootstrap,并在外部观察上限 12 秒时仍未退出。前者种下 `auto`,后者种下 `GLOBAL`。这说明卡住与 mirror 已经正确写入可以同时发生。 + +最终 Actions 的三平台 baseline 和模块对照共四个 job 均通过。绿色表示现状断言成立且观测成功,不表示下表中的联网等待或模块错误已经修复: + +| runner | home 优先级、只读 JSON env 与 force 哨兵 | 离线 doctor | 冷 doctor + 拒绝代理 | 冷 config GLOBAL + 拒绝代理 | +|---|---|---|---|---| +| ubuntu-24.04 | 通过 | rc 0,0.565 秒 | 超过 12.002 秒,测试器终止 | 超过 12.002 秒,测试器终止 | +| windows-2025 | 通过 | rc 0,1.094 秒 | 超过 12.047 秒,测试器终止 | 超过 12.032 秒,测试器终止 | +| macos-15 | 通过 | rc 0,0.722 秒 | 超过 12.014 秒,测试器终止 | rc 0,约 12.08 秒;bootstrap ninja 失败后仍写入配置 | + +外部观察上限设为 12 秒,表中记录实际 wall time,包含轮询和清理开销;macOS config 在等待器观察到自然退出时略超过该值,不能据此声称产品有严格 12 秒期限。 + +Windows 两个冷命令日志均停在 sandbox `Initialize`;Linux 已进入 patchelf 安装;macOS doctor 已继续到 package home 检查。相同注入下的等待点不同,不能只修某一个下载函数便宣称闭合。 + +Windows runner 的 D 盘类型为本地固定盘,临时目录写入成功。这只验证检测事实的一个正例,不代表目标 `D:\mcpp` 已验证,也不代表原生菜单已验证。新 locator、Windows 菜单和 5 秒探测尚无实现,不属于此次现状探针已通过的功能。 + +首轮模块对照 job 失败于测试夹具:setup 导出的 MCPP 为 workspace shim,在临时项目目录中报告 mcpp 未安装;三种模式均未进入真实编译。测试改用固定发布版真实二进制后重跑成功,最终对照见第 10.2 节;[首轮](https://github.com/mcpp-community/mcpp/actions/runs/39184064081) 不能作为 #816 复现证据。完整 CI 也被测试文件触发;本次不合入,仅取专用探针结果,因此取消该临时 PR 的重复全量 CI,不将取消计作通过。 + +故障注入只覆盖拒绝代理连接,不等价于完整断网,也不覆盖 DNS 阻塞、TLS、重定向后停滞、丢包和真实 CN 用户线路。离线探针检查后续诊断和未创建 bootstrap/index 状态,并未进行操作系统级网络流量审计。 + +## 2. 原始 home、真实 home 与单跳入口 + +### 2.1 优先级与兼容边界 + +解析分为两步,不能把两步合成一张优先级列表。 + +第一步选择原始 home(入口位置),沿用现有顺序:非空 `MCPP_HOME` → 可识别的自包含安装目录 → 用户目录下的 `.mcpp`。`MCPP_HOME` 在这一步优先级最高,`--home` 不改变原始 home 的选择。 + +第二步选择真实 home(本次实际存放配置、工具链与缓存的位置):`self init --home ` 的显式目标 → 原始 home 下 `home.toml` 的直接目标 → 原始 home 本身。 + +默认情况下没有重定向文件,也没有 `--home`,原始 home 与真实 home 是同一个目录。`MCPP_HOME` 不因为引入真实 home 概念而自动变成另外一个路径,不新增第二个真实 home 环境变量。 + +入口文件为 `<原始 home>/home.toml`。只有原始 home 采用默认用户目录时,它才是 `~/.mcpp/home.toml`;显式 `MCPP_HOME` 与自包含安装不会读取另一套用户默认入口。只有在自己的原始 home 中显式保存重定向后,它们才使用另一真实目录。这修正前版“永远写 ~/.mcpp,再提示被环境变量遮蔽”的方案,避免保存了一个日常命令实际不用的设置。 + +| 使用条件 | 原始 home | 无重定向时真实 home | 显式保存到 E 后 | +|---|---|---|---| +| 未设置环境变量、非自包含 | `~/.mcpp` | `~/.mcpp` | `~/.mcpp/home.toml → E` | +| `MCPP_HOME=D` | D | D | `D/home.toml → E`,环境变量仍为 D | +| 自包含安装在 P,未设置环境变量 | P | P | `P/home.toml → E`,程序仍位于 P | + +`--home` 仅为 `self init` 的选项,本轮不扩展为全局选项。本次目标通过统一 home 上下文传递;不持久写环境变量,也不在进程中把 `MCPP_HOME` 改成真实 home 再次解析,以免下一次读取误把目标当入口、出现第二跳。自包含判断的 `target` 和 `data/xpkgs` 排除规则保留。 + +用户目录继续采用当前平台规则:Windows 优先 `USERPROFILE`,然后 `HOME`;POSIX 使用 `HOME`。用户目录无法解析且没有其他原始 home 来源时,不写永久 locator,显式报错;保留现有无 locator 情况的工作目录回退行为,以免顺手更改其他路径契约。 + +### 2.2 文件格式与单跳 + +例如用户入口文件为: + +```toml +# C:/Users/alice/.mcpp/home.toml +home = "D:/mcpp" +previous_home = "C:/Users/alice/.mcpp" +``` + +`home` 为必填绝对路径。`previous_home` 为可选诊断元数据,只保存上一次持久默认位置,不是另一个跳转入口,也不是待自动清理列表。记录它的原因是 D→E 切换后,仅扫描原始 home 无法报告 D 中保留的数据。用户已认可 R3;该字段不扩展为多条历史记录。 + +解析器只读原始 home 下的入口文件一次。目标目录中的同名文件不参与解析;本次显式 `--home` 目标中的 locator 也不解释。显式环境变量和自包含目录如果被选为原始 home,则只解释该目录自己的入口。不展开 TOML 中的 shell 变量,不执行命令。命令行相对路径在入口处转为绝对路径后保存;输出路径以平台可读形式呈现,文件内容与机器输出使用正确编码。 + +不存在、不可读、格式错误或不是目录的保存目标:普通命令报带入口路径和目标路径的错误,禁止回退后创建另一套 home。未被选中的原始 home 中的 locator 不读取,其损坏不影响当前来源。`self init --force --home P` 可在确认原始 home 后修复损坏的 locator,不要求先成功解析旧目标;无法确认旧位置时不编造 `previous_home`。 + +切回原始 home 本身时移除 locator,而不是写出指向自己的 locator。实现按路径身份识别同一路径,涵盖 Windows 大小写、分隔符、junction 与 POSIX symlink 别名。相同目标视为幂等;拒绝把原始 home 重定向到自己的子目录或祖先目录,避免入口与真实布局相互包含;不以字符串前缀判断。 + +原始 home 已经有旧数据时保留数据。新用户采用用户默认入口并选择外置 home 后,入口目录只有 locator;已有用户或自包含安装可能同时保留旧配置、registry、缓存和程序文件,不能自动清空。 + +## 3. 初始化、切换与 force + +### 3.1 命令矩阵 + +| 命令与状态 | 结果 | 删除行为 | +|---|---|---| +| `self init`,已有当前 home | 幂等检查、补全当前 home | 无 | +| `self init --home P`,无已初始化 home | 本地初始化 P,保存用户默认,按需 bootstrap | 无 | +| `self init --home P`,与当前/已存默认为同一路径 | 幂等采用 P,必要时保存指向 | 无 | +| `self init --home P`,已有不同的初始化 home | 退出 1,给出当前位置、目标及 `--force --home` 提示 | 无 | +| `self init --force --home P` | 覆盖保存的默认指向,采用或补全 P | 不删除旧 home,也不清空 P | +| 裸 `self init --force` | 保留现有重建语义,作用于本次解析出的 home | 清理现有四类目录 | +| `--offline self init --home P` | 本地配置与默认指向正常建立,不探测、不下载 | 无 | + +存在空目录或 locator 不等于已经初始化。现有 home 的识别依据可读取的 mcpp 配置和可识别的 mcpp 布局;布局不完整也属于已建立的 home,应提示修复而非假装首次运行。不能通过联网判定是否已有 home。 + +检查既有 home 时,同时区分原始 home、未应用 `--home` 前的真实位置与已保存目标。显式环境变量的值、自包含程序的位置不被改写;本次明确的 `--home` 操作在该原始 home 下保存默认真实位置。 + +目标已有合法 mcpp home 时验证后复用;目标是不可写目录、非法路径或与既有布局不兼容时拒绝。`--force --home` 不授权清空不兼容目标。与当前目标相同的 `--force --home` 仍为非破坏性路径选择,不转为裸 force 的删除逻辑。 + +### 3.2 持久写入时机 + +状态转换为:解析与冲突检查 → 建立目标本地配置 → 原子保存默认位置 → 按需网络/下载 → 校验。 + +路径或本地配置验证失败时不改 locator;网络失败或 Ctrl+C 发生在保存后时,保留已保存目标和可恢复的本地状态。报告 bootstrap 未完成,下一次继续使用新目标。离线缺少必需工具时退出 1,并明确区分“默认位置已保存”和“初始化未完成”。 + +locator 同目录临时写入,再原子替换;使用原始 home 处的锁串行化多个进程的默认位置更新,替换前重读并检查冲突。需要保存或覆盖入口时,先验证原始 home 的入口可写性,再建立新目标,避免在不可写自包含目录下初始化到最后才发现无法保存。保存本身失败时仍保留旧文件;新目标已建立的本地文件不自动回滚删除,并明确报告未切换。已有入口的普通读取不要求原始 home 可写、不创建锁文件、不联网;不修改 shell profile、用户环境变量或系统配置。 + +本轮不新增 `self reset`,不删除旧 home,不进行数据迁移。 + +### 3.3 只在相关操作输出提示 + +新提示限定在:发生选择/覆盖的 `self init`、用户主动查看的 `self config`、`self doctor`;`self env` 的路径来源是其报告字段。普通 build/run/search/help/version 不重复输出 home 重定向提示。 + +裸 force 仅在当前 home 来自 locator 时补充真实清理目标和入口保留说明;未经过重定向的裸 force 保持当前输出与行为。`--home` 保存成功时显示原始 home、真实 home 和入口位置;不再沿用前版“保存后被同一个 MCPP_HOME 遮蔽”的提示,因为该环境变量决定的正是本次入口。相关说明只在设置或主动诊断时显示,其他命令不反复提示。 + +```text + Home D:\mcpp + Origin C:\Users\alice\.mcpp\home.toml + Warning --force rebuilds the registry and clears initialization caches in D:\mcpp + The home locator and previous home data are retained. +``` + +`self config` 和 `self doctor` 显示原始 home、真实 home、来源、locator 与保留的旧数据。两者相同时无需重复两行相同路径;重定向时明确区分。只有经检查仍存在可识别旧数据时显示 retained;不存在时不宣称有残留。若旧 home 被显式列为 upstream,显示“作为上游复用”,不显示“未使用”。不扫描磁盘寻找未记录的历史 home,不把保留数据算作健康错误。 + +## 4. Windows 首次交互 + +该交互选择仅用于 Windows。Linux 和 macOS 不显示目录选择菜单,沿用原始 home 或显式 `--home`。Windows 上也只有显式 `self init`、未提供 `--home`、没有已初始化当前 home、没有已保存 locator、无显式 `MCPP_HOME`、不在自包含模式,且 stdin 与提示输出终端均可交互时才显示菜单。普通命令的懒初始化不弹菜单,保存过但不完整的初始化继续使用已选目标。 + +检测 D 为存在且可写的本地固定磁盘。使用平台驱动器类型 API,排除网络盘、可移动盘、光驱与未就绪盘;可写性用可清理的临时目录/文件验证,不能只看权限位。选择后仍验证 `D:\mcpp` 本身;D 根可写不代表既有 `D:\mcpp` 可用。路径由重解析点导向网络位置时不将其标为本地固定盘推荐目标。 + +符合条件时显示以下静态布局。`Choose` 与 `Select` 的首字母左对齐,标题与选项之间留一空行;选项保持内容列缩进。状态动词仍沿用现有宽度;提示、诊断输出到 stderr。该示例为目标输出,需在 PowerShell、cmd、Windows Terminal 和可识别的 Git Bash 终端验证。 + +```text + Home No existing home found + Drive D:\ is a writable local fixed drive + + Choose the mcpp home: + + 1) D:\mcpp [default] + 2) C:\Users\alice\.mcpp + + Select [1/2, Enter=1]: +``` + +只接受去掉首尾空白后的 `1`、`2` 或空行。空行选 1;EOF 和 Ctrl+C 取消,不等同空行,不保存 locator。其他输入一次报错退出 2,不循环、不接受路径输入: + +```text + Error Invalid selection; expected 1 or 2 + Hint For a custom path, run: mcpp self init --home +``` + +D 不合适时不显示菜单,使用现有用户目录默认。无终端或输入重定向时同样使用既有默认,不读输入、不自动选择 D。Git Bash 终端识别必须使用既有平台抽象并验证 mintty 场景;无法可靠识别时采用非交互路径,不能挂起。其他平台同一命令、同一 home/force/离线语义,只有 D 盘建议属于 Windows。 + +离线模式仍允许目录选择,选择后网络阶段明确 skipped。显式 `--home` 不产生任何菜单。 + +## 5. 初始化阶段与只读配置 + +将 `load_or_init` 的职责拆成读取/解析配置、本地创建布局、按能力 bootstrap。调用者显式选择允许的副作用,不能再由通用配置读取自动安装工具。 + +| 阶段 | 工作 | 网络规则 | +|---|---|---| +| Home | 解析来源,检查路径、冲突与交互选择 | 无 | +| Configure | 创建必要本地配置,原子保存默认 | 无 | +| Provider | 找到或从安装包/现有本地来源取得 xlings | 不为诊断临时下载 provider | +| Resolve | 识别本地工具、完整 payload 和可复用 upstream | 不以外网探测阻断本地复用 | +| Network | 仅对确实需要下载的资源做有界检查 | 离线跳过;资源已齐全则不主动探测 | +| Fetch/Install | 刷新必要索引、安装缺少的初始化工具 | 按资源回退、报告进展;支持取消 | +| Verify | 报告 ready 或具体缺失项 | 不偷偷重试下载 | + +同一条命令内的资源需求可能逐步获知,不为提前显示检查而强迫先刷新索引。下一资源需要不同主机时,可复用新鲜结果或在该下载前增量探测。 + +当前 `xlings self init` 不能因为名称中有 init 就被当作已确认无网络的本地阶段。网络检查必须位于第一次可能获取索引的调用之前;优先由 provider 将纯布局创建与索引获取分开。在上游尚未分开前,把这个初始化调用整体视为潜在网络步骤,放在检查之后并受取消/超时约束,不能只拦住后面的工具安装。 + +`self env`、`self config`、`self doctor`、`toolchain list` 不自动 bootstrap;doctor 缺 provider 时报告缺失,其余本地检查继续。显式 `self init` 和真正需要工具的 build/run/toolchain install 才按能力获取。`self config --mirror` 应通过 provider 的无网络配置接口更新;若现接口会隐式初始化,需要修改提供者接口,不在 mcpp 中另造一套 provider 文件编辑规则。 + +## 6. 网络检查、选源与下载边界 + +### 6.1 端点与结果 + +网络模块报告“此资源端点可用”,不声称“互联网正常”。按实际候选索引/产物端点进行小请求,覆盖允许的重定向;不通过 ping、首页可达或地理位置推断下载可用。避免只用 HEAD 而误判拒绝 HEAD 的镜像;小 GET 也要限制读取量并可取消,不能下载完整工具链用于测试。 + +`auto` 下并行检查 GLOBAL/CN 候选,优先使用可服务该资源的候选;探测不能保证随后下载成功,实际下载仍需回退。已有显式 CN/GLOBAL 和自定义索引保留提供者当前策略,不静默改成 auto;支持 `self config --mirror auto` 恢复自动策略。显式 region 的失败回退沿用提供者契约,本轮不擅自改为“禁止其他域名”或“总访问其他域名”。 + +自定义/企业索引只检测自己的候选,不为了检查外网去访问无关公共站点。代理、证书及 TLS 验证沿用真实下载的设置,不关闭证书验证。输出是否使用代理及失败阶段,不输出带凭据 URL、代理秘密或完整环境变量。 + +结构化结果包含资源类别、region、脱敏 host、DNS/connect/TLS/HTTP/timeout/cancelled 分类、耗时和检测时间。失败不修改永久离线状态;正结果按资源与代理配置短期复用,不能让一个 GitHub 首页结果代表所有 release CDN。建议进程内正缓存 5 分钟、失败缓存 15 秒;本轮不新增持久网络缓存。 + +### 6.2 候选时间预算(review R5) + +| 工作 | 候选默认 | 上限含义 | +|---|---|---| +| 初始化探测或 doctor 网络段 | 总计 5 秒,包含全部候选与重定向 | 共享 deadline,并行任务到期取消;不是每个端点各等 5 秒 | +| 探测单次连接 | 最多 3 秒,且不超过剩余总预算 | 需要覆盖 DNS 与连接阶段的实际等待 | +| 实际下载连接 | 8 秒/候选,受该操作预算约束 | 失败后尝试其他合法源 | +| 实际下载无有效字节进展 | 30 秒 | heartbeat、日志或 spinner 不刷新此计时 | +| 小型索引刷新 | 沿用当前总上限 120 秒 | 重试、退避、切源共享总预算 | +| 大型安装安全总上限 | 保留现有 3 小时作为最后保护 | 有效下载可持续;无法替代连接与无进展上限 | + +探测 deadline 包含启动、DNS、代理、TLS 与重定向。只在等待线程外层返回而留下阻塞 DNS/子进程不满足有界要求。取消使用平台进程树/取消原语,并验证 Windows 无遗留 curl/git/xlings。 + +下载 byte progress 与接口 heartbeat 的区分需要 xlings 暴露。mcpp 不另造完整下载器;职责为调用策略、总预算、阶段输出、提供者错误映射和父进程兜底。如果 provider 缺少接口,先在 xlings 落地能力并更新最小兼容版本,再接入。不能把“传入一个 idle timeout”写成字节停滞检测已经完成。 + +直接安装路径不能继续丢弃决定网络行为的输出后只显示计时。优先统一到稳定的结构化进展接口;若大型包的直接路径仍需保留,也须获得字节/阶段/失败信息,不能无限等待后用另一安装接口重做同一失败下载。 + +### 6.3 输出与离线 + +拟议输出沿用现有动词列,不增加独立横幅或每条命令的网络公告: + +```text + Home D:\mcpp + Configure Mirror policy: auto + Provider xlings 2026.x.x (local) + Network Checking GLOBAL / CN endpoints... + Network GLOBAL timed out; CN reachable (186 ms) + Source CN selected for package index + Fetching Package index... + Installing ninja... + Ready D:\mcpp +``` + +“Source”是此资源的实际选择。下载中发生切源时报告对应原因和下一候选;初始探测失败但真实请求成功时显示真实结果。不把 CN 不可用归因为整机无网。 + +离线模式优先于探测、刷新、下载和 provider 自动联网。初始化、设置 locator 和本地复用仍可执行: + +```text + Home D:\mcpp + Configure Local configuration ready + Default Saved in C:\Users\alice\.mcpp\home.toml + Network Skipped (offline mode) + Missing ninja + Error Initialization incomplete; the default home was saved +``` + +工具已齐全时离线 init 成功;缺少必需资源时退出 1。不因为探测失败自动切为离线模式,不从联网失败推断本地资源不可用。 + +Unix 安装脚本的连接、停滞、校验文件请求也补齐边界;保留 `MCPP_PREFIX`。程序安装位置、xlings 自身 home 与 mcpp 数据 home 分开说明,设置 mcpp locator 不移动前两者。安装器获取脚本本身的网络问题属于其上游调用,不宣称 mcpp 初始化检查可以覆盖。 + +## 7. doctor 行为 + +doctor 先做本地检查,不调用安装或修复流程;`--offline` 完全跳过网络段。普通 doctor 默认做一次共享 5 秒预算的资源端点诊断,网络失败作为 warning,其余本地结果照常完成。已存在的本地错误继续影响退出状态,不能被网络 warning 覆盖。 + +5 秒约束专指网络段,不是 doctor 总耗时保证。provider 查询、编译器信息、项目只读规划也须有各自的子进程约束;本次本地 0.615 秒不能推广为所有环境都在一秒内完成。 + +home 来源、locator、旧数据保留、代理是否启用和检测端点归入诊断结果。新增结构化字段若进入现有机器协议,按对应协议演进规则处理,不私自改变已发布 envelope。 + +## 8. 实现顺序与修改边界 + +1. 先使配置读取、mirror 修改和诊断不执行 bootstrap,补充缺 provider 的只读降级。 +2. 为 home 建立两阶段、带来源的解析结果、原始 home 下的 locator、单跳规则、冲突保护和原子更新;普通命令不增加提示。 +3. 接入 `self init --home`,区分裸 force 与显式目标 force;验证环境变量、自包含与 `--system` 边界。 +4. 在 `modules/platform` 增加/复用驱动器和输入终端能力,接入两项菜单;CLI 层组织布局,不在 home leaf 层读取交互输入。 +5. 先完善 xlings 的阶段、下载进展、连接/停滞取消能力,再接入初始化 preflight 与 doctor 网络段;保留现有索引与大包预算含义。 +6. 用户文档与 help 在功能落地时同步中英文更新;此前仅保留本设计记录。 + +### 8.1 架构约束 + +一次调用只有一个已解析的 home 上下文,包含 `originalHome`、原始来源、`effectiveHome`、真实来源和 locator 路径。先完成 CLI 参数解析,再完成 home 选择,之后配置、provider、缓存、日志和 force 清理共享该结果。查询模式与修改模式复用路径解析,分别决定是否允许写入或联网。 + +`home::root()` 对既有数据消费者继续表示真实使用的 home。原始 home 只服务入口读写、来源报告和旧数据说明;不允许 toolchain、缓存各自重新解析环境变量。程序随附资源从实际安装位置获取,其安装位置不因数据 home 重定向而改变。provider 仅接收已经确定的真实 registry 路径,不再次解释 locator。 + +路径选择与本地 locator 读写归 home 子系统;平台层只提供文件系统、驱动器、终端与取消能力;CLI 层组织菜单和提示;provider 负责下载/选源事实,mcpp 组织阶段和预算。原始/真实两个概念是同一次解析的输入与输出,不是两套相互同步的全局配置。 + +## 9. 验证矩阵与通过条件 + +临时 PR 的现状探针只回答现状问题。后续实现不能以该 PR 绿色作为功能验收。 + +| 领域 | 需要验证的行为 | 环境/证据 | +|---|---|---| +| 路径兼容 | 原始 home 的 env、自包含、dev/xpkgs 排除、fallback 均保持;无重定向时原始=真实;无全局新提示 | Linux/macOS/Windows,解析单测与真实 CLI | +| locator | 重启仍生效;只跳一次;只读所选原始 home 的入口;坏目标拒绝;显式 force-home 可修复坏入口 | 三平台隔离入口;验证 env/portable 均不读另一套 ~/.mcpp 入口 | +| 路径身份 | 相对路径、空格、Unicode、大小写别名、junction/symlink、切回入口 | Windows 非 UTF-8 ACP 路径也要覆盖;失败有明确错误 | +| 切换事务 | 同路径幂等;不同路径需 force;两边 sentinel 保留;写入失败保留旧 locator;并发切换无半文件 | 可控故障注入与真实文件系统 | +| force | 裸 force 与当前清理范围一致;重定向时只清理解析目标;显式 `--force --home` 不删除 | 三平台目录 sentinel,不用生产 home | +| 菜单 | 1/2/空行/非法输入/EOF/Ctrl+C;无终端不等待;无 D/非固定/不可写 D 不推荐;Linux/macOS 永不显示 | Windows 原生控制台/ConPTY 与 Git Bash;D 探测以可注入平台事实补全反例 | +| 输出 | 相同动词列、详情对齐;窄终端长路径不破坏结构;相关操作才输出提示 | 真实终端/PTY transcript,不以管道输入冒充交互验证 | +| 离线 | CLI flag 和 env 两入口;无 preflight/refresh/download;本地复用不依赖外网 | 控制网络调用记录,必要时操作系统级拦截;冷/暖 home 对照 | +| 故障网络 | DNS、代理拒绝、TLS、403/404、重定向、握手后停滞、持续小流量、CN/GLOBAL 部分故障 | 可控本地 HTTP/TLS/代理端点与少量真实资源检查 | +| deadline | 并行候选共享预算;heartbeat 不掩盖停滞;重试不重置总预算;取消无孤儿 | 三平台测 elapsed 和子进程存活;不只断言返回码 | +| doctor/config | 冷 home 无 bootstrap;provider 缺失仍报告本地结果;offline 跳过;网络失败有界 | 真正隔离 home、完整日志和后续检查已执行证据 | +| 大包 | 有进展的慢下载不被 5 秒探测预算截断;停滞及时切源/失败 | 字节进展事实;有限大小的可控流模拟,真实大包抽验 | + +网络时间测试允许平台调度余量,但余量不能成为隐藏的第二个长超时;建议探测 5 秒加最多 2 秒终止清理余量。Windows D 盘存在的一条正例不能代替无 D/不可写等反例。跨平台当前只有发布版探针,不声称 PowerShell 交互已验证。 + +## 10. 同轮独立缺陷 + +### 10.1 macOS benchmark 比例判断 + +[原 CI job](https://github.com/mcpp-community/mcpp/actions/runs/39129581254/job/117682786534) 报告 116 passed、1 failed、171 skipped,唯一失败为 `230_bench_harness.sh`。测得 cold 2.70 秒、noop 1.36 秒;`bench/src/main.cpp` 因 `cold < 2 * noop` 将其判为 clean 未生效。cold 日志显示执行构建,但仅凭该日志也不替代产物级清理验证。 + +设计方向:用产物删除与重新生成、实际编译事件作为测试断言;耗时比例作为可疑提示,不能单凭比例报确定的清理失败。不以调低阈值或增大 fixture 作为根治。修复独立提交,不属于本设计功能 PR。 + +### 10.2 #816 的头文件传递 import + +[#816](https://github.com/mcpp-community/mcpp/issues/816) 的关键形状是 `main.cpp → #include helper.h → import mylib → import dependency`。`plan.cppm` 已沿 `cu.imports` 构建传递闭包;需要验证源扫描未看到头文件 import 时,编译器 P1689/dyndep 后发现的模块如何获得正确 map。 + +临时 PR 在 macOS 用同一 local path dependency 比较直接 import、源码中传递 import、头文件中传递 import,保存构建日志、CDB 和 build.ninja。它不依赖 Godot/Quill 网络下载,但仍只是一个工具链版本的诊断,不替代原包的最终回归。 + +[最终模块对照 job](https://github.com/mcpp-community/mcpp/actions/runs/39184402622/job/117843665715) 使用 LLVM 23.1.3 与 release mcpp 2026.10.17.1,得到: + +| main 的形状 | build 结果 | CDB 中 main 的模块映射 | +|---|---|---| +| `import depmod` | rc 0,1.785 秒 | 有 `.modmap` | +| `import mylib`,mylib 再 import depmod | rc 0,1.408 秒 | 有 `.modmap` | +| `#include helper.h`,头文件 import mylib | rc 1,1.240 秒 | 无 `.modmap`,只有 flat prebuilt module path | + +第三项原始错误为 `helper.h:2:1: error: failed to find module file for module 'depmod'`。这将问题收窄至头文件引入 import 时的映射覆盖;源码中明确 import 的传递闭包在本对照中成立。该失败是被观察的产品缺陷,模块 job 的绿色仅保证直接 import 对照成功并完成证据采集,不把第三项当成产品通过。 + +实现方向:使实际编译读取的模块映射与编译器发现的依赖一致,同时维持包可见性、BMI 构建顺序、缓存 staging 和 workspace 稳定性。不通过扁平复制/符号链接依赖 BMI 掩盖缺失映射,也不无条件将所有包模块暴露给所有编译单元。 + +## 11. 决策状态与本轮 review 要点 + +| 编号 | 状态 | 方案及需要核对的细节 | +|---|---|---| +| R1 | 按最新反馈修订 | 原始 home 先按 env/portable/fallback 选定;真实 home 再按 --home/原始入口/原始目录选定。`MCPP_HOME=D` 后显式选择 E,写 D/home.toml;不写另一套 ~/.mcpp,不改环境变量。只跳一级。 | +| R2 | 用户已认可 | 裸 force 保持破坏性;显式 --force --home 即使同路径也不清空数据。仅 locator 来源的裸 force 增加目标提示;本轮不增 reset。 | +| R3 | 用户已认可 | 一个 locator 文件,可附 previous_home;只保存上一持久默认,旧目录存在才报告,不扫描历史、不自动清理。 | +| R4 | 用户已认可 | 本地验证成功即保存默认,先于联网。下载失败/离线缺工具时默认仍已切换,命令返回 1;保存失败则旧指向保留,并说明目标本地文件可能已建立。 | +| R5 | 用户已认可 | 探测共享 5 秒;下载连接 8 秒、无字节进展 30 秒。doctor 网络失败只警告;大包保留 3 小时最终保护;provider 须暴露真实进展。 | +| R6 | 按最新反馈修订 | 仅 Windows 首次显式 init 有两项菜单;Choose 与 Select 左对齐,标题与选项隔一空行;空行选 D。非法输入退出 2,EOF/Ctrl+C 取消;其他平台无菜单。 | +| R7 | 用户已认可 | human env/config/doctor/list 不自动安装;相关现有 E2E 按新契约更新;离线跳过探测和下载。 | + +本轮额外列出的边界:保存或覆盖入口时原始 home 不可写则显式报错,不偷偷换入口,普通读取不要求可写;坏 locator 可通过 `--force --home` 修复;切回原始目录删除 locator;原始与目标不能互相包含。最后一项是避免目录归属歧义的保守约束,需 review 是否接受,暂不承诺支持嵌套 home。 + +## 12. 文档复核 + +2026-10-19 完成结果回填后对前版进行了第二遍成文复核。以下保留当时结论;本轮两阶段模型修订以第 12.1 节为准。 + +| 维度 | 结论与位置 | +|---|---| +| 读者、归属与梯度 | 通过;开头明确实现/review 读者,提案只写在设计树,链接现有参考与实现 | +| 渐进性与直观 | 通过;第 1 节先给事实和实测,再给第 2—7 节行为;可粘贴的新命令及输出已由状态行统一标为设计 | +| 覆盖度与可查阅性 | 通过;第 3 节有命令矩阵,第 9 节列出反例、平台与证据,第 11 节逐项给出 review 决策 | +| 断言与证据 | 通过;release/main SHA 分开;第 1.2 节说明故障注入范围;第 10.2 节区分 job 绿色与被观察的产品失败 | +| 信息密度与易读 | 通过;表格比较语义、时间预算与验证范围,说明段落解释因果;Windows 输出集中在第 4 节 | +| 推荐路径与增量状态 | 通过;固定入口文件和单跳为唯一推荐方案,无其他并列持久化机制;未实现内容不写入用户手册 | +| 旧行为与提示范围 | 通过;第 2.1、3.1、3.3 节保留 env/portable/force,限定提示,不向普通命令增加提醒 | +| 当前边界 | 通过;第 1.2、9 节明确新功能未验收、原生菜单未测试、故障注入不覆盖真实 CN 网络 | + +`bash .github/tools/check_docs_style.sh` 通过;该脚本作用域主要为用户文档,不能代替上表对本设计的人工复核。设计文件链接和代码块结构另行核对。尚未实现产品功能,因此不运行或声称完成其功能回归。 + +### 12.1 本轮多视角自我 review + +本轮按更新后的整篇成文重新进行自我 review;已有发布版探针只作为现状证据。 + +| 视角 | 发现与修订 | 复核结论 | +|---|---|---| +| 模型一致性 | 前版混排 --home 与 MCPP_HOME,导致保存位置被当前 env 遮蔽;第 2 节改为原始/真实两阶段,入口随原始 home | 已消除前版矛盾;R1 的具体例子供本轮核对 | +| 默认与兼容 | 未设置重定向时两者相同;env 与 portable 不读取另一用户入口,已有排除规则保留 | 设计成立;需后续三平台回归验证 | +| 架构与简洁性 | 防止将 MCPP_HOME 改写为真实路径后再读目标 locator;第 8.1 节规定单一上下文、单次解析,数据消费者只用真实 home | 只增加入口选择职责,不建立两套运行配置 | +| Windows UX | Choose 与 Select 对齐并增加标题空行;菜单严格限 Windows,已保存但未完成初始化不再弹窗 | 文案与状态规则一致;原生终端布局仍待实现验证 | +| Linux/macOS UX | 不引入目录菜单;显式 --home 与错误/离线语义和 Windows 一致 | 不把 Windows 盘符推荐推广为跨平台默认行为 | +| 文件事务与失败恢复 | 补充入口写操作的可写性预检、普通读取不要求写权限、保存失败不删目标、坏 locator 的显式修复、切回入口的删除语义 | 失败不会悄悄启用另一个 home;嵌套目录约束列为边界 review | +| 自包含与资源归属 | 原始入口可保留程序与旧数据;重定向仅改变真实数据位置,程序随附资源按安装位置查找 | 不把自包含二进制移走,也不把其安装资源误认成新 home 已下载内容 | +| 网络与离线 | 入口与真实位置解析不触发探测;首次可能联网的 provider init 也受阶段与预算约束;heartbeat 不算下载进展 | 已认可预算保留;provider 能力仍为实施前置依赖 | +| 输出与可诊断性 | 修正旧的遮蔽提示;仅 init 设置操作和主动 config/doctor 报告双路径,普通命令不重复 | 与用户限定的提示范围一致 | +| 验证与证据 | 第 9 节新增原始/真实矩阵、坏入口修复和非 Windows 无菜单;历史 Actions 只保留现状事实 | 没有宣称修订后的 locator 或交互已通过 CI | + +待实现验证与待 review 的语义边界分开列出;本轮为设计修订,无产品代码变更,无需重跑原样的现状探针。 + +### 12.2 实施检查点:home 与只读配置 + +已在功能分支开始 T1—T3:两阶段 home 解析、单跳 TOML 入口、显式 force-home 非删除语义、Windows 两项菜单、初始化策略拆分、provider 内的本地 mirror 更新,以及相关来源报告。T4 网络检查与 provider 下载边界尚未完成,不能将此检查点视为最终实现。 + +本地 GCC 16.1 自举构建成功;聚焦 `mcpp --offline test home` 完成,13 个用例通过(`/tmp/mcpp-first-run-home-tests.log`),覆盖既有路径与缓存规则、单跳、坏入口修复、强制切换数据保留、返回原始位置、嵌套目录拒绝及只读配置不写入。真实 CLI 首轮发现 TOML 字符串重复加引号,已修正;对外 CLI 必须重新链接后再验收,不以单测通过替代实际二进制读回。 + +当前 provider 边界、模块 wiring、文件长度与版本 pin 检查通过。尚未运行当前功能 head 的跨平台 CI,也未验证 Windows 原生交互。xlings(provider)的连接超时仍为 30 秒硬编码,已有 low-speed 看门狗;T4 要求补齐可配置连接预算和结构化探测,不用旁路下载器冒充 provider 已支持。 + +本检查点重新链接后的真实 CLI 验证已通过:`919_a_home_selection_is_remembered_once.sh`;既有 `38_self_config_mirror.sh`、`46_self_config_mirror_no_bootstrap.sh`、`604_offline_skips_sandbox_bootstrap.sh` 按无隐式 bootstrap 的契约更新后通过。它们只证明 Linux 当前基础行为,不替代 Windows 原生交互、网络 T4 或最终全 CI。 + + +### 12.3 实施检查点:有界网络诊断与平台选择 + +mcpp 功能草稿为 [PR #824](https://github.com/mcpp-community/mcpp/pull/824),提供者前置能力为 +[xlings PR #684](https://github.com/openxlings/xlings/pull/684)。后者新增本地端点发现、 +复用下载传输层的资源探测、可缩短的连接/读取预算、实际来源和候选尝试信息。 +mcpp 通过 provider port 获取结构化结果,端点发现与并发子进程共享 5 秒期限。 +诊断不建立第二套下载器,不根据一次探测永久改写 mirror 配置。 + +本地 Linux 已有以下证据: + +| 验证 | 结果 | 边界 | +|---|---|---| +| provider 首轮网络用例 | 5 个通过,随后补实际候选尝试用例后 6 个通过 | 真实 loopback GET/404、拒绝代理、无凭据输出、字节进展与总预算;不是完整公网故障矩阵 | +| 旧 provider 对照 | 3 个资源诊断断言因缺少新 capability 失败 | 旧版 2026.10.18.1;证明新断言有区分度 | +| mcpp 网络与进程用例 | 5 个通过;连续 heartbeat 输出下仍在约 225 ms 被测试期限停止 | POSIX 探测 fixture;输出期间取消的进程用例待 Windows CI 复核 | +| 显式迁移与 home E2E | 10、11、39、151、681、687、846、916、919 全部通过 | Linux 实际重新链接的 CLI;保留文件状态断言 | +| 文档与层次检查 | 文档样式、干净 worktree 文档结构、provider 边界、模块 wiring 通过 | 原生 Windows 交互仍需实际 runner 证据 | + +对下载链的补充 review 发现:socket timeout 不能覆盖所有 DNS/响应头等待,interface heartbeat +也不能代表字节进展。因此父进程增加进展期限轮询,由平台进程封装在持续输出时仍检查并清理 +整个子进程树。只增加字节数或真正切换候选才能更新进展时间;完成后的解压/配置阶段不沿用 +活动下载的 idle 计时。该层补充不替代 provider 的来源回退与 socket 预算。 + +本轮新增的可移植平台选择采用 `if constexpr (mcpp::platform::is_windows)`。 +stdin 终端检测由 facade 分派到对应平台绑定;条件编译仅留在系统头和 syscall 绑定边界。 +Windows 原生控制台检查通过独立 console 输入/屏幕缓冲驱动真实 CLI,检查 Choose/Select +同列、标题空行、非法输入退出、选择 2 与 Enter 默认选择 D。该脚本仅用于可丢弃的 hosted +Windows runner;已有 D:\mcpp 时拒绝测试,禁止清理未知 home。 + +基础 CI 已暴露两类需要修正的既有断言:路径规范化后的 /private/var 与 /var(Windows 同类 +拼写差异),以及把 self env 当作初始化入口的 E2E。前者改用目录身份比较,并由原始解析来源 +直接报告 user/portable;后者改为显式初始化或本地配置修改。最终 head 的全 CI 尚未完成, +provider 尚未发布和更新 floor,当前不能宣称 T4—T8 完成,未合入也未发布 mcpp。 + + +### 12.4 补充 review:首次请求、只读入口与安装器 + +安装接口的总保护不能只依赖第一个 download_progress 事件:首次索引获取可能发生在它之前。 +统一的安装期限状态同时覆盖结构化安装入口与 bootstrap 入口:前置资源阶段 120 秒;活动下载 +按字节与候选变化判断 30 秒无进展;进入解压、hook 或配置后交还本地阶段与最终进程期限。 +超时保留先前已报告的本地错误,网络超时作为 E_NETWORK 返回,禁止再通过另一个前端重复下载。 + +端点查询前先在同一个 5 秒预算内读取 provider handshake。旧 provider 不支持诊断时停止在握手, +避免未知 capability 被其通用命令入口当成写操作。真实 CLI 联调也暴露 provider 自身会为新查询 +创建 operation journal;前置 PR 增加只读登记,并跳过这些 interface 读取的旧 home 采用和 profile +升级,加入目录与文件内容不变的对照。最终补丁的 8 个网络用例已通过,另有 12 个 home-context 用例通过;最终 head 的多平台 CI 仍在执行。 + +独立 install.sh 继续使用其原有 curl 传输,补齐两侧镜像、版本元数据和校验文件的等待保护。 +连接 8 秒;元数据总上限 120 秒;压缩包最终上限 3 小时。curl 的最小非零 low-speed 阈值为 +1 B/s,窗口 30 秒,因此安装器的低速保护与 mcpp/provider 的严格字节 idle 判断并不等同; +不能用 curl 参数测试替代 provider 连续字节进展的验收。 + + +### 12.5 平台分派与离线复核 + +公共 stdin 检测采用 `if constexpr` 分派,系统 API 的声明与调用留在平台绑定中。 +mcpp `04c2aaff` 的 Windows `test + package` job 已通过,原生控制台步骤已实际覆盖 +布局、非法输入、选项 2 与 Enter 默认选项。完整 CI 尚有未完成任务。 + +provider `85d321f2` 删除独立后台 HEAD 预取:进度总量来自实际 GET,而 SHA256 已验证缓存 +直接复用。回归测试用本地代理连接数断言缓存命中不触网,并以恢复旧 HEAD 实现为反例, +确认旧实现会多出一次连接。诊断用例比较 home 中的目录及文件内容,冷 home 的 +payload_status 返回本地 E_NOT_FOUND 也必须保持 home 不变。实际 mcpp doctor 联调已验证 +在线故障能显示诊断且继续本地检查,离线不请求资源;该 E2E 在 provider 发布和 pin 更新后 +才加入正式测试集合。 + + +### 12.6 完整 CI 发现的规划与路径回归 + +macOS E2E 916 的迁移位置已正确,但原断言只做 lexical normpath 比较,无法识别 +/var 与 /private/var。改为 realpath 比较后,Linux 符号链接别名 fixture 通过;同一 fixture +恢复原断言时按预期失败,仍保留索引实际迁移和旧载荷内容不变的检查。 + +Windows E2E 688 在冷 home 的只读构建数据库规划失败。本地重现的 JSON 明确报告 +registry/bin/xlings 缺失:只读配置正确地没有获取二进制,但规划仍使用实际执行的 +bootstrap 前置检查。修正为仅实际执行要求完整 bootstrap,规划读取已有工具链,所需资源 +仍由各消费者校验。重新链接后 E2E 688 全部通过,包括冷 std 缓存不产生编译输出,以及 +configure-only 对照会实际编译 std。该修正的 Windows CI 验收尚待完成。 + +provider 网络测试另以 `if constexpr` 隔离小写代理环境变量设置,避免 Windows 大小写不敏感 +导致测试恢复到被修改后的代理值;修正后 8 个网络用例重新通过。产品接口没有因此改变。 + + +### 12.7 补充网络故障实测 + +待 provider 发布后纳入正式集合的 E2E 920 已在本地扩展并通过:本地 HTTP 403、响应头后 +不发送 body、持续发送少量字节都在探测预算内失败;TLS fixture 验证真实 ClientHello 到达 +后主动发送 fatal handshake_failure。传输库将该 TLS 失败折叠为 connect,因此断言其 +不可达和已执行握手,不伪造更细分类。此边界已在双语 home 文档中说明。 + +这些用例同时检查 home 文件不变。doctor 的资源请求数与前面的直接探测分别计量,防止 +前置请求使 doctor 的触网断言空过;随后 offline doctor 的请求数必须保持不变。Windows +临时探针 39194687278 也已复现 E2E 688 相同的缺 provider 二进制诊断;其失败是修正前的 +反例,不能当作 7324eeea 已通过 Windows 验收。 + + +### 12.8 旧自定义提供者的只读边界 + +使用发布版随附的 xlings 2026.10.17.1 显式配置 binary,隔离冷 home 的离线 doctor 返回 0, +但新增 registry/logs/ops 下的操作日志。只修新提供者不能保护仍显式使用旧 binary 的用户。 +因此增加 `read_only_queries` 能力保证;状态查询缺失该能力时不调用旧 RPC,并在 doctor +相关检查中报告查询不可用,不获取替代提供者。握手也改为有界、隔离临时 home 执行,按 +二进制路径、大小、修改时间缓存结果,避免旧 main 在返回握手前采用或升级真实 home。 + +首轮 7 个网络/进程用例通过,包括可写入 home 的旧接口 stub,验证实际选定 home 仍为空; +补充后的 8 个网络/进程用例通过;旧 stub 的握手必须实际执行并给出预期版本,避免 +以未启动冒充不写入。provider 的 26 个协议用例与 8 个网络用例通过,涵盖 6 类查询的目录 +及文件内容不变。新能力仍需发布后更新 pin,并完成最终 head 的多平台 CI。 + + +真实旧 provider 的 CLI 回归已通过:离线 doctor 返回 1,说明只读能力不可用并继续 build +policy 等本地检查,home 内容与运行前完全一致。新 provider 的 HTTP/TLS 故障和离线 E2E +也通过。元数据捕获使用子进程环境覆盖与有界 shell 捕获,保留 Windows 批处理包装器, +不修改父进程环境;对应 8 个网络/进程用例已重新通过,最终 CLI 已重新链接并完成旧版提供者及故障端点联调。 + +7324eeea 的 Windows E2E 1/3 与 Windows test/package 已通过,确认冷规划修正进入 Windows +真实执行路径。该 head 的 Android emulator E2E 因设备内 fork Out of memory 失败; +provider ab2308d2 的 Linux acceptance 因控制台 triple-click 仍保留 word 而非整行失败。 +已保留两项日志,不能将其计作通过;最终 head 必须重新获得对应 gate 的成功证据。 + + +### 12.9 最终依赖验收前的 CI 复核 + +7324eeea 的完整 CI 完成,首次结果 69 项通过、2 项失败。Android emulator 因 guest 内 +fork Out of memory 的失败已在同一 head 新 runner 重跑通过(job 117884405856)。 +另一个失败为 E2E 22:测试在仓库项目目录中用空 home 做 doctor,触发项目显式 default +SubOS 不存在的真实错误。夹具改在无项目的临时目录验证冷 doctor,再在构建阶段继承工具链, +并断言 doctor 没有创建 registry;本地整项通过。不能通过把真实项目错误降为 warning 修测试。 + +本地重新链接的 2026.10.19.1 CLI 通过 E2E 22、688、732、733、734、812、817、920, +PM 16 个用例通过。provider c9727222 的 Linux acceptance 已通过,先前 triple-click +失败没有复现;其 Windows、Linux 分片与 ARM64 镜像 gate 仍在运行。此时不合入、不发布, +等待依赖最终 gate 后按发布、镜像、索引、mcpp pin、最终 CI 的顺序继续。 + +诊断尾段的 provider 版本核对也统一复用隔离握手,移除直接 `--version` 子进程,避免旧 +提供者在操作日志之外的旧 home 采用/profile 更新路径产生副作用,并复用同一有界缓存。 + + +provider c9727222 的最后 Windows 作业通过了单元测试、发布包构建和真实 WSL2 生命周期, +但 E2E-07 的实时 d2x 配方要求 mcpp 2026.10.17.1,而测试 xim 指向固定 ebf1fbb 快照, +该依赖不在旧快照中。b6e1f9e1 仅修测试配置,使实时跨索引集成使用当前官方 xim,其他 +bootstrap fixture 保留原 pin,安装与目录断言均保留。隔离 native 客户端确认官方 artifact +能解析该依赖;最终 head 的完整 CI 已重新启动。诊断版本核对复用隔离握手后的新 CLI, +通过真实旧提供者 home 不变与 HTTP/TLS 故障、离线 E2E 联调。 + +隔离握手同时使用临时 home 作为子进程工作目录,避免旧提供者向调用者项目写入相对路径。 +启动前将相对 binary 路径转为绝对路径,保留其路径拼写及符号链接语义。增强后的旧接口 +夹具实际写入 home 与工作目录,8 个网络/进程用例通过;重新链接的 CLI 再次通过 E2E 920 +及真实旧提供者的离线检查,确认 home 与当前项目均不变。 + +同一工作树的 xlings 集成回归通过 4 个测试程序、70 个用例,文档风格检查通过。 +provider b6e1f9e1 的 Windows 主任务、WSL2、macOS、ARM64 主构建及 Linux acceptance +已通过。Linux unit 2/6 的控制台三击选择断言再次出现 word/line 差异;其 8 个新增网络 +用例全部通过,失败分片在同一 head 重跑(job 117896332478)。ARM64 镜像启动仍在 +运行。失败证据保留;重跑和镜像 gate 结束前不视为完整 CI 通过。 + + +### 12.10 推荐目录的重解析点边界 + +最终约束复核发现,原有 Windows 驱动器检测仅核验 D 根,不能排除 `D:\mcpp` 已是 +重解析点的情况。推荐资格增加目标自身的属性检查,拒绝文件、重解析点及不可判断的 +目标,不跟随链接探测远程卷。显式 `--home` 路径语义不变。该保守限制也不自动推荐 +本地 junction,避免把潜在网络文件系统等待带入首次离线目录推荐。 + +系统绑定采用 [GetFileAttributesW](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfileattributesw) +读取链接自身属性;CLI 的平台选择仍使用 `if constexpr`。原生终端夹具增加本地 junction +对照:子进程确有控制台,但应自行完成用户默认目录的本地初始化,不读菜单输入,且 +junction 目标保持为空。此项仍需最终 Windows CI 执行验证,不能用 Python 语法检查代替。 + +Linux 的 15 个 home 解析/事务用例和重新链接的 CLI 构建通过;新增 Windows 排除条件 +仍待原生 CI,不能据此声称该条件已获得跨平台运行证明。 + +### 12.11 重跑产物与手势夹具的证据修正 + +provider b6e1f9e1 的重跑分片 117896332478 已通过,但后续 report 117901034854 +仍读入首次失败的同名产物。API 同时保留 `xdev-linux-unit-1` 的两份产物,按通配符 +下载到同一目录时旧文件覆盖了重跑证据。现按每个 lane 名称选择创建时间最新的产物 ID, +明确下载所选 ID,保留全部原始产物。实际 API 元数据对照确认选择新 ID 12164244620, +排除首次失败 ID 12164445489;不依赖 ID 数值递增推断先后,也不删除失败证据。 + +控制台三击夹具的独立问题是双击之后立即再三击:实现按 400 ms 窗口累计同格点击, +五次连续点击最终停在 word,快慢帧造成结果不同。夹具在两个独立手势之间等待 500 ms, +保留双击词、三击行及中键粘贴的全部断言,不修改生产 UI。ecff2f12 仅修此夹具和 +CI 产物选择;完整最终 head CI 已重启,仍须全部通过后再完成最终 review、合入及发布。 + +Windows 原生菜单夹具另补充已显示菜单后的 Ctrl+Z/Enter(EOF)与 Ctrl+C,检查退出且 +不创建 locator 或本地配置;不是通过管道绕过菜单的取消对照。路径 E2E 改用带空格和 +Unicode 的相对目标,验证真实 argv、持久化及新进程读取;home 单测补充 Unicode locator +往返,Windows 检查 ACP,UTF-8 ACP 时明确跳过旧代码页证明。新增用例仍待最终跨平台 CI。 + +增强后的 Linux home 单测 16 项通过;真实 CLI E2E 919 已通过 Unicode 相对目标的保存与 +重启读取、损坏但未选中的用户 locator 隔离、portable 自有 locator、四个并发切换进程与 +独立读取、不可写入口导致失败后旧 locator 字节不变,以及重定向裸 force 的四类清理 +范围与旧数据保留。本地 uid 1000 的权限故障控制确实失败,未走特权账户跳过路径; +Windows 使用 locator 只读属性制造替换失败,仍需最终 Windows CI 验收。 + +常规 Windows 单测由根 `[test] windows_code_page = "utf-8"` 固定为 UTF-8,不能把其跳过 +结果当作旧 ACP 验收。新增专项 CI 在完整常规单测之后临时切换为 `legacy`,离线运行 +home 单测,要求 Unicode locator 用例实际给出通过结果;无论结果如何均恢复 manifest。 +常规测试策略与正式二进制的 UTF-8 策略不变。该专项同样等待最终 Windows CI 的真实结果。 + +网络安装错误的恢复提示改为运行 `self doctor` 检查配置资源,再重试;不再把裸 `--force` +重建 registry 作为网络故障的默认恢复建议。裸 force 本身仍保持已约定的现有行为。 + +### 12.12 提供者最终 CI、review 与发布 + +provider 最终 head ecff2f123ebe5cd4b5376cd2f993c2c6fa2dc896 的 31 项检查通过,2 项 +ASAN 作业按仓库 PR 策略跳过。Linux report 117909583623 已实际通过,覆盖最新产物 +选择与 required requirement 的执行证据;Windows/WSL2、macOS、ARM64 构建、兼容及 +UEFI 镜像 gate 均通过。最终复核层次、平台/C 头绑定、环境读取、隐私、只读启动边界、 +实际传输预算与回退,没有阻断项;所有检查通过后完成 review 记录并 squash 合入 +[openxlings/xlings#684](https://github.com/openxlings/xlings/pull/684),合入 SHA 为 +`a1c80e3ff2aba87aa78eb1e4545dfc0f60e84982`,内容与验收 head 完全一致。 + +[2026.10.19.1 provider 发布](https://github.com/openxlings/xlings/actions/runs/39206533262) +已从该 main 提交启动。按用户授权取消同一 main 提交的重复 CI,以优先发布;不将取消 +计作通过。已启动本地资源监视,公开归档及校验文件出现后即补齐 GitCode,并以完整 +GET 和 SHA256 核验四类 payload。此时新资源尚未发布,mcpp pin 仍未前移;后续仍需 +镜像与索引传播、mcpp 最终 CI/review、合入发布及生态真实验收,不能视为整个目标完成。 + +### 12.13 已发布资源、索引传播与进程树回收 + +provider 发布运行 39206533262 已全部成功,v2026.10.19.1 于 06:13 UTC 公开。 +四类 payload 的 24 个归档及校验文件已完成 GitCode 全量 GET 与 SHA256 对照, +GitHub 资源镜像也与源归档一致。公开 Linux 载荷的握手实际返回 1.13 协议及 +`network_diagnostics`、`download_socket_budgets`、`read_only_queries`。 + +官方索引 bot PR 补齐同版 luban、luban-os、xdesk 二进制配方,保留旧版本和组合包 +自身版本。最终 head 465bcad3 的 21 项检查通过、1 项条件检查跳过,复核每个平台唯一 +`latest` 和资源校验值后 squash 合入 +[openxlings/xim-pkgindex#1002](https://github.com/openxlings/xim-pkgindex/pull/1002), +main 提交为 0a25593cab2beefe8aa12327993e7ea58e5ce8f0。 + +冷客户端安装揭示传播阶段的区别:单项 release manifest 已更新时,客户端实际读取的 +组合指针仍指向发布时的旧索引,精确安装返回包不存在。因此依赖 pin 的放行证据必须 +包含组合指针及真实安装,不能仅检查单项 manifest 或索引 main 配方。已保留失败日志, +等待发布运行 39212388579 更新组合指针后重新以独立 home 验证精确版与 latest。 + +本地故障注入另复现了查询子进程超时后后代继续写文件:doctor 约 3.03 秒返回, +写入计数仍从 60 增至 66。元数据 shell 查询及网络资源 argv 查询现拥有独立进程组, +超时回收其后代;普通 argv 调用默认行为不变。9 个网络/进程用例通过,新增用例分别 +确认两个查询阶段确实启动写入者,并验证超时后文件停止变化。仍须新 CLI 的真实 +doctor 复测和最终跨平台 CI,不以局部单测替代完整验收。 + +重新构建的 CLI 已完成该故障注入:doctor 3.04 秒返回,后代写入计数在返回前后均为 +57,本地检查继续到 build policy。同一新 CLI 的 home E2E 通过,包含实际权限故障下 +旧入口不变;E2E 920 使用公开发布的 provider,通过 HTTP/TLS 故障、共享期限、诊断 +home 不变与离线零请求对照。索引发布运行 39212388579 已成功,组合指针服务端提交 +63882590 指向 0a25593;公开 raw 缓存及实际冷安装仍须核实。 + +公开 raw 传播后,两个独立 home 按正式发布流程先安装客户端,再分别执行精确版和 +不带版本安装;二者均实际下载 16.8 MB 归档,列出唯一的 xlings 2026.10.19.1, +从安装载荷读取的握手均返回所需三项能力。验证不继承开发终端的 `XLINGS_PROJECT_DIR` +等上下文。直接用裸二进制指向空 home 的前一轮实验缺少可更新的自有入口,触发 entry +authority 检查;该实验不作为已安装客户端的升级证明,也未放宽权限断言。 + +据此将 mcpp provider floor 与八处 CI/bootstrap 配置中的 xlings pin 统一前移至 +2026.10.19.1。mcpp 自身构建版本为 2026.10.19.1;`.xlings.json` 的 mcpp bootstrap +仍为已发布的 2026.10.10.2,待 mcpp 新版发布、镜像和索引验收后再收尾。 +版本关系检查及更新 pin 后的新 CLI 构建通过,公开 provider 的网络 E2E 再次通过。 + +更新 pin 后的 E2E 919、920 及曾失败的 E2E 22 均通过。E2E 22 先检查真正冷 home 的 +doctor 不创建 registry,再单独继承工具链验证 std 缓存及发布 dry-run,保留原有断言。 + + +### 12.14 Windows 取消夹具的继承环境 + +be6fb711 的四平台构建通过,Windows 原生菜单的非法输入和 EOF 均通过,但 Ctrl+C +等待 20 秒后超时。[Windows 的忽略 Ctrl+C 属性](https://learn.microsoft.com/en-us/windows/console/setconsolectrlhandler)由父进程继承;控制器在子进程创建之后 +才屏蔽自身中断,不能消除 CI shell 已经传下的忽略属性。 + +临时观测 PR #823 的 [运行 39214421955](https://github.com/mcpp-community/mcpp/actions/runs/39214421955) +复用 be6fb711 的 Windows 二进制,只在每次创建子进程前让测试控制器取得控制台并恢复 +正常 Ctrl+C 处理。全部菜单场景通过:非法输入退出 2、EOF 退出 1、Ctrl+C 退出 +3221225786,1/2/空行的目录选择与 reparse 排除也通过;未放宽原有退出及零写入断言。 +生产程序不作修改,正式夹具采用相同的启动条件修正。 + +为优先验证该已失败项,已取消 be6fb711 旧 CI 的剩余作业,不把取消项算作通过。 +其已完成证据保留;最终 gate 必须重新运行修正夹具后的完整 head,包括旧 ACP 专项, +不能用临时探针或旧 head 的局部绿色替代。 + +取消之后的 target-matrix 汇总 job 117935724226 也返回失败:扫描作业被取消,下载结果为 +0 个 matrix 产物,未进入目标行为对照。保留此结果,不修改汇总 gate 或减少预期平台。 diff --git a/.agents/docs/README.md b/.agents/docs/README.md index d039868d6..605dc9d59 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 --- ``` -333 records. +334 records. ## By subject @@ -66,6 +66,10 @@ Records that declare one. Everything else is listed by date below. - [The island boundary's names: one rule for both lanes, and the check that makes it true](2026-09-08-island-boundary-names.md) — active - [Implementation plan: the island boundary's names](2026-09-08-island-boundary-names-implementation-plan.md) — active +### home + +- [首次初始化、home 重定向与网络诊断优化设计](2026-10-19-first-run-home-and-network-design.md) — active + ### modules - [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active @@ -130,6 +134,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-10 +- [首次初始化、home 重定向与网络诊断优化设计](2026-10-19-first-run-home-and-network-design.md) — active - [升级的连续性:打包修订不在使用路径上替换载荷、替换失败不拆掉可用状态、home 中的悬空记录自愈、真实的升级 lane](2026-10-17-upgrade-continuity-design.md) — active - [mcpp 对 xlings 的依赖:Provider 端口、归属与位置、上游复用、系统级安装、payload 不可变、文件系统能力与长路径(#809)](2026-10-16-xlings-provider-port-and-shared-store-design.md) — active - [构建依赖的一条管线:配置与产物两轴、包的实例、库接口、兼容系统与工作空间收尾(#797)](2026-10-11-build-dependencies-one-pipeline-design.md) — active diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index b6366ab53..23e49b7c2 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -33,7 +33,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.10.17.1' + default: '2026.10.19.1' outputs: sandbox-key: diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index e31d38365..0de88636b 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.10.17.1' + default: '2026.10.19.1' image: description: > The runner label the job runs on (macos-15, xcode-27). It is part of the diff --git a/.github/tools/check_windows_home_legacy.py b/.github/tools/check_windows_home_legacy.py new file mode 100644 index 000000000..81bc2fc1c --- /dev/null +++ b/.github/tools/check_windows_home_legacy.py @@ -0,0 +1,28 @@ +"""Prove locator round-trips under a genuinely non-UTF-8 Windows ACP.""" +import os +from pathlib import Path +import re +import subprocess +import sys + +if os.name != 'nt': + raise SystemExit('This check requires Windows') +root = Path(__file__).resolve().parents[2] +manifest = root / 'mcpp.toml' +original = manifest.read_bytes() +marker = b'windows_code_page = "utf-8"' +assert original.count(marker) == 1, 'identify the test-only code-page setting explicitly' +try: + manifest.write_bytes(original.replace(marker, b'windows_code_page = "legacy"', 1)) + result = subprocess.run([os.environ['MCPP'], '--offline', 'test', 'home'], cwd=root, + capture_output=True, text=True, encoding='utf-8', errors='replace', + timeout=600) + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + assert result.returncode == 0, result.returncode + output = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout + result.stderr) + assert ('[ OK ] HomeSelection.UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath' + in output), 'the legacy ACP case did not pass; a UTF-8-only skip is not proof' +finally: + manifest.write_bytes(original) +print('PASS: the locator round-trip ran under legacy Windows ACP; manifest restored') diff --git a/.github/tools/check_windows_home_menu.py b/.github/tools/check_windows_home_menu.py new file mode 100644 index 000000000..5696064f7 --- /dev/null +++ b/.github/tools/check_windows_home_menu.py @@ -0,0 +1,223 @@ +"""Drive the real first-run CLI through a native Windows console, not a pipe.""" +import ctypes as c +from ctypes import wintypes as w +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time + +if os.name != 'nt': + raise SystemExit('This check requires Windows') + +kernel = c.WinDLL('kernel32', use_last_error=True) +INVALID_HANDLE_VALUE = c.c_void_p(-1).value + +class Coord(c.Structure): + _fields_ = [('X', c.c_short), ('Y', c.c_short)] + +class Rect(c.Structure): + _fields_ = [(name, c.c_short) for name in ('Left', 'Top', 'Right', 'Bottom')] + +class ScreenInfo(c.Structure): + _fields_ = [('size', Coord), ('cursor', Coord), ('attributes', w.WORD), + ('window', Rect), ('maximum', Coord)] + +class KeyChar(c.Union): + _fields_ = [('UnicodeChar', w.WCHAR), ('AsciiChar', c.c_char)] + +class KeyEvent(c.Structure): + _fields_ = [('down', w.BOOL), ('repeat', w.WORD), ('key', w.WORD), + ('scan', w.WORD), ('char', KeyChar), ('state', w.DWORD)] + +class Event(c.Union): + _fields_ = [('key', KeyEvent), ('padding', c.c_byte * 16)] + +class InputRecord(c.Structure): + _fields_ = [('type', w.WORD), ('event', Event)] + +kernel.CreateFileW.argtypes = [w.LPCWSTR, w.DWORD, w.DWORD, c.c_void_p, + w.DWORD, w.DWORD, w.HANDLE] +kernel.CreateFileW.restype = w.HANDLE +kernel.AttachConsole.argtypes = [w.DWORD] +kernel.AttachConsole.restype = w.BOOL +kernel.GetConsoleScreenBufferInfo.argtypes = [w.HANDLE, c.POINTER(ScreenInfo)] +kernel.GetConsoleScreenBufferInfo.restype = w.BOOL +kernel.ReadConsoleOutputCharacterW.argtypes = [w.HANDLE, w.LPWSTR, w.DWORD, + Coord, c.POINTER(w.DWORD)] +kernel.ReadConsoleOutputCharacterW.restype = w.BOOL +kernel.WriteConsoleInputW.argtypes = [w.HANDLE, c.POINTER(InputRecord), w.DWORD, + c.POINTER(w.DWORD)] +kernel.WriteConsoleInputW.restype = w.BOOL +kernel.CloseHandle.argtypes = [w.HANDLE] +kernel.SetConsoleCtrlHandler.argtypes = [c.c_void_p, w.BOOL] +kernel.SetConsoleCtrlHandler.restype = w.BOOL +kernel.GenerateConsoleCtrlEvent.argtypes = [w.DWORD, w.DWORD] +kernel.GenerateConsoleCtrlEvent.restype = w.BOOL +kernel.GetConsoleCP.argtypes = [] +kernel.GetConsoleCP.restype = w.UINT +kernel.AllocConsole.argtypes = [] +kernel.AllocConsole.restype = w.BOOL + +def opened(name): + handle = kernel.CreateFileW(name, 0xC0000000, 3, None, 3, 0, None) + if handle == INVALID_HANDLE_VALUE: + raise c.WinError(c.get_last_error()) + return handle + +def screen(handle): + info = ScreenInfo() + if not kernel.GetConsoleScreenBufferInfo(handle, c.byref(info)): + raise c.WinError(c.get_last_error()) + width = info.size.X + rows = min(info.size.Y, max(60, info.cursor.Y + 3)) + data = c.create_unicode_buffer(width * rows + 1) + count = w.DWORD() + if not kernel.ReadConsoleOutputCharacterW(handle, data, width * rows, Coord(0, 0), c.byref(count)): + raise c.WinError(c.get_last_error()) + return '\n'.join(data.value[i:i + width].rstrip() for i in range(0, count.value, width)) + +def input_line(handle, value): + for char in value + '\r': + record = InputRecord() + record.type = 1 + record.event.key.down = True + record.event.key.repeat = 1 + record.event.key.key = 13 if char == '\r' else ord('Z') if char == '\x1a' else ord(char.upper()) + record.event.key.char.UnicodeChar = char + record.event.key.state = 8 if char == '\x1a' else 0 # LEFT_CTRL_PRESSED for EOF + count = w.DWORD() + if not kernel.WriteConsoleInputW(handle, c.byref(record), 1, c.byref(count)): + raise c.WinError(c.get_last_error()) + assert count.value == 1 + +def check_layout(text): + lines = text.splitlines() + choose = next(i for i, line in enumerate(lines) if 'Choose the mcpp home:' in line) + select = next(i for i, line in enumerate(lines) if 'Select [1/2, Enter=1]:' in line) + assert lines[choose].index('Choose') == lines[select].index('Select') == 6, text + assert not lines[choose + 1].strip(), text + assert '1) D:\\mcpp' in lines[choose + 2] and '[default]' in lines[choose + 2], text + assert '2) ' in lines[choose + 3], text + assert not lines[select - 1].strip(), text + +source = Path(os.environ['MCPP']).resolve() +with tempfile.TemporaryDirectory(prefix='mcpp-console-menu-') as temporary: + root = Path(temporary) + # `target` deliberately excludes portable-home resolution. Each profile is fresh. + binary = root / 'target' / 'bin' / 'mcpp.exe' + binary.parent.mkdir(parents=True) + shutil.copy2(source, binary) + + def run_case(name, choice): + profile = root / name + profile.mkdir() + env = os.environ.copy() + env.pop('MCPP_HOME', None) + env.update(USERPROFILE=str(profile), HOME=str(profile), MCPP_OFFLINE='1') + # A CI shell can leave its descendants ignoring Ctrl-C. Give this + # native-console fixture an ordinary interactive launch context. + if kernel.GetConsoleCP() == 0 and not kernel.AllocConsole(): + raise c.WinError(c.get_last_error()) + if not kernel.SetConsoleCtrlHandler(None, False): + raise c.WinError(c.get_last_error()) + proc = subprocess.Popen([str(binary), 'self', 'init'], env=env, cwd=root, + creationflags=subprocess.CREATE_NEW_CONSOLE) + output = stdin = None + ignored_interrupt = False + try: + if choice is None: + # A native console is still attached to the child. The former + # drive-only check waits for input here and hits this timeout. + code = proc.wait(timeout=10) + assert code in (0, 1), code + assert (profile / '.mcpp' / 'config.toml').is_file() + assert not (profile / '.mcpp' / 'home.toml').exists() + return + kernel.FreeConsole() + until = time.monotonic() + 10 + while not kernel.AttachConsole(proc.pid): + if proc.poll() is not None or time.monotonic() >= until: + raise AssertionError('Could not attach the native child console') + time.sleep(.02) + output, stdin = opened('CONOUT$'), opened('CONIN$') + while True: + text = screen(output) + if 'Select [1/2, Enter=1]:' in text: + break + if proc.poll() is not None or time.monotonic() >= until: + raise AssertionError(text or 'No home menu appeared') + time.sleep(.02) + check_layout(text) + if name == 'interrupt': + # The controller shares the child's console; only it ignores + # this broadcast. The child was spawned before the ignore flag. + if not kernel.SetConsoleCtrlHandler(None, True): + raise c.WinError(c.get_last_error()) + ignored_interrupt = True + if not kernel.GenerateConsoleCtrlEvent(0, 0): # CTRL_C_EVENT + raise c.WinError(c.get_last_error()) + else: + input_line(stdin, choice) + code = proc.wait(timeout=20) + text = screen(output) + print(name, 'exit', code, '\n' + text, flush=True) + if name in ('invalid', 'eof', 'interrupt'): + if name == 'invalid': + assert code == 2 and '--home' in text, text + elif name == 'eof': + assert code == 1, (code, text) + else: + assert code in (1, -1073741510, 3221225786), (code, text) + assert not (profile / '.mcpp' / 'home.toml').exists() + assert not (profile / '.mcpp' / 'config.toml').exists() + else: + assert code in (0, 1), text # offline initialization can lack tools + data = subprocess.run([str(binary), 'self', 'env', '--format', 'json'], + env=env, cwd=root, capture_output=True, text=True, + encoding='utf-8', timeout=10, check=True) + home = Path(json.loads(data.stdout)['data']['mcppHome']) + expected = Path('D:/mcpp') if name in ('drive', 'default') else profile / '.mcpp' + assert os.path.samefile(home, expected), data.stdout + assert (expected / 'config.toml').is_file(), text + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5) + for handle in (stdin, output): + if handle is not None: + kernel.CloseHandle(handle) + kernel.FreeConsole() + if ignored_interrupt: + kernel.SetConsoleCtrlHandler(None, False) + + # This check runs on an ephemeral hosted Windows runner. Refuse an existing + # D:\mcpp rather than touching a home this test did not create. + default = Path('D:/mcpp') + assert Path('D:/').is_dir(), 'The native-menu lane requires its hosted D drive' + default.mkdir() # exclusive reservation; an existing home is a test setup error + marker = default / '.mcpp-console-fixture' + marker.write_text('owned by this isolated CI test', encoding='utf-8') + try: + run_case('invalid', '3') + run_case('eof', '\x1a') + run_case('interrupt', '') + run_case('user', '2') + run_case('drive', '1') + run_case('default', '') + shutil.rmtree(default) # this test exclusively reserved the directory + target = root / 'junction-target' + target.mkdir() + subprocess.run(['cmd', '/d', '/c', 'mklink', '/J', str(default), str(target)], + capture_output=True, text=True, check=True, timeout=5) + try: + run_case('reparse', None) + assert not list(target.iterdir()), 'the suggestion followed a reparse point' + finally: + default.rmdir() # remove the junction, retaining its target + finally: + if marker.is_file(): + shutil.rmtree(default) +print('PASS: native Windows menu layout, input, cancellation, default, and reparse exclusion') diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index abd2f95d0..bf34b44ae 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-bootstrap.yml b/.github/workflows/ci-bootstrap.yml index 05cdc6546..431769158 100644 --- a/.github/workflows/ci-bootstrap.yml +++ b/.github/workflows/ci-bootstrap.yml @@ -41,7 +41,7 @@ jobs: os: [ubuntu-24.04, macos-15, windows-2025] env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' steps: - uses: actions/checkout@v4 @@ -50,7 +50,7 @@ jobs: shell: bash run: | set -euo pipefail - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.17.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.19.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" echo "$HOME/.xlings/bin" >> "$GITHUB_PATH" @@ -124,7 +124,7 @@ jobs: shell: bash run: | set -euo pipefail - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.17.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.19.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install the newest release and build the tree with it (mcpp build --strict) diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index c6decda34..4935b8e7e 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.17.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.19.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -315,7 +315,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.17.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.19.1 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -464,7 +464,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.17.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.19.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 6add974f1..fa3091c93 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -414,7 +414,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.17.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.19.1 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 84e71d950..131dfee19 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -35,12 +35,22 @@ jobs: "$MCPP_FRESH" --version echo "MCPP_SELF=$MCPP_FRESH" >> "$GITHUB_ENV" + - name: The first home menu is aligned in a native Windows console + shell: bash + run: MCPP="$MCPP_SELF" python .github/tools/check_windows_home_menu.py + - name: Unit + integration tests via mcpp test shell: bash run: | export MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe") "$MCPP_SELF" test + - name: Home locators preserve Unicode under a legacy Windows code page + shell: bash + run: | + export MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe") + MCPP="$MCPP_SELF" python .github/tools/check_windows_home_legacy.py + # WS8: this row has Visual Studio, so its answer is the "Windows with # usable MSVC" row of docs/01 and docs/20. - name: The documented default toolchain is this host's answer diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 11752564d..ef0dc1e9b 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -131,7 +131,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -323,7 +323,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb962774a..31b0f3b80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' run: | # BY VERSION, NOT BY PRESENCE. A restored ~/.xlings holds the # xlings of an earlier release, and this binary is the one the @@ -329,7 +329,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -400,7 +400,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.10.17.1-linux-aarch64.tar.gz" + XLA="xlings-2026.10.19.1-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -409,9 +409,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.10.17.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.10.19.1/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.10.17.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.10.19.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -489,7 +489,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' run: | # By version, not by presence (see the Linux job). have=$("$HOME/.xlings/subos/default/bin/xlings" --version 2>/dev/null | awk '{print $2}') @@ -676,7 +676,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.10.17.1' + XLINGS_VERSION: '2026.10.19.1' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index c60258300..235b49d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ > 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.19.1] - 2026-10-19 + +### Added + +- `mcpp self init --home ` initializes a home and remembers it through a + single-hop locator under the original home. `--force --home` changes that + default without moving or deleting either home's data. The environment and + portable installation retain their original home-selection precedence. +- The first interactive Windows initialization offers a writable local D drive + and the user directory as two choices. Enter selects the displayed default; + `--home` supplies another location without a prompt. +- `mcpp self doctor` reports resource reachability, HTTP failures and proxy use + through the configured provider. Discovery and probes share a five-second + deadline; offline diagnosis starts no network requests. + +### Fixed + +- Local environment/configuration queries, toolchain listing and diagnosis no + longer bootstrap the private registry. Mirror recovery writes local settings + without waiting for downloads. Planning an installed toolchain does not + require the provider's executable or initialize its home. Status queries are + skipped when a custom provider lacks the read-only guarantee. +- First-run installations report their actual download source and candidate + attempt. Connect, byte-idle and pre-download budgets bound network waits; + heartbeat output cannot keep a stalled transfer alive. Parent cancellation + stops the provider's process tree, and network failures are not repeated + through a second install frontend. + +### Changed + +- Bundle xlings 2026.10.19.1 with read-only network diagnostics and configurable + download socket budgets. Verified SHA256 cache hits make no background HEAD + request; the provider retains ownership of mirror selection and failover. +- Homebrew and Nix notifications consume the immutable sealed release manifest + after all platform assets have been validated. + ## [2026.10.17.1] - 2026-10-17 ### Fixed diff --git a/docs/25-home-indices-and-store.md b/docs/25-home-indices-and-store.md index 39c9cabdb..cc10dbb78 100644 --- a/docs/25-home-indices-and-store.md +++ b/docs/25-home-indices-and-store.md @@ -43,6 +43,60 @@ A home created by an earlier mcpp keeps working. Its index data moves to built against them keeps running. The markers an earlier mcpp wrote into payloads (`.mcpp_ok`, `.mcpp-fixup.json`) are no longer read. +### Selecting the home (2026.10.19.1+) + +Choose a directory before the first initialization: + +```powershell +mcpp self init --home D:\mcpp +``` + +The selection persists across processes without setting an environment variable. +The original home is chosen by `MCPP_HOME`, then a portable installation root, +then the user's `.mcpp`. Its `home.toml` names the effective home; mcpp reads +that file once. By default the original and effective directories are the same. +A locator in the target directory is ignored. `MCPP_HOME` is never rewritten. + +An existing initialized home requires an explicit switch: + +```powershell +mcpp self init --force --home E:\mcpp +``` + +This saves the new default and initializes it. Old data and existing target data +remain in place. `mcpp self config` and `mcpp self doctor` report the original, +effective and previous directories when relevant. The previous path is a report, +not a migration or automatic cleanup instruction. Nested original/target homes +are refused. Selecting the original directory removes its locator. + +On Windows, the first explicit `self init` may offer `D:\mcpp` and the user's +`.mcpp` when D is a writable local fixed drive and stdin/stderr are terminals. +An existing `D:\mcpp` that is a file or reparse point is not suggested; an explicit +`--home` still selects a custom path. Enter selects D; only `1`, `2` and an empty line are accepted. Other input exits +with a `--home` hint. There is no menu on Linux, macOS or noninteractive runs, +or when an environment, portable installation or saved selection supplies home. + +`self init --force` without `--home` retains its registry/cache reset semantics. +For a redirected home it names the effective target before resetting it. +`--system self init --home` is refused. The exact selection and persistence +rules are in [SPEC-010](specs/home-initialization.md). + +### Initialization and network diagnosis (2026.10.19.1+) + +`self env`, `self config`, `self doctor` and `toolchain list` read local state +without installing tools. `self config --mirror auto|CN|GLOBAL` updates only local +configuration, so it remains usable while the network is broken. + +Before a missing bootstrap tool requires network access, initialization displays +bounded checks of the provider's configured resources. `self doctor` reports the +same checks as warnings; local errors keep their meaning. `--offline` skips the +checks and acquisition. Installed tools and cached packages remain usable. +A failed probe does not disable a source or block local reuse. + +The locator is saved after local validation, before acquisition. An incomplete +offline or failed-network init returns 1 while retaining the chosen directory. +Rerunning `self init` continues there. + ## 2. Refreshing an index An index mcpp owns is fetched into a staging directory beside it, checked there @@ -158,3 +212,11 @@ setting; `mcpp self doctor` reports it. A program's own manifest states whether it is long-path aware: `windows_long_paths` ([04](04-mcpp-toml.md)). + +## Current limitations + +The home locator follows one hop only. It does not migrate or remove old data, and nested original/target directories are refused. The Windows drive picker is limited to a first explicit terminal initialization; a custom path uses `--home`. Network probes diagnose a resource at that moment and cannot guarantee a later download. + +Failure categories depend on the transport details available to the provider. Some TLS handshake failures appear as `connect`; that status does not prove that TCP itself was refused. + +Status queries require the provider's `read_only_queries` feature. A custom older provider without it is skipped with a diagnostic warning; local checks continue without installing a replacement. The binary handshake runs in an isolated temporary home. diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index 9d9fd50f1..7fb52c80a 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -186,7 +186,7 @@ mcpp --protocol-version "envelope": { "min": 1, "max": 1 }, "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 }, "commands": { - "self env": { "effects": ["init-mcpp-home"] }, + "self env": { "effects": [] }, "xpkg parse": { "effects": [] }, "cache list": { "effects": [] } } @@ -302,6 +302,9 @@ mcpp self env --format json { "initialized": false, // is there a config.toml yet? "mcppHome": "/home/u/.mcpp", + "originalHome":"/home/u/.mcpp", + "homeSource": "user", + "homeLocator": "", "registry": "/home/u/.mcpp/registry", "xlingsHome": "/home/u/.mcpp/registry", "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", @@ -313,11 +316,12 @@ mcpp self env --format json } ``` -This path is read-only, deliberately. The human `mcpp self env` initialises -`$MCPP_HOME` if it is missing — someone typing it at a prompt expects that — -but a client asking *where things are* should not be what puts them there. On -a machine that has never run mcpp, the output is the paths it **would** use and -`initialized: false`, and the disk is untouched. +Both human and machine `self env` queries are read-only (2026.10.19.1+). +On a cold home they report the paths that would be used and `initialized: false`. +`originalHome` is the locator directory; `mcppHome` is the effective data home. +`homeSource` names the effective selection (`locator` when redirected, otherwise +`environment`, `portable`, `user` or `system`); `homeLocator` is empty without a +redirection. These additive fields do not create any files. That is why this exists at all: without it a client has to reimplement mcpp's home resolution, including the part where the `mcpp` on `PATH` may be an diff --git a/docs/README.md b/docs/README.md index 5de8f3798..fd963c6ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -167,3 +167,4 @@ downstream tooling. - [SPEC-007 — Build plugins: configuration, construction and verification, and the runtime and planning obligations](specs/build-plugins.md) - [SPEC-008 — A library's interface: public modules, the published closure, and one interface in both forms](specs/library-interface.md) - [SPEC-009 — The support and maintenance of toolchains: lines, defaults, provenance, moves and retirement](specs/toolchain-maintenance.md) + - [SPEC-010 — Home selection and initialization](specs/home-initialization.md) diff --git a/docs/specs/README.md b/docs/specs/README.md index b39d6dd3b..8bde8f09f 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -39,6 +39,7 @@ | [SPEC-007](build-plugins.md) | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | 草案 v0.9 | 2026-10-11 | 逐条标注;mcpp >= 2026.9.26.2;v0.3 条款 mcpp >= 2026.9.27.1;v0.4 条款 mcpp >= 2026.9.28.1;v0.5 条款 mcpp >= 2026.9.28.2;v0.6(§9)mcpp >= 2026.9.28.3;R3.9、R3.10 mcpp >= 2026.10.10.1;§10 mcpp >= 2026.10.11.1 | | [SPEC-008](library-interface.md) | 库的接口:公开模块、发布闭包与两种形态的一致 | 草案 v0.2 | 2026-10-11 | 第一阶段(只警告)mcpp >= 2026.9.28.3;v0.2 mcpp >= 2026.10.11.1 | | [SPEC-009](toolchain-maintenance.md) | 工具链的支持与维护:版本线、默认值、来源、移动与退役 | 草案 v0.6 | 2026-10-09 | 逐条标注;本版只有规范,多数条款未实现;§6.4 mcpp >= 2026.10.8.1 | +| [SPEC-010](home-initialization.md) | home 选择、单跳入口、初始化与网络边界 | 草案 v0.1 | 2026-10-19 | mcpp >= 2026.10.19.1;网络能力逐条标注 | ## 文档约定 diff --git a/docs/specs/home-initialization.md b/docs/specs/home-initialization.md new file mode 100644 index 000000000..621773f21 --- /dev/null +++ b/docs/specs/home-initialization.md @@ -0,0 +1,102 @@ +# SPEC-010: home 选择与初始化 + +| 项 | 值 | +|---|---| +| 规范编号 | SPEC-010 | +| 标题 | home 的入口、实际目录、初始化阶段与网络边界 | +| 状态 | 草案 v0.1 | +| 最后修改 | 2026-10-19 | +| 对应实现 | mcpp >= 2026.10.19.1;网络预算依赖提供者能力,逐条标注 | +| 相关设计文档 | `.agents/docs/2026-10-19-first-run-home-and-network-design.md` | +| 相关 issue | mcpp#822;openxlings/xlings#683 | +| 使用文档 | [25 — home、索引与存储](../zh/25-home-indices-and-store.md) | + +本规范定义普通用户 home 的选择、保存与初始化边界。系统 home 的管理员权限和共享策略继续按使用文档执行。 +用语按 RFC 2119:**必须 / 禁止**为强制,**应当**为建议,**可以**为可选。 + +## 1. 两阶段解析 + +1. 原始 home **必须**按非空 `MCPP_HOME`、可识别自包含安装根、用户 `.mcpp` 的顺序选择。 +2. 实际 home **必须**按 `self init --home` 显式目标、原始 home 的 `home.toml` 目标、原始 home 本身选择。 +3. 默认原始 home 与实际 home 相同。选择过程**禁止**改写 `MCPP_HOME`,**禁止**读取目标中的第二个 locator。 +4. `MCPP_HOME` 与自包含安装**必须**只读取自己的原始目录,**禁止**读取另一份用户默认 locator。 +5. 系统 home **禁止**采用用户 locator;`--system self init --home` **必须**拒绝。 + +## 2. 入口文件 + +`<原始 home>/home.toml` 保存绝对路径,字符串采用 TOML 转义: + +```toml +home = "D:/mcpp" +previous_home = "C:/Users/user/.mcpp" +``` + +`home` **必须**为可访问的目录。`previous_home` 为可选报告字段,最多保存前一实际目录, +**禁止**作为回退位置或第二跳。解析或目录访问失败**必须**显式报错,提示使用 +`mcpp self init --force --home ` 修复,**禁止**静默改用其他 home。 + +实际路径采用绝对、规范化形式;原始目录与目标互为祖先或后代时**必须**拒绝,相同目录可以使用。 +写入口**必须**在原始目录的写锁内,以关闭后的同目录临时文件原子替换; +普通读取**禁止**要求目录可写。切回原始目录**必须**删除其 locator,保留两边其他内容。 + +## 3. 切换与 force + +| 命令 | 必须执行的语义 | +|---|---| +| `self init --home P` | 初始化 P 并设为默认;已有不同的初始化后 home 时拒绝并提示 force | +| `self init --force --home P` | 授权覆盖默认指向;禁止删除旧 home 或清空目标已有数据 | +| `self init --force` | 保留当前 registry/cache 重置语义;重定向时先标明实际目标与保留入口 | +| `self config`、`self doctor` | 相关场景报告原始、实际和前一 home;禁止每条普通命令重复提示 | + +切换**禁止**迁移数据。存在旧数据时,原始 `.mcpp` 可以同时保留数据和 locator。 +入口**必须**在本地校验、配置初始化成功后保存,在网络获取前发布。 +网络失败或离线缺资源**必须**返回 init incomplete(退出码 1),保留已保存选择,供下次继续。 + +## 4. Windows 首次交互 + +菜单仅适用于 Windows 首次显式 init:未设置 `MCPP_HOME`、非自包含、无 locator、 +无初始化后的 home、stdin/stderr 均连接终端,且 D 为可写的本地固定磁盘。 +已有 `D:\mcpp` 必须为非重解析点目录;缺失可作为候选,属性读取失败则不推荐。 +不得为判断推荐资格而跟随重解析点;显式 `--home` 仍按路径选择规则验证目标。 +Linux/macOS 和非交互运行**禁止**显示菜单。 + +```console + Choose the mcpp home: + + 1) D:\mcpp [default] + 2) C:\Users\user\.mcpp + + Select [1/2, Enter=1]: +``` + +Choose 与 Select **必须**左对齐,标题与选项隔一空行。只接受 `1`、`2`、空行; +空行选择 1。其他输入**必须**退出 2 并提示 `--home`;EOF 和 Ctrl+C 取消,不保存选择。 + +## 5. 配置与网络边界 + +只读查询(env/config/doctor/toolchain list)**禁止**隐式安装或 bootstrap; +mirror 更改只写本地配置。配置读取、本地初始化、能力获取为独立阶段。 + +首次缺少 bootstrap 能力时,在第一个可能联网的提供者初始化前**必须**报告网络检查。 +所有端点发现和资源探测共用 5 秒 wall-clock 预算,由父进程终止超时子进程。 +探测**必须**使用提供者实际配置的资源、代理与 TLS 策略,**禁止**仅以域名 TCP 成功认定资源可用。 +失败只影响诊断,**禁止**阻止已安装能力复用。doctor 网络失败只计警告,本地错误保留退出语义。 +`--offline` **禁止**启动探测或下载。 + +提供者元数据握手**必须**在隔离临时目录中读取,避免旧提供者在握手前改写原 home。 +状态查询要求 `read_only_queries` 能力;缺失时**必须**跳过查询,继续本地诊断, +**禁止**为了诊断安装替代提供者。 + +下载连接上限 8 秒、无字节进展上限 30 秒;已有更短限制保留。 +heartbeat 或重复字节值**禁止**更新下载进展时间。小型索引请求保留 120 秒总保护, +大型安装保留 3 小时最终进程保护。实际下载源和候选重试次数来自提供者事件, +**禁止**将探测成功的候选伪报为实际下载来源。 + +提供者缺失或不支持诊断时**必须**报告 unavailable/unsupported,继续本地检查, +**禁止**为了 doctor 下载提供者。旧提供者不能满足新下载预算时,应按能力握手选择兼容的随附版本。 + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v0.1 | 2026-10-19 | 定义两阶段单跳 home、非删除切换、Windows 选择与初始化网络边界 | diff --git a/docs/zh/25-home-indices-and-store.md b/docs/zh/25-home-indices-and-store.md index 95de6b60e..96ae666a7 100644 --- a/docs/zh/25-home-indices-and-store.md +++ b/docs/zh/25-home-indices-and-store.md @@ -33,6 +33,52 @@ mcpp 经由其包提供者 xlings 安装工具链与包。payload(一个包的 `registry/data/xpkgs` 下的 payload 留在原处且仍能找到,基于它们构建的产物继续可以运行。早期 mcpp 写进 payload 的标记(`.mcpp_ok`、`.mcpp-fixup.json`)不再被读取。 +### 选择 home(2026.10.19.1+) + +在首次初始化前指定目录: + +```powershell +mcpp self init --home D:\mcpp +``` + +选择会跨进程保留,不需要设置环境变量。原始 home 按 `MCPP_HOME`、自包含安装根目录、 +用户目录下 `.mcpp` 的顺序选择。其 `home.toml` 指向实际使用的 home,mcpp 只读取一次。 +默认情况下原始目录与实际目录相同,目标目录中的 locator 不参与解析。`MCPP_HOME` 不会被改写。 + +已有初始化后的 home 时,需要显式切换: + +```powershell +mcpp self init --force --home E:\mcpp +``` + +该操作保存新默认位置并初始化,旧目录和目标目录已有数据均保留。 +`mcpp self config` 和 `mcpp self doctor` 在相关场景报告原始目录、实际目录与前一目录。 +前一目录是报告信息,不代表迁移或自动清理。原始目录与目标目录互相嵌套时拒绝操作。 +重新选择原始目录会删除其 locator。 + +Windows 首次显式 `self init` 时,若 D 是可写的本地固定磁盘,且 stdin/stderr 均为终端, +可以提供 `D:\mcpp` 与用户 `.mcpp` 两项选择。已有 `D:\mcpp` 为文件或重解析点时不推荐; +仍可用显式 `--home` 指定路径。Enter 选择 D;仅接受 `1`、`2` 与空行, +其他输入退出并提示使用 `--home`。Linux、macOS、非交互运行,以及已有环境变量、 +自包含安装或已保存选择的场景均无菜单。 + +不带 `--home` 的 `self init --force` 保留原有 registry/cache 重置语义; +重定向场景会先标明实际目标。`--system self init --home` 被拒绝。 +完整选择与持久化规则见 [SPEC-010](../specs/home-initialization.md)。 + +### 初始化与网络诊断(2026.10.19.1+) + +`self env`、`self config`、`self doctor` 和 `toolchain list` 读取本地状态,不安装工具。 +`self config --mirror auto|CN|GLOBAL` 仅更新本地配置,因此网络故障时仍可使用。 + +初始化因缺少基础工具而需要联网前,会显示提供者已配置资源的有界检查结果。 +`self doctor` 将同类检查结果作为警告报告,本地错误的语义保持不变。 +`--offline` 跳过检查与获取;已有工具和缓存包仍可使用。 +探测失败不会禁用来源或阻止本地复用。 + +locator 在本地校验成功后、获取资源之前保存。离线缺资源或联网失败导致初始化未完成时, +退出码为 1,所选目录仍保留。再次运行 `self init` 会在该目录继续。 + ## 2. 刷新索引 mcpp 所有的索引被拉取到它旁边的暂存目录,在那里按其声明的下限(`index.toml` 的 `min_mcpp`)检查,只有当前 mcpp @@ -121,3 +167,11 @@ Windows 上路径长度限制为 260 个字符,除非系统启用长路径(` `MCPP_HOME`。mcpp 不修改该设置;`mcpp self doctor` 报告它。 程序自己的清单陈述它是否支持长路径:`windows_long_paths`([04](04-mcpp-toml.md))。 + +## 当前边界 + +home locator 仅解析一跳,不迁移或删除旧数据,原始目录与目标目录互相嵌套时拒绝。Windows 磁盘选择仅用于首次显式终端初始化;自定义路径使用 `--home`。网络探测只反映当时的资源状态,不能保证后续下载成功。 + +故障分类取决于提供者能获得的传输细节。部分 TLS 握手失败显示为 `connect`;该状态不代表 TCP 连接本身一定被拒绝。 + +状态查询要求提供者声明 `read_only_queries` 能力。自定义旧提供者缺少该能力时跳过查询并给出诊断警告,本地检查继续,也不会为此安装替代程序。二进制握手在隔离的临时 home 中执行。 diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index 6826244b5..7db462f0d 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -157,7 +157,7 @@ mcpp --protocol-version "envelope": { "min": 1, "max": 1 }, "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 }, "commands": { - "self env": { "effects": ["init-mcpp-home"] }, + "self env": { "effects": [] }, "xpkg parse": { "effects": [] }, "cache list": { "effects": [] } } @@ -265,6 +265,9 @@ mcpp self env --format json { "initialized": false, // is there a config.toml yet? "mcppHome": "/home/u/.mcpp", + "originalHome":"/home/u/.mcpp", + "homeSource": "user", + "homeLocator": "", "registry": "/home/u/.mcpp/registry", "xlingsHome": "/home/u/.mcpp/registry", "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", @@ -276,11 +279,12 @@ mcpp self env --format json } ``` -这条命令刻意是只读的。人类使用的 `mcpp self env` 会在 `$MCPP_HOME` 缺失时 -初始化它 —— 在提示符下敲这条命令的人预期它会这样做 —— 但一个客户端**询问 -东西在哪**,不应该成为把东西放到那里的原因。在一台从未运行过 mcpp 的机器 -上,得到的输出是它**将会**使用的那些路径,加上 `initialized: false`,而磁盘 -未被触碰。 +人类和机器使用的 `self env` 查询均为只读(2026.10.19.1+)。 +冷 home 下返回将使用的路径和 `initialized: false`。 +`originalHome` 是 locator 所在目录,`mcppHome` 是实际数据目录; +`homeSource` 表示实际目录的选择来源(重定向时为 `locator`,否则为 +`environment`、`portable`、`user` 或 `system`),未重定向时 `homeLocator` 为空。 +这些新增字段不会创建文件。 这也是这个 kind 存在的理由:没有它,客户端就得自己重新实现一遍 mcpp 的 home 解析逻辑 —— 包括「PATH 上的 `mcpp` 可能是一个 xlings shim 而不是真正的 diff --git a/docs/zh/README.md b/docs/zh/README.md index 74cf6f5be..69855f076 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -162,3 +162,4 @@ - [SPEC-007 —— 构建插件:配置、施工与校验的分工,运行时与规划期的义务](../specs/build-plugins.md) - [SPEC-008 —— 库的接口:公开模块、发布闭包与两种形态的一致](../specs/library-interface.md) - [SPEC-009 —— 工具链的支持与维护:版本线、默认值、来源、移动与退役](../specs/toolchain-maintenance.md) + - [SPEC-010 —— home 选择与初始化](../specs/home-initialization.md) diff --git a/install.sh b/install.sh index dc472327e..1afa65a61 100755 --- a/install.sh +++ b/install.sh @@ -51,7 +51,11 @@ trap 'rm -rf "$WORK"' EXIT GH_REL="https://github.com/${REPO}/releases" GTC_API="https://api.gitcode.com/api/v5/repos/xlings-res/mcpp" GTC_DL="https://gitcode.com/xlings-res/mcpp/releases/download" -CT=8 # connect timeout (s) for the GitHub probe before falling back +# curl is the installer's existing transport. Bound both mirrors and metadata; +# 1 B/s is its smallest nonzero low-speed threshold, over a 30-second window. +CURL_TRANSFER=(--connect-timeout 8 --speed-limit 1 --speed-time 30) +CURL_ARCHIVE=("${CURL_TRANSFER[@]}" --max-time 10800) +CURL_METADATA=("${CURL_TRANSFER[@]}" --max-time 120) fetch_github() { local url @@ -62,7 +66,7 @@ fetch_github() { fi echo ":: Trying GitHub: ${url}" if curl --fail --location --silent --show-error \ - --connect-timeout "$CT" -o "$WORK/mcpp.tar.gz" "$url"; then + "${CURL_ARCHIVE[@]}" -o "$WORK/mcpp.tar.gz" "$url"; then SHA_URL="${url}.sha256"; SOURCE="GitHub"; return 0 fi return 1 @@ -74,7 +78,7 @@ fetch_gitcode() { # GitCode has no 'latest' redirect — resolve the highest release tag. # per_page=100: the default page (20) lists oldest-first and misses # recent versions, so a plain sort would pick a stale release. - ver=$(curl --fail --location --silent --connect-timeout "$CT" \ + ver=$(curl --fail --location --silent "${CURL_METADATA[@]}" \ "${GTC_API}/releases?per_page=100" 2>/dev/null \ | grep -oE '"tag_name":"[^"]+"' | sed 's/.*:"//; s/"$//' \ | sort -V | tail -1) @@ -82,7 +86,7 @@ fetch_gitcode() { fi local url="${GTC_DL}/${ver}/mcpp-${ver}-${PLAT}.tar.gz" echo ":: Trying GitCode (CN mirror): ${url}" - if curl --fail --location --silent --show-error -o "$WORK/mcpp.tar.gz" "$url"; then + if curl --fail --location --silent --show-error "${CURL_ARCHIVE[@]}" -o "$WORK/mcpp.tar.gz" "$url"; then SHA_URL="${url}.sha256"; SOURCE="GitCode"; VERSION="$ver"; return 0 fi return 1 @@ -97,7 +101,7 @@ else fi echo ":: Downloaded from ${SOURCE}" # .sha256 sidecar is GitHub-only; GitCode mirror ships tarballs without it. -curl --fail --location --silent --show-error -o "$WORK/mcpp.sha256" "$SHA_URL" 2>/dev/null || { +curl --fail --location --silent --show-error "${CURL_METADATA[@]}" -o "$WORK/mcpp.sha256" "$SHA_URL" 2>/dev/null || { echo "warning: no .sha256 sidecar (${SOURCE}), skipping verification" >&2 } diff --git a/mcpp.toml b/mcpp.toml index 2622ec479..f25c9df9b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.10.17.1" +version = "2026.10.19.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/platform/src/process.cppm b/modules/platform/src/process.cppm index 862dacec4..7e38ea43f 100644 --- a/modules/platform/src/process.cppm +++ b/modules/platform/src/process.cppm @@ -222,6 +222,15 @@ int run_shell_deadline(std::string_view command, 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 @@ -264,14 +273,17 @@ 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. +// 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); + 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 @@ -315,6 +327,11 @@ int run_streaming(std::string_view command, // 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 @@ -324,7 +341,8 @@ int run_streaming_bounded(std::string_view command, std::chrono::milliseconds total, std::chrono::milliseconds idle, bool* timed_out, - bool split_on_cr = false); + 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. @@ -1120,7 +1138,8 @@ BoundedOutcome dispatch_bounded( std::chrono::milliseconds idle = std::chrono::milliseconds{0}, bool ownGroup = false, const std::function* on_line = nullptr, - bool splitOnCr = false) + bool splitOnCr = false, + const std::function* should_stop = nullptr) { BoundedOutcome outcome; @@ -1147,7 +1166,11 @@ BoundedOutcome dispatch_bounded( std::string* buffer; const std::function* on_line; bool splitOnCr; - } streamCtx{ &outcome.output, on_line, 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 @@ -1185,7 +1208,7 @@ BoundedOutcome dispatch_bounded( ? 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); + 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; @@ -1197,7 +1220,7 @@ BoundedOutcome dispatch_bounded( auto r = mcpp::platform::unixproc::capture_with_deadline( argvPtrs.data(), static_cast(argvPtrs.size()), envArg, envCount, cwdArg, ms, idleMs, ownGroup ? 1 : 0, - sink, &streamCtx); + sink, &streamCtx, stop); outcome.supported = r.supported; outcome.exit_code = r.exit_code; outcome.timed_out = r.timed_out; @@ -1282,12 +1305,36 @@ int run_shell_deadline(std::string_view command, 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) + 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; @@ -1299,7 +1346,7 @@ int run_streaming_bounded(std::string_view command, 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); + /*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; @@ -1418,15 +1465,21 @@ RunResult capture_exec_deadline( std::chrono::milliseconds deadline, bool* timed_out, std::string_view cwd, - int* spawn_error) + int* spawn_error, + bool ownGroup) { if (timed_out) *timed_out = false; if (spawn_error) *spawn_error = 0; - if (deadline.count() <= 0) return capture_exec(argv, extraEnv, cwd, spawn_error); 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); + 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 @@ -1439,6 +1492,7 @@ RunResult capture_exec_deadline( 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; diff --git a/modules/platform/src/terminal.cppm b/modules/platform/src/terminal.cppm index b42df85ce..659dcdd87 100644 --- a/modules/platform/src/terminal.cppm +++ b/modules/platform/src/terminal.cppm @@ -41,6 +41,9 @@ 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 { @@ -49,6 +52,7 @@ 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 @@ -211,6 +215,13 @@ bool is_terminal(Stream s) { #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) { diff --git a/modules/platform/src/unix/bounded_process.cppm b/modules/platform/src/unix/bounded_process.cppm index ede5ebc2c..abf28ad93 100644 --- a/modules/platform/src/unix/bounded_process.cppm +++ b/modules/platform/src/unix/bounded_process.cppm @@ -78,6 +78,7 @@ struct DeadlineRun { }; using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); +using StopPoll = bool (*)(void* ctx); // ─── Starting a child is safe for concurrent callers ───────────────────── // @@ -150,7 +151,8 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, long long idleMs, int ownGroup, OutputSink sink, - void* ctx); + void* ctx, + StopPoll stop); // ─── A child that outlives the call that started it (#496) ─────────────── // @@ -269,7 +271,8 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, long long idleMs, int ownGroup, OutputSink sink, - void* ctx) + void* ctx, + StopPoll stop) { DeadlineRun out; const bool idleBound = idleMs > 0 && sink != nullptr; @@ -365,7 +368,7 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, if (!capture) return false; ssize_t n; bool any = false; - while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) { + 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; } @@ -374,6 +377,12 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, }; 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); @@ -667,7 +676,7 @@ void install_handlers() { DeadlineRun capture_with_deadline(const char* const*, unsigned long, const char* const*, unsigned long, const char*, long long, long long, int, - OutputSink, void*) { + OutputSink, void*, StopPoll) { // Not POSIX: mcpp.platform.windows.bounded_process owns this. return {}; } diff --git a/modules/platform/src/unix/terminal.cppm b/modules/platform/src/unix/terminal.cppm new file mode 100644 index 000000000..1bf097585 --- /dev/null +++ b/modules/platform/src/unix/terminal.cppm @@ -0,0 +1,22 @@ +// 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 index 3185bc5a9..6ab1594f7 100644 --- a/modules/platform/src/windows/bounded_process.cppm +++ b/modules/platform/src/windows/bounded_process.cppm @@ -120,6 +120,7 @@ private: // 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 @@ -142,7 +143,8 @@ DeadlineRun capture_with_deadline(const char* commandLine, long long deadlineMs, long long idleMs, OutputSink sink, - void* ctx); + void* ctx, + StopPoll stop); // ─── A child that outlives the call that started it (#496) ─────────────── // @@ -321,7 +323,8 @@ DeadlineRun capture_with_deadline(const char* commandLine, long long deadlineMs, long long idleMs, OutputSink sink, - void* ctx) + void* ctx, + StopPoll stop) { DeadlineRun out; const bool idleBound = idleMs > 0 && sink != nullptr; @@ -442,6 +445,12 @@ DeadlineRun capture_with_deadline(const char* commandLine, }; 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) { @@ -731,7 +740,7 @@ int wait_background(unsigned long long process, int* exitCode) { #else DeadlineRun capture_with_deadline(const char*, const char* const*, unsigned long, - const char*, long long, long long, OutputSink, void*) { + const char*, long long, long long, OutputSink, void*, StopPoll) { // Not Windows: the POSIX launcher in mcpp.platform.process owns this. return {}; } diff --git a/modules/platform/src/windows/windows.cppm b/modules/platform/src/windows/windows.cppm index 85b028392..679b21fe5 100644 --- a/modules/platform/src/windows/windows.cppm +++ b/modules/platform/src/windows/windows.cppm @@ -58,9 +58,51 @@ std::optional env_directory(const char* name); // `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 { diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 79590a6ff..d7e6e36db 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.17.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.10.19.1"; } // namespace mcpp diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index d817caade..dfebc34bb 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -126,13 +126,16 @@ static std::expected step1_define_early_toolchain_closures(Pr state.get_cfg = [&](bool requireBootstrap = true) -> std::expected { if (!state.cfg_opt) { auto c = mcpp::config::load_or_init(/*quiet=*/false, - mcpp::fetcher::make_bootstrap_progress_callback()); + mcpp::fetcher::make_bootstrap_progress_callback(), {}, + state.overrides.plan_only ? mcpp::config::Initialization::ReadOnly + : mcpp::config::Initialization::Bootstrap); if (!c) return std::unexpected(c.error().message); state.cfg_opt = std::move(*c); } - // Commands that need bootstrap tools (build, run, toolchain install) - // pass requireBootstrap=true to get an early, clear error. - if (requireBootstrap && !state.bootstrap_checked) { + // Execution requires the bootstrap tools. A planning pass reads the + // installed toolchain without requiring an initialized provider or + // installing tools it will not execute. + if (requireBootstrap && !state.overrides.plan_only && !state.bootstrap_checked) { state.bootstrap_checked = true; auto problem = mcpp::config::check_base_init(*state.cfg_opt); if (!problem.empty()) { diff --git a/src/cli.cppm b/src/cli.cppm index bac2dc747..e4ea4218a 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -84,7 +84,8 @@ void print_usage() { std::println("About mcpp itself:"); std::println(" mcpp self doctor Diagnose mcpp environment health"); std::println(" mcpp self env Print mcpp paths and toolchain"); - std::println(" mcpp self config [--mirror CN|GLOBAL] Show or modify mcpp's xlings config"); + std::println(" mcpp self init [--force] [--home PATH] Initialize or select a remembered home"); + std::println(" mcpp self config [--mirror auto|CN|GLOBAL] Show or modify mcpp's xlings config"); std::println(" mcpp self version Show mcpp version"); std::println(" mcpp self explain Show extended description for an error code"); std::println(" mcpp --help / --version Help / version"); @@ -276,6 +277,13 @@ int run(int argc, char** argv) { // say. Building as root would write root's files into a user's project, // so no other command takes it. if (std::ranges::any_of(std::span(argv + 1, argv + argc), [](const char* a) { return std::string_view(a) == "--system"; })) { + if (std::ranges::any_of(std::span(argv + 1, argv + argc), [](const char* a) { + const std::string_view arg{a}; + return arg == "--home" || arg.starts_with("--home="); + })) { + mcpp::ui::error("--home cannot be combined with --system"); + return 2; + } if (int rc = enter_system_home(argc, argv); rc != 0) return rc; } // Decline xlings' linker-wrapper path injection, for this process and @@ -962,8 +970,10 @@ int run(int argc, char** argv) { .description("Inspect and manage mcpp itself") .subcommand(cl::App("init") .description("Initialize or repair mcpp sandbox") + .option(cl::Option("home").takes_value().value_name("PATH") + .help("Initialize PATH and remember it from the original home (does not migrate data)")) .option(cl::Option("force") - .help("Delete registry and re-initialize from scratch"))) + .help("Rebuild registry/caches; with --home, replace the default without deleting data"))) .subcommand(cl::App("doctor") .description("Diagnose mcpp environment health")) .subcommand(cl::App("env") @@ -972,7 +982,7 @@ int run(int argc, char** argv) { .help("Machine-readable output (enveloped; see docs/50-machine-output.md)"))) .subcommand(cl::App("config") .description("Show or modify mcpp's private xlings configuration") - .option(cl::Option("mirror").takes_value().value_name("CN|GLOBAL") + .option(cl::Option("mirror").takes_value().value_name("auto|CN|GLOBAL") .help("Set xlings mirror for mcpp's private registry"))) .subcommand(cl::App("version") .description("Show mcpp version")) @@ -1431,17 +1441,15 @@ int run(int argc, char** argv) { // protocol module that knew the command list would mean adding a command // in one file and remembering to describe it in another. // - // `self env` carries `init-mcpp-home` because on a fresh machine it does - // create $MCPP_HOME -- measured: six entries, where `xpkg parse` and - // `cache list` create none. Naming the effect rather than flagging a - // boolean lets an IDE ignore this one and still refuse `exec-build-script`. + // Path and toolchain queries read defaults in memory. Only commands + // that request initialization or provisioning declare home creation. auto protocol_commands = [] { using mcpp::wire::Effect; return std::vector{ - {"self env", {Effect::InitMcppHome}}, + {"self env", {}}, {"xpkg parse", {}}, {"cache list", {}}, - {"toolchain list", {Effect::InitMcppHome}}, + {"toolchain list", {}}, // `why toolchain` DECLARES MORE THAN IT USUALLY DOES, AND THAT // IS THE CORRECT DIRECTION. // diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index 1c574d240..5097cffdf 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -25,26 +25,15 @@ namespace mcpp::cli { // `self env --format json`. // -// Deliberately NOT `env_report()` plus a serializer. That path calls -// `config::load_or_init()`, which on a machine that has never run mcpp -// creates $MCPP_HOME -- measured: `config.toml`, `registry/`, `cache/`, -// `bin/`, `build-cache/`, `log/`, six entries, where `mcpp --version`, -// `xpkg parse` and `cache list` create none. Reporting where things ARE -// should not be what puts them there. -// -// So the machine path computes paths and reads what already exists. When -// nothing does, it says so (`initialized: false`) and reports the paths mcpp -// WOULD use, rather than creating them to be able to answer. A client asking -// "where is your home" on a fresh machine gets an answer and an untouched -// disk. -// -// The human path is unchanged: someone typing `mcpp self env` at a prompt -// expects mcpp to set itself up, and always has. +// Both human and machine queries are read-only. This serializer reads paths +// and existing configuration directly, without toolchain detection; a cold home +// reports initialized=false and is left untouched. namespace { nlohmann::json env_data_readonly() { namespace fs = std::filesystem; - const auto home = mcpp::home::root(); + const auto selected = mcpp::home::selection(); + const auto home = selected.effective; std::error_code ec; const auto registry = home / "registry"; @@ -55,6 +44,9 @@ nlohmann::json env_data_readonly() { return nlohmann::json{ {"initialized", initialized}, {"mcppHome", s(home)}, + {"originalHome", mcpp::home::spelling(selected.original)}, + {"homeSource", selected.redirected ? "locator" : selected.source}, + {"homeLocator", selected.redirected ? mcpp::home::spelling(selected.original / "home.toml") : ""}, {"registry", s(registry)}, {"xlingsHome", s(registry)}, // registryDir unless overridden {"xlingsBinary", s(registry / "bin" / @@ -149,7 +141,49 @@ export int cmd_self_version(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { } export int cmd_self_init(const mcpplibs::cmdline::ParsedArgs& parsed) { - return mcpp::doctor::self_init(parsed.is_flag_set("force")); + std::string target = parsed.option_or_empty("home").value(); + if (parsed.value("home") && target.empty()) { + std::println(stderr, "error: --home requires a non-empty directory"); + return 2; + } + if constexpr (mcpp::platform::is_windows) { + const auto origin = mcpp::home::original_root(); + std::error_code ec; + const auto* explicitHome = std::getenv("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 + && !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")) { + auto fallback = mcpp::home::spelling(origin); + std::ranges::replace(fallback, '/', '\\'); + mcpp::platform::terminal::write(mcpp::platform::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" + " 1) D:\\mcpp [default]\n" + " 2) {}\n\n" + " Select [1/2, Enter=1]: ", fallback)); + std::cerr.flush(); + std::string input; + if (!std::getline(std::cin, input)) return 1; + const auto first = input.find_first_not_of(" \t\r"); + input = first == std::string::npos ? "" + : input.substr(first, input.find_last_not_of(" \t\r") - first + 1); + if (input.empty() || input == "1") target = "D:/mcpp"; + else if (input == "2") target = fallback; + else { + std::println(stderr, " Error Invalid selection; expected 1 or 2\n" + " Hint For a custom path, run: mcpp self init --home "); + return 2; + } + } + } + return mcpp::doctor::self_init(parsed.is_flag_set("force"), target); } export int cmd_self_config(const mcpplibs::cmdline::ParsedArgs& parsed) { diff --git a/src/cli/cmd_toolchain.cppm b/src/cli/cmd_toolchain.cppm index ec2539989..a10b993bc 100644 --- a/src/cli/cmd_toolchain.cppm +++ b/src/cli/cmd_toolchain.cppm @@ -29,7 +29,9 @@ export int cmd_toolchain(const mcpplibs::cmdline::ParsedArgs& parsed) { // treatment any other download here gets, so the user isn't staring at // a quiet "Bootstrap…" line during a slow link. auto cfg = mcpp::config::load_or_init(/*quiet=*/false, - mcpp::fetcher::make_bootstrap_progress_callback()); + mcpp::fetcher::make_bootstrap_progress_callback(), {}, + subname == "list" ? mcpp::config::Initialization::ReadOnly + : mcpp::config::Initialization::Bootstrap); if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; } // The optional --target axis (install/default/remove): which target's diff --git a/src/config.cppm b/src/config.cppm index 2dde831a7..a7abf566a 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -249,6 +249,30 @@ std::string check_base_init(const GlobalConfig& cfg) { return {}; // all ok } +// Returns whether diagnosis found a warning. Network failure never prevents +// reuse of installed tools, and never changes the command's local errors. +bool report_network(const GlobalConfig& cfg, bool quiet = false) { + const auto report = mcpp::provider::open(make_xlings_env(cfg))->check_network(); + auto line = [&](std::string_view key, std::string_view value) { + if (!quiet) std::println(stderr, "{:>12} {}", key, value); + }; + if (report.status == "offline") { line("Network", "Skipped (offline mode)"); return false; } + if (report.status != "checked") { + line("Network", std::format("Check {} (local operations remain available)", report.status)); + return true; + } + bool failed = false; + if (report.endpoints.empty()) line("Network", "No remote index sources configured"); + for (const auto& e : report.endpoints) { + line("Network", std::format("{} {} {}: {}{}", e.resource, + e.region.empty() ? "custom" : e.region, e.host.empty() ? "endpoint" : e.host, + e.status, e.proxy ? " (proxy)" : "")); + failed |= !e.reachable; + } + if (failed) line("Hint", "Check proxy settings or use mcpp self config --mirror auto|CN|GLOBAL"); + return failed; +} + // Delete the registry directory (sandbox) for a clean re-init. // Preserves: bin/mcpp (self-contained mode), config.toml, log/. void reset_registry(const GlobalConfig& cfg) { @@ -292,6 +316,8 @@ using BootstrapFile = mcpp::provider::BootstrapFile; using BootstrapProgress = mcpp::provider::BootstrapProgress; using BootstrapProgressCallback = mcpp::provider::BootstrapProgressCallback; +enum class Initialization { ReadOnly, Local, Bootstrap }; + // `initial_mirror`: when seeding a *fresh* .xlings.json (file did not // already exist), use this as the mirror value instead of the default. // Empty means "use the default". Used by `mcpp self config --mirror X` @@ -302,7 +328,8 @@ using BootstrapProgressCallback = mcpp::provider::BootstrapProgressCallback; std::expected load_or_init( bool quiet = false, BootstrapProgressCallback onBootstrapProgress = {}, - std::string_view initial_mirror = {}); + std::string_view initial_mirror = {}, + Initialization initialization = Initialization::Bootstrap); // Pretty-print resolved config for `mcpp env` command. void print_env(const GlobalConfig& cfg); @@ -366,8 +393,8 @@ void write_file(const std::filesystem::path& p, std::string_view content) { os << content; } -bool write_default_config_toml(const std::filesystem::path& path) { - constexpr auto tmpl = R"(# mcpp global config — auto-generated; safe to edit. +std::string_view default_config_toml() { + return R"(# mcpp global config — auto-generated; safe to edit. [xlings] # binary: "bundled" (use $MCPP_HOME/registry/bin/xlings) | "system" | absolute path @@ -400,7 +427,10 @@ search_ttl_seconds = 3600 [build] default_jobs = 0 )"; - write_file(path, tmpl); +} + +bool write_default_config_toml(const std::filesystem::path& path) { + write_file(path, default_config_toml()); return std::filesystem::exists(path); } @@ -696,7 +726,8 @@ parse_payload_overrides(const mcpp::libs::toml::Document& doc) { std::expected load_or_init( bool quiet, BootstrapProgressCallback onBootstrapProgress, - std::string_view initial_mirror) + std::string_view initial_mirror, + Initialization initialization) { GlobalConfig cfg; @@ -733,15 +764,13 @@ std::expected load_or_init( // 2. Create directory tree std::error_code ec; - for (auto& d : { cfg.binDir, cfg.registryDir, cfg.bmiCacheDir, - cfg.metaCacheDir, cfg.logDir }) { - std::filesystem::create_directories(d, ec); - if (ec) return std::unexpected(ConfigError{ - std::format("cannot create '{}': {}", d.string(), ec.message())}); - } - - // 2b. Initialize logger (early init with defaults; re-init after config load) - { + if (initialization != Initialization::ReadOnly) { + for (auto& d : { cfg.binDir, cfg.registryDir, cfg.bmiCacheDir, + cfg.metaCacheDir, cfg.logDir }) { + std::filesystem::create_directories(d, ec); + if (ec) return std::unexpected(ConfigError{ + std::format("cannot create '{}': {}", d.string(), ec.message())}); + } mcpp::log::Config logCfg; logCfg.logDir = cfg.logDir; mcpp::log::init(logCfg); @@ -749,11 +778,13 @@ std::expected load_or_init( // 3. Seed config.toml if missing bool fresh_config = !std::filesystem::exists(cfg.configFile); - if (fresh_config) write_default_config_toml(cfg.configFile); - else mcpp::fallback::migrate_config_toml_index_names(cfg.configFile); + if (initialization != Initialization::ReadOnly) { + if (fresh_config) write_default_config_toml(cfg.configFile); + else mcpp::fallback::migrate_config_toml_index_names(cfg.configFile); + } // 4. Load config.toml - auto doc = t::parse_file(cfg.configFile); + auto doc = fresh_config ? t::parse(default_config_toml()) : t::parse_file(cfg.configFile); if (!doc) { return std::unexpected(ConfigError{ std::format("invalid config.toml: {}", doc.error().message)}); @@ -835,8 +866,8 @@ std::expected load_or_init( else return std::unexpected(ConfigError{overrides.error()}); - // [log] section — re-initialize logger with config values - { + // A read-only report must not create its log directory either. + if (initialization != Initialization::ReadOnly) { mcpp::log::Config logCfg; logCfg.logDir = cfg.logDir; auto levelStr = doc->get_string("log.level").value_or("off"); @@ -925,6 +956,30 @@ std::expected load_or_init( r.store = mcpp::provider::layout().own_store(cfg.mcppHome); } + // 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) { + constexpr std::size_t kDeepestPayloadSuffix = 140; + const auto length = cfg.mcppHome.native().size(); + if (!mcpp::platform::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", + "enable long paths (LongPathsEnabled = 1, `mcpp self doctor` shows it) or use a shorter MCPP_HOME"); + } + + if (initialization == Initialization::ReadOnly) { + if (cfg.xlingsBinaryMode == "system") { + if (auto binary = mcpp::platform::fs::which( + std::string("xlings") + std::string(mcpp::platform::exe_suffix))) + cfg.xlingsBinary = *binary; + } else if (cfg.xlingsBinaryMode != "bundled") { + cfg.xlingsBinary = mcpp::home::from_utf8(cfg.xlingsBinaryMode); + } + return cfg; + } + // 5. Seed registry/.xlings.json if missing; migrate legacy cached // sandboxes in place so CI/user caches move from mcpp-index to mcpplibs // without losing xlings' active subos or version bindings. @@ -945,18 +1000,7 @@ std::expected load_or_init( if (!r.dir.empty()) owned.push_back({ r.name, r.url, {}, {}, {}, r.dir, r.store }); mcpp::provider::open(make_xlings_env(cfg))->own_indices(owned); } - // 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) { - constexpr std::size_t kDeepestPayloadSuffix = 140; - const auto length = cfg.mcppHome.native().size(); - if (!mcpp::platform::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", - "enable long paths (LongPathsEnabled = 1, `mcpp self doctor` shows it) or use a shorter MCPP_HOME"); - } + { std::vector upstreams; for (auto& dir : cfg.upstreams) @@ -964,6 +1008,8 @@ std::expected load_or_init( mcpp::provider::open(make_xlings_env(cfg))->set_upstreams(upstreams); } + if (initialization == Initialization::Local) return cfg; + // 6. Acquire xlings binary if needed const auto bundledBinary = cfg.xlingsBinary; if (cfg.xlingsBinaryMode == "bundled") { @@ -1024,6 +1070,7 @@ std::expected load_or_init( const bool bootstrapAllowed = !mcpp::platform::env::offline_mode(); ensure_sandbox_xlings_binary(cfg, quiet); if (bootstrapAllowed) { + if (!check_base_init(cfg).empty()) report_network(cfg, quiet); ensure_sandbox_init(cfg, quiet); } else if (!quiet) { // Once per process: the doctor loads the configuration more than once, @@ -1054,7 +1101,10 @@ std::expected load_or_init( } void print_env(const GlobalConfig& cfg) { - std::println("MCPP_HOME = {}", cfg.mcppHome.string()); + const auto home = mcpp::home::selection(); + std::println("MCPP_HOME = {}", mcpp::home::spelling(home.original)); + if (home.redirected) + std::println("mcpp home = {}", mcpp::home::spelling(cfg.mcppHome)); std::println("xlings binary = {}", cfg.xlingsBinary.string()); std::println("xlings pinned = {}", kXlingsPinnedVersion); std::println("xlings home = {}", cfg.xlingsHome().string()); diff --git a/src/doctor.cppm b/src/doctor.cppm index 39be4c4ac..b8bd2da03 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -165,13 +165,31 @@ find_fixincludes_banners(const std::filesystem::path& payloadRoot) { return out; } +namespace { +void report_home(const mcpp::config::GlobalConfig& cfg) { + const auto home = mcpp::home::selection(); + mcpp::ui::status("Home", mcpp::home::spelling(home.effective)); + if (home.redirected) { + mcpp::ui::status("Original", std::format("{} ({})", mcpp::home::spelling(home.original), home.source)); + mcpp::ui::status("Locator", mcpp::home::spelling(home.original / "home.toml")); + } + if (!home.previous.empty() && home.previous != home.effective + && mcpp::home::initialized(home.previous)) { + const bool upstream = std::ranges::find(cfg.upstreams, home.previous) != cfg.upstreams.end(); + mcpp::ui::status("Previous", std::format("{} ({})", mcpp::home::spelling(home.previous), + upstream ? "retained; reused as an upstream" : "data retained; not migrated")); + } +} +} + // `mcpp self env`. export int env_report() { - auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); + auto cfg = mcpp::config::load_or_init(true, {}, {}, mcpp::config::Initialization::ReadOnly); if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; } mcpp::config::print_env(*cfg); + report_home(*cfg); auto tc = mcpp::toolchain::detect(); if (tc) { @@ -192,7 +210,11 @@ export int doctor_report() { auto warn = [&](std::string_view m) { mcpp::ui::warning(m); ++warns; }; auto err = [&](std::string_view m) { mcpp::ui::error(m); ++errors; }; - auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); + auto cfg = mcpp::config::load_or_init(true, {}, {}, mcpp::config::Initialization::ReadOnly); + if (cfg) { + report_home(*cfg); + if (mcpp::config::report_network(*cfg)) ++warns; + } // ONE PREPARE, READ-ONLY, AND THE SECTIONS BELOW REPORT ON IT (design // 2026-10-17 D7). @@ -307,7 +329,8 @@ export int doctor_report() { std::size_t broken = 0, notices = 0; std::string example; bool answered = false; - if (auto r = provider->call("system_status", "{}"); r && r->exitCode == 0) { + const auto r = provider->call("system_status", "{}"); + if (r && r->exitCode == 0) { for (const auto& d : r->dataEvents) { if (d.dataKind != "binding_state") continue; auto j = nlohmann::json::parse(d.payloadJson, nullptr, false); @@ -322,9 +345,13 @@ export int doctor_report() { } } } - if (!answered) - ok("(the provider reports no binding state)"); - else if (broken == 0) + if (!answered) { + std::error_code binaryError; + if (!r && std::filesystem::exists(mcpp::config::make_xlings_env(*cfg).binary, binaryError)) + warn(std::format("provider status unavailable: {}", r.error())); + else + ok("(the provider reports no binding state)"); + } else if (broken == 0) ok(notices == 0 ? std::string("consistent") : std::format("consistent ({} notice(s))", notices)); else @@ -754,7 +781,7 @@ export int doctor_report() { // simply never appear, and the code that consumes them degrades // quietly because a missing block is also a legitimate state. { - auto have = mcpp::fallback::vendored_xlings_version((*cfg).xlingsBinary); + 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 {}", @@ -1635,7 +1662,7 @@ export int why_report(const std::string& topic, const std::string& features, // refreshes on a resolution miss, so "why did I get this version" // frequently has "because that is the newest one your local index // knows" as its answer — which is unguessable without this line. - if (auto cfgW = mcpp::config::load_or_init(/*quiet=*/true)) { + if (auto cfgW = mcpp::config::load_or_init(true, {}, {}, mcpp::config::Initialization::ReadOnly)) { std::println("package index: {}", mcpp::pm::staleness_note(mcpp::config::make_xlings_env(*cfgW))); } @@ -1763,8 +1790,39 @@ export int explain_code(std::string_view code) { // can share `cmd_doctor` / `cmd_env` between top-level and `mcpp self`. // `mcpp self init [--force]`. -export int self_init(bool force) { - if (force) { +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()); + if (!lock) { + mcpp::ui::error("cannot lock the original home for writing; no default was changed"); + return 1; + } + auto selected = mcpp::home::choose(mcpp::home::from_utf8(target), force); + if (!selected) { mcpp::ui::error(selected.error()); return 1; } + const auto previousContext = mcpp::home::inspect(selected->original); + mcpp::home::use(*selected); + auto local = mcpp::config::load_or_init(false, {}, {}, mcpp::config::Initialization::Local); + if (!local) { + if (previousContext) mcpp::home::use(*previousContext); + mcpp::ui::error(local.error().message); + return 1; + } + auto saved = mcpp::home::save(*selected); + if (!saved) { + if (previousContext) mcpp::home::use(*previousContext); + mcpp::ui::error(saved.error()); + return 1; + } + mcpp::ui::status("Home", mcpp::home::spelling(selected->effective)); + mcpp::ui::status("Default", std::format("Saved from {}", mcpp::home::spelling(selected->original))); + } else if (force) { + const auto selected = mcpp::home::selection(); + if (selected.redirected) { + mcpp::ui::status("Home", mcpp::home::spelling(selected.effective)); + mcpp::ui::warning("--force rebuilds this home's registry and caches; its locator and previous home data are retained"); + } // --force: delete registry (sandbox) + caches and re-bootstrap. // Preserves: bin/mcpp (self-contained mode), config.toml, log/. mcpp::ui::info("Resetting", "mcpp sandbox (registry, caches)"); @@ -1849,26 +1907,19 @@ export int store_dedupe(bool apply) { export int self_config(std::string mirror) { if (!mirror.empty()) { mirror = upper_ascii(std::move(mirror)); - if (mirror != "CN" && mirror != "GLOBAL") { + if (mirror != "CN" && mirror != "GLOBAL" && mirror != "AUTO") { mcpp::ui::error(std::format( - "invalid mirror '{}'; expected CN or GLOBAL", mirror)); + "invalid mirror '{}'; expected auto, CN or GLOBAL", mirror)); return 2; } } + if (mirror == "AUTO") mirror = "auto"; - // When --mirror is given AND this is a fresh MCPP_HOME, seed .xlings.json - // with the user's choice on the very first write so the immediately- - // following xlings sandbox bootstrap (patchelf / ninja download) uses - // their mirror — not the historical CN default that an overseas user - // is trying to redirect away from. For an already-initialized MCPP_HOME - // the seed is skipped and config_set_mirror below updates the existing - // file via xlings. - // - // TODO(mirror-default): the default "CN" lives in - // mcpp::provider::xlings::seed_xlings_json — see the matching note there for the - // long-term plan (flip default to GLOBAL, or auto-detect on first init). + // Reading configuration is side-effect free; changing it creates only + // local configuration. Neither operation bootstraps or invokes a download. auto cfg = mcpp::config::load_or_init( - /*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback(), mirror); + /*quiet=*/false, {}, mirror, mirror.empty() ? mcpp::config::Initialization::ReadOnly + : mcpp::config::Initialization::Local); if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; @@ -1876,6 +1927,7 @@ export int self_config(std::string mirror) { auto env = mcpp::config::make_xlings_env(*cfg); if (mirror.empty()) { + report_home(*cfg); auto rc = mcpp::provider::open(env)->show_config(); return rc == 0 ? 0 : 1; } diff --git a/src/home.cppm b/src/home.cppm index b5ab05e1d..7f34c7098 100644 --- a/src/home.cppm +++ b/src/home.cppm @@ -9,8 +9,8 @@ // `.mcpp-bmi/`, while dep BMIs went to %USERPROFILE%\.mcpp\bmi — two roots for // one cache, and a cwd-dependent one at that. // -// This module is deliberately a leaf: it imports std + mcpp.platform only, so -// anything (config, toolchain, build) can depend on it without cycles. +// A leaf over platform and the TOML reader: configuration, toolchains and +// builds share its single path selection without depending on CLI or provider. module; #include // getenv @@ -19,6 +19,7 @@ export module mcpp.home; import std; import mcpp.platform; +import mcpp.libs.toml; export namespace mcpp::home { @@ -42,6 +43,27 @@ bool is_system_home(const std::filesystem::path& home); // $MCPP_HOME. See root() below for the resolution order. std::filesystem::path root(); +// The original directory owns the one-hop locator; every data consumer uses +// effective. No environment variable is rewritten to implement a selection. +struct Selection { + std::filesystem::path original; + std::filesystem::path effective; + std::filesystem::path previous; + std::string source; + bool redirected = false; +}; +struct Error : std::runtime_error { using std::runtime_error::runtime_error; }; +std::string spelling(const std::filesystem::path& path); +std::filesystem::path from_utf8(std::string_view value); +std::filesystem::path original_root(); +std::string original_source(); +std::expected inspect(const std::filesystem::path& original); +Selection selection(); +bool initialized(const std::filesystem::path& path); +std::expected choose(const std::filesystem::path& target, bool force); +std::expected save(const Selection& selected); +void use(const Selection& selected); + // The cross-project build cache root ($MCPP_HOME/build-cache/v1). Holds both // the std module cache (toolchain/stdmod.cppm, under std/) and the per-package // dependency cache (bmi_cache.cppm, under pkg/) — they MUST agree. @@ -90,11 +112,8 @@ std::filesystem::path default_home() { // "in a bin/ dir" shape — so we additionally exclude any path // with a "target" ancestor as mcpp's own dev convention. // 3. fallback to $HOME/.mcpp (%USERPROFILE%\.mcpp on Windows). -std::filesystem::path root() { - // 1. Explicit $MCPP_HOME takes priority (CI, advanced users). - if (auto* e = std::getenv("MCPP_HOME"); e && *e) - return std::filesystem::path(e); - +namespace { +std::filesystem::path portable_root() { auto exe = mcpp::platform::fs::self_exe_path(); if (exe.has_parent_path() && exe.parent_path().filename() == "bin") { auto candidate = exe.parent_path().parent_path(); @@ -119,7 +138,174 @@ std::filesystem::path root() { return candidate; } - return default_home(); + return {}; +} +} // namespace + +std::filesystem::path original_root() { + if (auto* e = std::getenv("MCPP_HOME"); e && *e) 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"; + return portable_root().empty() ? "user" : "portable"; +} + +std::string spelling(const std::filesystem::path& path) { + const auto bytes = path.generic_u8string(); + return {reinterpret_cast(bytes.data()), bytes.size()}; +} + +std::filesystem::path from_utf8(std::string_view value) { + return std::filesystem::path(std::u8string( + reinterpret_cast(value.data()), value.size())); +} + +namespace { +std::optional selected_home; + +std::expected absolute_home(const std::filesystem::path& path) { + std::error_code ec; + auto result = std::filesystem::weakly_canonical(std::filesystem::absolute(path, ec), ec); + if (ec) return std::unexpected(std::format("cannot resolve home '{}': {}", spelling(path), ec.message())); + return result; +} + +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) { + // Existing ancestors use filesystem identity, including Unicode case + // and directory aliases; ASCII folding alone does not cover those. + for (auto ancestor = child; !ancestor.empty();) { + std::error_code ec; + if (std::filesystem::equivalent(parent, ancestor, ec)) return true; + const auto next = ancestor.parent_path(); + if (next == ancestor) break; + ancestor = next; + } + std::ranges::transform(p, p.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + std::ranges::transform(c, c.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + } + if (p == c) return true; + if (!p.ends_with('/')) p += '/'; + return c.starts_with(p); +} +bool same_path(const std::filesystem::path& a, const std::filesystem::path& b) { + std::error_code ec; + if (std::filesystem::equivalent(a, b, ec)) return true; + return contains_path(a, b) && contains_path(b, a); +} +} // namespace + +std::expected inspect(const std::filesystem::path& original) { + auto absolute = absolute_home(original); + if (!absolute) return std::unexpected(absolute.error()); + Selection result{.original = *absolute, .effective = *absolute}; + result.source = is_system_home(*absolute) ? "system" : original_source(); + if (result.source == "system") return result; + const auto locator = result.original / "home.toml"; + std::error_code ec; + const bool exists = std::filesystem::exists(locator, ec); + if (ec) return std::unexpected(std::format("cannot read home locator '{}': {}", spelling(locator), ec.message())); + if (!exists) return result; + auto doc = mcpp::libs::toml::parse_file(locator); + if (!doc) return std::unexpected(std::format("invalid home locator '{}': {}. Repair it with `mcpp self init --force --home `", spelling(locator), doc.error().message)); + const auto home = doc->get_string("home"); + if (!home || home->empty() || !from_utf8(*home).is_absolute()) + return std::unexpected(std::format("home locator '{}' requires an absolute `home` path", spelling(locator))); + auto target = absolute_home(from_utf8(*home)); + if (!target) return std::unexpected(target.error()); + if (!std::filesystem::is_directory(*target, ec) || ec) + return std::unexpected(std::format("home '{}' from '{}' is unavailable; repair it with `mcpp self init --force --home `", spelling(*target), spelling(locator))); + result.effective = *target; + result.redirected = !same_path(result.effective, result.original); + if (result.redirected && (contains_path(result.original, result.effective) + || contains_path(result.effective, result.original))) + return std::unexpected(std::format("invalid nested home in '{}'", spelling(locator))); + if (auto previous = doc->get_string("previous_home"); previous && from_utf8(*previous).is_absolute()) + result.previous = from_utf8(*previous); + // Deliberately do not inspect effective/home.toml: one hop, on every host. + return result; +} + +Selection selection() { + auto origin = absolute_home(original_root()); + if (!origin) throw Error(origin.error()); + if (selected_home && selected_home->original == *origin) return *selected_home; + auto result = inspect(*origin); + if (!result) throw Error(result.error()); + selected_home = *result; + return *result; +} + +void use(const Selection& selected) { selected_home = selected; } + +std::filesystem::path root() { return selection().effective; } + +bool initialized(const std::filesystem::path& path) { + std::error_code ec; + return std::filesystem::is_regular_file(path / "config.toml", ec) + || (std::filesystem::is_directory(path / "registry", ec) + && std::filesystem::is_directory(path / "bin", ec)); +} + +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; + } + if (!user || !*user) + return std::unexpected("cannot save a default home without a user directory; set MCPP_HOME explicitly"); + } + auto original = absolute_home(original_root()); + auto effective = absolute_home(target); + if (!original) return std::unexpected(original.error()); + if (!effective) return std::unexpected(effective.error()); + if (is_system_home(*original)) return std::unexpected("--home cannot change a system home"); + auto before = inspect(*original); + if (!before && !force) return std::unexpected(before.error()); + if (before && !same_path(before->effective, *effective) && initialized(before->effective) && !force) + return std::unexpected(std::format("a home is already initialized at '{}'; use `mcpp self init --force --home {}` to select '{}'", spelling(before->effective), 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, + .source = before ? before->source : "explicit", + .redirected = !same_path(*original, *effective)}; + if (!result.redirected) result.effective = result.original; + if (before) result.previous = same_path(before->effective, *effective) ? before->previous : before->effective; + return result; +} + +std::expected save(const Selection& selected) { + const auto locator = selected.original / "home.toml"; + std::error_code ec; + if (selected.original == selected.effective) { + std::filesystem::remove(locator, ec); + } else { + auto staged = locator; + staged += std::format(".{}.tmp", std::chrono::steady_clock::now().time_since_epoch().count()); + { + std::ofstream file(staged, std::ios::binary); + file << "home = " << mcpp::libs::toml::escape_string(spelling(selected.effective)) << '\n'; + if (!selected.previous.empty()) + file << "previous_home = " << mcpp::libs::toml::escape_string(spelling(selected.previous)) << '\n'; + file.close(); + if (!file) { + std::filesystem::remove(staged, ec); + return std::unexpected(std::format("cannot write home locator '{}'; the previous default was retained", spelling(locator))); + } + } + mcpp::platform::fs::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())); + use(selected); + return {}; } std::filesystem::path system_config_file() { diff --git a/src/main.cpp b/src/main.cpp index a96c075d8..648dc0462 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ import std; import mcpp.cli; import mcpp.ui; +import mcpp.home; int main(int argc, char* argv[]) { // Line-buffer stdout even when it is not a TTY. @@ -33,6 +34,9 @@ int main(int argc, char* argv[]) { int rc; try { rc = mcpp::cli::run(argc, argv); + } catch (const mcpp::home::Error& e) { + mcpp::ui::error(e.what()); + rc = 4; } catch (const std::exception& e) { // Last-resort boundary: without it an escaped exception is // std::terminate — on Windows a silent 0xC0000409 that git-bash diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index 2c3b12dff..2a4007c4f 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -1064,7 +1064,7 @@ std::string install_failure_hint(const std::optional const std::filesystem::path& home) { const std::string code = error ? error->code : std::string{}; if (code == "E_NETWORK") - return "check network and retry, or `mcpp self init --force`"; + return "run `mcpp self doctor` to check the configured sources, then retry"; if (code == "E_DISK_FULL") return std::format("free space on the disk that holds {}, then retry", home.string()); if (code == "E_PERMISSION") @@ -1304,7 +1304,10 @@ PayloadAction payload_action(PayloadIntent intent, bool present, bool usable, bo InstallFailurePolicy install_failure_policy(std::string_view errorCode) { if (errorCode == "E_NOT_FOUND") return {.refreshIndex = true, .retry = true}; if (errorCode.empty()) return {.refreshIndex = true, .retry = true}; - if (errorCode == "E_NETWORK" || errorCode == "E_INVALID_INPUT") return {.refreshIndex = false, .retry = true}; + // The provider already exhausted its candidates and transport retries. + // Repeating the same download through another frontend cannot repair it. + if (errorCode == "E_NETWORK") return {}; + if (errorCode == "E_INVALID_INPUT") return {.refreshIndex = false, .retry = true}; return {}; } diff --git a/src/provider/provider.cppm b/src/provider/provider.cppm index a5f7716d6..8311ded83 100644 --- a/src/provider/provider.cppm +++ b/src/provider/provider.cppm @@ -123,6 +123,8 @@ struct BootstrapFile { double totalBytes = 0; bool started = false; bool finished = false; + std::string sourceHost; + int sourceAttempt = 0; }; struct BootstrapProgress { @@ -132,6 +134,19 @@ struct BootstrapProgress { using BootstrapProgressCallback = std::function; +// Network facts from the same sources and transport an install uses. Discovery +// and all probes share a single caller-owned wall-clock budget. +struct NetworkEndpoint { + std::string resource, region, host, status; + int httpStatus = 0; + double elapsedSeconds = 0; + bool reachable = false, proxy = false; +}; +struct NetworkReport { + std::string status; // checked, offline, unavailable, unsupported, timeout + std::vector endpoints; +}; + // ─── Indexes ──────────────────────────────────────────────────────── // A local index, read without touching the network (`mcpp index status`). @@ -417,6 +432,7 @@ public: virtual std::filesystem::path sysroot() const = 0; // headers and libraries a build targets virtual std::filesystem::path bin_dir() const = 0; // the programs it placed virtual bool initialized() const = 0; + virtual NetworkReport check_network(std::chrono::milliseconds budget = std::chrono::seconds(5)) const = 0; virtual std::filesystem::path data_root() const = 0; // where its indexes are virtual std::filesystem::path config_file() const = 0; // the home's own configuration diff --git a/src/provider/xlings/provider.cppm b/src/provider/xlings/provider.cppm index afc983e16..d4e027a4f 100644 --- a/src/provider/xlings/provider.cppm +++ b/src/provider/xlings/provider.cppm @@ -362,6 +362,9 @@ public: } int show_config() const override { return config_show(env_); } int set_mirror(std::string_view mirror, bool quiet) const override { return config_set_mirror(env_, mirror, quiet); } + mcpp::provider::NetworkReport check_network(std::chrono::milliseconds budget) const override { + return network_check(env_, budget); + } void ensure_initialized(bool quiet) const override { ensure_init(env_, quiet); } void pin_runtime(std::string_view runtimeId, bool relink) const override { // `.xlings.json` `runtime: {"glibc": "2.44.3"}` is the pin; set diff --git a/src/provider/xlings/xlings.cppm b/src/provider/xlings/xlings.cppm index fe8dfc0f2..b3d68fb25 100644 --- a/src/provider/xlings/xlings.cppm +++ b/src/provider/xlings/xlings.cppm @@ -142,7 +142,11 @@ namespace pinned { // (`payload_currency`), drops dangling records in every transaction and // says so (`scope_repaired`), and summarizes the binding state // (`binding_state`) for `mcpp self doctor` (design 2026-10-17). - inline constexpr std::string_view kXlingsVersion = "2026.10.17.1"; + // Eleventh, at 2026.10.19.1 (openxlings/xlings#684). Resource probes and + // download socket budgets share the provider's actual transport. Read-only + // queries do not adopt a home or create an operation journal; older custom + // providers are queried for metadata only in an isolated temporary home. + inline constexpr std::string_view kXlingsVersion = "2026.10.19.1"; inline constexpr std::string_view kNasmVersion = "3.02"; } @@ -484,10 +488,61 @@ using BootstrapProgress = mcpp::provider::BootstrapProgress; using BootstrapProgressCallback = mcpp::provider::BootstrapProgressCallback; -// Run xlings install with progress callback (used by bootstrap functions). -// When not `quiet` and stderr is a TTY, an elapsed-time spinner is shown -// during the (otherwise silent) direct install so first-run doesn't look -// frozen. +// Parent-owned byte progress protection, independent of provider heartbeats +// and socket reads (DNS or a trickle of headers may produce neither body bytes +// nor a socket timeout). Time is injectable for the state-machine tests. +class DownloadWatchdog { + struct File { + double bytes = 0; + int attempt = 0; + bool finished = false; + std::chrono::steady_clock::time_point progress; + }; + std::map files_; + std::chrono::milliseconds idle_; +public: + explicit DownloadWatchdog(std::chrono::milliseconds idle = std::chrono::seconds(30)) : idle_(idle) {} + void observe(const BootstrapProgress& progress, + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now()) { + for (const auto& f : progress.files) { + auto [it, added] = files_.try_emplace(f.name); + auto& state = it->second; + if (added || f.sourceAttempt != state.attempt || f.downloadedBytes > state.bytes) + state.progress = now; + state.bytes = f.downloadedBytes; + state.attempt = f.sourceAttempt; + state.finished = f.finished || !f.started; + } + } + bool expired(std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now()) const { + return std::ranges::any_of(files_, [&](const auto& f) { + return !f.second.finished && now - f.second.progress >= idle_; + }); + } +}; + +class InstallDeadline { + DownloadWatchdog downloads_; + std::chrono::steady_clock::time_point started_; + std::chrono::milliseconds preparation_; + bool downloadSeen_ = false, localWork_ = false; +public: + explicit InstallDeadline(std::chrono::milliseconds preparation = std::chrono::seconds(120), + std::chrono::milliseconds idle = std::chrono::seconds(30), + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now()) + : downloads_(idle), started_(start), preparation_(preparation) {} + void observe(std::string_view line, + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now()); + bool expired(std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now()) const { + return downloads_.expired(now) + || (!downloadSeen_ && !localWork_ && now - started_ >= preparation_); + } +}; + +mcpp::provider::NetworkReport network_check(const Env& env, std::chrono::milliseconds budget); + +// One structured install with native source/byte progress, an optional +// callback (otherwise the common download renderer), and parent-owned bounds. int install_with_progress(const Env& env, std::string_view target, const BootstrapProgressCallback& cb, bool quiet = false); @@ -660,7 +715,8 @@ 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. -mcpp::provider::ProviderInfo describe(const std::filesystem::path& binary); +mcpp::provider::ProviderInfo describe(const std::filesystem::path& binary, + std::chrono::milliseconds budget = std::chrono::seconds(3)); // Why that xlings cannot serve this mcpp (design 2026-10-16 §12): empty when // it can. The pinned version, and every feature mcpp relies on. @@ -911,26 +967,66 @@ std::optional index_revision(const std::filesystem::path& indexDir) // ─── The handshake ────────────────────────────────────────────────── -mcpp::provider::ProviderInfo describe(const std::filesystem::path& binary) { +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; - auto out = run_capture(std::format("{} interface --version {}", shq(binary.string()), - mcpp::platform::null_redirect)); - if (!out) return info; - auto j = nlohmann::json::parse(*out, nullptr, false); - if (!j.is_object()) return info; - const auto strings = [&](const char* key) { - std::vector v; - if (j.contains(key) && j[key].is_array()) - for (auto& e : j[key]) if (e.is_string()) v.push_back(e.get()); - return v; - }; - info.version = j.value("version", std::string{}); - info.protocol = j.value("protocol_version", std::string{}); - info.capabilities = strings("capabilities"); - info.features = strings("features"); - if (j.contains("home_layouts") && j["home_layouts"].is_array()) - for (auto& e : j["home_layouts"]) if (e.is_number_integer()) info.homeLayouts.push_back(e.get()); - info.answered = !info.version.empty(); + 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; + 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()) { + 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"); + 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(); + } + } catch (const std::exception&) { info = {}; } + cache.emplace(key, info); return info; } @@ -939,7 +1035,8 @@ std::string unmet(const mcpp::provider::ProviderInfo& info) { 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"}) + "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()) @@ -1472,10 +1569,23 @@ std::filesystem::path sandbox_init_marker(const Env& env) { // ─── 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}, }; } @@ -1671,10 +1781,19 @@ std::optional parse_event_line(std::string_view line) { // ─── 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 @@ -1701,10 +1820,25 @@ call(const Env& env, std::string_view capability, 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; @@ -1732,14 +1866,20 @@ call(const Env& env, std::string_view capability, } }, *ev); }, - std::chrono::milliseconds{0}, + installing ? std::chrono::duration_cast(kDirectInstallTimeout) + : syncing ? std::chrono::duration_cast(index_refresh_timeout()) + : std::chrono::milliseconds{0}, std::chrono::duration_cast(kInterfaceIdleTimeout), - &timedOut); + &timedOut, false, [&] { return installing && deadline.expired(); }); if (timedOut) { result.exitCode = result.exitCode != 0 ? result.exitCode : 124; - result.stderrTail.push_back(std::format( - "xlings interface {} wrote nothing for {} seconds (not even its " - "heartbeat) and was stopped", capability, kInterfaceIdleTimeout.count())); + 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) { @@ -1749,7 +1889,8 @@ call(const Env& env, std::string_view capability, std::ifstream in(stderrFile); std::stringstream text; text << in.rdbuf(); - result.stderrTail = stderr_error_tail(text.str()); + 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); @@ -1817,6 +1958,8 @@ std::optional download_progress_of(std::string_view line) { 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; @@ -1901,6 +2044,101 @@ private: }; } // 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, @@ -1908,110 +2146,66 @@ int install_with_progress(const Env& env, std::string_view target, bool quiet) { ScopedInvocationEnv scope(env); // #614 - auto argsJson = std::format( - R"({{"targets":["{}"],"yes":true}})", target); - - // All platforms: try direct `xlings install ... -y` first. - // The direct command is more reliable for large packages (e.g. LLVM - // ~800MB) because: - // - it doesn't pipe through NDJSON interface (simpler subprocess chain) - // - xlings manages its own stdin/stdout/stderr - // - extraction subprocess coordination works normally - // The NDJSON interface path is kept as a fallback for progress reporting. - { - auto directCmd = build_command_prefix(env) + - std::format(" install {} -y {}", target, mcpp::platform::shell::silent_redirect); - // Seal stdin so xlings and any grandchildren (curl, git, 7z, xcode-select - // / brew prompts, etc.) cannot block on a terminal read. Without it, - // first-run toolchain install pauses waiting for the user to press Enter - // — first seen on Windows, now also reproduced on macOS (the "需要回车 - // 才能继续" first-run report). The old "POSIX must keep stdin open for - // subprocess coordination" claim never held in practice; `-y` already - // makes the install non-interactive, so the redirect only removes the - // spurious terminal blocking. Windows uses done{false}; - int directRc = 0; - std::thread worker([&] { - bool timedOut = false; - directRc = mcpp::platform::process::run_streaming_bounded( - directCmd, [](std::string_view) {}, - 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)); - done.store(true, std::memory_order_release); - }); - - if (showSpinner) { - mcpp::ui::ProgressBar bar("Installing", target); - const auto start = std::chrono::steady_clock::now(); - while (!done.load(std::memory_order_acquire)) { - bar.update_indeterminate(0, std::chrono::duration( - std::chrono::steady_clock::now() - start).count()); - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - } - worker.join(); - if (directRc == 0) bar.finish(); - else bar.finish_failed(target); - if (directRc == 0) return 0; - } else { - worker.join(); - if (directRc == 0) return 0; - } - } - - // Fallback: NDJSON interface path (provides progress callbacks). - // Seal stdin (same rationale as the direct path above) so the install can't - // block on a terminal read. The protocol is NDJSON-over-stdout + "yes":true, - // so nothing here needs the terminal. - // The same decision as the direct path above: the fallback used to spell - // global mode by hand on POSIX whatever `env.projectDir` said (#614). + // 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 != "data") return; - if (ls.find_str("dataKind") != "download_progress") return; - if (!cb) return; - - if (auto prog = download_progress_of(line)) cb(*prog); + 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::milliseconds{0}, + cmd, handle_line, std::chrono::duration_cast(kDirectInstallTimeout), std::chrono::duration_cast(kInterfaceIdleTimeout), - &idleTimedOut); + &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", std::format( - "xlings interface install_packages wrote nothing for {} seconds and was stopped", - kInterfaceIdleTimeout.count())); + 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; @@ -2099,20 +2293,42 @@ void seed_xlings_json(const Env& env, } int config_show(const Env& env) { - ScopedInvocationEnv scope(env); // #614 - auto cmd = std::format("{} config", build_command_prefix(env)); - return mcpp::platform::process::run_silent(cmd); + 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) { - ScopedInvocationEnv scope(env); // #614 if (mirror.empty()) return 0; - auto cmd = std::format( - "{} config --mirror {} {}", - build_command_prefix(env), - shq(mirror), - quiet ? mcpp::platform::shell::silent_redirect : ""); - return mcpp::platform::process::run_silent(cmd); + // 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) { @@ -2136,7 +2352,7 @@ void ensure_init(const Env& env, bool quiet) { bool initTimedOut = false; int rc = mcpp::platform::process::run_streaming_bounded( cmd, [](std::string_view) {}, - std::chrono::duration_cast(kLocalCommandTimeout), + std::chrono::seconds{120}, std::chrono::milliseconds{0}, &initTimedOut); if (rc != 0 && !quiet) { std::println(stderr, diff --git a/tests/e2e/10_env_command.sh b/tests/e2e/10_env_command.sh index 77716f711..906e4cfa9 100755 --- a/tests/e2e/10_env_command.sh +++ b/tests/e2e/10_env_command.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # requires: -# `mcpp env` initializes $MCPP_HOME and prints expected layout. +# `self env` reads a cold home; explicit offline init creates local layout. set -e TMP=$(mktemp -d) @@ -8,8 +8,16 @@ trap "rm -rf $TMP" EXIT export MCPP_HOME="$TMP/mcpp-home" -# First invocation should silently init the directory tree. +# The first query reports paths without creating the home. out=$("$MCPP" self env 2>&1) +[[ ! -e "$MCPP_HOME" ]] || { echo "self env initialized a cold home"; exit 1; } + +set +e +"$MCPP" --offline self init > "$TMP/init.log" 2>&1 +init_rc=$? +set -e +[[ "$init_rc" == 0 || "$init_rc" == 1 ]] || { cat "$TMP/init.log"; exit 1; } + # Verify directory tree [[ -d "$MCPP_HOME/bin" ]] || { echo "missing bin/"; exit 1; } diff --git a/tests/e2e/11_index_list.sh b/tests/e2e/11_index_list.sh index 33a639529..4bbe38980 100755 --- a/tests/e2e/11_index_list.sh +++ b/tests/e2e/11_index_list.sh @@ -9,7 +9,7 @@ trap "rm -rf $TMP" EXIT export MCPP_HOME="$TMP/mcpp-home" # init -"$MCPP" self env > /dev/null +"$MCPP" self config --mirror auto > /dev/null # list should at minimum show mcpplibs (from seeded config) out=$("$MCPP" index list 2>&1) diff --git a/tests/e2e/151_index_url_artifact_migration.sh b/tests/e2e/151_index_url_artifact_migration.sh index cee05ff3a..f8453cb45 100755 --- a/tests/e2e/151_index_url_artifact_migration.sh +++ b/tests/e2e/151_index_url_artifact_migration.sh @@ -36,7 +36,7 @@ PYEOF2 # 1. Fresh init: new org URL + artifact declaration in both files. # (xlings' own bootstrap may re-serialize .xlings.json with its writer, so # assert on the key/value pairs, not on mcpp's seed line layout.) -"$MCPP" self env > /dev/null +"$MCPP" self config --mirror auto > /dev/null grep -q "url = \"$NEW_URL\"" "$CFG" || { echo "config.toml missing new url"; exit 1; } grep -qF "artifact = { GLOBAL = \"$ART\", CN = \"$ART_CN\" }" "$CFG" \ || { echo "config.toml missing the region artifact"; cat "$CFG"; exit 1; } @@ -74,7 +74,7 @@ cat > "$XJ" < /dev/null +"$MCPP" self config --mirror auto > /dev/null grep -q "url = \"$NEW_URL\"" "$CFG" || { echo "config.toml url not healed"; cat "$CFG"; exit 1; } entry_has "{\"url\": \"$NEW_URL\", \"artifact\": $REGION}" \ || { echo ".xlings.json not healed"; cat "$XJ"; exit 1; } @@ -85,13 +85,13 @@ fi # Idempotent: a second run must not touch either file again. cp "$CFG" "$TMP/cfg1"; cp "$XJ" "$TMP/xj1" -"$MCPP" self env > /dev/null +"$MCPP" self config --mirror auto > /dev/null cmp -s "$CFG" "$TMP/cfg1" || { echo "config.toml heal not idempotent"; exit 1; } cmp -s "$XJ" "$TMP/xj1" || { echo ".xlings.json heal not idempotent"; exit 1; } # 3. Legacy heal (compact spacing, old index name): xlings-writer format. printf '{"index_repos":[{"name":"mcpp-index","url":"%s"}],"mirror":"auto"}' "$OLD_URL" > "$XJ" -"$MCPP" self env > /dev/null +"$MCPP" self config --mirror auto > /dev/null entry_has '{}' || { echo "compact name not healed"; cat "$XJ"; exit 1; } grep -q 'xlings-res/mcpp-index' "$XJ" || { echo "compact artifact not injected"; cat "$XJ"; exit 1; } grep -q 'gitcode.com/xlings-res/mcpp-index' "$XJ" || { echo "compact artifact has no CN half"; cat "$XJ"; exit 1; } @@ -99,9 +99,9 @@ grep -q 'gitcode.com/xlings-res/mcpp-index' "$XJ" || { echo "compact artifact ha # 4. A home seeded before the region object (flat GitHub base, both spacings) # gains the CN half, once; a base the user wrote is left alone. printf '{\n "index_repos": [\n { "name": "mcpplibs", "url": "%s", "artifact": "%s" }\n ],\n "mirror": "CN"\n}\n' "$NEW_URL" "$ART" > "$XJ" -"$MCPP" self env > /dev/null +"$MCPP" self config --mirror auto > /dev/null entry_has "{\"artifact\": $REGION}" || { echo "flat default artifact not upgraded"; cat "$XJ"; exit 1; } -cp "$XJ" "$TMP/xj4"; "$MCPP" self env > /dev/null +cp "$XJ" "$TMP/xj4"; "$MCPP" self config --mirror auto > /dev/null cmp -s "$XJ" "$TMP/xj4" || { echo "region upgrade not idempotent"; diff "$TMP/xj4" "$XJ"; exit 1; } # A base the user states in config.toml owns the entry, and has no CN half. cat > "$CFG" < /dev/null +"$MCPP" self config --mirror auto > /dev/null grep -q 'https://mirror.example/idx' "$XJ" || { echo "a user artifact base was rewritten"; cat "$XJ"; exit 1; } if grep -q 'gitcode.com/xlings-res/mcpp-index' "$XJ"; then echo "a user artifact base gained a CN half"; cat "$XJ"; exit 1; fi if grep -q 'mcpp-community' "$XJ"; then diff --git a/tests/e2e/22_doctor_cache_publish.sh b/tests/e2e/22_doctor_cache_publish.sh index b10bc4db3..1e1467e1a 100755 --- a/tests/e2e/22_doctor_cache_publish.sh +++ b/tests/e2e/22_doctor_cache_publish.sh @@ -10,6 +10,8 @@ set -e TMP=$(mktemp -d) trap "rm -rf $TMP" EXIT export MCPP_HOME="$TMP/mcpp-home" +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$TMP" # 0) cache list on a genuinely empty cache: friendly message. out=$("$MCPP" cache list 2>&1) @@ -30,6 +32,12 @@ grep -q 'Doctor result' doctor.log || { cat doctor.log; echo "no res out=$("$MCPP" cache list 2>&1) [[ "$out" == *"empty"* ]] || { echo "doctor wrote to the build cache: '$out'"; exit 1; } +[ ! -e "$MCPP_HOME/registry" ] || { echo "doctor bootstrapped the cold home"; exit 1; } + +# Only the build needs installed tools. Keep the doctor's cold-home check +# separate, then share payloads without warming this home's std build cache. +source "$HERE/_inherit_toolchain.sh" + # 2) after a build, the std module it precompiled must be VISIBLE. std entries # dominate the cache's size, and `cache list` used to walk only dep entries. # Hiding them is how 16 GB of duplicated std BMIs went unnoticed, so listing diff --git a/tests/e2e/27_self_contained_home.sh b/tests/e2e/27_self_contained_home.sh index bd9c5e179..c967bc0e7 100755 --- a/tests/e2e/27_self_contained_home.sh +++ b/tests/e2e/27_self_contained_home.sh @@ -24,7 +24,11 @@ out=$(env -u MCPP_HOME "$ROOT/bin/mcpp" self env 2>&1) echo "$out" | grep -q "MCPP_HOME *= *$ROOT" || { echo "mcpp did not self-locate to $ROOT"; echo "--- output:"; echo "$out"; exit 1; } -# And it must have actually populated the layout there. +# A read-only query must leave the release tree unchanged. +[[ ! -e "$ROOT/registry" && ! -e "$ROOT/config.toml" ]] || { echo "self env initialized the home"; exit 1; } + +# Explicit initialization populates that same home. +env -u MCPP_HOME "$ROOT/bin/mcpp" self init > "$TMP/init.log" 2>&1 || { cat "$TMP/init.log"; exit 1; } [[ -d "$ROOT/registry" ]] || { echo "missing registry/"; exit 1; } [[ -f "$ROOT/config.toml" ]] || { echo "missing config.toml"; exit 1; } [[ -x "$ROOT/registry/bin/xlings" ]] || { echo "xlings not acquired into $ROOT/registry/bin"; exit 1; } diff --git a/tests/e2e/38_self_config_mirror.sh b/tests/e2e/38_self_config_mirror.sh index d7021fecd..307774ec1 100755 --- a/tests/e2e/38_self_config_mirror.sh +++ b/tests/e2e/38_self_config_mirror.sh @@ -6,21 +6,19 @@ set -e TMP=$(mktemp -d) trap "rm -rf $TMP" EXIT export MCPP_HOME="$TMP/mcpp-home" -source "$(dirname "$0")/_inherit_toolchain.sh" - -"$MCPP" self env > "$TMP/env.log" 2>&1 || { - cat "$TMP/env.log" - echo "FAIL: self env failed" +"$MCPP" self config > "$TMP/query.log" 2>&1 || { + cat "$TMP/query.log" + echo "FAIL: reading fresh configuration failed" exit 1 } +[[ ! -e "$MCPP_HOME" ]] || { echo "FAIL: reading configuration initialized the home"; exit 1; } -# Default (no explicit --mirror): mcpp seeds "auto" so xlings' own region -# detection (probe github vs gitcode) picks the reachable/faster mirror, instead -# of mcpp hardcoding one. An explicit `mcpp self config --mirror X` still pins a -# concrete value (asserted below). +# Choosing a mirror creates local configuration, never a sandbox bootstrap. +"$MCPP" self config --mirror auto > "$TMP/auto.log" 2>&1 || { + cat "$TMP/auto.log"; echo "FAIL: selecting auto failed"; exit 1; } grep -q '"mirror": "auto"' "$MCPP_HOME/registry/.xlings.json" || { cat "$MCPP_HOME/registry/.xlings.json" - echo "FAIL: default mirror should be auto (defer to xlings region detection)" + echo "FAIL: auto should restore adaptive region detection" exit 1 } diff --git a/tests/e2e/39_xlings_index_migration.sh b/tests/e2e/39_xlings_index_migration.sh index 5d4b5b3af..489e4b940 100755 --- a/tests/e2e/39_xlings_index_migration.sh +++ b/tests/e2e/39_xlings_index_migration.sh @@ -52,7 +52,7 @@ cat > "$MCPP_HOME/registry/.xlings.json" <<'JSON' } JSON -"$MCPP" self env > "$TMP/env.log" +"$MCPP" self config --mirror auto > "$TMP/env.log" grep -q 'default = "mcpplibs"' "$MCPP_HOME/config.toml" || { cat "$MCPP_HOME/config.toml"; echo "config.toml default index was not migrated"; exit 1; } diff --git a/tests/e2e/46_self_config_mirror_no_bootstrap.sh b/tests/e2e/46_self_config_mirror_no_bootstrap.sh index 4ab255618..a5beec47c 100755 --- a/tests/e2e/46_self_config_mirror_no_bootstrap.sh +++ b/tests/e2e/46_self_config_mirror_no_bootstrap.sh @@ -1,10 +1,7 @@ #!/usr/bin/env bash # requires: # 46_self_config_mirror_fresh_seed.sh — `mcpp self config --mirror X` on a -# fresh MCPP_HOME must seed .xlings.json with X from the very first write, -# so the immediately-following sandbox bootstrap (patchelf / ninja -# download) uses the user's chosen mirror instead of the historical CN -# default. +# fresh MCPP_HOME seeds .xlings.json with X without bootstrapping a sandbox. # # This is the user-visible fix for the "fresh-install mirror config hangs # on overseas networks" report: previously the seed always wrote @@ -38,9 +35,14 @@ grep -q '"mirror": "GLOBAL"' "$MCPP_HOME/registry/.xlings.json" || { echo "FAIL: .xlings.json mirror should be GLOBAL after fresh-init --mirror" exit 1 } +if grep -qE 'Initialize|Bootstrap|Fetching' "$TMP/out.log"; then + cat "$TMP/out.log"; echo "FAIL: mirror selection attempted bootstrap"; exit 1 +fi +[[ ! -e "$MCPP_HOME/registry/subos/default" ]] || { + echo "FAIL: mirror selection created a sandbox"; exit 1; } # Round-trip back to CN — also via --mirror — exercises the post-init -# path (xlings config --mirror) since .xlings.json now exists. +# path through the adapter's local configuration writer. "$MCPP" self config --mirror CN > "$TMP/cn.log" 2>&1 || { cat "$TMP/cn.log"; echo "FAIL: switching back to CN failed"; exit 1; } grep -q '"mirror": "CN"' "$MCPP_HOME/registry/.xlings.json" || { diff --git a/tests/e2e/52_local_path_namespaced_index.sh b/tests/e2e/52_local_path_namespaced_index.sh index 799839cd4..a61518fd8 100755 --- a/tests/e2e/52_local_path_namespaced_index.sh +++ b/tests/e2e/52_local_path_namespaced_index.sh @@ -117,7 +117,7 @@ fi cat > "$TMP/fake-bin/xlings" <<'EOF' #!/usr/bin/env bash # The handshake mcpp asks of every xlings (docs/25 §5). -[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.11","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state"]}'; exit 0; } +[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.13","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state","network_diagnostics","download_socket_budgets","read_only_queries"]}'; exit 0; } set -e if [[ "${1:-}" == "self" && "${2:-}" == "init" ]]; then diff --git a/tests/e2e/58_preinstall_mcpp_deps_for_hooks.sh b/tests/e2e/58_preinstall_mcpp_deps_for_hooks.sh index ae8d2b1ff..83112ee47 100644 --- a/tests/e2e/58_preinstall_mcpp_deps_for_hooks.sh +++ b/tests/e2e/58_preinstall_mcpp_deps_for_hooks.sh @@ -88,7 +88,7 @@ fi cat > "$TMP/fake-bin/xlings" <<'EOF' #!/usr/bin/env bash # The handshake mcpp asks of every xlings (docs/25 §5). -[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.11","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state"]}'; exit 0; } +[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.13","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state","network_diagnostics","download_socket_budgets","read_only_queries"]}'; exit 0; } set -e if [[ "${1:-}" == "self" && "${2:-}" == "init" ]]; then diff --git a/tests/e2e/604_offline_skips_sandbox_bootstrap.sh b/tests/e2e/604_offline_skips_sandbox_bootstrap.sh index 9a8fc5410..583e7e048 100755 --- a/tests/e2e/604_offline_skips_sandbox_bootstrap.sh +++ b/tests/e2e/604_offline_skips_sandbox_bootstrap.sh @@ -1,13 +1,6 @@ #!/usr/bin/env bash -# `--offline` promises never to touch the network, and a fresh home is where -# that promise was being broken: the sandbox bootstrap that `load_or_init` runs -# on first use clones the package index and installs ninja and patchelf through -# `xlings install`. Measured before the gate, `MCPP_OFFLINE=1 mcpp self doctor` -# in an empty home spent 26 seconds and wrote 126 MB before its first check. -# -# Under offline mode the three network-bound steps are skipped, once, visibly, -# and the command goes on to its remaining checks. A home that is already -# bootstrapped says nothing, because there is nothing to skip. +# Doctor reads local state without bootstrapping, on cold and warm homes. +# Offline mode must reach later diagnostics without creating the cold home. set -e TMP=$(mktemp -d) @@ -17,9 +10,8 @@ trap "rm -rf $TMP" EXIT out="$TMP/fresh.log" MCPP_HOME="$TMP/home" MCPP_OFFLINE=1 "$MCPP" self doctor > "$out" 2>&1 || true -n=$(grep -c "Skipping sandbox bootstrap (offline mode" "$out" || true) -[[ "$n" -eq 1 ]] || { - cat "$out"; echo "FAIL: expected the skip to be announced once, saw $n"; exit 1; } +[[ ! -e "$TMP/home" ]] || { + cat "$out"; echo "FAIL: an offline diagnostic initialized the home"; exit 1; } # The index clone is the first network step and the largest; its absence is # the structural evidence that nothing was fetched. @@ -32,7 +24,7 @@ n=$(grep -c "Skipping sandbox bootstrap (offline mode" "$out" || true) # registry one still run. grep -q "Checking build policy" "$out" || { cat "$out"; echo "FAIL: the doctor stopped at the registry check"; exit 1; } -echo "PASS: a fresh home under offline mode is left un-bootstrapped, audibly" +echo "PASS: a fresh home under offline mode stays untouched and diagnosis continues" # ── Control: a bootstrapped home has nothing to skip and says nothing ─── # diff --git a/tests/e2e/60_stale_xpkg_cache_reinstall.sh b/tests/e2e/60_stale_xpkg_cache_reinstall.sh index 6699ecde0..c2cf8d827 100644 --- a/tests/e2e/60_stale_xpkg_cache_reinstall.sh +++ b/tests/e2e/60_stale_xpkg_cache_reinstall.sh @@ -46,7 +46,7 @@ FAKE_INSTALL_LOG="$TMP/fake-install.log" cat > "$TMP/fake-bin/xlings" <<'EOF' #!/usr/bin/env bash # The handshake mcpp asks of every xlings (docs/25 §5). -[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.11","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state"]}'; exit 0; } +[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.13","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state","network_diagnostics","download_socket_budgets","read_only_queries"]}'; exit 0; } set -e if [[ "${1:-}" == "self" && "${2:-}" == "init" ]]; then diff --git a/tests/e2e/681_index_repos_reach_an_existing_home.sh b/tests/e2e/681_index_repos_reach_an_existing_home.sh index 5c33eb0df..b56821bea 100755 --- a/tests/e2e/681_index_repos_reach_an_existing_home.sh +++ b/tests/e2e/681_index_repos_reach_an_existing_home.sh @@ -47,20 +47,20 @@ same_json() { # same_json : equal as JSON values; mcpp writes its own fo python3 -c 'import json, sys; sys.exit(0 if json.load(open(sys.argv[1])) == json.load(open(sys.argv[2])) else 1)' "$1" "$2" } -"$MCPP" self env > /dev/null 2>&1 || true +"$MCPP" self config --mirror auto > /dev/null 2>&1 || true [ -f "$XJ" ] || fail "the registry was not seeded" [ "$(entry_url xim)" = "absent" ] || fail "a fresh home already names xim" "$XJ" # ── A ────────────────────────────────────────────────────────────────────── printf '\n[index.repos.xim]\nurl = "%s"\n' "$CHECKOUT" >> "$CFG" -"$MCPP" self env > a.log 2>&1 || true +"$MCPP" self config --mirror auto > a.log 2>&1 || true [ "$(entry_url xim)" = "$CHECKOUT" ] || fail "A: the table did not reach .xlings.json" "$XJ" a.log grep -qF "xim -> $CHECKOUT ([index.repos.xim] in config.toml)" a.log || fail "A: no line names the change" a.log [ -f "$REC" ] || fail "A: the change was not recorded" # ── B ────────────────────────────────────────────────────────────────────── cp "$XJ" xj.before -"$MCPP" self env > b.log 2>&1 || true +"$MCPP" self config --mirror auto > b.log 2>&1 || true cmp -s "$XJ" xj.before || fail "B: a second run changed .xlings.json" "$XJ" if grep -q "xim -> " b.log; then fail "B: a second run printed the index line again" b.log; fi @@ -70,14 +70,14 @@ import re, sys p = sys.argv[1]; s = open(p).read() open(p, "w").write(re.sub(r'\n\[index\.repos\.xim\]\nurl = "[^"]*"\n', '\n', s)) PY -"$MCPP" self env > c.log 2>&1 || true +"$MCPP" self config --mirror auto > c.log 2>&1 || true [ "$(entry_url xim)" = "absent" ] || fail "C: removing the table left the entry" "$XJ" c.log grep -q "xim restored" c.log || fail "C: no line names the restoration" c.log [ ! -f "$REC" ] || fail "C: the record outlived the table" "$REC" # ── D ────────────────────────────────────────────────────────────────────── printf '\n[index.repos.xim]\nurl = "%s"\n' "$CHECKOUT" >> "$CFG" -"$MCPP" self env > /dev/null 2>&1 || true +"$MCPP" self config --mirror auto > /dev/null 2>&1 || true python3 - "$XJ" <<'PY' import json, sys p = sys.argv[1]; d = json.load(open(p)) @@ -90,7 +90,7 @@ import re, sys p = sys.argv[1]; s = open(p).read() open(p, "w").write(re.sub(r'\n\[index\.repos\.xim\]\nurl = "[^"]*"\n', '\n', s)) PY -"$MCPP" self env > d.log 2>&1 || true +"$MCPP" self config --mirror auto > d.log 2>&1 || true [ "$(entry_url xim)" = "/somewhere/else" ] || fail "D: an entry someone else changed was overwritten" "$XJ" # ── E ────────────────────────────────────────────────────────────────────── @@ -112,7 +112,7 @@ json.dump(d, open(p, "w"), indent=2) PY cp "$XJ" xj.e rm -f "$REC" -"$MCPP" self env > e.log 2>&1 || true +"$MCPP" self config --mirror auto > e.log 2>&1 || true cmp -s "$XJ" xj.e || fail "E: the default entry was rewritten without a table" "$XJ" e.log if grep -q "mcpplibs -> " e.log; then fail "E: a table was named for the default entry" e.log; fi [ ! -f "$REC" ] || fail "E: a change was recorded for the default entry" "$REC" @@ -121,7 +121,7 @@ if grep -q "mcpplibs -> " e.log; then fail "E: a table was named for the default MIRROR="$TMP/mcpplibs-checkout" mkdir -p "$MIRROR" printf '\n[index.repos.mcpplibs]\nurl = "%s"\n' "$MIRROR" >> "$CFG" -"$MCPP" self env > f1.log 2>&1 || true +"$MCPP" self config --mirror auto > f1.log 2>&1 || true [ "$(entry_url mcpplibs)" = "$MIRROR" ] || fail "F: the table did not replace the default entry" "$XJ" f1.log grep -qF "mcpplibs -> $MIRROR ([index.repos.mcpplibs] in config.toml)" f1.log || fail "F: no line names the change" f1.log python3 - "$CFG" <<'PY' @@ -129,7 +129,7 @@ import re, sys p = sys.argv[1]; s = open(p).read() open(p, "w").write(re.sub(r'\n\[index\.repos\.mcpplibs\]\nurl = "[^"]*"\n', '\n', s)) PY -"$MCPP" self env > f2.log 2>&1 || true +"$MCPP" self config --mirror auto > f2.log 2>&1 || true same_json "$XJ" xj.e || fail "F: removing the table did not restore the entry it replaced" "$XJ" f2.log grep -q "mcpplibs restored" f2.log || fail "F: no line names the restoration" f2.log diff --git a/tests/e2e/686_an_activated_xlings_subos_does_not_redirect_the_registry.sh b/tests/e2e/686_an_activated_xlings_subos_does_not_redirect_the_registry.sh index 7af2f967d..8cc9ce5d5 100755 --- a/tests/e2e/686_an_activated_xlings_subos_does_not_redirect_the_registry.sh +++ b/tests/e2e/686_an_activated_xlings_subos_does_not_redirect_the_registry.sh @@ -23,7 +23,7 @@ fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2 export MCPP_HOME="$TMP/mcpp-home" cd "$TMP" SHELL_SUBOS=mcpp-e2e-shell-subos -XLINGS_ACTIVE_SUBOS=$SHELL_SUBOS "$MCPP" self env > env.log 2>&1 || true +XLINGS_ACTIVE_SUBOS=$SHELL_SUBOS "$MCPP" self init > env.log 2>&1 || true SUBOS="$MCPP_HOME/registry/subos" [ -d "$SUBOS/default" ] || fail "the registry was not initialised" env.log diff --git a/tests/e2e/687_the_vendored_xlings_probe_is_an_argument_vector.sh b/tests/e2e/687_the_vendored_xlings_probe_is_an_argument_vector.sh index 6e0bd5bd8..7036c2c6d 100755 --- a/tests/e2e/687_the_vendored_xlings_probe_is_an_argument_vector.sh +++ b/tests/e2e/687_the_vendored_xlings_probe_is_an_argument_vector.sh @@ -47,11 +47,11 @@ cd "$TMP" VENDORED="$MCPP_HOME/registry/bin/xlings$EXE" -"$MCPP" self env > first.out 2> first.err || true +"$MCPP" self init > first.out 2> first.err || true [ -f "$VENDORED" ] || fail "the first command did not vendor xlings at $VENDORED" first.out first.err # ── A ────────────────────────────────────────────────────────────────────── -"$MCPP" self env > second.out 2> second.err || true +"$MCPP" self init > second.out 2> second.err || true if grep -qi 'cannot find the path specified' second.err; then fail "A: the version probe reached a shell that could not open its redirect" second.err fi @@ -76,7 +76,7 @@ rm -f "$VENDORED" cp "$NINJA" "$VENDORED" chmod +x "$VENDORED" 2>/dev/null || true -MCPP_VENDORED_XLINGS="$TMP/real/xlings$EXE" "$MCPP" self env > third.out 2> third.err || true +MCPP_VENDORED_XLINGS="$TMP/real/xlings$EXE" "$MCPP" self init > third.out 2> third.err || true grep -q "vendored xlings $older -> " third.err \ || fail "B: a vendored xlings answering $older was not replaced" third.out third.err "$VENDORED" --version 2>/dev/null | grep -q '^xlings ' \ @@ -98,7 +98,7 @@ C_PATH="/usr/bin:/bin" if PATH="$C_PATH" command -v xlings > /dev/null 2>&1; then fail "C: an xlings is reachable on $C_PATH, so the criterion cannot tell its sources apart" fi -env -u MCPP_VENDORED_XLINGS PATH="$C_PATH" "$TMP/release/bin/mcpp$EXE" self env > fourth.out 2> fourth.err || true +env -u MCPP_VENDORED_XLINGS PATH="$C_PATH" "$TMP/release/bin/mcpp$EXE" self init > fourth.out 2> fourth.err || true grep -q "vendored xlings $older -> " fourth.err \ || fail "C: the released xlings did not replace a vendored xlings answering $older" fourth.out fourth.err "$VENDORED" --version 2>/dev/null | grep -q '^xlings ' \ diff --git a/tests/e2e/688_emit_build_database.sh b/tests/e2e/688_emit_build_database.sh index 489d38151..3a1e21b43 100755 --- a/tests/e2e/688_emit_build_database.sh +++ b/tests/e2e/688_emit_build_database.sh @@ -290,7 +290,7 @@ compiled_in_cold_home() { export MCPP_HOME="$COLD" MCPP_OFFLINE=1 source "$HERE/_inherit_toolchain.sh" "$MCPP" emit build-database --format json > "$OUT/cold.json" 2> "$OUT/cold.err" -) || fail "L: emit in a home with a cold std cache" "$OUT/cold.err" +) || fail "L: emit in a home with a cold std cache" "$OUT/cold.err" "$OUT/cold.json" "$PY" - "$OUT/cold.json" <<'EOF' || fail "L: the cold-home document" "$OUT/cold.json" import json, sys db = json.load(open(sys.argv[1]))["data"]["database"] diff --git a/tests/e2e/732_xlings_refresh_is_bounded_and_owned.sh b/tests/e2e/732_xlings_refresh_is_bounded_and_owned.sh index c054edcbb..bcdef8f49 100755 --- a/tests/e2e/732_xlings_refresh_is_bounded_and_owned.sh +++ b/tests/e2e/732_xlings_refresh_is_bounded_and_owned.sh @@ -34,7 +34,7 @@ mkdir -p "$TMP/bin" "$TMP/home" cat > "$TMP/bin/xlings" <<'EOF' #!/usr/bin/env bash # The handshake mcpp asks of every xlings (docs/25 §5). -[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.11","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state"]}'; exit 0; } +[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.13","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state","network_diagnostics","download_socket_budgets","read_only_queries"]}'; exit 0; } case "${1:-}:${2:-}" in update:*|interface:update_packages) echo "$$" > "${STUB_PID:?}"; exec sleep 1000 ;; esac diff --git a/tests/e2e/733_envelope_reports_observed_network_access.sh b/tests/e2e/733_envelope_reports_observed_network_access.sh index 0a195b09f..00faad08f 100755 --- a/tests/e2e/733_envelope_reports_observed_network_access.sh +++ b/tests/e2e/733_envelope_reports_observed_network_access.sh @@ -24,7 +24,7 @@ mkdir -p "$TMP/bin" cat > "$TMP/bin/xlings" <<'EOF' #!/usr/bin/env bash # The handshake mcpp asks of every xlings (docs/25 §5). -[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.11","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state"]}'; exit 0; } +[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.13","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state","network_diagnostics","download_socket_budgets","read_only_queries"]}'; exit 0; } echo "$*" >> "${STUB_LOG:?}" exit 0 EOF diff --git a/tests/e2e/734_auto_refresh_false_governs_first_index_sync.sh b/tests/e2e/734_auto_refresh_false_governs_first_index_sync.sh index b6f51dca9..3e1935a58 100755 --- a/tests/e2e/734_auto_refresh_false_governs_first_index_sync.sh +++ b/tests/e2e/734_auto_refresh_false_governs_first_index_sync.sh @@ -22,7 +22,7 @@ mkdir -p "$TMP/bin" cat > "$TMP/bin/xlings" <<'EOF' #!/usr/bin/env bash # The handshake mcpp asks of every xlings (docs/25 §5). -[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.11","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state"]}'; exit 0; } +[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.13","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state","network_diagnostics","download_socket_budgets","read_only_queries"]}'; exit 0; } echo "$*" >> "${STUB_LOG:?}" exit 0 EOF diff --git a/tests/e2e/75_index_status_offline.sh b/tests/e2e/75_index_status_offline.sh index 4f9cc2da3..383bc9d60 100755 --- a/tests/e2e/75_index_status_offline.sh +++ b/tests/e2e/75_index_status_offline.sh @@ -11,7 +11,7 @@ trap "rm -rf $TMP" EXIT export MCPP_HOME="$TMP/mcpp-home" # init (this is the one place a first run may fetch the index) -"$MCPP" self env > /dev/null +"$MCPP" self init > /dev/null # status: exits 0, prints the table header + both index rows out=$("$MCPP" index status 2>&1) diff --git a/tests/e2e/812_an_index_floor_is_a_closing_tip.sh b/tests/e2e/812_an_index_floor_is_a_closing_tip.sh index 5d653c597..11eb4ff9c 100755 --- a/tests/e2e/812_an_index_floor_is_a_closing_tip.sh +++ b/tests/e2e/812_an_index_floor_is_a_closing_tip.sh @@ -35,7 +35,7 @@ mkdir -p "$TMP/bin" cat > "$TMP/bin/xlings" < "$OUT/bin/xlings" <<'EOF' #!/usr/bin/env bash # The handshake mcpp asks of every xlings (docs/25 §5). -[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.11","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state"]}'; exit 0; } +[[ "${1:-}" == "interface" && "${2:-}" == "--version" ]] && { echo '{"protocol_version":"1.13","version":"9999.1.1.1","capabilities":["install_packages"],"home_layouts":[2],"features":["index_dir","index_store","upstreams","payload_status","sync_index","payload_currency","binding_state","network_diagnostics","download_socket_budgets","read_only_queries"]}'; exit 0; } echo "$*" >> "${STUB_LOG:?}" exit 0 EOF diff --git a/tests/e2e/846_the_vendored_xlings_is_replaced_from_the_newest_source.sh b/tests/e2e/846_the_vendored_xlings_is_replaced_from_the_newest_source.sh index 0099d326c..494a34557 100755 --- a/tests/e2e/846_the_vendored_xlings_is_replaced_from_the_newest_source.sh +++ b/tests/e2e/846_the_vendored_xlings_is_replaced_from_the_newest_source.sh @@ -37,7 +37,7 @@ MCPP_INHERIT_CONFIG=0 source "$(dirname "$0")/_inherit_toolchain.sh" cd "$TMP" VENDORED="$MCPP_HOME/registry/bin/xlings$EXE" -"$MCPP" self env > setup.out 2> setup.err || true +"$MCPP" self init > setup.out 2> setup.err || true [ -f "$VENDORED" ] || fail "setup: the first command did not vendor xlings at $VENDORED" setup.out setup.err "$VENDORED" --version 2>/dev/null | grep -q '^xlings ' \ || fail "setup: the vendored binary does not answer as xlings" setup.err @@ -71,7 +71,7 @@ fi place() { rm -f "$1"; cp "$2" "$1"; chmod +x "$1" 2>/dev/null || true; } run() { # run : the release-layout mcpp, with the test PATH env -u MCPP_VENDORED_XLINGS PATH="$TMP/pathbin:$BASE_PATH" \ - "$TMP/release/bin/mcpp$EXE" self env > "$1.out" 2> "$1.err" || true + "$TMP/release/bin/mcpp$EXE" self init > "$1.out" 2> "$1.err" || true } count() { grep -c "$1" "$2" || true; } diff --git a/tests/e2e/914_an_upstream_home_is_linked_never_written.sh b/tests/e2e/914_an_upstream_home_is_linked_never_written.sh index 80e7652d1..b81a54b09 100755 --- a/tests/e2e/914_an_upstream_home_is_linked_never_written.sh +++ b/tests/e2e/914_an_upstream_home_is_linked_never_written.sh @@ -26,14 +26,14 @@ fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && cat "$2"; exit 1; } UP="$TMP/upstream" export MCPP_HOME="$UP" source "$(dirname "$0")/_inherit_toolchain.sh" -"$MCPP" self env > "$TMP/up.env" 2>&1 || fail "the upstream home does not initialise" "$TMP/up.env" +"$MCPP" self init > "$TMP/up.env" 2>&1 || fail "the upstream home does not initialise" "$TMP/up.env" snapshot() { (cd "$UP/registry/data/xpkgs" && find . -maxdepth 2 -printf '%p %l\n' | sort); } snapshot > "$TMP/up.before" # The home under test: nothing installed, the upstream named. export MCPP_HOME="$TMP/home" mkdir -p "$MCPP_HOME" -"$MCPP" self env > /dev/null 2>&1 || true +"$MCPP" self config --mirror auto > /dev/null 2>&1 || true sed -i.bak "s|^\[xlings\]\$|[xlings]\nupstream = [\"$(host_path "$UP")\"]|" "$MCPP_HOME/config.toml" grep -q '^upstream = ' "$MCPP_HOME/config.toml" || fail "the test could not name the upstream" "$MCPP_HOME/config.toml" diff --git a/tests/e2e/916_a_home_from_before_keeps_working.sh b/tests/e2e/916_a_home_from_before_keeps_working.sh index 7d29a8a56..837a1be15 100755 --- a/tests/e2e/916_a_home_from_before_keeps_working.sh +++ b/tests/e2e/916_a_home_from_before_keeps_working.sh @@ -41,7 +41,7 @@ printf 'ok\n' > "$OLD/.mcpp_ok" before=$(cd "$OLD" && find . -type f -exec sha256sum {} + | sort) cd "$TMP" -"$MCPP" self env > a.log 2>&1 || fail "A: the first run failed" a.log +"$MCPP" self config --mirror auto > a.log 2>&1 || fail "A: the first run failed" a.log [ -f "$MCPP_HOME/indices/mcpplibs/pkgs/f/fixture.lua" ] || fail "A: the index data did not move" [ ! -e "$REG/data/mcpplibs" ] || fail "A: the index data is still at the old place" python3 - "$REG/.xlings.json" "$MCPP_HOME" <<'PY' || fail "A: the configuration does not record the index's place" "$REG/.xlings.json" @@ -49,8 +49,8 @@ import json, os, sys doc = json.load(open(sys.argv[1])) e = next(r for r in doc["index_repos"] if r["name"] == "mcpplibs") home = sys.argv[2] -assert os.path.normpath(e["dir"]) == os.path.normpath(os.path.join(home, "indices", "mcpplibs")), e -assert os.path.normpath(e["store"]) == os.path.normpath(os.path.join(home, "xpkgs")), e +assert os.path.realpath(e["dir"]) == os.path.realpath(os.path.join(home, "indices", "mcpplibs")), e +assert os.path.realpath(e["store"]) == os.path.realpath(os.path.join(home, "xpkgs")), e PY echo "ok: A. the index moved once and its place is recorded" diff --git a/tests/e2e/919_a_home_selection_is_remembered_once.sh b/tests/e2e/919_a_home_selection_is_remembered_once.sh new file mode 100644 index 000000000..47b0166e0 --- /dev/null +++ b/tests/e2e/919_a_home_selection_is_remembered_once.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# requires: python3 +# Real CLI processes prove persistence, one hop, repair and force data retention. +set -euo pipefail +python3 "$(dirname "$0")/_home_selection.py" diff --git a/tests/e2e/920_the_doctor_checks_resources_without_bootstrap.sh b/tests/e2e/920_the_doctor_checks_resources_without_bootstrap.sh new file mode 100644 index 000000000..6ceceb291 --- /dev/null +++ b/tests/e2e/920_the_doctor_checks_resources_without_bootstrap.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# requires: python3 +set -euo pipefail +python3 "$(dirname "$0")/_doctor_network.py" diff --git a/tests/e2e/_doctor_network.py b/tests/e2e/_doctor_network.py new file mode 100644 index 000000000..b558060a5 --- /dev/null +++ b/tests/e2e/_doctor_network.py @@ -0,0 +1,123 @@ +"""Actual CLI + provider resource checks, with a local HTTP oracle.""" +import http.server +import json +import os +from pathlib import Path +import subprocess +import socketserver +import tempfile +import threading +import time + +binary = Path(os.environ['MCPP']).resolve() +provider = Path(os.environ.get('MCPP_NETWORK_PROVIDER') or os.environ.get('MCPP_VENDORED_XLINGS') + or os.environ['XLINGS_BIN']).resolve() + +class ResourceServer(http.server.BaseHTTPRequestHandler): + requests = 0 + def do_GET(self): + type(self).requests += 1 + body = b'{"indexes":{}}' + code = 403 if self.path == '/denied' else 404 if self.path.startswith('/cn/') else 200 + self.send_response(code) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + try: + if self.path == '/stall': + time.sleep(2) + if self.path == '/trickle': + for byte in body: + self.wfile.write(bytes([byte])) + self.wfile.flush() + time.sleep(0.1) + else: + self.wfile.write(body) + except OSError: + pass # the bounded probe deliberately closes a stalled response + def log_message(self, *_): + pass + +class RejectTLS(socketserver.BaseRequestHandler): + client_hellos = 0 + def handle(self): + self.request.settimeout(2) + try: + hello = self.request.recv(1024) + if hello and hello[0] == 0x16: + type(self).client_hellos += 1 + self.request.sendall(bytes.fromhex('15030300020228')) # fatal handshake_failure + except OSError: + pass + +class TLSServer(socketserver.ThreadingTCPServer): + daemon_threads = True + +tls_server = TLSServer(('127.0.0.1', 0), RejectTLS) +threading.Thread(target=tls_server.serve_forever, daemon=True).start() +server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), ResourceServer) +threading.Thread(target=server.serve_forever, daemon=True).start() +try: + with tempfile.TemporaryDirectory(prefix='mcpp-doctor-network-') as temporary: + root = Path(temporary) + home = root / 'home' + registry = home / 'registry' + registry.mkdir(parents=True) + (home / 'config.toml').write_text('[xlings]\nbinary = ' + json.dumps(provider.as_posix()) + '\n', encoding='utf-8') + base = 'http://127.0.0.1:' + str(server.server_port) + (registry / '.xlings.json').write_text(json.dumps({'mirror': 'auto', 'index_repos': [{ + 'name': 'corp', 'url': base + '/corp.git', 'artifact': { + 'GLOBAL': base + '/global', 'CN': base + '/cn'}}]}), encoding='utf-8') + env = os.environ | {'MCPP_HOME': str(home), 'XLINGS_HOME': str(registry), + 'XLINGS_INDEX_BASE_URL': base + '/official', 'MCPP_OFFLINE': '0'} + handshake = subprocess.run([str(provider), 'interface', '--version'], env=env, cwd=root, + capture_output=True, text=True, encoding='utf-8', timeout=5, check=True) + assert {'network_diagnostics', 'download_socket_budgets', 'read_only_queries'} <= set(json.loads(handshake.stdout)['features']), handshake.stdout + def snapshot(): + return {str(p.relative_to(home)): p.read_bytes() if p.is_file() else None + for p in home.rglob('*')} + before = snapshot() + local_env = env | {name: '' for name in ('HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy')} + local_env |= {'NO_PROXY': '*', 'no_proxy': '*'} + for url, expected, http_status in ( + (base + '/denied', ('http',), 403), + (base + '/stall', ('timeout',), 200), + (base + '/trickle', ('timeout',), 200), + ('https://127.0.0.1:' + str(tls_server.server_address[1]) + '/resource', ('tls', 'connect'), 0)): + started = time.monotonic() + response = subprocess.run([str(provider), 'interface', 'probe_url', '--args', + json.dumps({'url': url, 'socket_timeout_ms': 500})], env=local_env, cwd=root, + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3) + assert response.returncode == 0, (response.stdout, response.stderr) + data = next(json.loads(line)['payload'] for line in response.stdout.splitlines() + if json.loads(line).get('dataKind') == 'network_probe') + assert data['status'] in expected and not data['reachable'], data + assert data['http_status'] == http_status, data + assert time.monotonic() - started < 2, data + assert snapshot() == before, 'a resource probe changed the home' + assert RejectTLS.client_hellos == 1, 'the TLS probe never reached the handshake oracle' + before_doctor_requests = ResourceServer.requests + started = time.monotonic() + online = subprocess.run([str(binary), 'self', 'doctor'], env=env, cwd=root, capture_output=True, + text=True, encoding='utf-8', errors='replace', timeout=8) + assert time.monotonic() - started < 7, online.stderr + assert online.returncode == 1, (online.returncode, online.stdout, online.stderr) + assert 'corp GLOBAL' in online.stderr and ': reachable' in online.stderr, online.stderr + assert 'corp CN' in online.stderr and ': http' in online.stderr, online.stderr + assert 'Checking build policy' in online.stderr, online.stderr + assert snapshot() == before, 'online doctor changed the home' + requests = ResourceServer.requests + assert requests - before_doctor_requests >= 3, 'doctor never reached the actual resource server' + env['MCPP_OFFLINE'] = '1' + offline = subprocess.run([str(binary), 'self', 'doctor'], env=env, cwd=root, capture_output=True, + text=True, encoding='utf-8', errors='replace', timeout=8) + assert offline.returncode == 0, (offline.returncode, offline.stdout, offline.stderr) + assert 'Skipped (offline mode)' in offline.stderr, offline.stderr + assert ResourceServer.requests == requests, 'offline doctor made a resource request' + assert snapshot() == before, 'offline doctor changed the home' + assert not (registry / 'subos').exists(), 'doctor bootstrapped the provider' +finally: + server.shutdown() + server.server_close() + tls_server.shutdown() + tls_server.server_close() +print('PASS: bounded HTTP/TLS faults, unchanged diagnostic home, and no requests offline') diff --git a/tests/e2e/_home_selection.py b/tests/e2e/_home_selection.py new file mode 100644 index 000000000..e287b8d7b --- /dev/null +++ b/tests/e2e/_home_selection.py @@ -0,0 +1,174 @@ +import json +import os +import shutil +import stat +from pathlib import Path +import subprocess +import tempfile + +binary = Path(os.environ['MCPP']).resolve() +with tempfile.TemporaryDirectory(prefix='mcpp-home-e2e-') as tmp: + root = Path(tmp).resolve() + original, target, third = (root / name for name in ('original home \u0915\u0916\u0917', 'selected home \u65e5\u672c\u8a9e \U0001f642', 'third')) + original.mkdir() + (original / 'config.toml').write_text('# Existing home\n', encoding='utf-8') + user = root / 'unselected user' + user_locator = user / '.mcpp' / 'home.toml' + user_locator.parent.mkdir(parents=True) + user_locator.write_text('home = [\n', encoding='utf-8') + env = os.environ | {'MCPP_HOME': str(original), 'MCPP_OFFLINE': '1', + 'HOME': str(user), 'USERPROFILE': str(user)} + + def run(*args, expected=None, offline=True): + childenv = env.copy() + if not offline: + childenv.pop('MCPP_OFFLINE', None) + p = subprocess.run([str(binary), *args], env=childenv, cwd=root, + stdin=subprocess.DEVNULL, capture_output=True, + text=True, encoding='utf-8', errors='replace', timeout=15) + if expected is not None: + assert p.returncode == expected, (args, p.returncode, p.stdout, p.stderr) + return p + + # CLI argv, a relative path, spaces and Unicode survive persistence/restart. + relative_target = str(target.relative_to(root)) + p = run('self', 'init', '--home', relative_target, expected=1) + assert '--force' in p.stderr and not target.exists(), (p.stdout, p.stderr) + p = run('self', 'init', '--force', '--home', relative_target) + assert p.returncode in (0, 1), (p.stdout, p.stderr) + assert (target / 'config.toml').is_file(), (p.stdout, p.stderr) + assert (original / 'config.toml').is_file() + assert (original / 'home.toml').is_file() + data = json.loads(run('self', 'env', '--format', 'json', expected=0).stdout)['data'] + assert Path(data['mcppHome']).resolve() == target + assert Path(data['originalHome']).resolve() == original + + # A locator in the destination is ignored, including an unavailable target. + (target / 'home.toml').write_text(f'home = {json.dumps(third.as_posix())}\n', encoding='utf-8') + data = json.loads(run('self', 'env', '--format', 'json', expected=0).stdout)['data'] + assert Path(data['mcppHome']).resolve() == target + assert not third.exists() + + sentinel = target / 'registry' / 'retain-sentinel' + sentinel.write_text('keep', encoding='utf-8') + run('self', 'init', '--force', '--home', str(target)) + assert sentinel.is_file(), 'explicit force-home deleted existing data' + + # A mirror change must succeed with no usable network and no bootstrap. + env.update(HTTPS_PROXY='http://127.0.0.1:9', HTTP_PROXY='http://127.0.0.1:9', + ALL_PROXY='http://127.0.0.1:9', NO_PROXY='') + p = run('self', 'config', '--mirror', 'auto', expected=0, offline=False) + assert not any(x in p.stderr for x in ('Initialize', 'Bootstrap', 'Fetching')), p.stderr + assert json.loads((target / 'registry' / '.xlings.json').read_text(encoding='utf-8'))['mirror'] == 'auto' + assert sentinel.is_file() + + (original / 'home.toml').write_text('home = [\n', encoding='utf-8') + p = run('self', 'env', '--format', 'json', expected=4) + assert 'locator' in p.stderr and 'internal:' not in p.stderr + run('self', 'init', '--force', '--home', str(target)) + assert sentinel.is_file() + data = json.loads(run('self', 'env', '--format', 'json', expected=0).stdout)['data'] + assert Path(data['mcppHome']).resolve() == target + + run('self', 'init', '--force', '--home', str(original)) + assert not (original / 'home.toml').exists() + assert sentinel.is_file() + data = json.loads(run('self', 'env', '--format', 'json', expected=0).stdout)['data'] + assert Path(data['mcppHome']).resolve() == original + + # Portable installations own their locator and ignore the user's bad one. + portable = root / 'portable' + portable_bin = portable / 'bin' / binary.name + portable_bin.parent.mkdir(parents=True) + shutil.copy2(binary, portable_bin) + (portable / 'home.toml').write_text('home = ' + json.dumps(target.as_posix(), ensure_ascii=False) + + '\n', encoding='utf-8') + portable_env = env.copy() + portable_env.pop('MCPP_HOME') + p = subprocess.run([str(portable_bin), 'self', 'env', '--format', 'json'], + env=portable_env, cwd=root, stdin=subprocess.DEVNULL, + capture_output=True, text=True, encoding='utf-8', timeout=15) + assert p.returncode == 0, (p.returncode, p.stdout, p.stderr) + data = json.loads(p.stdout)['data'] + assert Path(data['originalHome']).resolve() == portable + assert Path(data['mcppHome']).resolve() == target + assert user_locator.read_text(encoding='utf-8') == 'home = [\n' + + # Real concurrent writers and separate readers exercise the shared locator. + raced = [root / ('concurrent home ' + str(i)) for i in range(4)] + writers = [] + try: + for destination in raced: + writers.append(subprocess.Popen([str(binary), 'self', 'init', '--force', '--home', + str(destination)], env=env, cwd=root, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE)) + for _ in range(4): + data = json.loads(run('self', 'env', '--format', 'json', expected=0).stdout)['data'] + assert Path(data['mcppHome']).resolve() in [original, *raced], data + for writer in writers: + out, err = writer.communicate(timeout=15) + assert writer.returncode in (0, 1), (writer.returncode, out, err) + finally: + for writer in writers: + if writer.poll() is None: + writer.kill() + writer.communicate(timeout=5) + data = json.loads(run('self', 'env', '--format', 'json', expected=0).stdout)['data'] + assert Path(data['mcppHome']).resolve() in raced, data + assert sentinel.is_file() and (original / 'config.toml').is_file() + assert not list(original.glob('home.toml.*.tmp')), 'a normal writer left a staged locator' + + # Force a genuine publication/write failure and retain the previous pointer. + locator = original / 'home.toml' + before = locator.read_bytes() + blocked = root / 'blocked publication' + write_fault_proved = False + if os.name == 'nt': + locator.chmod(stat.S_IREAD) + try: + p = run('self', 'init', '--force', '--home', str(blocked), expected=1) + assert locator.read_bytes() == before, (p.stdout, p.stderr) + write_fault_proved = True + finally: + locator.chmod(stat.S_IREAD | stat.S_IWRITE) + else: + mode = stat.S_IMODE(original.stat().st_mode) + original.chmod(0o500) + try: + control = original / 'write-fault-control' + try: + control.write_bytes(b'control') + except PermissionError: + p = run('self', 'init', '--force', '--home', str(blocked), expected=1) + assert locator.read_bytes() == before, (p.stdout, p.stderr) + write_fault_proved = True + else: + control.unlink() + print('SKIP: this user bypasses directory write permissions; no POSIX write-fault proof') + finally: + original.chmod(mode) + + assert not list(original.glob('home.toml.*.tmp')), 'a failed publication left a staged locator' + + # Bare force still resets the effective target, retaining origin and locator. + run('self', 'init', '--force', '--home', str(target)) + retained = original / 'registry' / 'retained-old-data' + retained.parent.mkdir(exist_ok=True) + retained.write_text('keep', encoding='utf-8') + markers = [] + for name in ('registry', 'cache', 'build-cache', 'bmi'): + folder = target / name + folder.mkdir(exist_ok=True) + marker = folder / 'bare-force-marker' + marker.write_text('derived', encoding='utf-8') + markers.append(marker) + p = run('self', 'init', '--force') + assert p.returncode in (0, 1), (p.stdout, p.stderr) + assert '--force rebuilds' in p.stderr and target.name in p.stderr, p.stderr + assert all(not marker.exists() for marker in markers) + assert retained.read_text(encoding='utf-8') == 'keep' and locator.is_file() + data = json.loads(run('self', 'env', '--format', 'json', expected=0).stdout)['data'] + assert Path(data['mcppHome']).resolve() == target + +print('PASS: Unicode/relative persistence, separate env/portable locators, concurrent publication, and force data boundaries; write-fault retention ' + ('verified' if write_fault_proved else 'unavailable on this account')) diff --git a/tests/unit/test_home.cpp b/tests/unit/test_home.cpp index 7a2cd029f..bbb2ff09a 100644 --- a/tests/unit/test_home.cpp +++ b/tests/unit/test_home.cpp @@ -3,7 +3,10 @@ import std; import mcpp.home; +import mcpp.platform; import mcpp.toolchain.stdmod; +import mcpp.config; +import mcpp.libs.toml; namespace { @@ -42,9 +45,10 @@ class ScopedEnv { TEST(Home, ExplicitMcppHomeWins) { ScopedEnv home("MCPP_HOME", "/tmp/mcpp-home-test"); - EXPECT_EQ(mcpp::home::root(), std::filesystem::path("/tmp/mcpp-home-test")); + const auto expected = std::filesystem::weakly_canonical(std::filesystem::absolute("/tmp/mcpp-home-test")); + EXPECT_EQ(mcpp::home::root(), expected); EXPECT_EQ(mcpp::home::cache_root(), - std::filesystem::path("/tmp/mcpp-home-test") / "build-cache" / "v1"); + expected / "build-cache" / "v1"); } TEST(Home, CacheRootIsAlwaysRootSlashBuildCacheV1) { @@ -98,3 +102,146 @@ TEST(Home, StdModuleCacheRootEqualsHomeCacheRoot) { EXPECT_EQ(mcpp::toolchain::default_cache_root(), mcpp::home::cache_root()); } } + +namespace { +class HomeSelection : public ::testing::Test { +protected: + std::filesystem::path scratch = std::filesystem::temp_directory_path() + / std::format("mcpp-home-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::path original = scratch / "original"; + std::filesystem::path target = scratch / "target"; + ScopedEnv env{"MCPP_HOME", mcpp::home::spelling(original).c_str()}; + void SetUp() override { + std::filesystem::create_directories(original); + std::filesystem::create_directories(target); + } + void TearDown() override { + std::error_code ec; + std::filesystem::remove_all(scratch, ec); + } + void locator(const std::filesystem::path& directory, const std::filesystem::path& destination) { + std::ofstream(directory / "home.toml") << "home = " + << mcpp::libs::toml::escape_string(mcpp::home::spelling(destination)) << '\n'; + } +}; +} + +TEST_F(HomeSelection, DefaultOriginalAndEffectiveAreIdentical) { + const auto selected = mcpp::home::selection(); + EXPECT_EQ(selected.original, std::filesystem::weakly_canonical(original)); + EXPECT_EQ(selected.original, selected.effective); + EXPECT_FALSE(selected.redirected); +} + +TEST_F(HomeSelection, EnvironmentSelectsTheLocatorAndOnlyOneHopIsRead) { + const auto third = scratch / "third"; + std::filesystem::create_directories(third); + locator(original, target); + locator(target, third); + const auto selected = mcpp::home::selection(); + EXPECT_EQ(selected.original, std::filesystem::weakly_canonical(original)); + EXPECT_EQ(selected.effective, std::filesystem::weakly_canonical(target)); + EXPECT_EQ(mcpp::home::cache_root(), selected.effective / "build-cache" / "v1"); + EXPECT_TRUE(selected.redirected); +} + +TEST_F(HomeSelection, UnavailableTargetIsAnErrorInsteadOfAFallback) { + locator(original, scratch / "missing"); + const auto selected = mcpp::home::inspect(original); + ASSERT_FALSE(selected); + EXPECT_NE(selected.error().find("unavailable"), std::string::npos); + EXPECT_FALSE(std::filesystem::exists(original / "config.toml")); +} + +TEST_F(HomeSelection, ForceCanRepairAMalformedLocator) { + std::ofstream(original / "home.toml") << "home = [\n"; + EXPECT_FALSE(mcpp::home::choose(target, false)); + const auto selected = mcpp::home::choose(target, true); + ASSERT_TRUE(selected) << selected.error(); + EXPECT_TRUE(mcpp::home::save(*selected)); + const auto restored = mcpp::home::inspect(original); + ASSERT_TRUE(restored) << restored.error(); + EXPECT_EQ(restored->effective, std::filesystem::weakly_canonical(target)); +} + +TEST_F(HomeSelection, ExistingHomeNeedsForceAndChangingItKeepsBothDataTrees) { + std::ofstream(original / "config.toml") << "# existing\n"; + std::ofstream(target / "sentinel") << "keep\n"; + EXPECT_FALSE(mcpp::home::choose(target, false)); + const auto selected = mcpp::home::choose(target, true); + ASSERT_TRUE(selected) << selected.error(); + 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)); + const auto restored = mcpp::home::inspect(original); + ASSERT_TRUE(restored); + EXPECT_EQ(restored->previous, std::filesystem::weakly_canonical(original)); +} + +TEST_F(HomeSelection, ReturningToOriginalRemovesOnlyTheLocator) { + locator(original, target); + std::ofstream(target / "config.toml") << "# existing\n"; + const auto selected = mcpp::home::choose(original, true); + ASSERT_TRUE(selected) << selected.error(); + ASSERT_TRUE(mcpp::home::save(*selected)); + EXPECT_FALSE(std::filesystem::exists(original / "home.toml")); + EXPECT_TRUE(std::filesystem::exists(target / "config.toml")); + EXPECT_EQ(mcpp::home::root(), std::filesystem::weakly_canonical(original)); +} + +TEST_F(HomeSelection, NestedDirectoriesAreRefused) { + EXPECT_FALSE(mcpp::home::choose(original / "nested", true)); + EXPECT_FALSE(mcpp::home::choose(scratch, true)); +} + +TEST_F(HomeSelection, ReadOnlyConfigurationDoesNotInitializeOrAcquireAProvider) { + const auto cold = scratch / "cold"; + ScopedEnv home("MCPP_HOME", mcpp::home::spelling(cold).c_str()); + const auto config = mcpp::config::load_or_init(true, {}, {}, mcpp::config::Initialization::ReadOnly); + ASSERT_TRUE(config) << config.error().message; + EXPECT_EQ(config->mcppHome, std::filesystem::weakly_canonical(cold)); + EXPECT_FALSE(std::filesystem::exists(cold)); +} + +TEST_F(HomeSelection, UserSourceSurvivesOriginalPathNormalization) { + const auto user = mcpp::home::spelling(scratch); + ScopedEnv env("MCPP_HOME", nullptr); + ScopedEnv posixHome("HOME", user.c_str()); + ScopedEnv windowsHome("USERPROFILE", user.c_str()); + const auto selected = mcpp::home::inspect(scratch / ".mcpp"); + ASSERT_TRUE(selected) << selected.error(); + EXPECT_EQ(selected->source, "user"); +} + +TEST_F(HomeSelection, MissingUserDirectoryCannotPublishALocatorInTheWorkingDirectory) { + ScopedEnv explicitHome("MCPP_HOME", nullptr); + ScopedEnv posixHome("HOME", nullptr); + ScopedEnv windowsHome("USERPROFILE", nullptr); + const auto selected = mcpp::home::choose(target, true); + ASSERT_FALSE(selected); + EXPECT_NE(selected.error().find("user directory"), std::string::npos); +} + + +TEST_F(HomeSelection, UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath) { + if constexpr (mcpp::platform::is_windows) { + const auto acp = mcpp::platform::windows::active_code_page(); + ASSERT_NE(acp, 0u); + if (acp == 65001) + GTEST_SKIP() << "UTF-8 ACP cannot prove the legacy-code-page path conversion boundary; " + "the actual CLI Unicode case still runs"; + } + // Devanagari cannot be represented by any legacy Windows ANSI code page. + // Explicit UTF-8 code units keep this independent of compiler source encoding. + const auto unicode = scratch / std::filesystem::path( + std::u8string(u8"selected space \u0915\u0916\u0917 \u65e5\u672c\u8a9e")); + std::filesystem::create_directory(unicode); + const auto selected = mcpp::home::choose(unicode, true); + ASSERT_TRUE(selected) << selected.error(); + ASSERT_TRUE(mcpp::home::save(*selected)); + const auto restored = mcpp::home::inspect(original); + ASSERT_TRUE(restored) << restored.error(); + EXPECT_EQ(restored->effective, std::filesystem::weakly_canonical(unicode)); + EXPECT_EQ(mcpp::home::from_utf8(mcpp::home::spelling(unicode)), unicode); +} diff --git a/tests/unit/test_network.cpp b/tests/unit/test_network.cpp new file mode 100644 index 000000000..219fc16d3 --- /dev/null +++ b/tests/unit/test_network.cpp @@ -0,0 +1,216 @@ +#include + +import std; +import mcpp.provider; +import mcpp.provider.xlings; +import mcpp.platform; + +namespace { +struct NetworkFixture { + std::filesystem::path dir = std::filesystem::temp_directory_path() + / std::format("mcpp-network-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + mcpp::provider::Location env; + NetworkFixture(std::string_view script) { + std::filesystem::create_directories(dir); + env = {dir / "provider", dir, {}}; + std::ofstream(env.binary) << R"SH(#!/bin/sh +touch "$XLINGS_HOME/invoked" +if [ "$2" = "--version" ]; then + echo '{"version":"9999.1.1.1","features":["network_diagnostics","read_only_queries"]}' + exit 0 +fi +)SH" << script; + std::filesystem::permissions(env.binary, std::filesystem::perms::owner_all); + } + ~NetworkFixture() { std::error_code ec; std::filesystem::remove_all(dir, ec); } +}; +} + +TEST(NetworkCheck, OfflineNeverInvokesTheProvider) { + if constexpr (mcpp::platform::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"); + 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"); + 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"; + 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"}]}}' +else + sleep 1000 +fi +)SH"); + mcpp::platform::env::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)); + ASSERT_EQ(result.endpoints.size(), 5u); + for (const auto& e : result.endpoints) EXPECT_EQ(e.status, "timeout"); +} + +TEST(NetworkCheck, MalformedProviderDataDoesNotEscapeDiagnosis) { + if constexpr (mcpp::platform::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"}]}}' +else + echo '{"kind":"data","dataKind":"network_probe","payload":{"reachable":"wrong-type"}}' +fi +)SH"); + mcpp::platform::env::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"); +} + +TEST(NetworkCheck, HeartbeatsCannotKeepAByteStalledDownloadAlive) { + using namespace std::chrono; + mcpp::provider::xlings::DownloadWatchdog watchdog(seconds(30)); + const auto start = steady_clock::now(); + mcpp::provider::BootstrapProgress p; + p.files.push_back({.name = "tool", .started = true, .sourceAttempt = 1}); + watchdog.observe(p, start); + watchdog.observe(p, start + seconds(29)); // repeated protocol sample + EXPECT_TRUE(watchdog.expired(start + seconds(30))); + p.files.front().downloadedBytes = 1; + watchdog.observe(p, start + seconds(30)); + EXPECT_FALSE(watchdog.expired(start + seconds(59))); + p.files.front().sourceAttempt = 2; + p.files.front().downloadedBytes = 0; + watchdog.observe(p, start + seconds(60)); + EXPECT_FALSE(watchdog.expired(start + seconds(89))); + p.files.front().finished = true; + watchdog.observe(p, start + seconds(90)); + EXPECT_FALSE(watchdog.expired(start + hours(3))); +} + +TEST(NetworkProcess, ProgressCancellationIsPolledWhileOutputContinues) { + using namespace std::chrono; + const auto start = steady_clock::now(); + const auto cmd = mcpp::platform::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); }); + EXPECT_TRUE(stopped); + EXPECT_GT(lines, 0); + EXPECT_LT(steady_clock::now() - start, seconds(3)); +} + +TEST(NetworkCheck, IndexPreparationIsBoundedBeforeTheFirstDownloadEvent) { + using namespace std::chrono; + const auto start = steady_clock::now(); + mcpp::provider::xlings::InstallDeadline deadline(seconds(120), seconds(30), start); + deadline.observe(R"({"kind":"heartbeat","ts":100})", start + seconds(119)); + EXPECT_TRUE(deadline.expired(start + seconds(120))); + deadline.observe(R"({"kind":"progress","phase":"extract","percent":0})", start + seconds(119)); + EXPECT_FALSE(deadline.expired(start + minutes(10))); +} + +TEST(NetworkCheck, LegacyStatusAndHandshakeDoNotWriteTheSelectedHome) { + const auto dir = std::filesystem::temp_directory_path() + / std::format("mcpp-legacy-probe-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + struct Cleanup { + std::filesystem::path dir, previousCwd; + ~Cleanup() { + std::error_code ec; + std::filesystem::current_path(previousCwd, ec); + std::filesystem::remove_all(dir, ec); + } + } 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"); + { + std::ofstream script(binary, std::ios::binary); + if constexpr (mcpp::platform::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 + script << "#!/bin/sh\necho invoked >> \"$XLINGS_HOME/invoked\"\necho invoked > metadata-cwd\n" + "echo '{\"version\":\"9999.1.1.1\",\"features\":[]}'\n"; + } + std::filesystem::permissions(binary, std::filesystem::perms::owner_all); + const auto project = dir / "project"; + std::filesystem::create_directories(project); + std::filesystem::current_path(project); + mcpp::provider::Location env{binary, home, {}}; + const auto metadata = mcpp::provider::xlings::describe(std::filesystem::relative(binary, project)); + ASSERT_TRUE(metadata.answered) << "the legacy handshake fixture never ran"; + EXPECT_EQ(metadata.version, "9999.1.1.1"); + for (const auto* capability : {"system_status", "payload_status"}) { + const auto result = mcpp::provider::xlings::call(env, capability, "{}"); + ASSERT_FALSE(result); + EXPECT_NE(result.error().find("read_only_queries"), std::string::npos); + } + mcpp::platform::env::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"; +} + +TEST(NetworkCheck, BootstrapRequiresDownloadBudgetsAndReadOnlyQueries) { + mcpp::provider::ProviderInfo info; + info.answered = true; + info.version = "9999.1.1.1"; + info.capabilities = {"install_packages"}; + info.features = {"index_dir", "index_store", "upstreams", "payload_status", "sync_index", + "payload_currency", "binding_state"}; + for (const auto* feature : {"network_diagnostics", "download_socket_budgets", "read_only_queries"}) { + EXPECT_NE(mcpp::provider::xlings::unmet(info).find(feature), std::string::npos); + info.features.push_back(feature); + } + EXPECT_TRUE(mcpp::provider::xlings::unmet(info).empty()); +} + + +TEST(NetworkProcess, MetadataAndResourceDeadlinesStopDescendants) { + if constexpr (mcpp::platform::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; + const std::string writer = R"SH( +(file="${0%/*}/ticks"; i=0 +while [ "$i" -lt 50 ]; do + printf x >> "$file" + sleep 0.05 + i=$((i+1)) +done) >/dev/null 2>&1 & +wait +)SH"; + mcpp::platform::env::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" + "else\n" + writer + "fi\n"); + if (metadata) std::ofstream(f.env.binary) << "#!/bin/sh\n" << writer; + const auto start = std::chrono::steady_clock::now(); + if (metadata) + EXPECT_FALSE(mcpp::provider::xlings::describe(f.env.binary, std::chrono::milliseconds(500)).answered); + else { + const auto report = mcpp::provider::xlings::network_check(f.env, std::chrono::milliseconds(500)); + ASSERT_EQ(report.endpoints.size(), 1u); + EXPECT_EQ(report.endpoints.front().status, "timeout"); + } + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(2)); + const auto ticks = f.dir / "ticks"; + ASSERT_TRUE(std::filesystem::exists(ticks)) << "the descendant fixture never ran"; + const auto before = std::filesystem::file_size(ticks); + ASSERT_GT(before, 0u); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_EQ(std::filesystem::file_size(ticks), before) + << (metadata ? "metadata" : "resource") << " deadline left a descendant writing"; + } +} diff --git a/tests/unit/test_pm_package_fetcher.cpp b/tests/unit/test_pm_package_fetcher.cpp index e080261d1..dc0b32dc8 100644 --- a/tests/unit/test_pm_package_fetcher.cpp +++ b/tests/unit/test_pm_package_fetcher.cpp @@ -364,7 +364,7 @@ TEST(PmInstallFailurePolicy, OnlyAMissingPackageRefreshesTheIndex) { EXPECT_TRUE(mcpp::pm::install_failure_policy("E_NOT_FOUND").refreshIndex); EXPECT_TRUE(mcpp::pm::install_failure_policy("E_NOT_FOUND").retry); EXPECT_FALSE(mcpp::pm::install_failure_policy("E_NETWORK").refreshIndex); - EXPECT_TRUE(mcpp::pm::install_failure_policy("E_NETWORK").retry); + EXPECT_FALSE(mcpp::pm::install_failure_policy("E_NETWORK").retry); EXPECT_TRUE(mcpp::pm::install_failure_policy("E_INVALID_INPUT").retry); // A provider that said nothing (#238): the old conservative path. EXPECT_TRUE(mcpp::pm::install_failure_policy("").refreshIndex); diff --git a/tests/unit/test_xlings.cpp b/tests/unit/test_xlings.cpp index 2bd37dd0c..27c910b3e 100644 --- a/tests/unit/test_xlings.cpp +++ b/tests/unit/test_xlings.cpp @@ -404,7 +404,8 @@ TEST(XlingsHomeTool, FindsPayloadUnderNonXimPrefix) { mcpp::platform::env::set("MCPP_HOME", oldMcppHome ? oldMcppHome : ""); ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, real); + // The original home is normalized; macOS spells /var's target /private/var. + EXPECT_TRUE(std::filesystem::equivalent(*found, real)); std::filesystem::remove_all(tmp); }