Skip to content

feat(toolchain): hermetic link model — CRT/loader from one resolver, fixup pipeline, hermeticity check (v0.0.83, fixes #195)#196

Merged
Sunrisepeak merged 15 commits into
mainfrom
feat/hermetic-link-model
Jul 7, 2026
Merged

feat(toolchain): hermetic link model — CRT/loader from one resolver, fixup pipeline, hermeticity check (v0.0.83, fixes #195)#196
Sunrisepeak merged 15 commits into
mainfrom
feat/hermetic-link-model

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Implements the architecture in .agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md, fixing #195 at the root instead of patching the symptom.

What was broken (#195)

The clang-with-cfg payload link path emitted only -L/-rpath/--dynamic-linker — no -B. The driver resolves Scrt1.o/crti.o/crtn.o through -B prefixes and sysroot paths only, so:

  • on hosts without a system toolchain (fresh WSL2): bare CRT names reached lld → cannot open Scrt1.o;
  • on hosts with one (every dev machine and CI runner): the driver silently linked the host's CRT — contamination that made CI green a false signal and hid the bug.

Root architecture defect: the "how to link against the payload glibc" knowledge lived in four divergent mcpp copies (flags / stdmod / build_program / cfg-fixup) plus the install-time-generated cfg, with no owner and no verification.

Changes

  1. mcpp.toolchain.linkmodel — single resolver for the C-library axis (CRT dir, lib dirs, loader, system includes; payload-first, --sysroot fallback) + the clang cfg-bypass driver model. All four consumers converge on it; the payload link gains -B (the Linux 下 llvm 工具链链接阶段传入 --no-default-config,丢失 clang++.cfg 中的 --sysroot,导致 cannot open Scrt1.o/crti.o/crtn.o #195 fix, as a model field). Loader names come from data: declared payload exports → per-arch triple map → glob. grep ld-linux-x86-64 src/ now hits only the triple map.
  2. One post-install fixup pipelineensure_post_install_fixup (content-fingerprinted marker, ownership guard) called from all three install paths; the manifest [toolchain] path previously ran no fixup (why the reporter's cfg still carried an install-time --sysroot). The clang cfg is now regenerated deterministically from the model — same payload ⇒ same cfg on every machine, and a human running clang++ directly gets hermetic CRT discovery too.
  3. Hermetic link check-### dry-run before the build asserts every CRT object + the effective dynamic linker resolve inside the sandbox; hard error naming the leak, escape hatches [build] allow_host_libs = true / MCPP_ALLOW_HOST_LIBS=1, verdict cached per flag-set. Converts both silent failure modes into one diagnostic.
  4. Probe stops mining the cfg for --sysroot (dead trust — the fixup regenerates the cfg without it); kept as a debug log.
  5. Tests/CI — unit tests for the link model; e2e 86_llvm_hermetic_link.sh (-### sandbox-prefix assertion — fails on both regression modes on any machine); llvm e2e unpinned from 20.1.7 (MCPP_E2E_LLVM_VERSION, default newest installed); new hermetic CI job on debian:stable-slim with no host toolchain — the only environment class that faithfully reproduces Linux 下 llvm 工具链链接阶段传入 --no-default-config,丢失 clang++.cfg 中的 --sysroot,导致 cannot open Scrt1.o/crti.o/crtn.o #195 (the standard e2e job never installs llvm, so the llvm suite always skipped on CI).

Verification

  • Linux 下 llvm 工具链链接阶段传入 --no-default-config,丢失 clang++.cfg 中的 --sysroot,导致 cannot open Scrt1.o/crti.o/crtn.o #195 reproduction (manifest [toolchain] linux = "llvm@22.1.8" + mcpp run) passes; -### shows all three CRT objects + loader resolving inside the glibc payload.
  • Regenerated cfg verified: bare clang++ hello.cpp links hermetically (CRT from payload, runs).
  • Unit: 28 test binaries green (incl. 12 new linkmodel cases). e2e: 94 passed / 1 skipped locally on llvm 20.1.7 and 22.1.8 — the six previously "environmental" llvm e2e failures were this very bug and are now green. (5 remaining local failures reproduce identically on the pre-change 0.0.82 binary — pre-existing local-machine xlings-layout issues, not from this PR.)

Companion PRs: xim-pkgindex (deterministic llvm.lua cfg + packaging admission gate) follows after this merges + releases.

ToolchainLinkModel (CRT dir / lib dirs / loader / system includes, payload-first
with --sysroot fallback) + ClangDriverModel (cfg-bypass driver flags), replacing
the four divergent copies of this knowledge. Loader names come from data:
declared payload exports (.xpkg-exports.json) -> per-arch triple map -> glob,
never a hardcoded x86_64 string.

Part of the hermetic toolchain link model
(.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md, issue #195).
…#195)

flags.cppm, stdmod.cppm and build_program.cppm host_base_flags now derive
their sysroot/payload flags from the shared link model. The clang-with-cfg
payload link path previously emitted only -L/-rpath/--dynamic-linker; the
driver resolves Scrt1.o/crti.o/crtn.o through -B prefixes and sysroot paths
only, so on hosts without a system toolchain it passed bare CRT names that
lld cannot open (issue #195), and on hosts with one it silently linked the
host's CRT. The model's link_flags carry -B<glibc-payload-lib>, so CRT
objects now resolve inside the payload everywhere.

build.mcpp host compiles stop trusting the sibling clang cfg (an
install-time-generated, per-machine artifact) and use the same explicit
flags as the main build.
…cfg regeneration

ensure_post_install_fixup(cfg, payloadRoot, pkg) is now the single entry for
toolchain post-install fixups (gcc: patchelf + specs; llvm: patchelf(lib) +
cfg), called from all three install paths — explicit `toolchain install`,
default-toolchain auto-install, and manifest [toolchain] auto-install. The
manifest path previously ran NO fixup, so a fresh llvm auto-install kept its
stale install-time cfg and unpatched runtime libs (how the #195 reporter's
cfg still carried an install-time --sysroot while other installs didn't).

Idempotent via a content-fingerprinted marker (<payload>/.mcpp-fixup.json:
schema + kind + fixup rev + glibc lib) — drifted inputs re-run the fixup.
The ownership guard (inherited/symlinked payloads are not ours to patch) now
covers llvm too.

fixup_clang_cfg no longer line-patches whatever a given install produced: it
regenerates the cfg deterministically from the link model, so the same
payload yields byte-identical cfgs on every machine and install path — and a
human running clang++ directly now gets hermetic CRT discovery (-B) too.
All loader paths come from resolve_loader (no ld-linux-x86-64 hardcodes).
…only)

The cfg-mined sysroot was dead trust: mcpp's fixup pipeline regenerates the
cfg without a --sysroot line (the C library comes from the payload link
model), so the mined value existed only on never-fixed-up installs and
pointed at an environment directory the payload doesn't own. Builds derive
everything from the link model; the cfg serves humans running clang++
directly. The parse is kept as a debug log.
…he sandbox

Dry-run the driver (-###) with the exact ldflags the build will use and
assert every CRT object plus the EFFECTIVE dynamic linker (last occurrence
wins — the driver emits its built-in default before the -Wl override)
resolve under the sandbox's xpkgs registry or the toolchain sysroot.

This converts two previously silent failure modes into one actionable
build-time diagnostic: on hosts WITH a system toolchain the driver would
quietly link the host's CRT (contamination that made CI green a false
signal), and on hosts WITHOUT one it passed bare CRT names lld cannot open
(issue #195). Sandbox toolchains only — a PATH/system compiler is the
user's explicit choice of the host world. Verdict cached per flag-set
(.mcpp-hermetic-ok). Escape hatches: [build] allow_host_libs = true or
MCPP_ALLOW_HOST_LIBS=1.
…codes

fixup_gcc_specs detects the baked loader name from the specs content and
replaces it with resolve_loader()'s answer (any glibc arch); the specs dir is
discovered instead of assuming x86_64-linux-gnu. mcpp pack derives the
BundleProject PT_INTERP distro path from the loader soname ldd resolved for
the binary being packed (LSB /lib64 vs /lib), instead of a hardcoded x86_64
string. The only remaining literal loader names live in linkmodel.cppm's
per-arch triple map.
86 asserts, via a -### dry-run with the exact ldflags mcpp generated, that
every CRT object and the effective dynamic linker resolve inside the sandbox
(an xpkgs path) — failing on both regression modes of issue #195: bare CRT
names (link failure on hosts without a system toolchain) and host-CRT
contamination (silent on hosts with one).

The llvm e2e scripts (36-41, 47, 65) source the new _llvm_env.sh instead of
hardcoding llvm@20.1.7: MCPP_E2E_LLVM_VERSION overrides, default is the
newest installed payload — so new toolchain versions get coverage the day
they're installed instead of never.
debian:stable-slim with NO compiler and NO host Scrt1.o: the only
environment class that faithfully reproduces issue #195 (standard runners
ship libc6-dev, so a sandbox toolchain leaking to the host CRT still links
green there). Bootstraps xlings + released mcpp, builds the PR code with the
sandbox gcc only, then runs the #195 manifest-llvm reproduction plus the
hermetic e2e subset.
…ed payloads

mcpp passes symlink-view paths on the command line, but the clang driver
reports its own resource dir (clang_rt.crt*) through the canonical path;
with payloads symlink-inherited from another MCPP_HOME (the e2e isolation
pattern) that canonical registry must be allowed too.
…st semantics

Two regressions the first CI round caught (exactly the environments local
verification can't fake):

1. gcc specs corruption (linux): the rewritten detect walked the loader
   path to 'whitespace/:;' — but specs embed the baked loader inside
   %-spec conditionals (%{mmusl:...;:/baked/ld-linux-x86-64.so.2}), so the
   scan swallowed closing braces and the rewrite corrupted the spec
   grammar ('braced spec body ... is invalid' from every g++ run after).
   Now: path-character whitelist scan, skip pristine /lib* multilib
   defaults, unit-tested against the real spec grammar (incl. aarch64
   loader names). detect_baked_loader is exported for the tests.
   Fixup rev bumped (hermetic-2) so payloads stamped by the broken pass
   re-run the fixup.

2. macOS host/cfg semantics: the cfg-bypass host flags and the
   -nostdinc++/-stdlib=libc++ cfg regeneration are LINUX semantics; a bare
   macOS link has no libc++abi handling (that lives in the main build's
   needs_explicit_libcxx path) and died with undefined __cxa_* /
   __gxx_personality_v0. build.mcpp host compiles keep trusting the cfg
   off-Linux, and the macOS cfg keeps its historical shape
   (--sysroot=<sdk> + payload libc++ headers only).
…ings

The fixup pipeline now runs on every toolchain install path, which means
the process executing it can itself be linked against the very libraries
being patched (a self-hosted mcpp loads the sandbox glibc/libgcc_s).
In-place patchelf rewrites the backing file of those live mappings and
corrupts the running process — reproduced deterministically on fresh-
sandbox CI as an exit-time SIGSEGV in _dl_fini jumping to an unrelocated
address (0x45a0), while a control run of main on the same fresh sandbox
passed (the old code only ever patched from the statically-linked
bootstrap mcpp, so the hazard was structural but unexposed).

patchelf now operates on a copy and atomically rename()s it into place:
the patched content gets a fresh inode, live processes keep the old one.
@Sunrisepeak Sunrisepeak merged commit b0747c9 into main Jul 7, 2026
6 checks passed
@Sunrisepeak Sunrisepeak deleted the feat/hermetic-link-model branch July 7, 2026 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant