Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
dce5acd
feat(toolchain): linkmodel — single resolver for the C-library link axis
Sunrisepeak Jul 7, 2026
abbd821
fix(build): CRT discovery via linkmodel — payload link gains -B (fixe…
Sunrisepeak Jul 7, 2026
f6baa5e
refactor(toolchain): one post-install fixup pipeline + deterministic …
Sunrisepeak Jul 7, 2026
e1c6f30
refactor(probe): stop mining the clang cfg for --sysroot (diagnostic …
Sunrisepeak Jul 7, 2026
6e4c755
feat(build): hermetic link check — assert CRT/loader resolve inside t…
Sunrisepeak Jul 7, 2026
df7607a
refactor: loader paths from data everywhere — no ld-linux-x86-64 hard…
Sunrisepeak Jul 7, 2026
34d3fc9
test(e2e): 86_llvm_hermetic_link + llvm suite unpinned from 20.1.7
Sunrisepeak Jul 7, 2026
a146b51
ci: hermetic e2e job — no host toolchain container
Sunrisepeak Jul 7, 2026
c744163
chore: 0.0.83 + changelog (hermetic toolchain link model)
Sunrisepeak Jul 7, 2026
68958da
fix(hermetic): allow the canonical xpkgs registry for symlink-inherit…
Sunrisepeak Jul 7, 2026
17ecd64
docs: hermetic toolchain link model design (issue #195)
Sunrisepeak Jul 7, 2026
b0e275b
fix(fixup): specs-grammar-safe loader detection + macOS keeps cfg-tru…
Sunrisepeak Jul 7, 2026
24d138e
ci: TEMP-DEBUG — capture core backtrace for the exit-time segfault (#…
Sunrisepeak Jul 7, 2026
26e0d44
fix(fixup): patchelf a copy + atomic rename — never rewrite live mapp…
Sunrisepeak Jul 7, 2026
7bc5ff9
Revert "ci: TEMP-DEBUG — capture core backtrace for the exit-time seg…
Sunrisepeak Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions .agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions .github/workflows/ci-linux-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,76 @@ jobs:
# Warm musl once so fresh-home e2e tests inherit the payload.
"$MCPP" toolchain install gcc 15.1.0-musl
bash tests/e2e/run_all.sh

# ──────────────────────────────────────────────────────────────────
# Hermetic (no host toolchain): the ONLY environment class that
# faithfully reproduces issue #195. Standard runners ship gcc +
# libc6-dev, so a sandbox toolchain that leaks to the host's CRT
# still links "green" there; this container has no compiler and no
# host Scrt1.o, so any leak fails loudly. Builds PR code with the
# bootstrap mcpp, then runs the llvm flow end-to-end.
# ──────────────────────────────────────────────────────────────────
hermetic:
name: hermetic e2e (no host toolchain, container)
runs-on: ubuntu-24.04
container: debian:stable-slim
timeout-minutes: 60
env:
XLINGS_NON_INTERACTIVE: '1'
steps:
- name: Install base utilities (NO compiler)
run: |
apt-get update -qq
apt-get install -y -qq curl ca-certificates git xz-utils unzip
# The whole point of this job: no host toolchain, no host CRT.
! command -v gcc
! command -v cc
test ! -e /usr/lib/x86_64-linux-gnu/Scrt1.o
test ! -e /usr/lib/gcc

- uses: actions/checkout@v4

# Payload cache (downloads only — the container still has no host
# toolchain, which is the property under test).
- name: Cache mcpp sandbox payloads
uses: actions/cache@v4
with:
path: ~/.mcpp
key: mcpp-hermetic-${{ hashFiles('mcpp.toml') }}
restore-keys: |
mcpp-hermetic-

- name: Bootstrap xlings + released mcpp
run: |
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.62
export PATH="$HOME/.xlings/subos/current/bin:$PATH"
xlings update
xlings install mcpp -y -g
MCPP_BOOT="$HOME/.xlings/subos/current/bin/mcpp"
"$MCPP_BOOT" --version
"$MCPP_BOOT" self config --mirror GLOBAL
echo "MCPP_BOOT=$MCPP_BOOT" >> "$GITHUB_ENV"

- name: Build PR mcpp from source (sandbox gcc only)
run: |
"$MCPP_BOOT" build
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
test -x "$MCPP"
"$MCPP" --version
echo "MCPP=$MCPP" >> "$GITHUB_ENV"

- name: "issue #195 reproduction: manifest llvm toolchain, fresh"
run: |
cd "$(mktemp -d)"
"$MCPP" new hello195
cd hello195
printf '\n[toolchain]\nlinux = "llvm@22.1.8"\n' >> mcpp.toml
printf 'import std;\nint main() { std::println("hello {}", 195); return 0; }\n' > src/main.cpp
"$MCPP" run

- name: Hermetic llvm e2e subset
run: |
export PATH="$HOME/.xlings/subos/current/bin:$PATH"
export MCPP
bash tests/e2e/86_llvm_hermetic_link.sh
bash tests/e2e/37_llvm_import_std.sh
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,37 @@
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。

## [0.0.83] — 2026-07-07

### 修复

- **Linux llvm 工具链链接失败 `cannot open Scrt1.o/crti.o/crtn.o`(#195)**:clang-with-cfg
的 payload 链接路径此前只带 `-L/-rpath/--dynamic-linker`,缺少 CRT 启动对象的发现前缀
`-B<glibc payload lib>`——driver 查找 `Scrt1.o/crti.o/crtn.o` 只走 `-B` 前缀与 sysroot
派生路径,不查 `-L`。在装有宿主 libc6-dev 的机器上 driver 会静默兜底宿主 `/lib` 的 CRT
(污染式"假绿"),在没有的机器(如全新 WSL2)上则把裸文件名传给 lld 直接失败。

### 新增 / 架构

- **工具链链接模型单一化(hermetic toolchain link model)**:新增 `mcpp.toolchain.linkmodel`
作为「如何对该工具链的 C 库编译/链接」的唯一解析器(payload-first,--sysroot 回退),
`flags` / `stdmod` / `build_program` / cfg 再生全部消费同一模型,消除四份漂移实现;
动态链接器名按 声明式 payload 元数据 → 按 triple 的 arch 映射 → glob 三级解析,全链
不再硬编码 `ld-linux-x86-64.so.2`(aarch64 glibc 的 loader 障碍随之消除)。详见
`.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md`。
- **post-install fixup 归位为统一管线**:`ensure_post_install_fixup` 成为所有工具链安装
路径(显式 install / 默认工具链 auto-install / manifest `[toolchain]` auto-install)共享
的唯一 fixup 入口,内容指纹 marker 幂等;此前 manifest 路径不跑任何 fixup。clang cfg
由行级补丁改为从链接模型**确定性再生**(同一 payload 在任何机器/安装路径产出一致 cfg,
人类直接使用 `clang++` 同样获得 hermetic 的 CRT 发现)。
- **hermetic 链接校验**:构建前用 `-###` 干跑断言 CRT 对象与生效 dynamic linker 全部解析
在沙箱(xpkgs registry)内,越界即报错并指明泄漏路径;逃生阀
`[build] allow_host_libs = true` / `MCPP_ALLOW_HOST_LIBS=1`。按 flag 集缓存判定。
- **测试与 CI**:新增 e2e `86_llvm_hermetic_link.sh`(`-###` 前缀断言,双向防「链接失败」
与「宿主污染」回归);llvm e2e 解除 20.1.7 硬 pin(`MCPP_E2E_LLVM_VERSION`,默认最新
已装 payload);ci-linux-e2e 新增 **无宿主工具链容器 job**(debian:stable-slim,无 gcc /
无宿主 CRT)——唯一能真实复现 #195 环境类的 CI 形态。

## [0.0.71] — 2026-06-29

### 新增
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "0.0.82"
version = "0.0.83"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
68 changes: 56 additions & 12 deletions src/build/build_program.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export module mcpp.build.build_program;

import std;
import mcpp.manifest;
import mcpp.platform;
import mcpp.platform.process;
import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex)
import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model
import mcpp.toolchain.model; // Toolchain, PayloadPaths, is_clang/is_musl_target
import mcpp.toolchain.registry; // archive_tool
import mcpp.ui;
Expand Down Expand Up @@ -116,21 +118,63 @@ std::string env_value(const std::string& name) {
// only the native cases; these are passed as separate argv tokens (no shell).
std::vector<std::string> host_base_flags(const mcpp::toolchain::Toolchain& tc) {
std::vector<std::string> f;
// Clang reads its sibling `<clang>.cfg` by default, which wires libc++ + the
// sysroot. A simple host compile trusts it (the main build bypasses the cfg
// for reproducibility; here correctness on a fresh box is all we need).
if (mcpp::toolchain::is_clang(tc)) return f;
const auto lm = mcpp::toolchain::resolve_link_model(tc);

// Clang with a bundled cfg on LINUX: bypass it (--no-default-config) and
// provide everything explicitly, same as the main build — the cfg is an
// install-time-generated artifact, so trusting it here while bypassing
// it in the main build meant two different toolchains for one project.
// On macOS/Windows keep trusting the cfg: the macOS link additionally
// needs the platform's libc++abi/unwind handling that the main build's
// needs_explicit_libcxx path owns (duplicating it for a host compile
// produced undefined __cxa_*/__gxx_personality_v0), and the fixup
// pipeline regenerates the cfg deterministically anyway.
if (mcpp::toolchain::is_clang(tc)) {
if constexpr (!mcpp::platform::is_linux) return f;
const auto dm = mcpp::toolchain::resolve_clang_driver(tc);
if (dm.hasCfg) {
f.push_back("--no-default-config");
f.push_back("-nostdinc++");
f.push_back("-stdlib=libc++");
for (auto& inc : dm.cxxIncludes) f.push_back("-isystem" + inc.string());
f.push_back("-fuse-ld=lld");
f.push_back("--rtlib=compiler-rt");
f.push_back("--unwindlib=libunwind");
for (auto& d : dm.libDirs) {
f.push_back("-L" + d.string());
f.push_back("-Wl,-rpath," + d.string());
}
}
if (lm.mode == mcpp::toolchain::CLibMode::Sysroot) {
f.push_back("--sysroot=" + lm.sysroot.string());
} else if (lm.mode == mcpp::toolchain::CLibMode::PayloadFirst) {
for (auto& inc : lm.systemIncludes) f.push_back("-isystem" + inc.string());
f.push_back("-B" + lm.crtDir.string()); // Scrt1.o/crti.o discovery
for (auto& d : lm.libDirs) {
f.push_back("-L" + d.string());
f.push_back("-Wl,-rpath," + d.string());
}
if (!lm.loader.empty())
f.push_back("-Wl,--dynamic-linker=" + lm.loader.string());
}
// Runtime lib dirs so the produced program can load private libs in-tree.
for (auto& d : tc.linkRuntimeDirs) {
f.push_back("-L" + d.string());
f.push_back("-Wl,-rpath," + d.string());
}
return f;
}

// GCC: a fresh sandbox g++ needs --sysroot to find the C library + the
// include-fixed headers; without a sysroot, wire the glibc payload directly.
if (!tc.sysroot.empty()) {
f.push_back("--sysroot=" + tc.sysroot.string());
} else if (tc.payloadPaths) {
auto& pp = *tc.payloadPaths;
f.push_back("-idirafter"); f.push_back(pp.glibcInclude.string());
if (!pp.linuxInclude.empty()) { f.push_back("-idirafter"); f.push_back(pp.linuxInclude.string()); }
f.push_back("-B" + pp.glibcLib.string()); // crt1.o/crti.o discovery
f.push_back("-L" + pp.glibcLib.string()); // -lc/-lm resolution
if (lm.mode == mcpp::toolchain::CLibMode::Sysroot) {
f.push_back("--sysroot=" + lm.sysroot.string());
} else if (lm.mode == mcpp::toolchain::CLibMode::PayloadFirst) {
for (auto& inc : lm.systemIncludes) {
f.push_back("-idirafter"); f.push_back(inc.string());
}
f.push_back("-B" + lm.crtDir.string()); // crt1.o/crti.o discovery
for (auto& d : lm.libDirs) f.push_back("-L" + d.string()); // -lc/-lm
}
// binutils -B so the driver finds ld/as (GCC, non-musl; musl ships its own).
if (!mcpp::toolchain::is_musl_target(tc)) {
Expand Down
133 changes: 39 additions & 94 deletions src/build/flags.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import mcpp.build.plan;
import mcpp.platform;
import mcpp.toolchain.clang;
import mcpp.toolchain.detect;
import mcpp.toolchain.linkmodel;
import mcpp.toolchain.provider;
import mcpp.toolchain.registry;

Expand Down Expand Up @@ -153,34 +154,28 @@ CompileFlags compute_flags(const BuildPlan& plan) {
include_flags += " -I" + escape_path(abs);
}

// Sysroot / payload paths.
//
// Payload-first: when PayloadPaths are available (glibc + linux-headers
// xpkgs found), use -isystem for each payload include dir. This avoids
// dependency on xlings subos.
//
// For Clang with a cfg file: use --no-default-config to bypass
// potentially-stale paths, then provide all flags explicitly.
//
// Fallback: if no PayloadPaths, use --sysroot from probe_sysroot().
// Sysroot / payload paths — resolved ONCE by the toolchain link model
// (mcpp.toolchain.linkmodel, the single source of truth shared with
// stdmod / build_program / the cfg fixup; see
// .agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md).
// Payload-first, --sysroot fallback; for Clang with a cfg file we bypass
// the (install-time-generated, non-reproducible) cfg with
// --no-default-config and provide everything explicitly.
const auto dm = mcpp::toolchain::resolve_clang_driver(plan.toolchain);
const auto lm = mcpp::toolchain::resolve_link_model(plan.toolchain);
const mcpp::toolchain::PathEscape ninjaEsc =
[](const std::filesystem::path& p) { return escape_path(p); };

std::string compile_toolchain_flags;
std::string link_toolchain_flags;
bool isClangWithCfg = false;
std::filesystem::path cfgPath;
const bool isClangWithCfg = dm.hasCfg;
// LLVM root of a clang-with-cfg toolchain — used by the macOS link
// path below to locate libc++.a/libc++abi.a for staticStdlib.
std::filesystem::path llvmRootForStdlib;
if (mcpp::toolchain::is_clang(plan.toolchain)) {
cfgPath = plan.toolchain.binaryPath.parent_path()
/ (plan.toolchain.binaryPath.stem().string() + ".cfg");
isClangWithCfg = std::filesystem::exists(cfgPath);
}

if (isClangWithCfg) {
// Clang with cfg: bypass cfg and provide all paths explicitly.
auto llvmRoot = plan.toolchain.binaryPath.parent_path().parent_path();
auto libcxxInclude = llvmRoot / "include" / "c++" / "v1";
compile_toolchain_flags = " --no-default-config -nostdinc++";
// --no-default-config -nostdinc++ + libc++ headers.
compile_toolchain_flags = dm.compile_flags(ninjaEsc);
// macOS deployment target: make the resolved value explicit on
// the command line so (a) the ninja commands don't depend on env
// propagation and (b) the value participates in the BMI
Expand All @@ -193,71 +188,22 @@ CompileFlags compute_flags(const BuildPlan& plan) {
compile_toolchain_flags +=
" -mmacosx-version-min=" + macosDeploymentTarget;
}
llvmRootForStdlib = llvmRoot;
// libc++ headers
compile_toolchain_flags += " -isystem" + escape_path(libcxxInclude);
if (!plan.toolchain.targetTriple.empty()) {
auto targetInclude = llvmRoot / "include"
/ plan.toolchain.targetTriple / "c++" / "v1";
if (std::filesystem::exists(targetInclude))
compile_toolchain_flags += " -isystem" + escape_path(targetInclude);
}
// C library + kernel headers from payload
if (plan.toolchain.payloadPaths) {
auto& pp = *plan.toolchain.payloadPaths;
compile_toolchain_flags += " -isystem" + escape_path(pp.glibcInclude);
if (!pp.linuxInclude.empty())
compile_toolchain_flags += " -isystem" + escape_path(pp.linuxInclude);
} else if (auto sdk = mcpp::platform::macos::sdk_path()) {
auto sysroot_flag = " --sysroot=" + escape_path(*sdk);
compile_toolchain_flags += sysroot_flag;
link_toolchain_flags += sysroot_flag;
} else if (!plan.toolchain.sysroot.empty()) {
auto sysroot_flag = " --sysroot=" + escape_path(plan.toolchain.sysroot);
compile_toolchain_flags += sysroot_flag;
link_toolchain_flags += sysroot_flag;
}
// Linker flags that cfg normally provides
link_toolchain_flags = " --no-default-config" + link_toolchain_flags
+ " -stdlib=libc++ -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind";
llvmRootForStdlib = dm.llvmRoot;
// C library headers (payload -isystem, or --sysroot fallback).
compile_toolchain_flags += lm.compile_flags(ninjaEsc);
// Linker flags that cfg normally provides. The payload C-runtime
// flags (-B/-L/loader) are appended via payload_ld below.
link_toolchain_flags = " --no-default-config";
if (lm.mode == mcpp::toolchain::CLibMode::Sysroot)
link_toolchain_flags += lm.link_flags(ninjaEsc);
link_toolchain_flags +=
mcpp::toolchain::ClangDriverModel::kLinkDriverFlags;
f.sysroot = link_toolchain_flags;
} else if (!plan.toolchain.sysroot.empty()) {
// GCC (or Clang without cfg): use --sysroot from probe.
// GCC requires --sysroot for include-fixed headers (stdlib.h wrapper).
// Supplement with -isystem for linux kernel headers from payload
// if the probed sysroot is missing them.
auto sysroot_flag = " --sysroot=" + escape_path(plan.toolchain.sysroot);
compile_toolchain_flags = sysroot_flag;
link_toolchain_flags = sysroot_flag;
// Self-contained musl toolchains ship their own kernel headers in the
// sysroot; for a cross target the host (x86) linux-headers payload is
// the wrong arch, so don't supplement it.
if (!mcpp::toolchain::is_musl_target(plan.toolchain)
&& plan.toolchain.payloadPaths && !plan.toolchain.payloadPaths->linuxInclude.empty()) {
auto sysrootLinux = plan.toolchain.sysroot / "usr" / "include" / "linux" / "limits.h";
if (!std::filesystem::exists(sysrootLinux))
compile_toolchain_flags += " -isystem" + escape_path(plan.toolchain.payloadPaths->linuxInclude);
}
f.sysroot = link_toolchain_flags;
} else if (plan.toolchain.payloadPaths) {
// No usable sysroot: wire the C library headers from the payload.
// For GCC use -idirafter (appended after the built-in dirs) so that
// libstdc++'s #include_next wrappers can reach them; -isystem would
// place them BEFORE the built-ins, invisible to #include_next.
auto& pp = *plan.toolchain.payloadPaths;
const bool clangTc = mcpp::toolchain::is_clang(plan.toolchain);
auto inc_flag = [&](const std::filesystem::path& p) {
return (clangTc ? " -isystem" : " -idirafter") + escape_path(p);
};
compile_toolchain_flags += inc_flag(pp.glibcInclude);
if (!pp.linuxInclude.empty())
compile_toolchain_flags += inc_flag(pp.linuxInclude);
// Link-time C runtime: a usable --sysroot would have provided the
// startup objects and core libs implicitly. Without one, point the
// driver at the glibc payload lib dir: -B for crt1.o/crti.o discovery,
// -L for -lm/-lc resolution.
link_toolchain_flags += " -B" + escape_path(pp.glibcLib);
link_toolchain_flags += " -L" + escape_path(pp.glibcLib);
} else if (lm.mode != mcpp::toolchain::CLibMode::None) {
// GCC (or Clang without cfg): --sysroot from probe, or the payload
// headers + C runtime (-B for crt discovery, -L for -lc/-lm).
compile_toolchain_flags = lm.compile_flags(ninjaEsc);
link_toolchain_flags = lm.link_flags(ninjaEsc);
f.sysroot = link_toolchain_flags;
}

Expand Down Expand Up @@ -358,16 +304,15 @@ CompileFlags compute_flags(const BuildPlan& plan) {
}
}

// For Clang with payload paths: add glibc lib + dynamic linker to link flags.
// For Clang with payload paths: the payload C runtime — -B so the driver
// resolves Scrt1.o/crti.o/crtn.o inside the payload (the driver never
// consults -L for CRT objects; without -B it silently falls back to the
// host's /lib or, on hosts without a system toolchain, passes bare names
// that lld cannot open — issue #195), -L/-rpath for -lc/-lm, and the
// payload's dynamic linker.
std::string payload_ld;
if (isClangWithCfg && plan.toolchain.payloadPaths) {
auto& pp = *plan.toolchain.payloadPaths;
payload_ld += " -L" + escape_path(pp.glibcLib);
payload_ld += " -Wl,-rpath," + escape_path(pp.glibcLib);
auto loader = pp.glibcLib / "ld-linux-x86-64.so.2";
if (std::filesystem::exists(loader))
payload_ld += " -Wl,--dynamic-linker=" + escape_path(loader);
}
if (isClangWithCfg && lm.mode == mcpp::toolchain::CLibMode::PayloadFirst)
payload_ld = lm.link_flags(ninjaEsc);

std::string link_extra;
if (prof.lto) link_extra += " -flto";
Expand Down
Loading
Loading