Skip to content

Commit 3a7c504

Browse files
committed
fix: a dependency's [feature-xlings] tools must be installed before its build.mcpp runs
The root's `[xlings.workspace]` declarations are provisioned early. The GRAPH's were provisioned about 1700 lines further down, after every build.mcpp had already run. So a package that declares a tool under the feature that needs it and then asks for it with `xpkg_dir` worked as the ROOT and was refused as a DEPENDENCY -- with the very declaration it had already made quoted back at it. Measured on the published `ggml-org:llamacpp@b10069.2`, which is exactly this shape: [feature-xlings.backend-vulkan] "xim:shaderc" = "2026.3" A consumer that writes the one line its README asks for could not build on a clean machine: error: dependency 'llamacpp': build.mcpp exited with 2 (build aborted): ggml-org:llamacpp backend-vulkan needs glslc. Declare it: ... With an isolated MCPP_HOME the unfixed engine pulled twenty-four xim payloads for that graph and not shaderc; the fixed one pulls it and the consumer builds, 1921 shaders compiled. WHY NOTHING CAUGHT IT. Once the tool is in the registry for any reason, and building the package itself puts it there, `xpkg_dir` finds it and the ordering stops mattering. llama.cpp-m's own CI builds it as the root; every machine that had touched the package had the payload. Only an empty registry can see this, which is what the sandbox run is for. The split is now computed once, beside `activeFeaturesByPackage`, and read by both the early provisioning pass and the later record-writing pass, so the two cannot disagree about what "the graph declared" means. The late provisioning call is kept: the stamp is keyed by content, so it is normally a hit, and it remains the site that makes the record true if the early pass did not run. e2e 618 is the criterion, and its first version was a false green worth recording: it used `xim:ninja`, which mcpp installs to run its own builds, so `xpkg_dir` answered on the broken engine too. It now uses the tool the defect was actually found with.
1 parent 145a3f2 commit 3a7c504

4 files changed

Lines changed: 193 additions & 22 deletions

File tree

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.9.6.3"
3+
version = "2026.9.6.4"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

modules/versioning/src/version.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ import std;
3131

3232
export namespace mcpp {
3333

34-
inline constexpr std::string_view MCPP_VERSION = "2026.9.6.3";
34+
inline constexpr std::string_view MCPP_VERSION = "2026.9.6.4";
3535

3636
} // namespace mcpp

src/build/prepare.cppm

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3708,6 +3708,29 @@ prepare_build(bool print_fingerprint,
37083708
// ask, and re-deriving it there would be a second copy of the aggregation
37093709
// rule.
37103710
std::vector<std::vector<std::string>> activeFeaturesByPackage;
3711+
3712+
// The split is computed HERE and reused by the late pass, so the two
3713+
// cannot disagree about what "the graph declared" means.
3714+
auto graph_xlings_split = [&] {
3715+
std::vector<std::string> rootSpecs = applicable_xlings_addresses(
3716+
runtimeOwnerManifest, activeFeaturesByPackage.empty()
3717+
? std::vector<std::string>{} : activeFeaturesByPackage[0],
3718+
toolPurpose, /*isRoot=*/true);
3719+
std::vector<std::string> fromGraph;
3720+
for (std::size_t i = 0; i < packages.size(); ++i) {
3721+
const auto& man = packages[i].manifest;
3722+
const auto feats = i < activeFeaturesByPackage.size()
3723+
? activeFeaturesByPackage[i] : std::vector<std::string>{};
3724+
for (auto const& spec : applicable_xlings_addresses(
3725+
man, feats, toolPurpose, /*isRoot=*/i == 0)) {
3726+
if (std::ranges::find(rootSpecs, spec) != rootSpecs.end())
3727+
continue;
3728+
if (std::ranges::find(fromGraph, spec) == fromGraph.end())
3729+
fromGraph.push_back(spec);
3730+
}
3731+
}
3732+
return std::pair{std::move(rootSpecs), std::move(fromGraph)};
3733+
};
37113734
packages.push_back({*root, *m});
37123735

37133736
// dep_manifests is kept around purely so the build plan can move it
@@ -7034,6 +7057,43 @@ prepare_build(bool print_fingerprint,
70347057
}
70357058
activeFeaturesByPackage.resize(packages.size());
70367059

7060+
// ── The GRAPH's `[xlings.workspace]`, provisioned BEFORE build.mcpp ──
7061+
//
7062+
// Same ordering rule as the host-tool block directly below, and for the
7063+
// same reason: a build program consumes what was provisioned, so
7064+
// provisioning after it has run is provisioning that did not happen.
7065+
//
7066+
// MEASURED, on the published `ggml-org:llamacpp@b10069.2`. That package
7067+
// declares its shader compiler under the feature that needs it:
7068+
//
7069+
// [feature-xlings.backend-vulkan]
7070+
// "xim:shaderc" = "2026.3"
7071+
//
7072+
// and its build program asks for it with `xpkg_dir`. As the ROOT it
7073+
// works, because the root's pass runs early. As a DEPENDENCY it did
7074+
// not: the graph's pass ran ~1700 lines further down, after every
7075+
// build.mcpp, so `xpkg_dir` answered "" and the package refused its own
7076+
// headline feature with the very declaration it had already made. A
7077+
// clean `MCPP_HOME` pulled twenty-four xim payloads for that graph and
7078+
// not shaderc.
7079+
//
7080+
// IT WAS INVISIBLE ON ANY MACHINE THAT HAD BUILT THE PACKAGE ITSELF.
7081+
// Once `xim:shaderc` is in the registry for any reason, `xpkg_dir`
7082+
// finds it and the ordering stops mattering; only an empty registry can
7083+
// see this. The sandbox run is what caught it.
7084+
//
7085+
{
7086+
auto [_rootSpecs, fromGraph] = graph_xlings_split();
7087+
if (!fromGraph.empty()) {
7088+
if (auto cfg = get_cfg()) {
7089+
if (auto pv = provision_xlings_addresses(
7090+
**cfg, fromGraph, *root,
7091+
"[xlings.workspace] entries declared by dependencies");
7092+
!pv) return std::unexpected(pv.error());
7093+
}
7094+
}
7095+
}
7096+
70377097
// ── #355: HOST tool provisioning ────────────────────────────────────
70387098
//
70397099
// Runs AFTER feature activation (a tool target's gate is a feature) and
@@ -9301,31 +9361,21 @@ prepare_build(bool print_fingerprint,
93019361
// and never acted on. The two definitions are one expression below, so
93029362
// they cannot drift — the third of the three hazards §12.6 named.
93039363
{
9304-
std::vector<std::string> xlingsSpecs = applicable_xlings_addresses(
9305-
runtimeOwnerManifest, activeFeaturesByPackage.empty()
9306-
? std::vector<std::string>{} : activeFeaturesByPackage[0],
9307-
toolPurpose, /*isRoot=*/true);
9308-
// Everything the GRAPH declared, on the tiers this verb needs. `isRoot`
9309-
// is false for every one of them, which is what makes `when = "dev"`
9310-
// stop at the package that wrote it.
9311-
std::vector<std::string> fromGraph;
9312-
for (std::size_t i = 0; i < packages.size(); ++i) {
9313-
const auto& man = packages[i].manifest;
9314-
const auto feats = i < activeFeaturesByPackage.size()
9315-
? activeFeaturesByPackage[i] : std::vector<std::string>{};
9316-
for (auto const& spec : applicable_xlings_addresses(
9317-
man, feats, toolPurpose, /*isRoot=*/i == 0)) {
9318-
if (std::ranges::find(xlingsSpecs, spec) != xlingsSpecs.end())
9319-
continue;
9320-
if (std::ranges::find(fromGraph, spec) == fromGraph.end())
9321-
fromGraph.push_back(spec);
9322-
}
9323-
}
9364+
// THE SAME SPLIT THE EARLY PASS USED. Written once, above, next to the
9365+
// provisioning that has to happen before build.mcpp; this site reads it
9366+
// for the records below. Two copies of "what did the graph declare"
9367+
// would be two definitions of the same word.
9368+
auto [xlingsSpecs, fromGraph] = graph_xlings_split();
93249369
// THE ROOT'S OWN PASS RAN LONG AGO, AND THIS ONE MUST NOT REPEAT IT.
93259370
// The stamp is keyed by the LIST, so provisioning root+graph together
93269371
// would key a different list than the early pass wrote and re-run an
93279372
// xlings round trip on every build. Only what the graph added is
93289373
// provisioned here, under its own key.
9374+
//
9375+
// Since the graph's pass moved above build.mcpp this call is normally a
9376+
// stamp hit. It is kept rather than deleted because the stamp is keyed
9377+
// by content: if the early pass did not run, or ran on a different
9378+
// list, this is still the site that makes the record true.
93299379
if (!fromGraph.empty()) {
93309380
if (auto cfg = get_cfg()) {
93319381
if (auto pv = provision_xlings_addresses(
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env bash
2+
# requires: elf gcc
3+
# A DEPENDENCY's `[feature-xlings.<f>]` must be installed before that
4+
# dependency's build.mcpp runs.
5+
#
6+
# THE ROOT AND A DEPENDENCY WERE NOT THE SAME PATH. The root's declarations are
7+
# provisioned early; the graph's were provisioned ~1700 lines further down, after
8+
# every build.mcpp had already run. A package that declares a tool under the
9+
# feature that needs it and then asks for it with `xpkg_dir` therefore worked as
10+
# the root and was refused as a dependency -- with the very declaration it had
11+
# already made quoted back at it.
12+
#
13+
# Measured on the published `ggml-org:llamacpp@b10069.2`: a clean MCPP_HOME
14+
# pulled twenty-four xim payloads for that graph and not `xim:shaderc`.
15+
#
16+
# WHY IT SURVIVED EVERY OTHER CHECK. Once the tool is in the registry for any
17+
# reason -- and building the package itself puts it there -- `xpkg_dir` finds it
18+
# and the ordering stops mattering. The package's own CI builds it as the ROOT.
19+
# Only a registry that has never seen it can tell, which is why this test uses
20+
# an isolated MCPP_HOME rather than the ambient one.
21+
set -e
22+
23+
TMP=$(mktemp -d)
24+
trap "rm -rf $TMP" EXIT
25+
cd "$TMP"
26+
27+
# THE TOOL MUST BE ONE mcpp DOES NOT INSTALL FOR ITS OWN REASONS.
28+
#
29+
# The first version of this test used `xim:ninja`, and it passed on the broken
30+
# engine: mcpp installs ninja to run its own builds, so `xpkg_dir` answered
31+
# whether or not the feature had been provisioned. The criterion had selected an
32+
# object that is present for an unrelated reason -- it could never have failed.
33+
#
34+
# Measured: a minimal isolated MCPP_HOME holds binutils, gcc, gcc-runtime,
35+
# gcc-specs-config, glibc, linux-headers, ninja and patchelf. `shaderc` is in
36+
# none of that, is 16M, and is the tool the real defect was found with.
37+
TOOL=shaderc
38+
TOOL_VERSION="2026.3"
39+
40+
mkdir -p dep/src
41+
cat > dep/src/lib.cpp <<'EOF'
42+
int dep_touch() { return 1; }
43+
EOF
44+
cat > dep/build.mcpp <<EOF
45+
#include <cstdio>
46+
#include <string>
47+
import mcpp;
48+
int main() {
49+
// THE ASSERTION IS HERE, in the dependency's own build program: the tool it
50+
// declared under an active feature has to be findable by the time it runs.
51+
const char* d = mcpp::xpkg_dir("xim", "$TOOL");
52+
if (d == nullptr || d[0] == '\0') {
53+
std::fprintf(stderr,
54+
"a dependency's [feature-xlings] tool was not provisioned before "
55+
"its build.mcpp ran\n");
56+
return 2;
57+
}
58+
// TO A FILE, NOT TO stdout. mcpp prints a build program's output only when
59+
// it FAILS, so an assertion that grepped the build log for this line would
60+
// be unreachable on exactly the run that is supposed to produce it.
61+
std::string out = std::string(mcpp::manifest_dir()) + "/tool-seen.txt";
62+
std::FILE* f = std::fopen(out.c_str(), "w");
63+
if (f == nullptr) return 3;
64+
std::fprintf(f, "%s\n", d);
65+
std::fclose(f);
66+
return 0;
67+
}
68+
EOF
69+
cat > dep/mcpp.toml <<EOF
70+
[package]
71+
name = "featuretool"
72+
version = "0.1.0"
73+
[modules]
74+
sources = ["src/**/*.cpp"]
75+
[features]
76+
default = []
77+
usestool = []
78+
[feature-xlings.usestool]
79+
"xim:$TOOL" = "$TOOL_VERSION"
80+
[targets.featuretool]
81+
kind = "lib"
82+
EOF
83+
84+
mkdir -p app/src
85+
cat > app/src/main.cpp <<'EOF'
86+
int main() { return 0; }
87+
EOF
88+
cat > app/mcpp.toml <<'EOF'
89+
[package]
90+
name = "consumer"
91+
version = "0.1.0"
92+
[dependencies]
93+
featuretool = { path = "../dep", features = ["usestool"] }
94+
[targets.consumer]
95+
kind = "bin"
96+
main = "src/main.cpp"
97+
EOF
98+
99+
# AN ISOLATED HOME IS THE POINT. The ambient registry very likely holds the
100+
# tool already, and then this test passes on a broken engine.
101+
export MCPP_HOME="$TMP/home"
102+
mkdir -p "$MCPP_HOME"
103+
104+
cd app
105+
if ! "$MCPP" build >build.log 2>&1; then
106+
echo "FAIL: the consumer did not build"
107+
grep -iE 'not provisioned|error' build.log | head -5
108+
exit 1
109+
fi
110+
seen="$TMP/dep/tool-seen.txt"
111+
[ -s "$seen" ] || {
112+
echo "FAIL: the dependency's build program left no record of finding its tool"
113+
tail -10 build.log
114+
exit 1
115+
}
116+
echo "the dependency's build program saw its tool at: $(cat "$seen")"
117+
grep -q "xim-x-$TOOL" "$seen" || {
118+
echo "FAIL: the recorded path does not name $TOOL: $(cat "$seen")"
119+
exit 1
120+
}
121+
echo "PASS: a dependency's [feature-xlings] tool is provisioned before its build.mcpp runs"

0 commit comments

Comments
 (0)