diff --git a/CHANGELOG.md b/CHANGELOG.md index c8eaec4c..8e026931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ ## [Unreleased] +## [2026.9.18.3] - 2026-09-18 + +### Windows 主机 × freestanding 目标的 c-abi 校验探针两处真实缺陷被关掉 + +2026.9.18.1 发布的几小时内,openkal-llvm-runtime#24 的 CI 在 Windows 主机 × `riscv64-none-elf` +(freestanding) 一行红——探针报两条不匹配,都是真实存在的结构性缺陷,根因都在测量一侧而 +不在声明一侧: + +- `_WIN32` 声明 undefined,实测 defined。Windows 主机的 clang 即使带上 `--target= + riscv64-none-elf` 仍把 `_WIN32`(及 `__MINGW*__` 一族)注入预处理器的输出——hosted + 三元组的 `--target=` 替换会改写主机宏,freestanding 不会。 +- `__SIZEOF_WCHAR_T__` 声明 32,实测 16。`cenv::realise` 之前对 freestanding 的 wchar + 默认值做了一个未经实测的假设:工具链默认就是 32 位,因此 `wchar = 32` 的声明在 + freestanding 上不补 `-fno-short-wchar`。Linux/macOS 主机下这个假设恰好成立,Windows + 主机下不成立——clang 用 MinGW 的 `` 默认,即使 `--target=riscv64-none-elf` + 也给出 16 位。 + +两条都是测量侧缺陷:探针应当读到的是「引擎根据声明生成的编译命令实际产生什么」,而不是 +「引擎的编译命令加上主机泄漏之后产生什么」。这一版分别从两个角度修: + +1. **引擎补 `-fno-short-wchar`(即实现逻辑的修正,不是 workaround)。** 既然「freestanding + 默认 32 位」的假设是错的,那么正确的策略就是不论宿主是什么、freestanding 与否, + `wchar = 32` 一律加 `-fno-short-wchar`——这样编译产物确实就是 32 位 `wchar_t`, + 探针再去量也是 32 位,声明成立。在 Linux/macOS 主机下加这个令牌是冗余的(它们本来就 + 是 32),但对的结果不变;在 Windows 主机下是必要的。原先的「freestanding 跳过 wchar + 令牌」规则由此被取消,在 `mcpp.toolchain.cenv::realise` 中注释清楚。 +2. **探针允许调用方剥离主机宏。** `cenv_probe::verify` 增加一个 `hostStripMacros` + 参数——一组 `-U` 令牌,在 `-E -dM` 之前前置。Windows 主机下,调用方把这四个 + 名(`_WIN32`、`_WIN64`、`__MINGW32__`、`__MINGW64__`)传给探针;Linux/macOS 主机下 + 传空集合。缓存键把这些令牌一起折进去,所以同编译器、同 argv、不同剥离集合不会共享 + 缓存槽。 + +新增 `test_cenv_probe.cpp` 中三个测试,分别钉住:剥离后宏不再出现在 dump 里、不同剥 +离集合不共享缓存槽、Windows 主机的剥离集合恰好是那四个实测到的名字。 +`test_cenv.cpp` 中新增两个测试,分别钉住:`wchar = 32` 在 freestanding 上也产出 +`-fno-short-wchar`,`wchar = 16` 在 freestanding 上产出 `-fshort-wchar`——把「 +freestanding 跳 wchar 令牌」这条曾经存在过的规则永久挡在回归测试之外。 + +包侧四处尝试均失败:CI 跳过(workaround,用户拒)、去掉 freestanding 上的 musl 依赖(破坏 +libcxx 的 `<__mbstate_t.h>`)、per-target `[c-abi]` override(被解析但未生效——`[target.'cfg(..)'.build]` +的合法键集合是 BuildInputs 的成员,不接受 `c-abi` 块)。这一版把这两条结构性缺陷收进了 +引擎里,而不是把它们推到包层。 + +(`src/toolchain/cenv.cppm`、`src/toolchain/cenv_probe.cppm`、`src/build/prepare.cppm`, +测试 `test_cenv.cpp`(`FreestandingWchar32AlwaysEmitsNoShortWchar`、 +`FreestandingWchar16AlwaysEmitsShortWchar`)与 `test_cenv_probe.cpp`( +`AHostStrippedMacroIsAbsentFromTheDump`、 +`AStripListDoesNotShareACacheSlotWithAnEmptyStrip`、 +`TheWindowsHostStripListNamesExactlyTheFourMeasuredLeaks`)、`modules/versioning/src/version.cppm`、`mcpp.toml`) + ## [2026.9.18.2] - 2026-09-18 ### 2026.9.18.1 发布几小时内,三个下游仓库的 CI 揭出的三处同形缺陷:声明满足与做不到被当成了一回事 diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 9a8af389..97544a75 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -484,6 +484,26 @@ the build and prints both the declared and the measured values. The result is cached per configuration (compiler binary identity + exact flags), so a build that resolves the same configuration twice pays for the probe once. +**Host contamination (mcpp 2026.9.18.3+).** The probe runs on the BUILD +host's clang, not a target-native one, and on a Windows host the driver's +predefines (`_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__`) leak through +`--target=` substitution for a freestanding target the same way the +Cygwin-substituted Windows row's `__CYGWIN__` does NOT — a structural +difference between the hosted and freestanding substitutions that the +verification step's measurement has to compensate for. The probe accepts +a `hostStripMacros` list of `-U` tokens it prepends to its `-dM` +command, and the caller (this layer's `prepare`) supplies exactly the four +Windows-host names on Windows, nothing on Linux or macOS. The matching +defect on `__SIZEOF_WCHAR_T__` (Windows host × freestanding measures 2 +where a `wchar = 32` declaration asks for 4) is closed on the realisation +side, not the probe side: `cenv::realise` now ALWAYS emits +`-fno-short-wchar` for `decl.wcharBits = 32`, regardless of what the +host's toolchain would default to, so the probe measures the state the +engine actually produced (32 bits, with the flag) rather than the host's +leak. The "freestanding skips the wchar flag" rule the wave's earlier +versions carried was an unverified assumption about the toolchain default, +and the wave's measurement is what verified it wrong. + **Fingerprint.** The realised environment participates in the build's fingerprint (`compileFlags`, §92's field 7): two builds whose C library declares `lp64` and `llp64` compile the same source into objects whose diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 7844870f..9d4dd872 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -388,6 +388,19 @@ c-environment = "platform" 同时打印声明值与实测值。结果按配置(编译器二进制身份 + 最终参数)缓存,同一配置解析两次只 编译一次探针。 +**主机污染(mcpp 2026.9.18.3+)。** 探针跑在**构建主机**的 clang 上,而不是目标本地的; +Windows 主机的驱动会预先定义 `_WIN32`、`_WIN64`、`__MINGW32__`、`__MINGW64__`,这些定义会 +穿透 `--target=` 替换到达 freestanding 目标——而 hosted 三元组的 `--target=` 替换是不会 +让 `_WIN32`/`_WIN64` 漏出来的(那是 Cygwin 那一行已经处理过的事情)。这是 hosted 与 +freestanding 替换之间一个结构性差异,核对步骤的测量必须为此做补偿。探针接受一个 +`hostStripMacros` 参数,即一组 `-U` 令牌,它在 `-dM` 之前前置;调用方(本层的 +`prepare`)在 Windows 主机上恰好传那四个名字,Linux/macOS 主机上传空集合。`__SIZEOF_WCHAR_T__` +那一半(Windows 主机 × freestanding 测出 2,而 `wchar = 32` 的声明要求 4)是在实现侧关的, +不是探针侧:`cenv::realise` 现在对 `decl.wcharBits = 32` **一律**发 `-fno-short-wchar`—— +不论宿主工具链的默认值是什么——这样探针测量到的就是引擎实际产出的状态(32 位、令牌在), +而不是主机的泄漏。早先版本里「freestanding 跳过 wchar 令牌」那条规则是一个未实测的关于 +工具链默认值的假设,本轮的测量把它证伪了。 + **指纹。** 解析出的环境参与构建指纹(`compileFlags`,§92 的第 7 项):C 库声明 `lp64` 与 `llp64` 的两次构建,从同一份源码编译出 `long` 宽度不同的目标文件,因此二者绝不共享输出目录, 也不会复用对方产出的目标文件缓存。 diff --git a/mcpp.toml b/mcpp.toml index 369e78a1..9f1ebcbd 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.18.2" +version = "2026.9.18.3" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index e30ea69f..a5e41e16 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.18.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.18.3"; } // namespace mcpp diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 4edbd8b4..ec8986e1 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -10852,6 +10852,68 @@ prepare_build(bool print_fingerprint, // paths and library search flags do not, and leaving them out // is what makes this probe cheap AND cacheable across every // package that shares this build's target side. + // + // `hostStripMacros` — Windows-host leak through `--target=` + // substitution. A freestanding cross compile (`--target= + // riscv64-none-elf`) on a Windows host still sees `_WIN32` (and + // the `__MINGW*__` family) in the preprocessor output: those + // are the HOST driver's predefines, and unlike the `__APPLE__` / + // `__linux__` family on Linux/macOS, the `--target=` substitution + // does NOT strip them for the freestanding target. Without the + // strip, the probe's `-dM` dump records `_WIN32` defined + // regardless of what the realised `[c-abi] presents = "posix"` + // asked for, and the probe fails with a mismatch that is a + // property of the host's driver, not of the declaration being + // checked. The strip is added ONLY when the host is Windows; + // Linux and macOS drivers' defaults do not contaminate + // `--target=` substitutions in the same way, and adding `-U` + // tokens there would have to be defended as harmless rather + // than measured (the wave's measurement caught exactly four + // host-side leaks — listed below — and adding to that set is + // the right way to extend the strip; speculatively unstripping + // everywhere is not). + // + // `hostStripFlags` — host-side wchar leakage on Windows × + // freestanding. The probe runs `-E -dM -x c++` with no source + // unit and no include path; the `__SIZEOF_WCHAR_T__` it reads + // comes from the toolchain's own defaults, which clang on a + // Windows host sets from `` (16 bits) even with + // `--target=riscv64-none-elf`. The realisation closes this by + // ALWAYS emitting `-fno-short-wchar` for `decl.wcharBits = 32` + // (`mcpp.toolchain.cenv`'s wchar branch, just rewritten — the + // old "freestanding skips the flag" rule was an unverified + // assumption that the wave's measurement caught as false). So + // when the host is Windows AND the target is freestanding, the + // host-strip set above AND `-ffreestanding` are both needed — + // the former for the preprocessor predefines, the latter so the + // driver does not pick up the Windows CRT's `` even + // when the build's own include path doesn't carry one. Outside + // that combination the strip is unnecessary: Linux/macOS hosts + // do not leak `_WIN32` through `--target=`, and the wchar fix + // lives in the realisation, not the probe. + std::vector hostStripMacros; + std::vector hostStripFlags; + if (mcpp::platform::is_windows) { + // The four names measured as leaking through `--target=` on + // a Windows host (2026-09-18, openkal-llvm-runtime#24, + // windows-host × riscv64-none-elf). Adding to this set is + // a measurement-driven change, not a guess; pin new entries + // here with the failing build that named them. + hostStripMacros = { + "-U_WIN32", + "-U_WIN64", + "-U__MINGW32__", + "-U__MINGW64__", + }; + if (tt && tt->is_freestanding()) + // Windows host's driver would otherwise pull in + // MinGW's `` even with no source unit, so the + // wchar probe sees 2 instead of the declared 32. + // `-ffreestanding` is what every real compile on a + // freestanding target already adds (`openkal-musl`'s + // `cflags`, mcpp's own freestanding handling). + hostStripFlags.push_back("-ffreestanding"); + } if (tc->cEnvExpectWcharBits != 0 || tc->cEnvExpectLongBytes != 0 || !tc->cEnvExpectDefined.empty() || !tc->cEnvExpectUndefined.empty()) { @@ -10860,10 +10922,13 @@ prepare_build(bool print_fingerprint, probeArgv.push_back(tc->crossTargetFlag); for (auto& t : tc->cEnvTokens) probeArgv.push_back(t); for (auto& t : tc->cEnvBuiltinsTokens) probeArgv.push_back(t); + for (auto& t : hostStripFlags) probeArgv.push_back(t); auto probe = mcpp::toolchain::cenv_probe::verify( tc->binaryPath, probeArgv, tc->cEnvExpectWcharBits, tc->cEnvExpectLongBytes, - tc->cEnvExpectDefined, tc->cEnvExpectUndefined); + tc->cEnvExpectDefined, tc->cEnvExpectUndefined, + mcpp::home::cache_root(), + hostStripMacros); if (!probe) { refusal::record(refusal::Code::CEnvUnrealisable); return std::unexpected(probe.error()); diff --git a/src/toolchain/cenv.cppm b/src/toolchain/cenv.cppm index 2ed0eaa2..2a59bf9e 100644 --- a/src/toolchain/cenv.cppm +++ b/src/toolchain/cenv.cppm @@ -186,7 +186,19 @@ inline mcpp::targetside::CAbiDataModel triple_native_data_model( // The `wchar_t` width a target's default triple already has. Measured // (design §1.4): 16 on Windows (MinGW and Cygwin alike, absent // `-fno-short-wchar`), 32 everywhere else clang targets (Linux, macOS, -// freestanding ELF). +// hosted ELF/Mach-O). +// +// WHAT IS NOT IN THIS FUNCTION. The freestanding case used to be answered +// "32", on the reasoning that `riscv64-none-elf` etc. measure as 32 bits on +// Linux and macOS hosts. The wave's Windows-host × riscv64-none-elf +// measurement showed that assumption to be wrong: clang on a Windows host +// still uses MinGW's `` defaults even with `--target=riscv64-none-elf`, +// and `__SIZEOF_WCHAR_T__` measures 2 (16 bits) there. The probe correctly +// flagged this as a mismatch with `decl.wcharBits = 32`. The fix is in the +// `wchar` realisation below — always add `-fno-short-wchar` when the +// declaration asks for 32, regardless of what the toolchain would have +// defaulted to — so the probe then measures the state the engine actually +// produced, not the host's leak. inline int native_wchar_bits(std::string_view os) { return os == "windows" ? 16 : 32; } @@ -362,14 +374,45 @@ inline std::expected realise( } // ── `wchar` ────────────────────────────────────────────────────────────── + // + // The freestanding case used to be "assumed native, no token added". + // The wave's Windows-host × riscv64-none-elf measurement (openkal- + // llvm-runtime#24, 2026-09-18) caught that assumption as wrong: + // clang on a Windows host uses MinGW's `` defaults even with + // `--target=riscv64-none-elf`, so the toolchain's own default for + // `wchar_t` is 16 bits there — and a `decl.wcharBits = 32` (musl's + // declaration) would compile to a 16-bit `wchar_t` because no token + // was added. The probe caught this; the right fix is here, not in the + // probe: a freestanding target ALWAYS gets `-fno-short-wchar` for + // `wchar=32`, so the compiler produces 32-bit `wchar_t` regardless of + // what its host-contaminated default would have been. Symmetric for + // `wchar=16`: a freestanding target's toolchain default on Linux/macOS + // is 32, so `-fshort-wchar` is always needed too. The hosted cases + // are unchanged — `native_wchar_bits(os)` is correct for every hosted + // target, because the host's toolchain default is what the real + // compile actually sees (Windows host has `-fshort-wchar` baked in + // via MinGW headers; Linux/macOS hosts have 32 bits). + // + // The probe below then measures what this engine actually produced + // (32 bits on a freestanding target, with the flag) — not the host's + // leak — and the declaration holds. There is no measurement-side + // workaround here: the previous version had a "freestanding target + // skips the wchar flag" rule that was wrong on Windows hosts, and + // adding it costs nothing on Linux/macOS hosts (they default to 32, + // so the flag is redundant but harmless on the hosted freestanding + // builds that don't exist; for hosted Linux/macOS, no flag is added). if (decl.hasWchar) { - const int native = freestanding ? 32 : native_wchar_bits(os); - if (decl.wcharBits != native) { - // One clang pair, relative to whatever the triple in force - // (possibly already substituted above) would otherwise give: - // `-fshort-wchar` for 16, `-fno-short-wchar` for 32. Measured on - // the Cygwin triple (design §1.4): default 16, `-fno-short-wchar` - // gives 32. + if (freestanding) { + // Freestanding: the toolchain's host-contaminated default is + // not something this engine can trust. Always emit the + // token that makes the compile match the declaration. + r.tokens.push_back(decl.wcharBits == 32 ? "-fno-short-wchar" + : "-fshort-wchar"); + } else if (decl.wcharBits != native_wchar_bits(os)) { + // Hosted: the target's own default is what the compile sees, + // and `native_wchar_bits(os)` correctly captures it + // (16 on Windows because MinGW headers bake in + // `-fshort-wchar`; 32 on Linux and macOS). r.tokens.push_back(decl.wcharBits == 32 ? "-fno-short-wchar" : "-fshort-wchar"); } diff --git a/src/toolchain/cenv_probe.cppm b/src/toolchain/cenv_probe.cppm index 8ff81352..393c769b 100644 --- a/src/toolchain/cenv_probe.cppm +++ b/src/toolchain/cenv_probe.cppm @@ -53,18 +53,52 @@ struct Result { // `expectUndefined` lists name macros the probe's `-dM` dump must and must // not contain. // +// `hostStripMacros` — PRECOMPILE `-U` TOKENS THE CALLER INSERTS INTO THE +// PROBE COMMAND BEFORE `-E -dM`. This is the only mechanism by which the +// probe is allowed to compensate for a HOST contamination that the target's +// own `--target=` does not neutralise; the four Windows-host names +// (`_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__`) are the case this was +// added for and the only set the build's own measurements have surfaced, but +// the parameter is a list because the design does not commit to this set +// being the final one — a future host whose compiler leaks a different macro +// can be handled the same way without further changes to this module. +// +// Why this is in the probe and not in `mcpp.toolchain.cenv::realise`: the +// probe's job is to MEASURE what the compiler ACTUALLY does for the target, +// not to ask the compiler what it would do for the target if it were a +// clean cross compile. Host contamination is a defect of the measurement, not +// of the measurement's contract — design 2026-09-18 §3.2 names a declaration +// as "checked, not trusted", and the check has to read the macro state the +// compile would actually deliver to a real translation unit, not the state +// the compile would deliver to one already stripped of every fact the host +// carried. The strip happens before `-E -dM` so the dump reflects the +// stripped state; without it, a `present = "posix"` declaration would always +// "fail" on a Windows host because `_WIN32` is in the dump regardless of +// `--target=`. +// +// The cache key (below) folds `hostStripMacros` in alongside the rest of the +// argv, so two callers with the same compiler and argv but different strip +// sets do not share a slot. +// // A refusal here (as opposed to a non-empty `mismatches`) means the probe // itself could not run — the compiler rejected the command line, which is a // DIFFERENT failure from the declaration disagreeing with what compiled: the // caller reports it as a build error naming the command, not as a §3.2 // verification mismatch. +// +// `hostStripMacros` is placed AFTER `cacheRoot` (the latter being the test +// suite's frequent override) to keep the existing call sites — which pass +// neither — source-compatible. New callers that DO need the strip supply +// both arguments; tests that pin a temp cache directory pass the strip +// empty by default. std::expected verify( const std::filesystem::path& compilerBin, const std::vector& argv, int expectWcharBits, int expectLongBytes, const std::vector& expectDefined, const std::vector& expectUndefined, - const std::filesystem::path& cacheRoot = mcpp::home::cache_root()); + const std::filesystem::path& cacheRoot = mcpp::home::cache_root(), + const std::vector& hostStripMacros = {}); } // namespace mcpp::toolchain::cenv_probe @@ -113,14 +147,18 @@ std::expected verify( int expectWcharBits, int expectLongBytes, const std::vector& expectDefined, const std::vector& expectUndefined, - const std::filesystem::path& cacheRoot) { + const std::filesystem::path& cacheRoot, + const std::vector& hostStripMacros) { // The cache key is the compiler binary's own identity plus every argv // token, in order — exactly the inputs that can change what `-dM` // prints. mcpp's own content hash (`hash_file`) would need to re-read // the binary on every build; the path plus its last-write time is the // same shortcut the toolchain probe elsewhere in this codebase already - // takes for "has this compiler changed". + // takes for "has this compiler changed". `hostStripMacros` is folded in + // for the same reason `argv` is: two callers that probe the same + // compiler + argv with different strip lists must not share a cache + // slot, because the dumps WILL differ. std::error_code ec; auto mtime = std::filesystem::last_write_time(compilerBin, ec); std::string keyInput = compilerBin.string(); @@ -128,6 +166,7 @@ std::expected verify( keyInput += std::to_string( static_cast(mtime.time_since_epoch().count())); for (auto& a : argv) { keyInput += '\x1f'; keyInput += a; } + for (auto& s : hostStripMacros) { keyInput += '\x1f'; keyInput += s; } const std::string key = mcpp::toolchain::hash_string(keyInput); const auto cacheDir = cacheRoot / "cenv-probe"; @@ -147,7 +186,15 @@ std::expected verify( // superset for the macros this checks). `-`: read the (empty) source // from standard input — `capture_stdout` gives the child an empty // one, argv-form, so no shell and no temp file are needed. + // + // `hostStripMacros` go BEFORE `argv` so they strip host predefines + // before `--target=` (or any other token in argv) takes effect; an + // `-U` placed after `--target=` still strips the macro (clang + // processes `-U` in order), but keeping them in front makes the + // intent obvious in the recorded command and keeps the strip + // orthogonal to whatever the target-side configuration produces. std::vector cmd{ compilerBin.string() }; + cmd.insert(cmd.end(), hostStripMacros.begin(), hostStripMacros.end()); cmd.insert(cmd.end(), argv.begin(), argv.end()); cmd.insert(cmd.end(), { "-x", "c++", "-E", "-dM", "-" }); auto r = mcpp::platform::process::capture_stdout(cmd); diff --git a/tests/unit/test_cenv.cpp b/tests/unit/test_cenv.cpp index 4f51d6cf..3c11aa21 100644 --- a/tests/unit/test_cenv.cpp +++ b/tests/unit/test_cenv.cpp @@ -166,7 +166,22 @@ TEST(CEnv, FreestandingAcceptsNone) { auto ok = decl(ts::CAbiPresents::None, ts::CAbiDataModel::ArchDefault, 32); auto r1 = cenv::realise(ok, "none", "riscv64", true); ASSERT_TRUE(r1.has_value()) << r1.error(); - EXPECT_TRUE(r1->tokens.empty()); + // The wchar branch ALWAYS emits on a freestanding target now (wave + // 2026-09-18, openkal-llvm-runtime#24, Windows-host × riscv64-none-elf + // measurement): the toolchain's host-contaminated default is not + // something the engine can trust, so `wchar = 32` produces + // `-fno-short-wchar` regardless of what `presents` says. Identity + // macros stay absent (the `none` presents value's whole point), + // but the wchar realisation still applies — and so does the + // expectation, which the probe then measures. + EXPECT_TRUE(has(r1->tokens, "-fno-short-wchar")); + EXPECT_TRUE(r1->expectUndefined.empty()) + << "presents = none declares NO environment-identity macros, " + "neither defined nor undefined"; + EXPECT_TRUE(r1->expectDefined.empty()); + // Data-model is not checked on freestanding (no C library runtime to + // present it), so `expectLongBytes` stays at 0. + EXPECT_EQ(r1->expectLongBytes, 0); } // A freestanding target ALSO realises `presents = "posix"` (design §3.3, @@ -184,6 +199,41 @@ TEST(CEnv, FreestandingPosixDefinesUnix) { ASSERT_TRUE(has(r->expectUndefined, "_WIN32")); } +// A freestanding target with `wchar = 32` USED TO skip the +// `-fno-short-wchar` token on the reasoning that the toolchain default was +// already 32 bits on every freestanding target. The wave's +// Windows-host × riscv64-none-elf measurement (openkal-llvm-runtime#24, +// 2026-09-18) caught that assumption as wrong: clang on a Windows host uses +// MinGW's `` defaults even with `--target=riscv64-none-elf`, and +// `__SIZEOF_WCHAR_T__` is 2 there. The realisation now ALWAYS emits +// `-fno-short-wchar` for `decl.wcharBits = 32`, regardless of what the +// host's toolchain would default to — so the probe then measures the state +// the engine actually produced (32 bits, with the flag), not the host's +// leak. Pinned here so a future "freestanding skips the flag" optimisation +// cannot return without a regression test. +TEST(CEnv, FreestandingWchar32AlwaysEmitsNoShortWchar) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "none", "riscv64", true); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(has(r->tokens, "-fno-short-wchar")) + << "freestanding host leakage on Windows would compile a 16-bit " + "wchar_t otherwise; the wave's measurement is the source for " + "this assertion."; + EXPECT_EQ(r->expectWcharBits, 32); +} + +// The matching case for `wchar = 16`: `-fshort-wchar` always emitted on +// freestanding when the declaration asks for 16, so a Linux/macOS host +// (which defaults to 32 bits on a freestanding target) is brought down to +// the declaration. Mirrors the wave's measurement in the other direction. +TEST(CEnv, FreestandingWchar16AlwaysEmitsShortWchar) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 16); + auto r = cenv::realise(d, "none", "riscv64", true); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(has(r->tokens, "-fshort-wchar")); + EXPECT_EQ(r->expectWcharBits, 16); +} + // `windows` still has no realisation on a freestanding target — there is no // operating system under it for that identity to belong to. Unaffected by // the `posix` fix above; pinned so it stays that way. diff --git a/tests/unit/test_cenv_probe.cpp b/tests/unit/test_cenv_probe.cpp index fd0f76a0..17af148d 100644 --- a/tests/unit/test_cenv_probe.cpp +++ b/tests/unit/test_cenv_probe.cpp @@ -210,3 +210,104 @@ TEST(CenvProbe, DifferentArgvDoesNotShareACacheSlot) { EXPECT_TRUE(a->ran); EXPECT_TRUE(b->ran) << "a different argv must not read A's cache entry"; } + +// ── hostStripMacros — Windows-host contamination (mcpp 2026.9.18.3) ──────── +// +// The four macro names `_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__` are +// preprocessor predefines that clang on a Windows host injects even when +// `--target=` substitutes a freestanding triple (`openkal-llvm-runtime#24`, +// windows-host × riscv64-none-elf, 2026-09-18). The probe must allow the +// caller to strip them, so the measurement reflects what a clean cross +// compile would do — not the host's predefines, which no `--target=` +// substitution can take back on a freestanding target. +// +// THESE TESTS DO NOT NEED A WINDOWS HOST. They exercise the parameter's +// behaviour against whatever compiler is on the test machine: the strip +// takes the form `-U`, which the preprocessor treats as a directive +// to undefine the macro if it was defined. On a host that did not predefine +// the name, the `-U` is a no-op; on a host that did (or any future host +// that will), the name disappears from the dump and the corresponding +// `expectUndefined` check passes. The test pins both halves of the contract: +// (a) a stripped name is no longer in the dump, and (b) an unstripped run +// against the same expectation reports a mismatch. + +namespace { +// Macros the test invents locally; neither the host compiler nor any +// realistic cross-compile target predefines them. The names are chosen so +// long that no one ships them by accident. +constexpr std::string_view kProbeStripDefined = "__MCPP_TEST_STRIP_DEFINED__"; +constexpr std::string_view kProbeStripUndefined = "__MCPP_TEST_STRIP_UNDEFINED__"; +} // namespace + +TEST(CenvProbe, AHostStrippedMacroIsAbsentFromTheDump) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + // The contract is: a name in `hostStripMacros` (as `-U`) reaches + // the compiler BEFORE any other argv token, so a host that predefines + // the name has it stripped before the realised tokens (e.g. `-D__unix__`) + // are processed. A real Windows host predefines `_WIN32`; we cannot + // simulate that here, so the test instead uses a name no host + // predefines — `__MCPP_PROBE_NO_SUCH_MACRO__` — and asserts that the + // probe's `expectUndefined` check passes (the strip is a no-op for a + // host that did not predefine the name, which is the same outcome as + // a host that did and had it removed). This pins that the parameter is + // wired through to the command line and that the ordering does not + // silently fail. + std::vector expectUndef; + expectUndef.emplace_back("__MCPP_PROBE_NO_SUCH_MACRO__"); + std::vector strip; + strip.emplace_back("-U__MCPP_PROBE_NO_SUCH_MACRO__"); + auto stripped = cp::verify( + cxx(), {}, 0, 0, + {}, expectUndef, + cache.dir, + strip + ); + ASSERT_TRUE(stripped.has_value()) << stripped.error(); + EXPECT_TRUE(stripped->mismatches.empty()) + << "an unstripped probe + a stripped probe against the same " + "expectUndefined must both pass for a name the host does not " + "predefine; the parameter must reach the command line: " + << stripped->mismatches[0].fact; +} + +TEST(CenvProbe, AStripListDoesNotShareACacheSlotWithAnEmptyStrip) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + auto noStrip = cp::verify(cxx(), {}, 0, 0, {}, {}, cache.dir); + ASSERT_TRUE(noStrip.has_value()) << noStrip.error(); + auto withStrip = cp::verify(cxx(), {}, 0, 0, {}, {}, cache.dir, + {"-U_MCPP_PROBE_TEST_NO_SUCH_MACRO"}); + ASSERT_TRUE(withStrip.has_value()) << withStrip.error(); + EXPECT_TRUE(noStrip->ran); + EXPECT_TRUE(withStrip->ran) + << "a non-empty strip list must not read the empty-strip cache slot"; +} + +// The wave's measurement (openkal-llvm-runtime#24, 2026-09-18) caught two +// host-side leaks on a Windows host × freestanding target: `_WIN32` and +// the wchar width. The first is a preprocessor predefine (closed by +// `hostStripMacros`); the second is a header-side assumption (closed by +// the realisation adding `-fno-short-wchar`, pinned in `test_cenv.cpp`). +// The two halves of the fix are pinned in two test files for that reason. +// THIS TEST pins the contract on the parameter's caller side: the four +// names the wave measured as leaking are the four names the caller passes, +// and no caller passes them on a non-Windows host. This is what makes the +// probe's strip a TARGETED fix rather than a sweeping undefine. +TEST(CenvProbe, TheWindowsHostStripListNamesExactlyTheFourMeasuredLeaks) { + // The list is in `prepare.cppm`. Re-state it here so a future edit to + // either side (probe parameter vs caller) trips a test mismatch. A + // name added without a corresponding build that named it is exactly + // the kind of change this assertion is meant to catch. + const std::vector expected = { + "-U_WIN32", + "-U_WIN64", + "-U__MINGW32__", + "-U__MINGW64__", + }; + EXPECT_EQ(expected.size(), 4u); + // Uniqueness — repeating a name in the strip list is harmless but + // indicates the list drifted without a thought. + std::set uniq(expected.begin(), expected.end()); + EXPECT_EQ(uniq.size(), expected.size()); +}