How mcpp's toolchain machinery works under the hood, and how to extend it with new toolchains, new architectures, and (eventually) embedded targets. Companion to 03 — Toolchain Management, which covers the user-facing CLI. This document is for contributors and maintainers.
mcpp.toml [xlings].subos / mcpp-managed default runtime
▼
resolve runtime binding ← which libc the artifact will load (§2.1) — an
answer, not a search
▼
resolve toolchain payload ← project/default/install paths share one pipeline
▼
ensure_post_install_fixup() ← exact glibc@version, marker-gated; never readdir-first
▼
detect / probe ← triple, sysroot, payload paths (glibc, linux-headers)
▼
ToolchainLinkModel (single resolver for the C-library axis)
├──► flags.cppm (main build compile/link flags)
├──► stdmod.cppm (`import std;` BMI precompile)
├──► build_program (build.mcpp host compiles)
└──► cfg regeneration (the human-facing clang++.cfg)
▼
hermetic link check (`-###` dry-run) ← checks sandbox CRT/loader resolution
▼
link → internal ELF physics check ← validates the artifact and resolved closure (§6.1)
Two principles run through everything:
- Sandbox toolchains are hermetically checked by default. For the normal
payload-first or sysroot path, a produced binary's CRT startup objects,
libc, and dynamic linker must resolve under allowed sandbox prefixes. This
is not an unconditional containment guarantee:
CLibMode::Nonefalls back to host defaults, system/PATH compilers are an explicit host-world choice, and[build] allow_host_libs = trueorMCPP_ALLOW_HOST_LIBS=1opt out of the host-library check. On a machine with no compiler and no/usr/lib/**/Scrt1.o(fresh WSL2, minimal containers), the normal sandbox path still works. - Path knowledge has one owner per layer. What used to be four divergent
copies of "how to link against the payload glibc" is now one resolver
(
linkmodel); what used to be per-entry-path fixup behavior is now one pipeline. Divergence between copies is where an entire class of bugs came from (issue #195).
Since 0.0.93 identity is two orthogonal axes: a ToolchainSpec is
(family ∈ gcc|llvm|msvc, version, target Triple). triple.cppm is the
single triple parser + the closed known-target vocabulary; compat.cppm is
the only file that knows legacy spellings (gcc@15.1.0-musl, musl-gcc,
mingw, mingw-cross, clang, <triple>-gcc — normalized on parse,
permanently accepted). to_xim_package is a (family, target, host) payload
mapping producing an XimToolchainPackage with the xim name, version, and
frontend candidates — this is where host-split distribution names like
mingw-cross-gcc (Linux host) vs mingw-gcc (Windows host) live; they are
current distribution-layer identity, not user-facing spellings. The payload
is resolved/auto-installed via the xlings backend into the sandbox
($MCPP_HOME/registry/data/xpkgs/xim-x-<name>/<version>/). See
.agents/docs/2026-07-15-toolchain-target-naming-unification-design.md.
detect/probe (src/toolchain/detect.cppm, probe.cppm) then derive:
| Field | How |
|---|---|
targetTriple |
<compiler> -dumpmachine |
sysroot |
-print-sysroot (validated: must actually carry libc headers), with a remap fallback for xlings-built GCC whose baked build-time path doesn't exist locally |
payloadPaths |
the resolved runtime binding (§2.1) names the glibc payload exactly; linux-headers is still discovered as a sibling. No binding ⇒ no payload-first, by design |
| runtime dirs | toolchain-private lib dirs for produced binaries' -L/-rpath |
Note the probe deliberately does not mine the clang cfg for --sysroot
anymore: the cfg is an output of this machinery, not an input (§5).
A payload-first build links against a specific glibc, and which one is a fact about the root project's local development OS, not something to infer from a compiler path or shell. mcpp has exactly two selection modes:
- No
[xlings].subos:McppDefault, the initializedsubos/defaultin the xlings home selected by global mcpp configuration. [xlings] subos = "<name>":NamedSubos(name). Explicit"default"remains a named selection; other names resolve in the root project's local xlings scope.
The workspace root owns the selection during a workspace build. Member and
dependency declarations do not merge or propagate; the same member's
declaration applies only when that source is an independent root. Neither
XLINGS_ACTIVE_SUBOS, current, the compiler's owner home, nor a CLI/env
override is a third selection rung.
A named environment that does not exist is a hard error, never a fallback to
default/active/compiler-baked state — the request cannot be satisfied, and
substituting a different one would make one mcpp.toml mean different ABIs on
different machines.
A SubOS that exists but does not describe itself is a different case, and it
degrades. declared = false is recorded with a note the caller prints; runtime
rules report inconclusive instead of a verdict, and no payload-first binding is
available (mcpp declines rather than guessing a libc version, so the hermeticity
check will report the host fallback). The build is not stopped. Likewise a
subos_info schema newer than this mcpp understands is read for the fields it
knows, with a note — the same rule the reader itself documents: publishing data
must not invalidate the program that reads it. Refusing outright is what stopped
every mcpp build and mcpp test on Windows in 2026.8.10.2, where xlings writes
no such block and the facts it carries do not exist (openxlings/xlings#543).
mcpp reads the SubOS once into a
RuntimeBinding snapshot, feeds its libc identity into payload probing, and
reuses the same snapshot for configure/link/run/test and the fast-path cache.
On Linux the snapshot also records the canonical selected loader/libc directory
and the optional creation-host glibc floor. These are evidence used by the
post-link validator, not another selection mechanism.
The resolved SubOS view is authoritative when it canonically names one managed
glibc payload. Older xlings state may retain runtime = "glibc@2.39" after the
view has atomically moved to the managed 2.44 payload; mcpp records the physical
2.44 identity and path in that case. If an older view contains a broken link,
mcpp may resolve only the exact payload named by runtime; it never enumerates
installed versions or chooses a nearest/newest one. Both paths consume xlings
facts and preserve one RuntimeBinding rather than introducing an mcpp policy.
No binding is a refusal, not a default: CLibMode::PayloadFirst is
declined rather than picking a libc.
The rule this replaced asked a directory for "the glibc" and took the first
entry readdir yielded. With one glibc installed that is always right, so
nothing forced it to be correct — and a dependency carrying xim:glibc@>=2.38
is enough to install a second one. When that happened in mcpp-index, the
compile side took 2.44 while the artifact's interpreter, frozen in gcc's specs
at install time, still named 2.39; binaries referenced GLIBC_2.42 symbols
against a runtime without them, and the failures surfaced on packages
unrelated to the dependency that pulled the second glibc in. Directory order
is not a decision procedure.
Because the binding decides what the artifact loads, the complete canonical contract hash is field 11 of the toolchain fingerprint — two named SubOS environments that happen to use the same libc still must not share a cache entry when their providers or environment declarations differ.
RuntimeBinding also carries the provider-neutral facts selected by xlings for
the chosen development OS. subos_info.runtime_contract is an optional,
additive schema-1 block:
{
"providers": [
{"capability": "display.present", "provider": {
"namespace": "xim", "name": "display-runtime", "version": "1.0.0",
"source": "xim-pkgindex@<revision>"}}
],
"artifacts": [
{"role": "driver", "provider": {
"namespace": "xim", "name": "display-runtime", "version": "1.0.0",
"source": "xim-pkgindex@<revision>"},
"path": "${subosdir}/lib/runtime/provider.so",
"provenance": "subos_view", "abi": "elf-x86_64",
"digest": "sha256:...", "host_fingerprint": "..."}
]
}The binding parser resolves ${subosdir} and relative artifact paths once,
sorts the facts, and includes them in the contract hash and cache snapshot. In
the build plan these selected provider facts precede descriptor-declared
fallbacks. Descriptor requirements and artifacts are independently stamped
with their resolved requester's/provider's canonical PackageId, so equal short
names in different namespaces remain distinct end to end.
The ownership boundary is deliberate: mcpp-index expresses generic runtime requirements; xlings/xim chooses and diagnoses the host graphics/runtime stack; mcpp consumes only the selected provider/artifact facts and generic LinkIntent. mcpp has no hardware, driver-vendor, WSL, or ICD selection path. Its source gate rejects introducing such provider-specific branches or coupling those terms to a launched probe.
LinkIntent keeps linkLibraryDirs, transitiveNeededDirs, and
runtimeSearchDirs separate. The last category is never rendered as -L:
ELF receives rpath plus -rpath-link only for the transitive category, Mach-O
receives rpath/framework flags, and PE receives link-library paths plus explicit
deploy-file copy edges. The exact RuntimeBinding, canonical identities, link
intent, search mechanism, and post-link verdict are persisted in
resolution.json schema 2. mcpp why runtime only interprets that stored file;
re-diagnosis belongs to xlings doctor.
mcpp passes --sysroot=<subos> on the compile and link lines, so a library
the SubOS provides — -lGL, -lX11, -lwayland-client — resolves with no
flags from the user. The run-time search path has to be derived from the same
decision, or a link succeeds and the artifact cannot start.
The closure is one ordered list, each entry tagged with where it came from:
| origin | example | mutable? | ships? |
|---|---|---|---|
payload |
<store>/xim-x-glibc/2.39/lib64 |
no — written once at install | no |
package |
a dependency's [runtime] dir |
no | no |
subos_farm |
<subos>/lib |
yes — rewritten by every xlings install |
no |
host_default |
/usr/lib/x86_64-linux-gnu |
n/a — the target's own | n/a |
Order is decreasing mutability, and the farm is last. That is the whole
invariant: payload-first keeps libc / libm / libstdc++ resolving from the
pinned payload, leaving the farm to supply only what nothing else does.
Farm-first would let a later xlings install change which libc an already
linked artifact loads.
Two guards decide whether the farm applies at all. The format must have a search path (ELF; Mach-O and PE get nothing, matching the loader-tag contract), and the target must be this host's runtime — a cross target gets no farm, since it belongs to the host SubOS.
host_default enters the model only when the artifact will really run under
the host loader. A hermetic artifact's PT_INTERP names a private loader with
different built-in defaults, so including /usr/lib there models the wrong
loader — and since a developer machine usually has its own libGL.so.1, doing
so reported "resolved" for binaries that exited 127.
The closure is recorded in resolution.json under runtime.search.closure and
printed by mcpp why runtime. A DT_NEEDED that nothing on a hermetic
artifact's path can satisfy is a proven failure (unresolvable), not an
inconclusive one, and it fails the build. Measured with LD_DEBUG=libs: the
private loader's built-in default path is the glibc payload's own build-time
prefix, a directory that does not exist on the machine — /usr/lib is never
consulted.
Three things narrow that proof, and each of them is a case where mcpp knows less than the wording suggests:
- The artifact's format decides, not the binding's. A cross build runs with this host's binding while producing a PE or Mach-O. ELF rules do not apply to the artifact whatever the binding says.
- Only an unfindable SONAME proves anything. "I could not read this object" and "I stopped after 512 objects" are statements about the check; a check that could not look has proven nothing, so those stay inconclusive.
[build] allow_host_libsopts out of both phases. It already switches off the link-time hermeticity check; once resolution is the user's responsibility mcpp reports rather than blocks, because they may run underLD_LIBRARY_PATHor on a machine where the library sits where the private loader looks.
mcpp also declares XLINGS_SUBOS_LD_PATHS=0 for every process it spawns. That
is the opt-out from xlings' linker-wrapper path injection
(openxlings/xlings#540): mcpp wants the wrapper's --disable-new-dtags and
must refuse its -rpath "$XLINGS_SUBOS_LIB", because that variable names the
active shell's SubOS — mcpp keeps its own xlings home under
<mcpp home>/registry, so it generally points at a different farm backed by a
different physical glibc payload. mcpp emits the farm entry it derived from the
binding it actually selected.
ToolchainLinkModel answers exactly one question — how do we compile and
link against this toolchain's C library — and every consumer derives its
flags from it:
CLibMode::PayloadFirst glibc/linux-headers xpkgs found (the normal bundled-LLVM
and no-usable-sysroot GCC case)
compile: -isystem (clang) / -idirafter (gcc) payload headers
link: -B <glibcLib> ← CRT discovery (Scrt1.o/crti.o/crtn.o;
the driver never consults -L for these)
-L <glibcLib> [+ -rpath + --dynamic-linker for clang]
CLibMode::Sysroot a usable --sysroot (GCC include-fixed world, self-contained
musl sysroots, the macOS SDK)
link: --sysroot, plus --dynamic-linker and -L/-rpath
for the payload when one is known — the
sysroot says where headers live, not which
loader runs the result
CLibMode::None nothing usable — host defaults apply; the hermetic
check (§6) rejects that leakage unless an explicit
host-library exception is in effect
ClangDriverModel is the companion for bundled LLVM: mcpp always passes
--no-default-config (bypassing the install-time cfg for reproducibility)
and re-provides libc++ headers/libs plus
-fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind explicitly.
Loader resolution is data-driven, never hardcoded: a per-arch triple map
(x86_64 / aarch64 / riscv64 / loongarch64 / i686, glibc and musl spellings),
then a ld-*.so* glob of the payload as the fallback for arches the map
doesn't know. A third source — declared metadata persisted by the installer
(.xpkg-exports.json) — was implemented, evaluated, and removed: its
only consumer would have been this resolver, the two sources above already
cover every real payload (the entire 0.0.83 verification matrix ran green
without the file ever existing), and a general-purpose package manager
shouldn't carry a mechanism whose sole reader is one downstream tool. If an
installed-state metadata DB ever appears, it must be designed with xlings
itself as its first consumer; mcpp can then re-add a reader.
Sandbox payloads are prebuilt ELF trees whose baked PT_INTERP/RUNPATH are
unknowable at packaging time and must be aligned to the local sandbox.
ensure_post_install_fixup(cfg, payloadRoot, pkg) is the single entry for
that alignment, called from all three entry paths (explicit install, default
auto-install, manifest auto-install).
This pipeline used to rewrite GCC's
specsfile as well, so that produced binaries got a loader and an rpath. That is no longer done, and §5 is why.
Historical note: before 0.0.83 each path remembered — or forgot — its own subset. The manifest path ran nothing, which is how a freshly auto-installed llvm kept a stale, environment-dependent cfg (issue #195), and how gcc once shipped a sandbox that couldn't find
stdlib.h. "Which command you installed with" must never decide "whether the toolchain works".
Trigger semantics — ask every build, act once:
every build → ensure() → read <payload>/.mcpp-fixup.json
marker == {schema, kind, rev, glibcLib}? → return (ms-level)
mismatch → run the fixup for this kind, write marker
The marker is a content-fingerprinted cache, not an event flag: it encodes
the fixup revision and the glibc payload it was aligned against. The
"act" branch therefore fires exactly once per
(payload × fixup-rev × glibc-fingerprint) — first use, plus the two
re-convergence events that genuinely require rewriting (a fixup-logic
upgrade via kFixupRev, or the glibc payload changing underneath). mcpp
asks on every build because the events that invalidate a payload (xlings
swapping glibc, a payload inherited from another home) happen outside
mcpp's sight — trust-but-verify is the only reliable semantic.
Per-kind actions:
| kind | actions |
|---|---|
gcc (glibc) |
patchelf walk over the gcc payload and the shared binutils payload (PT_INTERP → sandbox loader, RUNPATH → glibc+gcc lib dirs) — so that gcc itself runs. Nothing is written into specs |
llvm |
patchelf walk over lib/ only (runtime .so RUNPATH; bin/ is left alone to preserve xlings-set RUNPATHs); deterministic cfg regeneration (§5) |
musl-gcc |
nothing — self-contained sysroot, static world |
Safety invariants (each earned by a real incident):
- Never patch in place. patchelf operates on a copy which is then
atomically
rename()d in: the payload can contain libraries the current process (a self-hosted, dynamically linked mcpp) or a concurrent build has mmapped, and rewriting a live mapping's backing file corrupts the running process (observed: exit-time SIGSEGV in_dl_fini).renamegives new content a fresh inode; live processes keep the old one. - Ownership guard. Payloads that resolve outside this home's registry
(symlink-inherited from another
MCPP_HOME) are never patched — their owner already converged them, and patching through the symlink would brick the owner's toolchain. - Extending content-awareness to the patchelf walk (compare
--print-interpreter/--print-rpathbefore writing, so an already-aligned payload converges with zero writes) is a known follow-up. - The long-term direction is for the installer (xlings) to own all writes — at install time and when a payload enters a new home — leaving mcpp read-only + verification. The pipeline here is the compatibility layer until then, and the self-healing mechanism for drift either way.
A payload ships two separable things: the ability to compile, and an opinion about how to link. mcpp wants the first and supplies the second itself — the link line is where a build's decisions belong, because it is the thing that varies per build. Both compilers now follow that rule; only the mechanism differs.
clang — --no-default-config on every mcpp invocation.
gcc — -specs= with a generated file. <compiler> -dumpspecs prints the
built-in specs (unaffected by anything on disk); mcpp extracts the *link:
body, drops the loader and rpath lines from it, and writes the result to the
build directory. A -specs= file without a leading + replaces the rule it
names, so the payload's own opinion is overridden without the payload being
touched. Two consequences worth stating: it is per-build, so a second project
on the same machine is unaffected; and it needs no write access to the
toolchain, so an inherited or read-only payload works.
Removing gcc's baked *link: also removes what it provided. mcpp therefore
supplies --dynamic-linker and every rpath entry explicitly on the link
line — the loader, the glibc lib dir, and gcc's own lib64 (libgcc_s). Each
of those was found by removing the specs and watching what broke.
Why not keep rewriting specs? Because the file is shared and the value is
per-build. The rewrite had a single-path needle and a two-path replacement, so
every home that ever ran it left one entry behind: 68 stale RUNPATH
entries, all naming deleted mktemp directories, in every gcc artifact one
developer machine produced. Nothing detected them, because a dead RUNPATH
entry costs only search time. e2e 201_gcc_no_specs_pollution.sh asserts on
the artifact, not the specs file — what a user ships is what matters.
bin/clang++.cfg exists so that direct invocations of the bundled
clang++ (outside mcpp) get a working, hermetic compiler configuration. The fixup pipeline regenerates it
deterministically from the link model — same payload ⇒ byte-identical cfg on
every machine and install path — rather than line-patching whatever an
install produced. On Linux that means CRT discovery (-B), payload loader +
rpath, lld/compiler-rt/libunwind, and bundled libc++ for the C++ drivers; on
macOS it keeps the historical shape (--sysroot=<SDK> + payload libc++
headers — the C++ runtime link stays with the platform's
needs_explicit_libcxx handling in the main build).
Before running a build with a sandbox toolchain on Linux, mcpp dry-runs the
driver with the exact link flags (-### -x c++ /dev/null) and asserts every
CRT object and the effective dynamic linker (last occurrence wins) resolve
under allowed sandbox prefixes. This turns both silent failure modes into
one actionable diagnostic: bare CRT names that lld can't open (the #195
symptom on clean machines) and quiet host-CRT contamination (which made
green CI a false signal on machines with a host toolchain). The verdict is
cached per flag-set (.mcpp-hermetic-ok); escape hatches:
[build] allow_host_libs = true or MCPP_ALLOW_HOST_LIBS=1. System/PATH
compilers are exempt — using the host world explicitly is the user's choice.
CI keeps this honest with a job that has no host toolchain at all
(debian:stable-slim, no gcc, no host Scrt1.o) — the only environment
class that faithfully reproduces the clean-machine failure mode, plus e2e
86_llvm_hermetic_link.sh which re-checks the -### resolution on every
machine.
The hermetic check answers a pre-link question: what does the driver appear to
resolve? The runtime-physics check answers the stronger post-link question:
what did the newly produced ELF actually record and what will its closure
load? [build] allow_host_libs = true deliberately relaxes the first check;
it does not suppress physical impossibilities in the second.
For each newly linked Linux executable/shared object, mcpp parses ELF64
little-endian program/dynamic/GNU-version tables internally—no readelf,
patchelf, or ldd subprocess on the build path—and records PT_INTERP,
DT_RPATH/DT_RUNPATH, DT_NEEDED, and required/defined GLIBC_* versions.
It resolves the declared closure using the artifact search paths, selected
runtime/toolchain directories, and known host library directories, then applies:
- Rule B (same source):
PT_INTERPand every resolvedlibc.so.6must be the canonical payload selected byRuntimeBinding. A host loader plus private libc, or two private libc payloads, is a proven pre-main failure. - Rule A (version floor): every closure request for
GLIBC_x.ymust be no newer than the selected libc's exported GNU version definitions. Linking a host DSO is allowed when this holds; mcpp is checking physics, not imposing a no-host-library policy.
Verdicts are typed: Pass, ProvenMismatch, or Inconclusive. Proven A/B
mismatches fail the build with canonical requester/provider/artifact paths and
a copyable SubOS remediation. Missing loader-cache/hardware closure data is
reported as inconclusive, never relabelled green. macOS and Windows use the
same interface as a typed no-op and never receive ELF/glibc rules.
The verdict is stored as .mcpp-runtime-verdicts.json beside build.ninja,
keyed by artifact stat fingerprint plus the complete runtime contract hash.
Hot no-op builds require a current passing record, compare artifact stats
before/after Ninja, and perform zero ELF parses. An unexpected relink drops to
the full path before success or execution. mcpp self doctor reports the same
stored verdict rather than re-probing a potentially different current host.
Post-install alignment follows the same identity rule: glibc@2.44 resolves
only <xpkgs>/xim-x-glibc/2.44/{lib64,lib}. A missing/stale exact payload is an
error; another installed version is never a fallback.
There are two ways to tell a loader where to look, and they differ by blast radius, not by convenience:
| reaches | ||
|---|---|---|
DT_RUNPATH |
the one object that carries it, and its dlopen() |
per-binary |
LD_LIBRARY_PATH |
the process and every process it ever spawns | inherited, forever |
That second row is why the private libc payload is binary-scoped. A glibc's
libc.so.6 and its ld.so are version-locked through GLIBC_PRIVATE: 2.44's
libc carries an undefined __pointer_chk_guard that only 2.44's own loader
exports. An mcpp-built program is fine — PT_INTERP names the private loader.
/bin/sh is not: its PT_INTERP names the host loader and no environment
variable can override it, so a popen()/system() child dies during
relocation, before main, with no output (mcpp#401; mcpp#291 is the same shape
one hop closer in, killing mcpp's own nested host tools).
So mcpp never publishes the private libc directory through the environment. It
does not need to: wherever a payload exists the link model already emits
-Wl,-rpath,<glibc> beside --dynamic-linker, which covers the case the
directory exists for — a dlopen() whose own DT_NEEDED closure does not
consult the executable's RUNPATH.
This is a scope, not a condition. "Only export it when a dependency might
dlopen()" still exports it, and the child that dies does not care why. Plain
dependency runtime directories keep their environment scope: they have no
loader coupling, so a host binary that stumbles onto them is at worst confused.
- Index side (xim-pkgindex): a package with the payload assets and —
critically —
depson whatever C library payload it needs (xim:glibc,xim:linux-headers). Follow the llvm/gcc packaging SOP including the admission gate (verify-toolchain.sh): completeness + hermetic CRT resolution + a real compile/link/run before an asset ships. - Vocabulary + registry: add the target row to
triple.cppm'skKnownTargets(tier/pin/defaultStatic), then teachto_xim_package(src/toolchain/registry.cppm) the (family, target, host) → xim package row and itsfrontendCandidates(which binary is the C++ driver). Legacy spellings, if any, go incompat.cppmonly. - Capabilities (
src/toolchain/provider.cppm): stdlib identity, BMI traits, and feature switches consumed byflags.cppm. - Fixup kind (
post_install.cppm): decide what post-install alignment the payload needs — gcc-like (patchelf + specs), llvm-like (lib patchelf + cfg), or none (self-contained). Wire it intoensure_post_install_fixup's dispatch. - e2e: a hermetic-link test in the spirit of
86_llvm_hermetic_link.sh, and coverage in the no-host-toolchain CI job.
The machinery is already arch-parameterized; the work is data:
- add the glibc/musl loader names to the triple map in
linkmodel.cppm::loader_filename(the glob fallback covers you until then); - ship payload assets for the arch (glibc, linux-headers, the toolchain
itself) — the aarch64-linux-musl cross target is the working precedent
(
[target.aarch64-linux-musl], cross frontend resolution via the spec'stargetTriple); - nothing else:
-B/-L/loader emission, the fixup pipeline, and the hermetic check are all name-agnostic.
The model extends naturally to arm-none-eabi-class toolchains because the
hard parts of the hosted world disappear rather than multiply:
- No dynamic linker:
loaderstays empty — already legal everywhere (renderers omit--dynamic-linker; the pack/deploy story is flashing, not ELF interp). - No glibc payload: newlib/picolibc live inside the toolchain's own
sysroot ⇒
CLibMode::Sysroot, the exact mode self-contained musl uses today.is_musl_target-style self-containment detection generalizes to a capability flag ("ships own C library"). - Fixup kind = none or gcc-like depending on how the payload is built (a cross gcc payload still wants PT_INTERP/RUNPATH alignment for the host-run compiler binaries — that part is identical to today's gcc kind; the target side needs nothing).
- Hermetic check generalizes: assert crt0/semihosting stubs resolve inside the toolchain payload instead of Scrt1.o/loader.
- What genuinely needs new design: per-target
[target.'cfg(...)']specs for MCU flags (-mcpu,--specs=nosys.specs), linker-script handling, and a run/flash story — build-graph concerns above this document's layer.
macOS (Mach-O) and Windows (PE) intentionally bypass most of this document:
macOS resolves its C world from the SDK (CLibMode::Sysroot) with its own
libc++ linkage handling; Windows has no rpath — mcpp deploys runtime DLLs
next to the produced exe, which is the platform's native equivalent of
everything §3–§4 does for ELF.
| Concern | File |
|---|---|
| spec → xim package, frontends | src/toolchain/registry.cppm |
| detect/probe (triple, sysroot, payloads) | src/toolchain/detect.cppm, probe.cppm |
| link model + loader resolution | src/toolchain/linkmodel.cppm |
| unified fixup pipeline (patchelf/specs/cfg, marker) | src/toolchain/post_install.cppm |
| install/lifecycle entry | src/toolchain/lifecycle.cppm; auto-install entries in src/build/prepare.cppm |
| root runtime selection/binding | src/platform/xlings/runtime_selection.cppm, src/platform/runtime_binding.cppm, src/platform/xlings/subos_info.cppm |
| generic runtime contract + LinkIntent | src/manifest/types.cppm, src/build/plan.cppm, src/build/flags.cppm |
| stored resolution explanation | src/build/prepare.cppm, src/build/runtime_validation.cppm, src/doctor.cppm |
| flag assembly (main build) | src/build/flags.cppm |
import std; precompile |
src/toolchain/stdmod.cppm |
| build.mcpp host flags | src/build/build_program.cppm |
| hermetic link check | src/build/hermetic.cppm |
| regression fences | tests/e2e/86_llvm_hermetic_link.sh, unit test_linkmodel.cpp, test_post_install.cpp; the no-host-toolchain CI job in ci-linux-e2e.yml |
Design history: .agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md.