drm: refuse a libdrmtap that cannot do the split export

The root --service must never load libEGL/libGLESv2: the point of the split is
that it exports the scanout dma-buf and the unprivileged --server converts. Two
paths could still break that, both because the loader accepted a library too
old to export.

drm_prewarm() called grab() when the loaded .so had no drmtap_grab_desc, and
grab() maps and detiles, so the privileged process pulled in the vendor GL stack
at startup, before any consumer had asked for a frame. The per-connection
capture loop then did the same for every frame, through the CPU fallback.

The version guard could not prevent it: it compared the ABI major only, and this
library is still 0.x, so every release it has ever made passed. Add a floor at
0.4.9, where the split entry points landed, and require the three split symbols,
which also rejects a build that reports a new enough version without carrying
them. That is not hypothetical: a pre-release stamped 0.4.15 shipped without the
multi-GPU accessors. Both refusals fall back to PipeWire/portal and say which
file and which symbols, at warn level.

The split symbols are no longer Options, so the type system carries the
guarantee instead of a convention. What is left of the CPU path is only what it
was meant to be: the consumer has no render node of its own, or the seat exports
no transferable dma-buf. Both are facts about the hardware, with no alternative
that keeps the stream, and neither is a property of which file was on the load
path.

Verified against the real library on i915. With 0.4.15 the export path captures
a tiled XR30 scanout and libEGL stays out of /proc/self/maps, while the old
grab() branch maps it, so the finding reproduces. A stub reporting 0.4.8 and a
stub reporting 0.4.15 without the split symbols are both refused, each with its
own diagnostic. The mirrored repr(C) layouts are unchanged across 0.4.9 to
0.4.15, checked field by field against include/drmtap.h at both ends, so the
floor costs no compatibility that was real.
This commit is contained in:
Mariano Abad
2026-07-28 08:28:53 -03:00
parent ec8e3a3caf
commit eec14a0592
7 changed files with 188 additions and 81 deletions

View File

@@ -497,7 +497,7 @@ pub enum Data {
/// Client -> service: begin streaming the chosen display.
#[cfg(all(target_os = "linux", feature = "drm"))]
// `need_cpu` is set by an unprivileged consumer that could not open a render-node convert context
// (drmtap_open_render failed, or an old .so lacks the split symbols). The service then streams the
// (drmtap_open_render failed, e.g. no /dev/dri/renderD* access). The service then streams the
// CPU-converted `DrmFrame` path for this connection instead of a dma-buf fd the consumer cannot
// detile, so a render-node-less seat still captures instead of losing the stream.
DrmStart { display: i32, need_cpu: bool },
@@ -512,7 +512,7 @@ pub enum Data {
#[cfg(all(target_os = "linux", feature = "drm"))]
DrmDisplaysChanged(Vec<DrmDisplayInfo>),
/// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`.
/// CPU-fallback path (old .so, no render node): pixels cross the wire.
/// CPU-fallback path (no render node, or no transferable dma-buf): pixels cross the wire.
#[cfg(all(target_os = "linux", feature = "drm"))]
DrmFrame { width: u32, height: u32 },
/// Service -> client: a zero-copy dma-buf frame descriptor. The scanout fd is NOT a field; when
@@ -1641,9 +1641,10 @@ enum DrmProducerMsg {
fd: Option<OwnedFd>,
},
/// A captured frame (CPU-mapped fallback path): a full packed-BGRA frame body. Used when the
/// loaded libdrmtap predates the split API (no `drmtap_grab_desc`) or the seat has no transferable
/// dma-buf (ENOTSUP). Forwarded as `Data::DrmFrame{width,height}` + `send_raw(BGRA)`, exactly like
/// the pre-split protocol, so an unprivileged converter is never required.
/// consumer has no render-node convert context (`need_cpu`) or the seat has no transferable
/// dma-buf (ENOTSUP) -- both hardware/seat facts, with no alternative that keeps the stream.
/// Forwarded as `Data::DrmFrame{width,height}` + `send_raw(BGRA)`, exactly like the pre-split
/// protocol, so an unprivileged converter is never required.
FrameCpu {
width: u32,
height: u32,
@@ -2067,15 +2068,14 @@ fn drm_prewarm() {
schedule_drm_cache_refresh();
match scrap::drm_reader::DrmReader::open(None, 0) {
Some(mut r) => {
// Warm the first framebuffer export. On the split path, grab_desc() exports a dma-buf fd
// WITHOUT loading libEGL/libGLESv2 into the root service (the convert now runs in the
// unprivileged --server); only an old .so (no grab_desc) still force-maps via grab().
if r.supports_grab_desc() {
if let Ok((fd, _desc)) = r.grab_desc() {
drop(fd); // close the warm-up fd; we only wanted to prime the device/import path
}
} else {
let _ = r.grab();
// Warm the first framebuffer export with grab_desc(), which exports a dma-buf fd WITHOUT
// loading libEGL/libGLESv2 into the root service (the convert runs in the unprivileged
// --server). Deliberately NOT grab(): that maps and detiles, so warming with it would
// pull the vendor GL stack into the privileged process on every start, before any
// consumer has even asked for a frame. A libdrmtap without grab_desc never loads (see
// drmtap_dl::abi_accepted), so there is no older-library branch to fall back to here.
if let Ok((fd, _desc)) = r.grab_desc() {
drop(fd); // close the warm-up fd; we only wanted to prime the device/import path
}
log::info!("drm: pre-warm framebuffer primed in {:?}", t.elapsed());
}
@@ -2548,11 +2548,14 @@ fn drm_capture_worker(
let conn_epoch = DRM_CONN_EPOCH.fetch_add(1, Ordering::Relaxed);
// Prefer the zero-copy split export (root does NO EGL / convert / copy). Fall back to the
// CPU-mapped path for this connection (pixels cross the wire) when: the loaded libdrmtap predates
// the split API, grab_desc later reports ENOTSUP (no transferable dma-buf on this seat), OR the
// consumer asked for the CPU path because it has no render-node convert context (need_cpu) — in
// that last case the dma-buf fd would be useless to it and the stream would be lost.
let mut use_dmabuf = reader.supports_grab_desc() && !need_cpu;
// CPU-mapped path for this connection (pixels cross the wire, and root pays the convert) only
// when the alternative is no stream at all: the consumer asked for it because it has no
// render-node convert context (need_cpu), so a dma-buf fd would be useless to it, or grab_desc
// later reports ENOTSUP (no transferable dma-buf on this seat). Both are facts about the seat or
// the consumer. A stale libdrmtap is NOT one of them: one too old for the split export is
// refused at load time, so this never demotes root to the in-process convert merely because of
// which file was on the load path.
let mut use_dmabuf = !need_cpu;
let mut last_cursor_id: u64 = 0;
let mut stalled: u32 = 0;

View File

@@ -394,7 +394,8 @@ async fn recv_thread(
// SUCCEEDS and yields corrupted pixels, so there is no convert error for the prefer-cpu bit above
// to learn from - the stream just looks broken. The node is empty when the service ran against a
// libdrmtap without `drmtap_render_node` (we dlopen by soname, so the runtime .so can be older
// than the one this was built against). Ask for the CPU path instead: the service converts on the
// than the one this was built against, anywhere in 0.4.9..0.4.14 -- below that it does not load
// at all). Ask for the CPU path instead: the service converts on the
// device it already has open, which is correct by construction. Single-render-node hosts (the
// common case) keep the dma-buf fast path untouched.
let ambiguous_gpu = render_node.is_empty() && render_node_count() > 1;
@@ -414,7 +415,8 @@ async fn recv_thread(
} else if force_cpu {
"a prior consumer convert failed, e.g. multi-GPU render-node mismatch"
} else {
"no render-node convert context: drmtap_open_render failed or old .so"
"no render-node convert context: libdrmtap did not load here, or \
drmtap_open_render found no usable /dev/dri/renderD*"
}
);
}