Skip to content

Commit 797b4f5

Browse files
committed
fix(runtime): SubOS 缺声明降级,不再让构建失效 (openxlings/xlings#543)
Windows 上 xlings 不写 subos_info 块,而 mcpp 把「缺声明」当成错误返回, prepare 无平台条件地把它变成 std::unexpected —— 于是每一次 mcpp build / mcpp test 都停在一条讲 GL 驱动的消息上,在一台没有 ELF、没有 PT_INTERP、没有私有 libc 的机器上。 error: selected SubOS 'default' cannot provide a RuntimeBinding: … does not describe itself … a GL application will not find its drivers 回归窗口已核:git log -S 只命中 PR #400(它把返回 string 的软函数换成了 expected), 且该 commit 在 2026.8.8.4 的 bump 之后 ⇒ 首次随 2026.8.10.2 发布。与报告 「降级到 2026.8.8.4 就能跑」逐字吻合。 判据改为一句话:矛盾报错,缺席降级。 点名的 SubOS 不存在 → 仍是硬错误。该请求无法被满足,换一个环境会让同一份 mcpp.toml 在不同机器上意味着不同 ABI。 SubOS 没有描述自己 → declared=false + 一条调用方必须打印的 note,构建继续。 runtime 规则报 inconclusive 而不是给出判决。 同一位置的第二颗雷一并拆掉:schema 检查是 !=,而它的读取器 subos_info::read 明写着 「更高的 schema 照读,取我们认识的字段」。xlings 写出 schema 2 的那天,全平台所有 构建会同时停摆 —— 这是 index-floor 事故的第二次转生。改为上限语义:发布数据不得 使读它的程序失效。 诚实的边界:Linux 上降级 binding 确实拿不到 payload(mcpp 拒绝猜一个 libc 版本), 所以链接会回落宿主并被 hermeticity 检查如实拦下。note 现在把这句话说出来,而不是 承诺「构建不受影响」—— e2e 221 断言的正是这个:失败可以,但必须是关于 C 运行时的 失败,不能是关于 binding 的失败。 e2e 221 刻意不声明任何能力。带 `# requires: elf` 或 `gcc` 会让它在唯一需要它的平台 上被跳过 —— 与 217 从未在 Windows/macOS 跑过是同一形状。 RuntimeBinding 同时新增 searchDirs 字段(SubOS 库视图),本 commit 内无消费者。
1 parent 09bc6b6 commit 797b4f5

4 files changed

Lines changed: 416 additions & 22 deletions

File tree

src/build/prepare.cppm

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ import mcpp.build.backend; // BuildOptions for the tool sub-build
4646
import mcpp.build.ninja; // make_ninja_backend — driving that sub-build
4747
import mcpp.lockfile;
4848
import mcpp.config;
49-
import mcpp.xlings;
50-
import mcpp.xlings.subos_info;
51-
import mcpp.xlings.runtime_selection;
49+
import mcpp.platform.xlings;
50+
import mcpp.platform.xlings.subos_info;
51+
import mcpp.platform.xlings.runtime_selection;
5252
import mcpp.platform.runtime_binding;
53+
import mcpp.platform.runtime_search;
5354
import mcpp.toolchain.post_install;
5455
import mcpp.platform;
5556
import mcpp.fetcher;
@@ -1293,6 +1294,12 @@ prepare_build(bool print_fingerprint,
12931294
runtimeSelection, {}, **cfgRuntime);
12941295
if (!resolved) return std::unexpected(resolved.error());
12951296
runtimeBindingSnapshot = std::move(*resolved);
1297+
// A degradation that nobody prints is indistinguishable from no
1298+
// degradation, which is the failure this whole area keeps paying for.
1299+
// A note is not a warning: nothing is wrong with the build, some facts
1300+
// are simply unavailable — so it is reported once, at info level.
1301+
if (!runtimeBindingSnapshot.note.empty())
1302+
mcpp::ui::info("Runtime", runtimeBindingSnapshot.note);
12961303
}
12971304
const auto runtimePayload = runtimeBindingSnapshot.libc.value_or("");
12981305
const auto runtimeLibDir = runtimeBindingSnapshot.libraryDirs.empty()
@@ -6394,12 +6401,29 @@ prepare_build(bool print_fingerprint,
63946401
const bool macho = triple.find("darwin") != std::string::npos
63956402
|| triple.find("apple") != std::string::npos;
63966403
std::string format = pe ? "pe" : macho ? "macho" : "elf";
6404+
// The ORDERED run-time search closure with provenance. Order is
6405+
// semantics here, not presentation: it is what the loader will walk,
6406+
// and the mutable SubOS farm sitting last is the invariant that keeps
6407+
// libc resolving from the pinned payload. Recorded so "why does my GL
6408+
// program find its driver" is answerable without readelf, and so a
6409+
// regression in the ordering is visible to CI and to `mcpp why`.
6410+
nlohmann::json closure = nlohmann::json::array();
6411+
for (auto const& dir : ctx.plan.runtimeSearch) {
6412+
closure.push_back({
6413+
{"path", dir.path.generic_string()},
6414+
{"origin", std::string(
6415+
mcpp::platform::search::to_string(dir.origin))},
6416+
{"machine_local",
6417+
mcpp::platform::search::is_machine_local(dir.origin)},
6418+
});
6419+
}
63976420
nlohmann::json search = {
63986421
{"format", format},
63996422
{"link_library", pe ? "libpath" : "library_path"},
64006423
{"transitive_needed", format == "elf" ? "rpath_link" : "none"},
64016424
{"runtime", format == "pe" ? "deploy"
64026425
: format == "macho" ? "loader_rpath" : "runpath"},
6426+
{"closure", closure},
64036427
};
64046428
j["runtime"] = {
64056429
{"library_dirs", dirs},

src/platform/runtime_binding.cppm

Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import std;
1111
import mcpp.config;
1212
import mcpp.libs.json;
1313
import mcpp.platform;
14-
import mcpp.xlings.runtime_selection;
15-
import mcpp.xlings.subos_info;
14+
import mcpp.platform.xlings.runtime_selection;
15+
import mcpp.platform.xlings.subos_info;
1616

1717
export namespace mcpp::platform::runtime {
1818

@@ -28,7 +28,17 @@ struct RuntimeBinding {
2828
std::optional<std::filesystem::path> loader;
2929
std::optional<std::string> libc;
3030
std::optional<std::string> hostLibc;
31+
// IMMUTABLE payload directories (`<store>/xim-x-glibc/2.39/lib64`).
3132
std::vector<std::filesystem::path> libraryDirs;
33+
// The SubOS symlink farm (`<subos>/lib`) — a union view of everything
34+
// installed into this environment, rewritten on every re-resolution.
35+
//
36+
// Deliberately a SECOND field rather than more entries in `libraryDirs`:
37+
// merging them discards the immutability distinction, and that
38+
// distinction is the whole of `mcpp.platform.runtime_search`'s ordering
39+
// rule. A payload directory and a farm directory are not interchangeable
40+
// even when they currently resolve to the same file.
41+
std::vector<std::filesystem::path> searchDirs;
3242
std::vector<mcpp::xlings::subos::EnvDecl> environment;
3343
std::vector<std::string> providerBindings;
3444
std::vector<std::string> capabilities;
@@ -37,6 +47,32 @@ struct RuntimeBinding {
3747
std::string provenance;
3848
std::filesystem::path subosDir;
3949
mcpp::xlings::runtime::RuntimeSelection selection;
50+
51+
// Did the SubOS describe itself (does it carry a `subos_info` block)?
52+
//
53+
// FALSE IS NOT AN ERROR. A SubOS that says nothing leaves some facts
54+
// unknown — rules A/B become inconclusive, declared environment is
55+
// unavailable — and leaves everything else working. Treating absence as a
56+
// failure is what stopped every `mcpp build` and `mcpp test` on Windows
57+
// (openxlings/xlings#543), on a machine where the missing facts describe
58+
// concepts (ELF, PT_INTERP, a private libc) that do not exist there.
59+
//
60+
// A CONTRADICTION still fails: naming a SubOS that is not present cannot
61+
// be satisfied, so it is reported rather than degraded.
62+
bool declared = false;
63+
64+
// Why something degraded. Non-empty ⇒ the caller MUST surface it. Never
65+
// an error: "it did not happen" and "it succeeded" producing identical
66+
// output is the property that made mcpp#352 expensive.
67+
std::string note;
68+
69+
// Does this artifact run under a PRIVATE loader?
70+
//
71+
// The predicate the closure resolver needs: when PT_INTERP points into a
72+
// payload, the HOST loader's built-in default directories are not part of
73+
// the search path, and modelling them is how a binary that cannot start
74+
// was reported as valid.
75+
bool hermetic() const { return loader.has_value(); }
4076
};
4177

4278
namespace detail {
@@ -80,6 +116,14 @@ std::string canonical_contract(const RuntimeBinding& binding) {
80116
append_field(out, binding.hostLibc.value_or(""));
81117
for (auto const& p : binding.libraryDirs)
82118
append_field(out, p.generic_string());
119+
// The farm participates in the hash because it participates in the
120+
// artifact: it lands in DT_RPATH, so a build made against one farm is not
121+
// interchangeable with a build made against another. `declared` is in for
122+
// the same reason — a SubOS that gains self-description changes what the
123+
// build knows, and the fast path must not reuse the older answer.
124+
append_field(out, binding.declared ? "declared" : "undeclared");
125+
for (auto const& p : binding.searchDirs)
126+
append_field(out, p.generic_string());
83127
for (auto const& provider : binding.providerBindings)
84128
append_field(out, provider);
85129
for (auto const& d : binding.environment) {
@@ -213,22 +257,51 @@ resolve_runtime_binding(
213257
selection.subosName, out.subosDir.string()));
214258
}
215259

260+
// CONTRADICTION vs ABSENCE. The check above is a contradiction: the user
261+
// named a SubOS that is not there, and no amount of degrading makes that
262+
// request satisfiable. Everything below is absence — some facts are
263+
// unavailable, the rest of the build is unaffected — so it degrades.
264+
//
265+
// The distinction is not academic. Collapsing it is what made every
266+
// `mcpp build` and `mcpp test` on Windows fail with a message about GL
267+
// drivers (openxlings/xlings#543), and it is the same shape as the index
268+
// floor incident: DATA THAT IS MISSING OR NEWER MUST NOT INVALIDATE THE
269+
// PROGRAM THAT READS IT.
270+
auto note = [&](std::string message) {
271+
if (!out.note.empty()) out.note += "\n";
272+
out.note += std::move(message);
273+
};
274+
216275
auto info = mcpp::xlings::subos::read(out.subosDir);
276+
out.declared = info.present;
217277
if (!info.present) {
218-
return std::unexpected(std::format(
219-
"selected SubOS '{}' cannot provide a RuntimeBinding: {}",
220-
selection.subosName, info.note));
221-
}
222-
if (info.schema != mcpp::xlings::subos::kSupportedSchema) {
223-
return std::unexpected(std::format(
224-
"selected SubOS '{}' uses runtime contract schema {}, but this "
225-
"mcpp requires schema {}; update xlings/mcpp before building",
278+
note(std::format(
279+
"SubOS '{}' does not describe itself: {}\n"
280+
" Runtime facts (identity, loader, declared environment) are "
281+
"unavailable: runtime rules report `inconclusive` rather than a "
282+
"verdict, and a program launched from here gets no environment this "
283+
"SubOS declares.\n"
284+
" Where the C runtime comes from a payload, there is now no "
285+
"declared runtime to bind to — mcpp declines to guess a version, so "
286+
"the link falls back to the host and the hermeticity check will say "
287+
"so. `xlings self update` writes the block.",
288+
selection.subosName,
289+
info.note.empty() ? "no `subos_info` block" : info.note));
290+
} else if (info.schema > mcpp::xlings::subos::kSupportedSchema) {
291+
// Mirrors `subos_info::read`, which already reads a HIGHER schema and
292+
// says so. A consumer stricter than its own reader is a time bomb:
293+
// the day xlings writes schema 2, an equality check stops every build
294+
// on every platform.
295+
note(std::format(
296+
"SubOS '{}' declares runtime contract schema {}, newer than the {} "
297+
"this mcpp understands; using the fields it knows",
226298
selection.subosName, info.schema,
227299
mcpp::xlings::subos::kSupportedSchema));
228300
}
229-
if (info.runtime.empty()) {
230-
return std::unexpected(std::format(
231-
"selected SubOS '{}' has no runtime identity in subos_info.runtime",
301+
if (info.present && info.runtime.empty()) {
302+
note(std::format(
303+
"SubOS '{}' has no runtime identity in subos_info.runtime; runtime "
304+
"rules cannot be evaluated for artifacts built here",
232305
selection.subosName));
233306
}
234307

@@ -247,11 +320,27 @@ resolve_runtime_binding(
247320
out.libc = info.runtime;
248321
if (!info.hostGlibc.empty()) out.hostLibc = info.hostGlibc;
249322

250-
// Resolve the selected SubOS VIEW to its immutable payload. The view
251-
// already embodies RuntimeSelection, so following these exact links is
252-
// not payload discovery and cannot choose another installed version.
323+
// ONE traversal, TWO answers.
324+
//
325+
// searchDirs the view directory itself — the farm, where every
326+
// library this environment installed is reachable by
327+
// SONAME (`-lGL` already resolves here, because
328+
// `--sysroot=<subos>` makes it the linker's default).
329+
// libraryDirs the immutable payload the view's libc RESOLVES to.
330+
//
331+
// Deriving both here rather than in two places is the point: the
332+
// layout knowledge (`lib64` before `lib`) exists exactly once.
333+
//
334+
// The view already embodies RuntimeSelection, so following these exact
335+
// links is not payload discovery and cannot choose another installed
336+
// version.
253337
std::vector<std::filesystem::path> candidates{
254338
out.subosDir / "lib64", out.subosDir / "lib"};
339+
for (auto const& candidate : candidates) {
340+
std::error_code fec;
341+
if (std::filesystem::is_directory(candidate, fec))
342+
out.searchDirs.push_back(candidate.lexically_normal());
343+
}
255344
for (auto const& candidate : candidates) {
256345
std::error_code lec;
257346
auto libc = candidate / "libc.so.6";
@@ -322,6 +411,11 @@ std::string serialize_runtime_binding(const RuntimeBinding& binding) {
322411
j["library_dirs"] = nlohmann::json::array();
323412
for (auto const& path : binding.libraryDirs)
324413
j["library_dirs"].push_back(path.generic_string());
414+
j["declared"] = binding.declared;
415+
j["note"] = binding.note;
416+
j["search_dirs"] = nlohmann::json::array();
417+
for (auto const& path : binding.searchDirs)
418+
j["search_dirs"].push_back(path.generic_string());
325419
j["environment"] = nlohmann::json::array();
326420
for (auto const& decl : binding.environment)
327421
j["environment"].push_back({
@@ -391,6 +485,11 @@ deserialize_runtime_binding(std::string_view encoded) {
391485
if (auto it = j.find("library_dirs"); it != j.end() && it->is_array())
392486
for (auto const& v : *it) if (v.is_string())
393487
out.libraryDirs.emplace_back(v.get<std::string>());
488+
out.declared = j.value("declared", false);
489+
out.note = j.value("note", "");
490+
if (auto it = j.find("search_dirs"); it != j.end() && it->is_array())
491+
for (auto const& v : *it) if (v.is_string())
492+
out.searchDirs.emplace_back(v.get<std::string>());
394493
if (auto it = j.find("environment"); it != j.end() && it->is_array()) {
395494
for (auto const& v : *it) {
396495
if (!v.is_object()) continue;
@@ -456,8 +555,15 @@ deserialize_runtime_binding(std::string_view encoded) {
456555
: mcpp::xlings::runtime::RuntimeSelection::Source::DefaultPolicy;
457556
out.selection.subosName = s.value("name", "default");
458557
out.selection.ownerRoot = s.value("owner_root", "");
459-
if (out.schema == 0 || out.runtimeId.empty()
460-
|| out.contractHash.empty() || out.subosDir.empty())
558+
// Completeness is conditional on `declared`. An UNDECLARED binding
559+
// legitimately has schema 0 and no runtime identity — that is what
560+
// "the SubOS said nothing" looks like — so demanding those fields
561+
// would make every cached degraded binding undecodable and send the
562+
// build back down the slow path forever. The hash still has to match,
563+
// which is what actually proves the record was not tampered with.
564+
if (out.contractHash.empty() || out.subosDir.empty())
565+
return std::unexpected("cached RuntimeBinding is incomplete");
566+
if (out.declared && (out.schema == 0 || out.runtimeId.empty()))
461567
return std::unexpected("cached RuntimeBinding is incomplete");
462568
if (detail::hash_contract(detail::canonical_contract(out))
463569
!= out.contractHash)

0 commit comments

Comments
 (0)