Skip to content

B line: interpose every glvnd entry point, with provenance in the proof - #532

Merged
Sunrisepeak merged 6 commits into
mainfrom
feat/bline-interposer
Aug 6, 2026
Merged

B line: interpose every glvnd entry point, with provenance in the proof#532
Sunrisepeak merged 6 commits into
mainfrom
feat/bline-interposer

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Completes the B line of the subos architecture proposal: the NVIDIA
vendor's dependency closure is bound per-object by RPATH instead of
broadcast to the process through LD_LIBRARY_PATH.

Requires openxlings/libxpkg#36 (libxpkg 0.0.52).

What changed

pkgs/i/interposer-stub.lua (new) — the empty ELF object that patchelf
turns into an interposer. There is no compiler at install time and patchelf
edits objects rather than creating them, so the object is shipped (AD-12).
x86_64 8984 B, aarch64 66264 B, zero DT_NEEDED.

pkgs/n/nvidia-gl-host-link.lua — an interposer for every glvnd
entry point, and the LD_LIBRARY_PATH subos.env declaration is gone.

Interposing only libEGL, as the first cut did, is not "most of it". glvnd
dlopens each vendor library BY NAME, so each is the root of its own load
chain, and DT_RPATH is transitive only down a chain — never across to
another root. Measured: EGL rendered on the RTX 4080 while GLX still pulled
the vendor's entire closure out of /usr/lib. libGLX_nvidia.so.0 is also
the Vulkan ICD, so that one root carries two APIs.

The install line reports interposers as a fraction (4/4) of the entry
points the host has. A bare "yes" made one-of-four read as success, and the
three uncovered roots were exactly the APIs nothing probed.

patchelf is declared as a build dep — it is what builds the interposer.

Verification

.agents/tools/graphics/verify-host-link.sh — 12 checks, all passing on a
real RTX 4080 (driver 550.144.03) in an isolated home.

✓ libEGL_nvidia.so.0 interposes /usr/lib/x86_64-linux-gnu/libEGL_nvidia.so.0
✓ libGLX_nvidia.so.0 interposes /usr/lib/x86_64-linux-gnu/libGLX_nvidia.so.0
✓ libGLESv1_CM_nvidia.so.1 interposes …
✓ libGLESv2_nvidia.so.2 interposes …
✓ EGL rendered (PIXEL=336699)
✓ EGL renderer: GL_RENDERER=NVIDIA GeForce RTX 4080/PCIe/SSE2
✓ EGL went through OUR interposer
✓ GLX renderer: NVIDIA GeForce RTX 4080/PCIe/SSE2
✓ GLX went through OUR interposer
✓ the host's real vendor was pulled in by absolute DT_NEEDED
✓ LD_LIBRARY_PATH is empty in the subos
✓ host vendor libraries are untouched

Why the probes now print load paths

A renderer string cannot carry this claim. glxinfo run inside a subos
printed NVIDIA GeForce RTX 4080/PCIe/SSE2 while every object came from
/usr/lib
— the host binary under the host loader, our payload
contributing nothing. That output is indistinguishable from success.

Both probes now report the path of every GL object mapped, and the harness
asserts our interposer is among them.

A consumer requirement this exposes

The vendor dlopen is served by the calling object's search path, and
libGLX.so.0's own RPATH is $ORIGIN — it cannot see the vendor package.
What makes it resolve is that DT_RPATH is searched transitively up the
load chain to the executable
. DT_RUNPATH is not: the same probe built
with --enable-new-dtags finds no vendor at all, with the X connection and
the GLX extension both fine. Documented in glxprobe.c.

…ory and

the search path

Two things go away together, and neither could have been fixed by getting the
other right.

**The hand-written table.** `lib/xlings-deps/` held SONAMEs someone listed:
glibc's three 2.34+ stubs, libX11, libXext, libGLdispatch, libxcb, libXau,
libXdmcp. Measured against the vendor's actual DT_NEEDED closure it was missing
libm, libdrm, libgbm, libgcc_s and libwayland-* — every one of which was
therefore resolving from the HOST, silently, which is the leak this package
exists to close. A list of what someone thought of is not a measurement (R7).

**LD_LIBRARY_PATH's lack of scope.** It is inherited by every child of the
subos shell, and most of those are host binaries on the host loader. That is
how a libc in that directory once returned a `/bin/bash` that died of SIGSEGV
before printing a character.

An interposer replaces both: ~9 KB of empty object carrying the vendor's
SONAME, NEEDing the real vendor by absolute path so `dlsym` still reaches its
entry points through the handle's dependency tree, and holding DT_RPATH —
transitive along the load chain, where DT_RUNPATH is not — naming the closure
the RESOLVER computed rather than one this file lists.

Measured on this host, with LD_LIBRARY_PATH removed entirely:

    before   GL_RENDERER = llvmpipe (LLVM 20.1.7, 256 bits)
    after    GL_RENDERER = NVIDIA GeForce RTX 4080/PCIe/SSE2, PIXEL=336699

The probe renders and reads the pixel back; a renderer string is something a
half-working stack prints too.

The host driver directory is not declared either. The vendor dlopens its own
siblings by bare SONAME at runtime and those must match the host's kernel
module — but the host loader finds them through its own ld.so cache, with no
help from us.

PRECONDITION, and it is why only OUR vendor JSON names the interposer: it may
only be loaded by a consumer whose INTERP points into our payload. The host's
glvnd keeps using the host's vendor, so both rules hold at once. Handing one to
a host binary fails as `librt.so.1: undefined symbol: __pointer_chk_guard,
version GLIBC_PRIVATE`.

Degradation is loud, not silent: a client whose libxpkg predates
host_link_interposer says so and says what to run, and the install line now
reports whether the interposer exists — without that, "the vendor's
dependencies resolve to our payloads" and "they resolve to the host's" print
the same thing, and the second still renders.
…ve it

Completes the B line: the NVIDIA vendor's dependency closure is bound
per-object by RPATH instead of broadcast to the process through
LD_LIBRARY_PATH.

WHAT CHANGED

- interposer-stub: new package carrying the empty ELF object that
  patchelf turns into an interposer. There is no compiler at install
  time and patchelf edits objects rather than creating them, so the
  object has to be shipped (AD-12).

- nvidia-gl-host-link: builds an interposer for EVERY glvnd entry point
  -- libEGL_nvidia.so.0, libGLX_nvidia.so.0, libGLESv1_CM_nvidia.so.1,
  libGLESv2_nvidia.so.2 -- and drops the `LD_LIBRARY_PATH` subos.env
  declaration that used to make the vendor's deps resolve.

  Interposing only libEGL, as the first cut did, is not "most of it":
  glvnd dlopens each vendor library BY NAME, so each is the root of its
  own load chain, and DT_RPATH is transitive only DOWN a chain, never
  across to another root. Measured: EGL rendered on the RTX 4080 while
  GLX still pulled the vendor's entire closure out of /usr/lib.
  libGLX_nvidia.so.0 is also the Vulkan ICD, so that one root carries
  two APIs.

  The install line now reports interposers as a fraction (4/4) of the
  entry points the host actually has. A bare "yes" made one-of-four read
  as success, and the three uncovered roots were exactly the APIs
  nothing probed.

- patchelf is declared as a build dep (`deps.build`). It is what BUILDS
  the interposer; without it there is no vendor entry point at all.

VERIFICATION

`.agents/tools/graphics/verify-host-link.sh` -- 12 checks, all passing
on a real RTX 4080 (driver 550.144.03) in an isolated home.

Both probes now report the PATH of every GL object they mapped, because
the renderer string alone cannot carry the claim: `glxinfo` run inside a
subos printed "NVIDIA GeForce RTX 4080/PCIe/SSE2" while every single
object came from /usr/lib -- the host binary under the host loader, with
our payload contributing nothing. That output is indistinguishable from
success. The harness asserts our interposer is among the mapped files,
that the host's real vendor arrived behind it by absolute DT_NEEDED,
that LD_LIBRARY_PATH is empty, and that the host's own driver files were
not modified.

A CONSUMER REQUIREMENT THIS EXPOSES

The vendor dlopen is served by the CALLING object's search path, and
libGLX.so.0's own RPATH is `$ORIGIN` -- it cannot see the vendor
package. What makes it resolve is that DT_RPATH is searched transitively
up the load chain to the executable. DT_RUNPATH is not: the same probe
built with --enable-new-dtags finds no vendor at all, with the X
connection and the GLX extension both fine. Documented in glxprobe.c.
Self-review of the previous commit found a forward-compatibility hole in
my own change.

`deps = { "a", "b", build = {...} }` -- a positional runtime list with a
`build` key beside it -- is handled correctly only from libxpkg 0.0.52.
Every client before that takes the legacy array branch on it: `build` is
dropped and the positional entries are copied into build_deps in its
place. Silently. The install succeeds having done neither thing.

The index serves every client version, and unlike a Lua function there
is nothing in a recipe that can probe the loader's shape handling. So
the recipe must not depend on the fix at all: the split form,
`deps = { runtime = {...}, build = {...} }`, means the same thing on
every client that has ever existed.

`tests/test_deps_shape.py` now fails any recipe that mixes them. The
check is brace-matched rather than pattern-matched -- a `deps` table
almost always contains nested tables and comments, and `\{[^}]*\}` stops
at the first inner `}` and then draws a conclusion from half a table.

Measured before writing it: exactly one recipe in the index was the
mixed shape (this one, added by the previous commit). 155 recipes
scanned, 947 static assertions pass, and injecting the mixed shape back
fails precisely the one package.
With push alone, changed-files compares HEAD against the previous commit,
so the install test only ever sees the last push -- never the change the
PR proposes. A PR that adds a package in one commit and depends on it in
the next ends with a docs-only commit that touches no pkgs/ file, the
step is skipped, and the check reports green.

Measured on this PR: a passing linux-install-test that ran zero packages,
one push after the run that actually failed.
install() branched on `is_host("windows")`, and the `else` arm ran
__link_cuda_backends() and __install_systemd_user_service(). Both are
linux-only: the CUDA sentinel is declared only in `xpm.linux`, and
systemd is not a thing on macOS. So macOS took the linux path, and the
first thing it did was ask for the install dir of a package macOS never
resolves.

openxlings/xlings#487 reported this as "dependency resolution does not
filter by platform". It does -- xlings's resolver reads
`runtime_deps[platform]` -- and the recipe's declaration was right too.
The branch was what was wrong. The message ("cannot get install dir")
named an internal state and sent the reader to paths; libxpkg 0.0.53
makes it name the platform instead.

The recipe's other two hooks already guard on `is_host("linux")`;
install() was the outlier. Scanned every recipe with an
`is_host("windows")` branch for the same shape -- ollama was the only
one.

Also: interposer-stub declares `namespace = "xim"`.

`config --add-xpkg` registers a recipe into the LOCAL index, and without
a declared namespace it lands under `local:`. CI pre-registers every
changed recipe so a PR that adds a stack can install it -- but a dep
written `xim:interposer-stub` does not match a `local:` registration.
The failure reads "package 'xim:interposer-stub@>=0.1' not found" for a
file sitting in the same diff. Declaring it makes the local registration
and the published one the same address; verified locally, the address
now resolves.
Sunrisepeak added a commit to openxlings/xlings that referenced this pull request Aug 6, 2026
0.0.53 adds the diagnostic half of #487. `cannot get install dir` named
an internal state and covered two causes pointing in opposite
directions:

  - not a dependency of this package ON THIS PLATFORM
  - declared here, but the payload never landed

It now says which, and names the package, the platform, and what the
deps here actually are.

The issue's own hypothesis -- that dependency resolution does not filter
by platform -- does not hold: resolver.cppm reads
`pkg->xpm.runtime_deps.find(platform)`, and ollama declares the CUDA
sentinel only under `xpm.linux`. The cause was ollama's install hook
branching on `is_host("windows")` when the real distinction was linux,
so macOS took the linux path. Fixed in openxlings/xim-pkgindex#532; the
message is fixed here because it is what sent the reader to paths.
@Sunrisepeak
Sunrisepeak merged commit bf969a6 into main Aug 6, 2026
13 checks passed
@Sunrisepeak
Sunrisepeak deleted the feat/bline-interposer branch August 6, 2026 06:55
Sunrisepeak added a commit to openxlings/xlings that referenced this pull request Aug 6, 2026
…oven by provenance (#490)

* docs: the B line's core claim, proven on the real NVIDIA stack

The gate (§2.7) proved the MECHANISM works — DT_RPATH transitivity, dlsym
through the handle, GLX needing no process-global variable. This proves it
works on the actual driver, and measures the boundary the gate could not see.

Baseline, with glprobe, which renders and reads a pixel back rather than
printing a version string:

    host env    GL_RENDERER = NVIDIA GeForce RTX 4080/PCIe/SSE2   PIXEL=336699
    in subos    GL_RENDERER = llvmpipe (LLVM 20.1.7, 256 bits)    PIXEL=336699

§2.6's defect, measured: the same host-linked binary drops from the GPU to
software rendering inside the subos, silently. Both pixels are correct, so
"does it render" cannot catch this — only the renderer name can.

With a 27 KB interposer built by patchelf alone, and LD_LIBRARY_PATH carrying
ONLY the host driver directory — lib/xlings-deps not on it at all:

    GL_RENDERER = NVIDIA GeForce RTX 4080/PCIe/SSE2   PIXEL=336699  RESULT=ok

B2's acceptance criterion, satisfied.

The boundary came out of the same experiment. Handing that interposer to a
HOST binary fails as

    librt.so.1: undefined symbol: __pointer_chk_guard, version GLIBC_PRIVATE

which is the 2026-08-05 crash verbatim: the interposer's RPATH names OUR
glibc, and the consumer's libc is the host's. Not a defect — the domain of
applicability, and consistent with §2.3, which says the process the vendor is
dlopen'd into is ours by construction. But it is a precondition B1 has to put
in the contract rather than leave implied:

    an object produced by host_link_interposer may only be loaded by a
    consumer whose INTERP points into our payload; host binaries must keep
    using the host's own vendor.

Which is precisely the argument for B4: bake the vendor directory into the
libglvnd WE build, and the two paths separate by construction instead of by
an environment variable that every child inherits.

Also corrects two wrong calls of mine about the install itself. It was never
network-infeasible: a fresh isolated home defaults to the GLOBAL mirror, and
`xlings config --mirror CN` installs all 22 packages in minutes. Before that I
had declared it hung on twenty seconds of no growth in one directory, while it
was between finishing its downloads and extracting them.

* 2026.8.6.3: the B line lands, and a loader shape that dropped build deps

Pins libxpkg 0.0.52, which brings two things:

- `elfpatch.host_link_interposer` — the mechanism the B line is built
  on. From a shipped empty ELF stub, patchelf produces an object with
  the vendor's SONAME, the HOST vendor as an absolute DT_NEEDED, and the
  payload closure as DT_RPATH. The vendor's dependencies then resolve
  out of our payloads along one load chain, instead of being broadcast
  to the whole process through LD_LIBRARY_PATH.

- A `deps` table mixing a positional list with `build = {...}` used to
  drop the build deps silently and copy the positional entries into
  build_deps in their place. Declared, reported as installed, neither
  done.

Companion: openxlings/libxpkg#36, openxlings/xim-pkgindex#532.

The doc's §7.7 records how this went, because the interesting part is
that every step produced a passing result first:

  - an empty payload that installed "successfully" — caught only by the
    hook's own assertion, and caused by a resource key nested one level
    too deep;
  - `glxinfo` printing "NVIDIA GeForce RTX 4080/PCIe/SSE2" inside the
    subos while every object came from /usr/lib — the host binary under
    the host loader, our payload contributing nothing. That output is
    identical whether the B line works or never happened;
  - `interposer: yes` with one of four glvnd entry points covered. Each
    vendor library is dlopened BY NAME and so is the root of its own
    load chain; DT_RPATH is transitive only down a chain. EGL rendered
    on the GPU while GLX pulled its whole closure from the host.

Each was found by measuring the artifact rather than the log line, and
each fix is now asserted by
`xim-pkgindex/.agents/tools/graphics/verify-host-link.sh` — 12 checks on
a real RTX 4080, driver 550.144.03, in an isolated home.

Also corrects a claim I made earlier in this work: elfpatch does NOT
silently fall back to the host's patchelf. `_find_tool` resolves
payload → subos view → home bin → host, warns when it leaves the
payload, and `tool_payload_dir` scans the whole store — so a home that
has patchelf at all uses it, declared or not. The real gap is narrow: a
home that has never installed patchelf.

* pin libxpkg 0.0.53: install_dir names the cause (#487)

0.0.53 adds the diagnostic half of #487. `cannot get install dir` named
an internal state and covered two causes pointing in opposite
directions:

  - not a dependency of this package ON THIS PLATFORM
  - declared here, but the payload never landed

It now says which, and names the package, the platform, and what the
deps here actually are.

The issue's own hypothesis -- that dependency resolution does not filter
by platform -- does not hold: resolver.cppm reads
`pkg->xpm.runtime_deps.find(platform)`, and ollama declares the CUDA
sentinel only under `xpm.linux`. The cause was ollama's install hook
branching on `is_host("windows")` when the real distinction was linux,
so macOS took the linux path. Fixed in openxlings/xim-pkgindex#532; the
message is fixed here because it is what sent the reader to paths.

* ci: bump mcpp to 2026.8.6.1 so it can see a freshly published index

CI pinned mcpp 2026.8.3.3 while the index's latest is 2026.8.6.1, and
that pin is what made `mcpplibs.xpkg@0.0.53` read as "not found" long
after it was published.

The index side was verified correct at every layer before touching this:
the mcpp-index commit has the 0.0.53 entry, the published artifact
`mcpp-index-84fa166.tar.gz` contains it, the rolling pointer
`mcpp-index-pointers.json` names 84fa166, and `releases/latest` resolves
to that tag. A local mcpp 2026.8.5.4 has 0.0.53 in its live index copy.
So it was not publish lag and not a stale pointer.

The workflows' `xlings update` step refreshes the XIM index and its three
sub-indexes -- visible in the log -- and never touches the mcpplibs one,
which is fetched by mcpp itself.

Bumping mcpp means bumping XIM_PKGINDEX_REF with it, in all six
workflows: the two are pinned as a known-good pair, and moving one alone
is how a run ends up resolving a new client against an old index. The
new ref is xim-pkgindex bf969a6 -- main with #532 (the B line) merged,
so CI resolves against the index that carries interposer-stub.

* 2026.8.6.3: the client's own version constant

The version-consistency contract caught it: mcpp.toml said 2026.8.6.3
and src/core/config.cppm still said 2026.8.6.2. A release whose binary
reports the previous version is exactly what that contract exists to
stop.

* ci: installing mcpp is not switching to it, so assert the version

`xlings install -y` lays the payload down; the shim keeps resolving
whatever version was already active. The client says so -- "installed,
but 'x' still resolves to ..." -- but with ~/.mcpp restored from an
Actions cache that one line is the only sign that the mcpp about to run
is the old build.

Measured 2026-08-06: `.xlings.json` pinned mcpp 2026.8.6.1, the run used
the cached 2026.8.3.3, and its index snapshot predated the dependency
this PR needs. It surfaced as `mcpplibs.xpkg@0.0.53 not found` against
an index that demonstrably had it -- the mcpp-index commit, the
published artifact mcpp-index-84fa166.tar.gz, the rolling pointer, and
releases/latest all carry the entry, and a local mcpp 2026.8.5.4
refetches it from a wiped registry on both mirrors. Every layer I could
check was correct, which is what made the version the last place to
look.

So: switch explicitly, then ASSERT the version rather than print it. A
version line in a log is only ever read after something has already gone
wrong; a failed assert names the cause at the point it happens. Seven
workflows, including release.yml.

* ci: on a build failure, print the index the runner actually holds

`mcpplibs.xpkg@<ver> not found` has now been diagnosed five times from
the outside. Each check was correct:

  index commit          has the entry
  published artifact    mcpp-index-84fa166.tar.gz has it
  rolling pointer       names 84fa166
  releases/latest       resolves to that tag
  publish lag           still failing 30+ minutes later
  mcpp version pin      bumped 2026.8.3.3 -> 2026.8.6.1, assert passes
  install-vs-use        the assert proves the right mcpp runs
  mcpp.lock hash        regenerates byte-identical from the published index

Every one of those is inference about a file only the runner can see. So
the failure path now prints it: the version keys in the mcpplibs index
under both registry roots, the index snapshots present, and
`mcpp self version`.

Guessing a sixth time would cost another CI round either way; this way
the round produces an answer instead of another elimination.

* ci: the diagnostic hung off a command that never ran

The index dump added one commit ago sat inside `mcpp test || { ... }`,
with `mcpp build` running unguarded above it. The shell is `bash -e`, so
a failing `mcpp build` aborted the step before the block was reached.
The diagnostic existed, never ran, and the failure output was identical
to a run where it had -- an entire CI round spent adding output that
could not print.

Now it is a separate step with `if: failure()`, so it covers any failing
step in the job rather than one command's non-zero exit, and it also
reports both registry roots, the snapshots present, and the mcpp/xlings
versions.

* ci: force `mcpp index update`, and assert what the refresh landed

The prune drops the resolved indexes, and mcpp's own re-fetch happily
uses what it finds in the restored Actions cache -- so a freshly
published dependency reads as "not found" against an index that
demonstrably has it. `mcpplibs.xpkg@0.0.53` failed this way while the
index commit, the published artifact mcpp-index-84fa166.tar.gz, the
rolling pointer and releases/latest all carried the entry, and a local
mcpp at the same version refetched it from a wiped registry on both
mirrors.

`mcpp index update` is the refresh. `mcpp index status` prints the
revision each index is actually at -- locally, `mcpplibs 84fa166`, which
is the one datum this failure needed and nothing was printing.

The assert matters as much as the refresh: calling update alone would
leave "refreshed" and "still stale" producing identical output, which is
the exact failure mode the step exists to end. It greps the refreshed
index for the version mcpp.toml pins and, on a miss, prints the index
revision and the versions that ARE there before failing.

Seven workflows, including release.yml.

* ci: the index assert must slice the platform section first

A whole-file grep for `["0.0.53"]` passes on a file where the entry
exists only under `xpm.linux` -- which is exactly the bug this assert
was written to catch and did not. Both mcpp-index bumps edited the linux
section alone; linux CI went green and macOS/Windows failed against a
file that literally contains the string.

Falsified before committing: against the broken file the check reports
linux ok, macosx MISSING, windows MISSING; against the fixed one all
three pass.

* ci: the Windows index assert was still a whole-file match

The platform-aware fix landed in six workflows and skipped this one --
the PowerShell block did not match the POSIX text my patch keyed on. So
Windows kept the version of the check that passes on the broken file:
`Select-String` over the whole descriptor finds `["0.0.53"]` in the
linux block and reports success.

Measured on this PR: macOS failed AT the assert, naming
`xpm.macosx`, while Windows sailed through it and failed two steps later
in `mcpp build` with a message about a package rather than a platform --
the exact difference the assert exists to make.

Now it slices the `windows = {` block first, and on a miss prints the
index revision and the versions present anywhere in the file, so the
"it's right there" confusion is answered in the same output.

Falsified before committing by replicating the slicing rule against the
pre-fix descriptor: windows-block MISSING on the broken file, ok on the
fixed one.

* ci: the index assert reads the pin from mcpp.toml, never a literal

`0.0.53` was baked into all seven workflows. That is a value that
changes: the next libxpkg bump moves `mcpp.toml` and leaves every
workflow asserting the previous version, which then passes against an
index that still carries it. The check would go stale without ever
failing -- the same shape as everything else this session has been
chasing, this time in the check itself.

Now the version is read from `[dependencies.mcpplibs] xpkg` at runtime,
so the only constant is where the source of truth lives.

Falsified before committing, all four combinations:

  new index + pin 0.0.53 / macosx    ok
  new index + pin 0.0.53 / windows   ok
  old index + pin 0.0.53 / macosx    FAIL   (the platform-slice defect)
  new index + pin 0.0.99 / macosx    FAIL   (the pin moved, assert follows)

The first extraction attempt returned an empty string -- `gsub(/.*"|".*/,"")`
is greedy to the LAST quote -- and only the falsification caught it. It
would have made every job fail on the guard rather than pass silently,
but wrong either way.

* ci: drop the index assert, keep the refresh

Per review: `mcpp index update` alone is enough. The assert added on top
of it turned a transient condition into a hard failure on a timer nobody
controls, and its own literal version was a value that changes.

`mcpp index status` stays -- it prints the revision each index landed
on, which is what makes a stale-index failure readable instead of
surfacing two layers away as `<pkg>@<ver> not found`.

Seven workflows; release.yml had three copies of the block.
Sunrisepeak added a commit that referenced this pull request Aug 7, 2026
…tcome (#543)

* test(graphics): one coverage matrix, run per machine, with a third outcome

Verification of this stack was three scripts covering one slice each --
verify-host-link.sh (NVIDIA only), selfcontained-check.sh (empty host), and
whatever got typed that day. Each had its own setup, none knew about the others,
and the union was never reported. So "the ecosystem works" rested on one machine
with one GPU, and every other cell was untested in a way that produced no output.

verify-stack.sh creates a subos, installs `graphics`, and walks the matrix:
software rendering, NVIDIA proprietary (delegated to the provenance verifier),
radeonsi / iris / nouveau, WSL2 d3d12, Vulkan, X11, Wayland, a real GUI
application, and empty-host self-containment.

THE POINT IS THE THIRD OUTCOME. A cell that could not be exercised here is
printed, counted, and listed again in the summary WITH ITS REASON. Skips do not
fail the run -- treating "I have no AMD GPU" as a failure would make the script
useless to everyone -- but they are never silent, because "we lack that hardware"
and "it works" must not look alike. The summary's skip list is the recruitment
list: no one machine has NVIDIA, AMD, Intel and WSL2 at once, so coverage is the
union of runs by different people, and --json exists so those runs can be
aggregated.

Writing it immediately caught two false passes in itself, both the exact shape it
exists to prevent:

* `nouveau` reported PASS on this host. MESA_LOADER_DRIVER_OVERRIDE=nouveau
  rendered fine -- on llvmpipe -- and the cell only checked RESULT=ok. A hardware
  cell now asserts the renderer is not the software fallback, and nouveau is
  correctly reported as not-exercisable while the proprietary nvidia.ko owns the
  GPU.
* `xlings install graphics` printed "0 package(s)" as a pass. That is what a
  re-run looks like; it now says "already satisfied" instead of a count that
  reads as coverage -- the same trap #532 hit in CI.

First run on an RTX 4080 / driver 550.144.03: pass 13, fail 0, not-exercised 6
(amd, intel, WSL2, Vulkan, Wayland, and the empty-host check which is currently
INCONCLUSIVE by its own control run).

* feat(libXinerama): the first gap the coverage matrix named, closed end to end

verify-stack.sh reported it as a non-fatal unresolved dlopen under the real-GUI
cell, which is the only place it could have shown up: libXinerama is on no
DT_NEEDED path -- mesa does not need it, nothing in the rendering closure does --
and a toolkit dlopens it to ask where the monitors are. It appears in no
dependency graph derived from ELF metadata, and the surfaceless probe that was
this stack's acceptance criterion could never miss it. godot printed

    libXinerama.so.1: cannot open shared object file

and started anyway, falling back to single-screen geometry. A non-fatal dlopen
failure survives every test that only asks "did it run".

Built with build-in-subos.sh (leak check: no host references), published to a new
xlings-res/libXinerama, recipe added, pulled into `graphics`. Verified by
reinstalling and running godot: the line is gone, and it still reports
OpenGL API 3.3.0 NVIDIA 550.144.03 on the RTX 4080.

Also fixes build-in-subos.sh, which is why the build failed the first time.
It spliced only `<dep>/lib/pkgconfig` into PKG_CONFIG_LIBDIR. A PROTOCOL-ONLY
package installs no library and puts its .pc in `share/pkgconfig` -- xorgproto
ships 40 of them there, xcb-proto likewise -- so every protocol package was
invisible to pkg-config while its HEADERS were still spliced in via -I. That
combination is why the gap lasted: mesa builds fine because it includes the
headers and never asks pkg-config for a protocol module; libXinerama does ask,
and died on `XINERAMA_CFLAGS ... no such package` for a package sitting right
there with its headers already on the command line.

GLOBAL mirror only, stated in the recipe rather than papered over: `gtc` can
publish a release into an existing GitCode project but cannot create the
project, and xlings-res/libXinerama does not exist there yet. A CN URL pointing
at a missing project fails at download time instead of falling back, which is
worse than not having one.

* feat(vulkan): a loader, so the ICD mesa already ships is no longer unread

The matrix reported Vulkan as an empty cell: mesa builds RADV and rewrites its
ICD manifest to an absolute path in our payload, and nothing ever read it,
because an ICD is a DRIVER and a driver is loaded BY a loader. No
libvulkan.so.1 meant no vulkaninfo and a dead zink in a payload that ships it.

vulkan-headers 1.4.313 and vulkan-loader (from tag vulkan-sdk-1.4.313.0), both
built in a subos and leak-checked, published to xlings-res, pulled into
`graphics`. Verified: lib/libvulkan.so.1 reaches <subos>/lib and its DT_NEEDED
closure resolves with 0 unresolved under our own loader.

Discovery needed no new declaration -- the loader searches
$XDG_DATA_DIRS/vulkan/icd.d and mesa's config() already prepends its share
directory there. VK_DRIVER_FILES would have been wrong: it is an override that
suppresses system discovery.

## And a bug in build-in-subos.sh that produced a mislabeled payload

The download cache was keyed on the URL's basename. Every GitHub archive URL is
`.../archive/refs/tags/v<tag>.tar.gz`, so the basename carries the tag and
nothing about the project -- and Vulkan-Headers and Vulkan-Loader are both
released as v1.4.313. The loader build found the headers' tarball already in
$SRC, skipped the download, then configured, built, staged, LEAK-CHECKED and
packaged the wrong source. Every step reported success and the artifact was
published to xlings-res as `vulkan-loader` while containing Vulkan-Headers.

It was caught only because the installed package had no lib/ directory. The
release has been deleted and replaced with the real loader; the cache is now
keyed on NAME-VERSION.

Two smaller things the same build surfaced: Vulkan-Loader's tags are
`vulkan-sdk-<x>` and not `v<x>` (GitHub's archive endpoint answers 200 for a ref
that is not the tag you meant), and xrandr.pc Requires.private xrender, which was
simply missing from --deps -- a missing dependency, not a tooling fault.

* fix(vulkan): the loader found the HOST's ICDs, and the cell called that a pass

Adding a Vulkan loader immediately broke two of the matrix's own cells, both by
being too weak. Recording them because each is the shape the matrix exists for.

**"software rendering (llvmpipe)" started failing with**
    zink Vulkan 1.3 (NVIDIA GeForce RTX 4080 (NVIDIA_PROPRIETARY))
Selecting the mesa vendor is not selecting SOFTWARE. With a working
libvulkan.so.1 present, mesa switched to zink -- its GL-over-Vulkan driver, a
real and welcome capability, and emphatically not the CPU path this cell is
named for. It now forces LIBGL_ALWAYS_SOFTWARE and asserts the renderer is one of
llvmpipe/softpipe/swrast. (That zink came alive at all is the loader paying for
itself: the recipe predicted "zink is dead in a payload that ships it".)

**"Vulkan loader + ICDs" reported PASS with "0 ICD manifest(s)".** A loader with
none of OUR ICDs in the subos is not Vulkan support -- it is a loader that finds
the HOST's ICDs and succeeds. Same boundary the GL side needed interposers for,
one API over, and it looks like a pass from every angle except asking whose ICD
answered. Zero ICDs is now a failure with that sentence as its message.

Which then named the real defect: mesa's ICD manifest lived only in its payload.
The loader reads $XDG_DATA_DIRS/vulkan/icd.d and mesa puts ${subosdir}/share on
that list -- so a manifest that never reaches the subos is never found.
graphics.declare_vulkan_icd() places them, the same shared-directory shape the
glvnd vendor JSON uses and for the same reason.

Verified: radeon_icd.x86_64.json now lands in <subos>/share/vulkan/icd.d, the
cell passes on OUR ICD, and the software cell is back to llvmpipe.
Matrix on this host: pass 13, fail 0, not-exercised 6.

* test(graphics): vendorprobe — and it found that the stack is not self-contained

The tool the last round said to write, written. It dlopens a glvnd vendor
directly and reports whether the object loaded and whether __egl_Main is there,
which is the one question every other elimination had left.

First run answered it, and not at the step I predicted:

    DLOPEN=fail
    ERR=libXau.so.6: cannot open shared object file

The vendor never loads at all; __egl_Main is never reached. With
LD_LIBRARY_PATH=<subos>/lib it loads and the entry point is present -- so the
missing thing is a library, not an entry point.

The chain, each link measured:

  libxcb.so.1 NEEDs libXau.so.6
  libxcb's RUNPATH is only $ORIGIN -- its own payload directory
  libXau.so.6 lives in a DIFFERENT payload
  DT_RUNPATH is not transitive, so nothing above libxcb can help it
  => libXau is resolved from the HOST

<subos>/lib does contain libXau.so.6. Nothing on libxcb's search path points
there. The file is present and the path is not.

So the stack has never been self-contained, and the gap is a SECOND-LEVEL
dependency. Outside a container nobody notices, because every Linux machine has
libXau in its ld.so.cache. The empty-host container has no cache, the vendor
fails to load, and that surfaces as zero vendors and EGL_BAD_PARAMETER -- which
is what S1 has been reporting.

This is exactly what the S3 assertion was written to catch, one layer lower than
expected: not a GL renderer coming from the host, but an X11 second-level
dependency.

I was wrong to write that S1's failure was uninformative. The control run failing
too does not mean the test is broken; it means both runs failed for the same real
defect. The INCONCLUSIVE gate is still worth keeping -- it turns a message that
would blame the closure into an honest "could not tell" -- but this time the
closure really is incomplete.

Not fixed here: elfpatch writes a full closure RPATH onto executables and leaves
payload libraries with $ORIGIN. Either each payload library's RPATH should cover
its own closure, or <subos>/lib should be appended to it -- which is precisely
what §B1 just did for the interposer, and this shows the same reasoning applies
to the whole stack. That is a blast-radius decision, not a drive-by edit.

* fix(graphics): the payloads resolved from the host, and every test said pass

`exports.runtime.libdirs` is what a package OFFERS its dependents. Something
has to CONSUME it, and that something is `elfpatch` — which no recipe in this
index, or in mcpp-index, ever called. `elfpatch.closure_lib_paths()` is a
public, documented libxpkg API that had zero callers in the entire ecosystem.

So `libxcb.so.1` shipped as

    DT_NEEDED   libXau.so.6, libXdmcp.so.6, libc.so.6
    DT_RUNPATH  $ORIGIN

with libXau in a different payload. It resolved anyway — from the host's
/etc/ld.so.cache, on every machine that has libxau, which is every desktop
Linux. The stack looked self-contained and never was.

The subos link directory does not cover this, and the reason is one sentence
of ld.so's search order: if an object has DT_RUNPATH, no ancestor's DT_RPATH
is consulted for its dependencies. A consumer with `<subos>/lib` in a
transitive DT_RPATH cannot serve libxcb's search for libXau, because libxcb
has a RUNPATH of its own.

Measured on a sealed bwrap with no /usr at all:

    as shipped   EGL_CLIENT_EXTENSIONS= , surfaceless refused 0x300c   exit 1
    sealed       GL_RENDERER=llvmpipe (LLVM 20.1.7)  PIXEL=336699      exit 0

libs/selfcontain.lua wraps the closure patch; 28 recipes call it from
install(). Under-declared direct deps are completed at the same time — a
closure is only as complete as the deps list it is computed from, and
`runtime_deps` is direct, not transitive. glibc is patched by nothing: it is
the root, and rewriting the loader's own payload is how this was nearly
broken while being investigated.

The verify-stack cell that has reported INCONCLUSIVE since it was written now
reads `✓ empty-host self-containment — S1-S4 pass`. Both arms of that A/B had
been failing for the same real defect, which is why it never accused anything.

pass 14  fail 0  not-exercised-here 5

* fix(fontconfig): a bare dep name is only unambiguous until CI touches both

CI registers every changed recipe a second time under `local:`. fontconfig
declared bare `expat@2.6.2`, so the moment this PR also touched expat the name
had two candidates and the install died with a candidate list — a failure
caused by the SHAPE of the PR, not by either recipe.

62 bare dep names remain index-wide (cairo, glib, libpng, harfbuzz...). Each is
latent in exactly the same way: fine until some unrelated change happens to hit
the depender and the dependee together.

* fix(selfcontain): refuse to call an install that produced nothing a success

Every recipe ends `os.mv(srcdir, install_dir); return true` with the move
unchecked. When the extracted source directory is already gone the move does
nothing, install() returns true, and xlings prints a tick over an EMPTY payload.

Reproduced locally, not inferred:

    xim:libffi@3.4.4     installed as a dependency    -- consumes the srcdir
    local:libffi@3.4.4   ✓ done, 1 package installed  -- payload directory empty

The only complaint came from the *config* hook two steps later, about pkgconfig
globs — the error named the wrong subsystem because by then nothing remembered
that the payload never arrived. That is what CI is currently reporting for
pkgs/l/libffi.lua.

The check cannot fire in CI's install test, and the comment says so: under
`config --add-xpkg` every `xim.pkgindex.*` import is a no-op proxy, so that job
does not exercise selfcontain.seal at all. Cell 6 of verify-stack.sh is the
evidence that the seal works; a green linux-install-test is not.

* fix(ci): a recipe under test must REPLACE its published copy, not coexist

`config --add-xpkg` registers the changed recipe under `local:` while the
published one stays under `xim:`. Two candidates for one package is a state
that never exists after merge, and it breaks the run in two ways that both
read as bugs in the diff:

  1. AMBIGUITY. Any recipe naming a dep without a namespace now has two
     candidates. Published `xim:fontconfig` says `expat@2.6.2`, so a PR that
     merely touched expat broke fontconfig AND graphics. Whether a PR passed
     depended on which OTHER packages it happened to touch.

  2. DOUBLE INSTALL FROM ONE EXTRACTION. `xim:libffi` arrives as another
     package's dependency and its hook MOVES the extracted tree into place.
     `local:libffi` then has no download artifact, so no extraction; the
     recipe's os.mv finds nothing, install() returns true, and xlings prints a
     tick over an EMPTY payload. The only complaint came from the config hook
     two steps later, about pkgconfig globs.

So the recipe is written OVER the published one, in place, in the same
namespace. Deleting the published copy instead was tried and is NOT
equivalent: it also leaves one candidate, but removes the `xim:` NAME, so every
self-qualifying dep (`xim:expat@2.6.2`, `xim:libffi@>=3.4`) stops resolving —
3 failures became 3 different failures. Overlay keeps the name.

libs/ is overlaid into the same index for the reason the local-index copy
already documents: a recipe imports `xim.pkgindex.*` from the index it was
loaded from, and a missing libs/ turns every helper call into a truthy no-op.
That blindness was described in this file and never fixed; the same job could
not execute selfcontain.seal at all.

Falls back to --add-xpkg when the index path is a symlink (a developer pointing
their home at the checkout) so the copy can never write into the source tree.

Validated against a CI-shaped home (index populated from origin/main, not the
working copy): 21 packages, 0 failures, including the four that were red.

* feat(mirrors): CN mirrors for the three new graphics packages

These shipped GLOBAL-only with a comment saying gtc could publish a release but
not create a GitCode project. That was wrong — `gtc repo create` exists. The
actual blocker was different and only shows up on an EMPTY project: tagging a
release fails with `main is not exist`, because there is no branch to target.
Pushing a README first is what makes the release possible.

Created xlings-res/{libXinerama,vulkan-headers,vulkan-loader} on GitCode,
pushed the same README the GitHub mirror carries, published the tag and
uploaded the payload.

Verified by DOWNLOADING each asset from the CN URL and comparing sha256 to the
artifact the recipe pins — not by checking that the URL exists. All three match
byte for byte.

* fix(ci): overlay only a recipe that already exists upstream

The previous commit overlaid every changed recipe into the index. That is right
for a change to a PUBLISHED package and wrong for one the PR ADDS: the package
is not in the index, so asking for it by its index name makes xlings say

    'xim:libXinerama' not in current index; refreshing index...

and the refresh re-fetches the whole index, overwriting the file just placed
there. The install then fails with `not found` — all three new packages died in
21 seconds.

Overlay now requires the published copy to exist. That is exactly the case that
produces the duplicate candidate, and the only case overlay can serve. A new
package keeps the --add-xpkg / local: path, where it is unambiguous anyway:
nothing published shares the name. This is also the namespace rule the index
already follows — new package referenced bare, changed published one with xim:.

Validated on the union of both failure sets — the 3 new packages that just
failed and the 4 that were red before: 7 tested, 0 failures.

---------

Co-authored-by: sunrisepeak <x.d2learn.org@gmail.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.

2 participants