From 7fccd957cab2d3942043036fdd9afbcacb721075 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 22 Jun 2026 03:31:28 +0800 Subject: [PATCH] feat(doctor): detect missing toolchain runtime deps / dangling subos symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "toolchain runtime deps" section to `mcpp self doctor` that catches the failure class where a provider xim package gets removed, leaving an installed toolchain's RUNPATH (and subos/default/lib symlinks) pointing at files that no longer exist. Concretely: deleting `xim-x-zlib` left clang++'s baked RUNPATH pointing at the gone `xim-x-zlib//lib` dir, so the package compiled fine but the produced binary died at runtime with "libz.so.1: cannot open shared object" (exit 127). Nothing surfaced the broken state until a build mysteriously failed. The new section (Linux/ELF only, guarded by `#if !defined(__APPLE__) && !defined(_WIN32)`): 1. Enumerates installed xim toolchains the same way `mcpp toolchain list` does (iterate /data/xpkgs/xim-x-/, resolve the frontend via toolchain_frontend()). 2. Reads each compiler's RUNPATH/RPATH via `readelf -d` (reusing mcpp::platform::process::capture — no new process code, no new deps) and warns for every absolute RUNPATH dir that is now missing, naming the toolchain and hinting the providing xim package may have been removed. 3. Scans /subos/default/lib for dangling symlinks (std::filesystem::exists follows links -> false when the target is gone) and warns with the symlink + its broken target. Exit-code semantics are unchanged (warnings only). RUNPATH parsing is factored into an exported, process-free `parse_readelf_runpath()` helper with self-contained gtest coverage (tests/unit/test_doctor_runpath.cpp), including the zlib-removal RUNPATH shape, legacy DT_RPATH, no-runpath, and empty-token cases. --- src/doctor.cppm | 130 +++++++++++++++++++++++++++++ tests/unit/test_doctor_runpath.cpp | 53 ++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/unit/test_doctor_runpath.cpp diff --git a/src/doctor.cppm b/src/doctor.cppm index d48978c..ed6162f 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -16,13 +16,51 @@ import mcpp.build.plan; import mcpp.config; import mcpp.fallback.install_integrity; import mcpp.fetcher.progress; +import mcpp.platform.process; import mcpp.toolchain.detect; +import mcpp.toolchain.registry; import mcpp.toolchain.stdmod; import mcpp.ui; import mcpp.xlings; namespace mcpp::doctor { +// Parse the RUNPATH/RPATH search dirs out of a `readelf -d ` dump. +// readelf prints (one per DT_RUNPATH / DT_RPATH dynamic entry): +// 0x...001d (RUNPATH) Library runpath: [/a/lib:/b/lib:...] +// 0x...000f (RPATH) Library rpath: [/a/lib:/b/lib:...] +// We pull the text inside the [...] and split on ':'. Exported so it can be +// unit-tested without spawning a process. Empty entries are dropped. +export std::vector parse_readelf_runpath(std::string_view dump) { + std::vector out; + std::size_t pos = 0; + while (pos < dump.size()) { + auto nl = dump.find('\n', pos); + std::string_view line = dump.substr(pos, nl == std::string_view::npos + ? std::string_view::npos : nl - pos); + pos = (nl == std::string_view::npos) ? dump.size() : nl + 1; + + if (line.find("(RUNPATH)") == std::string_view::npos + && line.find("(RPATH)") == std::string_view::npos) + continue; + auto lb = line.find('['); + auto rb = line.find(']', lb == std::string_view::npos ? 0 : lb); + if (lb == std::string_view::npos || rb == std::string_view::npos || rb <= lb + 1) + continue; + std::string_view body = line.substr(lb + 1, rb - lb - 1); + std::size_t s = 0; + while (s <= body.size()) { + auto c = body.find(':', s); + std::string_view tok = body.substr(s, c == std::string_view::npos + ? std::string_view::npos : c - s); + if (!tok.empty()) out.emplace_back(tok); + if (c == std::string_view::npos) break; + s = c + 1; + } + } + return out; +} + // `mcpp self env`. export int env_report() { auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); @@ -144,6 +182,98 @@ export int doctor_report() { } } +#if !defined(__APPLE__) && !defined(_WIN32) + // ─── Toolchain runtime dependencies (Linux/ELF only) ──────────────── + // + // Installed xim toolchains bake absolute RUNPATH entries into their + // compiler binaries (e.g. clang++ points at xim-x-zlib/.../lib for + // libz.so.1). If the providing xim package is later removed, the + // RUNPATH dir vanishes and `` dies at runtime with + // "libz.so.1: cannot open shared object" (exit 127) — the package + // builds fine but the produced binary can't run. We detect the broken + // state here before a build mysteriously fails. + // + // Two symptoms, both stemming from a deleted provider package: + // 1. a compiler RUNPATH entry pointing at a now-missing dir, and + // 2. dangling symlinks under /subos/default/lib + // (std::filesystem::exists follows symlinks → false for dangling). + mcpp::ui::status("Checking", "toolchain runtime deps"); + if (cfg) { + auto pkgsDir = (*cfg).xlingsHome() / "data" / "xpkgs"; + std::error_code ec; + bool sawAny = false; + bool anyMissing = false; + + if (std::filesystem::exists(pkgsDir, ec)) { + // Mirror `mcpp toolchain list`: each xim-x-//bin + // holds one installed toolchain frontend (clang++/g++/musl-gcc-…). + for (auto& entry : std::filesystem::directory_iterator(pkgsDir, ec)) { + auto name = entry.path().filename().string(); + if (name.rfind("xim-x-", 0) != 0) continue; // toolchains only + std::string compiler = name.substr(std::string("xim-x-").size()); + + for (auto& vEntry : std::filesystem::directory_iterator(entry.path(), ec)) { + auto bin = mcpp::toolchain::toolchain_frontend( + vEntry.path() / "bin", compiler); + if (bin.empty()) continue; // not a compiler pkg + sawAny = true; + + auto label = mcpp::toolchain::display_label( + compiler, vEntry.path().filename().string()); + + // readelf is part of binutils, always present in our sandbox. + auto cmd = std::format("readelf -d \"{}\"", bin.string()); + auto r = mcpp::platform::process::capture(cmd); + if (r.exit_code != 0) { + warn(std::format( + "{}: could not read RUNPATH from '{}' (readelf exit {})", + label, bin.string(), r.exit_code)); + continue; + } + for (auto& dir : parse_readelf_runpath(r.output)) { + // Only absolute paths name on-disk dirs we can verify; + // $ORIGIN-relative entries are resolved by the loader. + if (dir.empty() || dir.front() != '/') continue; + if (!std::filesystem::exists(dir, ec)) { + anyMissing = true; + warn(std::format( + "{}: RUNPATH dir missing: {} " + "(its providing xim package may have been removed — " + "reinstall the toolchain to repair)", + label, dir)); + } + } + } + } + } + if (sawAny && !anyMissing) + ok("all installed toolchain RUNPATH dirs present"); + else if (!sawAny) + ok("no installed toolchains to check"); + + // Dangling symlinks under registry/subos/default/lib — these point + // into xim payload lib dirs; a removed package leaves them broken. + auto subosLib = (*cfg).xlingsHome() / "subos" / "default" / "lib"; + if (std::filesystem::exists(subosLib, ec)) { + bool anyDangling = false; + for (auto& e : std::filesystem::directory_iterator(subosLib, ec)) { + if (!e.is_symlink(ec)) continue; + // exists() follows the link → false when the target is gone. + if (!std::filesystem::exists(e.path(), ec)) { + anyDangling = true; + auto target = std::filesystem::read_symlink(e.path(), ec); + warn(std::format( + "dangling subos symlink: {} -> {} " + "(target's xim package may have been removed)", + e.path().filename().string(), target.string())); + } + } + if (!anyDangling) + ok(std::format("subos lib symlinks all resolve ({})", subosLib.string())); + } + } +#endif + std::println(""); if (errors) std::println("Doctor result: {} errors, {} warnings", errors, warns); else if (warns) std::println("Doctor result: {} warnings", warns); diff --git a/tests/unit/test_doctor_runpath.cpp b/tests/unit/test_doctor_runpath.cpp new file mode 100644 index 0000000..54b1ada --- /dev/null +++ b/tests/unit/test_doctor_runpath.cpp @@ -0,0 +1,53 @@ +#include + +import std; +import mcpp.doctor; + +using mcpp::doctor::parse_readelf_runpath; + +// `readelf -d` line shape (the case that motivated the check): clang++ with a +// RUNPATH that includes a now-removed xim-x-zlib lib dir. +TEST(DoctorRunpath, ParsesRunpathColonSeparatedDirs) { + std::string dump = + " 0x0000000000000001 (NEEDED) Shared library: [libz.so.1]\n" + " 0x000000000000001d (RUNPATH) Library runpath: " + "[/home/u/.mcpp/data/xpkgs/xim-x-llvm/20.1.7/lib:" + "/home/u/.mcpp/data/xpkgs/xim-x-zlib/1.3.1/lib:" + "/home/u/.mcpp/registry/subos/default/lib]\n" + " 0x000000000000000c (INIT) 0x1000\n"; + + auto dirs = parse_readelf_runpath(dump); + ASSERT_EQ(dirs.size(), 3u); + EXPECT_EQ(dirs[0], "/home/u/.mcpp/data/xpkgs/xim-x-llvm/20.1.7/lib"); + EXPECT_EQ(dirs[1], "/home/u/.mcpp/data/xpkgs/xim-x-zlib/1.3.1/lib"); + EXPECT_EQ(dirs[2], "/home/u/.mcpp/registry/subos/default/lib"); +} + +// DT_RPATH (legacy) is parsed the same way as DT_RUNPATH. +TEST(DoctorRunpath, ParsesLegacyRpath) { + std::string dump = + " 0x000000000000000f (RPATH) Library rpath: [/opt/a/lib:/opt/b/lib]\n"; + auto dirs = parse_readelf_runpath(dump); + ASSERT_EQ(dirs.size(), 2u); + EXPECT_EQ(dirs[0], "/opt/a/lib"); + EXPECT_EQ(dirs[1], "/opt/b/lib"); +} + +// A binary with no RUNPATH/RPATH entry yields no dirs. +TEST(DoctorRunpath, NoRunpathYieldsEmpty) { + std::string dump = + " 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]\n" + " 0x000000000000000c (INIT) 0x1000\n"; + EXPECT_TRUE(parse_readelf_runpath(dump).empty()); +} + +// Empty path tokens (e.g. a trailing ':') are dropped, not reported as a +// missing dir. +TEST(DoctorRunpath, DropsEmptyTokens) { + std::string dump = + " 0x000000000000001d (RUNPATH) Library runpath: [/a/lib::/b/lib:]\n"; + auto dirs = parse_readelf_runpath(dump); + ASSERT_EQ(dirs.size(), 2u); + EXPECT_EQ(dirs[0], "/a/lib"); + EXPECT_EQ(dirs[1], "/b/lib"); +}