Tags: mcpp-community/mcpp
Tags
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>
feat: 运行期搜索闭包 与 binding 降级 —— 链接期看得见的,运行期也要看得见 (2026.8.11.2) (#413) * refactor(platform): xlings 强相关模块归入 src/platform/xlings/ xlings 是 mcpp 面对的一个平台 —— 它决定 PT_INTERP、libc、库视图和运行期环境, 与 linux/macos/windows 处在同一层。此前它散在 src/xlings.cppm 与 src/xlings/, 读起来像一个横切工具层,而它其实是分层里最底下那一层。 src/xlings.cppm → src/platform/xlings/xlings.cppm src/xlings/subos_info.cppm → src/platform/xlings/subos_info.cppm src/xlings/runtime_selection.cppm → src/platform/xlings/runtime_selection.cppm 模块名随目录走(mcpp.platform.xlings[.*]),与 src/platform/<os>/ 的既有约定一致。 命名空间保持 mcpp::xlings::* 不动:238 处调用点的重命名换不来任何架构收益,而 src/platform/unix/bounded_process.cppm 导出 mcpp::platform::unixproc 已经说明 这个仓库不要求模块路径与命名空间逐段镜像。 RuntimeBinding 与后续的 runtime_search 留在 src/platform/ —— 它们是 provider 中立的契约类型(RuntimeBinding 有 providerId 字段正是为此),不是 xlings 专属。 顺带把三处引用旧路径的文本改掉:check_version_pins.sh 读 kXlingsVersion 的 grep 目标、bootstrap-mcpp action 的注释、发布 skill 的「唯一真源」表格。 其中第一处是硬的:不改它,pin 校验会以「读不到 kXlingsVersion」失败 —— 而它 是唯一能机器化捕捉 pin 漂移的东西。 * fix(runtime): SubOS 缺声明降级,不再让构建失效 (openxlings/xlings#543) Windows 上 xlings 不写 subos_info 块,而 mcpp 把「缺声明」当成错误返回, prepare 无平台条件地把它变成 std::unexpected —— 于是每一次 mcpp build / mcpp test 都停在一条讲 GL 驱动的消息上,在一台没有 ELF、没有 PT_INTERP、没有私有 libc 的机器上。 error: selected SubOS 'default' cannot provide a RuntimeBinding: … does not describe itself … a GL application will not find its drivers 回归窗口已核:git log -S 只命中 PR #400(它把返回 string 的软函数换成了 expected), 且该 commit 在 2026.8.8.4 的 bump 之后 ⇒ 首次随 2026.8.10.2 发布。与报告 「降级到 2026.8.8.4 就能跑」逐字吻合。 判据改为一句话:矛盾报错,缺席降级。 点名的 SubOS 不存在 → 仍是硬错误。该请求无法被满足,换一个环境会让同一份 mcpp.toml 在不同机器上意味着不同 ABI。 SubOS 没有描述自己 → declared=false + 一条调用方必须打印的 note,构建继续。 runtime 规则报 inconclusive 而不是给出判决。 同一位置的第二颗雷一并拆掉:schema 检查是 !=,而它的读取器 subos_info::read 明写着 「更高的 schema 照读,取我们认识的字段」。xlings 写出 schema 2 的那天,全平台所有 构建会同时停摆 —— 这是 index-floor 事故的第二次转生。改为上限语义:发布数据不得 使读它的程序失效。 诚实的边界:Linux 上降级 binding 确实拿不到 payload(mcpp 拒绝猜一个 libc 版本), 所以链接会回落宿主并被 hermeticity 检查如实拦下。note 现在把这句话说出来,而不是 承诺「构建不受影响」—— e2e 221 断言的正是这个:失败可以,但必须是关于 C 运行时的 失败,不能是关于 binding 的失败。 e2e 221 刻意不声明任何能力。带 `# requires: elf` 或 `gcc` 会让它在唯一需要它的平台 上被跳过 —— 与 217 从未在 Windows/macOS 跑过是同一形状。 RuntimeBinding 同时新增 searchDirs 字段(SubOS 库视图),本 commit 内无消费者。 * feat(runtime): 运行期搜索闭包 —— SubOS 库视图进 DT_RPATH,末位 mcpp 在编译与链接两条线上都发 --sysroot=<subos>,所以 subos 提供的库零 flag 就能 解析。运行期的搜索路径却是另一套独立推导(只有工具链载荷目录)。同一个决策 「subos 是我的根」推导了两次,而第二次的输入集合小了一整个库视图: $ mcpp build rc=0 $ ./bin/app libGL.so.1: cannot open shared object file (127) $ mcpp why runtime validation: pass ← 假绿 新增 mcpp.platform.runtime_search:一条搜索目录的来源、次序、是否机器本地。 放 platform/ 是因为它描述的是加载器怎么搜(物理),而 elf_runtime 必须读它 —— 放 build/ 会让 platform 反向依赖 build。它只 import std,四个消费者共读一份。 次序 = 不可变性递减,farm 在最后。载荷目录装一次不再动,<subos>/lib 每次 xlings install 都重写。载荷在前 ⇒ libc/libm/libstdc++ 永远从被 pin 的载荷解析, farm 只补没人提供的;farm 在前 ⇒ 一次安装能在事后换掉一个已链接产物的 libc。 今天 farm 的 libc.so.6 就是指向那个载荷的符号链接,所以两种次序行为相同 —— 这正是它必须被写下来并断言的理由。 假绿是独立缺陷,且必须先修:resolve_needed 搜完 rpath 后回落到宿主默认目录, 而宿主通常自带 libGL.so.1 ⇒ 模型认为解析到了。但产物跑在私有加载器下,它的默认 路径里没有宿主目录 —— 模型模拟的是宿主加载器,产物用的是私有加载器。现在宿主 默认目录只在非 hermetic binding 下参与。 第四种判决 Unresolvable:hermetic 下一个解析不到的 DT_NEEDED 是可证的失败 (整条搜索路径由 mcpp 算出,没有 ld.so.cache 兜底),把它归到 Inconclusive 是 把可证的事说成没查过。失败门改问 verdict.blocking(),而不是枚举状态。 护栏两条,与加载器标签契约同一判据:非 ELF 不发(Mach-O/PE 没有这个概念), 交叉目标不发(farm 属于宿主 subos)。 farm 不进 linkIntent.runtimeSearchDirs —— 那个字段会流向 runtimeLibraryDirs, 再变成 mcpp run 的 LD_LIBRARY_PATH,污染它拉起的每个子进程包括宿主二进制。 farm 只按对象可达(DT_RPATH),这是上一轮图形设计里明确立下的规矩。 载荷目录向发出它们的同一个函数要(resolve_link_model(...).libDirs): plan.toolchain.linkRuntimeDirs 只有 clang 会填,第一版记录只有一条 farm 而产物 DT_RPATH 有三条 —— 记录与产物不一致,正是这套东西要消灭的形状。 不读 $XLINGS_SUBOS_LIB。实测它指向当前 shell 的 subos,而 mcpp 有自己的 registry home,两者由不同物理 glibc 载荷支撑 —— 继承它等于给产物挂第二套 libc。 改为主动声明 XLINGS_SUBOS_LD_PATHS=0(xlings#540 的退出),今天是无操作。 观测:resolution.json 的 runtime.search.closure(保序,带 origin 与 machine_local),mcpp why runtime 按加载器次序打印。 测试:219 断言产物 DT_RPATH 末位是 farm、首位载荷目录真的有 libc,并要求记录与 产物逐条一致;220 从「farm 有而载荷没有」的差集里取一个库,--no-as-needed 让它 成为 DT_NEEDED,然后真的 exec —— 只有跑起来才能戳破假绿,因为宿主也有 libGL; 另一半用一个 SONAME 谁都提供不了的库断言构建变红并指名。215 的机器本地前缀从 data/xpkgs 扩到整个 mcpp home(farm 在 registry/subos 下,原来会漏过去),并补 --mode system 档 —— 它是唯一不重写 rpath 的档,此前没有任何用例检查过它。 * release: 2026.8.11.2 —— 文档、xlings pin、设计与实施记录 版本两处同步(mcpp.toml + src/version.cppm),内带 xlings pin 升到 2026.8.11.2 并同步全部 workflow/action 引用点;check_version_pins.sh 通过。bootstrap pin (.xlings.json)不动 —— 它必须指向一个已发布并进入索引的版本,发布收尾时才前移。 文档: - 08-toolchain-internals(中英)新增 §2.3 运行期搜索闭包:四种 origin、次序与 理由、两条护栏、host_default 为什么只对非 hermetic 产物成立、退出声明。 - 同一份的 §2.1 改掉一句现在是假的话:「contract 缺失/不兼容会 hard error」。 改成矛盾报错、缺席降级,并写明 Linux 上降级确实会失去 payload-first。 - 02-pack-and-release 补一句所有档位都不携带构建机路径,并指出 system 档不是 「保留构建时的样子」而是「目标机提供一切」—— 后者无法用本机绝对路径表达。 设计与实施记录进 .agents/docs。两份文档都在顶部标了与实施的出入,每一处都是被 实测推翻的:system 档早就剥干净(所以那一项从「实现」变成「补测试」)、 farm 不能复用 linkIntent 字段(会流进 LD_LIBRARY_PATH)、载荷目录不能只读 linkRuntimeDirs(GCC 上是空的)。 * fix(runtime): 「可证」要真的可证 —— 三处收窄,两处由 CI 抓到 上一 commit 把 hermetic 下解析不到的 DT_NEEDED 判为可证失败。方向对,范围过宽。 ① 产物的格式由产物决定,不由 binding 决定。 Linux→Windows 交叉构建用的是宿主 binding(Linux/glibc/私有加载器 ⇒ hermetic), 产物却是 PE。「不是 ELF」这条解析错误落进 unresolved,于是 crosswin.exe 被判成 「缺一个共享库」而构建失败 —— 一个连 DT_NEEDED 都没有的文件。 validate_runtime_artifact 现在先看 resolution.artifactIsElf。 ② unresolved 混装了三种东西,只有一种可证。 「找不到的 SONAME」「读不了的对象」「512 对象上限」此前同住一个 vector。后两种是 关于检查本身的陈述 —— 没能看的检查什么都没证明。拆出 unresolvedSonames,升级只看它。 ③ allow_host_libs 必须同时退出两个阶段,否则它意味着两件事。 它本就关掉链接期 hermeticity 检查。既然用户已声明「我有意伸到沙箱外」,mcpp 就不能 再断言产物起不来 —— 他们可能用 LD_LIBRARY_PATH 跑,或装在私有加载器确实会看的地方。 该档下未解析的 NEEDED 报 inconclusive 并指名,不阻断。谓词与 hermetic.cppm 逐字同源 (manifest 键 OR 环境变量),否则「链接放行、闭包判死」是两者中最坏的组合。 顺带把 e2e 206 的一条假绿改成真话。它的「安全宿主 DSO 对照」断言 status==pass, 而 LD_DEBUG=libs 实测:私有加载器的内建默认路径是 glibc 载荷自己的构建期前缀 (…/fromsource-x-glibc/2.39/lib),本机根本不存在,/usr/lib 从不被查,产物 exit 127。 它此前报 pass 只是因为闭包模型回落到宿主目录 —— 与那个被当成 pass 的 GL 程序同一形状。 而这份文件自己的注释「execution is not part of this link-physics control」正是让这条 假绿站住的理由:从来没人运行过它。改为断言 inconclusive + 诊断里指名 libtinfo 与 allow_host_libs。 219 的断言也放松到正确形状:farm 必须是最后一个「绝对路径」条目,而不是字面最后一个。 $ORIGIN 相对条目是另一类东西(它随产物走,不指本机),真实 GLFW 工程的 DT_RPATH 就以 … : <subos>/lib : $ORIGIN 结尾。 * fix(runtime): 闭包模型只看产物真正会看的地方 resolve_needed 此前直接读 binding.searchDirs,而**护栏在 plan 上**:交叉目标不发 farm、非 ELF 格式不发。于是模型会拿本宿主的 x86_64 farm 去解析一个 aarch64 产物的 DT_NEEDED —— 那条路径根本不在它的 DT_RPATH 里 —— 然后报一个目标机不会兑现的 pass。 改为经 additionalSearchDirs 从 plan.runtimeSearch 里取 SubosFarm 条目,与发出去的 那份逐条同源。**模型必须看产物看的地方,不能更宽** —— 更宽正是这一轮在修的形状。 * test(e2e): 220 自带一个 farm,不再靠机器碰巧装了什么 原来的那一半只能测「这台机器恰好有、而载荷没有」的库。CI 的 SubOS 里没有这种库, 于是它 SKIP —— 最需要它的地方,那条决定性断言从来没跑过。 新增 half 0:在测试自己的临时目录里建一个工程级具名 SubOS (`<proj>/.mcpp/.xlings/subos/probe/lib`),把 mcpp 自己构建的探针 .so 放进去, 然后断言产物**真的跑起来**。不写任何共享状态 —— 往本机真实 SubOS 里塞探针库正是 「一个测试的失败源于上一次运行」那类缺陷,这套件已经为它付过一次学费。 `allow_host_libs` 是必需的,而且不是绕开:没有 subos_info 的 SubOS 说不出自己的 runtime,mcpp 拒绝猜 libc 版本,hermeticity 检查如实报告回落宿主。那条声明就是 「对,我是故意的」。 顺带把 219 与两份文档里那句「farm 是最后一条」精确成「最后一条**绝对路径**」。 * refactor(runtime_search): 删掉两个只有测试在用的导出函数 paths_of 与 parse_origin 在 src/ 下零消费者。导出面只有测试在撑,就是没有理由存在的 导出面。origin 名字的往返测试改成直接钉 to_string 的字面值 —— 那才是真正要紧的: 它们是 resolution.json 里 runtime.search.closure 的 origin 字段,改名等于改 wire 格式。 * fix(plan): farm 的护栏补上 libc 轴,并且「不匹配」必须是被证明的 原护栏只看 os + arch。x86_64-linux-musl 两者都对得上,于是一个 glibc farm 会落到 musl 程序的搜索路径上 —— 正是 rule B 在防的载荷混用。今天那个目标是 static (实测:根本没有 dynamic section,flag 是惰性的),所以这是为它不再是 static 的那天准备的。⚠️ 第一版把「未声明」当成了「不匹配」:没有 subos_info 的 SubOS 没有 runtimeId, 于是它自己的库视图被拒绝了 —— e2e 220 的 half 0 当场变红。这是本次改动在别处正在 消灭的那个错误的又一次现形:**缺席不是矛盾**。改为只有两边都说了话且说的不一样才算 不匹配;任一侧未知,就交给 os/arch 护栏决定。 补一张护栏矩阵单测(host / 换 arch / 换 libc / 换格式 / 未声明),因为逐条端到端 验证每一格都要一整套交叉工具链,而它防的失败是静默的。 * docs: 记录落地后被 CI 与自查推翻的三处 每一条都是「可证」被用得太宽 —— 而这份设计本身就是在修同一种病。方向对不代表范围对。 §12.4 记下共同结构:unresolved 那种混装袋(三种不同强度的事实同住一个 vector, 消费者只能按最强的那种解读)是本轮所有误判的来源。 * fix(test): 护栏矩阵不能把「本机是 Linux」当成前提;doctor 呈现降级 note CI 的 macOS 与 Windows runner 上 RuntimeSearchClosure 两条红:它们用空 triple 断言 「拿得到 farm」,而空 triple 的意思是**本宿主的格式** —— 在 macOS/Windows 上那正是 「没有 ELF 搜索路径」,也就是这条护栏本身要表达的东西,不是它的例外。 改为:显式 Linux target 在任何 runner 上都断言拿得到(护栏读的是目标,不是 runner); 空 triple 那条按本机格式给期望值,并把理由写进断言消息。 顺带补上设计里承诺而没实现的一处:`mcpp why runtime` 现在呈现 binding 的 `declared` 与 `note`。只在发生那次构建里出现过一次的降级,是事后查不到的降级 —— 而「我的 verdict 为什么是 inconclusive」正是这个命令存在的理由。 * docs: 观测那一节记下实际落地位置(declared/note 归 why runtime,不归 doctor) doctor 面向「这台机器健康吗」,而 binding 是某一次构建的决定 —— 它属于解释那次构建 的命令。写下差异,而不是让文档说一件没做的事。 * test(e2e): 202 用 here-string,别让 pipefail 把「匹配成功」读成失败 `set -o pipefail` 开着,而 `grep -q` 一匹配就退出 —— 于是 `echo "$out" | grep -q` 让 echo 往一个已关闭的管道写、吃到 SIGPIPE,整条管道报 141,**尽管匹配是成功的**。 它只在 `$out` 足够大、echo 还没写完时现形,对 `cache list` 来说就是「这台机器构建过 几个东西之后」。实测(开发机、热构建缓存,48808 字节): OLD(已发布 2026.8.10.3) 4/5 次失败 NEW(本分支) 3/5 次失败 同一个字符串 `grep -c kind` 数出 153 处匹配 ⇒ **与被测的 mcpp 无关,与本机缓存大小有关。** CI 缓存小,所以那边一直绿。 我差点把它当成本分支的回归报出去 —— 一个会随无关缓存大小失败的测试,必然会被读成 「当时手里那个改动」的回归。改成 here-string,两个二进制各跑 8 次全过。 * refactor(runtime_validation): 删掉那个从来没人调用的汇总谓词 `has_proven_mismatch()` 在本轮之前就是死的,我只是把它改名成 `has_blocking_failure()` —— 改名的死代码比死代码更糟。 真正的门在 `ninja_backend` 里逐个产物走,因为它要**指名是哪一个**并打印它的解释。 再放一个「有没有失败」的谓词读同一份数据,就是同一个决策两处推导,而没人调用的 那一处正是会悄悄不再一致的那一处。留一条注释说明为什么这里没有它。 --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
feat: 源文件角色表 与 build.mcpp 运行上限 —— 两个硬编码变成两条声明 (#272, #410) (#411) * feat: 源文件角色表 与 build.mcpp 运行上限 —— 两个硬编码变成两条声明 (#272, #410) (2026.8.11.1) 把「哪个扩展名是模块接口」和「build.mcpp 能跑多久」这两个决策从代码里拿出来, 变成 mcpp.toml 的两条声明,并顺手把它们背后的架构债与跨平台缺口补上。 新增 [build] module_extensions:additive 到内置 .cppm。声明一个扩展名会同时 让默认 sources glob 找到它、让它走模块规则(产 BMI、.o 无条件进链接)、并让 新鲜度快路径扫描它 —— 一个键而不是三处配置。拒绝已代表其他角色的扩展名。 新增 [build] build_program_timeout:优先级 env > 该包自己的 manifest > 内置 600s。超时报错点名要改的那份 mcpp.toml —— 依赖超时时改自己的那份不会有效果。 optional 承重:int 的话「没写」与「写 0」不可区分,而 0 意为不限。 架构:「扩展名 → 角色」原本在 9 个文件 20 处推导、8 份互不一致的清单。现在 分类只发生一次(SourceUnit::kind → CompileUnit::kind),下游读字段。#272 修了 链接侧却漏了 pick_rule —— 边上声明 BMI 而命令行丢了 -fmodule-output=。 实测(GCC 16.1 / Clang 22.1):Clang 根本不认 .ixx,把它当链接输入、退出码 0、 不产 BMI。而显式旗标在已识别后缀上幂等(Clang .cppm 的 BMI 逐字节相同)。 ⇒ 不维护「谁认哪个后缀」这张会过期且错了静默的表,永远显式告诉编译器。 跨平台:capture_exec_deadline 此前只在 POSIX 生效,Windows 直接回落无界路径, 于是 mcpp test --timeout / --build-timeout / 这个新键在那里全是空操作。现在 两侧各有实现(Windows 用 Job object 杀整棵树,否则孙进程攥着捕获管道会让 杀掉之后的读取挂住),process.cppm 单点 if constexpr 分派。 src/platform/ 拆成 unix/ windows/ linux/ macos/。 可观察性:mcpp self doctor 报告生效的扩展名表、超时值及其来源、deadline 是否 真的强制;module_extensions 零命中的条目告警。 顺带修掉三个发现的缺陷:isModuleInterface/isImplementation 是写而不读的死字段 (5 写 0 读、3 份不一致推导)⇒ 删除;is_implementation_source 漏 .mm 导致 Objective-C++ 对象永不进链接;stage 兜底 glob 漏全部三种汇编扩展名。 兼容:未配置时构建图零差分(同样的文件、BMI、链接对象、指纹目录)。 module_extensions 进指纹(改图形态),build_program_timeout 不进(不改任何边)。 设计:.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md * fix(platform): 未捕获的有界运行必须继承 stdio,而不是先缓冲后回放 自审发现的真回归。`run_exec_deadline` 是 `mcpp test` 非 JSON 模式跑测试二进制 的路径,原本继承调用方的 stdio —— 输出实时出现,且子进程的 stdout 是终端。 把它改成「捕获后在结束时一次性回放」有两个后果: 1. 长测试的输出全部憋到退出才出现,恰好抵消了 `mcpp test` 可观察性那一整 轮工作(「只有子进程输出、mcpp 一行没有」正是缓冲问题的指纹); 2. 子进程的 stdout 变成管道而非终端,gtest 之类会静默关掉彩色输出。 两侧启动器现在共用一条契约:`sink == nullptr` 表示「不捕获」,子进程直接继承 调用方的 stdio,但**仍然有界**。POSIX 侧不建管道也不设 dup2 file action; Windows 侧不设 STARTF_USESTDHANDLES,也不加 CREATE_NO_WINDOW(未捕获的运行 本来就是要给人看的)。`dispatch_bounded` 多一个 capture 形参把这个选择传下去。 * fix(cache): module_extensions 进依赖缓存键的 E 轴 指纹管的是 target/<triple>/<fp>/,全局依赖缓存是另一套键。一个声明了 module_extensions 的依赖产出不同的 .o/BMI,它的缓存键必须体现这一点。 今天不可达 —— 默认 glob 会跟着变,sourceGlobs 已经动了;索引包描述符按版本 冻结,version 在 D 轴。但这正是本 PR 在消灭的形状(同一决策漏一处),一行补上 比留着等它以后变成一次错误的缓存命中便宜。 不 bump epoch:老条目命令行里没有 -x c++,而该旗标在已识别后缀上幂等(产物逐 字节相同),沿用安全,不必让全网缓存作废。 * fix(platform/windows): 捕获路径必须把 stdin 封成 NUL 自审发现。被替换掉的 Windows 捕获路径经 _popen 走 shell,命令行尾部带 '< NUL' —— 这个文件的同伴 mcpp.platform.process 头注释就点名了原因:xlings / xim / curl / git 子进程在 bootstrap 期间阻塞在终端 stdin 上,逼用户反复敲回车。 新实现把父进程的 console stdin 直接透传给了被捕获的子进程,会静默把那个挂起 带回来。改为从 NUL 打开;打不开时退回 console 句柄而不是交一个无效句柄 ——「完全没有 stdin」的失败长得一点也不像「stdin 没被封」。 未捕获的子进程保持继承真实 stdin,与 run_exec 一致:mcpp run 就是要把终端交 给程序。 * refactor(prepare): 扩展名表按包建一次,不再每个生成物重建一次 * fix(manifest): 自动推断的 lib 备注要说出实际找到的那个扩展名 macOS e2e 抓到的:我把备注从「lib from .cppm in src/」改成了「lib from module interface in src/」,25_convention_mode.sh 断言的是前者。 这条我判断为文案改得不够好,而不是测试过时。备注该说的是**实际找到了什么**: .cppm 工程输出与从前逐字不变(那条断言原样通过),而声明了 module_extensions 的工程会看到「lib from .ixx in src/」—— 说 .cppm 才是名不副实。 顺带 CHANGELOG 补 2026.8.11.1 条目。 * fix(e2e): 218 的指纹断言不能靠 find|head -1 推目录 自审 + 全量套件抓到的:第 2 部分之后 target/ 下有两个输出目录, `find target -name build.ninja | head -1` 取到的是 find 恰好先走到的那个 —— 单跑绿、进套件红。改成直接问 mcpp 要指纹值(--print-fingerprint), 断言那个值本身,而不是一个目录列举的副作用。 (这正是「指纹目录随版本变,ls|head -1 会自查到旧产物」那条老坑的同一形状, 在一个专门用来抓这类问题的测试里又踩了一次。) * docs: 补实施记录 —— CI 19/19、本机 12 条失败的逐条对照结论、以及我在自己测试里重踩 head -1 的教训 --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
PreviousNext