Skip to content

feat(windows): usable on a bare Windows box (2026.8.2.1) - #332

Merged
Sunrisepeak merged 14 commits into
mainfrom
feat/windows-usability
Aug 1, 2026
Merged

feat(windows): usable on a bare Windows box (2026.8.2.1)#332
Sunrisepeak merged 14 commits into
mainfrom
feat/windows-usability

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Closes #331 (the parts that are mcpp bugs — see the triage below for what was
declined and why).

Why

mcpp new && mcpp build failed on a stock Windows machine. Windows ships the
UCRT 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 sitting
right next to it.

This was not a missing toolchain. x86_64-windows-gnu (winlibs MinGW-w64) has
been a verified target with Windows-native CI for a while: self-contained,
zero Visual Studio, import std included. It just was not the default, and
that 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 the
Windows 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 — either
alone is a half-installed state that a cheaper find_vs_install_path() probe
would 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.7 in config.toml — the first-run branch
never 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. TcOrigin distinguishes a spec
mcpp chose from one the user wrote down. An explicit [toolchain] or
[target.X].toolchain gets a diagnostic naming the alternative; a project that
needs the MSVC ABI to link vcpkg-built .lib files is far better served by an
error than by a silent ABI swap. msvc@system is 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 Files and no account name has a space. The same manifest
include_dirs reached 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 survive cmd.exe's /c quote
stripping — 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.mcpp under MSVC (dialect.cppm, build_program.cppm). Support was
at zero, not partial: grep -i msvc over build_program.cppm hit only
comments. Three layers had to go together, since each only becomes visible once
the previous is gone — '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, the host compile goes through it,
and capture_exec finally receives tc.envOverrides (only ninja_backend
consumed them before).

build.mcpp can import std; — mcpp asked projects to modularize and then
made their build script fall back to #include. It reuses the same std module
the 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 .ifc pipeline — that is an explicit
refusal, not an obscure failure.

Testing

The gap that produced all of this was MSVC × {build.mcpp, spaced paths} being
an empty cell, so the CI shapes matter as much as the fixes:

  • windows-fresh becomes a matrix over windows-2022 / windows-2025. GitHub
    publishes 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-fresh masks Visual Studio, since no VS-free runner
    exists. Masking risks a false green — miss one of msvc.cppm's three
    discovery 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 msvc FAILS
    .
  • New e2e: 179 (spaced paths, asserts on the generated ninja file — a split
    include flag can still compile by luck), 180 (MSVC × build.mcpp), 181
    (import std; alone, with import mcpp;, and an #include-only program
    that 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 one
    configuration where a host/target mix-up is detectable.
  • The Windows command-line shaping is now built by host-independent functions
    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.sh clean.

Declined

ldflags path heuristics, a [build] linkage shorthand, built-in cppwinrt
generation, 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] linkage
turned out to be teaching a key the parser never reads (toml.cppm:995 only
accepts it under [target.<triple>]); corrected.

… 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
Sunrisepeak force-pushed the feat/windows-usability branch from 214fb2c to 93b88b3 Compare August 1, 2026 18:29
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant