Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions mcpp.lock
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ hash = "fnv1a:3465dd0bd5d7aa20"

[package."mcpplibs.xpkg"]
namespace = "mcpplibs"
version = "0.0.56"
source = "index+mcpplibs@0.0.56"
hash = "fnv1a:1f00ff8945ea9597"
version = "0.0.57"
source = "index+mcpplibs@0.0.57"
hash = "fnv1a:91399fb9d7d59ebc"

[package."mcpplibs.capi.lua"]
namespace = "mcpplibs.capi"
Expand Down
4 changes: 2 additions & 2 deletions mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "xlings"
version = "2026.8.10.3"
version = "2026.8.10.4"
description = "Universal package management infrastructure tool with SubOS isolation"
license = "Apache-2.0"
repo = "https://github.com/openxlings/xlings"
Expand Down Expand Up @@ -39,7 +39,7 @@ libarchive = "3.8.7"

[dependencies.mcpplibs]
cmdline = "0.0.2"
xpkg = "0.0.56"
xpkg = "0.0.57"
tinyhttps = "0.2.9"
capi.lua = "0.0.3"

Expand Down
127 changes: 127 additions & 0 deletions src/core/closure_check.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ import xlings.core.elf_same_source;
// than the host's glibc recorded at subos creation. Host objects
// can always enter a form-X process (dlopen, host-link), and an
// older libc cannot serve their symbol versions.
// rule E a form-X EXECUTABLE's search path must be in DT_RPATH, not
// DT_RUNPATH. DT_RUNPATH is consulted only for the object carrying
// it, so an executable that dlopens two or three levels deep -- any
// GL program does -- cannot reach the bottom however correct the
// path is. Libraries are deliberately out of scope: the transitive
// tag on a LIBRARY is measured harmful (xim-pkgindex#593).
//
// Rule B (loader and libc from one payload) is elfcheck's, already enforced
// hard earlier in the same install loop. This module deliberately does not
Expand Down Expand Up @@ -64,11 +70,98 @@ struct VersionFloor {
std::string interpPayload;
};

// A form-X executable whose search path is in the non-transitive tag.
//
// rule E. DT_RUNPATH is consulted only for the object that carries it;
// DT_RPATH is consulted for every dlopen anywhere in the process. An
// executable that dlopens something three levels deep -- which is what any GL
// program does, since glvnd dlopens a vendor, the vendor dlopens its platform
// modules, and those dlopen their own dependencies -- cannot reach the bottom
// through DT_RUNPATH however correct the path itself is.
//
// This rule exists for the same reason as rule D, one layer up. Rule D's
// comment says it: a predicate that is right but "runs in exactly one place --
// a repository workflow the user's machine never sees". The TAG was in a worse
// place than that -- it lived in each recipe author's head. Measured on a real
// home: 1 of 73 installed executables carried DT_RPATH, 68 carried DT_RUNPATH,
// and 55 of those 68 already carried the correct PATH. The one that was right
// was right because that package's author found the problem independently and
// fixed it locally; nothing carried the finding to the other 68.
struct NonTransitiveTag {
std::string elf;
std::string rpath; // the value, which is usually correct
};

// Which of the two search-path tags an ELF carries.
//
// Parsed here rather than shelled out because there is no tool to shell out
// TO: `patchelf --print-rpath` prints the value of whichever tag exists and
// does not say which, and `readelf` is not something a payload is guaranteed
// to have. It is also the right shape for a check that runs over every ELF of
// every install -- no process per file.
enum class PathTag { None, Rpath, Runpath };

inline PathTag path_tag_of(const fs::path& file) {
std::ifstream f(file, std::ios::binary);
if (!f) return PathTag::None;
std::string buf((std::istreambuf_iterator<char>(f)), {});
if (buf.size() < 64) return PathTag::None;
const auto* p = reinterpret_cast<const unsigned char*>(buf.data());
if (!(p[0] == 0x7f && p[1] == 'E' && p[2] == 'L' && p[3] == 'F'))
return PathTag::None;

const bool is64 = p[4] == 2;
// Little-endian only. Every target this check runs on is LE, and a wrong
// guess here would misreport rather than fail loudly, so it declines
// instead.
if (p[5] != 1) return PathTag::None;

auto rd = [&](std::size_t off, int width) -> std::uint64_t {
if (off + static_cast<std::size_t>(width) > buf.size()) return 0;
std::uint64_t v = 0;
for (int i = width - 1; i >= 0; --i) v = (v << 8) | p[off + static_cast<std::size_t>(i)];
return v;
};

const std::uint64_t phoff = is64 ? rd(32, 8) : rd(28, 4);
const std::uint64_t phentsize = is64 ? rd(54, 2) : rd(42, 2);
const std::uint64_t phnum = is64 ? rd(56, 2) : rd(44, 2);
if (phoff == 0 || phentsize == 0) return PathTag::None;

constexpr std::uint64_t PT_DYNAMIC = 2;
constexpr std::uint64_t DT_NULL = 0, DT_RPATH = 15, DT_RUNPATH = 29;

for (std::uint64_t i = 0; i < phnum; ++i) {
const std::uint64_t ph = phoff + i * phentsize;
if (rd(ph, 4) != PT_DYNAMIC) continue;
const std::uint64_t dynoff = is64 ? rd(ph + 8, 8) : rd(ph + 4, 4);
const int w = is64 ? 8 : 4;
// The WHOLE dynamic section, not the first hit. When both tags are
// present the loader IGNORES DT_RPATH and uses DT_RUNPATH, so an ELF
// carrying both behaves as Runpath -- and returning whichever
// happened to come first in the table would report the opposite on
// half of them.
bool sawRpath = false;
for (std::uint64_t d = dynoff; d + 2 * static_cast<std::uint64_t>(w) <= buf.size();
d += 2 * static_cast<std::uint64_t>(w)) {
const std::uint64_t tag = rd(d, w);
if (tag == DT_NULL) break;
if (tag == DT_RUNPATH) return PathTag::Runpath;
if (tag == DT_RPATH) sawRpath = true;
}
return sawRpath ? PathTag::Rpath : PathTag::None;
}
return PathTag::None;
}

struct Report {
int scannedElves = 0;
int formXElves = 0;
std::vector<MissingSoname> missing; // one entry per (first elf, soname)
std::optional<VersionFloor> floor; // one offender is enough to say it
// rule E. One offender names the payload: every executable in a payload is
// stamped by the same pass, so listing all of them would repeat one fact.
std::optional<NonTransitiveTag> nonTransitive;
};

// ── pure core ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -210,6 +303,21 @@ inline Report scan_payload(const fs::path& payloadDir,
}
}

// rule E -- the same set rule A uses: this ELF has PT_INTERP (it is
// an executable, not a library) and that interpreter is a payload
// (form X). Libraries are deliberately out of scope: forcing the
// transitive tag on a LIBRARY is measured harmful, because
// transitivity runs downward too and the library's path then enters
// every lookup beneath it (xim-pkgindex#593).
if (!rep.nonTransitive
&& path_tag_of(path) == PathTag::Runpath) {
const auto rp = run_lines(std::format("--print-rpath \"{}\"", path));
rep.nonTransitive = NonTransitiveTag{
.elf = path,
.rpath = rp.empty() ? std::string{} : rp.back(),
};
}

// rule D
if (!store) store = store_sonames(xpkgsRoot, payloadDir);
const auto needed = run_lines(std::format("--print-needed \"{}\"", path));
Expand Down Expand Up @@ -237,6 +345,25 @@ inline std::string describe_missing(const MissingSoname& m) {
fs::path(m.elf).filename().string(), m.soname);
}

// Names the file, the tag, and the consequence -- because the consequence is
// the part nobody would guess. The path is usually right, so a message that
// only said "wrong tag" would read as pedantry rather than as the reason GL
// renders in software.
inline std::string describe_non_transitive(const NonTransitiveTag& t) {
return std::format(
"non-transitive search path: {} carries DT_RUNPATH, not DT_RPATH.\n"
" Its path ({}) is consulted for this binary's own libraries and for "
"NOTHING it dlopens. A GL program reaches its driver through three "
"levels of dlopen -- glvnd opens a vendor, the vendor opens its "
"platform modules, those open their own dependencies -- so with this "
"tag it silently renders in software however correct the path is. "
"elfpatch stamps DT_RPATH on executables since libxpkg 0.0.57; a "
"payload installed before that keeps the old tag until it is "
"reinstalled. (rule E, warn-only)",
fs::path(t.elf).filename().string(),
t.rpath.empty() ? "empty" : t.rpath);
}

inline std::string describe_floor(const VersionFloor& v) {
return std::format(
"version floor: {} runs on glibc {} while this subos recorded the "
Expand Down
2 changes: 1 addition & 1 deletion src/core/config.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import xlings.core.xvm.db;
namespace xlings {

export struct Info {
static constexpr std::string_view VERSION = "2026.8.10.3";
static constexpr std::string_view VERSION = "2026.8.10.4";
static constexpr std::string_view REPO = "https://github.com/openxlings/xlings";
};

Expand Down
58 changes: 58 additions & 0 deletions src/core/subos.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,45 @@ int use_global(const std::string& name, EventStream& stream) {
// was created with image/tmpfs storage so the user understands the
// attribute is dormant in this entry. Writes to stderr (not stdout)
// so the --shell <kind> path stays eval-safe.
// `--sandbox` without `--gpu` on a machine that has one, for a subos that does
// graphics: say so, once, before entering.
//
// bwrap's `--dev` builds a fresh /dev from a hard-coded whitelist that does not
// include /dev/nvidia*, /dev/dri or /dev/dxg, so a sandbox without `--gpu` is a
// software-rendering environment by construction. That default is CORRECT --
// device passthrough should be a decision someone takes, not something a tool
// does quietly -- and `--gpu` genuinely restores it: measured, GLX and Vulkan
// come back byte-identical to the unsandboxed subos.
//
// So the gap is not capability, it is silence. Without the flag the user gets
// "runs, draws a window, exits 0", indistinguishable from the GPU case except
// in frame rate. That is this stack's whole failure mode: succeeding at the
// wrong thing without saying so.
//
// THREE conditions, and the narrowness is the point. `warn_storage_dormant_on_
// shell_` a few lines below is a hint that fired on every entry of its kind,
// became noise, and is now commented out -- a hint that cannot be acted on is
// worse than none. This one can only fire where acting on it changes the
// outcome:
//
// * the subos has a GL dispatch -- a subos that does no graphics is not
// missing anything (read, not probed: same state file `subos info` reads)
// * the host actually has GPU device nodes -- on a machine with no GPU,
// `--gpu` would expose nothing and the advice would be false
// * `--gpu` was not passed -- the user who asked does not need telling
void warn_sandbox_without_gpu_(const std::string& name, bool gpu,
EventStream& stream) {
if (gpu) return;
auto w = xlings::subos::graphics::read_graphics_wiring(Config::subos_dir(name));
if (!w.has_dispatch()) return;
if (!xlings::subos::gpu::host_has_gpu_devices()) return;
stream.emit(DataEvent{"tip", nlohmann::json{
{"message",
"this subos has a GL stack but the sandbox exposes no GPU device; "
"GL will render in software. Add --gpu to pass the host's GPU through."}
}.dump()});
}

void warn_storage_dormant_on_shell_(const std::string& name) {
auto& p = Config::paths();
auto storage = sandbox::read_storage_mode_(p.homeDir / "subos" / name);
Expand Down Expand Up @@ -1433,6 +1472,7 @@ int use_spawn_shell(const std::string& name, EventStream& stream,
// both sides of the boundary.
if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc;
use_detail_::apply_subos_env_(name);
warn_sandbox_without_gpu_(name, gpu, stream);
return sandbox::enter(name, stream, sandbox_backend, gpu, cmd);
}
warn_storage_dormant_on_shell_(name);
Expand Down Expand Up @@ -1676,6 +1716,24 @@ nlohmann::json graphics_fields_(const fs::path& subosDir) {
break;
}

// The one part of this stack we do not own is the host's NVIDIA driver,
// and it moves: a distribution update replaces it, the versioned SONAMEs
// our payload links to change, and the wiring below describes a driver
// that is no longer there. The detector already existed and worked
// (`xlings-gl-doctor`); what it lacked was a way to reach the user without
// being remembered. Reported before the per-vendor rows because when it
// fires, every row under it is about the old driver.
if (auto d = gfx::read_driver_stamp(w.dispatchDir / "lib" /
std::string(gfx::kVendorSubdir));
d.drifted()) {
row("host driver",
"CHANGED — this stack was wired for " + d.builtFor +
" and the host is now running " + d.hostNow +
". Re-run 'xlings install graphics'; until then the states below "
"describe a driver that is no longer loaded.",
true);
}

if (w.dispatchMismatch) {
// The record describes libraries nobody in this subos will load.
// Reporting their states as this subos's would be a confident wrong
Expand Down
23 changes: 23 additions & 0 deletions src/core/subos/gpu.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,27 @@ inline std::vector<std::string> passthrough_args() {
});
}

// Does this host have a GPU that `--gpu` would actually expose?
//
// Derived from `passthrough_args` rather than from a second list of device
// paths. A separate list is a second answerer to a question this function
// already answers, and the two would drift the first time a platform's node
// is added to one of them — /dev/dxg was added to the list above precisely
// because a platform whose node is missing reports "no GPU" identically to a
// machine that has none.
//
// The `--ro-bind /sys` triple is unconditional, so the presence of any
// `--dev-bind` is exactly "at least one GPU character device exists".
inline bool host_has_gpu_devices(std::function<bool(const std::string&)> exists_fn) {
auto args = passthrough_args(std::move(exists_fn));
return std::find(args.begin(), args.end(), "--dev-bind") != args.end();
}

inline bool host_has_gpu_devices() {
return host_has_gpu_devices([](const std::string& p) {
std::error_code ec;
return std::filesystem::exists(p, ec);
});
}

} // namespace xlings::subos::gpu
70 changes: 70 additions & 0 deletions src/core/subos/graphics.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,76 @@ GraphicsWiring read_graphics_wiring(const fs::path& subosDir) {
return w;
}

// ─── host driver drift ─────────────────────────────────────────────────────
//
// The one piece of this stack we do not own is the NVIDIA userspace driver: it
// is in lockstep with the host's kernel module (550.144.03 userspace talks to
// 550.144.03 `nvidia.ko` and to nothing else), so the stack links to the
// host's files rather than shipping them. That makes the host driver a version
// that moves under us — a distribution update replaces it, the versioned
// SONAMEs our payload symlinks to change, and the wiring recorded at install
// time describes a driver that is no longer there.
//
// The detector already exists and works: `xlings-gl-doctor`, shipped by the
// nvidia-gl-host-link package, compares the version stamped at install against
// the one the kernel module reports now. What it lacks is a way to reach the
// user — it has to be remembered and run. This reads the same two files it
// reads, so `subos info` can say it without anyone remembering.
//
// Two plain file reads, no subprocess: the local-query-answers-instantly
// contract (2026.8.10.1) applies here as much as to the wiring record, and the
// values are already written down. Re-deriving the driver version by running
// something would make this the second answerer to a question the installer
// answered.
struct DriverStamp {
bool known { false }; // the payload recorded a version at install
std::string builtFor; // <nvidia payload>/.host-driver-version
std::string hostNow; // /sys/module/nvidia/version
bool drifted() const {
// Only a DISAGREEMENT is drift. An unknown on either side is not:
// a machine whose module is not loaded right now (`hostNow` empty)
// has not changed driver, it has no driver running, and reporting
// that as drift would cry wolf on every laptop with the GPU asleep.
return known && !builtFor.empty() && !hostNow.empty()
&& builtFor != hostNow;
}
};

inline std::string read_trimmed_(const fs::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return {};
std::string s;
std::getline(in, s);
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' '))
s.pop_back();
return s;
}

// `vendorDir` is the dispatch's `glx-vendor/`. The NVIDIA entry there is a
// symlink into the nvidia payload, so the payload root — and the stamp beside
// it — is reached the same way everything else here is reached: by following
// the link the loader would follow, not by searching the store.
DriverStamp read_driver_stamp(const fs::path& vendorDir) {
DriverStamp d;
std::error_code ec;
for (auto it = fs::directory_iterator(vendorDir, ec);
!ec && it != fs::directory_iterator(); it.increment(ec)) {
auto name = it->path().filename().string();
if (name.find("nvidia") == std::string::npos) continue;
auto real = fs::weakly_canonical(it->path(), ec);
if (ec) continue;
// <payload>/lib/<soname> -> <payload>
auto payload = real.parent_path().parent_path();
auto stamp = payload / ".host-driver-version";
if (!fs::exists(stamp, ec)) continue;
d.known = true;
d.builtFor = read_trimmed_(stamp);
break;
}
if (d.known) d.hostNow = read_trimmed_("/sys/module/nvidia/version");
return d;
}

// ─── display ───────────────────────────────────────────────────────────────
//
// Presentation, not re-derivation: the SONAME is the record's key, and this
Expand Down
5 changes: 5 additions & 0 deletions src/core/xim/installer.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -3063,6 +3063,11 @@ public:
log::warn("[{}@{}] {}", node.name, node.version,
closurecheck::describe_floor(*rep.floor));
}
if (rep.nonTransitive) {
log::warn("[{}@{}] {}", node.name, node.version,
closurecheck::describe_non_transitive(
*rep.nonTransitive));
}
}

if (catalog_) {
Expand Down
Loading
Loading