2026.8.25.1 — the C library layer decides the link line, not the system - #503
Merged
Conversation
A predicate that ORs two layers was deciding something that depends on
one of them:
bool system_from_graph() const {
return kernelAbi.fromGraph() || cAbi.fromGraph();
}
Everything the link-side replacement drops — the payload's binutils
prefix, its library directories, its rpaths — is a way of reaching the
payload's C LIBRARY. The two layers move together in the arrangement the
predicate was written for, where an openkal target takes its kernel
interface and its C library from the same graph, and come apart in one
that is just as ordinary:
[dependencies]
openkal-linux = "0.5.4"
A backend that implements openkal ON TOP OF Linux, linked by a program
that still uses the payload's glibc. The driver went on asking for
startup files and the linker had nowhere to look:
error: hermetic link check failed
crt1.o (bare name — the linker cannot resolve it)
crti.o (bare name — the linker cannot resolve it)
crtn.o (bare name — the linker cannot resolve it)
⚠️ THIS SHIPPED IN 2026.8.24.1 AND REACHED EVERY BACKEND IN THE
ECOSYSTEM. That shape is how a backend is tested — openkal-linux,
openkal-macos and openkal-windows all build their conformance suite
against the platform's own C library. Their CI was pinned to an older
mcpp and kept passing; the defect surfaced only when the pin moved, four
releases later.
⚠️ AND NOTHING HERE HAD THE SHAPE. 278 e2e scripts and the combination
"kernel-abi from the graph, C library from the payload" appeared in
none. `285_kernel_abi_from_graph_keeps_the_payload_c_library.sh` asserts
it, and asserts the artefact RUNS — a program with no C library at all
would exit 0 from a link that merely succeeded.
The mingw branch takes the same predicate because the two are
complementary: its comment says the replacement "covers all three at
once", which holds only while each is the other's negation. Left
disagreeing, a mingw build whose kernel interface came from the graph
and whose C library did not would enter neither and emit no link line.
`graphTargetSide` is gone rather than left unused — keeping the name in
scope leaves the wrong question one keystroke away.
Located by bisecting in CI rather than locally: three branches, one line
each, run in the environment that was failing.
8.21.3 green
8.24.1 red ← #486
8.24.4 red
Local full e2e against the fix: 252 passed, 2 failed, 24 skipped. The
two failures are `208_private_libc_stays_in_the_binary` and
`62_runtime_library_dirs`, which fail identically before and after this
change and pass in CI — a property of this machine, and stated here
rather than folded into the count.
Three corrections to the fix in the previous commit, each from a
measurement rather than from re-reading it.
⭐ THE PREDICATE IS `cAbi.prebuilt()`, WHICH ALREADY EXISTED.
`c_library_off_payload()` spelled the question as a list of the origins
it was not — `fromGraph() || absent()` — and `Origin` has four values,
so it answered three and went quiet about `Xpkg`. A C library from a
prebuilt sysroot is no more the payload's than a graph-built one is, and
it would have taken the wrong branch. The question the link line asks is
whether the C library came from a directory that existed before
resolution, which is the distinction this module's own header draws and
the one `Layer::prebuilt` is named for. No new predicate is needed; this
is the third site in the file to read that same fact.
⭐ AND THE NINJA FIXTURE NOW HAS A TARGET SIDE.
`minimal_plan()` left `TargetSide` default-constructed — every layer at
`Origin::None`, which is not a native build but "nothing resolved". It
got away with that while the gate was `kernelAbi.fromGraph() ||
cAbi.fromGraph()`, false for all-None and for a payload build alike.
Asking the real question separates them, and the fixture then described
a target with no C library while all 44 assertions in the file are about
one that has the payload's. `resolve` gives a plain native build
`cAbi = { Payload, … }`; the fixture now says so.
⚠️ AND THE VERSION IS 2026.8.25.1, NOT 2026.8.24.7. The date rolled over
while this was being written.
Five unit tests, one per `Origin` plus one asserting that the kernel
interface's origin does not participate — the defect itself, written as
a check. A predicate spelled as a list of cases covers the cases its
author thought of; four tests against a four-valued enum leave a visible
hole when a fifth value arrives.
unit 93 passed; 0 failed
e2e 285 fix → ok, links against the payload's C library and runs
24.6 → hermetic link check failed (crt1.o, crti.o, crtn.o)
The entry described `c_library_off_payload()` — a predicate that was replaced before this branch was pushed, because spelling the question as a list of the origins it is not answers three of `Origin`'s four values and goes quiet about `Xpkg`. What the code does is read `Layer::prebuilt()`, which already names the distinction this module's header draws: a directory that existed before resolution, against packages that had to be resolved first. Adds what was missing beside it — the rejected intermediate spelling, the ninja fixture's target side, and why the unit tests are written one per enum value rather than one per case the author thought of.⚠️ A changelog that describes an earlier draft of a fix is worse than one that omits it: a reader who greps for `c_library_off_payload` finds nothing in the source and has no way to tell which of the two is stale.
Eight e2e scripts mentioned openkal before today and SEVEN used a
synthetic manifest — a package invented on the spot that claims
`provides = ["mcpp:kernel-abi=…"]`. Those test what the engine does with
a declaration. They cannot test what it does with the ecosystem, and the
difference is not academic:
#486 replaced the link line whenever
`kernelAbi.fromGraph() || cAbi.fromGraph()`, which is right for a
graph-supplied C library and wrong for a payload one. Every openkal
backend broke. mcpp's CI stayed green through four releases.
mcpp names no implementation, and that separation is the point of the
`mcpp:<layer>` capabilities. It is not a reason to leave the engine
untested against the one ecosystem that exercises every layer it models
— the direction that matters here is mcpp → openkal: a change to the
engine must not break a stack that did nothing.
286 — the whole target side from packages, on this machine. Asserts each
layer separately (a report that lost one would still have three, and a
test grepping for `graph` would pass on a build that took its C library
from the payload), that the image is static, that it has no INTERP
segment, and that it runs and prints the right thing.
287 — the same stack for aarch64. `aarch64-linux-musl` is a `verified`
row and the acceptance target of #492, and it appeared in ZERO of 278
scripts; what verified it was a probe run by hand, once. Crossing is
where a target side from the graph earns its keep and where its mistakes
show: a native build that quietly takes the payload's C library still
runs, a cross that does produces an artefact for the wrong machine.
⭐ AND 287 ASSERTS THE HELPERS, NOT ONLY THE ARCHITECTURE. clang enables
`+outline-atomics` on aarch64 whenever the compiler runtime is
compiler-rt, and the `__aarch64_*` helpers live in compiler-rt rather
than anywhere the engine could supply. A build that links because the
feature was switched OFF reads identically from the outside; the LSE
instruction count separates the two.
Measured locally against this branch's binary:
286 kernel-abi / c-abi / c++-abi from the graph · static ·
no INTERP · prints `x0x1x2x3 4`
287 ARM aarch64 · static · 14 helpers defined, 0 undefined ·
7 LSE instructions · `1 42 47` under qemu-aarch64
…ed to move Two defects in one workflow, both found by reading a failure that was neither of them — `build 3 targets on macos` went red on PR #503 with: fatal: unable to access '…/openkal-llvm-runtime/': Could not resolve host: github.com ⭐ THE CLONE NOW USES `git_clone_retry.sh`, WHICH ALREADY EXISTED. That helper's own note counts "six call sites, one failure mode"; this workflow was not among them, and the mode duly arrived here. One name that did not resolve ended a 120-minute job in its first minute, beside a real failure it had nothing to do with. Every `git clone` in this repository now retries, except two inline copies in `ci-aarch64-fresh-install.yml` whose comment explains why they cannot use the script: that job checks the repository out LAST on purpose, so the helper is not on disk yet.⚠️ AND `OPENKAL_BRANCH` WAS `feat/openkal-closure`, LONG AFTER THAT BRANCH MERGED. The comment beside it said "when they are on `main` this becomes `main`" — and the moment for that passed without anyone reading it again. Every run since verified this engine against a tree the ecosystem had left behind. That shape is not incidental today. Both regressions found while reviewing #503 hid behind exactly it: an ecosystem repository pinned to an older mcpp, an engine change that broke it, and two green CIs on either side of the break. A fixed reference kept a check passing by keeping it out of date.
`freestanding::compile_flags` takes `targetCxxRuntime` because the answer
changes with it: a freestanding target whose graph supplies a C++ runtime
is compiled WITHOUT `-fno-exceptions`, one whose graph does not is
compiled with it. `flags.cppm` passes it. `build_axes` did not, so the
cache key hashed the flags of the other configuration.
⭐ THE FAILURE IS A HIT ACROSS AN INCOMPATIBILITY, NOT A MISS. Two
configurations that must not share a slot produced the same key, and the
second build loaded the first's BMIs:
error: exception handling was enabled in precompiled file
'openkal.stream.pcm' but is currently disabled
The cache itself showed the shape: `openkal@0.7.0` held six fingerprint
slots with five differently-sized copies of that one BMI. Slotting per
configuration was working; choosing the slot was not.
⚠️ DORMANT UNTIL THE PARAMETER EXISTED. Before #486 the two computations
agreed for every input, so a key that ignored one of them was still
correct. The defect is that a cache key derived its inputs a second time
rather than reading what the build uses — which stops being harmless the
moment the derivation gains an argument. This is the second regression
from that PR found today, and both have the same shape: a new condition
that holds only in the arrangement its author had in front of them.
Bisected against the ecosystem's own bare-metal example, which its CI
builds green on every run because that CI is pinned to an older mcpp:
8.20.2 green ELF 64-bit LSB executable, UCB RISC-V, 40080 bytes
8.21.2 green
8.21.3 green
24.6 red
25.1 red
Three unit tests, on `build_axes` rather than on a hand-built
`BuildAxes` — that fixture cannot express this, because the defect is in
the derivation. Proved both ways: with the argument removed again,
FAILED CacheKey.AGraphSuppliedCxxRuntimeChangesTheFreestandingFlags
FAILED CacheKey.TheTwoFreestandingConfigurationsDoNotShareASlot
and the third — a hosted target's flags are unchanged either way — stays
green in both, which is what keeps this from being read as "the key now
varies with something it should not".
⚠️ The other unparameterised call site, in `linkline.cppm`, is left
alone and now says why: it builds a LINK line, and `-fno-exceptions`
means nothing to a linker.
e2e 288 and 289 come with it. 289 sweeps every target the ecosystem
serves from one host — which is cheap precisely because this stack takes
its target side from packages rather than from a payload that exists on
one machine — and it is what found this.
Third site with the same shape, found by the first item of the review
this PR's own tests made possible. `mi.graphCxxRuntime` decides whether
the contract table reaches for the payload's C++ archives, and it asked
`system_from_graph()` — an OR over two layers. A program whose kernel
interface comes from a package while its C library and C++ runtime are
the payload's is served by those archives, and the OR said otherwise:
undefined reference to `__cxa_allocate_exception'
undefined reference to `std::runtime_error::runtime_error(char const*)'
measured on `openkal-linux = "0.5.4"` with a `throw` in main.
⚠️ THIS SITE HAS NOW BEEN WRONG IN BOTH DIRECTIONS. It was
`targetCxxRuntime` first, which failed a C program (no C++ runtime in
its graph, answer "no", read as "the payload's is right"); #486 replaced
it with the OR, which overshot. The C library decides it, for the reason
`check_layering` already states in the other direction: the payload's
C++ runtime was configured against the payload's C library, so it is
eligible when and only when that C library is in use.
⭐ AND e2e 285 COULD NOT HAVE CAUGHT THIS, WHICH IS WHY IT CHANGED TOO.
Its program was `int main() { return 0; }` — a link that needs almost
nothing, and passes with the C++ runtime removed. It now throws and
builds a `std::string`: one reaches the C++ runtime, the other the C
library, so one program covers both layers this shape gets wrong. A test
that cannot fail need not have an empty assertion; a program too simple
to reach the defect does the same thing.
Also in this commit, from the same review:
⚠️ 288 WAS COVERING `Origin::Xpkg` WHILE CLAIMING `Origin::None`. The
target table gives `riscv64-none-elf` a default C library —
`xim:picolibc-riscv@1.8.12` — so "bare metal" does not imply "no C
library"; a project says that with `sysroot = ""`. What a test covers is
not decided by its name.
⚠️ AND ITS ASSERTION WAS THE WRONG WAY ROUND. It required the `c-abi`
row to be absent; the report prints one row per layer and writes `—`
when a layer resolved to nothing, which is right — a row that vanished
would be indistinguishable from a row nobody looked at. The assertion is
now about that row's VALUE.
⚠️ AND ITS EMULATOR PROBE PREFERRED PATH. `command -v
qemu-system-riscv64` finds an xlings shim that answers
"is not installed in this subos" when run, so a PATH-first probe selects
a program that cannot run and reports its failure as the test's. The
payload's copy is tried first and both candidates are checked with
`--version` before use.
31 lines of comment duplicated across two drafts of the first fix are
removed from targetside/model.cppm; the surviving copy is the one that
describes the code as it stands.
mcpp installs its tools into its own sub-OS — 221 programs on the
machine this was written on — and a build program inherited a PATH with
none of them on it. A program that wanted a tool had to ask PATH the way
a shell script would, and `command -v` answers about the machine rather
than about this build.
Measured with a build program that printed its own PATH:
/home/…/.xlings/data/xpkgs/xim-x-claude/2.1.222
/home/…/mcpp/.xlings/subos/_/bin
/home/…/.xlings/subos/current/bin
subos/bin in PATH: NO
and the concrete cost, from the same day: `command -v
qemu-system-riscv64` returned a shim that answers, when run,
[error] qemu-system-riscv64 is not installed in this subos (_)
— found, reported present, unable to execute, while the working copy sat
in mcpp's own directory unreachable.
⚠️ PREPENDED, NOT SUBSTITUTED. A build program legitimately calls `git`,
`python3` or a shell, none of which this build system ships; a PATH
holding only mcpp's directory would break every one of them for a
guarantee nobody asked for. Front position makes the isolated copy the
default answer and leaves the host reachable behind it. The inherited
value is read in `contract_env` rather than assumed, because `extraEnv`
replaces a variable outright in the child.
⭐ RESOLVED ONCE. `toolsBinDir` is computed after `get_cfg` and handed to
`fill_target_build_env`, whose comment already promised "all four in one
call" — it is now the fifth answer that call gives. The first draft of
this change derived it separately at each of the two call sites (root
package and dependency), which is precisely the shape behind all three
defects fixed in this release: one fact, computed in more than one
place, agreeing until it does not.
Read through the config resolver rather than `~/.mcpp`, like every other
consumer of that directory (doctor, resources): `MCPP_HOME` moves it.
Empty when no sub-OS exists yet, and PATH is then left as inherited.
e2e 290 asserts both halves — that the first entry is mcpp's directory,
AND that the inherited entries survive behind it. A test checking only
the first would pass on a PATH that had thrown the rest away. Measured
both ways: 40 entries after, and before the change the first entry was
an unrelated tool.
docs/07 gains a section stating the contract for package authors.
…ld system
The previous commit prepended mcpp's shared `subos/default/bin` to every
build program's PATH. That makes what a build sees depend on what else has
been installed on the machine: two projects on one machine agree with each
other, and the same project on two machines does not.
`[xlings].subos` already answers "which environment does this project build
in" — it is what decides the C library the project links against, and
`mcpp::xlings::runtime` calls itself the sole project runtime-selection
policy. This delivers that same resolved answer to one more consumer:
PATH=<the declared environment's bin>:<the PATH mcpp was started with>
A project that declares nothing comes out byte-for-byte unchanged.
No new decision point. `projectSubosBin` is derived once, from
`RuntimeBinding::subosDir`, right where the binding is resolved; the two
delivery sites read it. The per-package payload paths a build program may
also need are answered separately by `MCPP_XPKG_*_DIR` — a different
question that keeps a different answer.
e2e 290 asserts both directions, because only one of them is the feature:
prepending unconditionally passes the "declared" half, and that is the
design being withdrawn here.
Adds examples/07-project-subos/ and docs chapter 17.
`linkage = "dynamic"` was reported as ineffective whenever the target's
system came from the graph, and the warning's own reason is narrower than
its predicate: "those packages are compiled into this build as objects, and
there is no shared object to link against" is a property of the C library.
Measured on a backend running ON a platform — kernel interface from the
graph, C library from the payload:
warning: `linkage = "dynamic"` has no effect … The artifact is static.
$ file → dynamically linked
$ readelf → NEEDED libm.so.6, libgcc_s.so.1, libc.so.6
A payload libc has a shared object, so `dynamic` was honoured and the
diagnostic was false. Reads `cAbi.fromGraph()` instead.
The two other `system_from_graph()` uses in prepare.cppm stay: both ask
whether the graph supplies any part of the system, which is genuinely the
two-layer question.
e2e 291 asserts both directions — deleting the warning also stops it lying,
and that would lose the diagnostic the directive needs when the C library
really is the graph's. It also asserts the artifact's DT_NEEDED count, not
just the absence of the text.
Fourth defect of this shape in this release.
285-289 declare `# requires: llvm`. Both linux e2e shards report
Detected capabilities: elf unix-shell fresh-sandbox gcc patchelf pack …
with no `llvm`, because the shard workflow never installs one, and
`run_all.sh` exits 0 on a skip. The suite stayed green while the five
tests written to measure this ecosystem did not run at all.
No new token: run_all.sh's own note explains why a hard-requires cannot
work — one word cannot distinguish "this runner is misconfigured" from
"this platform legitimately lacks the capability", and the one test that
tried it broke the macOS suite. The guard has to know which runner it is
talking to, so it lives in the job. `ecosystem-e2e` installs gcc and llvm,
runs the six scripts directly, and then asserts each PASS line appeared.
Same shape as ci-linux-e2e.yml's `baremetal` job, for the same reason.
Also fixes 291's requires line: `network` is not a known capability, and
the runner's guard hard-fails on an unknown token rather than silently
skipping — which is what turned three CI checks red. That guard is right;
the declaration was wrong.
…ut LSE
The new ecosystem-e2e job's first run caught this. 287 picked its
disassembler with
command -v llvm-objdump || command -v objdump
On CI the first is not on PATH and the second is host GNU binutils, whose
BFD is built for one architecture. Asked to disassemble an aarch64 binary
on x86_64 it prints a header, no instructions, and no error:
$ objdump -d a-aarch64.o | grep -cE '^\s*[0-9a-f]+:'
0
$ llvm-objdump -d a-aarch64.o | grep -cE '^\s*[0-9a-f]+:'
5
`grep -c` returned 0 and the script reported "`+outline-atomics` looks
disabled". It passed on the machine it was written on, where
/usr/bin/llvm-objdump happens to exist and is picked first.
The tool now comes from the toolchain that built the binary — mcpp
installed llvm to compile this, and its llvm-objdump reads every target
clang emits. The count of instruction lines is checked first: zero means
the tool could not read the file, which is a SKIP, not a verdict. The
assertion carries its denominator now (7 LSE out of 148906).
Same run showed 287's and 288's run phases both degrading to a SKIP while
their OK lines printed anyway. The job installs both emulators and asserts
the run-phase lines separately — linking is not booting, as 288 itself says.
…ence
Same class as the 287 defect CI just caught, found by sweeping the family
for it.
286 asserts there is no INTERP segment. `readelf -l` on a file it cannot
parse prints zero lines and exits quietly, and `grep -q INTERP` then finds
nothing — which reads exactly like a static image. It now counts program
headers first and says how many it saw:
ok no INTERP among 9 program headers — nothing for a loader to resolve
291 counts DT_NEEDED entries. "no dynamic section" and "readelf said
nothing" both count zero, and only the first is about this build; a static
ELF says so in words, an unreadable file says nothing at all. Empty output
is now its own failure.
Neither was failing. Both could pass without measuring anything, which is
the property 287 demonstrated is not hypothetical.
…attern
Half one rejected a first PATH entry matching `*/subos/*/bin`. CI's own
PATH already begins with one — the runner activates an xlings environment
to get mcpp at all:
MCPP: /home/runner/.xlings/subos/default/bin/mcpp
so the test reported mcpp prepending something that was already there, and
turned the shard red on a build that behaved correctly.
A test for "did not change it" has to hold the before and the after side by
side. Both halves now compare against `$PATH` as the test process had it:
the plain project's child PATH must equal it byte for byte, and the
declaring project's must be one new entry followed by exactly it.
Verified under a PATH shaped like CI's, where the old criterion lied.
The msvc job went red on `239_msvc_managed_toolset.sh (TIMEOUT)`. Two
things are wrong with the test, and neither is in mcpp.
Its install step fetches ~376 MB (xim:msvc ~85 MB + xim:windows-sdk
~291 MB) and is the whole budget. Measured durations on this runner:
244 / 292 / 313 / 335 / 515s, against run_all.sh's 600s per-test default.
A download-bound test whose spread nearly reaches its own deadline reports
a red build for a correct one. Raised to 1500s in the one job that runs it,
where the step's `timeout-minutes: 30` is the real backstop — which is what
run_all.sh's comment says the per-test value sits under.
And the output was captured into `$out`, so it printed nothing while it
ran: the failing log has the test's header, then the harness line
TIMEOUT: 239_msvc_managed_toolset.sh (exceeded 600s
— likely network / xlings stall)
which is a guess. There was no way to tell a stalled download from a hung
install. `tee` now keeps `$out` for the assertions and puts the progress in
the log, so the next timeout can be read instead of guessed at.
Not introduced by this PR: the same workflow is green on main and passed
twice on this branch between the two failures.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A predicate that ORs two layers was deciding something that depends on one of them, and the link side dropped the payload's C-library search paths for builds whose C library was still the payload's.
Three lines of manifest reproduce it. It shipped in 2026.8.24.1 and reached every openkal backend — that shape is how a backend is tested — and surfaced only when their CI pin moved.
Bisected in CI: 8.21.3 green, 8.24.1 red (#486).
New e2e asserts the shape and asserts the artefact runs.