Skip to content

Commit 17ecd64

Browse files
committed
docs: hermetic toolchain link model design (issue #195)
1 parent 68958da commit 17ecd64

1 file changed

Lines changed: 347 additions & 0 deletions

File tree

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
# Hermetic toolchain link model — one-shot cross-repo fix for issue #195
2+
3+
Fixes [mcpp-community/mcpp#195] (llvm toolchain link fails with
4+
`ld.lld: cannot open Scrt1.o/crti.o/crtn.o` on Linux) at the architecture
5+
level: one coordinated change set across **mcpp**, **xim-pkgindex**,
6+
**xlings-res** and (optionally) **xlings**, shipped as mcpp **0.0.83**.
7+
8+
Companion analysis precedents: PR #62 (payload-first sysroot, removed the
9+
subos override), PR #119 (single deployment-target resolver on macOS — the
10+
same "one resolver, many consumers" cure applied here to the Linux C-library
11+
axis), PR #124 (macOS floor + static libc++ default).
12+
13+
---
14+
15+
## 1. Problem statement (what #195 actually is)
16+
17+
`mcpp` links Clang-with-cfg toolchains with `--no-default-config` and
18+
re-provides all paths itself. On the payload path (glibc xpkg present — the
19+
normal result of installing llvm, whose deps declare `xim:glibc`), the
20+
compile side is complete (`-isystem` payload headers) but the **link side
21+
provides only `-L`/`-rpath`/`--dynamic-linker` and no `-B`**
22+
(`src/build/flags.cppm`, `payload_ld`). The Clang driver locates CRT startup
23+
objects (`Scrt1.o`, `crti.o`, `crtn.o`) through `-B` prefixes and
24+
sysroot-derived paths — **never through `-L`** — so it passes the bare file
25+
names to `lld`, which fails.
26+
27+
Why it was invisible until now: on hosts that have a system C toolchain
28+
installed (dev machines, CI runners with `libc6-dev`), the driver silently
29+
falls back to the **host's** `/lib/x86_64-linux-gnu/Scrt1.o`. The link
30+
"succeeds" by mixing host CRT + payload glibc + payload loader. On hosts
31+
without one (the reporter's fresh WSL2 Ubuntu), the bug surfaces
32+
immediately. Verified both ways with `clang++ -###`: without `-B` the CRT
33+
resolves to host paths (or bare names); with `-B<glibc-payload-lib>` all
34+
three CRT objects resolve inside the payload and the binary links and runs.
35+
36+
## 2. Root architecture defects (why a one-line `-B` is not enough)
37+
38+
The knowledge "how to compile/link against the payload glibc" currently has
39+
**five divergent implementations across two repos** and no owner:
40+
41+
| # | Copy | Behavior today |
42+
|---|------|----------------|
43+
| 1 | `mcpp src/build/flags.cppm` (main build) | bypasses cfg (`--no-default-config`), compile side complete, **link side missing `-B`**#195 |
44+
| 2 | `mcpp src/toolchain/stdmod.cppm` (std module precompile) | independent re-implementation of the compile side |
45+
| 3 | `mcpp src/build/build_program.cppm` `host_base_flags` (build.mcpp host) | **trusts** the cfg for Clang |
46+
| 4 | `mcpp src/toolchain/post_install.cppm` `fixup_clang_cfg` | **rewrites** the cfg (deletes `--sysroot`, comment promises "mcpp provides sysroot via payload paths" — copy #1 never delivered it on the link side) |
47+
| 5 | `xim-pkgindex pkgs/l/llvm.lua` `__install_linux_cfg` | generates the cfg **from the install-time environment** (subos present → `--sysroot=<subos>`; absent → warn *"clang will use system sysroot"* — host fallback by design) |
48+
49+
Consequences found while tracing #195:
50+
51+
- **D1 — cfg trust is undefined.** flags bypasses it, `probe_sysroot`
52+
step 2 (`fallback/probe_sysroot.cppm parse_clang_cfg_sysroot`) *mines* it
53+
for `--sysroot`, `build_program` trusts it, `fixup_clang_cfg` rewrites it
54+
and deletes the very line probe mines (so probe's cfg path is dead on any
55+
fixed-up install). Four attitudes toward one file.
56+
- **D2 — cfg is a non-reproducible artifact.** Same xpkg version produces
57+
different cfgs depending on whether a subos existed at install time, and
58+
on *which mcpp code path installed it* (see D3). The #195 reporter's cfg
59+
carried `--sysroot=<subos>`; a fixed-up install carries no `--sysroot` and
60+
a payload loader line.
61+
- **D3 — post-install fixups are not part of the pipeline.** Explicit
62+
`mcpp toolchain install` runs gcc + llvm fixups
63+
(`src/toolchain/lifecycle.cppm`); the default-toolchain auto-install runs
64+
only the gcc fixup (`src/build/prepare.cppm`); the **manifest
65+
`[toolchain]` auto-install path runs no fixup at all** — the exact path a
66+
`mcpp.toml` llvm user takes. The gcc side already had this bug once
67+
("stdlib.h not found", fixed by sharing the fixup with one auto-install
68+
path); llvm re-created it on another path. Structural, not incidental.
69+
- **D4 — hermeticity is a promise, not an assertion.** Nothing at build,
70+
test or packaging time verifies that resolved CRT/libc/loader paths lie
71+
inside the sandbox. Host fallback makes CI green a false signal.
72+
- **D5 — `ld-linux-x86-64.so.2` is hardcoded ~10× in mcpp**
73+
(`flags.cppm`, `pack.cppm` ×3, `lifecycle.cppm` ×3, `post_install.cppm`
74+
×5) **and again in xim-pkgindex** (`llvm.lua`, `gcc.lua`,
75+
`gcc-specs-config.lua`, …), while `glibc.lua` already declares
76+
`exports.runtime = { loader, abi }` *precisely so consumers don't
77+
hardcode this* — and no consumer reads it. aarch64-glibc is silently
78+
broken across the whole chain.
79+
- **D6 — test blind spot.** llvm e2e (36–41, 47) pin `llvm@20.1.7` (zero
80+
coverage of 22.x) and run on hosts whose system CRT masks the entire
81+
category via D4.
82+
- **D7 — packaging has no admission gate.** Precedent: a slim-repacked
83+
llvm asset shipped without a runtime library it needed, caught only by
84+
users. Same class as #195: install-time artifacts are never smoke-linked.
85+
86+
## 3. Design
87+
88+
### 3.1 `ToolchainLinkModel` — one resolver, all consumers (mcpp)
89+
90+
New module `src/toolchain/linkmodel.cppm`:
91+
92+
```cpp
93+
export namespace mcpp::toolchain {
94+
95+
enum class CLibMode { PayloadFirst, Sysroot, HostSDK, SelfContained /*musl*/ };
96+
97+
struct ToolchainLinkModel {
98+
CLibMode mode;
99+
std::filesystem::path crtDir; // -B: where Scrt1.o/crti.o/crtn.o live
100+
std::vector<std::filesystem::path> libDirs; // -L + -Wl,-rpath
101+
std::filesystem::path loader; // -Wl,--dynamic-linker (empty ⇒ omit)
102+
std::vector<std::filesystem::path> systemIncludes; // compile-side -isystem
103+
// canonical flag renderers so every consumer emits IDENTICAL strings:
104+
std::string compile_flags(bool clang) const; // -isystem… (+ --sysroot when mode==Sysroot)
105+
std::string link_flags() const; // -B… -L… -rpath… --dynamic-linker=…
106+
};
107+
108+
ToolchainLinkModel resolve_link_model(const Toolchain& tc);
109+
110+
} // namespace
111+
```
112+
113+
Resolution (Linux, glibc world; musl/macOS/windows return their existing
114+
behavior unchanged under `SelfContained`/`HostSDK`):
115+
116+
1. `payloadPaths` present → `PayloadFirst`: `crtDir = libDirs[0] =
117+
glibcLib`, `systemIncludes = {glibcInclude, linuxInclude}`,
118+
`loader` per §3.2.
119+
2. else usable `tc.sysroot` → `Sysroot`: `--sysroot` on both sides (GCC
120+
include-fixed requirement preserved).
121+
3. else → empty model (current behavior; the hermeticity check in §3.5
122+
turns silent host fallback into a diagnosed warning).
123+
124+
Consumers — all four divergent copies converge on the model:
125+
126+
- `flags.cppm`: `compile_toolchain_flags`/`link_toolchain_flags`/`payload_ld`
127+
are computed **from the model**. This is where the `-B` lands (fixing
128+
#195), as a model field, not a patch.
129+
- `stdmod.cppm`: `sysroot_flag` assembly replaced by
130+
`model.compile_flags()`. (Identical strings also keep the
131+
`std-module.json` `std_build_commands` cache key honest.)
132+
- `build_program.cppm host_base_flags`: Clang branch stops trusting the cfg;
133+
uses the model like everything else. (GCC branch already matches the
134+
model's Sysroot/`-B` shape.)
135+
- `post_install.cppm fixup_clang_cfg`: regenerates the cfg **from the
136+
model** (see §3.3) instead of line-patching xlings' output.
137+
138+
### 3.2 Loader/ABI from data, not hardcodes (mcpp + pkgindex)
139+
140+
`resolve_loader(glibcLib, targetTriple)` in `linkmodel.cppm`, in priority
141+
order:
142+
143+
1. **Declared metadata** — if the payload carries persisted exports
144+
(`<payload>/.xpkg-exports.json`, written by xlings at install time once
145+
the optional xlings change in §4.4 lands), use `runtime.loader`.
146+
2. **Triple map** — `x86_64 → ld-linux-x86-64.so.2`,
147+
`aarch64 → ld-linux-aarch64.so.1`, `riscv64 → ld-linux-riscv64-lp64d.so.1`,
148+
musl triples → `ld-musl-<arch>.so.1`.
149+
3. **Glob fallback** — first `ld-*.so*` in `glibcLib`.
150+
151+
All ~10 mcpp hardcode sites (`flags.cppm:367`, `pack.cppm`,
152+
`lifecycle.cppm`, `post_install.cppm`) switch to this resolver. This
153+
resolves the aarch64-glibc loader gap as a by-product — do **not** fix
154+
`flags.cppm:367` as an isolated issue; it is one instance of D5.
155+
156+
### 3.3 cfg role: humans only; deterministic content (mcpp + pkgindex)
157+
158+
- **mcpp never reads the cfg again.** Build flags: always
159+
`--no-default-config` + full model (unchanged direction, now complete).
160+
`parse_clang_cfg_sysroot` is removed from the probe chain (it is dead on
161+
fixed-up installs anyway) and demoted to a verbose diagnostic log.
162+
- **The cfg exists so a human running `clang++` directly gets a working,
163+
hermetic compiler.** It is generated deterministically from the model:
164+
`-B<glibcLib>`, `-L<glibcLib>`, `--dynamic-linker`, rpath, libc++
165+
`-isystem`s — no `--sysroot=<subos>`, no dependence on install-time
166+
environment.
167+
- **`llvm.lua __install_linux_cfg` (xim-pkgindex)** is rewritten to the same
168+
deterministic recipe: resolve the sibling `xim:glibc` payload (guaranteed
169+
by the package's own `deps`), read `exports.runtime.loader` from
170+
`glibc.lua` (finally consuming the mechanism built for this), and
171+
**fail the install** if the payload is missing — delete the
172+
`warn + "clang will use system sysroot"` host-fallback branch.
173+
Also: name the versioned cfg from the actual major
174+
(`clang-<major>.cfg`), not the hardcoded `clang-20.cfg`.
175+
- Compat: an old mcpp (≤0.0.82) against the new cfg behaves no worse than
176+
against today's fixed-up cfg (both lack `--sysroot`); the #195 bug in old
177+
binaries is fixed only by upgrading mcpp, which is expected.
178+
179+
### 3.4 Fixups become a pipeline stage (mcpp)
180+
181+
New single entry `toolchain::ensure_post_install_fixup(cfg, payload, pkg)`:
182+
183+
- Dispatches by package kind (gcc → patchelf + specs; llvm → patchelf(lib)
184+
+ cfg regeneration from the model; musl → none).
185+
- **Idempotent via a content-fingerprinted marker**
186+
(`<payload>/.mcpp-fixup.json`: schema version + fixup-input hash — glibc
187+
lib dir, loader path, mcpp fixup code version). A marker whose inputs
188+
drifted re-runs the fixup (lesson from the `.mcpp_ok` blind-spot class:
189+
markers must witness content, not just "a process once exited 0").
190+
- Called from **one** place: the payload-resolution seam all three current
191+
call sites share (after `resolve_xpkg_path` for toolchain packages), so
192+
explicit install, default auto-install and manifest `[toolchain]`
193+
auto-install can never diverge again. The three existing scattered calls
194+
are deleted.
195+
- Ownership guard (inherited/symlinked payloads are not patched) moves into
196+
the entry point and now covers llvm too (today only gcc has it).
197+
198+
### 3.5 Hermeticity: from promise to assertion (mcpp)
199+
200+
- **Build-time check** (Linux glibc/musl toolchains): once per toolchain
201+
fingerprint (cached next to the BMI cache, not per link), run the driver
202+
with `-### … -o /dev/null` on a trivial input, extract CRT + loader
203+
paths, and assert every one lies under an allowed prefix (the toolchain
204+
payload root, the glibc payload root, the sandbox registry). Violation ⇒
205+
hard error naming the leaked host path, with escape hatch
206+
`[build] allow_host_libs = true` (and `MCPP_ALLOW_HOST_LIBS=1`) for users
207+
who genuinely want host linking.
208+
- This converts D4's silent contamination into a first-class diagnostic and
209+
is the regression fence for the whole category, not just #195.
210+
211+
### 3.6 Tests & CI (mcpp)
212+
213+
1. **Unit**: `linkmodel` resolution against fabricated payload trees
214+
(payload-first / sysroot / none; x86_64 + aarch64 loader; glob fallback).
215+
2. **e2e `86_llvm_hermetic_link.sh`**: build an `import std` project with
216+
the llvm toolchain, re-run the link command with `-###`, assert
217+
`Scrt1.o/crti.o/crtn.o` + `--dynamic-linker` all resolve inside the
218+
sandbox (`$MCPP_HOME`/registry) — catches both "link fails" and "links
219+
against the host" regressions on any machine.
220+
3. **Parametrize llvm e2e**: 36–41/47 read `MCPP_E2E_LLVM_VERSION`
221+
(default: newest installed payload) instead of the pinned `20.1.7`.
222+
4. **Hermetic CI job**: a container job on a minimal image with **no host
223+
toolchain** (e.g. `debian:stable-slim` without gcc/libc6-dev) that
224+
installs the llvm toolchain and runs the llvm e2e subset + test 86. This
225+
is the only environment class that reproduces #195 faithfully; standard
226+
runners cannot.
227+
228+
### 3.7 Packaging admission gate (xlings-res)
229+
230+
The llvm slim-repack workflow gains a release gate: in the same minimal
231+
container, install the candidate asset + `glibc` + `linux-headers`, then
232+
compile, **link and run** an `import std` hello and run the `-###` prefix
233+
assertion. Assets that cannot produce a self-contained binary are not
234+
published (would have caught both the missing-runtime-library precedent and
235+
the #195 environment class at the source).
236+
237+
## 4. Work breakdown — PRs, order, versions
238+
239+
All mcpp-side work ships in **one PR** (commit-staged for reviewability);
240+
the other repos land small coordinated PRs. Nothing here changes package
241+
asset contents, so no xpkg version bumps are needed — only mcpp releases.
242+
243+
### 4.1 PR-1 — mcpp `feat(toolchain): hermetic link model` → **v0.0.83**
244+
245+
Commit plan inside the single PR:
246+
247+
1. `feat(toolchain): linkmodel module + loader resolver` (new code + unit
248+
tests; no behavior change yet).
249+
2. `fix(build): link CRT discovery via linkmodel (-B payload glibc)` —
250+
flags/stdmod/build_program/post_install converge on the model; deletes
251+
the four copies. **This commit alone closes #195.**
252+
3. `refactor(toolchain): single post-install fixup pipeline` (marker,
253+
one call site, ownership guard for llvm).
254+
4. `refactor(probe): drop clang-cfg sysroot mining` (diagnostic only).
255+
5. `feat(build): hermeticity assertion + allow_host_libs escape hatch`.
256+
6. `refactor: loader hardcodes → linkmodel (pack/lifecycle/post_install)`.
257+
7. `test(e2e): 86_llvm_hermetic_link + llvm version parametrization`.
258+
8. `ci: hermetic no-host-toolchain container job`.
259+
9. `chore: 0.0.83 + changelog`.
260+
261+
Merge gate: full e2e matrix (linux/macos/windows self-host) green **plus**
262+
the new hermetic container job green.
263+
264+
### 4.2 PR-2 — xim-pkgindex `fix(llvm): deterministic hermetic cfg`
265+
266+
- `pkgs/l/llvm.lua`: rewrite `__install_linux_cfg` per §3.3 (payload-based,
267+
reads `glibc.lua exports.runtime`, fail-fast, versioned cfg name).
268+
- `pkgs/g/glibc.lua`: `exports.runtime` stays the single source of truth;
269+
add per-arch loader entries when aarch64 assets land (tracked, not
270+
blocking).
271+
- Independent of PR-1 (mcpp bypasses the cfg either way); land after PR-1
272+
so the hermetic CI job exists to validate it end-to-end via a fresh
273+
install in mcpp's smoke path.
274+
275+
### 4.3 PR-3 — xlings-res `ci(llvm): hermetic admission smoke` (§3.7)
276+
277+
Workflow-only change in the llvm repack repo; no asset changes.
278+
279+
### 4.4 PR-4 (optional, non-blocking) — xlings `feat(xim): persist xpkg exports`
280+
281+
Write declared `exports` (e.g. `runtime.loader`/`abi`) to
282+
`<install_dir>/.xpkg-exports.json` at install time so consumers read
283+
declared metadata instead of conventions. mcpp's resolver treats this as
284+
priority 1 (§3.2) but works without it (triple map + glob), so this PR can
285+
trail without blocking the release train. Ships in the next regular xlings
286+
patch release if taken.
287+
288+
### 4.5 Release train (0.0.83)
289+
290+
Follows the automated ecosystem-publish pipeline established for 0.0.82:
291+
292+
1. Merge PR-1 → tag `v0.0.83` → release workflow builds + publishes
293+
assets on GitHub.
294+
2. Pipeline mirrors the release into `xlings-res/mcpp` (**both** GitHub and
295+
GitCode remotes — CN mirror must not lag, or CN-side installs fetch a
296+
stale binary).
297+
3. Pipeline opens the **xim-pkgindex bump PR** (mcpp 0.0.82 → 0.0.83);
298+
PR-2 (llvm.lua) merges alongside or immediately after it (same repo,
299+
separate PRs — keep the bump PR mechanical).
300+
4. Post-merge verification on a clean environment: `xlings install mcpp`
301+
resolves 0.0.83; then the #195 reproduction —
302+
`mcpp new hello` + `[toolchain] linux = "llvm@22.1.8"` + `mcpp run` —
303+
succeeds in the no-host-toolchain container.
304+
5. Bump the workspace bootstrap pin to 0.0.83
305+
(`ci: workspace mcpp bootstrap pin -> 0.0.83`) only after step 4 passes.
306+
307+
## 5. Compatibility & migration
308+
309+
- **Existing installs**: first build with 0.0.83 hits the fixup pipeline's
310+
marker check (no marker → fixup runs), regenerating the cfg from the
311+
model and patching anything the old scattered paths missed. No user
312+
action; no reinstall.
313+
- **BMI caches**: the flag strings change (new `-B`, canonical model
314+
rendering) ⇒ `std-module.json` metadata mismatch ⇒ std module rebuilds
315+
once per fingerprint. Expected, self-healing.
316+
- **Hermeticity check rollout risk**: environments that intentionally link
317+
host libs (system OpenGL passthrough etc. use dep `[runtime]`
318+
library_dirs, which stay allowed — the assertion covers only CRT/loader
319+
resolution). If an unforeseen legitimate layout trips it, the escape
320+
hatch keeps users unblocked while the allowlist is extended.
321+
- **Windows/macOS**: untouched code paths (`HostSDK`/PE deploy model);
322+
e2e must show zero diffs there.
323+
324+
## 6. Acceptance criteria
325+
326+
1. #195 reproduction passes in the hermetic container (fresh env, no host
327+
toolchain, llvm 22.1.8 via manifest `[toolchain]`, `mcpp run` prints).
328+
2. `-###` assertion: CRT + loader resolve inside the sandbox on **every**
329+
Linux llvm e2e, on hosts both with and without a system toolchain.
330+
3. `grep -rn "ld-linux-x86-64" src/` returns only `linkmodel.cppm`'s triple
331+
map (mcpp) / only `glibc.lua exports` (pkgindex).
332+
4. One call site for post-install fixups; all three install paths produce
333+
byte-identical cfg for the same payload.
334+
5. Full existing e2e baseline unchanged on macOS/Windows/musl.
335+
6. Release train completed through the bootstrap-pin bump with the CN
336+
mirror verified.
337+
338+
## 7. Explicitly out of scope (tracked separately)
339+
340+
- aarch64 glibc-world enablement beyond the loader resolver (payload assets
341+
for aarch64 glibc/llvm don't exist yet; the resolver removes the code
342+
blocker).
343+
- Identity-first package resolution (`probe_payload_paths` picking the
344+
first glibc version dir without identity checks) — same family as the
345+
existing identity-first refactor plan; the linkmodel takes the payload it
346+
is handed and does not widen the guessing.
347+
- gcc-specs / musl worlds beyond keeping their current behavior green.

0 commit comments

Comments
 (0)