drm: convert each display on the GPU that exports it

The unprivileged converter opened its render context with
drmtap_open_render(NULL), letting libdrmtap auto-select. On a multi-GPU host
that can land on a different GPU than the one driving the display, and importing
a scanout across vendors can fail permanently on an incompatible tiling
modifier.

The service already knows the exporting device, so it now names its render node
(drmtap_render_node, libdrmtap 0.4.15) in each DrmDisplayInfo, and the consumer
opens the converter on that node. The field is serde(default) and empty means
auto-select, so a service and a server from mismatched builds still interoperate
and a pre-0.4.15 .so degrades to exactly the previous behaviour. The path is
realpath-gated to /dev/dri before it is opened, the same gate the capture device
gets, since it arrives over IPC. When the named node cannot be opened the
converter returns None and the existing need_cpu fallback runs the convert on the
exporting GPU service-side, which is the most correct place for it anyway.

Added a wire-compat test that a pre-render_node DrmDisplayInfo payload still
decodes (empty node) and a current one round-trips the node.
This commit is contained in:
Mariano Abad
2026-07-24 01:00:17 -03:00
parent bc02676ee7
commit 919f3b5097
5 changed files with 135 additions and 15 deletions

View File

@@ -545,6 +545,15 @@ pub struct DrmDisplayInfo {
pub width: u32,
pub height: u32,
pub active: bool,
/// Render node of the GPU that EXPORTS this display's scanout, so the
/// unprivileged converter binds to that device instead of auto-selecting one.
/// On a multi-GPU host auto-selection can land on a different GPU and the
/// cross-vendor import then fails on an incompatible tiling modifier. Empty
/// when the service cannot name it (a pre-0.4.15 libdrmtap, or a display-only
/// device with no render node), which keeps the previous auto-select
/// behaviour; `serde(default)` so an older peer's message still decodes.
#[serde(default)]
pub render_node: String,
}
/// Serializable metadata descriptor of a scanout dma-buf, shipped over `_drm` as the JSON payload of
@@ -1774,6 +1783,9 @@ static DRM_DISPLAY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic:
/// device outputs regardless of the reader's target CRTC, so a capture reader can refresh the cache.
#[cfg(all(target_os = "linux", feature = "drm"))]
fn drm_displays_from_reader(reader: &mut scrap::drm_reader::DrmReader) -> Vec<DrmDisplayInfo> {
// Every display this reader enumerates belongs to the reader's device, so they
// all share its render node. Resolved once here rather than per display.
let render_node = reader.render_node().unwrap_or_default();
reader
.displays()
.into_iter()
@@ -1796,6 +1808,7 @@ fn drm_displays_from_reader(reader: &mut scrap::drm_reader::DrmReader) -> Vec<Dr
width: d.width,
height: d.height,
active: d.active,
render_node: render_node.clone(),
})
.collect()
}
@@ -3728,6 +3741,36 @@ mod drm_conn_tests {
use hbb_common::tokio::{self, io::AsyncWriteExt};
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
// `render_node` was added to DrmDisplayInfo after the wire already existed, so a service
// and a server from different builds can disagree about it. `serde(default)` must keep an
// older peer's message decodable (empty node == "auto-select", the previous behaviour)
// rather than failing the whole DrmDisplayList and losing DRM capture.
#[test]
fn drm_display_info_decodes_without_render_node() {
let legacy = r#"{"name":"DP-1","crtc_id":386,"x":0,"y":0,
"width":3840,"height":2160,"active":true}"#;
let info: DrmDisplayInfo =
serde_json::from_str(legacy).expect("a pre-render_node payload must still decode");
assert_eq!(info.name, "DP-1");
assert_eq!(info.crtc_id, 386);
assert!(info.render_node.is_empty(), "missing node means auto-select");
// And a current payload round-trips the node.
let current = DrmDisplayInfo {
name: "DP-1".to_owned(),
crtc_id: 386,
x: 0,
y: 0,
width: 3840,
height: 2160,
active: true,
render_node: "/dev/dri/renderD129".to_owned(),
};
let wire = serde_json::to_vec(&current).unwrap();
let back: DrmDisplayInfo = serde_json::from_slice(&wire).unwrap();
assert_eq!(back, current);
}
// A blocking pipe as a probe fd: (read end, write end). Both are CLOEXEC-agnostic OwnedFds.
fn pipe() -> (OwnedFd, OwnedFd) {
let mut fds = [0 as libc::c_int; 2];

View File

@@ -295,11 +295,22 @@ async fn recv_thread(
// Skip opening the render-node converter entirely when this display previously failed to convert
// (multi-GPU render-node mismatch): request the CPU path so the service does the conversion on the
// exporting GPU. Otherwise open it normally and fall back to CPU only if no render node is usable.
// Bind the converter to the GPU that EXPORTS this display's scanout, which the service
// named in the display list. Auto-selection can land on a different GPU on a multi-GPU
// host, and importing a scanout across vendors can fail on an incompatible tiling
// modifier. Empty (an older service or a device with no render node) means auto-select,
// exactly as before. Every display of one device carries the same node, so a display
// index that does not resolve still gets the right answer from the first entry.
let force_cpu = drm_prefer_cpu(display);
let render_node = displays
.get(display.max(0) as usize)
.or_else(|| displays.first())
.map(|d| d.render_node.clone())
.unwrap_or_default();
let mut converter = if force_cpu {
None
} else {
RenderConverter::open_render()
RenderConverter::open_render(Some(render_node.as_str()))
};
let need_cpu = converter.is_none();
if need_cpu {