Skip to content

Commit 9291f1c

Browse files
committed
feat(build.mcpp): this build system's own tools come first on PATH
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.
1 parent 28c21c2 commit 9291f1c

5 files changed

Lines changed: 236 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,45 @@
7777
⚠️ **没有任何生产路径会带着全 `None` 的 `TargetSide` 走到 flags**:`resolve`
7878
给普通本机构建的是 `cAbi = { Payload, … }`。夹具现在照实写。
7979
80+
- **⭐ 载荷的 C++ 运行时,服务的是载荷的 C 库。**
81+
82+
```
83+
undefined reference to `__cxa_allocate_exception'
84+
undefined reference to `std::runtime_error::runtime_error(char const*)'
85+
```
86+
87+
契约表用 `system_from_graph()` 决定要不要取载荷的 C++ 归档 —— 又是那个跨两层的
88+
OR。一个「内核接口来自包、而 C 库与 C++ 运行时都来自载荷」的程序本该由那些归档
89+
服务,OR 却说不是,`-nostdlib++` 于是砍掉了它要用的那一份。
90+
91+
⚠️ **这一处在两个方向上都错过。** 最初是 `targetCxxRuntime`,对 C 程序失败
92+
(它没有 C++ 运行时,答「否」被读成「载荷的是对的」);#486 换成 OR,又矫枉过正。
93+
**C 库才是决定它的那一层** —— 理由 `check_layering` 早已反向陈述:载荷的 C++
94+
运行时是对着载荷的 C 库配置的,所以当且仅当那份 C 库在用时它才可用。
95+
96+
- **⭐ `build.mcpp` 的 `PATH` 前置 mcpp 自己的工具目录。**
97+
98+
```
99+
PATH=<mcpp 自己的工具目录>:<mcpp 启动时的 PATH>
100+
```
101+
102+
mcpp 把工具装进自己的 sub-OS(写下这条时那台机器上有 221 个),而此前
103+
build.mcpp 继承的 PATH 里**一个都没有**。它想用某个工具,只能像 shell 脚本
104+
一样去问 PATH —— 而 `command -v` 回答的是「这台机器有什么」,不是「这次构建
105+
用什么」。
106+
107+
实测代价:`command -v qemu-system-riscv64` 命中一个 shim,执行时答
108+
`[error] qemu-system-riscv64 is not installed in this subos` —— 找到了、
109+
报告为存在、却跑不了,而真正的那份就在 mcpp 自己的目录里,不在 PATH 上。
110+
111+
⚠️ **前置而非替换。** build.mcpp 合理地会调 `git`、`python3`、shell,这些
112+
mcpp 都不提供;只有 mcpp 的目录的 PATH 会把它们全部弄坏。前置让隔离的那份
113+
成为默认答案,宿主留在后面仍可达。
114+
115+
⭐ 解析**只发生一次**:`toolsBinDir` 在 `get_cfg` 之后算好,交给
116+
`fill_target_build_env` —— 而不是在它的两个调用点各算一遍。本次发布修的三条
117+
缺陷全部来自「一个事实在多处各自推导」,这个字段不再添一处。
118+
80119
### 测试
81120
82121
- 五条单元测试,**按 `Origin` 的四个值各一条**,外加一条把缺陷本身写成断言

‎docs/07-build-mcpp.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,30 @@ These values are folded into the re-run key **unconditionally** — changing the
482482
target, profile, or feature set re-runs the program without any
483483
`rerun-if-env-changed` declaration.
484484

485+
### `PATH` — this build system's tools first (mcpp 2026.8.25.1+)
486+
487+
The child's `PATH` is the one mcpp was started with, **prefixed** with the
488+
directory mcpp installs its own tools into. A build program that looks up a
489+
tool therefore finds the copy this build system placed there, and still reaches
490+
anything else the machine has.
491+
492+
```
493+
PATH=<mcpp's own tool directory>:<the PATH mcpp itself was started with>
494+
```
495+
496+
Why it is a prefix and not a replacement: a build program legitimately calls
497+
`git`, `python3` or a shell, none of which mcpp ships. Front position makes the
498+
isolated copy the default answer; the host stays reachable behind it.
499+
500+
⚠️ **`command -v` answers about the machine, not about this build.** Before
501+
this, a program asking `PATH` for a tool mcpp had installed could get an
502+
unrelated one — measured on `qemu-system-riscv64`, where the answer was a shim
503+
that reports "is not installed in this subos" when executed, while the working
504+
copy sat in mcpp's own directory and was not on `PATH` at all.
505+
506+
The directory is empty on an installation that has no sub-OS yet, and `PATH` is
507+
then left exactly as inherited.
508+
485509
## Dependencies' build.mcpp (mcpp 0.0.95+)
486510

487511
A dependency that ships a `build.mcpp` gets it compiled and run too (the

‎src/build/build_program.cppm‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,30 @@ struct BuildProgramEnv {
105105
// the same re-run key: a rebuilt tool re-runs the program that uses it,
106106
// with no `rerun-if-changed` needed from the author.
107107
std::vector<std::pair<std::string, std::string>> toolPaths;
108+
// ⭐⭐ THE DIRECTORY THIS BUILD SYSTEM'S OWN TOOLS ARE IN, PUT AT THE FRONT
109+
// OF THE CHILD'S `PATH`.
110+
//
111+
// A build program that wants a tool has, until this field, had to find it
112+
// the way any shell script would — and `command -v` answers about the
113+
// machine, not about this build. Measured 2026-08-25: mcpp installs
114+
// `qemu-system-riscv64` into its own subos, and a probe that asked PATH
115+
// got an xlings shim that answers, when run,
116+
//
117+
// [error] qemu-system-riscv64 is not installed in this subos (_)
118+
//
119+
// — found, reported as present, and unable to execute. The 221 programs
120+
// mcpp had installed on purpose were not on that PATH at all.
121+
//
122+
// ⚠️ PREPENDED, NOT SUBSTITUTED. A build program legitimately reaches for
123+
// things this build system does not ship — `git`, `python3`, a shell — and
124+
// a PATH containing only mcpp's directory would break every one of them for
125+
// the sake of a guarantee nobody asked for. Front position is what makes
126+
// the isolated copy the default answer; the host stays reachable behind it.
127+
//
128+
// This is the same division of labour as `toolchainDir` and `compilerId`
129+
// above: a package should be able to ASK rather than guess, and the thing
130+
// that knows the answer is the one that installed the tools.
131+
std::string toolsBin;
108132
// #355 step 5: dependency-provided modules to compile FOR THE HOST and make
109133
// importable from this build.mcpp — reusable build rules distributed as
110134
// ordinary mcpp packages (`import mcpp.rules.protobuf;`) instead of a
@@ -412,6 +436,24 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv
412436
// append the platform's exe suffix itself, and a tool's adjacent DATA
413437
// (protoc's well-known .proto files, say) lives in the package tree, which
414438
// dep_dir() already exposes.
439+
// ── The child's PATH, with this build system's own tools in front ───────
440+
//
441+
// See `BuildProgramEnv::toolsBin` for why this exists and why it is a
442+
// prefix rather than a replacement.
443+
//
444+
// ⚠️ THE INHERITED VALUE IS READ HERE AND NOT ASSUMED. `extraEnv` replaces
445+
// a variable outright in the child, so writing only mcpp's directory would
446+
// silently be the substitution this deliberately is not.
447+
if (!env.toolsBin.empty()) {
448+
const char* inherited = std::getenv("PATH");
449+
std::string path = env.toolsBin;
450+
if (inherited && *inherited) {
451+
path += mcpp::platform::env::path_list_separator();
452+
path += inherited;
453+
}
454+
e.emplace_back("PATH", path);
455+
}
456+
415457
for (auto const& [var, path] : env.toolPaths) {
416458
auto [it, inserted] = depVarValue.try_emplace(var, path);
417459
if (inserted) {

‎src/build/prepare.cppm‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -854,8 +854,19 @@ mcpp::platform::process::RunResult run_with_network_retry(
854854
}
855855

856856
void fill_target_build_env(mcpp::build::BuildProgramEnv& e,
857-
const mcpp::toolchain::Toolchain* tc)
857+
const mcpp::toolchain::Toolchain* tc,
858+
const std::string& toolsBin)
858859
{
860+
// ⭐ ONE CALL, AND THAT IS THE POINT OF THIS FUNCTION.
861+
//
862+
// Every answer a build program would otherwise hardcode is filled here, so
863+
// that the two call sites below cannot drift apart. `toolsBin` arrives as
864+
// an argument rather than being resolved here because this is a free
865+
// function with no configuration in scope — and resolving it twice at the
866+
// call sites is exactly the shape that produced three regressions in
867+
// #486: one fact, derived in more than one place, agreeing until it did
868+
// not.
869+
e.toolsBin = toolsBin;
859870
e.toolchainDir = (tc && !tc->binaryPath.empty())
860871
? tc->binaryPath.parent_path().parent_path().string() : std::string{};
861872
e.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{};
@@ -1280,6 +1291,28 @@ prepare_build(bool print_fingerprint,
12801291
return &*cfg_opt;
12811292
};
12821293

1294+
// ⭐ WHERE THIS BUILD SYSTEM'S OWN TOOLS ARE, RESOLVED ONCE.
1295+
//
1296+
// Read once here rather than at each `fill_target_build_env` call, because
1297+
// there are two of them (root package and dependency) and a value derived
1298+
// separately at each is a value that can disagree. That is not a
1299+
// hypothetical shape in this file: three defects fixed in this release
1300+
// came from one fact being computed in more than one place — see
1301+
// `TargetSide::c_library_is_the_payloads` and the note in
1302+
// `cache_key.cppm`.
1303+
//
1304+
// Through the config resolver, not `~/.mcpp`: `MCPP_HOME` moves this
1305+
// directory, and every other consumer of it (doctor, resources) reads it
1306+
// the same way. Empty when the directory does not exist — a fresh
1307+
// installation has no sub-OS yet, and `contract_env` then leaves `PATH`
1308+
// untouched rather than prefixing a path to nothing.
1309+
std::string toolsBinDir;
1310+
if (auto c = get_cfg(/*requireBootstrap=*/false)) {
1311+
auto bin = (*c)->xlingsHome() / "subos" / "default" / "bin";
1312+
std::error_code tec;
1313+
if (std::filesystem::is_directory(bin, tec)) toolsBinDir = bin.string();
1314+
}
1315+
12831316
// Resolve one exact runtime contract before resolving/fixing a toolchain.
12841317
// The fixup is itself a consumer of RuntimeBinding: doing it first would
12851318
// recreate #392 by letting directory order choose a libc and only later
@@ -5541,7 +5574,7 @@ prepare_build(bool print_fingerprint,
55415574
// The payload ROOT (not the driver), the target's C library, and the
55425575
// three answers that keep a board package from hardcoding a toolchain
55435576
// or a libc. All four in one call — see fill_target_build_env.
5544-
fill_target_build_env(bpEnv, tc ? &*tc : nullptr);
5577+
fill_target_build_env(bpEnv, tc ? &*tc : nullptr, toolsBinDir);
55455578
bpEnv.profile = effectiveProfile;
55465579
bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures);
55475580
bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp" / "deps"
@@ -6018,7 +6051,7 @@ prepare_build(bool print_fingerprint,
60186051
// The payload ROOT (not the driver), the target's C library, and the
60196052
// three answers that keep a board package from hardcoding a toolchain
60206053
// or a libc. All four in one call — see fill_target_build_env.
6021-
fill_target_build_env(bpEnv, tc ? &*tc : nullptr);
6054+
fill_target_build_env(bpEnv, tc ? &*tc : nullptr, toolsBinDir);
60226055
bpEnv.profile = effectiveProfile;
60236056
// Set explicitly rather than relying on build_dir()'s root-relative
60246057
// default: under BuildOverrides::work_dir the package root is shared
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env bash
2+
# requires: gcc unix-shell
3+
# A build program looks up a tool and finds the one mcpp installed.
4+
#
5+
# ⚠️ IT USED TO FIND WHATEVER THE MACHINE HAD, AND `command -v` CANNOT TELL THE
6+
# DIFFERENCE.
7+
#
8+
# mcpp installs its tools into its own sub-OS — 221 programs on the machine
9+
# this was written on, cross-compilers and emulators among them — and until
10+
# 2026.8.25.1 none of them were on the PATH a build program inherited.
11+
# Measured with a build program that printed its own PATH:
12+
#
13+
# /home/…/.xlings/data/xpkgs/xim-x-claude/2.1.222
14+
# /home/…/mcpp/.xlings/subos/_/bin
15+
# /home/…/.xlings/subos/current/bin
16+
# subos/bin in PATH: NO
17+
#
18+
# The concrete cost, from the same day: `command -v qemu-system-riscv64`
19+
# returned an xlings shim that answers, when run,
20+
#
21+
# [error] qemu-system-riscv64 is not installed in this subos (_)
22+
#
23+
# — found, reported present, unable to execute, while the real one sat in
24+
# mcpp's own directory unreachable.
25+
#
26+
# ⭐ PREPENDED, NOT SUBSTITUTED, AND THIS FILE ASSERTS BOTH HALVES. A build
27+
# program legitimately reaches for things mcpp does not ship, so the host's
28+
# entries must survive behind mcpp's. A test that only checked the first entry
29+
# would pass on a PATH that had thrown the rest away.
30+
set -e
31+
32+
MCPP="${MCPP:-mcpp}"
33+
work="$(mktemp -d)"
34+
trap 'rm -rf "$work"' EXIT
35+
mkdir -p "$work/app/src"
36+
cd "$work/app"
37+
38+
cat > mcpp.toml <<'TOML'
39+
[package]
40+
name = "pathprobe"
41+
version = "0.1.0"
42+
TOML
43+
printf 'int main() { return 0; }\n' > src/main.cpp
44+
45+
# ⚠️ A NON-ZERO EXIT, BECAUSE THAT IS WHAT MAKES THE OUTPUT VISIBLE. mcpp
46+
# prints a build program's stdout only when it fails — a probe that succeeds
47+
# says nothing, which is a property of the protocol and not of this test.
48+
cat > build.mcpp <<'CPP'
49+
import std;
50+
51+
int main() {
52+
const char* p = std::getenv("PATH");
53+
std::string_view path(p ? p : "");
54+
std::string_view first;
55+
for (auto part : std::views::split(path, ':')) {
56+
first = std::string_view(part);
57+
break;
58+
}
59+
std::println("PROBE_FIRST={}", first);
60+
std::println("PROBE_ENTRIES={}", std::ranges::count(path, ':') + 1);
61+
return 1;
62+
}
63+
CPP
64+
65+
out="$("$MCPP" build 2>&1 || true)"
66+
67+
first="$(printf '%s\n' "$out" | grep -oP 'PROBE_FIRST=\K.*' | head -1)"
68+
entries="$(printf '%s\n' "$out" | grep -oP 'PROBE_ENTRIES=\K[0-9]+' | head -1)"
69+
70+
if [ -z "$first" ]; then
71+
echo "SKIP: the build program did not report — it may not have run here"
72+
printf '%s\n' "$out" | grep -iE 'error' | head -3
73+
exit 0
74+
fi
75+
76+
case "$first" in
77+
*/subos/default/bin)
78+
echo " ok the first PATH entry is this build system's own tools: $first" ;;
79+
*)
80+
echo "FAIL: the first PATH entry is not mcpp's tools directory"
81+
echo " got: $first"
82+
exit 1 ;;
83+
esac
84+
85+
# ⭐ AND THE HOST IS STILL BEHIND IT. One entry means the inherited PATH was
86+
# replaced rather than extended, which would break every build program that
87+
# calls `git`, `python3` or a shell.
88+
if [ "${entries:-1}" -gt 1 ]; then
89+
echo " ok the inherited PATH survives behind it ($entries entries)"
90+
else
91+
echo "FAIL: PATH was replaced, not prefixed — only $entries entry"
92+
exit 1
93+
fi
94+
95+
echo "OK: a build program finds this build system's tools first, and the host's after"

0 commit comments

Comments
 (0)