Skip to content

feat(graphics): the discovery layer moves to the program, and WSL2 gets a GPU - #539

Closed
Sunrisepeak wants to merge 7 commits into
mainfrom
feat/graphics-experience-wsl2
Closed

feat(graphics): the discovery layer moves to the program, and WSL2 gets a GPU#539
Sunrisepeak wants to merge 7 commits into
mainfrom
feat/graphics-experience-wsl2

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

The 22-package graphics stack renders in a container with no /usr at all, and
until this commit nothing could reach it: no package in the index depended on
mesa, and the environment mesa needs -- which driver module to load, which GL
vendors exist -- existed only inside a subos use sub-shell. xlings install godot && godot in an ordinary terminal got the stack on RPATH and no way to
find a driver.

Surveyed six ecosystems first (Steam pressure-vessel, NixOS, nixGL,
nix-system-graphics, Snap gpu-2404, Flatpak, conda). Not one binds graphics
discovery to a shell. Nix wraps the program, Snap uses a command-chain wrapper,
Flatpak and pressure-vessel set it for the sandbox. And nixGL's README
independently documents the failure we already hit with LD_LIBRARY_PATH: env
vars propagate to every child, and system programs crash.

So the rule this commit applies: configuration binds to the NARROWEST scope
that works. Object (RPATH) < program (xvm.add{envs}) < shell (subos.env).
The stack used only the widest one; xvm.add{envs} has existed all along and
ten recipes use it.

libs/graphics.lua -- one table, two renderings

consumer_envs() for a program's shim (${XLINGS_DYNAMIC_SUBOS_DIR}-relative,
resolved at DISPATCH against the active subos) and declare_subos_env() for
the shell (${subosdir}), generated from one table so the two cannot disagree.

Values are subos-relative rather than ${pkgdir}: a payload path pins a
version directory, and the subos view is the stable indirection -- the role
/run/opengl-driver plays on NixOS. mesa now places lib/dri into
<subos>/usr/lib/dri, so it is complete by construction.

usr/lib/dri, not lib/dri, and that is not cosmetic: xlings only permits a
file asset whose destination starts with usr/etc/share, and a REJECTED
DESTINATION IS NOT AN ERROR -- the placement silently does not happen. A
lib/dri would have installed cleanly, pointed LIBGL_DRIVERS_PATH at nothing
and rendered on llvmpipe.

A1: one shared glvnd vendor directory

libglvnd scans __EGL_VENDOR_LIBRARY_DIRS in list order and sorts files WITHIN
each directory by name. So with mesa and nvidia-gl-host-link each contributing
their own payload directory, the 10_nvidia < 50_mesa convention meant
nothing, and NVIDIA-before-mesa held only because xlings sorts providers by
binding string and mesa@ sorts before nvidia-gl-host-link@. Correct by
alphabet. Both now place into one directory and declare that one, so the
filename decides -- as it does on the host.

Distributions went through this: before libglvnd they swapped the whole
libGL.so.1 with update-alternatives / eselect opengl, one vendor at a time.
Two directories is the halfway state.

A3 + A9: one command, five host shapes, no conditionals

graphics (0 payload) depends on mesa plus two sentinels. Each probes for a
userspace half it does not own and succeeds having linked nothing when it is
absent -- so depending on both is correct everywhere and the dependency graph
adapts by itself. That is what conda gets from __cuda/__glibc virtual
packages, without a solver feature.

wsl-gl-host-link is new, and WSL2 was a platform this stack silently did not
support: there is no DRM node for the GPU, it is reached only through mesa's
d3d12 gallium driver and /usr/lib/wsl/lib, so a subos on WSL2 rendered on
llvmpipe with no diagnostic.

It needs NO interposer, unlike the NVIDIA sentinel, and the reason is
structural: glvnd dlopens a vendor BY NAME, so libGLX_nvidia is its own
load-chain root with no RPATH of ours above it. libd3d12core.so is dlopened by
d3d12_dri.so, which is OUR file with OUR DT_RPATH -- verified on the shipped
payload, whose dri modules carry a full RPATH ending in the subos lib
directory. A symlink in the farm is enough.

It links the WHOLE set (R7: libd3d12core dlopens libdxcore, a Microsoft file
with no RPATH of ours), measures and records the borrowed set's glibc floor --
the line between "borrowing" and "chasing versions", and mcpp#352 is what the
wrong side of it looks like -- and declares GALLIUM_DRIVER=d3d12 only on WSL,
because mesa does not always select d3d12 by itself (microsoft/wslg#1332).

A8: four answers to "where is the host's ", three wrong

libs/hostlib.lua: ask ldconfig -p (the loader's own index, and the only thing
that knows /usr/lib/wsl/lib), filter by ELF class, first hit wins. There is no
layout to assume -- FHS biarch makes /usr/lib 32-bit, Debian declined that
clause so its /usr/lib is 64-bit, Arch is a third answer -- so a candidate list
is a decision to be wrong somewhere. That is mcpp#352.

nvidia-gl-host-link had it right; godot was right by ordering accident with no
ABI check; verify-host-link.sh pinned the Debian path and SKIPPED ITS OWN CHECK
4 IN SILENCE on every other distribution while still printing PASS. All now go
through hostlib. The new CI invariant found a fifth: libcuda-host-link, the
package the whole sentinel pattern came from.

The verifier now counts skips and reports them in the verdict, because "PASS: 12
checks" was compatible with two of them never having run.

A6: the driver pair, checked at run time

A distribution makes "kernel module and driver userspace are one version" an
install-time invariant and DKMS rebuilds on kernel upgrade. We do not own the
kernel module, so the same invariant can only be a run-time check: the sentinel
stamps /sys/module/nvidia/version and ships xlings-gl-doctor, which compares
it to the live one, lists dangling links and reports the interposer fraction.
The install log now carries the driver version, because 4/4 is only true for
the driver it was measured against.

Not a doctor rule: this repo already has three report/repair pairs that drifted
because the check and the fix live in different files.

A7: godot uses the stack

deps graphics, envs = graphics.consumer_envs(), and the host-directory probe
demoted to a fallback for a home without the stack -- kept, because removing it
breaks every existing install. The xpm.linux comment claiming a full
X11/mesa/wayland stack via xim is "infeasible" is corrected: it has been
falsified, and borrowing the host's Mesa is not merely unnecessary but the
failure mode, since it tracks the host's glibc forever (mcpp#352).

Tested: 779 static tests pass; libs/hostlib.lua has a plain-Lua behaviour
harness run against a FORGED BIARCH HOST (32-bit libGLX in /usr/lib, the real
one in /usr/lib64) -- the #352 shape -- 10/10, skipped rather than failed where
no lua interpreter exists.

Design: openxlings/xlings .agents/docs/2026-08-07-graphics-experience-industry-survey-and-plan.md
Refs: mcpp-community/mcpp#352

The generator used to model xvm / programs / archs in its core, which meant
every other ecosystem's pages carried them too. They are xlings concepts, so
they live in `.xpkgindex/plugins/xim.py` now.

- identity stays the short name: in xlings a descriptor's `namespace`
  (`config`, `xim`) is a classification label, and package references resolve
  as `[index:]name[@Version]` against the index repo — so joining it into the
  install command, as the mcpp side must, would produce commands the client
  rejects. Spelled out rather than left to the core default so a change there
  cannot silently rewrite 155 install commands
- a descriptor without a `namespace` is not un-namespaced: it resolves in the
  index's default namespace, so those 146 packages group under `xim` instead
  of a "—" bucket that does not exist
- facets from the fields this index actually populates: kind, category, status
- `card` listing layout, where the row leads with the copyable install command
- theme retoned (teal) so the two consumer sites are visually distinct
- stale links fixed: d2learn/* moved to openxlings/*
Same documents, one section name shared with the other consumer index. The
older `guides` config key still works, so this is a rename rather than a
migration.
Install xlings, install a package from this index, verify it runs — then
searching, listing and updating. Every command is taken from this repo's own
README rather than written from memory: notably each install is a single
package, which is the only form the repo documents.

Also: the About page's project link went to the website; the website stays
under the docs link and "project" now points at the repository, matching how
mcpp-index reads.
The index defaults to Chinese, so an English or Traditional visitor got a
Chinese title, a Chinese quick-start card and Chinese doc names. Site title
and lede, the install label, the card, the doc nav titles, the kind/category/
status axes and the Package/Credits/Keywords headings now carry zh / en /
zh-Hant.

Descriptor field names — programs, aliases, authors — stay as written, so a
package page and its .lua still read as the same document.

Also splits the two links that shared one entry: 'website' is xlings.d2learn.org
(globe icon), 'docs' now points at the documentation itself.
…ts a GPU

The 22-package graphics stack renders in a container with no /usr at all, and
until this commit nothing could reach it: no package in the index depended on
`mesa`, and the environment mesa needs -- which driver module to load, which GL
vendors exist -- existed only inside a `subos use` sub-shell. `xlings install
godot && godot` in an ordinary terminal got the stack on RPATH and no way to
find a driver.

Surveyed six ecosystems first (Steam pressure-vessel, NixOS, nixGL,
nix-system-graphics, Snap gpu-2404, Flatpak, conda). Not one binds graphics
discovery to a shell. Nix wraps the program, Snap uses a command-chain wrapper,
Flatpak and pressure-vessel set it for the sandbox. And nixGL's README
independently documents the failure we already hit with LD_LIBRARY_PATH: env
vars propagate to every child, and system programs crash.

So the rule this commit applies: configuration binds to the NARROWEST scope
that works. Object (RPATH) < program (`xvm.add{envs}`) < shell (`subos.env`).
The stack used only the widest one; `xvm.add{envs}` has existed all along and
ten recipes use it.

## libs/graphics.lua -- one table, two renderings

`consumer_envs()` for a program's shim (`${XLINGS_DYNAMIC_SUBOS_DIR}`-relative,
resolved at DISPATCH against the active subos) and `declare_subos_env()` for
the shell (`${subosdir}`), generated from one table so the two cannot disagree.

Values are subos-relative rather than `${pkgdir}`: a payload path pins a
version directory, and the subos view is the stable indirection -- the role
/run/opengl-driver plays on NixOS. `mesa` now places `lib/dri` into
`<subos>/usr/lib/dri`, so it is complete by construction.

`usr/lib/dri`, not `lib/dri`, and that is not cosmetic: xlings only permits a
file asset whose destination starts with usr/etc/share, and a REJECTED
DESTINATION IS NOT AN ERROR -- the placement silently does not happen. A
`lib/dri` would have installed cleanly, pointed LIBGL_DRIVERS_PATH at nothing
and rendered on llvmpipe.

## A1: one shared glvnd vendor directory

libglvnd scans `__EGL_VENDOR_LIBRARY_DIRS` in list order and sorts files WITHIN
each directory by name. So with mesa and nvidia-gl-host-link each contributing
their own payload directory, the `10_nvidia` < `50_mesa` convention meant
nothing, and NVIDIA-before-mesa held only because xlings sorts providers by
binding string and `mesa@` sorts before `nvidia-gl-host-link@`. Correct by
alphabet. Both now place into one directory and declare that one, so the
filename decides -- as it does on the host.

Distributions went through this: before libglvnd they swapped the whole
libGL.so.1 with update-alternatives / eselect opengl, one vendor at a time.
Two directories is the halfway state.

## A3 + A9: one command, five host shapes, no conditionals

`graphics` (0 payload) depends on mesa plus two sentinels. Each probes for a
userspace half it does not own and succeeds having linked nothing when it is
absent -- so depending on both is correct everywhere and the dependency graph
adapts by itself. That is what conda gets from `__cuda`/`__glibc` virtual
packages, without a solver feature.

`wsl-gl-host-link` is new, and WSL2 was a platform this stack silently did not
support: there is no DRM node for the GPU, it is reached only through mesa's
`d3d12` gallium driver and /usr/lib/wsl/lib, so a subos on WSL2 rendered on
llvmpipe with no diagnostic.

It needs NO interposer, unlike the NVIDIA sentinel, and the reason is
structural: glvnd dlopens a vendor BY NAME, so libGLX_nvidia is its own
load-chain root with no RPATH of ours above it. `libd3d12core.so` is dlopened by
`d3d12_dri.so`, which is OUR file with OUR DT_RPATH -- verified on the shipped
payload, whose dri modules carry a full RPATH ending in the subos lib
directory. A symlink in the farm is enough.

It links the WHOLE set (R7: libd3d12core dlopens libdxcore, a Microsoft file
with no RPATH of ours), measures and records the borrowed set's glibc floor --
the line between "borrowing" and "chasing versions", and mcpp#352 is what the
wrong side of it looks like -- and declares GALLIUM_DRIVER=d3d12 only on WSL,
because mesa does not always select d3d12 by itself (microsoft/wslg#1332).

## A8: four answers to "where is the host's <soname>", three wrong

libs/hostlib.lua: ask `ldconfig -p` (the loader's own index, and the only thing
that knows /usr/lib/wsl/lib), filter by ELF class, first hit wins. There is no
layout to assume -- FHS biarch makes /usr/lib 32-bit, Debian declined that
clause so its /usr/lib is 64-bit, Arch is a third answer -- so a candidate list
is a decision to be wrong somewhere. That is mcpp#352.

nvidia-gl-host-link had it right; godot was right by ordering accident with no
ABI check; verify-host-link.sh pinned the Debian path and SKIPPED ITS OWN CHECK
4 IN SILENCE on every other distribution while still printing PASS. All now go
through hostlib. The new CI invariant found a fifth: libcuda-host-link, the
package the whole sentinel pattern came from.

The verifier now counts skips and reports them in the verdict, because "PASS: 12
checks" was compatible with two of them never having run.

## A6: the driver pair, checked at run time

A distribution makes "kernel module and driver userspace are one version" an
install-time invariant and DKMS rebuilds on kernel upgrade. We do not own the
kernel module, so the same invariant can only be a run-time check: the sentinel
stamps /sys/module/nvidia/version and ships `xlings-gl-doctor`, which compares
it to the live one, lists dangling links and reports the interposer fraction.
The install log now carries the driver version, because `4/4` is only true for
the driver it was measured against.

Not a doctor rule: this repo already has three report/repair pairs that drifted
because the check and the fix live in different files.

## A7: godot uses the stack

deps `graphics`, `envs = graphics.consumer_envs()`, and the host-directory probe
demoted to a fallback for a home without the stack -- kept, because removing it
breaks every existing install. The `xpm.linux` comment claiming a full
X11/mesa/wayland stack via xim is "infeasible" is corrected: it has been
falsified, and borrowing the host's Mesa is not merely unnecessary but the
failure mode, since it tracks the host's glibc forever (mcpp#352).

Tested: 779 static tests pass; libs/hostlib.lua has a plain-Lua behaviour
harness run against a FORGED BIARCH HOST (32-bit libGLX in /usr/lib, the real
one in /usr/lib64) -- the #352 shape -- 10/10, skipped rather than failed where
no lua interpreter exists.

Design: openxlings/xlings .agents/docs/2026-08-07-graphics-experience-industry-survey-and-plan.md
Refs: mcpp-community/mcpp#352
Measured: `xim:mesa@>=25.0.7` and `@>=25.0.7.1` both resolve to
"package not found", while `@25.0.7.1` and a bare `xim:mesa` resolve. Every
other range in these recipes (`@>=0.1`, `@>=1.8`, `@>=2.39`) works, so the
resolver's comparison is fine and the four-component version is what it cannot
parse -- and mesa's fourth component is deliberately ours.

So the plan's "use lower bounds" is impossible for mesa specifically. Bare
namespaced name, with the measurement recorded at the call site: the failure
reads as a missing package rather than an unparseable constraint, which is the
wrong thing to send the next reader looking for.
…mething else

`S1: closure incomplete` is only a valid reading of a failure if the probe would
have SUCCEEDED in the same container with the host present. Measured 2026-08-07:
it does not.

The same subos-built probe renders `llvmpipe (LLVM 20.1.7)` with RESULT=ok
outside any container, and loads ZERO EGL vendors inside one -- identically with
no unshare, --unshare-net, --unshare-user and --unshare-all, and identically with
/usr, /etc and /lib bound. The vendor's DT_NEEDED closure resolves with 0
unresolved under our own loader, and the vendor JSON and .so are readable in the
container.

So the failure has nothing to do with the absence of /usr, which is the single
variable this test exists to isolate -- and the message was sending the reader to
look at the dependency closure.

Now the sealed run is preceded by a CONTROL: the identical container with the host
fully bound. If the control fails, the script exits 2 as INCONCLUSIVE and says
what that means, instead of running the sealed case and mis-attributing the same
failure. A test that reports a wrong cause is worse than one that reports
nothing, because someone acts on it.

Also names the mount-order trap in run_in_container, where the existing note
about it was three paragraphs away from the code: --tmpfs /tmp must precede the
home bind or a home under /tmp is shadowed and the binary 'does not exist'. My
first namespace bisect hit exactly that and produced no output for every variant,
which reads as a crash.

Verified: on this home the script now exits 2 with the INCONCLUSIVE explanation
rather than 1 with 'closure incomplete'.
@Sunrisepeak Sunrisepeak closed this Aug 6, 2026
@Sunrisepeak Sunrisepeak reopened this Aug 6, 2026
@Sunrisepeak

Copy link
Copy Markdown
Member Author

Reopening as a fresh PR: the pull_request event never reached Actions during the outage, and only that event makes changed-files diff against base. A workflow_dispatch run went green having installed zero packages — the exact #532 shape this workflow's header warns about.

@Sunrisepeak Sunrisepeak closed this Aug 6, 2026
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