Skip to content

Commit f33d8ff

Browse files
committed
fix(xlings): read the real subos_info wire format, and refuse a pre-upgrade cache
Carried over from the PR this work supersedes; the two defects and their evidence are unchanged. 2026.8.8.1 shipped a reader for a format that does not exist. `envs` is an object keyed by binding, the reader expected an array of {binding, decls}, so is_array() was false, the loop never ran, and against a real subos every variable came back unset -- LIBGL_DRIVERS_PATH, __EGL_VENDOR_LIBRARY_DIRS, XDG_DATA_DIRS still the host's. The whole #352 fix was inert. Ten unit tests and an e2e passed throughout, because every fixture was hand-written in the same invented shape the parser expected. A fixture composed from the same understanding as the parser cannot catch a misunderstanding of the wire format; only one taken from the writer can. RealXlingsCapture is that -- verbatim output from a real `xlings install graphics`, reformatted for width and nothing else. Transcribing the parser from xlings's own reader also caught a second divergence: xlings drops a declaration whose op is neither "set" nor "prepend", and this accepted any. Second defect: `mcpp run`'s fast path replayed a cache written before the subos field existed, so the fix did not survive an upgrade -- and not just for one run. The fast path's identity is the profile, cache mode and resource list, and its fingerprint check compares an entry against itself; nothing notices that a different mcpp wrote it. Measured on a real 2026.8.7.1 -> 2026.8.8.1 upgrade. An empty subosDir cannot stand in for "predates the field", since a system toolchain legitimately has no subos, so presence is tracked separately.
1 parent 2671024 commit f33d8ff

4 files changed

Lines changed: 177 additions & 42 deletions

File tree

src/build/execute.cppm

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ struct BuildCacheEntry {
6868
// property and would otherwise be unknowable on the fast path -- which
6969
// has no toolchain to derive it from.
7070
std::string subosDir;
71+
// Was the line present at all? An EMPTY subosDir is a legitimate answer
72+
// (a system toolchain outside the xpkgs store has no subos), so it cannot
73+
// stand in for "this cache predates the field" -- and those two need
74+
// opposite treatment: the first runs, the second must rebuild once.
75+
bool subosRecorded = false;
7176
// The resolved profile this entry was built for. Entries used to be keyed
7277
// by target triple alone, and the fast paths only refuse to run when an
7378
// EXPLICIT --profile/--dev/--release is passed — so a bare `mcpp build`
@@ -154,7 +159,8 @@ std::vector<BuildCacheEntry> read_build_cache(const std::filesystem::path& proje
154159
// using the cache at all -- the program would work once and then
155160
// silently stop finding its runtime data.
156161
if (haveNextLine && line.starts_with("subos=")) {
157-
e.subosDir = line.substr(6);
162+
e.subosDir = line.substr(6);
163+
e.subosRecorded = true;
158164
haveNextLine = static_cast<bool>(std::getline(f, line));
159165
}
160166
// Optional profile line. Same back-compat contract as the two blocks
@@ -201,7 +207,8 @@ void write_build_cache(const std::filesystem::path& projectRoot,
201207
// Insert at front (MRU).
202208
BuildCacheEntry newEntry{targetTriple, outputDir.string(), ninjaProgram, fingerprintHex,
203209
runtimeEnvKey, runtimeEnvValue, std::move(runTargets),
204-
runEnvKey, runEnvValue, subosDir, profile, cacheMode};
210+
runEnvKey, runEnvValue, subosDir, /*subosRecorded=*/true,
211+
profile, cacheMode};
205212
entries.insert(entries.begin(), std::move(newEntry));
206213

207214
// Trim to LRU capacity.
@@ -774,6 +781,18 @@ std::optional<int> try_fast_run(const std::filesystem::path& projectRoot,
774781
ninjaProgram = ninjaProgram.substr(1, ninjaProgram.size() - 2);
775782
if (match->runtimeEnvKey.empty())
776783
return std::nullopt; // old cache entry; go through prepare_build once
784+
// Written before this mcpp knew about subos environments (mcpp#352). Taking
785+
// the fast path here would run the program without them -- which is the
786+
// defect this field exists to fix, surviving an upgrade.
787+
//
788+
// It survives it for a long time, too: the fast path's identity is the
789+
// profile, the cache mode and the resource list, and its fingerprint check
790+
// compares a cached entry against ITSELF. Neither notices that a different
791+
// mcpp wrote the entry, so without this line an upgraded mcpp would reuse a
792+
// pre-upgrade build until something else happened to invalidate it. Measured
793+
// on a real upgrade from 2026.8.7.1, not reasoned about.
794+
if (!match->subosRecorded)
795+
return std::nullopt; // predates `subos=`; rebuild once, then it is there
777796

778797
// P1: verify fingerprint matches the outputDir basename.
779798
if (!match->fingerprint.empty()) {

src/xlings/subos_info.cppm

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -131,28 +131,39 @@ Info read(const std::filesystem::path& subosDir) {
131131
if (auto v = it->find("runtime"); v != it->end() && v->is_string())
132132
info.runtime = v->get<std::string>();
133133

134-
if (auto envs = it->find("envs"); envs != it->end() && envs->is_array()) {
135-
for (auto const& p : *envs) {
136-
if (!p.is_object()) continue;
134+
// `envs` is an OBJECT keyed by binding, whose values are arrays of
135+
// declarations:
136+
//
137+
// "envs": { "mesa@25.0.7.1": [ {"var":…,"op":…,"value":…}, … ], … }
138+
//
139+
// Transcribed from xlings's own reader (core/subos/manifest.cppm), not
140+
// from a model of it. The first version of this file expected an array of
141+
// {binding, decls} objects — a shape xlings never writes — and its tests
142+
// hand-wrote JSON in that same invented shape, so both agreed and both
143+
// were wrong. Against a real subos the loop simply never ran and every
144+
// variable came back unset, silently. That is why the fixture below is a
145+
// verbatim capture of real output rather than something composed here.
146+
if (auto envs = it->find("envs"); envs != it->end() && envs->is_object()) {
147+
for (auto e = envs->begin(); e != envs->end(); ++e) {
148+
if (!e.value().is_array()) continue;
137149
Provider prov;
138-
if (auto b = p.find("binding"); b != p.end() && b->is_string())
139-
prov.binding = b->get<std::string>();
140-
if (auto ds = p.find("decls"); ds != p.end() && ds->is_array()) {
141-
for (auto const& d : *ds) {
142-
if (!d.is_object()) continue;
143-
EnvDecl e;
144-
if (auto x = d.find("var"); x != d.end() && x->is_string())
145-
e.var = x->get<std::string>();
146-
if (auto x = d.find("op"); x != d.end() && x->is_string())
147-
e.op = x->get<std::string>();
148-
if (auto x = d.find("value"); x != d.end() && x->is_string())
149-
e.value = x->get<std::string>();
150-
// A declaration with no variable name is not a partial
151-
// declaration to be guessed at — it is malformed input,
152-
// and the right thing is to leave it out rather than
153-
// invent a name for it.
154-
if (!e.var.empty()) prov.decls.push_back(std::move(e));
155-
}
150+
prov.binding = e.key();
151+
for (auto const& d : e.value()) {
152+
if (!d.is_object()) continue;
153+
EnvDecl decl;
154+
if (auto x = d.find("var"); x != d.end() && x->is_string())
155+
decl.var = x->get<std::string>();
156+
if (auto x = d.find("op"); x != d.end() && x->is_string())
157+
decl.op = x->get<std::string>();
158+
if (auto x = d.find("value"); x != d.end() && x->is_string())
159+
decl.value = x->get<std::string>();
160+
// xlings drops a declaration whose var is empty or whose op is
161+
// neither "set" nor "prepend". Matched exactly: a reader that
162+
// is more permissive than the writer will one day apply
163+
// something the writer considers malformed.
164+
if (decl.var.empty()) continue;
165+
if (decl.op != "set" && decl.op != "prepend") continue;
166+
prov.decls.push_back(std::move(decl));
156167
}
157168
info.providers.push_back(std::move(prov));
158169
}

tests/e2e/200_subos_env_reaches_program.sh

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
# know what any of these variables mean -- it carries whatever the subos
1515
# declares -- and a test naming LIBGL_DRIVERS_PATH would quietly suggest
1616
# otherwise.
17+
#
18+
# The JSON below is xlings's REAL shape: `envs` is an object keyed by binding,
19+
# whose values are arrays of declarations. The first version of this test wrote
20+
# an array of {binding, decls} -- a shape xlings never produces -- and it
21+
# passed, because the reader had been written from the same misunderstanding.
22+
# Do not "simplify" this structure; it is a wire format, not a convenience.
1723
set -euo pipefail
1824

1925
TMP=$(mktemp -d)
@@ -28,9 +34,9 @@ mkdir -p "$subos/usr/lib/dri"
2834
cat > "$subos/.xlings.json" <<'EOF'
2935
{ "workspace": {},
3036
"subos_info": { "schema_version": 1, "runtime": "glibc@2.39",
31-
"envs": [ { "binding": "probe@1", "decls": [
37+
"envs": { "probe@1": [
3238
{ "var": "MCPP_E2E_PROBE", "op": "prepend",
33-
"value": "${subosdir}/usr/lib/dri" } ] } ] } }
39+
"value": "${subosdir}/usr/lib/dri" } ] } } }
3440
EOF
3541

3642
cd "$TMP"
@@ -106,6 +112,33 @@ echo "$out2" | grep -q 'PROBE=(unset)' || {
106112
exit 1
107113
}
108114

115+
# 2b. A cache written before this mcpp knew about subos environments must NOT
116+
# be replayed by the fast path. Simulated by stripping the field, which is
117+
# exactly what an older mcpp's cache looks like.
118+
#
119+
# Without this the fix survives an upgrade in name only: the fast path's
120+
# identity is the profile, the cache mode and the resource list, and its
121+
# fingerprint check compares a cached entry against itself -- so nothing
122+
# notices that a different mcpp wrote it, and an upgraded mcpp would keep
123+
# running the pre-upgrade build with no subos environment at all.
124+
cache="$TMP/hello/target/.build_cache"
125+
[ -f "$cache" ] || { echo "no build cache to age"; exit 1; }
126+
grep -q '^subos=' "$cache" || { echo "cache has no subos= line to strip"; exit 1; }
127+
grep -v '^subos=' "$cache" > "$cache.old" && mv "$cache.old" "$cache"
128+
aged=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1) || {
129+
echo "run against an aged cache failed:"; echo "$aged"; exit 1; }
130+
echo "$aged" | grep -q 'Resolving toolchain' || {
131+
echo "an aged cache was replayed by the fast path — the subos environment"
132+
echo " would be missing for every run after an upgrade:"
133+
echo "$aged"
134+
exit 1
135+
}
136+
echo "$aged" | grep -q "PROBE=$subos/usr/lib/dri" || {
137+
echo "the rebuild after an aged cache did not apply the environment:"
138+
echo "$aged"; exit 1; }
139+
grep -q '^subos=' "$cache" || {
140+
echo "the rebuild did not record subos= , so every later run repeats it"; exit 1; }
141+
109142
# 3. A subos with no self-description degrades quietly and still runs. This is
110143
# the state of every subos created before xlings grew the block, so it must
111144
# not be an error.

tests/unit/test_subos_info.cpp

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ TEST(SubosInfo, ReadsRuntimeAndEnvDeclarations) {
3737
"subos_info": {
3838
"schema_version": 1,
3939
"runtime": "glibc@2.39",
40-
"envs": [
41-
{ "binding": "mesa@25.0.7.1", "decls": [
40+
"envs": {
41+
"mesa@25.0.7.1": [
4242
{ "var": "LIBGL_DRIVERS_PATH", "op": "prepend",
4343
"value": "${subosdir}/usr/lib/dri" },
4444
{ "var": "XDG_DATA_DIRS", "op": "prepend",
4545
"value": "${subosdir}/share" }
46-
]}
47-
]
46+
]
47+
}
4848
}
4949
})");
5050
auto info = su::read(t.dir);
@@ -64,8 +64,8 @@ TEST(SubosInfo, ReadsRuntimeAndEnvDeclarations) {
6464
TEST(SubosInfo, ResolvesSubosdirPlaceholder) {
6565
Tmp t;
6666
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39",
67-
"envs":[{"binding":"mesa@1","decls":[
68-
{"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}]}})");
67+
"envs":{"mesa@1":[
68+
{"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}}})");
6969
auto env = su::resolve_env(su::read(t.dir), t.dir);
7070
ASSERT_EQ(env.size(), 1u);
7171
EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH");
@@ -81,11 +81,9 @@ TEST(SubosInfo, ResolvesSubosdirPlaceholder) {
8181
// EGL vendor directory. `prepend` joins them; it must not drop either.
8282
TEST(SubosInfo, PrependJoinsProvidersInOrder) {
8383
Tmp t;
84-
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[
85-
{"binding":"a-mesa@1","decls":[
86-
{"var":"V","op":"prepend","value":"${subosdir}/one"}]},
87-
{"binding":"b-vendor@1","decls":[
88-
{"var":"V","op":"prepend","value":"${subosdir}/two"}]}]}})");
84+
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{
85+
"a-mesa@1":[{"var":"V","op":"prepend","value":"${subosdir}/one"}],
86+
"b-vendor@1":[{"var":"V","op":"prepend","value":"${subosdir}/two"}]}}})");
8987
auto env = su::resolve_env(su::read(t.dir), t.dir);
9088
ASSERT_EQ(env.size(), 1u);
9189
const auto sep = mcpp::platform::env::path_list_separator();
@@ -98,9 +96,9 @@ TEST(SubosInfo, PrependJoinsProvidersInOrder) {
9896
// would otherwise grow the variable without bound.
9997
TEST(SubosInfo, PrependDeduplicates) {
10098
Tmp t;
101-
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[
102-
{"binding":"a@1","decls":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]},
103-
{"binding":"b@1","decls":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}]}})");
99+
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{
100+
"a@1":[{"var":"V","op":"prepend","value":"${subosdir}/x"}],
101+
"b@1":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}}})");
104102
auto env = su::resolve_env(su::read(t.dir), t.dir);
105103
ASSERT_EQ(env.size(), 1u);
106104
// One entry, not two. The de-duplication has to split on the PLATFORM's
@@ -114,9 +112,9 @@ TEST(SubosInfo, PrependDeduplicates) {
114112
// `set` replaces rather than joins — xlings's own precedence.
115113
TEST(SubosInfo, SetReplaces) {
116114
Tmp t;
117-
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[
118-
{"binding":"a@1","decls":[{"var":"V","op":"prepend","value":"/one"}]},
119-
{"binding":"b@1","decls":[{"var":"V","op":"set","value":"/two"}]}]}})");
115+
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{
116+
"a@1":[{"var":"V","op":"prepend","value":"/one"}],
117+
"b@1":[{"var":"V","op":"set","value":"/two"}]}}})");
120118
auto env = su::resolve_env(su::read(t.dir), t.dir);
121119
ASSERT_EQ(env.size(), 1u);
122120
EXPECT_EQ(env[0].second, "/two");
@@ -177,4 +175,78 @@ TEST(SubosInfo, FamilyOfMirrorsXlings) {
177175
EXPECT_EQ(su::family_of("glibc"), "linux-x86_64-glibc");
178176
}
179177

178+
179+
// A VERBATIM capture of what a real xlings wrote, after `xlings install
180+
// graphics` on an NVIDIA host. Reformatted for width and nothing else -- keys,
181+
// nesting and spelling are as found on disk.
182+
//
183+
// This test exists because its absence shipped a broken feature. The first
184+
// version of this file hand-wrote every fixture in a shape the reader also
185+
// expected and xlings never produces: `envs` as an array of {binding, decls}.
186+
// Ten tests passed against a format that does not exist, and against a real
187+
// subos the released build applied no variables at all -- silently, because
188+
// "no providers" and "nothing declared" look identical.
189+
//
190+
// A fixture composed from the same understanding as the parser cannot catch
191+
// that. Only one taken from the writer can.
192+
TEST(SubosInfo, RealXlingsCapture) {
193+
Tmp t;
194+
t.write(R"({
195+
"subos_info": {
196+
"created_at": "2026-08-08T01:40:00Z",
197+
"created_by": "xlings 2026.8.7.1",
198+
"runtime": "glibc@2.39",
199+
"schema_version": 1,
200+
"envs": {
201+
"mesa@25.0.7.1": [
202+
{"op": "prepend", "value": "${subosdir}/usr/lib/dri", "var": "LIBGL_DRIVERS_PATH"},
203+
{"op": "prepend", "value": "${subosdir}/share/glvnd/egl_vendor.d", "var": "__EGL_VENDOR_LIBRARY_DIRS"},
204+
{"op": "prepend", "value": "${subosdir}/share", "var": "XDG_DATA_DIRS"}
205+
],
206+
"nvidia-gl-host-link@0.1.1": [
207+
{"op": "prepend", "value": "${subosdir}/share/glvnd/egl_vendor.d", "var": "__EGL_VENDOR_LIBRARY_DIRS"}
208+
]
209+
}
210+
},
211+
"workspace": {}
212+
})");
213+
214+
auto info = su::read(t.dir);
215+
ASSERT_TRUE(info.present);
216+
EXPECT_EQ(info.runtime, "glibc@2.39");
217+
ASSERT_EQ(info.providers.size(), 2u);
218+
EXPECT_EQ(info.providers[0].binding, "mesa@25.0.7.1");
219+
EXPECT_EQ(info.providers[1].binding, "nvidia-gl-host-link@0.1.1");
220+
221+
auto env = su::resolve_env(info, t.dir);
222+
ASSERT_EQ(env.size(), 3u) << "all three graphics variables must be produced";
223+
224+
std::map<std::string, std::string> byVar;
225+
for (auto& [k, v] : env) byVar[k] = v;
226+
const auto sep = mcpp::platform::env::path_list_separator();
227+
EXPECT_EQ(byVar["LIBGL_DRIVERS_PATH"], t.dir.string() + "/usr/lib/dri");
228+
EXPECT_EQ(byVar["XDG_DATA_DIRS"], t.dir.string() + "/share");
229+
// Both providers name the same vendor directory; de-duplication must
230+
// leave exactly one, or libglvnd sees it twice and enumerates the device
231+
// twice -- which is a defect xlings hit on its own side.
232+
EXPECT_EQ(byVar["__EGL_VENDOR_LIBRARY_DIRS"],
233+
t.dir.string() + "/share/glvnd/egl_vendor.d");
234+
EXPECT_EQ(byVar["__EGL_VENDOR_LIBRARY_DIRS"].find(sep), std::string::npos);
235+
}
236+
237+
// xlings drops a declaration whose op it does not recognise. A reader more
238+
// permissive than its writer eventually applies something the writer meant to
239+
// reject, so this asserts the same refusal rather than a tolerant guess.
240+
TEST(SubosInfo, UnknownOpIsDroppedLikeXlingsDrops) {
241+
Tmp t;
242+
t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{
243+
"a@1":[{"var":"V","op":"append","value":"/nope"},
244+
{"var":"W","op":"prepend","value":"/yes"},
245+
{"var":"","op":"prepend","value":"/no-name"}]}}})");
246+
auto env = su::resolve_env(su::read(t.dir), t.dir);
247+
ASSERT_EQ(env.size(), 1u);
248+
EXPECT_EQ(env[0].first, "W");
249+
EXPECT_EQ(env[0].second, "/yes");
250+
}
251+
180252
} // namespace

0 commit comments

Comments
 (0)