Skip to content

Commit cfe4696

Browse files
Sunrisepeakspeak-agentclaude
authored
the c-abi probe measures the target it verifies, and a capability is answered where the answer exists (2026.9.20.1) (#678)
* the c-abi probe measures the target it verifies, and a capability is answered where the answer exists (2026.9.20.1) Four changes, one subject: information about a capability becomes available at three times, each being the earliest at which it exists (openkal SPEC 0.14 §6.2), and this engine had been answering two of those questions at the wrong one. ## The probe measured the build host The c-abi verification probe selected no target on every freestanding build. `Toolchain::crossTargetFlag` is set for hosted targets only --- its own comment states the reason --- and a freestanding target's `--target` travels with the ISA flags that must accompany it, in `mcpp.freestanding.linkline`, which the probe never asked. `cenv::realise` adds none either. The command line was therefore `-D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM -`, and clang answered for the machine it was running on. On a Linux host that machine satisfies `__unix__` defined, `_WIN32` undefined, 32-bit `wchar_t`, so the check passed for the wrong reason. On a Windows host it reported `_WIN32` defined and a 16-bit `wchar_t`, and 2026.9.18.3 read those two as a `--target=` substitution failing to strip host predefines. Clang's predefines follow the target: `--target= riscv64-none-elf` on a Linux host reports `__riscv`, no `__linux__`, and `__SIZEOF_WCHAR_T__` 4. Had that flag been on the command line it would have answered 4 rather than 2; 2 is the evidence that it was absent. The probe now receives the freestanding compile prefix. `hostStripMacros` is removed, and its removal is the point rather than a tidy-up: it deleted the one piece of evidence that said the probe was measuring the wrong machine. `cenv_probe::assemble_argv` holds the invariant --- every piece is legitimately empty in some configuration, so none of them could carry it at the call site --- and refuses a freestanding argv that selects no target while accepting a native hosted one, where the host IS the target and the absence is the decision rather than its omission. The three `hostStripMacros` tests are replaced. They pinned whether `-U` reached the command line, which is the mechanism; what matters is whether the probe measures the right machine, which is the property. The five new `CenvProbeArgv` tests reach it with no cross toolchain at all. ## builtins: the conclusion stands, the mechanism beside it did not `cenv.cppm` said clang's bundled `intrin.h` / `mm_malloc.h` were already closed by `-nostdlibinc`. That flag removes the standard system include directories and leaves clang's own resource directory in place, which is what `-nobuiltininc` removes; with it present `intrin.h:12:15` still reproduces, character for character the diagnostic mcpp-index recorded for fmtlib.fmt. What closes both is the Cygwin-flavoured realisation: `mm_malloc.h:42` selects `__mingw_aligned_malloc` on `__MINGW32__` and falls to `posix_memalign` without it. No Windows loop-idiom builtin to disable, as before. ## A capability is enumerated by the consumer, at resolution openkal SPEC 0.14 §3.3 withdrew `hosted`, the one name it had given to a set of interfaces, because a name describing a class of environment is falsified by an environment nobody had in mind --- and that one was falsified inside its own ecosystem within a release. Enumeration by the consumer replaced it. mcpp now carries that enumeration for the `kernel-abi` layer: `provides-interfaces` on the package that supplies it, `requires-interfaces` on anyone. The engine learns no member of either set. The only operation is a set difference, so a specification may add an interface without a release of mcpp, and a misspelling produces a refusal naming the string rather than a silently disabled check. A graph whose provider states nothing keeps building: a package that states nothing is not a package that provides nothing. ## [c-abi.absent]: enumerate the exception, not the rule POSIX has about twelve hundred names and enumerating them is the mistake §3.3 records withdrawing. The exceptions are enumerable --- openkal-musl's README lists six, in prose that nothing executes and that was contradicted once already. `form` is required and closed: `link` is the shape openkal's own model requires of an implementation (§6.1 calls a run-time report of unsupportedness a defect), and the other two are departures from it, named so that a departure is something that can be counted. mcpp reads the list back when a link names a `link`-shaped entry. ## presents is frozen docs/22 now states that `presents` answers which environment-identity macros source sees and answers nothing about capability, and that its value set does not grow --- for the reason openkal gives for closing its core set. Design: .agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md Review: .agents/docs/2026-09-20-issue-674-design-review.md Co-authored-by: Claude Code <noreply@anthropic.com> * the [c-abi.absent] parser gets its own function, and the docs index is regenerated Two CI reports, both from the first push. clang 20.1.7 on Windows crashed during LLVM IR generation of `parse_string` (exception 0xC0000005, the frame naming the `[c-abi.absent]` block's compound statement). Every other host compiled the same source. This codebase has met the shape before --- a construct that is fine in a function and not in a large one inside a module interface unit --- and the answer is the same: the block becomes `parse_c_abi_absent`, a free function, and the reason is written beside it so the next reader does not inline it back. `.agents/docs/README.md` regenerated, and the ecosystem design's front matter carries a `status` the structure check knows. Co-authored-by: Claude Code <noreply@anthropic.com> * the [c-abi.absent] parser is an internal helper, in the plainest form that compiles everywhere clang 20.1.7 on Windows crashed generating code for this parser in three spellings: as a block inside `parse_string`, as a free function in the module's exported purview returning `std::expected<std::vector< CAbiAbsentEntry>, std::string>`, and with a pointer-to-member projection in its sort. Every other host and compiler took all three. What it takes is the plainest form available, and each part of that form is now measured rather than stylistic: an internal helper in the anonymous namespace rather than an inline function in the purview that every importer emits; an out parameter and `std::optional<std::string>` rather than `expected` over a vector of structs carrying strings; a comparator rather than a projection. The notes are beside it so a later tidy-up does not restore a shorter spelling and rediscover this on a Windows runner. Co-authored-by: Claude Code <noreply@anthropic.com> * docs/22: which of the two new keys an older engine ignores, and which it refuses `[kernel-abi]` is an unknown top-level table to an engine that predates it and is ignored, so a package adopting it needs no floor move. `[c-abi.absent]` is a new key inside a table mcpp does know, where an unrecognised key is a parse error naming it --- the mechanism that keeps a misspelling from silently disabling a declaration --- so a package adopting that one does. Co-authored-by: Claude Code <noreply@anthropic.com> * the execution record: what the measurement settled, and the three defects found on the way Co-authored-by: Claude Code <noreply@anthropic.com> * the wave's sandbox verification, and the reading from each of its two runs Every CHANGE section is run against the published engine first and must fail there. Recorded in the script: 2026.9.17.1 reports fails=3 and 2026.9.20.1 reports fails=0, and the one leg that passes on both is named together with why that is the documented behaviour rather than a hole. Co-authored-by: Claude Code <noreply@anthropic.com> * two defects found reviewing this branch: the wrong provider, and a name that is a prefix of another The interface list was read from the first package in the graph that stated one. A graph may carry more than one candidate for a layer --- a workspace member beside a dependency, a second implementation behind a feature that did not activate --- and only one of them is the provider this build resolved. Comparing a consumer's requirements against an implementation the build is not using is a wrong answer rather than a missing one. It now reads the resolved layer. `c_abi_absent_facility_advice` matched the symbol as a substring, and `undefined symbol: open` is a prefix of `undefined symbol: opendir`. A link failure would have been explained by a row with nothing to do with it, and an explanation that is confidently wrong is worse than the linker's own message. The name must now end where the diagnostic's name ends. Co-authored-by: Claude Code <noreply@anthropic.com> * the record: what this wave did not do, and the first two implementations whose sets differ Co-authored-by: Claude Code <noreply@anthropic.com> * the resolved provider is matched by name, not by substring `Layer::impl` is `name@version`. A substring test matched `openkal` against `openkal-linux@0.15.0` and would have read one implementation's interface list as another's. The name is what precedes the separator. Co-authored-by: Claude Code <noreply@anthropic.com> * the wave's self-review: five defects, and the one place this landing differs most from its design Three of the five were found by reading the branch's own diff and were green on every host: the interface list read from the first package that stated one rather than the resolved provider, a package name matched as a substring of `name@version`, and a symbol matched as a substring of a longer one. The other two were found by CI. The largest gap between the design and what landed is P7's L3, the link-time set difference. Without it a consumer's `requires-interfaces` is still only trusted: a package may declare fewer interfaces than it calls, pass resolution, and link on an implementation that provides them --- failing only on one that does not, on a user's target rather than in its author's build. Co-authored-by: Claude Code <noreply@anthropic.com> * the design under-estimated L3, and the two sentences that cannot both hold §5.7.3 said the link-time check maps undefined symbols to interfaces through SURFACE.txt; §5.5 said the engine knows no member of either set. The mapping can only come from the graph, so L3's real shape is a second generated table in each implementation's manifest --- about a hundred lines, from the same script. The cost and the benefit are both different from what was written, so L3 is not in this landing, and the consequence of its absence is now written down rather than left implied: a consumer's requires-interfaces is still only trusted. Co-authored-by: Claude Code <noreply@anthropic.com> * the absent-facility advice reaches both channels a failed build reports through A build reports failure on two paths: the one with a plan, and the fast path, which has none by construction. `ninja_backend.cppm` already records why that matters for `link_failure_advice` --- advice attached to only one of them "would appear or not depending on whether build.ninja happened to be up to date, which is exactly the kind of 'same decision in two places' this codebase keeps paying for" --- and the new `[c-abi.absent]` advice had been attached to one. The list travels between them in a file beside build.ninja, written when the plan emits it and opened only after a build has already failed. The fast path's purpose is to read as little as possible, so it reads nothing extra until there is a failure to explain. A graph that declares no absence removes the file rather than leaving it: a stale one would let the fast path explain a failure with a list the current graph never declared. Co-authored-by: Claude Code <noreply@anthropic.com> * the design points at the record of what landed and what did not Co-authored-by: Claude Code <noreply@anthropic.com> * the two probe tests that never ran anywhere now run `find_a_cxx_compiler` answers `/usr/bin/c++` first, which is GCC on every host this suite runs on, and GCC takes no `--target`. The two tests that check the probe measures the target rather than the host therefore skipped on every host including the CI shards with an LLVM toolchain --- coverage that looks like coverage and is not, which is the shape the rest of this branch exists to remove. They now look for a clang, and for one that actually has the back end they name: the host this was written on carries a vendor clang with neither RISC-V nor AArch64, so a PATH-only search would have skipped for a reason that has nothing to do with what is being tested. The search asks each candidate before accepting it, and falls back to the LLVM payload mcpp installs, which every shard resolving an `llvm@` toolchain has already downloaded. Measured here: 15 passed, 0 skipped, where it was 13 passed and 2 skipped. Co-authored-by: Claude Code <noreply@anthropic.com> * the #674 Path C design is marked superseded, with a pointer to what replaced it Co-authored-by: Claude Code <noreply@anthropic.com> * the probe fix, read from the probe's own cache rather than from a log After a freestanding build with a [c-abi] graph, the newest -dM dump in ~/.mcpp/build-cache/v1/cenv-probe reports __riscv, no __linux__, and a 4-byte wchar_t. The older dumps beside it report the opposite and are correct: those are hosted Linux targets, where the host is the target. What distinguishes them is the first line, and a freestanding probe used to look like the second. Co-authored-by: Claude Code <noreply@anthropic.com> * the refusal carries its code, because something reads it The mcpp-index compatibility measurement distinguishes "this graph does not supply what the member asked for" from "the member did not build" --- a distinction that decides whether a member counts against a compatibility figure --- and it was written to do so on the refusal code `interface-not-provided`. That code reached no output: `refusal::record` is read by `mcpp doctor` and by one branch of the build-database path, and a `mcpp build` prints none of it. The consumer's judge could never fire. The code is now printed in the diagnostic, the way E0006 is, and e2e 743 asserts it. A refusal that only a person can recognise forces every machine consumer to match prose --- and prose that a package's own compile error could coincidentally contain. Found by running the consumer's own criterion against the engine rather than reading it: a mechanism with no way to trigger is the defect class this whole branch exists to remove, and it had one of its own. Co-authored-by: Claude Code <noreply@anthropic.com> * the refusal-token table gains the four it was missing c-env-unrealisable, c-env-verification-mismatch and platform-dependency have been emitted since 2026.9.18.1 and were never listed, so a program classifying an outcome read them as `other` --- the token the table itself describes as "a refusal whose branch has not been given a token yet". interface-not-provided joins them, together with the note that it is the one token `mcpp build` also prints in its own message. Co-authored-by: Claude Code <noreply@anthropic.com> * the zh note is placed beside the token table, not in the page's own preamble The previous commit inserted it at the first paragraph a regular expression matched, which was the page's "not here" line, and left that line reading as the note's opening. Repaired and placed where its English counterpart is: after the token table and before the exit-code paragraph. Co-authored-by: Claude Code <noreply@anthropic.com> * the end-to-end reading: one requirement, two implementations, two answers Against the published implementations, a package stating `requires-interfaces = ["openkal.space"]` builds on Linux and is refused on Windows before anything is compiled --- because openkal-linux provides fifteen interfaces and openkal-windows fourteen, and the refusal names which, whose, and how many. This is the first instance of the reason SPEC clause 3.3 gives for withdrawing `hosted`: a name for a class of environment would have made these two implementations look alike. Also recorded: `mcpp index update` reports `index updated` while leaving the artifact sha where it was. Clearing the cache marker was not enough; the directory had to go. The criterion is the sha in `.xlings-index-version`, not the command's exit code and not the line it prints. Co-authored-by: Claude Code <noreply@anthropic.com> * the release notes name the reason token, as this file's own convention does `interface-not-provided`, together with the three that have been emitted since 2026.9.18.1 and were never listed in the token table. A release note that describes a refusal without naming the token leaves the machine consumer to find it by reading the source. Co-authored-by: Claude Code <noreply@anthropic.com> * the assembler asked PATH first, and every other tool in this engine does not `find_usable_nasm` called `which("nasm")` before looking in the sandbox. A machine with an assembler installed assembled with that one; a machine without downloaded the pinned `xim:nasm`. Three machines could produce three different objects from one source tree and no line of any of those builds said which assembler made them. Nothing else here is arranged that way: the compiler and the linker are a payload, the C library and the C++ runtime are packages, ninja and patchelf come from xlings, and ar/strip/objcopy are derived from the resolved toolchain's own directory and never as a bare name. The order is reversed. The host copy is kept --- an offline machine that already has a usable assembler should still build --- and when it is the one that served, the build names it rather than leaving it silent. docs/20 gains a section listing every host item mcpp reaches and the reason each is still there, in both languages. A host tool that reaches a build is not by itself the defect; a host tool that reaches a build silently is, which is why that section is a table rather than a prohibition. Co-authored-by: Claude Code <noreply@anthropic.com> * the absence table is top-level, because the nested spelling refuses the manifest `[c-abi].absent` reads better and is where this was written. Measured against the genuine published 2026.9.18.3 archive -- the index floor at the time -- on the exact manifest openkal-musl 0.17.0 publishes: nested, every mcpp older than this release refuses THE WHOLE MANIFEST on every target with `[c-abi] has no member 'absent'`. The `[c-abi]` parser enumerates its members and rejects the rest, and that strictness is correct: a misspelled `presents` must not silently disable a declaration. In the same measurement an unknown TOP-LEVEL table is ignored and the build completes. Everything this table does is diagnostic. `c_abi_absent_facility_advice` annotates a link that has already failed; no flag, link line or artifact depends on it. So an engine that ignores it produces exactly the raw linker error it produces today, while an engine that refuses it takes the package away entirely and forces the index floor up to this release -- costing every client stopped below that floor the whole index, for a note they merely would not have received. Top-level, openkal-musl 0.17.0 asks nothing of `min_mcpp`. The two spellings are indistinguishable in the parsed result, so no test that reads only the outcome can tell them apart. Three tests state the shape directly: `absent` is not a member of `[c-abi]` and the refusal names the top-level spelling; absences may be stated without an environment declaration, which leaves `declared` false; and the provider gate `[c-abi]` applies is restated rather than inherited, since moving the table moved it out from behind that gate. `prepare` now tests `cAbiDecl->declared` rather than the optional, because a `[c-abi-absent]` table alone sets the optional and has nothing in it for `cenv::realise` to realise. * the host-surface table was written from one sweep, and three were missing The table in docs/20 says it is "the whole of what does not" come from the graph or from xlings. It was derived from a single sweep -- every `fs::which` call -- and three other shapes reach the host without passing through one. Two of them are rows the table owes a reason for, and now has: * A command interpreter. `[hooks]` runs through `run_shell_deadline`, the xlings CLI through `run_streaming_bounded`, and the detached codegen command through the same `/bin/sh` (`cmd.exe` on Windows). A hook is a line the USER wrote in shell syntax; shipping a shell would change the language that line is read in, so what mcpp depends on here is not a tool it could package. * The MSVC toolset and the Windows SDK, reached when a user names `msvc@system` or when a managed toolset has no SDK payload beside it. Not redistributable, the same category as the Apple SDK -- and already handled the way the assembler now is: a managed toolset BINDS its SDK and ignores `WindowsSdkDir` even when set, because a pin the environment can overwrite is not a pin, and the fallback to the machine's SDK carries a note the caller must surface. The third points the other way and is recorded for that reason: `src/runtime/ elf` writes `/usr/lib` and `/usr/lib64` only to MODEL a loader's run-time search, and `mcpp.toolchain.registry` REFUSES a payload descriptor whose `frontend` names `/usr/bin/g++`. The four sweeps are now written into the page, with the exact count each yields, so the next reader checks the list rather than trusting it. The count of `fs::which` call sites is five, and each is a row. * the self-review gains the two findings that neither the diff nor CI could reach Findings 6 and 7 of this wave, and the reason each needed something the machine doing the work did not have. Six: `[c-abi.absent]` nested in a table the engine already knows, so every older mcpp refused the whole manifest. Invisible to a local build (this tree's mcpp is the one that knows the key), invisible to a test that reads the parsed result (both spellings parse to the same `CAbiDecl`), and invisible to CI. The only criterion was the binary the index's `latest` points at, fed the manifest openkal-musl 0.17.0 would publish. Seven: the host-surface table in docs/20 called itself exhaustive on the strength of one sweep. Two rows were missing. Their shared shape is worth the entry: something was written, and green, while the premise it rested on had been measured by nothing. Six rested on "an older engine ignores a new key"; seven on "what I listed is all of it". The compatibility row of §2 and step 4 of the execution plan said the two keys differed and that one of them needed a floor raise. Both now say what is true: both keys are top-level, both are ignored, neither asks anything of `min_mcpp`, and openkal-musl 0.17.0 no longer waits on this release. * one predicate, named once, because both tables gate on it * the recorded readings are the ones the top-level table actually produces The verification script carried a reading taken while the absence table was still nested inside `[c-abi]`: three failures against 2026.9.17.1, one of them `[c-abi] has no member 'absent'`. That failure no longer exists, and recording it would have had the sandbox run confirm a shape the engine no longer has. Re-measured against the genuine published 2026.9.18.3 archive: fails=2, and fails=0 on this release. Each CHANGE section now has one leg that passes on BOTH, and the header says why that is the evidence rather than a hole -- both new tables are top-level, an older engine ignores them, so a graph that satisfies its requirements builds either way. What distinguishes the releases is the refusal in each, because an engine cannot refuse what it never read. The self-review's corresponding row moves with it. * the note about a declared absence was missing a closing parenthesis Every reader of it saw: note: the C library in this graph (musl declares that it does not supply The C library's name was interpolated from two separate conditionals --- an opening paren, then the name --- and the closing one was never emitted. It is one substitution now. NINE UNIT TESTS COVERED THIS FUNCTION AND NONE OF THEM SAW IT. They asserted `a.find("musl")`, which is true of the broken spelling as well: a criterion aimed at a substring of a sentence cannot see the sentence. The test now asserts the rendered clause, and a second one covers the other side of the same substitution --- an unnamed C library must leave no empty parentheses rather than `graph () declares`. WHAT ACTUALLY FOUND IT was running a real link. The nine unit tests covered the matching rules and the sidecar round-trip, and seven more covered parsing the table, but nothing ran the two together: that a real build writes the sidecar beside build.ninja, that a real link failure reads it back, and that what arrives is a sentence. e2e 744 does that, in four legs --- the linker's own message survives, the row's note reaches it, the sentence reads as one, and an absence is not reported as a defect in the build. Restoring the missing paren fails the third and only the third. The symbol the test uses is one nothing defines. `fork` is the real row in openkal-musl's manifest and every C library on a Linux host defines it, so a test written with it would link and assert nothing. * the label under a missing interface read as the claim the refusal denies The refusal listed what the implementation does not provide and then, directly beneath it, said: openkal.space provided by fakekernel (2 interfaces) which parses as "openkal.space is provided by fakekernel" --- the statement this refusal exists to deny. It now reads "the resolved implementation is fakekernel (2 interfaces), and none of those listed above is among them". EVERY ASSERTION ON THIS MESSAGE MATCHED AN IDENTIFIER, and an identifier sits in the right place under either wording: `grep -q fakekernel` is true of the sentence that says the opposite. e2e 743 now asserts the clause and rejects the old label explicitly; restoring it fails leg B and only leg B. This is the same shape as the missing parenthesis in the absence note, found the same way --- by rendering the message and reading it. The other five diagnostics this release adds were rendered too, and read correctly: `absent` written inside `[c-abi]` (which names the top-level spelling), `[c-abi-absent]` on a package that supplies no C library, an unknown `form`, a row that is not a table, and a table that is not a table. * e2e 744 depended on the host having a C runtime, and on a key mcpp ignores TWO DEFECTS IN ONE TEST, AND THE SHARD FOUND THE FIRST. The link it examines was an ordinary hosted one, so it needed startup files that the graph's C library --- a marker package supplying no C library at all --- does not provide. They came from the host: present on a developer's machine, absent in the container the Linux shard runs in, where the link died before it ever reached the symbol under examination: /usr/bin/ld: cannot find crt1.o: No such file or directory A test whose subject is a link diagnostic must not depend on anything else about the link succeeding up to that point. It is freestanding now (`-nostdlib -nostartfiles -static`, and an entry point of its own), so exactly one symbol is undefined and it is the one being examined. THE SECOND DEFECT WAS IN THE REPAIR. Those flags were first written as `[targets.<name>] ldflags`, which is not a key mcpp has: the manifest reports `unsupported key 'ldflags' (ignored)` and carries on. The link then still failed, still named the symbol, and still carried the note, so all four legs passed while the arrangement they rest on had not happened. `build.ldflags` is the spelling that exists, and the flags are now measured on the emitted link line rather than assumed. Leg E asserts the arrangement: no key was ignored, and the link never reached the host's C runtime. Putting the flags back in the target fails E and only E --- A through D go on passing, which is the whole reason E is there. A warning printed into a passing test is invisible. * the reason-token table and the engine no longer agree by being read docs/50's reason-token table is a machine interface: mcpp-index's compatibility measurement reads a token out of a refusal to tell "this graph does not supply what the member asked for" from "the member did not build", and that distinction decides a published figure. A token the engine emits and the table omits is a promise nobody can rely on. FOUR MORE WERE MISSING, AND THIS WAVE HAD ALREADY "ADDED THE MISSING ONES". `apple-sdk-absent`, `lld-required-absent`, `host-tool-toolchain` and `std-module-precompile` predate this branch and were absent the whole time; the earlier commit in this same branch added four others by reading the table against the code. A set compared by reading is a set compared by sampling. check_reason_tokens.sh compares both directions mechanically --- every token `refusal.cppm` can emit is a row, every row is a token some branch emits --- and requires the 简体中文 mirror to carry the same set, which a structural check counting headings cannot see. Removing a row from either page, or adding one no branch emits, fails it; all three were exercised. THE CHECK FIRST REPORTED `reason` AS A TOKEN. The table's COLUMN HEADER is `| `reason` | |`: a backticked name in the first cell, the same shape as every row under it. Selecting rows by "a backticked name at the start of a line" selects the header too. What distinguishes a row is a non-empty second cell, so the pattern requires one --- the object set is chosen by a property rather than by a syntax that the header also satisfies. Also corrects the assembler's degradation example in docs/20 and its mirror. It was written as `degraded:` with ten-space continuations; `diag::degraded` renders through `ui::warning`, so the real output is `warning:` with `impact:` and `hint:` labels, as every other example in these documents shows. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 05d03cd commit cfe4696

36 files changed

Lines changed: 5078 additions & 263 deletions

.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md

Lines changed: 359 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
#!/usr/bin/env bash
2+
# Ecosystem verification for the 2026.9.20.1 wave against the PUBLISHED mcpp
3+
# and index, run inside a SubOS sandbox with CN mirrors for xlings and mcpp.
4+
#
5+
# B64=$(base64 -w0 .agents/docs/2026-09-20-cenv-interfaces-verify.sh)
6+
# xlings subos new v920
7+
# xlings subos use v920 --sandbox --cmd \
8+
# "echo $B64 | base64 -d > /tmp/v.sh && MCPP_VERIFY_VERSION=2026.9.20.1 bash /tmp/v.sh"
9+
#
10+
# Run it once against the PREVIOUS release first
11+
# (MCPP_VERIFY_VERSION=2026.9.18.3): every section marked CHANGE must fail there
12+
# and pass here; every section marked GUARD must pass on both. A section that
13+
# passes on both releases in a CHANGE section measured nothing.
14+
#
15+
# The sandbox's $HOME persists between runs of one SubOS, so each section clears
16+
# its own directory. A section that cannot run says so and is listed again at
17+
# the end, because a run that reports only failures cannot be told from one that
18+
# examined nothing.
19+
# TWO RUNS, AND THE READING FROM EACH (host dry run, 2026-09-20, against the
20+
# genuine published archive of the older release rather than a local build):
21+
#
22+
# mcpp 2026.9.18.3 (published) fails=2
23+
# B a requirement the implementation does not provide must be refused
24+
# C an absence with an unknown shape must be refused
25+
# mcpp 2026.9.20.1 fails=0
26+
#
27+
# ONE PASSING LEG IN EACH CHANGE SECTION IS THE EVIDENCE, NOT A HOLE. Both new
28+
# tables are top-level, and an older engine IGNORES an unknown top-level table:
29+
# so B's first leg (a graph that satisfies its requirements) and C's first leg
30+
# (an absence with a known shape) build on both releases, and that is exactly
31+
# the backward compatibility this wave claims. What distinguishes the releases
32+
# is the REFUSAL in each: an older engine cannot refuse what it never read.
33+
#
34+
# An earlier revision of this file recorded three failures against 2026.9.17.1,
35+
# one of them `[c-abi] has no member 'absent'`. That reading was taken while
36+
# the absence table was nested inside `[c-abi]`, where an unrecognised member
37+
# is a parse error and the whole manifest was refused. Moving the table to the
38+
# top level is what turned that failure into the passing first leg above --
39+
# see docs/22 and the design's §5.7.5.
40+
set -u
41+
42+
VER="${MCPP_VERIFY_VERSION:?set MCPP_VERIFY_VERSION}"
43+
STORE="${MCPP_VERIFY_BIN:-$HOME/.xlings/data/xpkgs/xim-x-mcpp/$VER/bin/mcpp}"
44+
45+
fails=0
46+
skipped=""
47+
fail() { printf 'ASSERT-FAIL: %s\n' "$1"; fails=$((fails + 1)); }
48+
ok() { printf 'ok: %s\n' "$1"; }
49+
section() { printf '\n== %s ==\n' "$1"; }
50+
skip() { printf 'NOT RUN: %s\n' "$1"; skipped="$skipped
51+
- $1"; }
52+
unset XLINGS_ACTIVE_SUBOS
53+
54+
root="$HOME/verify-920"
55+
rm -rf "$root"; mkdir -p "$root"
56+
57+
section "A. identity and mirror"
58+
if [ ! -x "$STORE" ]; then
59+
skip "mcpp $VER is not in the store at $STORE"
60+
printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:- (none)}"
61+
exit 1
62+
fi
63+
got="$("$STORE" --version 2>&1 | head -1)"
64+
case "$got" in
65+
*"$VER"*) ok "mcpp $VER from $STORE" ;;
66+
*) fail "the binary at $STORE reports '$got'" ;;
67+
esac
68+
"$STORE" self config --mirror CN >/dev/null 2>&1 \
69+
&& ok "mcpp mirror set to CN" || fail "mcpp self config --mirror CN"
70+
71+
# ── CHANGE 1. A package states which interfaces of the layer it requires ─────
72+
#
73+
# The engine knows no interface name: it compares two sets and refuses before
74+
# anything is compiled. Both legs are here because only the second says the
75+
# comparison happened -- a build that succeeds proves nothing about a check
76+
# that never ran.
77+
section "B. requires-interfaces is answered at resolution (CHANGE)"
78+
b="$root/b"; rm -rf "$b"; mkdir -p "$b/impl/src" "$b/src"
79+
printf 'int fake_kernel_marker(void){return 0;}\n' > "$b/impl/src/lib.c"
80+
printf 'int main(void){return 0;}\n' > "$b/src/main.c"
81+
cat > "$b/impl/mcpp.toml" <<'EOF'
82+
[package]
83+
name = "fakekernel"
84+
version = "0.1.0"
85+
provides = ["mcpp:kernel-abi=openkal"]
86+
87+
[targets.fakekernel]
88+
kind = "lib"
89+
sources = ["src/*.c"]
90+
91+
[kernel-abi]
92+
provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"]
93+
EOF
94+
mk_root() { # $1 = requires-interfaces body
95+
cat > "$b/mcpp.toml" <<EOF
96+
[package]
97+
name = "iface-probe"
98+
version = "0.1.0"
99+
100+
[dependencies]
101+
fakekernel = { path = "impl" }
102+
103+
[build]
104+
allow_host_libs = true
105+
106+
[kernel-abi]
107+
requires-interfaces = [$1]
108+
EOF
109+
}
110+
mk_root '"openkal.stream"'
111+
rm -rf "$b/target"
112+
if (cd "$b" && "$STORE" build >/dev/null 2>&1); then
113+
ok "a requirement the implementation provides builds"
114+
else
115+
fail "a requirement the implementation provides must build"
116+
fi
117+
mk_root '"openkal.stream", "openkal.net"'
118+
rm -rf "$b/target"
119+
out="$(cd "$b" && "$STORE" build 2>&1)"
120+
if [ $? -eq 0 ]; then
121+
fail "a requirement the implementation does not provide must be refused"
122+
else
123+
case "$out" in
124+
*openkal.net*iface-probe*|*iface-probe*openkal.net*)
125+
ok "the refusal names the interface and the package" ;;
126+
*) fail "the refusal does not name both: $(printf '%s' "$out" | head -3 | tr '\n' ' ')" ;;
127+
esac
128+
if [ -d "$b/target" ] && find "$b/target" -name '*.o' -print -quit 2>/dev/null | grep -q .; then
129+
fail "the refusal arrived after something was compiled"
130+
else
131+
ok "nothing was compiled before the refusal"
132+
fi
133+
fi
134+
135+
# ── CHANGE 2. A C library states what it does not supply ────────────────────
136+
section "C. [c-abi-absent] is read, and a bad shape is refused (CHANGE)"
137+
c="$root/c"; rm -rf "$c"; mkdir -p "$c/libc/src" "$c/src"
138+
printf 'int fake_libc_marker(void){return 0;}\n' > "$c/libc/src/lib.c"
139+
printf 'int main(void){return 0;}\n' > "$c/src/main.c"
140+
cat > "$c/mcpp.toml" <<'EOF'
141+
[package]
142+
name = "absent-probe"
143+
version = "0.1.0"
144+
145+
[dependencies]
146+
fakelibc = { path = "libc" }
147+
148+
[build]
149+
allow_host_libs = true
150+
EOF
151+
mk_libc() { # $1 = the form value
152+
cat > "$c/libc/mcpp.toml" <<EOF
153+
[package]
154+
name = "fakelibc"
155+
version = "0.1.0"
156+
provides = ["mcpp:c-abi=musl"]
157+
158+
[targets.fakelibc]
159+
kind = "lib"
160+
sources = ["src/*.c"]
161+
162+
[c-abi]
163+
presents = "posix"
164+
data-model = "arch-default"
165+
wchar = 32
166+
167+
[c-abi-absent]
168+
fork = { form = "$1", note = "no process image duplication" }
169+
EOF
170+
}
171+
mk_libc link
172+
rm -rf "$c/target"
173+
if (cd "$c" && "$STORE" build >/dev/null 2>&1); then
174+
ok "a declared absence with a known shape is accepted"
175+
else
176+
fail "a declared absence with a known shape must be accepted"
177+
fi
178+
mk_libc sometimes
179+
rm -rf "$c/target"
180+
out="$(cd "$c" && "$STORE" build 2>&1)"
181+
if [ $? -eq 0 ]; then
182+
fail "an absence with an unknown shape must be refused"
183+
else
184+
case "$out" in
185+
*accepted-no-effect*) ok "the refusal names the shapes that exist" ;;
186+
*) fail "the refusal does not name the shapes: $(printf '%s' "$out" | head -2 | tr '\n' ' ')" ;;
187+
esac
188+
fi
189+
190+
# ── GUARD. A package that declares neither key is untouched ─────────────────
191+
section "D. a package declaring neither key is unchanged (GUARD)"
192+
d="$root/d"; rm -rf "$d"; mkdir -p "$d/src"
193+
printf '#include <cstdio>\nint main(){std::puts("plain");return 0;}\n' > "$d/src/main.cpp"
194+
cat > "$d/mcpp.toml" <<'EOF'
195+
[package]
196+
name = "plain"
197+
version = "0.1.0"
198+
EOF
199+
if (cd "$d" && "$STORE" build >/dev/null 2>&1) \
200+
&& "$d"/target/*/*/bin/plain 2>/dev/null | grep -q plain; then
201+
ok "a package declaring nothing builds and runs"
202+
else
203+
fail "a package declaring nothing must build and run unchanged"
204+
fi
205+
206+
# ── GUARD. openkal from the published index ─────────────────────────────────
207+
section "E. an openkal program from the published index (GUARD)"
208+
e="$root/e"; rm -rf "$e"; mkdir -p "$e/src"
209+
printf '#include <cstdio>\nint main(){std::puts("openkal ok");return 0;}\n' > "$e/src/main.cpp"
210+
cat > "$e/mcpp.toml" <<'EOF'
211+
[package]
212+
name = "openkal-hello"
213+
version = "0.1.0"
214+
215+
[dependencies]
216+
openkal-llvm-runtime = "0.12.0"
217+
EOF
218+
if (cd "$e" && "$STORE" build >/dev/null 2>&1); then
219+
if "$e"/target/*/*/bin/openkal-hello 2>/dev/null | grep -q "openkal ok"; then
220+
ok "an openkal program builds and runs from the published index"
221+
else
222+
fail "the openkal program built and did not run"
223+
fi
224+
else
225+
skip "openkal-llvm-runtime 0.12.0 did not resolve from the index"
226+
fi
227+
228+
printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:-
229+
(none)}"
230+
[ "$fails" -eq 0 ]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
---
2+
subject: review
3+
status: active
4+
---
5+
6+
# `__cxa_thread_atexit` 在 openkal-Windows 上:定位到一层,第二层未定位
7+
8+
- 日期:2026-09-20
9+
- 来源:mcpp-index 的 30-member 重测,doctest 与 spdlog 两个成员停在
10+
`ld.lld: error: undefined symbol: __cxa_thread_atexit`
11+
- 结论:**不要只修第一层。** 只修它会把一个构建期的响亮失败换成一个运行期的静默失败。
12+
13+
---
14+
15+
## 1. 最小复现(五行)
16+
17+
```cpp
18+
#include <cstdio>
19+
struct D { int v; ~D() { std::printf("dtor %d\n", v); } };
20+
thread_local D t{7};
21+
int main() { std::printf("v=%d\n", t.v); return 0; }
22+
```
23+
24+
依赖 `openkal-llvm-runtime = "0.12.0"`。
25+
26+
| 目标 | 读数 |
27+
| --- | --- |
28+
| `x86_64-linux-gnu` | 构建并运行 |
29+
| `x86_64-windows-gnu` | `ld.lld: error: undefined symbol: __cxa_thread_atexit` |
30+
31+
## 2. 第一层:已定位
32+
33+
`llvm/libcxxabi/src/cxa_thread_atexit.cpp:109`
34+
35+
```cpp
36+
#if defined(__linux__) || defined(__Fuchsia__)
37+
extern "C" {
38+
_LIBCXXABI_FUNC_VIS int __cxa_thread_atexit(Dtor, void*, void*) throw() { ... }
39+
}
40+
#endif
41+
```
42+
43+
**上游只在这两个系统上导出这个符号**,因为在别处别人已经导出了。实测:
44+
45+
```
46+
$ llvm-nm --defined-only .../x86_64-w64-mingw32/lib/libmingw32.a | grep -c __cxa_thread_atexit
47+
1
48+
```
49+
50+
——普通 MinGW 目标由 `libmingw32.a` 提供。openkal 把 C 库连同它的运行时一起换掉,
51+
于是**两边都以为对方会提供**。这与本轮其他几处同形:上游问的是「这是哪个 OS」,
52+
而真正的问题是「这个映像里还有没有第二个 C++ 运行时」。
53+
54+
文件里那段 fallback(`#ifndef HAVE___CXA_THREAD_ATEXIT_IMPL`,把析构挂在一个
55+
`__libcpp_tls_key` 上)是**完整的**,只是被这个守卫挡在导出之外。
56+
57+
## 3. 把守卫放开之后:链接通了,析构不跑
58+
59+
`#if` 上加一条本包自己的条件之后:
60+
61+
| | Linux | Windows(wine) |
62+
| --- | --- | --- |
63+
| 链接 | 通过 | **通过**(此前失败) |
64+
| `thread_local` 析构是否运行 | **运行** | **不运行** |
65+
66+
`examples/cxx` 加一条断言(在一个 spawned thread 里构造带析构的 `thread_local`,
67+
join 之后查标志),Linux `ok`、Windows `FAIL`
68+
69+
## 4. 第二层:两个假设,都被实测否掉
70+
71+
**假设一:PE`thread_local` 走 emutls,它自己的 pthread key 先于 libc++abi 的
72+
key 被析构,于是 `run_dtors` 读到的链表已经空了。**
73+
74+
否。key 析构里读 `thread_local` 在两个目标上都读到正确的值:
75+
76+
```cpp
77+
static thread_local int marker = 0;
78+
static void dtor(void*) { saw = marker; }
79+
// 线程里 marker = 42; pthread_setspecific(k, ...)
80+
```
81+
82+
```
83+
Linux : key destructor read thread_local as 42 (expect 42)
84+
Windows : key destructor read thread_local as 42 (expect 42)
85+
```
86+
87+
**假设二:`__cxa_thread_atexit_impl` 是弱符号,在 PE 上解析成了非空,于是走了
88+
`if (__cxa_thread_atexit_impl)` 那一支而不是 fallback。**
89+
90+
否。两个目标上都是 null:
91+
92+
```
93+
Linux : __cxa_thread_atexit_impl = 0 -> fallback branch
94+
Windows : __cxa_thread_atexit_impl = 0 -> fallback branch
95+
```
96+
97+
另有一条已确认为**正常**的:`pthread_key_create` 的析构在 Windows 上**会**在线程
98+
结束时运行(`pthread tsd dtor ran=1`)。所以不是 TSD 机制本身。
99+
100+
## 5. 为什么本轮不发这个补丁
101+
102+
| | 现状 | 只修第一层 |
103+
| --- | --- | --- |
104+
| 失败在哪 | **链接期** | 运行期 |
105+
| 调用方能否看见 | **能,链接器点名符号** | **不能,注册成功而析构不发生** |
106+
107+
第二种正是 openkal-musl 的 `[c-abi-absent]` 里叫作 `accepted-no-effect` 的那个形状,
108+
也是 SPEC §6.1 把「运行期报告不支持」称为缺陷的理由。**一个响亮的构建期失败,
109+
比一个静默的运行期失败好。**
110+
111+
补丁已撤回。第一层的定位、第二层的两个否定结果,以及最小复现,都在上面——下一个
112+
接手的人不必从 30 个成员的诊断重新走一遍。
113+
114+
## 6. 下一步的判据
115+
116+
1. 在 Windows 上确认 `run_dtors` **是否被调用**(在 fallback 里打一行,重建运行时)。
117+
- 被调用而链表为空 ⇒ 注册那一侧的问题
118+
- 没被调用 ⇒ `__libcpp_tls_create` / key 注册那一侧的问题
119+
2. 无论结论如何,修法必须让「析构会跑」与「链接会过」同时成立,或者两者都不成立。

0 commit comments

Comments
 (0)