Skip to content

0.0.51: the payload answers, and a relocation that asserts its own result - #35

Merged
Sunrisepeak merged 3 commits into
mainfrom
feat/payload-first-tools
Aug 6, 2026
Merged

0.0.51: the payload answers, and a relocation that asserts its own result#35
Sunrisepeak merged 3 commits into
mainfrom
feat/payload-first-tools

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

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_tool resolved a view, not the payload

elfpatch._find_tool ordered its candidates subos/binhome/bin
/usr/binPATH, 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.

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
remain 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 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, assert

A 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>/lib 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 we ship does not survive bash -n — 2.39 and 2.44 alike. It has
    been 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's
    TEXTDOMAINDIR=<build>/share/locale was untouched: the build path stayed in
    the 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 -n gives a verdict that depends on
what the host's /bin/sh is.

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/ldd today. The _find_tool
payload 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 xlings built against this
branch: five files with build paths and a bin/ldd failing bash -n at line
38 → zero build paths, bash -n clean, libc.so's GROUP naming real files,
and ldd printing resolved addresses for a payload binary instead of not
running at all.

mcpp test: 4/4 test binaries, 0 failures.

Self-review found two more, fixed in 8a3a666

  • the backward walk had no lower bound, so a marker occurring twice inside one
    path token would have silently deleted the text between them;
  • 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.

Downstream

xlings bumps xpkg to 0.0.51 and xim-pkgindex's glibc recipe calls
relocate_build_paths behind a type() probe, so older clients keep working
(and are told plainly what is left undone).

`_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
Sunrisepeak merged commit 2709f8f into main Aug 6, 2026
1 check passed
@Sunrisepeak
Sunrisepeak deleted the feat/payload-first-tools branch August 6, 2026 00:26
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant