diff --git a/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md b/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md new file mode 100644 index 00000000..5b171cbc --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md @@ -0,0 +1,421 @@ +# MSVC 纳入 mcpp 工具链体系 —— 设计 + 验证方案(2026-08-16) + +> **状态:已实现(2026.8.16.1)。** §6 的六个决策点全部有了答案,记在 §7; +> 其中**一条原方案的判断在实现时被推翻**,已在原处标注而不是悄悄改掉。 +> 落地结果与实测见 §7,跨仓库的任务拆分与依赖见 §8。 + +**三条缺陷全部在 HEAD 上核实过源码,不是照抄 issue; +上游依赖(xlings 包)已经就位并在 Windows CI 上装通。** + +对应 issue: #432。触发来源: [Sunrisepeak/xrgui#3](https://github.com/Sunrisepeak/xrgui/pull/3) +(用 mcpp 给 XRGUI 加 Windows 构建,已全绿——但代价是一个 workaround)。 + +| # | 缺陷 | 性质 | 改动面 | 是否阻塞 xrgui | +|---|---|---|---|---| +| A | `msvc` 是唯一不可声明版本的工具链 | 设计缺口 | 中 | **是** | +| B | `find_windows_sdk()` 写死两个绝对路径 | 可移植性 | 小 | 是(payload 化 SDK 后) | +| C | `cxx_runtime` 与 `linkage` 对静态 CRT 说法不一 | 接口一致性 | 小 | 否 | + +A 与 B 合起来才有意义:A 让 mcpp 能拿到指定的 toolset,B 让它能拿到配套的 SDK。 +C 独立,可并行。 + +--- + +## 0. TL;DR(给 review 的一页纸) + +- **MSVC 是 mcpp 工具链体系里唯一的例外**:gcc / llvm 由 mcpp 自己装、按声明解析; + 只有 MSVC 走 `msvc@system` 去**探测宿主**,而那个探测**无法被调用方覆盖**。 +- 后果不是「不够优雅」,是**同一份源码在两台机器上会被不同编译器编译,且不会报错**—— + xrgui#3 实测:同一轮 CI 里 mcpp 用 14.51、xmake 用 14.52,直到 14.51 ICE 才暴露。 +- **上游已经准备好了**:`xim:msvc` / `xim:windows-sdk` / `xim:curl` 已发布, + payload 布局刻意做成 mcpp 现状即可识别的形状,且 `xim:msvc@14.52.36629` + 正是 xrgui 需要的那个 toolset。 +- 提案:**`msvc@` 与 `gcc@` 同构**——非 system spec 走 `to_xim_package()` + 安装并解析;`msvc@system` 保留原义(用宿主已装的 VS)。 +- **验证不靠新写的测试,靠 xrgui**:它是这套东西的真实用例,且已经绿了—— + 验收标准是**删掉 workaround 之后仍然绿**,这是一个不能自证的判据。 + +--- + +## 1. 核实 + +### A. `msvc` 无法被指定 —— `src/toolchain/msvc.cppm` + +发现顺序: + +``` +1. vswhere -latest -products * -requires ...VC.Tools.x86.x64 +2. VSINSTALLDIR / VS*COMNTOOLS +3. Program Files\Microsoft Visual Studio\\ +``` + +两个事实叠加成缺口: + +1. 第 1 步**没有 `-prerelease`**,所以 Insider 实例对它完全不可见; +2. 第 1 步一旦成功,第 2 步**不会执行**——而那是调用方唯一能控制的入口。 + +`find_vs_via_env()` 的判据本身很宽松,只要求目录存在: + +```cpp +if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { + std::filesystem::path p{dir}; + if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) + return p; +} +``` + +**实测(xrgui#3)**:GitHub `windows-2025-vs2026` 镜像上同时存在 + +``` +C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Tools\MSVC\14.51.36231 ← 预装 +C:\VS2026Insider\VC\Tools\MSVC\14.52.36629 ← 装的 +``` + +同一轮 CI 里 mcpp 选 14.51、xmake 用 14.52。而 **14.51 编不了那个代码库**: + +``` +mo_yanxi_utility/src/utility/math/basic/vector2.ixx(67): + fatal error C1001: Internal compiler error. + (compiler file '...\CxxFE\sl\p1\c\template.cpp', line 26415) + note: IFC import detected. +``` + +**导出完整 vcvars 环境无效**,也是实测的:多花约 7 分钟装 Insider,构建日志里的 cl +一个字都没变。最终只能在 CI 里**把 `vswhere.exe` 挪开**,逼 mcpp 落到第 2 步。 +那个 workaround 现在还在 xrgui 的 workflow 里,是这份方案要消掉的东西。 + +### B. Windows SDK 路径写死 —— 同文件 + +```cpp +for (const char* base : {"C:\\Program Files (x86)\\Windows Kits\\10", + "C:\\Program Files\\Windows Kits\\10"}) { +``` + +无 `WindowsSdkDir` 覆盖、无注册表回退。toolset 那边没有对应问题: +`find_vs_via_env()` 只要求 `/VC/Tools/MSVC` 存在。 + +### C. 静态 CRT 有两个入口 —— 且注释互相矛盾 + +| 键 | MSVC 上的行为 | +|---|---| +| `[build] linkage = "static"` | **真的发 `/MT`** | +| `[build] cxx_runtime = "self-contained"` | **未实现**,warn 一次后退回 host-coupled | + +- `src/build/flags.cppm:605` — *"/MD default, **/MT under static linkage**"*,且确实发了 +- `src/build/distribution.cppm:202` — *"Under the MSVC runtime mcpp never made that promise + — **there is no /MT emission at all**"* + +`linkage` 那条实现得很扎实:`flags.cppm:611`(项目 TU)与 `prepare.cppm:4980`(std 模块) +共用同一个 `msvc_crt_flag()`——正是 #422 的修法,两边不可能再分叉。问题只在**入口有两个**, +而名字更像干这事的那个是无效的那个。 + +--- + +## 2. 上游已经就位(不需要 mcpp 侧配合的部分) + +xim-pkgindex 现已发布三个包(#626 / #627 / #628 均已合入,Windows CI 实测装/卸载通过): + +| 包 | 版本 | 说明 | +|---|---|---| +| `xim:msvc` | `14.44.35207`(latest)、**`14.52.36629`** | payload 包,多版本共存 | +| `xim:windows-sdk` | `10.0.26100` | ucrt/um/shared 头 + um 库 + rc/mt | +| `xim:curl` | `8.21.0` | 下载器本身也在生态内 | + +**payload 布局是刻意照着 mcpp 现状做的**: + +``` +/xpkgs/xim-x-msvc/14.52.36629/ +└── VC/Tools/MSVC/14.52.36629/{bin/Hostx64/x64/cl.exe, include/, lib/x64/, modules/std.ixx} + +/xpkgs/xim-x-windows-sdk/10.0.26100/ +├── Include/10.0.26100.0/{ucrt,um,shared}/ +└── Lib/10.0.26100.0/{ucrt,um}/x64/ +``` + +即 **toolset 那一半 mcpp 今天就能识别**(把 `VSINSTALLDIR` 指过去即可,前提是缺陷 A 修掉); +**SDK 那一半够不到**,因为缺陷 B。 + +--- + +## 3. 方案 + +### 3.1 A —— 让 `msvc@` 与 `gcc@` 同构 + +现状(`src/toolchain/lifecycle.cppm:503`): + +```cpp +// msvc@system: mcpp never installs MSVC — report what's there, or +// print installation guidance. +if (mcpp::toolchain::is_system_toolchain(*spec)) { ... return 1; } +``` + +`is_system_toolchain()` 对**所有** msvc spec 为真,所以 `msvc@14.52.36629` 也走这条死路。 + +**提案:按 spec 是否带具体版本分流** + +| spec | 语义 | 解析 | +|---|---|---| +| `msvc@system` | 用宿主已装的 VS(**保留现状,含探测顺序**) | `detect_installation()` | +| `msvc@` | 由 mcpp 安装并使用指定 toolset | `to_xim_package()` → `xim:msvc@` | + +改动集中在三处: + +1. `is_system_toolchain()` 只在 version 为 `system`/空时为真; +2. `to_xim_package()` 增加 msvc 行(`xim:msvc`,版本原样); +3. 解析已安装 payload 时,`VC/Tools/MSVC/` 直接由包版本拼出—— + **不再探测**,因为版本是声明的。 + +> **为什么这比"给 vswhere 加 `-prerelease`"更值得做**:后者只是让探测更可能猜对。 +> 而问题的本质是「这份构建用了哪个编译器」不该由机器状态回答。xlings V2 spec 的 +> R3 把这条写成了规则:*"If a fix ADDS a path rather than REMOVING one, it is a +> workaround. Making two independent answers more likely to agree is not a fix."* +> +> 不过 `-prerelease` 仍建议顺手加上:它让 `msvc@system` 在装了 Insider 的机器上 +> 至少能看见它。这是**兼容改进**,不是缺陷 A 的答案。 + +**兼容性**:`msvc@system` 行为不变,现有工程零影响。新增的是一条以前会报错的路径。 + +### 3.2 B —— `find_windows_sdk()` 认 `WindowsSdkDir` + +顺序改为: + +``` +1. WindowsSdkDir + WindowsSdkVersion (vcvars 本来就导出这两个) +2. 已解析 msvc 包的 windows-sdk 依赖 (走 3.1 的路径时) +3. 现有的两个绝对路径 (回退) +``` + +第 2 条是 3.1 的自然延伸:`xim:msvc` 声明了 `deps = { "xim:windows-sdk@10.0.26100" }`, +所以走包路径时 SDK 的位置是**已知的**,不必再探测。 + +### 3.3 C —— 消掉第二个入口 + +二选一: + +- **(推荐)** `cxx_runtime = "self-contained"` 在 MSVC 上直接映射到静态 CRT, + 与 `linkage = "static"` 走同一个 `msvc_crt_flag()`; +- 或保持未实现,但把诊断改成明确指向 `linkage`。 + +无论哪个,`distribution.cppm:202` 那句 *"there is no /MT emission at all"* 都要改—— +它与 `flags.cppm` 的实现直接矛盾。 + +--- + +## 4. 验证方案 —— 用 xrgui,不用新造的测试 + +xrgui#3 是这套东西的**真实用例**,而且**它已经是绿的**。这一点很重要: +验证不是"让它变绿",而是**在删掉 workaround 之后它是否仍然绿**—— +一个不能靠调整测试来自我满足的判据。 + +### 4.1 基线(今天的状态) + +``` +.github/workflows/mcpp-windows.yml + ├─ 装 VS 2026 Insider 到 C:\VS2026Insider ~7 min + ├─ 直接从该路径导出 vcvars(不经 vswhere) + ├─ 把 vswhere.exe 挪开 ← workaround,本方案要消掉的 + └─ 断言 mcpp 解析到 14.52,否则立刻失败 +``` + +两条腿(mcpp / xmake)全绿,`xrgui_tests` 54 项通过。 + +### 4.2 验收步骤 + +| 阶段 | 动作 | 通过标准 | +|---|---|---| +| V0 | 不改 xrgui,先在一台装了 VS 的机器上 `mcpp toolchain install msvc 14.52.36629` | payload 落在 `xpkgs/xim-x-msvc/14.52.36629`,`mcpp why toolchain` 报告它 —— **且宿主的 VS 仍在**(证明不是靠探测碰巧对) | +| V1 | xrgui 的 `mcpp.toml` 改 `[toolchain] windows = "msvc@14.52.36629"` | `mcpp why toolchain` 报告 14.52.36629 | +| V2 | **删掉 workflow 里「挪开 vswhere.exe」那一步** | 仍解析到 14.52.36629 —— 这是缺陷 A 修复的**唯一有效证据** | +| V3 | 删掉 Insider 安装与 vcvars 导出两步 | mcpp 自己装 toolset + SDK;`mcpp build --features tests` 通过,`xrgui_tests` 仍 54/54 | +| V4 | 对比 xmake 腿 | 两条腿仍然只差构建系统 —— 但**注意**:xmake 仍需 Insider 安装,V3 不能删它需要的那部分 | + +**V2 是关键**:workaround 还在的时候,缺陷 A 是否修好无法区分—— +`VSINSTALLDIR` 被采纳和 vswhere 找不到东西,现象一样。 + +### 4.3 反向验证(容易被忽略) + +- **`msvc@system` 未回归**:在同一台机器上把 spec 换回 `msvc@system`, + 应仍解析到宿主的 VS(而不是刚装的 payload)。两条路径必须互不污染。 +- **版本切换真的生效**:`xlings use msvc 14.44.35207` 后重新构建, + `cl` 报告的版本随之改变。多版本不是摆设。 +- **SDK 来自包而非宿主**:装完后临时重命名 `C:\Program Files (x86)\Windows Kits`, + 构建应仍然成功(缺陷 B 修好的判据)。这条在 CI 上做比在本机安全。 + +### 4.4 已知不被覆盖的部分 + +- **`14.52` 的安装路径 CI 没跑过**。xim-pkgindex 的 `windows-test` 装的是 `latest`(14.44)。 + 两者差异只在 payload URL 与目录版本,后者已逐个从真实 payload 读出核对 + (14.44 的三个 payload 版本 35228/35220/35226 都解到 `35207`;14.52 的 payload + 与目录一致,都是 `36629`),但**没有实际装过一次**。V0 会第一次覆盖它。 +- **Insiders payload 会轮换** —— 但这条正在被消掉,见 §4.5。 + +### 4.5 镜像:把"地址会失效"变成"地址锁定" + +原本这份方案把 Insiders 的轮换列为已知风险:14.52.36629 下架时 URL 404。 +**这一点可以直接消掉,而不是接受**——review 反馈给的方向是镜像到 gitcode。 + +关键在于:**镜像的是同一份字节,所以 sha256 不变**。于是 payload 条目从 + +```lua +{ name = "...", sha256 = "...", url = "https://download.visualstudio.microsoft.com/..." } +``` + +变成一个**来源列表**,校验规则完全不动: + +```lua +{ name = "...", sha256 = "...", + urls = { "https://gitcode.com/xlings-res/msvc/.../", -- 镜像优先 + "https://download.visualstudio.microsoft.com/..." } } -- 官方回退 +``` + +`fetch_verified()` 依次尝试,**无论从哪个来源取到,都按同一个 sha256 校验**。 +所以镜像不是新的信任源——它只是同一份字节的第二个地址。取不到就换下一个, +两个都失败才报错。 + +这比 `XLINGS_RES` 的 res 形状更合适:后者的自动 URL 约定是 +`{name}-{version}-{os}-{arch}.{ext}`,而 MSVC 一个版本是**一组**文件、各自带着 +微软自己的文件名(SDK 那边光 CAB 就 15 个)。而 payload 表本来就是 recipe 自己解析的, +加一个来源列表不需要框架配合。 + +**要镜像的量**(已实测): + +| 内容 | 文件数 | 体积 | +|---|---|---| +| `msvc@14.44.35207` | 4 个 vsix | 83.5 MB | +| `msvc@14.52.36629` | 4 个 vsix | 102.4 MB | +| `windows-sdk@10.0.26100` | 4 MSI + 15 CAB | 139.1 MB | +| 合计 | 27 | **325 MB** | + +**边界要说清楚**:把微软的编译器二进制放到第三方主机,与"安装时从微软 CDN 下载" +是两件不同的事——前者是再分发。这是维护者的决定,不是技术选择;本文只描述机制。 +上传本身需要 gitcode 凭据,不在本方案的执行范围内。 + +**落地拆两步**,因为它们的风险完全不同: + +1. **recipe 侧支持多来源**(纯代码,可先合):`urls` 列表 + 依次回退, + 单来源时行为与今天完全一致 → 零风险,且为镜像就绪; +2. **实际上传 + 填入镜像 URL**:需要凭据与上面那条边界的决定。 + +第 1 步做完之后,即使一个镜像都还没有,这套东西也不会变差;而一旦 14.52 真的下架, +补一行 URL 就能恢复,不必重新找一个还活着的 toolset 版本再验证一遍。 + +--- + +## 5. 落地顺序 + +``` +C(独立,小) ──────────────────────────────┐ + ├─→ 发布 ─→ xrgui V1..V4 +A(核心) ─→ B(依赖 A 的包路径) ─→ V0 ─────┘ +``` + +- C 可以随时单独落,不阻塞任何人; +- A 不修,B 修了也没用(SDK 找得到,编译器还是错的); +- V0 应在 A+B 发布**之前**用本地构建跑一次,否则 xrgui 那边的红会同时有两个可能来源。 + +--- + +## 6. 待 review 决策点(已全部拍板,答案见 §7.1) + +1. **`msvc@` 的语义**是否如 3.1 所提(按版本分流,`@system` 保留原义)? +2. `-prerelease` 是否顺手加上(改善 `msvc@system`,不替代 A)? +3. C 选哪一个:让 `cxx_runtime` 生效,还是把诊断指向 `linkage`? +4. B 的第 2 条(从已解析的包依赖里取 SDK)是否值得做,还是只做 `WindowsSdkDir` 就够? +5. 验证方案里 4.3 的三条反向验证是否都要进 CI —— 尤其"重命名 Windows Kits"那条, + 它最有说服力,也最容易在别的 job 上产生副作用。 +6. **镜像(§4.5)**:recipe 侧的多来源支持可以先合(零风险);实际镜像涉及再分发, + 需要维护者拍板,且上传需要 gitcode 凭据。是否现在就做第 1 步? + +--- + +## 7. 落地结果(2026.8.16.1) + +### 7.1 六个决策点的答案 + +| # | 决定 | 落地方式 | +|---|---|---| +| 1 | **按版本轴分流,`@system` 完全保留** | `is_system_toolchain()` 加版本判据;`msvc@system` 的语义与探测链一字未改(只有顺序修了,见 2) | +| 2 | **加** | vswhere 加 `-prerelease`,但顺序也变了 —— 见 §7.2 那条被推翻的判断 | +| 3 | **让 `cxx_runtime` 生效** | 两个键都走 `msvc_wants_static_crt()`;`distribution.cppm:202` 那句自相矛盾的注释删掉 | +| 4 | **做,但不是「从包依赖里取」** | 改成从**编译器自己的路径**反推 store —— 见 §7.2 | +| 5 | **不进 CI** | 见 §7.3 | +| 6 | **两步一起做了** | recipe 侧多来源 + 27 个 payload 实际镜像完成并逐个校验(xim-pkgindex#629) | + +### 7.2 两条实现时改掉的判断 + +**① 缺陷 B 的第 2 条来源不是「已解析的包依赖」,而是编译器自己的位置。** + +原方案说:走包路径时 `xim:msvc` 声明了 `deps = { "xim:windows-sdk@10.0.26100" }`, +所以 SDK 的位置是已知的。能做,但它要求 **mcpp 侧知道那个依赖的名字和版本** —— +把 SDK 版本写进 mcpp,而它本该只是包的事。 + +实际做法:`sibling_sdk_roots(clPath)` 用已有的 `xpkgs_from_compiler()` 从 cl.exe +的路径反推出它所在的 store,再取 `xim-x-windows-sdk/*`。**编译器自己的路径就说明了 +它来自哪个 store**,SDK 是它在那里的邻居。零配置、零版本硬编码,而且对 +`msvc@system` 自动返回空(系统 cl 不在任何 store 里)。 + +**② `VSINSTALLDIR` 与 vswhere 的顺序,原方案说得不够。** + +原方案把 `-prerelease` 列为「兼容改进,不是缺陷 A 的答案」,这是对的; +但它没说**顺序本身就是缺陷**。vswhere 在几乎每台开发机上都返回点什么,所以 +`VSINSTALLDIR` 事实上不可达 —— 这正是 xrgui 那个 workaround 存在的原因,而 +workaround 是要被删掉的。所以顺序改成: + +``` +VSINSTALLDIR → vswhere(-prerelease) → VS*COMNTOOLS → 绝对路径 +``` + +`VS*COMNTOOLS` 留在 vswhere **之后**,这一点是新的:它们是机器全局的残留 +(2017 的 `VS150COMNTOOLS` 不该压过当前安装),而 `VSINSTALLDIR` 是有人为这个 +shell 设的。原来的 `find_vs_via_env()` 把两者混在一起,提前它就会连带提前残留。 + +### 7.3 4.3 的三条反向验证:两条进了 e2e,一条没进 + +| 反向验证 | 结论 | +|---|---| +| `msvc@system` 未回归 | **进了** —— `239_msvc_managed_toolset.sh` 第 3 步:同一台机器、同一个项目,spec 换回 `msvc@system` 必须解析到系统的 cl。没有这条,「受管能用」与「受管把一切换掉了」这两种结果长得一模一样。 | +| 版本切换真的生效 | **进了(以更强的形式)** —— 单测 `ResolvesTheDeclaredToolsetAndNotItsNeighbour`:两个 toolset 都在,要**老的**那个。「取最新」的实现会在这里失败,而在真机上它可能碰巧对。 | +| 重命名 `Windows Kits` | **没进**。它确实最有说服力,但它会让同一个 runner 上**其它 job** 的 MSVC 构建随机失败,而 CI 上的 job 隔离不到目录改名这一层。改由单测覆盖:`ExtraRootsCoverTheManagedPayload` 在没有任何 env、没有任何 Windows Kits 的 Linux 上跑 —— 这比重命名更彻底,因为那台机器上**本来就没有** SDK 可以被找到。 | + +### 7.4 单测第一次能在 Windows 之外跑 + +改动前,msvc 的发现逻辑**一行都无法在 Linux 上测试**:每个入口都从探测机器开始, +而探测在 `#if defined(_WIN32)` 里。 + +`installation_at()` 接受目录而不是探测机器,`find_windows_sdk()` 接受 root 列表, +两者都不再被平台宏包住 —— 于是 fixture 目录树就能驱动真实代码路径。新增 6 个 +单测在 Linux CI 上跑,其中三条是这次改动的核心判据: + +``` +MsvcManaged.ResolvesTheDeclaredToolsetAndNotItsNeighbour 两个都在,要老的 +MsvcManaged.AbsentToolsetIsNulloptNotASubstitute 缺了就是缺了,不替换 +MsvcSdk.DeclaredRootOutranksTheManagedOne 声明压过探测 +``` + +`test_toolchain_msvc` 25 项全绿,全套单测 83/83。 + +--- + +## 8. 跨仓库任务拆分与依赖 + +四个仓库,三条可并行的链。边是**真实依赖**,不是先后偏好: + +``` +xim-pkgindex #629 ──────────────┐ (包必须先发布,mcpp 才装得到) + 多来源 urls + 27 payload 镜像 │ + windows-sdk 导出 WindowsSdkDir │ + ▼ +mcpp #4xx (2026.8.16.1) ────→ 发布 ────→ xrgui:删 workaround + 改 spec + A 受管 toolset (V2 是唯一不能自证的证据) + B SDK 搜索顺序 + C CRT 双入口 ← 与 A/B 无依赖,可并行 + D 发现顺序 ← 与 A 同文件,顺序上一起改 +``` + +**为什么 C 和 D 仍然放在同一个 PR 里**:C 与 A/B 确实无依赖,但它改的是同一个 +「MSVC 在 mcpp 里到底怎么被描述」的问题,分开发会让 CHANGELOG 读者以为是两件事; +D 与 A 改同一个函数,分开发第二个 PR 必然要重写第一个 PR 刚写的注释。 + +**唯一的硬依赖**是 xim-pkgindex → mcpp:`mcpp toolchain install msvc 14.44.35207` +装的就是那个包。所以 #629 必须先合并并发布,`239_msvc_managed_toolset.sh` 才 +可能通过 —— 在此之前它会走 SKIP 分支(索引取不到时干净跳过),而不是红。 diff --git a/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md b/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md new file mode 100644 index 00000000..138adfa9 --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md @@ -0,0 +1,93 @@ +# MSVC 在 xlings 生态里打通 —— 跨仓库计划、依赖与验收(2026-08-16) + +> 配套 `2026-08-16-msvc-as-a-managed-toolchain.md`(mcpp 侧设计)。 +> 那份讲**为什么这么改**;这份讲**改动落在哪几个仓库、谁挡着谁、以及每一步凭什么算通过**。 + +--- + +## 0. 一句话 + +四个仓库、五条改动,只有**一条硬依赖**:包必须先发布,mcpp 才装得到,xrgui 才用得上。 +其余都可以并行,而把它们排成一条线是这类工作最常见的浪费。 + +--- + +## 1. 从八个角度看这次改的到底是什么 + +用户点名的八个角度不是修辞,它们各自对应一条具体改动。逐条落到实处: + +| 角度 | 改前的具体事实 | 改后 | +|---|---|---| +| **架构** | MSVC 是工具链体系里唯一「无法声明版本」的家族;`is_system_toolchain()` 对**所有** msvc spec 为真 | 版本轴决定来源。**获取**与 gcc 同路(xim 安装),**解析**与 `msvc@system` 同路(`installation_from_tools_dir`)——两条轴正交,受管 toolset 不是第二条代码路径 | +| **稳定性** | 同一份源码在两台机器上被不同编译器编译**且不报错**;xrgui#3 实测 mcpp 用 14.51、xmake 用 14.52,直到 ICE 才暴露 | 声明了版本就必须拿到那个版本,拿不到是 nullopt 而不是替代品 | +| **优雅简洁** | 两个旋钮(`linkage` / `cxx_runtime`)指向同一个物理开关,只有一个管用,注释互相矛盾 | 一个 `msvc_wants_static_crt()`,项目 TU 与 std 模块问同一个函数 | +| **用户体验** | 没装 VS 时告诉你「mcpp 不安装 MSVC,自己去装」——而现在这句话是假的 | 两条路都给出:装一个 pin 住的 toolset,或用机器自己的 VS。`install_guidance()` 里两条命令都能直接抄 | +| **兼容性** | —— | `msvc@system` 语义一字未改;唯一的破坏性变更(`msvc@19.44`)有精确的替代指引,而它原本**只在一个命令里生效、构建路径完全忽略** | +| **跨平台** | msvc 发现逻辑一行都无法在 Windows 之外测试(入口全在 `#if defined(_WIN32)` 里) | `installation_at()` 收目录、`find_windows_sdk()` 收 root 列表,6 个单测在 Linux CI 上跑真 fixture | +| **一致性** | `search` 说 `xim:msvc` 在,`info` 说 `not found` —— 同一台机器、同一个索引 | xlings#550:区分「不存在」与「这个平台没有构建」,并说出它在哪些平台有 | +| **无感升级** | —— | 现有工程零影响:`msvc@system` 不变、默认 CRT 仍是 `/MD`(判据取 manifest 字面值而非解析后的 contract,否则每个 Windows 构建都会翻成 `/MT`) | + +--- + +## 2. 五条改动与它们的仓库 + +| # | 仓库 | 改动 | PR | +|---|---|---|---| +| **X** | xim-pkgindex | payload 多来源 `urls` + 27 个 payload 镜像;windows-sdk 导出 `WindowsSdkDir`/`WindowsSdkVersion` | #629 ✅ 已合 | +| **M** | mcpp | A 受管 toolset / B SDK 搜索顺序 / C CRT 双入口 / D 发现顺序 | #434 | +| **L1** | xlings | D4 的管道那一半量到了(文档) | #549 | +| **L2** | xlings | `info` 对别的平台的包说 "not found" | #550 | +| **V** | xrgui | 删掉 vswhere workaround,验证整条链 | 待 mcpp 发布 | + +--- + +## 3. 依赖图 —— 边是真实依赖,不是先后偏好 + +``` +X (xim-pkgindex #629) +│ 包必须先发布:`mcpp toolchain install msvc 14.44.35207` 装的就是它 +│ 在此之前 mcpp 的 e2e 239 走 SKIP 分支,而不是红 +▼ +M (mcpp #434) ──→ 发布 2026.8.16.1 ──→ V (xrgui) + │ + L1、L2 与上面这条链 无 依赖 ────────┘(可随时合) +``` + +**为什么 C、D 没有拆成独立 PR**:C 与 A/B 确实无依赖,但它回答的是同一个问题 +——「MSVC 在 mcpp 里到底怎么被描述」;拆开会让 CHANGELOG 的读者以为是两件事。 +D 与 A 改同一个函数,拆开的第二个 PR 必然要重写第一个 PR 刚写的注释。 + +**为什么 L2 不在 M 里**:它是 xlings 的缺陷,是**在验证 M 的过程中**被发现的 +(`xlings info msvc` 在 Linux 上说 not found),但它与 MSVC 无关 —— 每一个 +windows-only 包在 Linux 上都会这样,反之亦然。 + +--- + +## 4. 验收标准 —— 哪些能自证,哪些不能 + +这一节是这份计划的重点。**能被"调一下测试"满足的判据,不算证据。** + +| 判据 | 能否自证 | 说明 | +|---|---|---| +| 27 个 payload 镜像正确 | **不能** | 判据不是「上传成功」,是**下载回来 sha256 与微软一致**。而这恰好抓到了真问题:上传工具对 16 个文件报了失败、release 列表却显示它们在,实际 404。**谁都不能信,只能信字节。** | +| 受管 toolset 真的被用了 | **不能** | e2e 239 的每一条断言都写成「系统编译器来应答就会失败」:cl.exe 必须在 mcpp 的 store 里、toolset 目录必须是 spec 声明的那个 | +| 两条来源互不污染 | **不能** | 同一台机器、同一个项目,spec 换回 `msvc@system` 必须解析到系统 cl。少了这条,「受管能用」与「受管把一切都换掉了」长得一样 | +| 声明的 toolset 优先于"最新" | **不能** | 单测:两个 toolset 都在,要**老的**那个。「取最新」的实现会在这里失败,而真机上它可能碰巧对 | +| **xrgui 删掉 workaround 后仍然绿** | **不能** | 全套里最强的一条。workaround 还在时,「`VSINSTALLDIR` 被采纳」与「vswhere 找不到东西」现象完全一样 —— **无法区分缺陷 A 是否真修好**。而且删掉之后 vswhere 与 14.51 **都还在**:错误答案没有被拿走,它只是必须输 | +| 单测 83/83、静态检查 1788 项 | **能** | 有用,但它们证明的是「没有回归」,不是「这件事做成了」 | + +--- + +## 5. 已知不被覆盖的部分(不要当成已完成) + +1. **`msvc@14.52.36629` 的安装路径没有在 CI 上跑过**。index 的 `windows-test` + 装的是 `latest`(14.44)。两者只差 payload URL 与目录版本,后者已逐个从真实 + payload 读出核对,但**没有实际装过一次**。xrgui 的 V3 会第一次覆盖它。 +2. **镜像回退没有被真正触发过**。两个前提单独验过了 —— `curl -f` 遇 404 退 22 + 且不留文件(所以 `pcall` 会接住、`os.isfile` 为假),官方地址仍然服务同样的 + 字节 —— 但「镜像挂掉时自动走官方」这条完整路径没有被执行过。 + 现在至少它**不会静默**:走到第一个之后的地址会 `log.warn`。 +3. **D4 的控制台那一半仍然开着**。管道那条路量到了(xlings#549), + 而 CI 结不了控制台的案:runner 上 job 没有附着的控制台。 +4. **gitcode 的 probe 资产删不掉**。API 没有删除端点(两个路径都 404), + 已在镜像 README 里点名说明,而不是留一堆没人知道是什么的文件。 diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c22053..4d6a2916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,96 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.16.1] — 2026-08-16 + +### 工具链 + +- **MSVC 不再是「唯一版本无法声明」的工具链。** + + gcc / llvm 由 mcpp 自己安装、按声明解析;MSVC 是体系里唯一的例外——**每一个** + msvc spec 都是系统 spec,manifest 写了版本也会被丢掉。后果不是不够优雅,是 + **同一份源码在两台机器上会被不同的编译器编译,而且不报错**。 + + xrgui#3 实测:同一轮 CI 里 mcpp 用 14.51、xmake 用 14.52,直到 14.51 触发 + ICE 才暴露。导出完整 vcvars 环境**无效**,最后只能在 CI 里把 `vswhere.exe` + 挪开,逼 mcpp 落到 `VSINSTALLDIR` 那一步。 + + 现在由 **spec 的版本轴**决定来源,两条来源并存: + + | spec | 来源 | 用哪个编译器 | + |---|---|---| + | `msvc@system`(或裸 `msvc`) | 机器自己的 Visual Studio | 这台机器上装的那个 | + | `msvc@`(如 `msvc@14.44.35207`) | mcpp 安装的 xlings payload | **声明的那个,每台机器都是** | + + `msvc@` 与 `gcc@16.1.0` 在每个方面都同构:多版本共存、 + `toolchain remove msvc@` 可卸载、manifest 里写了就自动安装。 + payload 自带编译器、STL,并通过 `xim:windows-sdk` 依赖带上 ucrt/um 头与库, + 机器上**什么都不必预装**。 + + 实现上,「获取」与「解析」被拆成两条正交的轴:获取与 gcc 共用一条 + (xim 安装),解析与 `msvc@system` 共用一条(`installation_from_tools_dir`)。 + 所以受管 toolset 不是第二条代码路径,也就不会长出自己的 bug。 + +- **⚠️ 破坏性变更:`msvc@19.44` 不再是 pin-verify。** + + 它过去表示「用系统 MSVC,并校验 banner 前缀」——而且只有 + `mcpp toolchain default` 会校验,**构建路径完全忽略它**。版本轴现在到处都表示 + toolset。写成 `19.x` 时,mcpp 会用这台机器自己的 cl 版本说清楚,并给出两个 + 替代写法(`msvc@system` 或该机器实际的 toolset 版本)。 + +- **`VSINSTALLDIR` 现在优先于 vswhere 探测。** + + vswhere 在几乎每台开发机上都能返回点什么,于是 `VSINSTALLDIR` 事实上不可达: + 一次已经导出了完整 vcvars 环境的构建,仍然用 vswhere 排第一的那个编译器。 + **猜测不该压过答案。** 顺带给 vswhere 加了 `-prerelease`——没有它,只装了 + Insiders 的机器会被报告成「没有 MSVC」,而磁盘上明明有一个可用的 cl.exe。 + + `VS*COMNTOOLS` 仍排在 vswhere **之后**:那是机器全局的残留(2017 的 + `VS150COMNTOOLS` 不该压过当前安装),而 `VSINSTALLDIR` 是有人为这个 shell + 设的。 + +- **Windows SDK 不再只认两个写死的绝对路径。** + + 顺序改为:`WindowsSdkDir`(+ `WindowsSdkVersion`,vcvars 本来就导出这两个) + → 受管 toolset 在 mcpp 自己 store 里的 `xim:windows-sdk` payload + → 原来的绝对路径(降为回退)。 + + 第二条不需要任何配置:**编译器自己的路径就说明了它来自哪个 store**, + SDK 是它在那里的邻居。所以 mcpp 里没有任何地方写死 SDK 版本。 + +### 接口一致性 + +- **`cxx_runtime = "self-contained"` 在 MSVC 上真的生效了。** + + 过去两个旋钮只有一个管用:`linkage = "static"` **确实发** `/MT`,而 + `cxx_runtime = "self-contained"` 报「未实现」——对着同一个物理开关。 + 两处注释也互相矛盾(`flags.cppm:605` 说发了 `/MT`,`distribution.cppm:202` + 说「根本没有 /MT」)。 + + 在 MSVC ABI 上这两条不是可以二选一的旋钮:`/MT` 把 C 运行时和 C++ 运行时 + 从同一个库里链进来,**它们本来就是一个开关**。现在两种写法都选中它,由 + `msvc_wants_static_crt()` 统一推导——项目的 TU 与 std 模块问的是同一个函数, + 不再各写各的表达式(#422 正是这样分叉的)。 + + 默认仍是 `/MD`:判据取的是**manifest 里写下的字面值**,不是解析后的 + contract——后者对多数 role 默认就是 self-contained,拿它做判据会把每一个 + Windows 构建都翻成 `/MT`。 + + MSVC 的 CRT 模型是**整个项目**的属性(一个项目只编一份 std 模块,cl 把 + `_MSVC_MT`/`_MSVC_MD` 烤进去),所以按 role 覆盖会被明确拒绝并说明原因, + 而不是在 ucrt 头文件里炸出 C5050/C2375。 + +### 测试 + +- msvc 的发现逻辑第一次可以在 Windows 之外测试:`installation_at()` 接受目录 + 而不是去探测机器,`find_windows_sdk()` 接受 root 列表。6 个新单测在 Linux CI + 上跑真实的 fixture 目录树,包括「两个 toolset 都在,要老的那个」这条—— + 「取最新」的实现会在这里失败。 +- 新增 e2e `239_msvc_managed_toolset.sh`。它的每一条断言都写成 + **系统编译器来应答就会失败**:cl.exe 必须在 mcpp 的 store 里、toolset 目录 + 必须是 spec 声明的那个、同一台机器上换回 `msvc@system` 必须仍然解析到系统 + 的 cl(两条来源互不污染)。 + ## [2026.8.13.1] — 2026-08-13 ### 性能 diff --git a/docs/03-toolchains.md b/docs/03-toolchains.md index 416cadcd..f89469a2 100644 --- a/docs/03-toolchains.md +++ b/docs/03-toolchains.md @@ -213,19 +213,40 @@ the current host can actually install, so if a target is missing from the Targets block, that host genuinely cannot serve it (implemented by `toolchain::host_can_serve`). -## MSVC (System Toolchain, Windows) +## MSVC (Windows) -MSVC is different from every other toolchain mcpp manages: it is a **system -toolchain**. mcpp locates and identifies an installed Visual Studio / Build -Tools — it never installs, updates, or removes MSVC itself. +An MSVC toolset reaches a build one of two ways, and the **version axis of the +spec** says which: + +| Spec | Origin | Which compiler you get | +|---|---|---| +| `msvc@system` (or bare `msvc`) | the machine's own Visual Studio | whatever is installed here | +| `msvc@` (e.g. `msvc@14.44.35207`) | an xlings payload mcpp installs | the one you named, on every machine | + +They are not alternatives to pick between once — they answer different +questions. `msvc@system` asks *"use what this developer already has"*; +`msvc@14.44.35207` asks *"build this project with exactly this compiler"*. +Pinned toolsets coexist with each other and with a system Visual Studio. + +### `msvc@system` — the machine's own Visual Studio + +mcpp locates and identifies an installed Visual Studio / Build Tools; it never +installs, updates, or removes one. ```bash mcpp toolchain default msvc ``` -On a machine with MSVC installed, mcpp auto-locates it (via `vswhere.exe`, -then `VSINSTALLDIR`/`VS*COMNTOOLS`, then the standard install paths), -identifies the versions involved, and persists the stable spec `msvc@system`: +mcpp auto-locates it in this order: + +1. **`VSINSTALLDIR`** — set by a developer command prompt or by a CI step that + ran `vcvarsall`. A declared answer, so it outranks the probes below. +2. `vswhere.exe` (including prerelease/Insiders instances) +3. `VS*COMNTOOLS` +4. the standard `Program Files\Microsoft Visual Studio\\` paths + +It then identifies the versions involved and persists the stable spec +`msvc@system`: ``` Detected msvc 19.44.35211 (VS 2022 BuildTools) (VC tools 14.44.35207) @@ -234,34 +255,75 @@ Detected msvc 19.44.35211 (VS 2022 BuildTools) (VC tools 14.44.35207) Default set to msvc@system (was: llvm@20.1.7) ``` -If MSVC is **not** installed, mcpp prints installation guidance instead -(Visual Studio Installer with the *Desktop development with C++* workload, or -`winget install Microsoft.VisualStudio.2022.BuildTools`) and exits non-zero — -install it yourself, then re-run the command. +If no Visual Studio is installed, mcpp says so and offers both routes — a +pinned toolset it can install for you, or the Visual Studio Installer / +`winget install Microsoft.VisualStudio.2022.BuildTools`. `mcpp toolchain list` shows the detected MSVC in a separate `System:` section, -and `mcpp self doctor` reports its status on Windows. In a manifest you can -pin it per-platform: +and `mcpp self doctor` reports its status on Windows. In a manifest: ```toml [toolchain] windows = "msvc@system" ``` -`msvc@` (e.g. `msvc@19.44`) acts as a pin-verify: mcpp still uses the -newest installed VC tools, but errors if the detected version doesn't match -the prefix. - -Since 0.0.90, **native cl.exe builds work**: mcpp synthesizes the -INCLUDE/LIB environment from the detected VC tools + Windows SDK (no -`vcvarsall` involved), stages `std.ixx`/`std.compat.ixx` as `.ifc` BMIs, -compiles `.cppm` module units via `/interface /TP /ifcOutput`, scans with -`/scanDependencies`, and links with `link.exe`/`lib.exe` through response -files. `[target.x86_64-windows-msvc] linkage = "static"` (or `mcpp build ---static`) selects the `/MT` CRT — not `[build] linkage`, which is not a key. -A missing Windows -SDK fails the build with installation guidance (`mcpp self doctor` reports -SDK status). +### `msvc@` — a toolset mcpp installs and pins + +```bash +mcpp toolchain list --available msvc # what can be pinned +mcpp toolchain install msvc 14.44.35207 +``` + +This works like `gcc@16.1.0` in every respect: the payload is downloaded into +mcpp's own store, several toolsets coexist, `mcpp toolchain remove +msvc@` uninstalls one, and a manifest that names one gets it +installed automatically on first build. + +```toml +[toolchain] +windows = "msvc@14.44.35207" +``` + +**The version is the toolset directory name** (`14.44.35207` — what +`VC\Tools\MSVC\` is named and what `-vcvars_ver` takes), *not* the cl banner +version (`19.44.35211`) and not the product year. Nothing needs to be +installed on the machine: the payload brings the compiler, the STL, and — via +its `xim:windows-sdk` dependency — the ucrt/um headers and libraries. + +> **Changed:** `msvc@19.44` used to mean "use the system MSVC and verify its +> banner starts with 19.44", which was checked by `mcpp toolchain default` and +> silently ignored by builds. The version axis now names a toolset everywhere. +> A `19.x` spelling gets an error naming both replacements — `msvc@system` or +> the toolset version that machine actually has. + +### Native cl.exe builds + +Since 0.0.90 these work on both origins: mcpp synthesizes the INCLUDE/LIB +environment from the VC tools + Windows SDK (no `vcvarsall` involved), stages +`std.ixx`/`std.compat.ixx` as `.ifc` BMIs, compiles `.cppm` module units via +`/interface /TP /ifcOutput`, scans with `/scanDependencies`, and links with +`link.exe`/`lib.exe` through response files. + +The Windows SDK is located in this order: **`WindowsSdkDir`** (+ +`WindowsSdkVersion`) if declared, then the `xim:windows-sdk` payload beside a +pinned toolset in mcpp's store, then `C:\Program Files (x86)\Windows Kits\10`. +A missing SDK fails the build with guidance (`mcpp self doctor` reports SDK +status). + +**CRT model.** `/MD` (host-coupled) by default; `/MT` when either + +```toml +[build] +linkage = "static" # the libc axis +cxx_runtime = "self-contained" # the C++ runtime axis +``` + +is written down. On the MSVC ABI these are one physical switch — `/MT` links +the C and C++ runtimes out of the same library — so both spellings select it +and mean the same thing. It is a **whole-project** property: one `std` module +is built per project and cl bakes `_MSVC_MT`/`_MSVC_MD` into it, so a +per-role override (`cxx_runtime = { tests = … }`) is refused with a message +saying so rather than producing a module mismatch inside the ucrt headers. ## Project-Level Version Pinning diff --git a/mcpp.toml b/mcpp.toml index ae01d056..613e813e 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.15.3" +version = "2026.8.16.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/distribution.cppm b/src/build/distribution.cppm index 3b4c667f..5f797342 100644 --- a/src/build/distribution.cppm +++ b/src/build/distribution.cppm @@ -199,13 +199,22 @@ struct MechanismInput { // // The distinction decides whether a cell with no mechanism SPEAKS. A // diagnostic is for a BROKEN PROMISE: mcpp said the artifact would be - // self-contained and it is not. Under the MSVC runtime mcpp never made - // that promise — there is no /MT emission at all — so warning on every - // Windows build would be noise nobody can act on. Write - // `cxx_runtime = "self-contained"` there and you get told, once, that it - // is not implemented. Cells where mcpp DOES promise something (a missing - // libc++.a under the default, say) report regardless. + // self-contained and it is not. Most roles DEFAULT to self-contained, so + // on a runtime where that default cannot be delivered, warning on every + // build would be noise nobody can act on. Cells where mcpp DOES promise + // something (a missing libc++.a under the default, say) report regardless. bool explicitRequest = false; + // MSVC only: is this project compiled with the static CRT (`/MT`)? + // + // A whole-PROJECT fact, not a per-role one, and that is a property of the + // platform rather than a simplification: cl bakes _MSVC_MT / _MSVC_MD + // into the std module, one std module is built per project, and a TU + // importing the other one fails inside the ucrt headers (#422). So the + // table can honour a project-level request and must refuse a per-role + // one — out loud, since silently ignoring it is how a knob becomes + // decoration. Derived by `msvc_wants_static_crt`, which is also what + // emits the flag. + bool msvcStaticCrt = false; // Toolchain capability id: "libstdc++", "libc++", or an MSVC STL spelling. std::string_view stdlibId; Format format = Format::Elf; @@ -389,26 +398,40 @@ Mechanism resolve(const MechanismInput& in) { // ---------------------------------------------------------------- PE case Format::Pe: { if (!detail::is_libstdcxx(in.stdlibId)) { - // MSVC STL (cl.exe, or clang on the MSVC ABI). The driver default - // is the DLL runtime and mcpp emits no runtime selection flag, so - // host-coupled is the only form that actually exists here. Both - // other contracts are refused BY NAME rather than quietly - // producing the same bytes and reporting success. - m.effective = Contract::HostCoupled; - if (in.requested == Contract::SelfContained) { + // MSVC STL (cl.exe, or clang on the MSVC ABI). The CRT model is + // the mechanism here, and it is a whole-project switch: /MT is + // self-contained (no vcruntime DLL dependency), /MD is + // host-coupled. `msvcStaticCrt` is that switch, already derived + // by whoever emits the flag — so what this table reports and what + // cl was actually told cannot disagree. + // + // No unit flags: the model is a COMPILE flag on every TU, not + // something added to the link line. + m.effective = in.msvcStaticCrt ? Contract::SelfContained + : Contract::HostCoupled; + if (in.requested == Contract::SelfContained && !in.msvcStaticCrt) { + // Asked for, not delivered. Only reachable from a per-ROLE + // override, because a project-level one would have set + // msvcStaticCrt — so name that, instead of the old "not + // implemented", which stopped being true and had already + // been contradicted by flags.cppm emitting /MT for + // `linkage = "static"`. m.degraded = in.explicitRequest; m.diagnostic = in.explicitRequest - ? "cxx_runtime = \"self-contained\" is not implemented for the " - "MSVC runtime yet (it would need the /MT runtime); using " - "host-coupled — the artifact needs the VC++ redistributable" + ? "on the MSVC runtime the CRT model is a whole-project " + "property — one std module is built per project and cl " + "bakes _MSVC_MT/_MSVC_MD into it, so a single role " + "cannot differ. Move it to [build] cxx_runtime = " + "\"self-contained\" (or linkage = \"static\") to apply " + "it everywhere; using host-coupled here" : ""; } else if (in.requested == Contract::ToolchainCoupled) { // Only reachable from an explicit request: it is never a default. m.degraded = true; - m.diagnostic = + m.diagnostic = std::format( "cxx_runtime = \"toolchain-coupled\" has no meaning for the " "MSVC runtime (it ships with the OS/redistributable, not with " - "the toolchain); using host-coupled"; + "the toolchain); using {}", to_string(m.effective)); } return m; } diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 758e88d1..d69895a8 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -601,15 +601,18 @@ CompileFlags compute_flags(const BuildPlan& plan) { if (prof.lto && !isMsvcDialect) opt_flag += " -flto"; // MSVC baseline: /nologo /EHsc /utf-8 (dialect alwaysFlags) + the CRT - // model — /MD default, /MT under static linkage (portable-by-default is - // impossible on MSVC-ABI; /MT at least removes the vcruntime DLL dep). + // model — /MD by default, /MT when either knob asks for the static CRT + // (portable-by-default is impossible on MSVC-ABI; /MT at least removes + // the vcruntime DLL dep). std::string msvc_base; if (isMsvcDialect) { msvc_base = std::format(" {}", d.alwaysFlags); // ONE derivation, shared with the std module build — see - // `msvc_crt_flag` in mcpp.toolchain.dialect and #422. + // `msvc_wants_static_crt` in mcpp.toolchain.dialect and #422. msvc_base += std::format(" {}", mcpp::toolchain::msvc_crt_flag( - d, plan.manifest.buildConfig.linkage == "static")); + d, mcpp::toolchain::msvc_wants_static_crt( + plan.manifest.buildConfig.linkage, + plan.manifest.buildConfig.cxxRuntime))); } // User link flags @@ -839,6 +842,14 @@ CompileFlags compute_flags(const BuildPlan& plan) { mi.stdlibId = caps.stdlib_id; mi.hostIsWindows = mcpp::platform::is_windows; mi.fullStaticLibc = (f.linkage == "static"); + // The CRT model this WHOLE project is being compiled with. Per-role + // contracts cannot move it: cl bakes _MSVC_MT / _MSVC_MD into the std + // module, one std module is built per project, and a TU importing the + // other one fails inside the ucrt headers (#422). The mechanism table + // needs to know so it can say that out loud rather than silently + // ignoring a role override. + mi.msvcStaticCrt = mcpp::toolchain::msvc_wants_static_crt( + bc.linkage, bc.cxxRuntime); mi.mingw = isMingwTc; mi.macosFloor = !macosDeploymentTarget.empty(); mi.format = format; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index d6189877..4715df91 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1248,8 +1248,12 @@ prepare_build(bool print_fingerprint, // resolution / fingerprinting. fold_build_defines_into_flags(m->buildConfig); - // msvc@system: a *system* toolchain — located on the machine, never - // resolved through xim packages. mcpp does not install MSVC. + // msvc@system: located on the machine, never resolved through xim + // packages. mcpp does not install the machine's Visual Studio. + // + // A VERSIONED msvc spec is a different origin and takes the xim path + // below — which is the whole point: what the manifest says is what gets + // used, on every machine, instead of whatever this one happens to have. bool tcSpecIsMsvc = false; if (tcSpec.has_value()) { if (auto s = mcpp::toolchain::parse_toolchain_spec(*tcSpec); @@ -1295,31 +1299,62 @@ prepare_build(bool print_fingerprint, mcpp::fetcher::InstallProgressHandler progress; auto payload = fetcher.resolve_xpkg_path(pkg.target(), /*autoInstall=*/true, &progress); if (!payload) { + // `windows = "msvc@19.44"` in a manifest is the retired + // cl-version spelling; saying "no such xim package" would send + // the reader looking for a toolset that cannot exist. + if (spec->family == mcpp::toolchain::Family::Msvc) { + if (auto hint = mcpp::toolchain::msvc::cl_version_spelling_hint( + spec->version)) + return std::unexpected(*hint); + } return std::unexpected(std::format( "toolchain '{}': {}", *tcSpec, payload.error().message)); } - explicit_compiler = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); - if (!std::filesystem::exists(explicit_compiler)) { - return std::unexpected(std::format( - "toolchain payload '{}' has no known C++ frontend in {}", - pkg.target(), payload->binDir.string())); + // A pinned MSVC toolset: the payload root IS a VS-shaped root and the + // package version IS the toolset directory name, so cl.exe is + // derived, not searched for. Nothing here can silently pick a + // different toolset — which is the defect this path exists to close. + // + // It also skips the two steps below: the bin/-shaped frontend lookup + // (cl.exe is four levels deeper) and the ELF post-install fixup + // (there is nothing to patchelf on a PE toolchain). + if (spec->family == mcpp::toolchain::Family::Msvc) { + auto inst = mcpp::toolchain::msvc::installation_at( + payload->root, pkg.ximVersion); + if (!inst) { + return std::unexpected(std::format( + "msvc payload at '{}' has no cl.exe under VC/Tools/MSVC/{}", + payload->root.string(), pkg.ximVersion)); + } + explicit_compiler = inst->clPath; + mcpp::ui::info("Resolved", std::format( + "{} → msvc {} ({})", spec->display(), + inst->display_version(), inst->clPath.string())); + } else { + explicit_compiler = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); + if (!std::filesystem::exists(explicit_compiler)) { + return std::unexpected(std::format( + "toolchain payload '{}' has no known C++ frontend in {}", + pkg.target(), payload->binDir.string())); + } + // Same post-install fixup as `mcpp toolchain install` — this + // manifest [toolchain] path previously ran none, so a freshly + // auto-installed payload kept its stale install-time cfg / + // unpatched runtime libs. + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfg, payload->root, pkg, + runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) + return std::unexpected(std::format( + "toolchain post-install fixup: {}", fixed.error())); + else report_fixup(*fixed, payload->root); + // Canonical rendering, whatever spelling the manifest/config used: + // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". + mcpp::ui::info("Resolved", + std::format("{} → {}", spec->display(), + mcpp::ui::shorten_path(explicit_compiler, + mcpp::fetcher::make_path_ctx(&**get_cfg(), *root)))); } - // Same post-install fixup as `mcpp toolchain install` — this manifest - // [toolchain] path previously ran none, so a freshly auto-installed - // payload kept its stale install-time cfg / unpatched runtime libs. - if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( - **cfg, payload->root, pkg, - runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) - return std::unexpected(std::format( - "toolchain post-install fixup: {}", fixed.error())); - else report_fixup(*fixed, payload->root); - // Canonical rendering, whatever spelling the manifest/config used: - // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". - mcpp::ui::info("Resolved", - std::format("{} → {}", spec->display(), - mcpp::ui::shorten_path(explicit_compiler, - mcpp::fetcher::make_path_ctx(&**get_cfg(), *root)))); } else if (tcSpec.has_value() && *tcSpec == "system") { // Explicit user opt-in to system PATH compiler — kept as escape hatch. } else if (mcpp::platform::env::offline_mode() @@ -4978,7 +5013,8 @@ prepare_build(bool print_fingerprint, // command is unchanged. const auto& stdDialect = mcpp::toolchain::dialect_for(*tc); const auto stdCrt = mcpp::toolchain::msvc_crt_flag( - stdDialect, m->buildConfig.linkage == "static"); + stdDialect, mcpp::toolchain::msvc_wants_static_crt( + m->buildConfig.linkage, m->buildConfig.cxxRuntime)); auto sm = mcpp::toolchain::ensure_built( *tc, m->package.standard, stdFlagAndDialect, mcpp::platform::macos::deployment_target( diff --git a/src/toolchain/dialect.cppm b/src/toolchain/dialect.cppm index 96d0b780..1adc549f 100644 --- a/src/toolchain/dialect.cppm +++ b/src/toolchain/dialect.cppm @@ -105,8 +105,35 @@ const CommandDialect& dialect_for(const Toolchain& tc); // // Same shape as `macos_deployment_target`, which `stdmod::ensure_built` already // takes for exactly this reason on the other platform. -constexpr std::string_view msvc_crt_flag(const CommandDialect& d, bool staticLinkage) { - return staticLinkage ? d.staticRuntime : d.dynamicRuntime; +constexpr std::string_view msvc_crt_flag(const CommandDialect& d, bool staticCrt) { + return staticCrt ? d.staticRuntime : d.dynamicRuntime; +} + +// Does this build want MSVC's STATIC CRT? TWO manifest keys answer it: +// +// [build] linkage = "static" the libc axis +// [build] cxx_runtime = "self-contained" the C++ runtime axis +// +// That is not a redundancy to be picked between — on the MSVC ABI they are +// physically ONE switch. `/MT` links the C runtime and the C++ runtime out of +// the same library, and there is no way to have one static and the other +// dynamic. distribution.cppm's PE/MinGW branch already says exactly this +// about `-static`; MSVC is the same statement, and used to disagree with +// itself about it (`linkage` emitted /MT while `cxx_runtime` reported "not +// implemented" — for the same physical outcome). +// +// `cxxRuntime` is the RAW manifest scalar, and must stay that way: the +// resolved Contract defaults to SelfContained for most roles, so keying off +// it would flip every Windows build from /MD to /MT. Empty here means nobody +// wrote it down, which is the only reading under which "explicit" is honest. +// +// Asking the question once is also what keeps the project's TUs and the std +// module from disagreeing (#422): both callers ask THIS, rather than each +// spelling out `linkage == "static"` and drifting the next time a key is +// added — which is precisely how the second key came to be ignored. +constexpr bool msvc_wants_static_crt(std::string_view linkage, + std::string_view cxxRuntime) { + return linkage == "static" || cxxRuntime == "self-contained"; } diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index c95ac215..96877f72 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -186,8 +186,13 @@ int msvc_wrong_host() { return 1; } -void msvc_print_detected(const mcpp::toolchain::msvc::MsvcInstallation& inst) { - mcpp::ui::status("Detected", std::format( +// `label` names the ORIGIN, because the two are not the same event. +// "Detected" is a claim about probing the machine, and saying it after +// unpacking a payload the caller named would describe the wrong thing — +// quietly, and in exactly the direction this whole change is about. +void msvc_print_detected(const mcpp::toolchain::msvc::MsvcInstallation& inst, + std::string_view label = "Detected") { + mcpp::ui::status(label, std::format( "msvc {}{} (VC tools {})", inst.display_version(), inst.vsProduct.empty() ? "" : std::format(" (VS {})", inst.vsProduct), @@ -197,6 +202,26 @@ void msvc_print_detected(const mcpp::toolchain::msvc::MsvcInstallation& inst) { inst.hasStdModules ? "available (std.ixx)" : "not available"); } +// The Windows SDK is the OTHER half of a usable MSVC, and a payload that +// unpacked a compiler without it is a half-installed state: cl.exe is right +// there, so everything reports success, and the build dies inside the ucrt +// headers much later. `has_usable_msvc()` exists for exactly this reason; +// this is the same judgement applied to the managed origin, where the SDK +// arrives as a package dependency and can therefore fail on its own. +void msvc_warn_if_sdk_missing(const mcpp::toolchain::msvc::MsvcInstallation& inst) { + auto roots = mcpp::toolchain::msvc::sibling_sdk_roots(inst.clPath); + if (auto sdk = mcpp::toolchain::msvc::find_windows_sdk(roots)) { + std::println(" windows sdk: {} ({})", + sdk->version, sdk->root.string()); + return; + } + mcpp::ui::warning( + "the toolset installed, but no Windows SDK was found next to it.\n" + " cl.exe cannot compile anything without the ucrt/um headers.\n" + " The toolset declares `xim:windows-sdk` as a dependency, so this\n" + " means that dependency did not install — check `xlings list`."); +} + EffectiveDefault effective_default_toolchain(const mcpp::config::GlobalConfig& cfg) { std::error_code ec; auto mpath = std::filesystem::current_path(ec) / "mcpp.toml"; @@ -499,15 +524,19 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, mcpp::toolchain::print_compat_hint(*spec); if (int rc = attach_target_arg(*spec, targetArg); rc != 0) return rc; - // msvc@system: mcpp never installs MSVC — report what's there, or - // print installation guidance. + // msvc@system: mcpp never installs the machine's Visual Studio — + // report what's there, or point at both ways forward. + // (msvc@ is NOT a system spec and falls through to the xim + // package path below, like every other family.) if (mcpp::toolchain::is_system_toolchain(*spec)) { if (!mcpp::platform::is_windows) return msvc_wrong_host(); if (auto inst = mcpp::toolchain::msvc::detect_installation()) { msvc_print_detected(*inst); std::println(""); - std::println("MSVC is already installed — mcpp does not manage it."); - std::println("Tip: `mcpp toolchain default msvc` to make it the default."); + std::println("This is the machine's own Visual Studio — mcpp does not manage it."); + std::println("Tip: `mcpp toolchain default msvc` to make it the default,"); + std::println(" or `mcpp toolchain install msvc ` for a pinned one"); + std::println(" that does not depend on what this machine has installed."); return 0; } mcpp::ui::error(mcpp::toolchain::msvc::install_guidance()); @@ -575,6 +604,38 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, return 1; } + // A pinned MSVC toolset shares the ACQUISITION with gcc (the xim + // install above) and the RESOLUTION with msvc@system (below) — the + // two axes are independent, so neither gets a private copy of the + // other's logic. + // + // What it does not share is the bin/-shaped frontend lookup and the + // ELF post-install fixup: an msvc payload keeps cl.exe under + // VC/Tools/MSVC//bin/Hostx64/x64, and there is nothing to + // patchelf on a PE toolchain. + if (spec->family == mcpp::toolchain::Family::Msvc) { + auto inst = mcpp::toolchain::msvc::installation_at( + payload->root, pkg.ximVersion); + if (!inst) { + mcpp::ui::error(std::format( + "msvc payload installed at '{}', but no cl.exe under " + "VC/Tools/MSVC/{} — the payload is not what this version " + "claims to be", + payload->root.string(), pkg.ximVersion)); + return 1; + } + msvc_print_detected(*inst, "Installed"); + msvc_warn_if_sdk_missing(*inst); + mcpp::ui::status("Installed", + std::format("{} → {}", pkg.display_spec(), inst->clPath.string())); + if (cfg.defaultToolchain.empty()) { + std::println(""); + std::println("Tip: `mcpp toolchain default {}` to make this the default.", + spec->spec_str()); + } + return 0; + } + auto bin = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); if (!std::filesystem::exists(bin)) { mcpp::ui::error(std::format( @@ -656,8 +717,9 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, mcpp::toolchain::print_compat_hint(*spec); if (int rc = attach_target_arg(*spec, targetArg); rc != 0) return rc; - // msvc@system: locate + identify the system MSVC, persist the stable - // spec (never a concrete version — config survives VS updates). + // msvc@system: locate + identify the machine's own MSVC, persist the + // stable spec (never a concrete version — config survives VS + // updates). A versioned msvc spec is a payload and falls through. if (mcpp::toolchain::is_system_toolchain(*spec)) { if (!mcpp::platform::is_windows) return msvc_wrong_host(); auto inst = mcpp::toolchain::msvc::detect_installation(); @@ -665,15 +727,6 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, mcpp::ui::error(mcpp::toolchain::msvc::install_guidance()); return 1; } - // `msvc@19.44` is a pin-verify against the detected install, not - // a selection among many — mcpp always uses the newest VC tools. - if (!spec->version.empty() && spec->version != "system" - && !inst->display_version().starts_with(spec->version)) { - mcpp::ui::error(std::format( - "msvc@{} requested, but the system MSVC is {} (VC tools {})", - spec->version, inst->display_version(), inst->toolsVersion)); - return 1; - } msvc_print_detected(*inst); auto wr = mcpp::config::write_default_toolchain(cfg, "msvc@system"); if (!wr) { @@ -707,6 +760,16 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, auto installDir = mcpp::xlings::paths::xim_tool(xlEnv, pkg.ximName, pkg.ximVersion); if (!std::filesystem::exists(installDir)) { + // Before "not installed", check whether this is the retired + // `msvc@` spelling — otherwise the advice is to + // install a toolset that does not exist and never will. + if (spec->family == mcpp::toolchain::Family::Msvc) { + if (auto hint = mcpp::toolchain::msvc::cl_version_spelling_hint( + spec->version)) { + mcpp::ui::error(*hint); + return 1; + } + } mcpp::ui::error(std::format( "{} is not installed. Run `mcpp toolchain install {} {}{}` first.", spec->display(), mcpp::toolchain::family_name(spec->family), @@ -741,8 +804,11 @@ export int toolchain_remove(const mcpp::config::GlobalConfig& cfg, auto xlEnv = mcpp::config::make_xlings_env(cfg); auto parsedSpec = mcpp::toolchain::parse_toolchain_spec(pos0); if (parsedSpec && mcpp::toolchain::is_system_toolchain(*parsedSpec)) { - mcpp::ui::error("msvc is a system toolchain managed by the Visual " - "Studio Installer — mcpp cannot remove it"); + mcpp::ui::error( + "msvc@system is the machine's own Visual Studio, managed by the " + "Visual Studio Installer — mcpp cannot remove it.\n" + " A toolset mcpp installed can be removed by naming it:\n" + " mcpp toolchain remove msvc@"); return 1; } if (!parsedSpec || parsedSpec->version.empty()) { diff --git a/src/toolchain/msvc.cppm b/src/toolchain/msvc.cppm index fe26cb22..a0417e7d 100644 --- a/src/toolchain/msvc.cppm +++ b/src/toolchain/msvc.cppm @@ -1,15 +1,25 @@ -// mcpp.toolchain.msvc — MSVC / Visual Studio discovery on Windows. +// mcpp.toolchain.msvc — locating an MSVC toolset, from either origin. // -// Provides reliable discovery of Visual Studio installations and MSVC -// toolchain components (std.ixx, cl.exe, lib.exe, etc.) using multiple -// strategies: -// 1. vswhere.exe (Microsoft's official VS locator) -// 2. Environment variables (VSINSTALLDIR, VS*COMNTOOLS) -// 3. Well-known installation paths (fallback) +// A toolset reaches a build one of two ways, and they answer different +// questions: // -// This module is used by clang.cppm to find MSVC STL's std.ixx when -// Clang targets x86_64-pc-windows-msvc. It will also serve as the -// foundation for future native MSVC (cl.exe) toolchain support. +// SYSTEM (`msvc@system`) — probed on this machine. The answer depends +// on what happens to be installed here. +// MANAGED (`msvc@`) — an xlings payload the manifest named. The +// answer is in the manifest; the machine only +// decides whether it has been downloaded yet. +// +// Everything below is one of those two, or shared between them. The shared +// part is `installation_from_tools_dir()`: given a `VC/Tools/MSVC/` +// directory, the record built from it is identical whichever origin produced +// it — which is what keeps a managed toolset from being a second code path +// with its own bugs. +// +// Discovery order for the SYSTEM origin is deliberate and is documented at +// find_vs_install_path(): a declared answer outranks a probe. +// +// Also used by clang.cppm to find MSVC STL's std.ixx when Clang targets +// x86_64-pc-windows-msvc. module; #include @@ -47,10 +57,7 @@ std::optional find_cl(); // the level gate reachable: every other provider answers 20. int std_module_min_level(const Toolchain& tc); -// ─── System-toolchain detection (msvc@system) ──────────────────────────── -// -// mcpp treats MSVC as a *system* toolchain: it locates and identifies an -// installed Visual Studio / Build Tools, but never installs or removes one. +// ─── Installation records (both origins) ───────────────────────────────── struct MsvcInstallation { std::filesystem::path vsRoot; // …\Microsoft Visual Studio\2022\BuildTools @@ -67,9 +74,28 @@ struct MsvcInstallation { } }; -// Locate the best (newest) usable installation. nullopt = MSVC absent. +// SYSTEM origin: locate the best (newest) usable installation on this +// machine. nullopt = MSVC absent. Everything about which toolset this picks +// is a property of the machine, not of the caller — see `installation_at` +// for the other origin. std::optional detect_installation(); +// MANAGED origin: build the record for an EXACT toolset under a VS-shaped +// root (`/VC/Tools/MSVC/`). +// +// Nothing is probed and nothing is ranked: `toolsVersion` is what the caller +// declared, so a missing directory is nullopt rather than a silent fallback +// to a neighbouring toolset. That is the whole difference from +// detect_installation(), and it is why a manifest pinning a toolset gets the +// same compiler on every machine. +// +// Not Windows-only: given a directory of that shape the record is the same +// anywhere, which is what makes the managed path testable off Windows. The +// cl banner simply stays unparsed there and `display_version()` falls back +// to the declared version. +std::optional installation_at(const std::filesystem::path& vsRoot, + std::string_view toolsVersion); + // Parse a cl.exe banner into (version, arch). Token-based so localized // banners work: first "d.d.d[.d]" run is the version, arch is the arm64/x64/ // x86 token. Pure and cross-platform for unit testing. @@ -79,10 +105,22 @@ parse_cl_banner(std::string_view banner); // Map a cl banner arch token to the canonical windows-msvc triple. std::string triple_for_arch(std::string_view arch); -// Multi-line guidance shown wherever MSVC is required but absent. -// States what was searched and how to install (mcpp does not install MSVC). +// Multi-line guidance shown wherever MSVC is required but absent: what was +// searched, and both ways to get a compiler (pin one, or use the machine's). std::string install_guidance(); +// A version-axis spelling that no longer means what it used to. +// +// `msvc@19.44` was a pin-verify against the SYSTEM install's cl banner. The +// version axis now names a toolset (`14.44.35207`), so that spelling has to +// say so — and say what the two things it might have meant are spelled as. +// +// Returns guidance only when this machine can PROVE that reading (its own cl +// banner matches the requested prefix), which makes the message a fact about +// this machine rather than a guess about a string. nullopt otherwise, so an +// ordinary "no such toolset" error is not decorated with speculation. +std::optional cl_version_spelling_hint(std::string_view requestedVersion); + // Classify + enrich an already-probed cl.exe binary for detect(): // version/arch from the banner, targetTriple, driverIdent, std.ixx lookup, // and the build env (INCLUDE/LIB/PATH from VC tools + Windows SDK) into @@ -98,8 +136,29 @@ struct WindowsSdk { std::string version; // "10.0.26100.0" (highest usable) }; -// Locate the Windows 10/11 SDK (highest version with ucrt headers). -std::optional find_windows_sdk(); +// Locate the Windows 10/11 SDK. Search order, most specific first: +// +// 1. WindowsSdkDir (+ WindowsSdkVersion) — what vcvars exports and what +// every other build system honours. A declared answer outranks a scan, +// for the same reason VSINSTALLDIR outranks vswhere. +// 2. `extraRoots` — roots the caller already knows about. In practice the +// windows-sdk payloads sitting beside a managed toolset in mcpp's own +// store; see sibling_sdk_roots(). +// 3. The conventional absolute install roots — a fallback, not the rule. +// They are still here because a system Visual Studio does put the SDK +// there, and nothing else would find it. +// +// Within a root the highest version carrying `ucrt/corecrt.h` wins, unless +// WindowsSdkVersion named one that is present. +std::optional find_windows_sdk( + std::span extraRoots = {}); + +// Windows SDK payload roots that belong to the same xlings store as this +// compiler. The compiler binary says which store it came from, so a managed +// toolset finds its own SDK with nothing configured and no version hardcoded +// anywhere in mcpp. Empty for a system cl.exe (it is not in a store). +std::vector +sibling_sdk_roots(const std::filesystem::path& clPath); // True only when BOTH halves of a usable MSVC C++ setup are present: the // STL's std module source AND the Windows SDK. @@ -165,15 +224,32 @@ std::string run_capture_line(const std::string& cmd) { return out; } -// Strategy 1: Use vswhere.exe to find VS installation. +// Strategy 1: VSINSTALLDIR — someone SAID which install to use. +// +// Set by a developer command prompt, by a CI step that ran vcvarsall, or by +// a tool that exported an environment on purpose. It is an answer, not a +// guess, which is why it now outranks vswhere. +std::optional find_vs_via_vsinstalldir() { + if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { + std::filesystem::path p{dir}; + if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) + return p; + } + return std::nullopt; +} + +// Strategy 2: vswhere.exe — Microsoft's locator, i.e. a ranked guess. std::optional find_vs_via_vswhere() { // vswhere.exe ships with the VS Installer at a well-known path std::filesystem::path vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"; if (!std::filesystem::exists(vswhere)) return std::nullopt; + // `-prerelease` or an Insiders instance is invisible here. Without it a + // machine with only an Insiders VS reports "MSVC was not found" while a + // perfectly good cl.exe sits on disk. auto result = run_capture_line( - "\"" + vswhere.string() + "\" -latest -products * " + "\"" + vswhere.string() + "\" -latest -prerelease -products * " "-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 " "-property installationPath 2>nul"); @@ -182,15 +258,10 @@ std::optional find_vs_via_vswhere() { return std::nullopt; } -// Strategy 2: Use environment variables. -std::optional find_vs_via_env() { - // VSINSTALLDIR is set inside VS Developer Command Prompt - if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { - std::filesystem::path p{dir}; - if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) - return p; - } - +// Strategy 3: VS*COMNTOOLS — machine-wide leftovers, so they rank BELOW +// vswhere. VS150COMNTOOLS lingering from a 2017 install must not outrank a +// current one; unlike VSINSTALLDIR nobody set these for this shell. +std::optional find_vs_via_comntools() { // VS*COMNTOOLS: VS170COMNTOOLS (2022), VS160COMNTOOLS (2019), VS150COMNTOOLS (2017) for (auto* var : {"VS170COMNTOOLS", "VS160COMNTOOLS", "VS150COMNTOOLS"}) { if (auto* val = std::getenv(var); val && *val) { @@ -204,7 +275,7 @@ std::optional find_vs_via_env() { return std::nullopt; } -// Strategy 3: Scan well-known paths. +// Strategy 4: Scan well-known paths. std::optional find_vs_via_paths() { static constexpr std::string_view bases[] = { "C:\\Program Files\\Microsoft Visual Studio", @@ -256,10 +327,17 @@ std::optional find_latest_msvc_tools(const std::filesyste std::optional find_vs_install_path() { #if defined(_WIN32) - // Try strategies in order of reliability - if (auto p = find_vs_via_vswhere()) return p; - if (auto p = find_vs_via_env()) return p; - if (auto p = find_vs_via_paths()) return p; + // Declared before probed. vswhere used to run first, and because it + // returns something on almost every developer machine, VSINSTALLDIR was + // effectively unreachable — a build that had exported a complete vcvars + // environment still compiled with whatever vswhere ranked highest + // (measured on xrgui#3: vcvars said 14.52, the build used 14.51, and the + // only way out was to hide vswhere.exe). A guess must not silently + // override an answer. + if (auto p = find_vs_via_vsinstalldir()) return p; + if (auto p = find_vs_via_vswhere()) return p; + if (auto p = find_vs_via_comntools()) return p; + if (auto p = find_vs_via_paths()) return p; #endif return std::nullopt; } @@ -343,15 +421,44 @@ std::string triple_for_arch(std::string_view arch) { std::string install_guidance() { return - "MSVC was not found on this system.\n" - " searched: vswhere.exe, VSINSTALLDIR / VS*COMNTOOLS, and the standard\n" + "no Visual Studio installation was found on this system.\n" + " searched: VSINSTALLDIR, vswhere.exe, VS*COMNTOOLS, and the standard\n" " 'Program Files\\Microsoft Visual Studio\\\\' paths\n" - " mcpp does not install MSVC — install it yourself, then retry:\n" + "\n" + " mcpp can install a pinned MSVC toolset instead — no Visual Studio,\n" + " no installer, no elevation, and several toolsets can coexist:\n" + " mcpp toolchain install msvc 14.44.35207\n" + " mcpp toolchain list --available msvc # other toolsets\n" + " then pin it in mcpp.toml, so every machine builds with that one:\n" + " [toolchain]\n" + " windows = \"msvc@14.44.35207\"\n" + "\n" + " or install Visual Studio yourself and use msvc@system:\n" " - Visual Studio Installer: add the 'Desktop development with C++' workload\n" " (component: Microsoft.VisualStudio.Component.VC.Tools.x86.x64)\n" " - or Build Tools only: winget install Microsoft.VisualStudio.2022.BuildTools\n" " then add the C++ workload in the installer\n" - " afterwards run: mcpp toolchain default msvc"; + " afterwards run: mcpp toolchain default msvc"; +} + +std::optional cl_version_spelling_hint(std::string_view requestedVersion) { + if (requestedVersion.empty() || requestedVersion == "system") + return std::nullopt; + auto inst = detect_installation(); + if (!inst) return std::nullopt; + // Only when the requested string really is this machine's cl version. + // A toolset version ("14.44.35207") never prefixes a banner ("19.44.…"), + // so this cannot fire on a genuine typo'd toolset. + if (!inst->display_version().starts_with(requestedVersion)) return std::nullopt; + if (inst->display_version() == inst->toolsVersion) return std::nullopt; // banner unparsed + return std::format( + "msvc@{} names a COMPILER version, not a toolset.\n" + " This machine's Visual Studio reports cl {} (VC tools {}).\n" + " The version axis now selects the toolset mcpp installs and pins:\n" + " - to use this machine's install: msvc@system\n" + " - to pin a toolset mcpp manages: msvc@{}", + requestedVersion, inst->display_version(), inst->toolsVersion, + inst->toolsVersion); } namespace { @@ -368,6 +475,18 @@ namespace { return {}; } +// The one place an MsvcInstallation is built — from a VS root and a +// `VC/Tools/MSVC/` directory under it. Both origins land here, so a +// managed toolset and a system one are described by the same code and cannot +// drift apart in what they report or which cl.exe they pick. +// +// Deliberately not Windows-guarded: given a directory of that shape the +// record is the same anywhere, which is what makes the managed path testable +// on a Linux CI runner. +std::optional +installation_from_tools_dir(const std::filesystem::path& vsRoot, + const std::filesystem::path& tools); + // Capture cl.exe's banner. cl prints it (plus a usage complaint) when run // bare; the exit status is irrelevant — parse whatever came out. std::string capture_cl_banner(const std::filesystem::path& cl) { @@ -376,19 +495,13 @@ std::string capture_cl_banner(const std::filesystem::path& cl) { return r.output; } -} // namespace - -std::optional detect_installation() { -#if defined(_WIN32) - auto vs = find_vs_install_path(); - if (!vs) return std::nullopt; - auto tools = find_latest_msvc_tools(*vs); - if (!tools) return std::nullopt; - +std::optional +installation_from_tools_dir(const std::filesystem::path& vsRoot, + const std::filesystem::path& tools) { MsvcInstallation inst; - inst.vsRoot = *vs; - inst.vsProduct = product_from_vs_root(*vs); - inst.toolsVersion = tools->filename().string(); + inst.vsRoot = vsRoot; + inst.vsProduct = product_from_vs_root(vsRoot); + inst.toolsVersion = tools.filename().string(); // Host-native bin dir first (arm64 hosts run arm64 cl; everything else // x64), with the remaining pairs as fallback. @@ -399,9 +512,9 @@ std::optional detect_installation() { } else { pairs = {{"Hostx64", "x64"}, {"Hostarm64", "arm64"}, {"Hostx86", "x86"}}; } + std::error_code ec; for (auto [host, target] : pairs) { - auto cl = *tools / "bin" / host / target / "cl.exe"; - std::error_code ec; + auto cl = tools / "bin" / host / target / "cl.exe"; if (std::filesystem::exists(cl, ec)) { inst.clPath = cl; inst.arch = std::string(target); @@ -410,45 +523,105 @@ std::optional detect_installation() { } if (inst.clPath.empty()) return std::nullopt; - std::error_code ec; inst.hasStdModules = - std::filesystem::exists(*tools / "modules" / "std.ixx", ec); + std::filesystem::exists(tools / "modules" / "std.ixx", ec); // Version identification: banner is authoritative; tolerate failure // (clVersion stays empty and display_version() falls back to the - // tools-dir version). + // tools-dir version). Off Windows that failure is the normal case. if (auto parsed = parse_cl_banner(capture_cl_banner(inst.clPath))) { inst.clVersion = parsed->first; if (!parsed->second.empty()) inst.arch = parsed->second; } return inst; +} + +} // namespace + +std::optional installation_at(const std::filesystem::path& vsRoot, + std::string_view toolsVersion) { + if (toolsVersion.empty()) return std::nullopt; + auto tools = vsRoot / "VC" / "Tools" / "MSVC" / std::string(toolsVersion); + std::error_code ec; + if (!std::filesystem::is_directory(tools, ec)) return std::nullopt; + return installation_from_tools_dir(vsRoot, tools); +} + +std::optional detect_installation() { +#if defined(_WIN32) + auto vs = find_vs_install_path(); + if (!vs) return std::nullopt; + auto tools = find_latest_msvc_tools(*vs); + if (!tools) return std::nullopt; + return installation_from_tools_dir(*vs, *tools); #else return std::nullopt; #endif } -std::optional find_windows_sdk() { -#if defined(_WIN32) - // Directory scan of the conventional install roots; highest version dir - // that actually carries the UCRT headers wins. (Registry Installed - // Roots would be marginally more correct — the path scan covers every - // real installer layout seen so far and needs no Win32 API surface.) - for (const char* base : {"C:\\Program Files (x86)\\Windows Kits\\10", - "C:\\Program Files\\Windows Kits\\10"}) { - std::filesystem::path root{base}; +std::vector +sibling_sdk_roots(const std::filesystem::path& clPath) { + std::vector out; + auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(clPath); + if (!xpkgs) return out; // a system cl.exe: not in any store + std::error_code ec; + auto pkgRoot = *xpkgs / "xim-x-windows-sdk"; + for (auto& e : std::filesystem::directory_iterator(pkgRoot, ec)) { + if (e.is_directory(ec)) out.push_back(e.path()); + } + // Newest payload version first, so the "highest usable" rule inside + // find_windows_sdk() sees them in the order it would have picked anyway. + std::sort(out.begin(), out.end(), std::greater<>{}); + return out; +} + +std::optional find_windows_sdk( + std::span extraRoots) { + // Highest version dir under `root/Include` that actually carries the UCRT + // headers; `want` (from WindowsSdkVersion) wins if it is one of them. + // (Registry Installed Roots would be marginally more correct — the path + // scan covers every real installer layout seen so far and needs no Win32 + // API surface.) + auto pick = [](const std::filesystem::path& root, + std::string_view want) -> std::optional { std::error_code ec; - if (!std::filesystem::exists(root / "Include", ec)) continue; + auto inc = root / "Include"; + if (!std::filesystem::is_directory(inc, ec)) return std::nullopt; std::string best; - for (auto& e : std::filesystem::directory_iterator(root / "Include", ec)) { - if (!e.is_directory()) continue; + for (auto& e : std::filesystem::directory_iterator(inc, ec)) { + if (!e.is_directory(ec)) continue; auto v = e.path().filename().string(); - if (std::filesystem::exists(e.path() / "ucrt" / "corecrt.h", ec) - && v > best) - best = v; + if (!std::filesystem::exists(e.path() / "ucrt" / "corecrt.h", ec)) + continue; + if (!want.empty() && v == want) return WindowsSdk{root, v}; + if (v > best) best = v; } - if (!best.empty()) return WindowsSdk{root, best}; + if (best.empty()) return std::nullopt; + return WindowsSdk{root, best}; + }; + + // 1. Declared: WindowsSdkDir (+ WindowsSdkVersion). vcvars exports both; + // WindowsSdkVersion carries a trailing backslash there, which is not + // part of the directory name. + std::string want; + if (auto* v = std::getenv("WindowsSdkVersion"); v && *v) { + want = v; + while (!want.empty() && (want.back() == '\\' || want.back() == '/')) + want.pop_back(); + } + if (auto* dir = std::getenv("WindowsSdkDir"); dir && *dir) { + if (auto s = pick(std::filesystem::path{dir}, want)) return s; + } + + // 2. Roots the caller knows about (managed toolset's own store). + for (const auto& root : extraRoots) + if (auto s = pick(root, want)) return s; + + // 3. The conventional absolute install roots. + for (const char* base : {"C:\\Program Files (x86)\\Windows Kits\\10", + "C:\\Program Files\\Windows Kits\\10"}) { + if (auto s = pick(std::filesystem::path{base}, want)) return s; } -#endif return std::nullopt; } @@ -628,7 +801,12 @@ std::expected enrich_toolchain_from_cl(Toolchain& tc) { // Build environment (INCLUDE/LIB/PATH/VSLANG). SDK absence keeps // detection working (selection UX on SDK-less boxes); the build path // errors with guidance when envOverrides is empty. - if (auto sdk = find_windows_sdk()) { + // + // A managed toolset carries its own SDK as an xlings dependency, and the + // compiler's own path says which store to look in — so the two halves of + // a pinned toolchain stay together without anything being configured. + auto extraSdkRoots = sibling_sdk_roots(tc.binaryPath); + if (auto sdk = find_windows_sdk(extraSdkRoots)) { tc.envOverrides = build_env_for_cl(tc.binaryPath, parsed->second, *sdk); } return {}; diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index b3f563ea..d21e0c88 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -116,15 +116,19 @@ struct PayloadIdentity { std::optional identify_xim_payload(std::string_view ximDirName); // Does an installed payload row match the configured default (toolchain axis; -// version exact)? msvc matches on family alone — the persisted spec is the -// stable "msvc@system", never a concrete version. +// version exact)? `msvc@system` names no version and so matches on family +// alone; a pinned toolset compares versions like every other family. bool spec_matches_payload(const ToolchainSpec& def, const PayloadIdentity& id, std::string_view payloadVersion); // System toolchains are located on the machine, never installed/removed by -// mcpp. Today that's MSVC (`msvc@system`); the PATH-compiler escape hatch -// (`[toolchain] … = "system"`) is a separate, older mechanism. +// mcpp: `msvc@system` and bare `msvc`. A VERSIONED msvc spec is NOT one of +// them — `msvc@14.44.35207` is an xim payload mcpp installs and pins, the +// same shape as `gcc@16.1.0`. +// +// (The PATH-compiler escape hatch, `[toolchain] … = "system"`, is a separate +// and older mechanism.) bool is_system_toolchain(const ToolchainSpec& spec); // Can THIS host serve that target — is there an installable payload for the @@ -239,7 +243,19 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { pkg.ximVersion = spec.version; if (spec.family == Family::Msvc) { - pkg.ximName = "msvc"; // never resolved via xim + // `xim:msvc@`. Only reached for a VERSIONED spec — + // `msvc@system` never gets here, because nothing about it is a + // package (see is_system_toolchain). + // + // frontendCandidates is what the generic bin/-shaped resolution + // looks for, and an msvc payload is not bin/-shaped: cl.exe lives at + // VC/Tools/MSVC//bin/Hostx64/x64/. The managed path therefore + // resolves through msvc::installation_at() instead — the same code + // that describes a system install. Keeping the candidate here means + // a caller that does use the generic path gets nothing rather than + // the wrong thing. + pkg.ximName = "msvc"; + pkg.ximVersion = spec.version; pkg.frontendCandidates = {"cl.exe"}; return pkg; } @@ -344,6 +360,15 @@ std::filesystem::path toolchain_frontend(const std::filesystem::path& binDir, std::optional identify_xim_payload(std::string_view ximDirName) { if (ximDirName == "gcc") return PayloadIdentity{ Family::Gcc, {} }; + // A pinned toolset is an installed payload like any other, so it shows up + // in `toolchain list` under its toolset version. Without this row the + // install would succeed and then be invisible. + // + // Host-target (empty triple), like gcc and llvm: an msvc payload only + // ever targets the machine it runs on, and a spec for it carries no + // target axis either — so the two sides compare equal. + if (ximDirName == "msvc") + return PayloadIdentity{ Family::Msvc, {} }; if (ximDirName == mcpp::toolchain::llvm::package_name()) return PayloadIdentity{ Family::Llvm, {} }; if (ximDirName == "musl-gcc") @@ -362,12 +387,23 @@ bool spec_matches_payload(const ToolchainSpec& def, const PayloadIdentity& id, std::string_view payloadVersion) { if (def.family != id.family) return false; - if (def.family == Family::Msvc) return true; // msvc@system: family match + // msvc@system names no version, so it matches on family alone. A pinned + // toolset compares versions like every other family — that is the point + // of pinning it. + if (is_system_toolchain(def)) return true; return def.version == payloadVersion; } bool is_system_toolchain(const ToolchainSpec& spec) { - return spec.family == Family::Msvc; + // The VERSION axis decides, not the family. `msvc@system` (and bare + // `msvc`) means "whatever this machine has"; `msvc@14.44.35207` names a + // toolset mcpp installs and pins, exactly as `gcc@16.1.0` names a gcc. + // + // Before this split, every msvc spec was a system spec — so a manifest + // could ask for a specific toolset and silently get a different one, + // which is the defect this whole file's msvc handling exists to close. + return spec.family == Family::Msvc + && (spec.version.empty() || spec.version == "system"); } bool host_can_serve(const triple::Triple& target) { @@ -405,6 +441,11 @@ std::vector available_toolchain_indexes() { // The Windows-PE gcc payload is host-split at the distribution layer // (§4.3); each host lists the package it would actually install. if constexpr (mcpp::platform::is_windows) { + // Pinned MSVC toolsets. Listing them is what makes the managed origin + // discoverable at all: without a row here `toolchain list --available` + // says gcc and llvm can be pinned and msvc cannot, which stopped being + // true. `msvc@system` is reported separately, as an installation. + out.push_back({ "msvc", Family::Msvc }); out.push_back({ "mingw-gcc", Family::Gcc }); // The windows-hosted canadian cross to Linux. Named by triple, exactly // as to_xim_package() derives it (`-gcc`), so the Available diff --git a/src/version.cppm b/src/version.cppm index efe06158..a12d47e6 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.15.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.16.1"; } // namespace mcpp diff --git a/tests/e2e/239_msvc_managed_toolset.sh b/tests/e2e/239_msvc_managed_toolset.sh new file mode 100755 index 00000000..c465086c --- /dev/null +++ b/tests/e2e/239_msvc_managed_toolset.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# requires: msvc +# 97_msvc_managed_toolset.sh — `msvc@`: the toolset the manifest +# names is the one that compiles, regardless of what this machine has. +# +# WHY THIS TEST CANNOT BE SATISFIED BY THE MACHINE'S COMPILER, which is the +# only reason it is worth running: the runner has its own Visual Studio, and +# every assertion below is written so that the system install answering +# instead would FAIL rather than pass quietly. +# +# - the resolved cl.exe must live under mcpp's payload store +# - its toolset directory must be the version the spec named +# - `msvc@system` on the same machine must still resolve to the SYSTEM cl, +# i.e. the two origins do not contaminate each other +# +# Network: installs xim:msvc (~85 MB) + xim:windows-sdk (~135 MB). Skips +# cleanly when the index cannot be reached, because an offline runner has +# nothing to say about this. +set -e + +TOOLSET="14.44.35207" # release channel; `latest` in xim:msvc + +CONF="${MCPP_HOME:-$HOME/.mcpp}/config.toml" +ORIG_DEFAULT="" +if [[ -f "$CONF" ]]; then + ORIG_DEFAULT=$(sed -n '/^\[toolchain\]/,/^\[/p' "$CONF" \ + | grep -E '^default[[:space:]]*=' | head -1 | cut -d'"' -f2 || true) +fi +TMP=$(mktemp -d) +restore() { + if [[ -n "$ORIG_DEFAULT" ]]; then + "$MCPP" toolchain default "$ORIG_DEFAULT" >/dev/null 2>&1 || true + fi + rm -rf "$TMP" +} +trap restore EXIT +cd "$TMP" + +# 1) install it. This runs BEFORE the discoverability check below, so that a +# runner which cannot reach the index skips instead of failing an assertion +# about a list the index would have filled in. +rc=0; out=$("$MCPP" toolchain install msvc "$TOOLSET" 2>&1) || rc=$? +if [[ $rc -ne 0 ]]; then + case "$out" in + *"index"*|*"network"*|*"resolve"*|*"offline"*|*"connect"*|*"not found"*) + echo "SKIP: xim:msvc@$TOOLSET unreachable: $out"; exit 0 ;; + *) echo "FAIL: install msvc $TOOLSET: $out"; exit 1 ;; + esac +fi +[[ "$out" == *"$TOOLSET"* ]] \ + || { echo "FAIL: install did not report the toolset: $out"; exit 1; } + +# 1b) it must now be LISTED. A toolset that installs but never appears is +# indistinguishable from one that did not install, and `toolchain list` is +# where a user looks. +out=$("$MCPP" toolchain list 2>&1) +[[ "$out" == *"msvc"* && "$out" == *"$TOOLSET"* ]] \ + || { echo "FAIL: installed toolset absent from toolchain list: $out"; exit 1; } + +# 2) build with it, from a manifest — the path that matters, and the one +# where the version used to be accepted and then ignored. +"$MCPP" new hello_pinned >/dev/null 2>&1 +cd hello_pinned +cat >> mcpp.toml <&1) || { echo "FAIL: pinned build: $out"; exit 1; } + +# The resolved compiler must be the PAYLOAD's, not the machine's. Two +# independent facts, because either alone can be true by accident: the path +# is inside mcpp's store, AND the toolset directory is the one named. +resolved=$(echo "$out" | grep -iE "Resolved .*msvc" | head -1) +[[ -n "$resolved" ]] || { echo "FAIL: no Resolved line: $out"; exit 1; } +case "$resolved" in + *xpkgs*xim-x-msvc*) ;; + *) echo "FAIL: cl.exe is not from mcpp's store: $resolved"; exit 1 ;; +esac +case "$resolved" in + *"$TOOLSET"*) ;; + *) echo "FAIL: resolved toolset is not $TOOLSET: $resolved"; exit 1 ;; +esac + +out=$("$MCPP" run 2>&1) || { echo "FAIL: pinned run: $out"; exit 1; } +[[ "$out" == *"Hello"* || "$out" == *"hello"* ]] \ + || { echo "FAIL: hello output: $out"; exit 1; } + +# 3) THE REVERSE DIRECTION. Same machine, same project, spec switched back to +# msvc@system: it must resolve to the SYSTEM cl again. Without this, a +# "managed works" result is equally consistent with "managed replaced +# everything", and the system origin would be quietly gone. +sed -i "s|windows = \"msvc@$TOOLSET\"|windows = \"msvc@system\"|" mcpp.toml +out=$("$MCPP" build --verbose 2>&1) || { echo "FAIL: system build: $out"; exit 1; } +resolved=$(echo "$out" | grep -iE "Resolved .*msvc" | head -1) +[[ -n "$resolved" ]] || { echo "FAIL: no Resolved line (system): $out"; exit 1; } +case "$resolved" in + *xpkgs*xim-x-msvc*) + echo "FAIL: msvc@system resolved to the PAYLOAD — the origins leak: $resolved" + exit 1 ;; +esac + +# 4) a toolset mcpp installed is removable — the other half of the message +# `toolchain remove msvc` prints. +cd "$TMP" +out=$("$MCPP" toolchain remove "msvc@$TOOLSET" 2>&1) \ + || { echo "FAIL: remove pinned toolset: $out"; exit 1; } +[[ "$out" == *"Removed"* ]] || { echo "FAIL: remove message: $out"; exit 1; } + +echo "PASS: msvc@$TOOLSET installs, builds, stays distinct from msvc@system, and removes" diff --git a/tests/e2e/95_msvc_system_toolchain.sh b/tests/e2e/95_msvc_system_toolchain.sh index 9e67a485..12dd5e6f 100755 --- a/tests/e2e/95_msvc_system_toolchain.sh +++ b/tests/e2e/95_msvc_system_toolchain.sh @@ -4,8 +4,10 @@ # - `toolchain default msvc` locates + identifies the system MSVC and # persists the stable spec msvc@system # - `toolchain list` shows the detected MSVC in a System section, starred -# - version pin-verify: `msvc@99` mismatches the detected install -# - `toolchain remove/install msvc`: mcpp never manages MSVC itself +# - a VERSIONED msvc spec is NOT this origin: it is a payload, and an +# absent one says so instead of silently using the machine's compiler +# - the retired `msvc@` spelling names both replacements +# - `toolchain remove/install msvc`: mcpp never manages the machine's VS set -e # This test flips the global default toolchain; save + restore it so later @@ -43,18 +45,47 @@ out=$("$MCPP" toolchain list 2>&1) echo "$out" | grep -E '\*\s*msvc' >/dev/null \ || { echo "FAIL: msvc row not starred as default: $out"; exit 1; } -# 3) version pin-verify: an impossible major must mismatch -rc=0; out=$("$MCPP" toolchain default msvc@99 2>&1) || rc=$? -[[ $rc -ne 0 ]] || { echo "FAIL: msvc@99 should mismatch"; exit 1; } -[[ "$out" == *"requested"* ]] || { echo "FAIL: mismatch message: $out"; exit 1; } +# 3) a VERSIONED spec is a PAYLOAD, not this origin. +# +# This is the load-bearing assertion of the whole split: a toolset that is +# not installed must FAIL. Falling back to the machine's compiler is +# exactly the silent substitution the version axis exists to prevent, and +# it would look like success from the outside. +rc=0; out=$("$MCPP" toolchain default msvc@14.0.99999 2>&1) || rc=$? +[[ $rc -ne 0 ]] || { echo "FAIL: an absent toolset must not resolve: $out"; exit 1; } +[[ "$out" == *"not installed"* ]] \ + || { echo "FAIL: absent-toolset message: $out"; exit 1; } +# …and it must not have quietly become the default. +out=$("$MCPP" toolchain list 2>&1) +echo "$out" | grep -E '\*\s*msvc' >/dev/null \ + || { echo "FAIL: default was disturbed by a failed pin: $out"; exit 1; } + +# 3b) the retired spelling. `msvc@19.x` used to mean "use the system MSVC and +# verify its banner" — checked here and silently ignored by builds. It now +# names a toolset, so this machine's own cl version has to say so and +# point at both replacements. +CLVER=$("$MCPP" toolchain default msvc 2>&1 | grep -oE 'msvc 19\.[0-9]+' | head -1 | cut -d' ' -f2) +if [[ -n "$CLVER" ]]; then + rc=0; out=$("$MCPP" toolchain default "msvc@$CLVER" 2>&1) || rc=$? + [[ $rc -ne 0 ]] || { echo "FAIL: msvc@$CLVER should not resolve"; exit 1; } + [[ "$out" == *"COMPILER version"* ]] \ + || { echo "FAIL: no cl-version signpost: $out"; exit 1; } + [[ "$out" == *"msvc@system"* ]] \ + || { echo "FAIL: signpost omits msvc@system: $out"; exit 1; } +fi -# 4) mcpp never manages the MSVC installation +# 4) mcpp never manages the machine's own Visual Studio rc=0; out=$("$MCPP" toolchain remove msvc 2>&1) || rc=$? -[[ $rc -ne 0 && "$out" == *"system toolchain"* ]] \ +[[ $rc -ne 0 && "$out" == *"cannot remove it"* ]] \ || { echo "FAIL: remove msvc: rc=$rc out=$out"; exit 1; } +# …but it must say that a toolset it DID install is removable, or the message +# leaves the reader thinking msvc is simply un-removable. +[[ "$out" == *"msvc@"* ]] \ + || { echo "FAIL: remove msvc omits the managed route: $out"; exit 1; } +"$MCPP" toolchain default msvc >/dev/null 2>&1 out=$("$MCPP" toolchain install msvc 2>&1) \ || { echo "FAIL: install msvc (present) should exit 0: $out"; exit 1; } -[[ "$out" == *"already installed"* ]] \ +[[ "$out" == *"does not manage it"* ]] \ || { echo "FAIL: install msvc message: $out"; exit 1; } # 5) native cl.exe builds WORK (0.0.90; the 0.0.88 gate is gone) — the full diff --git a/tests/unit/test_distribution.cpp b/tests/unit/test_distribution.cpp index f9f08b6d..bbb089a3 100644 --- a/tests/unit/test_distribution.cpp +++ b/tests/unit/test_distribution.cpp @@ -215,32 +215,62 @@ TEST(Distribution, MingwParity) { EXPECT_EQ(dist::resolve(in).unitFlags, " -static"); } -// MSVC's self-contained form would be the /MT runtime, which mcpp does not -// emit. Before the table this cell simply produced nothing and claimed -// success; now it names the gap. -TEST(Distribution, MsvcSelfContainedIsAnHonestGap) { +// MSVC's self-contained form IS the /MT runtime, and mcpp emits it — the +// switch is `msvcStaticCrt`, derived once by `msvc_wants_static_crt` from the +// two manifest keys that mean the same physical thing on this ABI. +// +// What the table must get right is that the switch is whole-PROJECT: cl bakes +// _MSVC_MT/_MSVC_MD into the one std module a project builds, so a per-role +// request that disagrees cannot be honoured and must say so. +TEST(Distribution, MsvcCrtModelIsWholeProjectAndReportedAsSuch) { dist::MechanismInput in; in.format = dist::Format::Pe; in.stdlibId = "msvc"; in.explicitRequest = true; - auto m = dist::resolve(in); - EXPECT_EQ(m.effective, dist::Contract::HostCoupled); - EXPECT_TRUE(m.degraded); - EXPECT_NE(m.diagnostic.find("/MT"), std::string::npos); - EXPECT_TRUE(m.unitFlags.empty()); + + // Project compiled /MT: self-contained is DELIVERED, not degraded. + in.requested = dist::Contract::SelfContained; + in.msvcStaticCrt = true; + auto served = dist::resolve(in); + EXPECT_EQ(served.effective, dist::Contract::SelfContained); + EXPECT_FALSE(served.degraded); + EXPECT_TRUE(served.diagnostic.empty()); + // The CRT model is a COMPILE flag on every TU, never a link-line addition. + EXPECT_TRUE(served.unitFlags.empty()); + + // Project compiled /MD, one role asking for self-contained: refused, and + // the message has to name the whole-project constraint rather than claim + // the feature is missing. + in.msvcStaticCrt = false; + auto refused = dist::resolve(in); + EXPECT_EQ(refused.effective, dist::Contract::HostCoupled); + EXPECT_TRUE(refused.degraded); + EXPECT_NE(refused.diagnostic.find("whole-project"), std::string::npos) + << refused.diagnostic; + EXPECT_NE(refused.diagnostic.find("cxx_runtime"), std::string::npos) + << refused.diagnostic; + EXPECT_TRUE(refused.unitFlags.empty()); + + // `linkage = "static"` is the same switch seen from the libc axis. + in.fullStaticLibc = true; + in.msvcStaticCrt = true; // as msvc_wants_static_crt would report it + EXPECT_EQ(dist::resolve(in).effective, dist::Contract::SelfContained); + in.fullStaticLibc = false; + in.msvcStaticCrt = false; in.requested = dist::Contract::HostCoupled; EXPECT_FALSE(dist::resolve(in).degraded); - // ...but the DEFAULT must be quiet. mcpp never promised a self-contained - // MSVC artifact — it emits no /MT at all — so warning on every Windows - // build would be unactionable noise. A diagnostic is for a broken - // promise, not for a platform limit nobody asked about. + // ...and the DEFAULT must be quiet. Most roles default to the + // self-contained contract, so a project that never mentioned the CRT gets + // /MD and no complaint: a diagnostic is for a broken promise, not for a + // default nobody asked about. in.requested = dist::Contract::SelfContained; in.explicitRequest = false; auto quiet = dist::resolve(in); EXPECT_FALSE(quiet.degraded); EXPECT_TRUE(quiet.diagnostic.empty()); + EXPECT_EQ(quiet.effective, dist::Contract::HostCoupled); } // --------------------------------------------------------------------------- diff --git a/tests/unit/test_toolchain_msvc.cpp b/tests/unit/test_toolchain_msvc.cpp index af6a25e9..d5fc30a8 100644 --- a/tests/unit/test_toolchain_msvc.cpp +++ b/tests/unit/test_toolchain_msvc.cpp @@ -1,4 +1,5 @@ #include +#include // setenv / _putenv_s — not part of `import std;` import std; import mcpp.toolchain.model; @@ -56,18 +57,24 @@ TEST(MsvcBanner, TripleForArch) { // ─── install guidance ──────────────────────────────────────────────────── -TEST(MsvcGuidance, MentionsInstallRoutesAndOwnership) { +TEST(MsvcGuidance, OffersBothOrigins) { auto g = msvc::install_guidance(); ASSERT_FALSE(g.empty()); - EXPECT_NE(g.find("winget"), std::string::npos); - EXPECT_NE(g.find("does not install"), std::string::npos); - EXPECT_NE(g.find("mcpp toolchain default msvc"), std::string::npos); + // The managed origin has to be reachable from the message a user sees + // when nothing is installed — otherwise "mcpp can install a toolset" is + // true and undiscoverable at the same time. + EXPECT_NE(g.find("mcpp toolchain install msvc"), std::string::npos) << g; + EXPECT_NE(g.find("[toolchain]"), std::string::npos) << g; + // …and the system origin stays offered, with its own route. + EXPECT_NE(g.find("winget"), std::string::npos) << g; + EXPECT_NE(g.find("msvc@system"), std::string::npos) << g; + EXPECT_NE(g.find("mcpp toolchain default msvc"), std::string::npos) << g; } -// ─── spec layer ────────────────────────────────────────────────────────── +// ─── spec layer: the VERSION axis decides the origin ───────────────────── -TEST(MsvcSpec, SystemToolchainClassification) { - for (auto s : {"msvc", "msvc@system", "msvc@19.44"}) { +TEST(MsvcSpec, SystemOriginIsTheUnversionedSpec) { + for (auto s : {"msvc", "msvc@system"}) { auto spec = parse_toolchain_spec(s); ASSERT_TRUE(spec.has_value()) << s; EXPECT_TRUE(is_system_toolchain(*spec)) << s; @@ -77,6 +84,20 @@ TEST(MsvcSpec, SystemToolchainClassification) { EXPECT_FALSE(is_system_toolchain(*gcc)); } +TEST(MsvcSpec, ToolsetVersionIsAManagedPayloadNotASystemSpec) { + // The defect this closes: EVERY msvc spec used to be a system spec, so a + // manifest could name a toolset and silently get whatever the machine + // had. A version means a payload, exactly like gcc@16.1.0. + for (auto s : {"msvc@14.44.35207", "msvc@14.52.36629"}) { + auto spec = parse_toolchain_spec(s); + ASSERT_TRUE(spec.has_value()) << s; + EXPECT_FALSE(is_system_toolchain(*spec)) << s; + auto pkg = to_xim_package(*spec); + EXPECT_EQ(pkg.ximName, "msvc") << s; + EXPECT_EQ(pkg.ximVersion, std::string(s).substr(5)) << s; + } +} + TEST(MsvcSpec, StableDefaultMatchesAnyDetectedVersion) { // The persisted default is always the stable "msvc@system" — it matches // whatever concrete version detection reports (family-level match). @@ -92,6 +113,229 @@ TEST(MsvcSpec, StableDefaultMatchesAnyDetectedVersion) { EXPECT_FALSE(spec_matches_payload(*gccDef, msvcId, "19.44.35211")); } +TEST(MsvcSpec, PinnedToolsetMatchesOnlyItsOwnPayload) { + // The other half of the family-level match above: once a version is + // named, it has to be compared. A pin that matched any payload would be + // the same silent divergence wearing a version number. + auto pinned = parse_toolchain_spec("msvc@14.52.36629"); + ASSERT_TRUE(pinned.has_value()); + PayloadIdentity msvcId{ Family::Msvc, {} }; + EXPECT_TRUE(spec_matches_payload(*pinned, msvcId, "14.52.36629")); + EXPECT_FALSE(spec_matches_payload(*pinned, msvcId, "14.44.35207")); +} + +TEST(MsvcSpec, PayloadDirectoryIsIdentifiedAsMsvc) { + // Installed payloads are listed by walking `xim-x-` directories. + // Without this row an install would succeed and then be invisible. + auto id = identify_xim_payload("msvc"); + ASSERT_TRUE(id.has_value()); + EXPECT_EQ(id->family, Family::Msvc); + EXPECT_TRUE(id->target.empty()); // host-target, like gcc and llvm +} + +// ─── managed origin: resolution from a payload ─────────────────────────── +// +// These run on every platform, and that is the point. The managed path used +// to be untestable off Windows because every entry point started by probing +// the machine; `installation_at` takes the directory instead, so a fixture +// tree exercises the same code a real payload does. + +namespace { + +// A payload-shaped tree: /VC/Tools/MSVC//{bin/Hostx64/x64/cl.exe, +// modules/std.ixx}. Exactly what xim:msvc unpacks, minus the 80 MB. +struct FakeToolset { + std::filesystem::path root; + + explicit FakeToolset(std::string_view tag) { + root = std::filesystem::temp_directory_path() + / std::format("mcpp-msvc-{}-{}", tag, + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root); + } + ~FakeToolset() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + FakeToolset(const FakeToolset&) = delete; + FakeToolset& operator=(const FakeToolset&) = delete; + + void add_toolset(std::string_view version, bool withStdIxx = true) { + auto tools = root / "VC" / "Tools" / "MSVC" / std::string(version); + std::filesystem::create_directories(tools / "bin" / "Hostx64" / "x64"); + std::ofstream{tools / "bin" / "Hostx64" / "x64" / "cl.exe"} << "not a compiler"; + if (withStdIxx) { + std::filesystem::create_directories(tools / "modules"); + std::ofstream{tools / "modules" / "std.ixx"} << "export module std;"; + } + } + void add_sdk(std::string_view version) { + auto inc = root / "Include" / std::string(version) / "ucrt"; + std::filesystem::create_directories(inc); + std::ofstream{inc / "corecrt.h"} << "#pragma once"; + } +}; + +void put_env(const char* name, const std::optional& value) { +#if defined(_WIN32) + // An empty value REMOVES the variable on Windows, which is what nullopt + // has to mean here. + ::_putenv_s(name, value ? value->c_str() : ""); +#else + if (value) ::setenv(name, value->c_str(), 1); + else ::unsetenv(name); +#endif +} + +// RAII for an environment variable. nullopt = unset it. +// +// Unsetting matters as much as setting: these tests run on a Windows CI +// runner that may have vcvars exported, and an ambient WindowsSdkDir would +// otherwise answer before the fixture ever got a turn — a green test proving +// nothing about the code under it. +struct ScopedEnv { + const char* name; + std::optional old; + ScopedEnv(const char* n, std::optional value) : name(n) { + if (auto* v = std::getenv(name)) old = v; + put_env(name, value); + } + ~ScopedEnv() { put_env(name, old); } + ScopedEnv(const ScopedEnv&) = delete; + ScopedEnv& operator=(const ScopedEnv&) = delete; +}; + +// Every SDK test starts from "nothing declared", then declares what it means +// to test. +struct NoSdkEnv { + ScopedEnv dir{"WindowsSdkDir", std::nullopt}; + ScopedEnv ver{"WindowsSdkVersion", std::nullopt}; +}; + +} // namespace + +TEST(MsvcManaged, ResolvesTheDeclaredToolsetAndNotItsNeighbour) { + FakeToolset t{"pick"}; + t.add_toolset("14.44.35207"); + t.add_toolset("14.52.36629"); + + // Both present, and the OLDER one is asked for. A "latest" scan would + // answer 14.52 — the exact substitution the managed origin exists to + // prevent. + auto older = msvc::installation_at(t.root, "14.44.35207"); + ASSERT_TRUE(older.has_value()); + EXPECT_EQ(older->toolsVersion, "14.44.35207"); + EXPECT_TRUE(older->hasStdModules); + EXPECT_EQ(older->clPath.filename(), "cl.exe"); + EXPECT_NE(older->clPath.string().find("14.44.35207"), std::string::npos); + + auto newer = msvc::installation_at(t.root, "14.52.36629"); + ASSERT_TRUE(newer.has_value()); + EXPECT_EQ(newer->toolsVersion, "14.52.36629"); +} + +TEST(MsvcManaged, AbsentToolsetIsNulloptNotASubstitute) { + FakeToolset t{"absent"}; + t.add_toolset("14.44.35207"); + // Nothing is "close enough": a pin that silently fell back would report + // success while building with a compiler nobody asked for. + EXPECT_FALSE(msvc::installation_at(t.root, "14.52.36629").has_value()); + EXPECT_FALSE(msvc::installation_at(t.root, "14.44").has_value()); + EXPECT_FALSE(msvc::installation_at(t.root, "").has_value()); +} + +TEST(MsvcManaged, VersionFallsBackToTheDeclaredOneWhenTheBannerCannotBeRead) { + // A fixture cl.exe produces no banner (and on Windows a real one would). + // display_version() must still name the toolset rather than an empty + // string — the declared version is a fact even when the probe fails. + FakeToolset t{"banner"}; + t.add_toolset("14.52.36629"); + auto inst = msvc::installation_at(t.root, "14.52.36629"); + ASSERT_TRUE(inst.has_value()); + EXPECT_EQ(inst->display_version(), "14.52.36629"); +} + +// ─── Windows SDK discovery ─────────────────────────────────────────────── + +TEST(MsvcSdk, WindowsSdkDirBeatsTheHardcodedPaths) { + NoSdkEnv clean; + FakeToolset t{"sdkenv"}; + t.add_sdk("10.0.26100.0"); + ScopedEnv dir{"WindowsSdkDir", t.root.string()}; + auto sdk = msvc::find_windows_sdk(); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->root, t.root); + EXPECT_EQ(sdk->version, "10.0.26100.0"); +} + +TEST(MsvcSdk, WindowsSdkVersionSelectsAmongInstalledOnes) { + NoSdkEnv clean; + FakeToolset t{"sdkver"}; + t.add_sdk("10.0.22621.0"); + t.add_sdk("10.0.26100.0"); + ScopedEnv dir{"WindowsSdkDir", t.root.string()}; + { // vcvars exports it with a trailing backslash; that is not part of + // the directory name, and comparing it raw finds nothing. + ScopedEnv ver{"WindowsSdkVersion", "10.0.22621.0\\"}; + auto sdk = msvc::find_windows_sdk(); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->version, "10.0.22621.0"); + } + // Unset again: highest usable wins. + auto sdk = msvc::find_windows_sdk(); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->version, "10.0.26100.0"); +} + +TEST(MsvcSdk, ExtraRootsCoverTheManagedPayload) { + NoSdkEnv clean; + FakeToolset t{"sdkextra"}; + t.add_sdk("10.0.26100.0"); + // Nothing declared — the payload root is the only way the SDK can be + // found, which is exactly the managed toolset's situation. + std::array roots{t.root}; + auto sdk = msvc::find_windows_sdk(roots); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->root, t.root); +} + +TEST(MsvcSdk, IncompleteSdkRootIsNotAnAnswer) { + // Include// exists but carries no ucrt/corecrt.h: a half-installed + // SDK must read as absent, not as a usable one that fails later inside + // the compiler. Checked through extraRoots so the assertion cannot be + // satisfied by a real SDK on the machine. + NoSdkEnv clean; + FakeToolset t{"sdkpartial"}; + std::filesystem::create_directories(t.root / "Include" / "10.0.26100.0" / "um"); + std::array roots{t.root}; + auto sdk = msvc::find_windows_sdk(roots); + if (sdk) EXPECT_NE(sdk->root, t.root) << "an SDK-less root was accepted"; +} + +TEST(MsvcSdk, DeclaredRootOutranksTheManagedOne) { + // Both available. WindowsSdkDir is someone saying which one to use, and + // it has to win — the same precedence VSINSTALLDIR has over vswhere. + NoSdkEnv clean; + FakeToolset declared{"sdkdeclared"}; + declared.add_sdk("10.0.22621.0"); + FakeToolset managed{"sdkmanaged"}; + managed.add_sdk("10.0.26100.0"); // newer, and still must not win + ScopedEnv dir{"WindowsSdkDir", declared.root.string()}; + std::array roots{managed.root}; + auto sdk = msvc::find_windows_sdk(roots); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->root, declared.root); + EXPECT_EQ(sdk->version, "10.0.22621.0"); +} + +TEST(MsvcSdk, SiblingSdkRootsAreEmptyForACompilerOutsideAnyStore) { + // A system cl.exe is not in an xlings store, so there is nothing to + // offer — and offering the wrong thing would be worse than nothing. + EXPECT_TRUE(msvc::sibling_sdk_roots( + "C:/Program Files/Microsoft Visual Studio/18/Enterprise/VC/Tools/" + "MSVC/14.51.36231/bin/Hostx64/x64/cl.exe").empty()); +} + // ─── model traits ──────────────────────────────────────────────────────── TEST(MsvcModel, BmiTraitsUseIfc) { @@ -185,8 +429,8 @@ TEST(ToolchainMsvc, CrtFlagHasASingleDerivation) { Toolchain cl; cl.compiler = CompilerId::MSVC; const auto& msvcDialect = dialect_for(cl); - EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticLinkage=*/true), "/MT"); - EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticLinkage=*/false), "/MD"); + EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticCrt=*/true), "/MT"); + EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticCrt=*/false), "/MD"); // GNU has no counterpart; the helper must yield nothing rather than invent // a flag that would be passed to gcc. @@ -195,3 +439,22 @@ TEST(ToolchainMsvc, CrtFlagHasASingleDerivation) { const auto& gnu = dialect_for(gcc); EXPECT_TRUE(msvc_crt_flag(gnu, false).empty()); } + +// Both manifest keys reach the SAME physical switch, because on the MSVC ABI +// they are the same switch. `cxx_runtime = "self-contained"` used to report +// "not implemented" while `linkage = "static"` quietly did the very thing it +// said was unimplemented. +TEST(ToolchainMsvc, BothKeysSelectTheStaticCrt) { + EXPECT_TRUE(msvc_wants_static_crt("static", "")); + EXPECT_TRUE(msvc_wants_static_crt("", "self-contained")); + EXPECT_TRUE(msvc_wants_static_crt("static", "self-contained")); + + // Default: neither written down. This has to stay /MD — most roles + // DEFAULT to the self-contained contract, so keying off the resolved + // contract instead of the written key would flip every Windows build to + // /MT and split the CRT across a project's dependencies. + EXPECT_FALSE(msvc_wants_static_crt("", "")); + EXPECT_FALSE(msvc_wants_static_crt("dynamic", "")); + EXPECT_FALSE(msvc_wants_static_crt("", "host-coupled")); + EXPECT_FALSE(msvc_wants_static_crt("", "toolchain-coupled")); +}