Tags: mcpp-community/mcpp
Tags
feat(windows): MSVC DLLs export, packages link with cl.exe, and modul… …e_extensions is followed everywhere Closes the three questions left open by 2026.8.18.1, from `.agents/docs/2026-08-18-windows-shared-library-and-module-extensions.md`. They shared a shape: mcpp treated "the toolchain does not do this for us" as "this cannot be done", and each had an industry answer older than a decade. ## `kind = "shared"` works on the MSVC ABI MSVC exports nothing from a DLL without `__declspec(dllexport)` or a `.def`, so the import library came out empty and consumers failed with unresolved externals for symbols visibly in the objects. The **reason** was right; the **conclusion** was not. CMake has shipped `WINDOWS_EXPORT_ALL_SYMBOLS` since 3.4, and its `bindexplib` reads COFF directly — decisive here, because `dumpbin` lives in a Visual Studio developer environment and mcpp's default Windows toolchain is clang, so a plain `mcpp build` is not inside one. `mcpp.build.coff_exports` is that reader, **a pure function over bytes** so it can be tested on hosts that cannot produce COFF at all. 16 unit tests build objects byte by byte — the only way to vary a storage class on demand — plus two committed real mingw objects, because a reader fed only its own test's output agrees with itself and nothing else. Past 65535 exports it **refuses rather than truncates**: a truncated export table links cleanly and fails at whichever consumer needed the symbol that fell off the end. **Annotation wins.** An object already carrying `/EXPORT:` directives — what `__declspec(dllexport)` emits — makes mcpp stand down. Adding a list on top would export the same names twice (`LNK4197`) and export everything else besides, replacing a chosen public surface with all of it. Detected, not configured: a key for "I annotated my exports" is a second place to say what the objects say. Two limits survive that no tool removes, and they are CMake's documented ones for the same mechanism — exported **data** still needs `dllimport` on the consumer, and a class whose **vtable** is referenced must be marked whole. Both are in docs/12 rather than left to be discovered. ## A package can be consumed by native `cl.exe` The generated manifest now also carries the dialect-neutral `[target.<pred>.runtime]` pair, which mcpp renders as `/LIBPATH:` + `<n>.lib` or `-L` + `-l<n>`. Not new vocabulary — the same two keys `[runtime]` has had at top level, made per-target. Both spellings ship: an older mcpp reads only the `ldflags` and silently ignores the new block, so dropping them would leave every older client with no link line.⚠️ **One leg is deliberately excluded, and e2e 257 is why.** A PE/MinGW shared leg links with `-L… -Wl,-Bdynamic -lmathkit`, and `-Wl,-Bdynamic` only works immediately before the `-l` it enables. Two attempts to route it through the neutral channel both failed with `have you installed the static version of the mathkit library?` — first by clearing the ldflags, then by rendering the library reference into a different command-line slot. Both were the same mistake: treating a hand-tuned link line as a two-field record. ## `module_extensions` is the knob, and everything follows it `.ixx` is **not** built in — the extension set is configuration, not a list mcpp grows one entry at a time. What that owes in return is that one declaration is enough, and three places were not holding up their end. **The scanner accepted what it could not classify.** An undeclared `.ixx` produced a compile edge whose object nothing links: ``` build obj/mathkit.ixx.o | gcm.cache/mathkit.gcm : cxx_object … bmi_out = gcm.cache/mathkit.gcm ← the BMI was produced build bin/app : cxx_link obj/main.o ← the object is not here ld: undefined reference to `mk::answer@mathkit()' ``` Two answers to "is this a module interface" and only one of them read. **The lib-root convention hard-coded `.cppm`**, so packing an `.ixx` library started its closure at a file that does not exist — and the result was silently wrong twice over: ``` $ mcpp pack mathkit Interface (headers only) ← the module interface, gone Withheld (nothing) Packed …-x86_64-linux-gnu ← the C-SURFACE tag ``` An empty published set is precisely how the packer recognises a C surface, so losing the interface **also** stopped the package constraining the C++ ABI, and the compatibility gate stopped checking compiler and stdlib. **The generated manifest listed `.ixx` sources without saying what an `.ixx` is.** It now declares the extension — computed from the published files, so it cannot disagree with `sources`, and absent for a `.cppm` package whose manifest is unchanged. ## Notes for review -⚠️ **Behaviour change**: a `sources` entry mcpp cannot classify is now a hard error. A project with a `.md` in `sources` starts failing. -⚠️ The probing lib-root resolver lives in `mcpp.manifest.toml`, not beside its sibling in `types` — measured: adding the extension-table import to `types`, which nearly everything depends on, made **GCC 16.1 ICE while compiling an unrelated `src/main.cpp`**, with a cleared gcm.cache. The edge is not added; the function moved to where the edge already is. - Local: **239 passed, 1 failed, 14 skipped**. The failure is `22_doctor_cache_publish`, which the previous RELEASE binary fails identically on this machine. - Still unverified: the `cl.exe` consumption path end to end (needs a consuming case in the msvc job), and the data-symbol `dllimport` limit is documented but has no reproducing test.
feat(pack): ship a library as interface + prebuilt binaries (#433) Closes #433. `mcpp pack <target>` now ships a library as **interface + prebuilt binaries**, and a consumer builds against it through the ordinary dependency path. **No new manifest section and no new key.** `[targets.<n>].kind` decides what is packed (so there is no `--lib`, no `--artifact`), the published interface is the module closure of the lib root, the public headers are `include_dirs` in full, and each leg's ABI tag, digest and provenance ride on the existing `[[runtime.artifacts]]`. The result is an ordinary mcpp package, which is why an older mcpp can still build against it — it simply does not run the gates. **Two gates, both for failures that are otherwise silent:** an ABI tag mismatch (measured: swapping two struct fields in a shipped interface compiled, linked, ran, printed wrong data, and produced no diagnostic at all) and an interface digest that ties the published sources to the binaries they were built from. **`kind = "shared"` beyond ELF.** Mach-O and PE/MinGW are supported now — the PE side needed the import library modelled (a PE shared library is two files, and mingw's tolerance for linking the `.dll` directly was hiding that), Mach-O needed `@rpath` install names or every relocated `.dylib` would report `image not found`. MSVC stays refused, for the real reason: it exports nothing from a DLL without `__declspec(dllexport)`, so the import library would be empty. **Three pre-existing defects fixed on the way**, each with its own regression test: `[target.'<triple>'.build]` never matched on a native build; `sources = []` was byte-identical to omitting the key; an implementation partition (`module M:part;`) was recorded as requiring its own name, which left the graph with no ordering edge and failed on Windows clang with `failed to read compiled module`. And `--target` no longer accepts a target this host cannot produce — it used to resolve the native compiler and deliver an ELF as a Windows build. Docs: `docs/12-binary-distribution.md` (+ zh), `examples/05-lib-distribution`. Design, measurements and the four designs my own experiments disproved: `.agents/docs/2026-08-17-library-distribution-design.md`.
feat(windows): the SDK and the runtime get the axes the compiler alre… …ady had (#448) * docs: the three Windows toolchain axes — a design, and two corrections The architecture review found where the problems are. This is what to do about them, written after a round of questions that overturned two shapes in my own proposal. Both corrections are recorded in §0.2, because the wrong version looked equally reasonable and will otherwise be proposed again: - generalising `@system` to gcc/llvm is backwards. xlings is a user-space OS and mcpp minimises host dependence; `msvc@system` is a Windows concession, not a capability three families are missing. - a `windows_sdk = "..."` manifest key should not exist. The model already reserves the slot (`runtime_binding.cppm:26` documents `ucrt@...` and nothing populates it), and a version key would sit next to `_WIN32_WINNT` looking interchangeable while controlling a different thing — which is worse than not having it. The design separates three axes that are currently entangled: where the compiler came from, which SDK is used, and what the artifact ships. They take values independently — a managed toolset with the machine's SDK is what xrgui's CI does today — so any design that fuses them into one switch is wrong. It also records the finding that makes `mcpp pack` more than a missing feature: `ldd_parse` computes the dependency closure by RUNNING the binary (LD_TRACE_LOADED_OBJECTS), so it cannot cross an OS or an architecture by construction. Reading imports statically is what makes cross-packaging fall out rather than be added. The acceptance criteria are written so that none of them can be met by CI going green — two of them explicitly require a machine POORER than the CI runner, because "the verification environment is richer than the target" is the shape that produced most of this round's eleven defect layers. * feat(windows): give the SDK and the runtime the axes the compiler already had Implements §1 / §2 / §3 of .agents/docs/2026-08-16-windows-toolchain-three-axes-design.md. Three questions were entangled because only one of them had ever been modelled. The compiler got a version axis in the last round; the headers it compiles against and the runtime the artifact loads did not. §2 THE SDK IS BOUND, NOT SEARCHED. `find_windows_sdk()` scanned — WindowsSdkDir, then the sibling store, then the conventional roots — for BOTH origins. So a pinned `msvc@<toolset>` was a pin the environment could overwrite, and two machines could build one manifest against two SDKs with nothing in the log naming either. It is now resolved by origin: a managed toolset takes the SDK payload from its own store and ignores WindowsSdkDir/WindowsSdkVersion *out loud*; `msvc@system` keeps today's chain, because a machine's things can only be found by looking. A managed toolset with no SDK payload beside it still falls back to the machine's — working beats failing — and says so, because that build is no longer reproducible and only that line records it. §2.3 `ucrt@<version>` FILLS A SLOT THAT HAS BEEN RESERVED SINCE THE FIELD EXISTED. `RuntimeBinding::runtimeId`'s comment has documented it from the start and nothing ever wrote one, so the SDK version never reached `runtimeContractHash` and two SDKs shared one build cache. It is NOT isomorphic to `glibc@`, and the comment says so where it will be read: glibc@ binds a payload (headers + .so, patchelf makes the artifact run on that copy), ucrt@ declares a floor (ucrtbase.dll is an OS component from Win10 on; mcpp's windows-sdk payload deliberately carries only half of ucrt and no redistributable). It is therefore not projected into `libc`. The `glibc@`-prefix gates become `runtime_provider()` dispatch, so another provider reads as "no rules here" rather than "no identity". §3.3 `toolchain-coupled` NOW MEANS SOMETHING ON PE. The refusal said the MSVC runtime "ships with the OS/redistributable, not with the toolchain" — true of ucrtbase.dll, false of vcruntime140.dll/msvcp140.dll, which sit in VC\Redist\MSVC\ inside every toolset. That is the relationship gcc has to libstdc++.so, so it takes the same contract; PE has no rpath, so the mechanism is a copy beside the artifact rather than a search path. /MT stays a degradation, and a genuine one: a static CRT leaves no DLL to couple to. The DLL set comes from `vc_redist_dir()` — the single criterion that excludes `debug_nonredist\`, which may not be redistributed. A second, name-shaped rule here could disagree with it, and disagreeing about that is a licensing defect rather than a bug. §1 THE ORIGIN AXIS IS CONTAINED, NOT GENERALISED. - `gcc@system` / `llvm@system` are refused where they are read, naming both things the user might have meant. They used to parse and then fail elsewhere as `xim:gcc@system` → "no such package", sending the reader after a version that was never going to exist. `msvc@system` is a concession to one platform, not a capability the other families lack; the family-less `system` escape hatch is untouched. - `resolve_managed_msvc()` replaces two hand-written copies of "where does a managed toolset live, and why is the fetcher's `root` wrong for it" — the reason existed in only one of them. - `needs_linux_sysroot_payloads()` replaces two spellings of one rule whose comment claimed they mirrored each other. They did not: the PE term was missing from one. Unreachable today, which is how it survived. - The toolchain resolution order was documented twice, as "3 steps" and "4 steps", naming five of the nine inputs and disagreeing about two. One table now, keyed to `TcOrigin` enumerators so it cannot quietly stop matching. `dist::Format` is derived from the target triple before falling back to the host, which only ADDS answers — and makes a Windows contract assertable on the Linux runner that reviews most of this. Tests: 22 new. The SDK-override criterion is the design doc's §6 acceptance test as a unit test (point WindowsSdkDir elsewhere; the payload SDK must still win, and the note must say the variable was ignored); the deploy tests assert reachability twice over, since a copy edge nothing asks for never runs under explicit ninja goals. * feat(pack): read the import table instead of running the binary (§4) `mcpp pack` refused Windows with `#if defined(_WIN32)`, and the reason given was that the tools were POSIX-only. That was the symptom. The cause is one layer down: the dependency closure comes from LD_TRACE_LOADED_OBJECTS=1 '<binary>' which RUNS the artifact — so it can cross neither an OS (a Linux box cannot execute a PE) nor an ARCHITECTURE (an x86_64 box cannot execute an aarch64 ELF, same OS or not). Porting `tar` would not have helped, and every tool the 2026-05-19 design proposed — dumpbin, ImageNtHeader, Compress-Archive — would have reintroduced the obstacle one layer down, because each exists only on the platform where the problem had already gone away. mcpp.pack.binfmt reads it out of the file instead: ELF DT_NEEDED through the segment table, PE imports AND delay-imports (a missing delay-load does not fail at startup — it fails at the first call through it, which is strictly worse to debug). Cross-OS packaging is then not a feature that had to be added; it is what remains once nothing has to be executed. mcpp.pack.zip writes the archive, for the same reason: no zip tool exists on every host (GNU tar cannot write zip, `zip(1)` is often absent, Compress-Archive is Windows-only). Entries are STORED, which is a real size cost and the honest trade — a DEFLATE encoder is the one part that could produce an archive that unpacks WRONG rather than failing loudly, and mcpp has no zlib to borrow one from. Deterministic by construction: no timestamps are read, so a published checksum means something. THE CONTRACT NOW REACHES PACKAGING (§4.3). `cxx_runtime` used to stop at the compile and link flags, so the step that decides which files actually travel could not see what had been promised — on ELF the `ldd` closure agreed with it by luck, on PE nothing did. It is now an input: toolchain-coupled the toolchain's runtime directory joins the search set host-coupled it stays OUT, so a vcruntime140.dll in the toolset is not silently swept into a package that promised the host would provide it --mode system/static + toolchain-coupled → refused, naming the way out PE layout is flat, and that is the relocation mechanism rather than a style: the Win32 loader resolves a DLL from the directory of the executable, and there is no rpath to point elsewhere. Windows' own DLLs are never bundled — two of something that must be unique is a broken program, not a heavier one — but `force_bundle` overrides that, as it always did on ELF. Verified on a Linux host against a real cross-built PE, not only synthesised fixtures: e2e 240 builds a mingw target, drops a stand-in for a DLL the EXE imports, packs, and has PYTHON verify the archive. The msvcrt.dll assertion is the positive half (a parser that read nothing could not have produced it) and the kernel32.dll assertion the negative half; together they are decisive. It runs in the mingw-cross job because running it on Windows would prove nothing. Docs: the Windows layout, the cross-host story and both size/determinism consequences in 02-pack-and-release (en+zh); the MSVC half of `cxx_runtime` in 05-mcpp-toml (en+zh), replacing a claim about /MT that stopped being true; SDK-by-origin and the `@system` rule in 03-toolchains (en; the zh MSVC section was rewritten — it still described msvc as a system-only toolchain and `msvc@19.44` as a pin-verify). 2026-05-19-pack-windows-design.md is marked superseded with what it got wrong and why, since the mistake is instructive. * chore: bump version to 2026.8.17.1, and record what §1–§4 actually became The design doc gains a status section per item, including the two places the PLAN was corrected by the implementation and the one half of §4 that was deliberately not done: - `dist::Format` now reads the target triple before falling back to the host. Everything but MinGW used to ask the host, which made a Windows contract unassertable on the Linux runner where most of this gets reviewed. It only ADDS answers, so no existing build changes. - `force_bundle` had to reach the PE system list too. ELF always worked that way; making the PE exclusion unconditional would have turned an explicitly written decision into decoration. - The ELF closure still runs the artifact. `ldd` hands back RESOLVED PATHS while `DT_NEEDED` gives only names, and turning names into paths means reimplementing the loader's search order ($ORIGIN, DT_RPATH before LD_LIBRARY_PATH before DT_RUNPATH before ld.so.cache, hwcaps). Rewriting that under a correct, e2e-covered path is more risk than it buys — and the cost is stated rather than left to be discovered: cross-ARCHITECTURE ELF packing is still unsupported, which is the second limit §4.1 names. `mcpp self doctor` reports the Windows SDK PER ORIGIN. One unlabelled line was the same "one question, two answerers" shape this axis exists to close: a user reading it would believe it applied to their pinned build, and it did not. * fix(pack): stop using the module-boundary shapes clang miscompiles Every Windows job and the macOS job went red on the same new code, in two different ways, while gcc was green everywhere: Windows clang 20.1.7 (MSVC ABI) segfaulted COMPILING mcpp.pack — 0xC0000005, no diagnostic, five jobs at once macOS test_pack_binfmt died with SIGSEGV at RUN time, inside the PE import-table test The parser is not the problem, and that was measured rather than assumed: the same code is clean under ASan+UBSan with clang 22.1.8 + libc++, and correct when compiled AS A CLANG MODULE on x86_64 Linux at both -O0 and -O2. What is left is the compiler, on the two targets neither of those probes covers. This codebase has been here before. hostflags.cppm exists because adding an UNUSED helper to a module's anonymous namespace miscompiled a NEIGHBOURING function under clang + C++20 modules + -O2, and its verdict was "mechanism unknown, reproduction solid; the cheap response is to not grow that namespace". Same response here — remove the shapes, keep the behaviour: mcpp.pack a scoped enum from another module as a defaulted member of an EXPORTED struct (`dist::Contract` in `Options`) → a plain bool, since only one of the three values changes anything here; a ranges projection over an imported type's member → std::sort with a comparator mcpp.pack.zip `std::span<const Entry>` across the boundary → the vector by const reference (one caller) mcpp.pack.binfmt `template <typename T> le(...)` in the module purview → four concrete le8/le16/le32/le64; `constexpr std::array` via `std::to_array<>` → plain arrays; ranges algorithms over them → loops WHICH ONE IT WAS IS NOT ESTABLISHED, and the comments say so rather than inventing a finding — they were removed together because each CI round costs minutes and none of the replacements is worse than what it replaced. Every one is also simpler, so nothing is being paid for the avoidance. Behaviour is unchanged: the same 10 unit tests and e2e 240 pass. * test(pack): split the PE case so it can localise its own crash The combined test died with SIGSEGV on the macOS ARM64 runner and nowhere else — not under ASan+UBSan with clang 22 + libc++, not as a clang module on x86_64 Linux, not under gcc. A single test that builds a fixture, identifies it and parses it cannot say which of the three it was, so each hypothesis costs a CI round. Three tests now: the fixture is well-formed, identify(), and needed_names(). * docs: 03-toolchains contradicted itself about `[build] linkage` The MinGW section says the key does not exist and is silently ignored; the MSVC section two hundred lines down showed exactly that form as the way to select `/MT`. Found by writing it out and watching mcpp print "unsupported key 'linkage' (ignored)" — which is also what a user following the page would have got, without the page telling them why nothing changed. `linkage` is exact-triple only (`[target.<triple>]`, or `--static`). Both language editions now show that, and say which section each key lives in — the zh page had it right before this round and lost it when the MSVC section was rewritten from the English one. * test(pack): rewrite the PE fixture plainly, and make it say where it dies Splitting the test localised the macOS ARM64 SIGSEGV to ThePeFixtureItselfIsWellFormed — fixture code that calls no module at all. So it is not mcpp.pack.binfmt, and the three probes that came back clean (ASan + UBSan under clang 22 + libc++, the same module compiled by clang on x86_64 Linux, gcc everywhere) were looking in the right place for the wrong thing. The fixture used two `std::span` parameters and two lambdas that mutated a captured string through a captured cursor. It now takes vectors, indexes explicitly, and captures nothing — and traces each phase to stderr, so if it moves again the log names the step instead of costing another CI round. `#include <cstdio>` is not redundant next to `import std;`: `stderr` is a macro, and a module cannot export one. * fix(pack): `--mode static` must not override a target the user asked for `--mode static` on its own has always meant "the musl-static ELF", and that is unchanged. But the re-prepare that enforces it ignored `opts.targetTriple`, so `--mode static --target x86_64-windows-gnu` silently produced a LINUX build. That was invisible while PE packaging did not exist — there was no Windows package to notice was missing — and it is a wrong answer now that there is. Measured both ways: with an explicit target the output is `…-x86_64-w64-mingw32-static.zip`; without one it is still `…-x86_64-linux-musl-static.tar.gz`. * test(pack): the PE fixture trace is opt-in It did its job — the plain rewrite is green on macOS ARM64 — and 24 lines of stderr in every CI run forever is a poor trade for a crash that is currently fixed. `MCPP_TEST_TRACE=1` brings it back, which keeps a recurrence one CI round to localise instead of the four this one cost. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
mcpp 2026.8.16.3
The release where msvc@<toolset> completes its whole lifecycle on a real
Windows runner:
PASS: msvc@14.44.35207 installs, builds, stays distinct from
msvc@system, and removes
Eleven layers of defect got there, each reachable only once the one before
it was fixed, and every one of them had been green in CI. They share one
shape: an acceptance criterion weaker than "usable".
- installed() checked cl.exe, then directories, then a sample
- the SDK subset had no kernel32.lib, then no um/shared headers, no rc/mt
- find_windows_sdk() accepted headers with no import libraries
- remove failed on Windows, and would not say which file
- vctip.exe held the payload open; not installing it did nothing for
machines that already had it
- the payload directory cannot be renamed while a file in it is open —
the files have to move instead
- and an empty directory skeleton still would not delete, so "removed"
had to mean "no files remain"
Also: macOS could not build any project carrying a build.mcpp (#437), the
default /MD build could not start on a clean Windows box, and mcpp doctor
could not see a toolset that mcpp toolchain list showed.
chore: bump version to 2026.8.16.2 (#439) Ships #436: an installed msvc toolset was invisible in `toolchain list`. The v2026.8.16.1 tag was cut before that fix, so `latest` currently carries it -- everything else in .1 is fine (install, build, default and remove all work; only the listing row was missing). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
feat(toolchain): MSVC gets a version axis — `msvc@<toolset>` alongsid… …e `msvc@system` (#434) * feat(toolchain): MSVC gets a version axis -- `msvc@<toolset>` alongside `msvc@system` (2026.8.16.1) gcc and llvm are installed by mcpp and resolved from what the manifest declares. MSVC was the one exception: EVERY msvc spec was a system spec, so a manifest could name a toolset and have that name discarded. The consequence is not inelegance. It is that the same source compiles with different compilers on different machines, and nothing says so. Measured on xrgui#3: in ONE CI run mcpp used 14.51 and xmake used 14.52, and it stayed invisible until 14.51 hit an ICE. Exporting a complete vcvars environment did not help -- the only way out was to move `vswhere.exe` aside so mcpp would fall through to VSINSTALLDIR, and that workaround is still in xrgui's workflow. ## The version axis decides the origin msvc@system (or bare msvc) the machine's own Visual Studio -- UNCHANGED msvc@<toolset> an xlings payload mcpp installs and pins `msvc@14.44.35207` is isomorphic to `gcc@16.1.0` in every respect: coexisting versions, `toolchain remove msvc@<toolset>`, auto-install from a manifest. The payload brings the compiler, the STL, and -- through its `xim:windows-sdk` dependency -- the ucrt/um headers and libs, so nothing has to be preinstalled. Structurally, ACQUISITION and RESOLUTION are separated: acquisition is shared with gcc (the xim install), resolution is shared with `msvc@system` (`installation_from_tools_dir`). A pinned toolset is therefore not a second code path, and cannot grow its own bugs. What it does not share is 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). BREAKING: `msvc@19.44` was a pin-verify against the system install's cl banner -- checked by `toolchain default` and silently ignored by builds. The version axis now names a toolset everywhere. A `19.x` spelling errors out with this machine's actual cl version and both replacements. ## VSINSTALLDIR now outranks vswhere vswhere returns something on nearly every developer machine, which made VSINSTALLDIR effectively unreachable -- a build that had exported a complete vcvars environment still compiled with whatever vswhere ranked first. A guess must not silently override an answer. vswhere also gains `-prerelease`: without it a machine with only an Insiders VS is reported as "MSVC not found" while a perfectly good cl.exe sits on disk. VS*COMNTOOLS stays BELOW vswhere. Those are machine-wide leftovers -- a 2017 VS150COMNTOOLS must not outrank a current install -- whereas VSINSTALLDIR is someone setting it for this shell. The old `find_vs_via_env()` conflated the two, so promoting it would have promoted the leftovers too. ## The Windows SDK stops being two absolute paths Order: WindowsSdkDir (+ WindowsSdkVersion, both exported by vcvars) -> the `xim:windows-sdk` payload beside a pinned toolset in mcpp's own store -> the hardcoded roots, now a fallback. The second source needs no configuration and hardcodes no version: the COMPILER'S OWN PATH says which store it came from, and the SDK is its neighbour there. `sibling_sdk_roots()` returns empty for a system cl, so the two origins stay separate. ## cxx_runtime = "self-contained" now actually does something on MSVC Two knobs, one working: `linkage = "static"` really emitted /MT while `cxx_runtime = "self-contained"` reported "not implemented" -- for the same physical switch. Two comments contradicted each other about it (flags.cppm:605 vs distribution.cppm:202). On the MSVC ABI these are not alternatives: /MT links the C runtime and the C++ runtime out of the same library. Both spellings now select it through one `msvc_wants_static_crt()`, which the project's TUs and the std module both ask -- rather than each spelling out `linkage == "static"`, which is exactly how they diverged in #422. The default stays /MD: the predicate reads the WRITTEN manifest scalar, not the resolved contract, because most roles default to self-contained and keying off that would flip every Windows build to /MT. The CRT model is a whole-PROJECT property (one std module per project, and cl bakes _MSVC_MT/_MSVC_MD into it), so a per-role override is now refused with a message that says why, instead of failing later inside the ucrt headers. ## Tests MSVC discovery is testable off Windows for the first time: `installation_at()` takes a directory instead of probing, `find_windows_sdk()` takes a list of roots, and neither is behind a platform macro. Six new unit tests drive real fixture trees on Linux CI -- including "both toolsets present, ask for the OLDER one", which a latest-wins implementation fails and a real machine might pass by accident. New e2e `239_msvc_managed_toolset.sh`, written so that the SYSTEM compiler answering would FAIL rather than pass quietly: cl.exe must be inside mcpp's store, the toolset directory must be the one named, and switching the same project back to `msvc@system` must resolve to the system cl again. 83/83 unit targets pass; 95/96/103/183 e2e pass on Linux. Closes #432 * test(e2e): 239 asked for a flag that does not exist `toolchain list --available` is not a thing -- `toolchain list` already prints an "Available toolchains" section. Caught by ci-windows on the first run, which is the right place for it: this test only ever executes there. Also reordered: the install now runs BEFORE the discoverability check, so a runner that cannot reach the index skips cleanly instead of failing an assertion about a list the index would have filled in. And the check became stronger than the one it replaces -- it asserts the INSTALLED toolset shows up, not merely that some msvc row exists, because a toolset that installs and then never appears is indistinguishable from one that did not install. * docs: the cross-repo plan, its one hard dependency, and which claims can self-certify Four repos, five changes, ONE real dependency edge: the packages must be published before mcpp can install them and before xrgui can use them. Everything else is parallel, and stringing it into a line is the usual waste in work shaped like this. The part worth reading is §4: which acceptance criteria a person could satisfy by adjusting a test, and which they could not. The mirror is the example that earned the distinction -- the criterion is not "the upload succeeded" but "the bytes came back with Microsoft's sha256", and that caught a real failure where the tool reported 16 files as failed while the release listing showed them present and they were in fact absent. * fix(toolchain): a managed toolset without its SDK reported success Self-review catch. `installation_at()` succeeding means cl.exe is where the declared version says it should be -- it says nothing about the ucrt/um headers, which arrive as a separate package dependency and can therefore fail on their own. The install printed "Installed", and the build died inside the ucrt headers much later. That is the half-installed state `has_usable_msvc()` was written for; this applies the same judgement to the managed origin, and names the dependency that must have failed rather than leaving the reader to work it out. Also: `msvc_print_detected` now takes the label. "Detected" is a claim about probing the machine, and printing it after unpacking a payload the caller NAMED describes the wrong thing -- quietly, and in exactly the direction this whole change is about. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
fix(diagnostics): stop promising a remedy that does not work (2026.8.… …15.3) 沙箱真实验证抓到的:mcpp 在 SubOS 缺 `subos_info` 时建议 `xlings self update`,而实测它与 `self doctor --fix` 都不回填已存在的 SubOS,`xlings subos` 也无 repair 子命令。已开 openxlings/xlings#547;三处措辞改为陈述事实并指向它。这句话出自 xlings 的原始措辞,mcpp 一直在转述 —— 把「上游这么说」当成「这是对的」转述给用户,和自己写错一样。
fix(toolchain,build): absent SubOS description must not stop the buil… …d; C-only units link with the C driver (2026.8.15.2) Closes #427, closes #426. Also un-reds `main`. 三条互不相关的缺陷,共同点是**一个事实的缺失被当成了矛盾**。 **#427** — `ensure_post_install_fixup` 在调用方没给出运行时身份时自己去读硬编码的 `<xlings home>/subos/default`,并把「读不到」变成致命错误。触发条件与沙箱无关:默认 SubOS 由早于 `subos_info` 块的 xlings 创建即可。`allow_host_libs` 救不了(判定在 hermeticity 策略之前),`mcpp toolchain install` 同样死。已发布三周。 真因是没做完的迁移 —— 调用点注释写着「fixup 是 RuntimeBinding 的消费者」,`runtimeId` 早已从四处传入,旧推导没删;而被它保护的函数本来就正确处理空值,那个降级分支从未执行过。 删掉第二处推导;未知降级、矛盾仍然失败;严重程度归调用方;降级不写 marker。 `toolchain_install` 自己解析一次 RuntimeBinding —— 缺这一步,单删兜底会让它永远跳过 fixup,把硬失败换成静默的坏安装。 **#426** — 所有链接一律走 `$cxx`。同一对象同一 ldflags 只换驱动:g++ 给出 `libstdc++.so.6` `libm.so.6` `libgcc_s.so.1` `libc.so.6`,gcc 只给出 `libc.so.6`。 按内容选驱动,查不到的对象保守判为 C++。只换驱动不够:`-lstdc++exp` 是显式命名的, 故 `CompileFlags` 增加 `ldC`(与 `ld` 同一表达式产生),契约表增加 `unitFlagsC` (`-static` / `-static-libgcc` 属于 libc,保留)。顺带补上 `std.compat.o` 缺失的收窄。 **main 的 bench pin 守卫** — 断言本身是错的。它要求 `reference_mcpp` 等于 bootstrap pin,而标准集不在任何 workflow 里、bootstrap pin 可合理滞后;它声称防止的危险已由 `run-standard.sh` 自己防住。删掉跨文件断言,报告表头直接写出实测版本。 **文档** — `edit-body` 改写为三种情形的表(`.cppm` 移动行号 / `.cppm` 原地等长 / 独立 `.cpp`);订正 SPEC.md 中已被实测证否的解释(决定因素是行号移动,不是成员函数体 进 BMI);记录「原地等长修改」这个具名缺口。 **测试** — 单测 +4 钉住门的四个分支(必须是单测:低成本 e2e 都继承载荷,fixup 对继承 载荷提前返回);e2e 237 / 238 两个方向都钉。 CI 18/18,本地 e2e 219 passed / 0 failed。 分析与两轮自我 review:`.agents/docs/2026-08-15-issues-426-427-analysis.md`
feat(bench): cross-platform benchmark suite, and the defects it expos…
…ed (2026.8.15.1)
一个跨平台、可扩展的构建引擎基准设施,以及**用它跑出来的、和 review 它时发现的**
一批缺陷修复。版本 2026.8.15.1。
## 1. bench/ —— 把一次性脚本变成测量设施
C++23 写、由 mcpp 构建,所以三个平台跑法一致(它替换掉的 shell 脚本只能在 Linux 跑)。
* **可断续**:测量单元 = `工程·variant·场景·引擎·轮次`,测完即 append+flush;
整份配置一个指纹,落 `.mbench/<指纹>/`。同配置命中续跑,改配置换目录。
实测杀掉后记录 12 个点,重跑跳过这 12 个、补完剩下 12 个。
* **接口/实现分离**:19 个模块单元全部拆成 `.cppm` 声明 + `.cpp` 定义。
判据是行为不变:拿重构前的二进制对照,`--list` 逐字节相同、**42 个生成文件
逐字节相同**、一次真实测量的 cell 结构完全相同。
* **Linux 标准数据集**:696 个测量点、每格 3 轮。未跑的格子、macOS、Windows
在表里一律标 `-`(未测),不留给读者当成结果。
* **CI 里的 bench matrix 已删**:它 10 格 32 条外部引擎臂里**豁免了 12 条**
(xmake 被豁免的比在测的还多)而 job 是绿的。改为本地 `run-standard.sh`。
## 2. 数据本身推翻了一个已发布的说法
新表有 old-vs-new 列,于是最显眼的 200x **不再是「一直如此的默认行为」**:
touch-hub 已发布 2026.8.11.3: 81.72s 本分支: 0.42s cmake: 83.21s
edit-comment 已发布 2026.8.11.3: 79.11s 本分支: 0.40s cmake: 83.21s
级联抑制以前并没有生效,是这个分支让它真正工作的。根 README 中英两份的表格现在
由 `report.py --headline` 从**同一份报告**生成,守卫核 50 个中位数。
## 3. bmi_schedule:开着它跑 CI,修到全绿
`[build] bmi_schedule = "on"` 打开后被 CI 在一个周期内否掉两次,两次都是真缺陷:
* **默认配置下并发完全没有上限** —— 模块自己写着「上限是信号量」,而没给
`--jobs` 时 `sched_cap = 0`,信号量被禁用,ninja 有多快就起多少个编译器
* **`.mcpp-sched` 令牌没有任何人回收** —— 对构建按一次 Ctrl-C 就永久少一个槽位,
攒够 cap 个之后下一次构建**无输出卡死**(e2e 实测卡满 600s)
* **phase 1 等 BMI 无上限**(phase 2 反而有界)、**supervisor 有一条退出路径不写 `.rc`**
* **`cmd.exe /c` 不用 CreateProcess 的引号规则** —— Windows 宿主交叉构建时编译器
少了 `-I`,报成「找不到头文件」
## 4. 七个 issue 的核实与修复
核实过程推翻了 issue 自己的三处说法(详见
`.agents/docs/2026-08-15-issues-412-422-analysis.md`):
* **#422**:归因给 `cxx_runtime` 是错的 —— CRT 模型来自 `linkage`,而构建 std 的
命令里**一个 `/M` 开关都没有**。所以**默认配置就已经不匹配**,不是 host-coupled
独有。修法与 `macos_deployment_target` 同形,且 CRT flag 进入
`std_build_commands` ⇒ 缓存键自动分叉。
* **#416**:归因给 `std.o` 是错的 —— 实测 `std.o` 有 **0 个未定义符号**,拖不动
任何库;纯 C 库的 `libstdc++.so.6` 来自链接一律用 g++。已拆出 #426。
本 PR 只做 std.o 按需链接(带传递可达性)。
* **#418**:`cxxRuntimeTests` 有**两个**同名字段,只有 `TargetEntry` 那个是死的。
* **#415**:`$ORIGIN` 进闭包,e2e 219 现在能**逐项**比对
(`closure == DT_RPATH, 4 entries, item by item`),不需要任何例外。
* **#417**:binding 无法求值是**一个事实**,不是每个产物一条(26 行 → 1 行)。
真因未定位,不动时序。
* **#421**:文档承诺了一个不存在的能力(宏保护的 `import` 其实被前置扫描直接拒),
中英两份都改对。
* **#412**:删掉一句劝退用户的假 note、一条与自身断言矛盾的注释,并补上
`module_extensions` 在**非 gcc 平台**的覆盖(此前只在 Linux 测过)。
## 5. 新增测试
`234`(bmi_schedule 端到端 + 陈旧令牌)、`235`(std.o 按需链接,含传递性)、
`236`(module_extensions 走各平台默认工具链)、`233` 的多处守卫加固,
以及单测若干(MSVC CRT 单一真源、`Origin::Artifact` 的 rank 与
`is_machine_local`、per-target 未知标量键)。
上游缺陷开了 #424(clang 两条)、#425(已修)、#426。
fix: 产物必须加载它链接的那一份库 —— 链接行顺序成为声明,共享库不再劫持运行时 (2026.8.11.3) (#414) `mcpp run` 一个 imgui/GLFW 工程,链接 rc=0,运行即死: undefined symbol: _ZNKSt13runtime_error4whatEv ## 缺陷一:`$ORIGIN` 被 SubOS 库视图遮蔽 2026.8.11.2(#413)首次把 SubOS 库视图(farm)写进产物 DT_RPATH,但它落在 `$ORIGIN` **之前**。而这两个目录在本生态里天然装着同名 SONAME —— mcpp 从 `compat.x11` 源码构建 `libX11.so` 部署到产物目录,xlings 又在 farm 里有 `xim:libX11`。于是**链接期用 A,运行期加载 B**。 真因不是"放错位置",而是**一条链接命令行的顺序由两个互不知情的生产者用 `+=` 决定**:`flags.cppm` 把 farm 拼进全局 ldflags(注释还写着 "so it is LAST"), `plan.cppm` 把 `$ORIGIN` 拼进 per-unit,而链接规则渲染的是 `$ldflags $unit_ldflags`。三处各自都对,合起来是错的。 新增 `mcpp.build.link_line`:把 per-unit 尾部声明成**具名槽位** (dependencies → cxxRuntime → runtimeFallback → loaderTag),相对顺序写在类型 里、由单测钉死。新增一个生产者必须先选一个槽 —— "选"正是"在产物自己的目录之前 还是之后"这个问题被提出来的地方。格式中立:槽位按职责命名,PE 的两个槽天然为空, Mach-O 的 dependencies 装 `@loader_path`,没有任何 `if (platform)`。 ## 缺陷二:共享库把自己的 C++ 运行时导出给了别人(ELF) `SharedLibrary` 与可执行文件共用 `Distributable` 角色,于是拿到同一份 self-contained 契约:`-static-libstdc++`。ELF 上这不是"私有一份" —— 只有一个全局 符号命名空间,共享对象导出它定义的每一个全局符号。一个**纯 C** 的 compat 包因此 导出了 777 个 GLOBAL 标准库定义(`libXau.so`:39KB 的 Xau + 9.5MB 的 libstdc++)。 可执行文件链接时 `-lX11` 排在驱动的 `-lstdc++` 之前,ld 用它满足了 `runtime_error::what()`,归档成员从不拉入 —— **exe 的 `-static-libstdc++` 变成 空操作,它的 C++ 运行时事实上是那个 `.so`**。缺陷一之所以致命,根源在这里。 共享库默认契约改为**按目标格式分档**: ELF toolchain-coupled 一个全局命名空间,先加载的定义胜出 Mach-O self-contained 机制本就是 -load_hidden,dyld 不归一; 且 toolchain-coupled 在 macOS 是死路(#202) PE self-contained 没有全局命名空间,导入按 DLL 逐个按名解析 **只有 ELF 的行为变了,而它正是有缺陷的那个**;Mach-O/PE 产物字节不变。 显式 `cxx_runtime = { shared = "self-contained" }` 仍可选回自包含,此时自动补 `-Wl,--exclude-libs`(实测:按归档基名匹配,与 `-l` 还是完整路径无关), 让内嵌的运行时留在动态符号表之外 —— 逃生舱不会重新打开这个洞。 ## 顺带修掉的架构债 `dist::default_contract` 自称"the role -> contract policy, in one place",实际 **没有任何生产调用方** —— 真正的策略在 `flags.cppm` 被第二次推导。这正是 `distribution.cppm` 开篇声讨的那类债("derived independently in five places") 换个位置复发。现在它是唯一真源。 `cxx_runtime` 补进 `[build]` 已知键白名单:它此前会打印 "unsupported key (ignored)" —— 而 "ignored" 是假的,`--strict` 还会直接拒绝 manifest。整个特性的 唯一入口不能一边工作一边说自己被忽略了。 ## 测试 - 新增 `test_link_line`(6):槽位顺序、空槽不产生多余分隔、顺序与赋值序无关 - 新增 `NinjaBackend.SubosFarmRpathFollowsTheArtifactsOwnDirectory`:断言在 **合成后的**链接行上 `$ORIGIN` 早于 farm(只看其中一个变量,正是原缺陷隐形的原因) - `test_distribution` +8:(Role × Format) 全表、`--exclude-libs` 只给共享库 - `test_manifest` +3:`shared` 键、未知角色键报错、标量拼写仍覆盖所有角色 - e2e 219 断言由「farm 是最后一个**绝对路径**条目」收紧为「**字面**最后一项」, 并补一条**行为**不变量(`LD_DEBUG=libs` 实测同名 SONAME 解析到 `$ORIGIN`)。 旧断言把 `$ORIGIN` 过滤掉了,对坏顺序与好顺序给出同一个结论;测试工程也从 `int main()` 换成消费依赖共享库 —— 否则它连 `$ORIGIN` 都不产生 - 新增 e2e 222:共享库不得导出 GLOBAL 标准库符号、必须 NEEDED libstdc++.so.6、 exe 无未定义 std 符号、显式自包含时护栏仍在 **红测**(两条新 e2e 对已发布 2026.8.11.2):219 报「farm is not the last entry」, 222 报「exports 713 GLOBAL standard-library symbols」—— 均以正确理由失败。 **⚠️ 行为不变量不得依赖崩溃**:缺陷二修好后崩溃会消失,依赖崩溃的断言会立刻假绿。 219 的不变量 4 因此测的是加载器的搜索过程,不是程序的退出码。 ## 实测(helloegui:imgui + GLFW + X11) | 判据 | 修复前 | 修复后 | |---|---|---| | DT_RPATH 尾部 | `… : <subos>/lib : $ORIGIN` | `… : $ORIGIN : <subos>/lib` | | `libX11.so.6` 解析到 | farm 里的 xim:libX11 1.8.10 | `$ORIGIN`(farm 未被试到) | | `bin/libX11.so` 导出 std 符号 | 2931(777 GLOBAL) | 0 | | `bin/libXau.so` | 9 557 936 B | 39 368 B | | exe 未定义 `runtime_error::what` | 有 | 无 | | 运行 | symbol lookup error | GUI 正常启动 | 分析:`.agents/docs/2026-08-11-runtime-search-origin-precedence-analysis.md` 计划:`.agents/docs/2026-08-11-origin-precedence-implementation-plan.md` Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
PreviousNext