0.0.51: the payload answers, and a relocation that asserts its own result - #35
Merged
Conversation
`_find_tool` ordered its candidates subos/bin -> home/bin -> /usr/bin -> PATH, and the payload was not a candidate at all. So patchelf -- the tool that writes INTERP and RPATH onto every payload we ship -- had its identity decided by a mutable, user-selected view, with a silent fallback to whatever `/usr/bin/patchelf` the machine happened to have. That is not a detail. patchelf versions differ in how they grow the dynamic segment and in `--force-rpath` semantics, so which one runs decides what our artifacts look like. It survived because in the default configuration all four candidates resolve to the same file: the answers agree by coincidence until a second home, a second version, or a host install exists. Payload first now, via a new pkginfo.tool_payload_dir. The view and the host stay as fallbacks -- a store that predates this has to keep working -- but using one is reported rather than preferred. tool_payload_dir rather than dep_install_dir because a missing resolver record means different things for the two. For a *dependency of the package being installed* it means the client predates resolved_deps, which is worth warning about. patchelf is a dependency of libxpkg's elfpatch module, not of the package being installed; nothing should have recorded it, so the same warning would fire on every install and name a condition the user cannot act on. R6, .agents/docs/2026-08-06-subos-architecture-proposal.md §1.5. Two tests, and the first was falsified before being kept: breaking the payload lookup makes it fail while the fallback test still passes.
A downloaded prebuilt carries the build machine's absolute paths in its text
files: linker scripts, .pc files, shell wrappers. Recipes have been rewriting
them by hand, and glibc's version got all three parts wrong at once. This is
the shared capability that replaces it, not a fourth copy.
1. ENUMERATE, do not list. glibc named six files. Measured on the payload on
disk: five files hold build paths, four of them ARE on that list and were
processed wrongly, and the fifth was never looked at. A list of what
someone thought of is not a measurement (R7).
2. ANCHOR the path token. glibc matched `([^%s)]+)/<marker>/lib`. `[^%s)]+`
runs leftward through anything that is not whitespace or `)` -- including
variable names and quotes. On the shipped `bin/ldd` it swallowed
`RTLDLIST="` along with the path:
RTLDLIST="/p/lib/ld-linux.so.2 /p/lib64/... /p/libx32/..."
./ld-linux.so.2 .64/ld-linux-x86-64.so.2 .x32/ld-linux-x32.so.2"
The ldd in the 2.39 and 2.44 payloads does not survive `bash -n`. Here the
match walks back from the marker to a character that cannot occur in a
path and requires what is left to be absolute -- deliberately a walk, not
a pattern, because a leftward pattern is greedy by construction.
3. ANCHOR AT THE MARKER, not at `/lib`. glibc's tail anchor meant the same
file's `share/locale` line was never touched, so the build path stayed in
the artifact -- the one thing the code existed to remove. And `/libx32`
matched `/lib` plus a leftover `x32`, which is where `.x32/` came from.
Then the part that made all of the above invisible: it wrote if it had
substituted anything and called that success. "Still has build paths" and "we
corrupted the file" produced the same output as a clean run. So the result is
now ASSERTED (R4): no absolute build path may remain anywhere in the payload,
and every rewritten shell script must parse -- checked with the script's OWN
shebang interpreter, because a fixed `sh -n` gives a verdict that depends on
what the host's /bin/sh is.
Binaries are skipped (a text substitution changes their length), symlinks are
never written through, and an occurrence that is already relative is left
relative rather than made absolute.
Two tests. The fixture reproduces the shipped damage byte for byte: running
glibc's current pattern over it produces exactly the line that is in
~/.xlings/data/xpkgs/xim-x-glibc/2.39/bin/ldd today.
Version 0.0.51.
The backward walk had no lower bound. A marker occurring TWICE inside one path token would walk back past the previous match, and the slice that copies the text between them -- `content:sub(pos, tok - 1)` -- would come out empty, silently deleting it. Not reachable through any path in the glibc payload, because `:` and whitespace bound the tokens there, but "not reachable today" is how the pattern this replaces was justified too. Bounded at the first byte not yet emitted. And a missing interpreter read as a broken script. `_exec_ok` cannot tell "syntax error" from "command not found", so a payload holding a zsh script on a machine without zsh would have FAILED THE INSTALL -- a check inventing the defect it exists to find. The interpreter is now probed once per name, and a script that cannot be checked is reported as unchecked rather than counted as broken.
Sunrisepeak
added a commit
to mcpplibs/mcpp-index
that referenced
this pull request
Aug 6, 2026
openxlings/libxpkg#35. Two changes, one rule: when libxpkg needs a tool it resolves the payload rather than a view, and when it transforms a payload it asserts the result rather than the intent. The CN asset is a byte-identical copy of the GitHub tag tarball, verified by downloading both and comparing sha256 (GET, not HEAD -- gitcode HEADs 401).
Sunrisepeak
added a commit
to openxlings/xlings
that referenced
this pull request
Aug 6, 2026
… and the subos layer's one live version (#488) * fix(subos): the sandbox gave the xlings home a second name, and every baked path pointed at the first Entering a sandbox bound the host xlings home at <user_home>/.xlings and left XLINGS_HOME naming the host path. Everything installed carries absolute host paths baked at install time -- xvm alias targets, RPATH, INTERP -- and nothing rewrites them on the way in, so inside the sandbox they all pointed somewhere that did not exist: xlings: alias for 'gcc' references itself but real binary not found path: <host-home>/data/xpkgs/xim-x-gcc/16.1.0/bin When XLINGS_HOME is the default the two spellings are the same string, so the remapping was invisible; an isolated home is the first configuration where they differ, and there the very first command fails. Bind the home at its own absolute path instead, and spell it that way in both XLINGS_HOME and PATH. Binding it at BOTH paths also fails, more quietly: a bind mount does not collapse two paths to one directory, so weakly_canonical still sees two homes and a shim warns about a conflict with itself. The rc templates hardcoded $HOME/.xlings too, in three copies that had drifted; they now read ${XLINGS_HOME:-$HOME/.xlings} from one writer. S8 asserted the old model (~/.xlings shows host content) and is rewritten to the contract: XLINGS_HOME is the host path, PATH[0] agrees with it, and no second spelling exists. Verified with env -i against a home under /tmp -- the hardest bind ordering, since /tmp is made private first. * fix(subos): a package could put our libc on LD_LIBRARY_PATH, and the subos shell died before printing anything `xlings subos use` returned a /bin/bash that exited 139. The declaration behind it was nvidia-gl-host-link's: the NVIDIA vendor library is the host's file and cannot carry an RPATH of ours, so the recipe gathered what it needs into one directory and put that on LD_LIBRARY_PATH. glibc's libraries were in there. LD_LIBRARY_PATH is inherited by every child, and most children in a subos are host binaries under the host loader. ld.so and libc.so.6 are two halves of one build that talk over GLIBC_PRIVATE, so those processes got our half against the host's other half -- the same split the same-source assertion exists to catch, arriving from the one direction it cannot see, because nothing we installed was wrong. The host's glibc here was the same upstream VERSION as ours, merely a different build. Drop such an entry when building the environment, name it and the package that declared it, and keep the rest of the variable. The manifest still records the declaration as written -- the guard belongs where the environment is built, not where it is declared, or the manifest becomes a second source of truth. The recipe is fixed separately, and that fix is a deletion: the libc was never usable for its stated purpose. The vendor is dlopen'd into a running process whose libc is long since bound, and an already-loaded SONAME is never searched for. Measured both ways -- same device, same GL_RENDERER (NVIDIA GeForce RTX 4080/PCIe/SSE2). E2E-63 covers the class rather than that one recipe. * fix(subos): the sandbox borrowed another home's proot, and skipped the subos env layer entirely Three findings from verifying a subos in an isolated XLINGS_HOME, and the first two only became visible once the third was fixed. locate_proot_ ended in a PATH lookup. A `proot` on PATH that lives in an xlings home is one of our shims, and running it re-enters xlings, which anchors to the home owning the shim and re-exports XLINGS_HOME to match. An isolated home with no backend installed silently ran the whole sandbox against the developer's real home -- packages, subos and all -- while looking exactly like a correct run. locate_bwrap_ already refused system binaries for its own reasons; this is the same refusal for the same mechanism. A genuine /usr/bin/proot is still used. `subos use --sandbox` never applied the subos.env layer. The same subos entered two ways had different environments: LIBGL_DRIVERS_PATH and __EGL_VENDOR_LIBRARY_DIRS set on the shell path, absent in the sandbox, so a GL program inside fell back to whatever the host offered and nothing said so. Both paths now go through one applier. And the libc guard from the previous commit was too wide. It listed the whole glibc set; libpthread, librt and libdl have been compatibility stubs since glibc 2.34 (27, 13 and 9 defined symbols, implementations moved into libc.so.6), and nvidia-gl-host-link has to offer all three -- measured one library at a time, without them the NVIDIA device disappears from EGL enumeration entirely. Narrowed to libc.so.6 and the loader, which are the pair that fails by segfaulting before main and naming nothing. A mismatched libm fails loudly and names the file. Verified in an isolated home, both entry paths, identical results: NVIDIA GeForce RTX 4080/PCIe/SSE2 on the device platform, llvmpipe on the software fallback, and a shell that survives. * fix(build): views::split | ranges::to broke the gcc 16 module build gcc 15.1.0-musl -- the release target -- compiled it, so the previous commit built and shipped clean while `mcpp test` on the default gcc@16.1.0 toolchain failed the whole module with "Bad file data" pointing at cli.cppm, a translation unit that had not changed. Replaced with a plain loop. * docs: subos x libc x graphics verification report Five defects, all invisible under the default home because four independent answers to "where is the xlings home" happen to be the same string there. Four fixed, one recorded. * docs: architecture proposal for the three problems under the subos defects Also corrects a wrong claim in the verification report: libm is named by 16 nvidia libraries, not none. I had sampled one entry of the closure. * docs: a mechanism for P2, verified end to end DT_RPATH is transitive along the load chain; DT_RUNPATH is not. A shim we own, placed between the loader and a host file we cannot modify, carries the resolution policy for that file's link-time dependencies -- scoped to the link chain rather than to every process in the subos. Falsified against RUNPATH (same shim, opposite result), then measured on the real stack: RTX 4080 with only the host driver directory on LD_LIBRARY_PATH and nothing of ours on any global search path. 27KB against the 327MB copy alternative, and the host/kernel version coupling is preserved because it is still a symlink to the host's file. The measured boundary -- RPATH transitivity does not cover the vendor's runtime dlopen of its own siblings -- falls exactly on the line the package already draws between the host's half and ours. Supersedes the earlier A+C recommendation, which by this document's own R3 was a workaround: it corrected the table and added a guard without removing an answerer. * docs: the shim fixes the loader half; two more global variables inject our libraries into host processes __EGL_VENDOR_LIBRARY_DIRS and LIBGL_DRIVERS_PATH are read by libglvnd and mesa themselves, not by the loader, so neither DT_RPATH nor the libc guard reaches them. Measured: a host binary linked only against the host's libEGL drops from NVIDIA RTX 4080 to llvmpipe under the subos declarations, and loads our libm, libgcc_s, libstdc++, libxcb and libxshmfence into a process running on the host's libc. Same shape as the libc crash, one beat slower: it survives here only because the host glibc is also 2.39. Solvable, and by this document's own principle -- we build mesa and libglvnd ourselves, so the paths belong baked into the artifact rather than in a variable every process inherits. * docs: rule 2 needs an enforcement point, and it costs almost nothing Nobody decided to take libm/libdrm/libgbm from the host. The recipe's table did not list them, nothing provided them, and the loader fell through to the host's default search -- silently, because finding a library is a success regardless of where it came from. The host is the default answer to any question we fail to answer. Measured across 483 ELFs in a full graphics home: exactly one real leak (wayland NEEDs libxml2, which is not in the store), plus 20 false positives from glibc's own gconv modules. So an install-time closure assertion with an explicit host_deps allowlist is cheap to adopt, and it turns 'depends on the host' from an accident into a declaration. * docs: R6 -- internal consumers bind the payload, not the view The three-layer model never said who consumes which layer. The payload is immutable and unambiguous; the subos sysroot is a mutable, shim-mediated, user-selected view. xlings resolving its own tools through the view hands its correctness to a choice the user can change at any time -- and the two agree by default, which is why it survived. This subsumes the earlier framing of the proot defect ('we borrowed from another home'): that is just one way the view can differ from the payload. The load-bearing violation is libxpkg's _find_tool, which locates patchelf -- the tool that stamps INTERP and RPATH onto every payload we install. Its candidates are subos bin, home bin, /usr/bin, then PATH. The payload path is not among them, and on this host /usr/bin/patchelf exists, so the fallback is silent. * docs: record the architecture decisions from review, including two corrections to my own analysis AD-1 subos is both a build-time and a runtime concept; the rule is a priority, not an ambiguity: go direct to the payload where you can. That resolves the doctor warning -- following the active subos at exec is correct, recording a build-time subos path is the defect. AD-2 refcount is the deletion criterion. The residue worth stating: it covers package-to-package references only, never a user's own compiled binary, so 'no package references it' is not 'safe to delete'. AD-3 XDG_DATA_DIRS is not in the problem domain. My line was wrong: the class that matters is variables that cause CODE to be loaded, not variables that are process-global. AD-4 I was wrong that the glibc loader's baked prefix is a load-bearing accident. For a relocatable package the build prefix can never equal the install path, so the default search necessarily points nowhere -- it is structural. glibc 2.44, shipped this season, carries the same prefix, so it is current convention, not a stale artifact. What remains is a build-machine leak in the artifact, the same class as libxml2's .pc. * docs: glibc's path relocation neither relocates nor leaves the file intact Chasing why ld.so carries a build-machine prefix: glibc ships as a prebuilt tarball and the recipe already has relocation code. It has a hardcoded six-file list, a greedy unanchored pattern, an anchor on /lib, and no verification. The pattern's [^%s)]+ swallowed the variable name: RTLDLIST="/home/... became .64/ld-linux-x86-64.so.2, so the shipped ldd fails bash -n at line 39 in both 2.39 and 2.44. The /lib anchor means TEXTDOMAINDIR still names the build machine, so the one job the code exists to do is also unfinished. Both outcomes write the file and report success. Same shape as #486: a regex rewrite produced a file that means something else, and nothing looked afterwards. * docs: second round of architecture decisions (AD-5..AD-10) AD-5 the loader's default search path stays necessarily-nonexistent, and becomes deliberate. Relative is not an option: the string is used verbatim, so a relative entry resolves against the CWD. AD-6 adopt the interposer with DT_RPATH, record the deprecation risk, and revisit later. Also answers why the host vendor must resolve to our libraries: with our loader's default search dead, the third outcome is 'vendor fails to load' -- measured as DEVICE_COUNT=0. Named interposer, not shim, which already means something else here. AD-7 nixGL's mechanism is not wrong; its scope is per-program while ours was per-session. That makes a wrapper the fallback if DT_RPATH ever goes. AD-8 hardware portability is a boundary condition, not a bug. What can be built is visibility: say which host driver a subos is linked to. AD-9 refcount warnings cover package references only; a user's own binary needs none, since the runtime error is the message. AD-10 D1 is a corollary of R1, not a separate rule. * docs: restructure and self-review the architecture proposal Sections were out of order after incremental edits (1.5 before 1.4, three decision rounds interleaved with the defect writeup). Reordered into 0-9 with one decision section. Substantive fixes found in review: - D1-D4 meant 'defects' in the verification report and 'proposals' here, and section 5 used both meanings in one paragraph. Observability proposals renamed O1-O4; D1-D5 keeps the defect meaning. - Proposal D1 still recommended a review checklist; AD-10 had superseded it with 'a corollary of R1'. Rewritten in place. - Proposal B5 still had its pre-AD-3 wording; rewritten to the code-loaded vs data-found line, with PATH split out as a third category that R6 governs. - R7 was decided in AD-14 but missing from the rule list in 1.3. - locate_proot_ had no proposal number; now A4. - 'shim' was used for two different things; the new object is an interposer throughout, with a glossary note. * fix(subos): three places where a view answered a question the payload owns Four separate changes, one rule: R6 -- when xlings itself needs something, it resolves the payload, never the view (.agents/docs/ 2026-08-06-subos-architecture-proposal.md §1.5). A3, elf_same_source: the same-source assertion located patchelf with `command -v`. Inside a subos session that hits one of our shims, which re-enters xlings and anchors to whichever home owns it; outside one it hits whatever /usr/bin/patchelf the machine has. The check that exists to catch a loader/libc mismatch was reading the fields with a tool that may not be the one that wrote them. It now resolves the payload out of the store that contains the directory being scanned -- derived from that directory rather than from XLINGS_HOME, because a shim rewrites XLINGS_HOME and the directory cannot lie about which store it is in. A4, locate_proot_: the PATH step is gone. An earlier fix rejected PATH candidates that turned out to be xlings shims, which treated the symptom. PATH is a view; letting it decide which proot runs makes the sandbox's own identity depend on the environment it was launched from. The host's proot survives as a named fallback at /usr/bin and /usr/local/bin -- declared, not discovered -- and using it prints a line. B5, the env guard: it only inspected variables the dynamic loader reads. __EGL_VENDOR_LIBRARY_DIRS and LIBGL_DRIVERS_PATH have the same shape and bypass the loader entirely, so neither the guard nor any RPATH mechanism can see them. Measured: a HOST binary linked against the host's libEGL drops from the NVIDIA GPU to llvmpipe under our declarations, with LD_DEBUG showing our libm and libstdc++ loaded into a process running on the host's libc. The new predicate is default-deny by variable NAME: manifest::names_only_data lists the BENIGN ones, and anything unclassified reads as privileged. Enumerating the dangerous set instead would be a hand-written list of what we happened to think of -- the anti-pattern R7 names, and the one that already cost five missing entries in nvidia-gl-host-link's dependency table. Per AD-3 the line is "causes code to be loaded" vs "causes data to be found"; PATH is a third category and belongs to R6/AD-1, not to this guard. C1 and C3, the subos layer's "exactly one": the store holds many versions by design, each consumer freezes one into its own RPATH, and the subos in between is supposed to hold exactly one. Nothing enforced that. Installing a second version simply appended a second provider section and both contributed their variables -- measured as mesa@25.0.7 and mesa@25.0.7.1 both bound in `default`, both on __EGL_VENDOR_LIBRARY_DIRS, EGL enumerating the device twice, and doctor silent. C3 reports it and C1 makes it unreachable, in that order, because existing homes already hold double bindings and a behaviour change nobody can see coming is worse than the state it fixes. Enforcement is in the WRITER (R2): recording a package's declarations unbinds the versions it supersedes. Report and --fix call ONE function, manifest::duplicate_bindings, rather than two pieces of equivalent logic -- every report/repair pair in this repo has drifted at least once, and the shape it takes is a finding that repairing does not clear. --fix keeps the xvm-active version, and when there is no active version it says so instead of guessing: picking the highest would be a convention applied at the read end, which is the rule this change enforces. Counted toward the exit code, because `healed` is before-minus-after over that count and an uncounted finding that --fix repairs reports "healed 0". The other Subos* findings are still uncounted -- pre-existing, task #53. 9 new unit tests; 35/35 test binaries pass. * fix(tools): a slice of a real home read its recipes from the real home slice-real-home.sh repointed `.xlings.json` and the sysroot symlinks, and stopped there. `data/xim-pkgindex/.xlings-index-cache.json` maps every package name to the ABSOLUTE path of its recipe, so it kept naming ~/.xlings -- and a slice therefore installed packages by reading the real home's recipes. Found the way these are always found: a recipe change under test did nothing, twice, with no diagnostic. Editing the slice's own copy of glibc.lua changed the install not at all, and the install looked entirely normal both times. Now the JSON state files directly under the home, `data/`, `data/xim-pkgindex/` and `data/xim-index-repos/` are repointed too -- enumerated per directory rather than by a `data/**` glob, because `data/xpkgs` is tens of gigabytes and its JSON belongs to payloads, which must keep the paths they were installed with. And asserted afterwards, rather than trusted: any file in those directories that still names the source home fails the slice. A new state file nobody added to the list would otherwise keep pointing at the real home, and the only symptom would be a measurement that quietly describes the wrong one. * fix(subos): a package installed twice exported its environment twice The store holds many versions by design, each consumer freezes one into its own RPATH, and the subos in between is live at exactly one. Nothing enforced that middle line: installing a second version appended a second provider section to the subos manifest and BOTH contributed. Measured on a real home as mesa@25.0.7 and mesa@25.0.7.1 both on __EGL_VENDOR_LIBRARY_DIRS, EGL duly enumerating the device twice, and `xlings self doctor` reporting nothing -- two records agreeing on an answer the model forbids. The fix is NOT that a second install unbinds the first. That was the first attempt here, and testing it showed what it really was: `install` adds to the store and `use` selects, so unbinding on install makes install a second selector -- one more answerer to a question that already has one. It also produced a NEW disagreement, with the manifest naming 2.0.0 while xvm still had 1.0.0 active. xvm already decides which version is live, and its answer is recorded in the same file as the declarations: `subos/<name>/.xlings.json` holds `workspace` (name -> active) and `subos_info.envs` (binding -> declarations) side by side. So activation now reads it -- manifest::select_effective -- and only the active version's section contributes. The dormant section stays in the record, because it is what lets `xlings use pkg@<older>` restore an environment without a reinstall. A package with NO active record keeps every provider. That default is the load-bearing one: filtering on a record that turns out to be absent would silently delete a package's whole environment, which is this file's own failure mode arrived at from the other side. Measured before choosing it -- a bare `xvm.add(name)` does record an active version, so this is the salvage path for a manifest whose workspace record was lost, not the common case. doctor therefore reports the SUBSET with no active version, not every duplicate: two versions with one active is ordinary, and reporting it would train users to delete the dormant sections that make `use` work. Nothing can repair a contested binding -- that is the definition of contested -- so the remedy is `xlings use pkg@<one of: ...>`, which makes it a decision someone took rather than a guess this code made. Two claims I had written into the comments were measured false and are corrected in place: `xvm.add(name)` does record an active version, and mesa/libglvnd/nvidia-gl-host-link have no workspace entry in this home only because they are not installed in it. Install keeps a narrow supersede for the no-active-record case, at the one moment a human is naming a version, and says so when it fires. 11 unit tests + E2E-64, which asserts the contract rather than the mechanism: both sections recorded, ONE entry exported, `use` switches it with no reinstall and no manifest rewrite. * docs: the B-line gate is open, and it corrects a boundary I had drawn wrong §2.7's four verifications, run rather than reasoned about (AD-14). All pass, and the answer is better than §2.3 predicted. The correction matters most. §2.3 said "DT_RPATH covers link-time dependencies and does not cover runtime dlopen -- a runtime dlopen has no load chain to attach to, so no RPATH mechanism can serve it." Measured, that is wrong: a `dlopen("libGLX_probe.so.0")` by bare SONAME DOES use the calling object's own DT_RPATH, and its DT_RUNPATH too. The real boundary is a runtime dlopen is served by the RPATH of the object that CALLS it which is servable when that object is ours and not when it is the host's vendor library. That is why last round's measurement showed it failing: the caller was the host's file, which we cannot patch. (The first run of this experiment showed all three cases failing. The `gcc` on PATH is a shim and had resolved to the musl toolchain, whose dlopen diagnostics differ -- R6 demonstrating itself in the middle of verifying R6.) What that unlocks: GLX has no vendor JSON, so "point the JSON at an absolute path" -- the EGL approach -- does not exist for it. It does not need to. With NO LD_LIBRARY_PATH set at all, and the only difference being whether the dispatching library carries a DT_RPATH into our directory: our process -> <ours>/libGLX_nvidia.so.0, __glx_Main reachable host process -> /lib/x86_64-linux-gnu/libGLX_nvidia.so.0 Both rules hold at once, with no process-global variable anywhere. V2 collapses into V1: libGLX_nvidia.so.0 exports __glx_Main AND vk_icdGetInstanceProcAddr AND vk_icdNegotiateLoaderICDInterfaceVersion -- the GLX vendor and the Vulkan ICD are the same file, which is why the ICD JSON names it. One interposer serves both paths. V3: all three entry points are reachable through a 25 KB interposer, matching the real vendor exactly. V4: the patchelf-only production recipe is measured, not assumed -- --set-soname, --add-needed <host vendor>, --set-rpath --force-rpath. No compiler at install time. §2.3 and §2.5's B3 are corrected in place rather than left contradicting §2.7, and B3 now has a count it can state: exactly one real case remains where RPATH does not apply -- the caller is the host's own file. * test(e2e): the isolated home defaults to a path that shares no prefix with HOME Four of the home-related defects in the 2026-08-06 review were completely asymptomatic under `~/.xlings`. The repo checkout is usually under $HOME as well, so a test home at `$ROOT_DIR/tests/e2e/runtime/` shares a long prefix with the real one and every "which home did we actually use?" bug stays invisible in exactly the same way. runtime_home_dir now defaults under $TMPDIR. That is also where the 2026-08-06 measurements happened to run, which put the home BELOW a directory the sandbox privatises before binding -- the hardest ordering in the bind list, hit by accident. E1 is about making that accident deliberate. E2E_RUNTIME_ROOT overrides it for runners with a small /tmp, but the default has to be the awkward path. Also assert_home_is_isolated, used by the two subos env tests: a test that cannot distinguish "we used the home under test" from "we used the developer's" is the whole class this change exists for, so it refuses rather than assumes. Spot-checked against four tests including two that predate it. * fix(subos): four defects the self-review found Read as a reviewer rather than as the author, looking for this repo's recurring shapes. Four survived. 1. locate_patchelf shared one error_code between the store loop and the per-candidate `is_regular_file`. A single unreadable entry would set it, end the outer iteration, and read as "no patchelf payload in this store" -- falling through to the host tool silently, which is precisely the behaviour the function was written to remove. 2. `names_only_data` has PATH on it, and PATH plainly does not name only data. The comment said so; the name did not. Renamed `never_loads_code`, which is what the list actually asserts. 3. The guard matched `${pkgdir}` and `/xpkgs/` and missed `${subosdir}`. The subos sysroot is a VIEW onto our payloads, made of symlinks into them, so a directory under it on a loader search path delivers our libraries just as surely as the store path does -- one hazard under two names, which is the shape this entire review is about. `${xlings_home}` too. 4. doctor's D3/D4 ran over the full provider set, so a dormant section (a second version whose sibling is active) was reported as one that "would export an unexpanded path" -- an export that will not happen. They now run over the effective set. D2 deliberately stays over the full set: "recorded here but not installed here" is true of a dormant section as well. One test added for #3; 35/35 test binaries pass. * chore(release): 2026.8.6.1, on libxpkg 0.0.51 The pin moves ahead of the publish deliberately: CI cannot resolve `mcpplibs.xpkg@0.0.51` until openxlings/libxpkg#35 merges and reaches mcpp-index, and a red dependency-fetch step is a better record of that ordering than a pin I would have to remember to raise later. Everything else in this branch is independent of it and can go green now. * test(tools): verify the release lifecycle against an isolated home, and check it A script rather than a checklist. Four home-related defects on 2026-08-06 were invisible in the default home, and three were found only because the same measurement was taken twice and disagreed -- a checklist executed by hand takes it once. Two things it asserts that an ordinary test does not: * the home under test is not the developer's and shares no prefix with it. A shim rewrites XLINGS_HOME to whichever home owns it, so "we ran in the isolated home" is a claim to check rather than a premise. * the real ~/.xlings is unchanged afterwards -- checked, not intended. The glibc step asserts the three things #42 was about: no build path anywhere in the payload, `bin/ldd` passing `bash -n`, and `RTLDLIST="` still present. * fix(tools): the verification script asked for a subcommand that does not exist, and called a design decision a defect `xlings self info` is not a command; `self config` is. And step 3 grepped the whole payload for the build marker, which flagged every binary in it. That second one matters more than a typo. ld.so has the build prefix compiled in as its DEFAULT LIBRARY SEARCH PATH -- and after AD-11 that is deliberately `/nonexistent/xlings-use-rpath-not-default-search`, a path that cannot exist so that everything must come from DT_RPATH and an unpatched binary fails loudly rather than quietly picking up the host's loader. The marker inside a binary is the design. The same marker inside a shell script or a .pc file is the defect. `grep -I` is the whole difference. * ci: one mcpp cache key had several writers `mcpplibs.xpkg` 0.0.50 → 0.0.51 failed CI as mcpplibs.cmdline: error: import 'std' has CRC mismatch which reads like a compiler bug. It is one cache key with more than one writer, and it is the same shape as everything else this branch fixes. **Read from the files, not inferred:** three Linux workflows compute the identical key `mcpp-v3-${{ runner.os }}-${{ hashFiles('mcpp.toml','mcpp.lock', '.xlings.json') }}-${{ env.BOOTSTRAP_XLINGS_VERSION }}`. The hash covers mcpp.toml -- but mcpp.toml declares BOTH the dev toolchain (gcc@16.1.0) and the release target (gcc@15.1.0-musl), and `xlings-ci-linux` builds dev while `xlings-ci-linux-root`'s build job produces the release tarball. Same key, two toolchains, so whichever job finishes first writes the exact key. **Consistent with, but not proof:** `gh cache list` shows one `mcpp-v3-Linux-665a3219…` entry created 00:47:55, and `xlings-ci-linux-root` is the only Linux job that succeeded in that window (00:48:27). The failing runs' cache steps are not in the re-run logs, so "they took an exact hit on that entry" is the explanation that fits, not something I read. The key-sharing on its own is enough to fix: it is wrong whether or not it caused this particular failure, and the existing guard cannot cover it because that guard only fires on an INEXACT restore -- the other failure mode, the one v3 was minted for. So the key now names the workflow that produced it, spelled out per file rather than derived from `github.workflow` so a rename cannot silently merge two keyspaces again. Retiring v3 also discards whatever is poisoned right now. The prefix history is worth keeping in one place, because it is three retirements for three different causes: v2 for a `restore-keys` fallback reaching a registry snapshot from before an index publish, v3 for entries a failed build left at the exact key, v4 for this. Each discarded what was already broken and none stopped the next kind -- which is the argument for a key that identifies its writer rather than a prefix that gets bumped. * docs: C1 as proposed was wrong, and implementing it is what showed that The proposal said a second install should unbind the first. Writing it and testing it showed what that really is: `install` adds to the store and `use` selects -- that is the semantics settled in 2026.7.31 -- so unbinding on install makes install a SECOND SELECTOR. One more answerer, not one fewer. It also produced a fresh disagreement on the spot: the manifest naming 2.0.0 while xvm still had 1.0.0 active. xvm already answers which version is live, and its answer is in the same file as the declarations. So the fix is not "the writer does one more thing", it is "activation reads the answer that exists" -- which is C2, and C2 alone is enough. Recorded rather than quietly corrected, because the shape of the mistake is the same one the whole document is about, and I made it while writing the document about it. * test(tools): the release check now actually enters a subos It installed packages and read doctor's output, which measures the home but not the thing the home exists for. Four steps added, all through `xlings subos`: * a subos of its own, entered with `--cmd` * XLINGS_HOME inside the session is still the home under test -- a session that re-anchors elsewhere makes every measurement taken inside it a measurement of the wrong home while looking exactly like the right one * PATH[0] is that subos's bin * no package bound at several versions with no active version, checked against the real index and a released binary rather than a fixture -- the same contract E2E-64 states with a fixture * doctor run from INSIDE the session, not beside it * test(e2e): the sandbox test's home moves off the $HOME prefix too E1 names subos and sandbox tests specifically, and this is the one it is most about: S8 exists because binding the home at ~/.xlings instead of its own path stranded every baked absolute path, and that was invisible until an isolated XLINGS_HOME was used. Leaving its own home under the checkout -- which is normally under $HOME -- kept the test in the configuration where the defect cannot appear. Under $TMPDIR it also sits BELOW a directory the sandbox privatises before binding, which is the hardest ordering in the bind list. The 2026-08-06 measurements hit that ordering by accident and found four defects with it; this makes it deliberate. * docs: §9 records what landed, and three cross-references pointed at a section that no longer exists The restructure moved the glibc-relocation material from §6.6/§6.7 to §8 and AD-7, and three references were left behind. A reference to a section that does not exist is the documentation form of the defect this document is about: it reads fine and means nothing. §9 now separates what shipped in 2026.8.6.1 / libxpkg 0.0.51 from what is gated, with the reason each remaining item is where it is. The B line is out of this round because B1/B2 need the graphics stack installed to verify end to end and this home has none of it -- the GATE, by contrast, needed only a synthetic experiment and the host's own vendor library, which is why it could be done now. * test(e2e): S12 asserted the spelling from before S8's own fix CI caught it. S1-S11 pass, including the S8 assertion rewritten for the home-identity fix, and then S12 fails: FAIL: S12: PATH first segment is NOT <home>/.xlings/subos/mybox/bin PATH:/home/runner/work/.../subos_sandbox/home/subos/mybox/bin:... The value it rejected is the correct one. S12 was written when the sandbox remapped the home to `~/.xlings`; the fix made the home visible at its own absolute path and updated S8, and S12 kept demanding `$HOME/.xlings` from a run whose home is somewhere else entirely. This is E2 in a single assertion: a test can not only miss a defect, it can pin one -- and the pin outlives the fix, because changing a test assertion is exactly what looks suspicious in review. The expectation is now derived from $HOME_DIR, which makes it true of any home rather than of the default one. Audited the rest of the file for the same shape: the remaining `$HOME/.xlings` references are S8's second-spelling check and two marker files, all correct, and every other `.xlings/subos/` in the e2e suite is a project-local path derived from its own scenario dir. * ci: a restored BMI set never works, and the guard was hiding it Three key-prefix retirements in this repo, each for a different diagnosis, each appearing to fix `import 'std' has CRC mismatch` for exactly one run. The diagnoses were wrong and the fixes were accidents. The guard wiped the BMIs on an INEXACT restore. Retiring the prefix forces an inexact restore. So every retirement fired the guard, produced one clean build, and looked like a fix -- and the next run, hitting the key exactly, skipped the guard, reused the restored BMIs, and failed. Read straight off two consecutive runs of xlings-ci-linux on this branch, same key, same hash, nothing between them but test and doc files: 387ff00 "Drop stale BMIs ..." success -> build passed ee9300a "Drop stale BMIs ..." skipped -> import 'std' has CRC mismatch The mechanism is simply that a restored BMI set is unusable. So the wipe is unconditional now, and the BMI directories are excluded from the cache as well -- the exclusion is an optimisation (do not store bytes we delete on arrival) and the unconditional wipe is the guarantee, which cannot depend on exclude-pattern semantics I have no way to test from here. No prefix retirement this time, and that is the evidence the fix is at the right level: it does not need one. The per-workflow key from the previous commit stays. mcpp.toml declares both the dev toolchain and the release target, so a CI build and a release build hash the same, and one key with several writers is wrong on its own terms -- it just was not what caused this. What the cache is for is `registry/data/xpkgs`: ~800 MB of payloads that are toolchain-independent and survive the round trip. * docs: four more instances of the same failure mode, found while implementing §4.1 catalogued six. Implementation produced four more with identical shape, and the last is the expensive form: the CI cache guard fired only on an inexact restore, so retiring the key prefix -- which forces one -- made THREE independent "fixes" look correct, each for exactly one run. A failure mode that hides a defect is ordinary here; one that manufactures evidence of a fix is worse. Three of the four were found the same way: the same measurement taken twice, disagreeing. A recipe change under test did nothing twice with no diagnostic; a CI failure "fixed" three times; a test rejecting the correct value. That is itself the argument for O1 -- the difference between declared and recorded has to be automatically inspectable rather than dependent on someone happening to look twice. --------- Co-authored-by: sunrisepeak <speakshen@163.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two changes, one rule between them: when xlings or libxpkg needs something, it
resolves the payload, and when it transforms a payload it checks the
result rather than the intent.
Design:
xlings/.agents/docs/2026-08-06-subos-architecture-proposal.md(R6 §1.5, R4 and R7 §1.3). Landing plan:
2026-08-06-subos-landing-plan.md._find_toolresolved a view, not the payloadelfpatch._find_toolordered its candidatessubos/bin→home/bin→/usr/bin→PATH, and the payload was not a candidate at all. Sopatchelf — the tool that writes INTERP and RPATH onto every payload we
ship — had its identity decided by a mutable, user-selected view, with a silent
fallback to whatever
/usr/bin/patchelfthe machine happened to have.patchelf versions differ in how they grow the dynamic segment and in
--force-rpathsemantics, so which one runs decides what our artifacts looklike. It survived because in the default configuration all four candidates
resolve to the same file: the answers agree by coincidence until a second home,
a second version, or a host install exists.
Payload first now, via a new
pkginfo.tool_payload_dir. The view and the hostremain as fallbacks — a store that predates this has to keep working — but
using one is reported rather than preferred.
tool_payload_dirrather thandep_install_dirbecause a missing resolverrecord means different things for the two. For a dependency of the package
being installed it means the client predates
resolved_deps, which is worthwarning about. patchelf is a dependency of libxpkg's own elfpatch module;
nothing should have recorded it, so the same warning would fire on every
install and name a condition the user cannot act on.
relocate_build_paths— enumerate, anchor, assertA downloaded prebuilt carries the build machine's absolute paths in its text
files. Recipes have been rewriting them by hand, and glibc's version got all
three parts wrong at once — and reported success every time.
It named six files. The payload has build paths in five, of which four
are on that list and were processed wrongly, and the fifth was never
opened. A list of what someone thought of is not a measurement (R7).
Its pattern ran leftward through quotes and variable names.
([^%s)]+)/<marker>/libswallowedRTLDLIST="along with the path:The
lddwe ship does not survivebash -n— 2.39 and 2.44 alike. It hasbeen shipping like that. Here the match walks back from the marker to a
character that cannot occur in a path and requires what is left to be
absolute — deliberately a walk, not a pattern, because a leftward pattern is
greedy by construction.
Its tail was anchored at
/lib, so the same file'sTEXTDOMAINDIR=<build>/share/localewas untouched: the build path stayed inthe artifact, which was the only thing the code existed to remove.
And the part that made all of it invisible: it wrote if it had substituted
anything and called that success. "Still has build paths" and "we corrupted
the file" produced the same output as a clean run. The result is now
asserted (R4): no absolute build path may remain anywhere in the payload,
and every rewritten shell script must parse — checked with the script's own
shebang interpreter, because a fixed
sh -ngives a verdict that depends onwhat the host's
/bin/shis.Binaries are skipped, symlinks are never written through, and an occurrence
that is already relative is left relative.
Verification
Four new tests. The relocation fixture reproduces the shipped damage byte for
byte: running glibc's current pattern over it produces exactly the line that
is in
~/.xlings/data/xpkgs/xim-x-glibc/2.39/bin/lddtoday. The_find_toolpayload test was falsified before being kept — breaking the payload lookup
makes it fail while the fallback test still passes.
End to end against a slice of a real home, with
xlingsbuilt against thisbranch: five files with build paths and a
bin/lddfailingbash -nat line38 → zero build paths,
bash -nclean,libc.so's GROUP naming real files,and
lddprinting resolved addresses for a payload binary instead of notrunning at all.
mcpp test: 4/4 test binaries, 0 failures.Self-review found two more, fixed in
8a3a666path token would have silently deleted the text between them;
_exec_okcannot tell"syntax error" from "command not found", so a payload holding a zsh script on
a machine without zsh would have failed the install.
Downstream
xlingsbumpsxpkgto 0.0.51 andxim-pkgindex's glibc recipe callsrelocate_build_pathsbehind atype()probe, so older clients keep working(and are told plainly what is left undone).