Skip to content

Commit 3e42e5a

Browse files
committed
fix(libgbm): make the backend repair invisible — stock <gbm.h> is the API
The first cut exposed `mcpp_gbm.h` and asked the consumer to call `mcpp_gbm_use_sibling_backends()` before creating a device. That changed the ordinary way to use libgbm, and it was not only a style problem: it does not work for the consumers that matter. libgbm is mostly called from INSIDE other libraries — SDL2's KMSDRM backend, wlroots, ffmpeg's VAAPI hwcontext all call gbm_create_device() from their own sources. None of them will ever call a helper of ours, so an opt-in repair leaves exactly those callers as broken as they were, while the package's own tests go green. The API is now stock `#include <gbm.h>` and nothing else. GBM_BACKENDS_PATH is wired from a CONSTRUCTOR in the package's own TU (priority 101, ahead of default-priority constructors in case one creates a device). An inherited value is still left alone — this is a default, not an override. That is reliable because a dependency's objects enter the consumer's link eagerly rather than being lazily selected; the emitted build.ninja names the object on the link line directly, so the constructor cannot be dropped. It is also what every other ecosystem does, and none of them use an API: distros (Debian libgbm1, Fedora mesa-libgbm) split libgbm out of the mesa SOURCE package so the compiled-in $libdir/gbm is right by construction; relocated and sandboxed stacks set the environment variable instead — Valve's pressure-vessel hit this exact bug when mesa 24.3 split the backends out (steam-runtime#797) and answers with GBM_BACKENDS_PATH, as do Nix, Conda and AppImage at activation time; and Mesa offers -Dgbm-backends-path= for packagers who control the build. This package is in the sandboxed case and cannot set a container-wide environment, so the constructor is the in-process equivalent. Longer term the wiring belongs in xim:mesa (build with -Dgbm-backends-path=, or declare lib/gbm/ into the view as it already declares lib and include), and then this package would carry no constructor at all; noted in the design doc. mcpp_gbm.h stays, demoted to optional introspection for diagnostics and for the tests, and says so in its own first line. Tests now come in two binaries, and the split is the regression guard for this very mistake: * tests/stock_usage.cpp includes STOCK <gbm.h> and nothing else — no mcpp_gbm.h, no helper declaration. If the repair ever goes back to being opt-in, this fails while the fuller gbm.cpp could still pass. * tests/gbm.cpp reads GBM_BACKENDS_PATH before calling anything at all, and re-execs itself with the variable preset to prove an inherited value survives the constructor — the only way to observe that rule, since by the time main runs the constructor is finished. Also drops the generated_files copy of the TU: install() is the only writer, and the parser takes literals only so the two could not share one source. Verified cold with the CI-pinned mcpp: 2 passed, 0 failed; both binaries confirmed failable by removing dri_gbm.so from the farm.
1 parent 865f2c0 commit 3e42e5a

7 files changed

Lines changed: 343 additions & 140 deletions

File tree

‎.agents/docs/2026-08-29-add-libgbm-plan.md‎

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,51 @@ MESA-LOADER: failed to open dri: /usr/lib/gbm/dri_gbm.so: cannot open shared obj
104104
Closing that is this package's real content, and it is why the shape is not a copy of
105105
`compat.glx-runtime`.
106106

107+
### The repair has to be invisible
108+
109+
The first version of this package exposed a header, `mcpp_gbm.h`, and asked the consumer to call
110+
`mcpp_gbm_use_sibling_backends()` before creating a device. That was wrong, and not merely
111+
stylistically: **libgbm is mostly called from inside other libraries.** SDL2's KMSDRM backend,
112+
wlroots and ffmpeg's VAAPI hwcontext all call `gbm_create_device()` out of their own sources, and
113+
none of them will ever call a helper of ours. A design that only works for callers who have read
114+
this descriptor leaves the important consumers exactly as broken as they were.
115+
116+
So the package exposes stock `gbm.h`, and the path is wired from a **constructor** in the
117+
package's own TU. Nothing has to be included, called, or known about.
118+
119+
What makes that reliable is a property of mcpp that is usually a nuisance: a dependency's objects
120+
enter the consumer's link *eagerly*, rather than being lazily selected the way an archive member
121+
would be. Confirmed in the emitted `build.ninja`, which names the object on the link line:
122+
123+
```
124+
build bin/gbm : cxx_link obj/gbm.o obj/compat_libgbm/mcpp_generated/gbm_backends.o
125+
```
126+
127+
so the constructor cannot be dropped. Priority 101 — the first value not reserved for the
128+
implementation — puts it ahead of default-priority constructors in case a consumer creates a
129+
device from one. An already-set `GBM_BACKENDS_PATH` is left alone: this is a default, not an
130+
override.
131+
132+
### This is what every other ecosystem does, and none of them use an API
133+
134+
| ecosystem | how the backend path is made right |
135+
|---|---|
136+
| Debian (`libgbm1`/`libgbm-dev`), Fedora (`mesa-libgbm`) | libgbm is a **binary package split out of the mesa source package**; one system-wide prefix makes Mesa's compiled-in `$libdir/gbm` correct by construction. Nothing to set. |
137+
| Valve pressure-vessel (Steam Runtime) | Hit exactly this bug when Mesa 24.3 split the backends out — [steam-runtime#797](https://github.com/ValveSoftware/steam-runtime/issues/797) — and answers with `GBM_BACKENDS_PATH=/run/host/usr/lib64/gbm` **in the container environment**. |
138+
| NixOS / Conda / AppImage | Same variable, set at environment-activation / wrapper level. |
139+
| Anyone controlling the build | Mesa's own [`-Dgbm-backends-path=`](https://cgit.freedesktop.org/mesa/mesa/commit/?id=7f615c66fbdd0a7aa7a513d011956dcc6c0ac2e6) meson option, added for precisely this. |
140+
141+
Every one of them is *environment* or *build-time*. None is "call our function". This package is
142+
in the sandboxed case and cannot set a container-wide environment, so the constructor is the
143+
in-process equivalent: same variable, same don't-override rule, scoped to processes that actually
144+
link libgbm.
145+
146+
**Where this really belongs.** The distro answer is the right one and it is one layer down:
147+
`xim:mesa` either building with `-Dgbm-backends-path=` pointing into the subos view, or declaring
148+
`lib/gbm/` into it the way it already declares `lib` and `include`. Then this package would carry
149+
no constructor at all. Worth filing against `xim:mesa`; until then the wiring lives here, where it
150+
is at least tested.
151+
107152
### How the path is found, without pinning anything
108153

109154
The farm is laid out so the backend directory is the sibling of the libgbm that actually loaded:
@@ -167,7 +212,21 @@ would come down to search order.
167212
## Test member
168213

169214
`tests/examples/libgbm`, linux-gated, no-op `main()` elsewhere (the `compat.libaio` /
170-
`compat.wil` pattern). Nineteen checks, all runnable with no GPU:
215+
`compat.wil` pattern). **Two binaries**, and the split is the point:
216+
217+
- `tests/stock_usage.cpp` includes **stock `<gbm.h>` and nothing else** — no `mcpp_gbm.h`, no
218+
helper declaration, no knowledge that this package exists. It is what a ported consumer looks
219+
like, and what a third-party library looks like from the inside. If the backend path ever
220+
regresses to something the application must opt into, this file fails while `gbm.cpp` could
221+
still pass. That asymmetry is why it exists.
222+
- `tests/gbm.cpp` covers the rest, including the optional introspection header.
223+
224+
`gbm.cpp`'s first assertion reads `GBM_BACKENDS_PATH` **before the program has called anything**,
225+
which is the direct check that the constructor did its job. Its last one re-execs the binary with
226+
the variable already set and asserts the child still sees the inherited value — the only way to
227+
observe the don't-override rule, since by the time `main` runs the constructor is finished.
228+
229+
All checks run with no GPU:
171230

172231
- `gbm_format_get_name` over four fourccs — `XRGB8888`→`XR24`, `ARGB8888`→`AR24`, `NV12`→`NV12`,
173232
`ABGR2101010`→`AB30`.
@@ -179,7 +238,8 @@ would come down to search order.
179238
- `gbm_create_device(-1) == nullptr`.
180239
- **Backend reachability**: the derived directory exists and holds at least one `*_gbm.so`. This is
181240
the assertion the package exists for and it needs no `/dev/dri`.
182-
- `GBM_BACKENDS_PATH` is set from the derived path, and an explicitly set one is left alone.
241+
- `GBM_BACKENDS_PATH` is already set on entry to `main`, and an inherited one survives (checked in
242+
a re-exec'd child).
183243

184244
Real device creation is opt-in behind `MCPP_RUN_GBM_DEVICE=1` plus a working `/dev/dri`, following
185245
`tests/examples/imgui-window`'s `MCPP_RUN_WINDOW=1`.
@@ -193,16 +253,26 @@ package's build-cache entry:
193253
Compiling compat.libgbm v2026.08.29
194254
Compiling gbm (test)
195255
Running bin/gbm
196-
… 19 checks …
256+
GBM_BACKENDS_PATH is set on entry to main (nothing called) ok
257+
…
258+
an inherited GBM_BACKENDS_PATH survives the constructor ok
197259
0 check(s) failed
198-
test result ok. 1 passed; 0 failed; finished in 23.61s
260+
stock_usage ... ok
261+
a <gbm.h>-only consumer inherits GBM_BACKENDS_PATH ok
262+
... and it is a directory ok
263+
... holding a backend libgbm can actually dlopen ok
264+
0 check(s) failed
265+
test result ok. 2 passed; 0 failed; finished in 45.69s
199266
```
200267

268+
Also verified outside mcpp, as an independent check of the mechanism: a consumer compiled against
269+
stock `gbm.h` and linked with no knowledge of the package prints the wired path before `main`.
270+
201271
- `mcpp xpkg parse pkgs/c/compat.libgbm.lua` → `parse OK` (no unknown mcpp-segment keys).
202272
- All eight lint gates reproduced locally (syntax, required fields, no leading `v`, mirror urls,
203273
package name, cross-package refs, platform parity, duplicate versions) → clean.
204274
- **Assertions confirmed failable**: moving `dri_gbm.so` out of the farm turns the reachability
205-
check to `FAILED` and the binary exits 1.
275+
check to `FAILED` and both binaries exit 1.
206276
- The linked binary is honest: `NEEDED libgbm.so.1` with the farm on `RPATH` and **no host path
207277
anywhere** on it.
208278
- CN mirror published at `gitcode.com/mcpp-res/libgbm@2026.08.29`, fetched back and confirmed

‎docs/descriptor-examples.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ in the [root README](../README.md#reference-examples).
3131
| C++ application framework compat (dependencies reuse packages already in the index) | [`compat.eui-neo`](../pkgs/e/compat.eui-neo.lua) (upstream's `3rd/` ships 8 vendored dependencies; none of them is compiled here — all are redirected to the same-version `compat.*` packages in this index) |
3232
| Mutually exclusive backends (one of several inside one package) | [`compat.eui-neo`](../pkgs/e/compat.eui-neo.lua): `vulkan` / `sdl2` each **replace** the default OpenGL / GLFW, and the default backend is expressed by *naming no feature at all* — there is no `opengl`/`glfw` feature. A `default` feature cannot express exclusivity: its own `defines`/`sources`/`deps` have no effect whatsoever, while its `implies` always applies and cannot be overridden by a named feature (which is, conversely, exactly the solution for the "always-on interface define" row below). The workable answer is to read the `-DMCPP_FEATURE_<NAME>` mcpp passes anyway and decide up front in a force-included header. Note also that `cflags` only reaches C TUs — C++ needs `cxxflags`, so a backend define written only into `cflags` never reaches any `.cpp` |
3333
| Host runtime adaptation (drivers are not vendored) | [`compat.glx-runtime`](../pkgs/c/compat.glx-runtime.lua) · [`compat.vulkan-runtime`](../pkgs/c/compat.vulkan-runtime.lua) (mcpp binaries run against a bundled glibc, so a bare-soname `dlopen` never reaches the host drivers; a symlink farm plus `runtime.library_dirs` bridges that. The farm holds only versioned sonames, so nothing there can shadow an index package. Note that `runtime.library_dirs` renders as `-Wl,-rpath` and **not** as `-L` — the `-L` key is `runtime.link_library_dirs`, which these two do not need because nothing links against their farms; see the row below for one that does) |
34-
| Ecosystem-stack binding (zero host) | [`compat.libgbm`](../pkgs/c/compat.libgbm.lua) (Mesa's GBM — buffer allocation out of a DRM device. The row above reaches the HOST; this one reaches the ECOSYSTEM and nothing else, and the distinction is the whole design. **Why not a source build**: libgbm is a build target *inside* Mesa, not a project — `src/gbm/meson.build` is `link_with: [libloader]`, and `libloader` wants `idep_mesautil`, the whole of Mesa's internal util library (~120 TUs plus Python-generated tables) for exactly one function, `loader_open_driver_lib`. Building it would make this index re-import libdrm + expat + xcb + a Mesa-util carve-out to duplicate what `xim:mesa` has already resolved hermetically. Contrast `compat.vulkan`, which *does* build the Khronos loader from source — Khronos releases that as a standalone project, Mesa releases no such thing for GBM. **Zero host, with no escape hatch**: unlike its two neighbours it has no `/usr/lib*` path and no `MCPP_HOST_*` override, because host libgbm is a leak the ecosystem already closed — `xim:nvidia-gl-host-link` names it directly ("the table … was missing libm, libdrm, **libgbm**, libgcc_s … all of which were therefore coming from the HOST, silently, which is the leak this package exists to close"). NVIDIA's own GBM backend, if ever needed, belongs in that host-link layer rather than here. The measured surface is 1 ecosystem package (`xim:mesa`, not `xim:graphics`'s twenty-two), zero index deps, and zero transitive burden — libgbm.so.1's own RUNPATH resolves entirely inside `xim-x-*`. **The part that is real work**: libgbm is a loader, and Mesa compiles `/usr/lib/gbm` in as its backend search path, which does not exist in the sandbox, so `gbm_create_device` finds nothing and no link check notices. `xim:mesa` declares `lib` into the view but `lib/gbm/` is a subdirectory and does not follow. So install() also harvests the backends, laid out as a **sibling** of the farm's libgbm, and a generated TU derives the path at runtime — `dlsym(RTLD_DEFAULT)` a gbm symbol, `dladdr` it, append `/gbm` — which reports the farm path rather than the realpath and therefore pins nothing; baking an absolute path into a generated header instead would fix the package to whichever mesa payload existed on install day. This is also the package that needs **both** directory keys: `library_dirs` for the RUNPATH and `link_library_dirs` for the `-L` that lets `-lgbm` resolve at all. Its test asserts the backend is *present at the derived path* rather than that it loads, which is checkable on a runner with no `/dev/dri` — and stays honest on a host where the stack's own mesa/glibc skew makes the dlopen fail) |
34+
| Ecosystem-stack binding (zero host) | [`compat.libgbm`](../pkgs/c/compat.libgbm.lua) (Mesa's GBM — buffer allocation out of a DRM device. The row above reaches the HOST; this one reaches the ECOSYSTEM and nothing else, and the distinction is the whole design. **Why not a source build**: libgbm is a build target *inside* Mesa, not a project — `src/gbm/meson.build` is `link_with: [libloader]`, and `libloader` wants `idep_mesautil`, the whole of Mesa's internal util library (~120 TUs plus Python-generated tables) for exactly one function, `loader_open_driver_lib`. Building it would make this index re-import libdrm + expat + xcb + a Mesa-util carve-out to duplicate what `xim:mesa` has already resolved hermetically. Contrast `compat.vulkan`, which *does* build the Khronos loader from source — Khronos releases that as a standalone project, Mesa releases no such thing for GBM. **Zero host, with no escape hatch**: unlike its two neighbours it has no `/usr/lib*` path and no `MCPP_HOST_*` override, because host libgbm is a leak the ecosystem already closed — `xim:nvidia-gl-host-link` names it directly ("the table … was missing libm, libdrm, **libgbm**, libgcc_s … all of which were therefore coming from the HOST, silently, which is the leak this package exists to close"). NVIDIA's own GBM backend, if ever needed, belongs in that host-link layer rather than here. The measured surface is 1 ecosystem package (`xim:mesa`, not `xim:graphics`'s twenty-two), zero index deps, and zero transitive burden — libgbm.so.1's own RUNPATH resolves entirely inside `xim-x-*`. **The part that is real work**: libgbm is a loader, and Mesa compiles `/usr/lib/gbm` in as its backend search path, which does not exist in the sandbox, so `gbm_create_device` finds nothing and no link check notices. `xim:mesa` declares `lib` into the view but `lib/gbm/` is a subdirectory and does not follow. So install() also harvests the backends, laid out as a **sibling** of the farm's libgbm, and a generated TU wires `GBM_BACKENDS_PATH` from a **constructor** — `dlsym(RTLD_DEFAULT)` a gbm symbol, `dladdr` it, append `/gbm` — which reports the farm path rather than the realpath and therefore pins nothing (baking an absolute path into a generated header instead would fix the package to whichever mesa payload existed on install day). **The repair has to be invisible, and that is the load-bearing design decision**: the API stays stock `#include <gbm.h>`, because libgbm is mostly called from *inside* other libraries (SDL2's KMSDRM backend, wlroots, ffmpeg's VAAPI hwcontext) which will never call a helper of ours — an opt-in fix would leave exactly those consumers broken. It works because mcpp links a dependency's objects eagerly, so the constructor cannot be dropped. This is also what every other ecosystem does: distros split `libgbm1` out of the mesa source package and rely on the compiled-in `$libdir/gbm`, while relocated stacks set the environment variable (Valve's pressure-vessel hit this exact bug in steam-runtime#797; Nix and Conda do it at activation) and Mesa offers `-Dgbm-backends-path=` for packagers who control the build — never an API. Longer term this belongs in `xim:mesa` itself. This is also the package that needs **both** directory keys: `library_dirs` for the RUNPATH and `link_library_dirs` for the `-L` that lets `-lgbm` resolve at all. It ships **two** test binaries, and the split is the point: `stock_usage.cpp` includes stock `<gbm.h>` and nothing else, so a regression back to an opt-in fix fails there while the fuller `gbm.cpp` could still pass. Both assert the backend is *present at the derived path* rather than that it loads, which is checkable on a runner with no `/dev/dri` — and stays honest on a host where the stack's own mesa/glibc skew makes the dlopen fail) |
3535
| Always-on interface define | `CURL_STATICLIB` in [`compat.curl`](../pkgs/c/compat.curl.lua): `cflags` is always on but package-private, while a feature's `defines` reaches consumers yet has to be named — `default = { implies = … }` applies unconditionally and happens to give both |
3636
| Multiple majors in one package (shape switches with the version) | [`compat.catch2`](../pkgs/c/compat.catch2.lua) (3.x compiles `src/catch2/` into a static library; 2.x goes header-only through `single_include/`) |
3737
| External build system (`install()` builds from source) | [`compat.openblas`](../pkgs/c/compat.openblas.lua) (Make) · [`compat.openssl`](../pkgs/c/compat.openssl.lua) (Perl Configure + Make, static libssl/libcrypto) |

0 commit comments

Comments
 (0)