feat(windows): usable on a bare Windows box (2026.8.2.1) - #332
Merged
Conversation
… choice A bare Windows box has the UCRT runtime DLLs but neither the MSVC STL nor the Windows SDK — both arrive only with Visual Studio's 'Desktop development with C++' workload. mcpp's first-run default shared one pin with macOS (llvm@20.1.7), and on Windows the host triple is MSVC-ABI, so clang picked the MSVC STL and the build failed at compile time with no actionable message. The self-contained alternative (winlibs GCC targeting x86_64-windows-gnu) was already a verified target with Windows-native CI — just not the default. - msvc::has_usable_msvc(): STL *and* SDK, so a half-installed VS cannot pass - split kFirstRunMacWin into per-platform pins; the Windows no-VS case seeds only the target axis and lets the existing vocabulary pin derive the rest - TcOrigin: distinguish a default mcpp chose from one the user wrote down; only the former may be auto-repaired - fold the old 'VC tools but no SDK' check into one MSVC-ABI-usability gate that runs on every build, which is what repairs users already carrying a persisted llvm@20.1.7 — the first-run branch never fires again for them
…and cmd.exe Two independent places assumed no path ever contains a space — which on Windows means assuming nobody installs under C:\Program Files and no user name has a space in it. include dirs (#331): the same manifest include_dirs reaches the compiler through the global blob in flags.cppm and through ninja_backend's per-TU $local_includes. Only the first shell-quoted; the second applied ninja's $ escaping alone, so ninja un-escaped the space and handed the shell a word that split. Both now go through mcpp::build::include_token — a shared helper rather than a second copy of the quoting, because a third channel is exactly how this happened. That also fixes the plain half of local_include_flags hardcoding -I while its after-dirs half honoured the dialect. cmd.exe: argv[0] was emitted raw to survive cmd's /c quote stripping, which traded 'the path gets mangled' for 'the path is cut at the first space'. Give cmd the outer quote pair it insists on consuming and the inner quoting arrives intact, so every token can be quoted. run_exec keeps inheriting stdio — sealing its stdin would break interactive `mcpp run`. The Windows command-line shape is now built by host-independent functions so Linux CI exercises it; that branch is not even compiled on the platforms mcpp is developed on, which is how the unquoted argv[0] survived this long.
mcpp asks projects to import std everywhere and then made their build script fall back to #include — there was no std BMI channel in the build.mcpp compile at all, and the bundled mcpp module says so in its own header comment. The std module the main build already uses is reusable verbatim: stdmod::ensure_built caches on (toolchain x standard x dialect), so a native build is a cache HIT on the very artifact the project's own TUs import — zero extra work. Only a cross build pays for a second one, which is unavoidable because build.mcpp runs on the host. That host/target split is the load-bearing part: prepare.cppm already resolves a host toolchain for build.mcpp (deliberately without the --target axis) and passes it in, so ensure_built gets the right one. Feeding it the target's std would produce a helper that cannot execute, and silently so until exec time. e2e 112 now proves the helper RAN by the file it was asked to write, not by the compiler's exit code. Detection matches the whole module name up to its ';' — 'import std' is a prefix of 'import std.compat', so the naive substring test builds a BMI nobody asked for. e2e 181 covers import std alone, import std with import mcpp (they share the staged-BMI cwd, which an implementation treating them as two independent conditions gets wrong), and an #include-only program that merely mentions import std in a comment.
…chain env MSVC support in the build.mcpp path was zero, not partial: `grep -i msvc` over build_program.cppm hit only comments. Three layers had to be fixed together, because each one only becomes visible after the previous is gone — first 'C:\Program' is not recognized, then D9002 on -O0, then LNK1181, then 'cannot open include file: cstdio'. - CommandDialect gains the link/language spellings it lacked: libFlag (a FORMAT, since GNU prefixes -lz and MSVC suffixes z.lib — no single prefix expresses both), libSearchPrefix, forceCxxLang, staticRuntime, outputExePrefix. - The host compile is spelled through the dialect instead of hardcoded GNU. - mcpp:link-lib / link-search / cfg are translated at the parse boundary, so the wire protocol stays declarative — a build program names WHICH library it needs, never how the local driver spells one. Storing the translated form in the cache is safe: the cache key already hashes the compiler. - host_base_flags returns nothing for MSVC — cl.exe finds headers and import libs through INCLUDE/LIB, not argv — and capture_exec now receives tc.envOverrides, which only ninja_backend consumed before. Named modules under cl.exe (.ifc + /reference) remain unimplemented; both import kinds share ONE gate and one diagnostic rather than failing obscurely.
CI gains the Windows shapes it never had. windows-fresh becomes a matrix over windows-2022 and windows-2025 — GitHub publishes no Windows CLIENT image, so those Server builds are the closest stand-ins for the Win10 and Win11 kernel generations; genuinely client-only behaviour (UAC, Defender, long-path policy) still needs a self-hosted runner and is called out as uncovered. windows-nomsvc-fresh masks Visual Studio, because no VS-free runner exists. Masking risks a false green — miss one of msvc.cppm's three discovery strategies and mcpp still finds MSVC, takes the old path and passes while proving nothing — so e2e 182 opens by asserting that detection FAILS. It then checks the fallback, that both axes persist, that the exe runs with the toolchain off PATH, and that an explicit [toolchain] is refused rather than swapped. e2e 179 (spaced paths) asserts on the generated ninja file, not just on the build succeeding: a split include flag can still compile by luck. e2e 180 fills the empty `MSVC x build.mcpp` cell, including the link-lib translation and the module refusal. Docs: the winlibs route moves from a footnote to the stated default, and the three `[build] linkage` mentions are corrected — the parser only reads `linkage` under `[target.<triple>]`, so that key was silently ignored everywhere it was documented.
…ystem mcpp never picks msvc@system itself — it cannot install one — so the only way it reaches config.toml is a user running `mcpp toolchain default msvc`. Under the origin rules that landed as GlobalDefault, i.e. repairable, which would have silently moved a user who evidently wants MSVC (and is most likely just missing the SDK component) onto MinGW instead of naming the component to install. Also records the implementation findings in the plan doc — the ones the plan could not have predicted, notably that the raw argv[0] was deliberate, that the Windows branch of command_from_argv is not compiled on the platforms mcpp is developed on, and that Directives is cache-serialized.
Sunrisepeak
force-pushed
the
feat/windows-usability
branch
from
August 1, 2026 18:29
214fb2c to
93b88b3
Compare
ci-fresh-install only runs post-release, so the fix this PR exists for — a stock Windows machine building without setup — had no pre-merge coverage. Learning that it broke after a release is far too late. mcpp is built while Visual Studio is still present (the self-host build uses llvm, which targets the MSVC ABI and needs it) and VS is masked only afterwards. e2e 182 opens by asserting MSVC detection FAILS, so an incomplete mask fails the job rather than silently exercising the ordinary path.
…e file Windows CI caught a regression the Linux run could not: every dependency header went missing (`'gtest/gtest.h' file not found`), with nothing in the error pointing at the include flag. Converging both include channels on one helper also converged their path form, and they legitimately differ. On Windows ninja copies $local_includes into a RESPONSE FILE (#261), which the drivers tokenize GNU-style — a backslash there is an ESCAPE character, inside quotes as well — so `C:\src\inc` loses its separators. The comment in ninja_backend.cppm says exactly this; the shared helper quietly stopped honouring it by using the native-separator escaper. include_token now takes the path form explicitly, so the difference is a stated parameter rather than an accident of which escaper a caller reached for. escape_ninja_chars takes text instead of a path for the same reason: round-tripping through std::filesystem::path re-normalizes separators on Windows and would silently undo a deliberate generic_string(). The regression test is guarded to Windows: POSIX treats '\' as an ordinary filename character, so the assertion cannot be made from a Linux host. A test that passes for the wrong reason everywhere would be worse than one that says where it applies. Also fixes the VS masking step, which failed with 'Access to the path C:\Program Files\Microsoft Visual Studio is denied' — the root is held open on the runner. All three of msvc.cppm's discovery strategies converge on <vsRoot>\VC\Tools\MSVC, so masking VC one level down works and is equally complete. The step now verifies its own postcondition instead of leaving an incomplete mask to surface later as a confusing pass.
…ng to split macOS CI segfaulted on every build.mcpp test. The crash was inside `contract_env` — a function this branch never touched — corrupting a local vector before the compile even started, and it reproduced only under clang/libc++, never under GCC. Bisecting with a clean target and dep cache each round (a first attempt was worthless: switching mcpp.toml's toolchain without clearing the dependency cache links a libstdc++-built mcpplibs.cmdline into a libc++ mcpp, which crashes for an entirely unrelated reason) narrowed it to a single addition: the `split_ws` helper. Not a call to it — its mere PRESENCE in the anonymous namespace. Deleting it fixed the crash; a trivial unused function in the same spot did not cause one. That is a clang codegen problem, not a logic error, and no amount of reading the diff would have found it. The fix is also the better design. `split_ws` only existed because the dialect table stored "-x c++" and "/nologo /EHsc /utf-8" as ninja-command strings while the build.mcpp path needs argv tokens — so the token boundary was being re-derived at the call site. The table now carries both forms, the argv one as a span over a static array, and the spelling stays in one row. Verified under BOTH toolchains from a clean target and cache: clang 22.1.8 (e2e 89 / 92 / 179 / 181 all pass, previously all segfaults) and GCC 16.1.0 (unit 49/49).
…d BMI's deployment target Both found by macOS CI, both clang-only, both real. A BMI flag and its path are one shell word, but only the include tokens were being quoted — so a project under `/Users/me/my work dir/…` handed the shell `-fmodule-file=std=/Users/me/my`, `work`, `dir/…` and died on "no such file or directory: 'work'", with nothing in the error naming the flag that split. GCC never showed it: its BmiTraits leave these prefixes empty. The prefixes carry a leading space for this string channel and MSVC's is itself two words (`/reference std=`), so the split is at the last space and only the value gets quoted. `import std;` in build.mcpp then failed on macOS with "compiled for the target 'arm64-apple-macosx14.0.0' but the current translation unit is being compiled for …": ensure_built was told the deployment target, the compile that consumes the BMI was not, and clang rejects the mismatch. The main build makes the value explicit on every TU for exactly this reason; host_base_flags contributes nothing on macOS because it trusts the clang cfg. The bare-Windows job no longer builds mcpp itself — it takes build-test's artifact. Compiling with clang reads the MSVC STL, and the handles that leaves make the VS directories unrenamable, so the masking silently did nothing. Two more mask bugs the postcondition check surfaced: a trailing wildcard segment in -Path lists a directory's CONTENTS rather than the directory, and the rename errors were being swallowed. Errors are now reported.
The Windows runner failed `LocalIncludeDirsWithSpacesAreShellQuoted` for a difference that is correct: shell_quote_arg emits single quotes for POSIX sh and double for cmd.exe, and the assertion hardcoded `'`. Both the unit test and e2e 179 now take the quote character from the platform. e2e 179 also drops its `unix-shell` requirement. Paths with spaces are the Windows problem — `C:\Program Files`, an account name with a space — so skipping the test there left the platform it exists for uncovered.
…tore xlings in the no-VS job macOS rejected `import std;` together with `import mcpp;`: mcpp.pcm had been built at the host default (15.0) while the compile consuming it now carried 14.0. Putting the flag on the std path alone just moved the mismatch to the other module — clang refuses either direction. The deployment target now lives in host_base_flags, which feeds every compile in this file: the bundled mcpp module's precompile, its object step, and the build.mcpp compile. One resolution, one place, and the same value goes to stdmod so the std BMI agrees too. The bare-Windows job also needs xlings back — masking Visual Studio worked (the postcondition and e2e 182's own self-check both confirm it), but dropping the build step took bootstrap-mcpp with it, so mcpp had no backend to install the winlibs toolchain through. It runs after masking, where nothing it does can hold Visual Studio open.
… [toolchain] e2e 182 caught the fallback breaking its own promise. Once the no-Visual-Studio path persists `default_target = x86_64-windows-gnu`, every later project inherits that target — and the vocabulary pin attached to it then replaced a project's explicit `[toolchain] windows = "llvm@20.1.7"` with gcc, silently. "mcpp revises its own defaults, never yours" has to hold on the second build too, not just the first. The pin now stands down when the target came from the global config AND the toolchain came from the user. A target the user actually asked for (--target, or `[build] target`) still wins, exactly as before — the distinction is between what mcpp remembered and what the user requested, which is the same line TcOrigin already draws on the toolchain axis.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #331 (the parts that are mcpp bugs — see the triage below for what was
declined and why).
Why
mcpp new && mcpp buildfailed on a stock Windows machine. Windows ships theUCRT runtime DLLs but neither the MSVC STL nor the Windows SDK — those arrive
only with Visual Studio's "Desktop development with C++" workload — and mcpp's
Windows default resolved to clang targeting the MSVC ABI, which needs both.
The toolchain installed fine; the failure came later, from clang, saying
'vector' file not found. Nothing pointed at the working alternative sittingright next to it.
This was not a missing toolchain.
x86_64-windows-gnu(winlibs MinGW-w64) hasbeen a
verifiedtarget with Windows-native CI for a while: self-contained,zero Visual Studio,
import stdincluded. It just was not the default, andthat fact lived in a README footnote.
No new toolchain is added. Shipping clang+libc++ for Windows was considered
and rejected: the MSVC-ABI build has no official binaries and is ABI-isolated
from the MSVC STL (no vcpkg, no third-party
.lib) and still needs theWindows SDK, so it would not solve this; llvm-mingw would work but duplicates
the winlibs GCC already present — same target, same ABI, same artifacts.
What changed
Windows default is chosen by detection (
msvc.cppm,triple.cppm,prepare.cppm).has_usable_msvc()requires the STL and the SDK — eitheralone is a half-installed state that a cheaper
find_vs_install_path()probewould wave through, only to fail at compile time. With a usable MSVC nothing
changes. Without one, the no-VS path seeds only the target axis and lets the
existing vocabulary pin derive the toolchain, so that decision stays in one
place.
Existing installs are repaired too. First-run logic alone would not help
anyone who already has
llvm@20.1.7inconfig.toml— the first-run branchnever fires again for them. The MSVC-ABI usability gate runs on every build,
and it absorbed the old "VC tools but no Windows SDK" check rather than sitting
next to it: deriving that judgement in two places is how the second case went
unnoticed in the first place.
mcpp revises its own defaults, never yours.
TcOrigindistinguishes a specmcpp chose from one the user wrote down. An explicit
[toolchain]or[target.X].toolchaingets a diagnostic naming the alternative; a project thatneeds the MSVC ABI to link vcpkg-built
.libfiles is far better served by anerror than by a silent ABI swap.
msvc@systemis likewise never overruled —mcpp cannot install one, so its presence is always deliberate.
Paths with spaces (
ninja_backend.cppm,flags.cppm,process.cppm).Two independent places assumed no path contains one — i.e. that nobody installs
under
C:\Program Filesand no account name has a space. The same manifestinclude_dirsreached the compiler through two channels and only one quoted;both now use one shared helper, because a third channel is exactly how this
happened. And
argv[0]was emitted raw to survivecmd.exe's/cquotestripping — trading "the path is mangled" for "the path is cut at the first
space". Giving cmd the outer quote pair it insists on consuming lets every
token be quoted properly.
build.mcppunder MSVC (dialect.cppm,build_program.cppm). Support wasat zero, not partial:
grep -i msvcoverbuild_program.cppmhit onlycomments. Three layers had to go together, since each only becomes visible once
the previous is gone —
'C:\Program' is not recognized, thenD9002on-O0,then
LNK1181, thencannot open include file: 'cstdio'.CommandDialectgains the link/language spellings it lacked, the host compile goes through it,
and
capture_execfinally receivestc.envOverrides(onlyninja_backendconsumed them before).
build.mcppcanimport std;— mcpp asked projects to modularize and thenmade their build script fall back to
#include. It reuses the same std modulethe main build already has (
stdmod::ensure_built, keyed on toolchain ×standard × dialect), so a native build is a cache hit costing nothing; only a
cross build pays for a second one, because the helper runs on the host. Not yet
under MSVC, where named modules need the
.ifcpipeline — that is an explicitrefusal, not an obscure failure.
Testing
The gap that produced all of this was
MSVC × {build.mcpp, spaced paths}beingan empty cell, so the CI shapes matter as much as the fixes:
windows-freshbecomes a matrix overwindows-2022/windows-2025. GitHubpublishes no Windows client image; these are the closest stand-ins for the
Win10/Win11 kernel generations. Client-only behaviour (UAC, Defender,
long-path policy) stays uncovered and is documented as such.
windows-nomsvc-freshmasks Visual Studio, since no VS-free runnerexists. Masking risks a false green — miss one of
msvc.cppm's threediscovery strategies and mcpp finds MSVC anyway, takes the old path and
passes while proving nothing — so e2e 182 opens by asserting that
mcpp toolchain default msvcFAILS.include flag can still compile by luck), 180 (MSVC × build.mcpp), 181
(
import std;alone, withimport mcpp;, and an#include-only programthat merely mentions it in a comment), 182 (bare Windows). e2e 112 gains an
import std;case that proves the helper ran on the host, which is the oneconfiguration where a host/target mix-up is detectable.
so Linux CI exercises it. That branch is not compiled on the platforms mcpp
is developed on, which is how the unquoted
argv[0]survived this long.Local: unit 48/48, e2e 172 passed / 0 failed / 8 skipped (Windows- and
macOS-only),
check_version_pins.shclean.Declined
ldflagspath heuristics, a[build] linkageshorthand, built-in cppwinrtgeneration, and Windows clang+libc++ — reasoning in
.agents/docs/2026-08-01-issue331-windows-msvc-triage.md§7 and.agents/docs/2026-08-02-windows-usability-design.md§7.While documenting
linkage, the three places that showed[build] linkageturned out to be teaching a key the parser never reads (
toml.cppm:995onlyaccepts it under
[target.<triple>]); corrected.