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

@@ -102,6 +102,13 @@ jobs:
cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \
--no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state
# The capture backend itself lives in the scrap crate, so its unit tests are a separate
# package. `--lib` keeps this to unit tests; none of them touch a device or a display server.
- name: Run scrap crate tests with the drm feature
shell: bash
run: |
cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib
libdrmtap:
name: libdrmtap pin, build and .so contract
runs-on: ubuntu-24.04

View File

@@ -21,14 +21,24 @@ imported once and re-imports are elided), detiles/converts it to linear RGBA in
its own unprivileged address space, and feeds the encoder — so the root service
never loads libEGL/libGLESv2 and never copies scanout pixels. Only the **CPU
fallback path** (used when the seat/driver cannot produce a transferable DMA-BUF,
or the loaded `libdrmtap` predates the split export) copies the scanout to packed
BGRA inside the root service and streams those bytes over `_drm`. This mirrors
or the consumer has no render node of its own, see *When the CPU fallback is
chosen* below) copies the scanout to packed BGRA inside the root service and
streams those bytes over `_drm`. This mirrors
the Windows `portable_service` split (a privileged process captures, an
unprivileged one presents) but reuses RustDesk's own hardened IPC.
- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the
library or one of its runtime deps is missing the load fails cleanly and the
caller falls back to the PipeWire/portal path.
- The loader also **refuses a library that cannot do the split**: one reporting
below 0.4.9, and one reporting a newer version without actually exporting
`drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or
pre-release build). The only way to capture with such a library is the
in-process convert, which in the root service means loading the vendor GL stack
there, so it is refused and the caller falls back to PipeWire/portal. The
privileged process therefore never loads GL because of which file happened to
be on the load path; the CPU fallback below is entered only for a fact about
the seat or the consumer.
- The reader restricts the device it opens to a realpath under `/dev/dri/`
(`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode
(`helper_path` is `NULL`), so no privileged child process is ever spawned and
@@ -76,10 +86,9 @@ unprivileged one presents) but reuses RustDesk's own hardened IPC.
packed-BGRA bytes over the same authorized socket (no fd passing, no shared
memory).
- **When the CPU fallback is chosen.** The split path is the default; the
consumer asks the service for the CPU-converted frame in three cases: the
`.so` predates the split (no `drmtap_open_render` / `drmtap_convert_dmabuf`),
no render node can be opened for this seat, or a previous convert on this
display already failed. A fourth case is a **multi-GPU safety fallback**: if
consumer asks the service for the CPU-converted frame in two cases: no render
node can be opened for this seat, or a previous convert on this display
already failed. A third case is a **multi-GPU safety fallback**: if
the service could not name the render node of the GPU that exports the scanout
(an older `libdrmtap` without `drmtap_render_node`) and the host has more than
one render node, the consumer refuses to guess one, because importing a scanout

View File

@@ -272,13 +272,6 @@ impl DrmReader {
}
}
/// True if the loaded libdrmtap exposes the split-capture export entry point
/// (`drmtap_grab_desc`, libdrmtap >= 0.4.9). When false the caller must use
/// the CPU-mapped `grab()` path (an older `.so`).
pub fn supports_grab_desc(&self) -> bool {
self.lib.grab_desc.is_some()
}
/// Render node (`/dev/dri/renderD*`) of the GPU this reader captures from, to
/// hand to the unprivileged converter so it binds to the device that EXPORTS
/// the scanout. On a multi-GPU host the converter's own auto-selection can
@@ -327,14 +320,10 @@ impl DrmReader {
/// -> WouldBlock (retry); ENOTSUP -> a distinct `Unsupported` error (this
/// seat/driver produced pixels but no transferable dma-buf) so the caller
/// falls back to the mapped/PipeWire path instead of a per-frame rebuild loop;
/// any other errno -> hard error. Errors when `grab_desc` is unbound (old .so).
/// any other errno -> hard error. Always available: the loader refuses a
/// libdrmtap that does not export this symbol (see `drmtap_dl::abi_accepted`).
pub fn grab_desc(&mut self) -> io::Result<(OwnedFd, drmtap_dmabuf_desc)> {
let grab_desc = self.lib.grab_desc.ok_or_else(|| {
io::Error::new(
io::ErrorKind::Unsupported,
"libdrmtap too old: drmtap_grab_desc unavailable (need >= 0.4.9)",
)
})?;
let grab_desc = self.lib.grab_desc;
// SAFETY: self.ctx is a valid context; desc/frame are zeroed before the
// call and the frame is released on every return path (after the dup).
unsafe {

View File

@@ -53,23 +53,14 @@ impl RenderConverter {
/// of the GPU that exports the scanout (from the service's display list); `None` or
/// an empty/invalid path falls back to libdrmtap auto-selection. It opens no KMS
/// card, spawns no helper, and needs no elevated capability. Returns `None` when
/// libdrmtap is unavailable, the split convert symbols are missing (a pre-0.4.9
/// `.so`), or no render node could be opened (a locked-down seat with no
/// `/dev/dri/renderD*` access) — the caller then degrades to the service-side CPU
/// convert / PipeWire path. MUST be called on the thread that will later `convert()`
/// and drop it.
/// libdrmtap is unavailable or too old to carry the split convert symbols (the
/// loader refuses a pre-0.4.9 `.so` outright), or when no render node could be
/// opened (a locked-down seat with no `/dev/dri/renderD*` access) — the caller
/// then degrades to the service-side CPU convert / PipeWire path. MUST be called
/// on the thread that will later `convert()` and drop it.
pub fn open_render(node: Option<&str>) -> Option<RenderConverter> {
let lib = drmtap_dl::get()?;
// The converter needs BOTH split symbols; bail (so the caller degrades) if either
// is absent, rather than open a ctx we could never convert with.
let open_render = lib.open_render?;
if lib.convert_dmabuf.is_none() {
log::info!(
"libdrmtap exposes drmtap_open_render but not drmtap_convert_dmabuf; \
cannot convert dma-buf frames (old .so)"
);
return None;
}
let open_render = lib.open_render;
// The service names the render node of the GPU that EXPORTS the scanout, which
// is the only device guaranteed to understand its tiling modifier; auto-select
// (NULL) is the fallback when it cannot. Same /dev/dri gate the capture device
@@ -131,12 +122,8 @@ impl RenderConverter {
desc: &mut drmtap_dmabuf_desc,
received_fd: RawFd,
) -> io::Result<(&[u8], u32, u32, Pixfmt)> {
let convert_dmabuf = self.lib.convert_dmabuf.ok_or_else(|| {
io::Error::new(
io::ErrorKind::Unsupported,
"libdrmtap too old: drmtap_convert_dmabuf unavailable (need >= 0.4.9)",
)
})?;
// Always bound: a libdrmtap without the split convert symbols never loads.
let convert_dmabuf = self.lib.convert_dmabuf;
// Overwrite the descriptor's fd with the one THIS process received (split_capture.c
// does the same at recv time). -1 means "reuse the cached import for `fb_id`".
desc.dma_buf_fd = received_fd;

View File

@@ -148,7 +148,7 @@ type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info
type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info);
type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int;
type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info);
// Split-capture entry points (libdrmtap >= 0.4.9). Bound OPTIONALLY (see below).
// Split-capture entry points (libdrmtap >= 0.4.9). REQUIRED (see below).
// `grab_desc` runs on the privileged export side; `open_render`/`convert_dmabuf`
// on the unprivileged converter side.
type FnGrabDesc =
@@ -176,13 +176,15 @@ pub struct DrmtapLib {
pub frame_release: FnFrameRelease,
pub get_cursor: FnGetCursor,
pub cursor_release: FnCursorRelease,
// Split-capture symbols (present only on libdrmtap >= 0.4.9). `None` on an
// older .so; callers gate on `Some(..)` and fall back to the mapped path.
// Split-capture symbols (libdrmtap >= 0.4.9). Not optional: a library that
// cannot do the split is refused at load time (see `abi_accepted`), so these
// are plain pointers and the type system carries the guarantee that no
// caller can silently take an in-process-convert path instead.
// Root needs `grab_desc`; the unprivileged converter needs
// `open_render` + `convert_dmabuf`.
pub grab_desc: Option<FnGrabDesc>,
pub open_render: Option<FnOpenRender>,
pub convert_dmabuf: Option<FnConvertDmabuf>,
pub grab_desc: FnGrabDesc,
pub open_render: FnOpenRender,
pub convert_dmabuf: FnConvertDmabuf,
// libdrmtap >= 0.4.15; `None` on an older .so, where the converter keeps
// relying on `open_render(NULL)` auto-selection exactly as before.
pub render_node: Option<FnRenderNode>,
@@ -203,6 +205,33 @@ unsafe impl Sync for DrmtapLib {}
// mismatched layout. Minor/patch bumps are additive and remain compatible.
const DRMTAP_ABI_MAJOR: c_int = 0;
// Lowest (minor, patch) this build accepts. The project is still 0.x, so the
// major alone bounds nothing: every release it has ever made reports major 0,
// and comparing only that accepts a library from before the split existed.
//
// 0.4.9 is where `drmtap_grab_desc` / `drmtap_open_render` /
// `drmtap_convert_dmabuf` landed, i.e. the oldest library that can serve the
// architecture this code implements: the privileged process exports the scanout
// dma-buf and NEVER converts, so it never loads libEGL/libGLESv2. An older .so
// has none of those entry points, and the only way to capture with it is the
// in-process convert, in the ROOT service. That is precisely the property the
// split exists to remove, so treat such a library as unusable and fall back to
// PipeWire/portal rather than quietly pulling the vendor GL stack into the
// privileged process because a stale file happened to be on the load path.
//
// The mirrored `#[repr(C)]` layouts above are unchanged across 0.4.9..0.4.15
// (verified field by field against include/drmtap.h at both ends), so the floor
// costs no compatibility that was real.
const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (4, 9);
/// Whether a library reporting `major.minor.patch` may be loaded. Pure, so the
/// version rule is unit-testable without an .so to dlopen: the major must match
/// exactly (struct layouts track it) and (minor, patch) must be at or above the
/// floor that provides the split-capture API.
fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool {
major == DRMTAP_ABI_MAJOR && (minor, patch) >= DRMTAP_MIN_MINOR_PATCH
}
impl DrmtapLib {
fn load() -> Option<Self> {
// Absolute install path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and
@@ -240,11 +269,20 @@ impl DrmtapLib {
// definitions above. Resolving symbols alone would not catch that.
let v = version();
let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff);
if major != DRMTAP_ABI_MAJOR {
if !abi_accepted(major, minor, patch) {
let why = if major != DRMTAP_ABI_MAJOR {
"the struct layouts this build mirrors track the ABI major, so reading a \
frame descriptor through a mismatched one would mis-decode it"
} else {
"it predates the split-capture API, so its only capture path converts \
in-process, which in the root service means loading the GL stack there"
};
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
log::warn!(
"libdrmtap {name} reports ABI major {major} (v{major}.{minor}.{patch}), \
expected {DRMTAP_ABI_MAJOR}; refusing to load to avoid struct-layout \
mismatch (falling back to PipeWire/portal)"
"libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \
use (needs ABI major {DRMTAP_ABI_MAJOR}, at least \
v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \
falling back to PipeWire/portal."
);
return None;
}
@@ -257,14 +295,42 @@ impl DrmtapLib {
let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?;
let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?;
let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?;
// Split-capture symbols are bound OPTIONALLY (not through the `.ok()?`
// chain above): a pre-0.4.9 .so lacks them, and forcing them here would
// fail the WHOLE load and silently disable DRM. Each side checks the
// symbol it needs before taking the split path.
let grab_desc: Option<FnGrabDesc> = lib.get(b"drmtap_grab_desc").ok().map(|s| *s);
let open_render: Option<FnOpenRender> = lib.get(b"drmtap_open_render").ok().map(|s| *s);
let convert_dmabuf: Option<FnConvertDmabuf> =
// Split-capture symbols are required too. The version floor above already turns
// away the libraries that predate them; requiring the symbols as well covers what
// the floor cannot see, a library that REPORTS a new enough version without
// carrying the API. That is not hypothetical (see the stale-build note below), and
// it fails the same way: no split export means the only capture path left runs the
// convert in the root service. Both refusals disable DRM capture and fall back to
// PipeWire/portal, which is the outcome we want.
// Resolved as a group and reported by name rather than through a bare `?`, so the
// log says which symbols are absent instead of the generic "dlopen failed" line,
// which would send whoever reads it hunting for a missing file.
let grab: Option<FnGrabDesc> = lib.get(b"drmtap_grab_desc").ok().map(|s| *s);
let open_r: Option<FnOpenRender> = lib.get(b"drmtap_open_render").ok().map(|s| *s);
let conv: Option<FnConvertDmabuf> =
lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s);
let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) {
(Some(g), Some(o), Some(c)) => (g, o, c),
(grab, open_r, conv) => {
let mut missing = Vec::new();
if grab.is_none() {
missing.push("drmtap_grab_desc");
}
if open_r.is_none() {
missing.push("drmtap_open_render");
}
if conv.is_none() {
missing.push("drmtap_convert_dmabuf");
}
log::warn!(
"libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \
{}: it is a stale or pre-release build, not the version it claims. \
Refusing to load; falling back to PipeWire/portal.",
missing.join(", ")
);
return None;
}
};
let render_node: Option<FnRenderNode> =
lib.get(b"drmtap_render_node").ok().map(|s| *s);
// Log the load only now that every required symbol resolved: this function still returns
@@ -329,15 +395,59 @@ impl DrmtapLib {
static DRMTAP_LIB: OnceLock<Option<DrmtapLib>> = OnceLock::new();
/// Returns the loaded libdrmtap, or None if the .so (or one of its runtime deps)
/// is not present. Loaded once; a failure is remembered (no repeated dlopen).
/// is not present, or is too old to serve the split-capture architecture (see
/// `abi_accepted`). Loaded once; a failure is remembered (no repeated dlopen).
pub fn get() -> Option<&'static DrmtapLib> {
DRMTAP_LIB
.get_or_init(|| {
let lib = DrmtapLib::load();
if lib.is_none() {
log::info!("libdrmtap not available (dlopen failed); DRM capture disabled");
// Deliberately not "dlopen failed": the load also declines a library that opens
// fine but is too old or does not carry the split API, and each of those paths
// has already said so, with the file name, at warn level.
log::info!("libdrmtap not available or not usable; DRM capture disabled");
}
lib
})
.as_ref()
}
#[cfg(test)]
mod tests {
use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_MIN_MINOR_PATCH};
#[test]
fn abi_gate_rejects_a_library_from_before_the_split() {
// The releases that predate drmtap_grab_desc. Accepting any of these means the
// privileged service has no export-only path and converts in-process, which is
// the whole thing the split was built to prevent.
for (minor, patch) in [(3, 3), (4, 0), (4, 8)] {
assert!(
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} predates the split-capture API and must be refused"
);
}
}
#[test]
fn abi_gate_accepts_the_floor_and_every_release_above_it() {
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch));
// 0.4.15 is what the deb ships today; the later ones guard against a floor
// comparison that only ever looks at `patch` (0.5.0 must pass, 0.4.15 too).
for (minor, patch) in [(4, 15), (4, 200), (5, 0), (9, 9)] {
assert!(
abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is at or above the floor and must be accepted"
);
}
}
#[test]
fn abi_gate_rejects_another_major_in_both_directions() {
// The mirrored #[repr(C)] layouts track the major, so a newer one is as unsafe
// to read through as an older one, however high its minor.
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0));
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99));
}
}

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*"
}
);
}