drm: the polish list, and a correction to my own ABI floor

The version floor I added two commits ago was one release too low.
drmtap_open_render and drmtap_convert_dmabuf are 0.4.9, but drmtap_grab_desc is
0.4.10, so a genuine 0.4.9 library passed the version gate and was then refused
by the symbol gate with a message that called it a stale or pre-release build,
which it is not. The floor is 0.4.10 now, the release where the whole split API
exists, and the test lists 0.4.9 among the rejected versions with the reason.

ExportLedger is deleted. DRM_FD_ELISION was false, so should_send_fd returned
true at its first branch and about sixty lines of eviction and epoch machinery
were unreachable, untested, in a security sensitive file. Why it was disabled
is worth keeping, so here it is: eliding the fd on an fb_id the converter has
already imported looks free, but the kernel can recycle an fb_id onto a
different buffer with identical geometry and modifier, and the exporter cannot
see the dma-buf inode that would tell the difference, so the elision can serve
a stale EGLImage. Sending it is cheap, the converter imports once per buffer and
closes the surplus fd, and libdrmtap's own cache keys on fb_id AND inode and can
only re-import when it is handed a real fd. That reasoning now lives here
instead of in dead code.

The rest:

- num_planes is clamped on the consumer before it reaches the C descriptor. The
  producer normalizes it and must be root, so this is only defense in depth, but
  the wire is the one place the value arrives from another process.
- warm_availability returns early on X11. Nothing there can consume a DRM
  stream, and probing makes the ROOT service open DRM readers, so an X11 host
  running a drm build was paying that at every startup for a path it can never
  take.
- drm_cursor_id no longer clones the cursor. The cursor service polls it at
  frame cadence to compare eight bytes, and a 256x256 cursor is 256 KiB.
- The premultiplied ARGB pass-through is now documented as matching the XFixes
  path, since that is why it is correct rather than an oversight.
- cfg hygiene: input_service.rs uses all(target_os = "linux", feature = "drm")
  like every other site, and active_uid_cached is gated with the feature too,
  which also removes a dead-code warning from drm-off Linux builds.
- Nits: DrmConn is pub(crate) like its constructors, new_drm_listener is no
  longer async with nothing to await, and the two anyhow! plus return Err pairs
  are bail! as the codebase writes them.
- DRM_CAPTURE_SECURITY.md moves to docs/ with the other docs, and its "no
  privileged child process is ever spawned" claim is corrected: an empty
  helper_path is not a disable switch in the C, find_helper searches six fixed
  paths and would exec one if the direct export ever failed. It is unreachable
  here for two independent reasons, the root service holds CAP_SYS_ADMIN so the
  direct path succeeds and the package builds no helper at all, and the paths
  are root-writable only, so the accurate statement is that this package never
  installs one, not that it can never happen.
- The comments that narrated the review rather than the code are rewritten to
  say what the code does. One of them had also drifted: the convert context is
  opened before we answer with DrmStart, not before the handshake.

Both configs build with no new warnings, 100 tests pass.
This commit is contained in:
Mariano Abad
2026-07-28 09:06:11 -03:00
parent 4449c1c2e3
commit 58ff3f9d2b
10 changed files with 111 additions and 161 deletions

View File

@@ -31,7 +31,7 @@ unprivileged one presents) but reuses RustDesk's own hardened IPC.
library or one of its runtime deps is missing the load fails cleanly and the library or one of its runtime deps is missing the load fails cleanly and the
caller falls back to the PipeWire/portal path. caller falls back to the PipeWire/portal path.
- The loader also **refuses a library that cannot do the split**: one reporting - 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 below 0.4.10, and one reporting a newer version without actually exporting
`drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or `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 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 in-process convert, which in the root service means loading the vendor GL stack
@@ -41,9 +41,21 @@ unprivileged one presents) but reuses RustDesk's own hardened IPC.
the seat or the consumer. the seat or the consumer.
- The reader restricts the device it opens to a realpath under `/dev/dri/` - 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 (`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 (`helper_path` is `NULL`). **No `drmtap-helper` binary is built, shipped, or
none is built, shipped, or installed. There is no `drmtap-helper` binary, no installed by this package**: there is no `setcap`, no capability-bearing file,
`setcap`, no capability-bearing file, and no capture group in this deployment. and no capture group in this deployment. Being precise about what that does
and does not guarantee: an empty `helper_path` is not by itself a "helper
disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six
hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the
directory this package installs into, and `fork`/`exec`s the first executable
it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is
unreachable for two independent reasons: the root service holds
`CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the
shared library, so no helper exists at any of those paths. They are all
root-writable-only, so a helper appearing there would not be an escalation
either, but the honest statement is "a privileged child is spawned only if a
helper binary exists at one of those fixed root-owned paths, and this package
never installs one", not "never".
- The `_drm` socket lives beside the hardened `_service` socket - The `_drm` socket lives beside the hardened `_service` socket
(`/tmp/<app>-service/ipc_drm`). It is `0666` so the unprivileged `--server` (`/tmp/<app>-service/ipc_drm`). It is `0666` so the unprivileged `--server`
can connect, but every accepted peer is authorized in `handle_drm_conn` can connect, but every accepted peer is authorized in `handle_drm_conn`

View File

@@ -170,8 +170,9 @@ impl DrmReader {
/// the internal buffer. Returns (width, height). The returned slice is valid /// the internal buffer. Returns (width, height). The returned slice is valid
/// until the next grab. A non-32bpp scanout, an oversized/degenerate /// until the next grab. A non-32bpp scanout, an oversized/degenerate
/// geometry, or a stride < w*4 is rejected with a hard error so the caller /// geometry, or a stride < w*4 is rejected with a hard error so the caller
/// falls back to PipeWire (see the codex format finding). Errno failures map /// falls back to PipeWire rather than encoding whatever the bytes happen to
/// to WouldBlock (retry) or a hard error (tear down) as in the old path. /// mean. Errno failures map to WouldBlock (retry) or a hard error (tear
/// down) as in the old path.
pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> { pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> {
// SAFETY: self.ctx is a valid context; frame is zeroed before the call // SAFETY: self.ctx is a valid context; frame is zeroed before the call
// and released on every path. // and released on every path.

View File

@@ -10,7 +10,8 @@
// import-once EGLImage cache it holds are THREAD-LOCAL inside libdrmtap: the // import-once EGLImage cache it holds are THREAD-LOCAL inside libdrmtap: the
// context MUST be created, used (`convert`), and closed (`drop`) on the SAME // context MUST be created, used (`convert`), and closed (`drop`) on the SAME
// thread (the consumer's `recv_thread`). Dropping it off-thread would strand the // thread (the consumer's `recv_thread`). Dropping it off-thread would strand the
// cached EGLImages — the exact leak class behind the 0.4.8 OOM regression. The raw // cached EGLImages, which leaks a GPU context per capture session until the process is out of
// memory. The raw
// ctx pointer makes `RenderConverter` !Send/!Sync, which enforces that at the type // ctx pointer makes `RenderConverter` !Send/!Sync, which enforces that at the type
// level. // level.
@@ -54,7 +55,7 @@ impl RenderConverter {
/// an empty/invalid path falls back to libdrmtap auto-selection. It opens no KMS /// 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 /// card, spawns no helper, and needs no elevated capability. Returns `None` when
/// libdrmtap is unavailable or too old to carry the split convert symbols (the /// 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 /// loader refuses anything below 0.4.10 outright), or when no render node could be
/// opened (a locked-down seat with no `/dev/dri/renderD*` access) — the caller /// 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 /// then degrades to the service-side CPU convert / PipeWire path. MUST be called
/// on the thread that will later `convert()` and drop it. /// on the thread that will later `convert()` and drop it.

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 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 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); type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info);
// Split-capture entry points (libdrmtap >= 0.4.9). REQUIRED (see below). // Split-capture entry points (libdrmtap >= 0.4.10). REQUIRED (see below).
// `grab_desc` runs on the privileged export side; `open_render`/`convert_dmabuf` // `grab_desc` runs on the privileged export side; `open_render`/`convert_dmabuf`
// on the unprivileged converter side. // on the unprivileged converter side.
type FnGrabDesc = type FnGrabDesc =
@@ -176,7 +176,7 @@ pub struct DrmtapLib {
pub frame_release: FnFrameRelease, pub frame_release: FnFrameRelease,
pub get_cursor: FnGetCursor, pub get_cursor: FnGetCursor,
pub cursor_release: FnCursorRelease, pub cursor_release: FnCursorRelease,
// Split-capture symbols (libdrmtap >= 0.4.9). Not optional: a library that // Split-capture symbols (libdrmtap >= 0.4.10). Not optional: a library that
// cannot do the split is refused at load time (see `abi_accepted`), so these // 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 // are plain pointers and the type system carries the guarantee that no
// caller can silently take an in-process-convert path instead. // caller can silently take an in-process-convert path instead.
@@ -209,20 +209,20 @@ const DRMTAP_ABI_MAJOR: c_int = 0;
// major alone bounds nothing: every release it has ever made reports major 0, // 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. // and comparing only that accepts a library from before the split existed.
// //
// 0.4.9 is where `drmtap_grab_desc` / `drmtap_open_render` / // 0.4.10 is the oldest release with the WHOLE split API: `drmtap_open_render`
// `drmtap_convert_dmabuf` landed, i.e. the oldest library that can serve the // and `drmtap_convert_dmabuf` arrived in 0.4.9, `drmtap_grab_desc` in 0.4.10.
// architecture this code implements: the privileged process exports the scanout // That is the oldest library that can serve the architecture this code
// dma-buf and NEVER converts, so it never loads libEGL/libGLESv2. An older .so // implements, where the privileged process exports the scanout dma-buf and NEVER
// has none of those entry points, and the only way to capture with it is the // converts, so it never loads libEGL/libGLESv2. Below it the only way to capture
// in-process convert, in the ROOT service. That is precisely the property the // is the in-process convert, in the ROOT service, which is precisely the
// split exists to remove, so treat such a library as unusable and fall back to // property the split exists to remove: treat such a library as unusable and fall
// PipeWire/portal rather than quietly pulling the vendor GL stack into the // back to PipeWire/portal rather than quietly pull the vendor GL stack into the
// privileged process because a stale file happened to be on the load path. // 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 // 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 // (verified field by field against include/drmtap.h at both ends), so the floor
// costs no compatibility that was real. // costs no compatibility that was real.
const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (4, 9); const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (4, 10);
/// Whether a library reporting `major.minor.patch` may be loaded. Pure, so the /// 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 /// version rule is unit-testable without an .so to dlopen: the major must match
@@ -420,8 +420,10 @@ mod tests {
fn abi_gate_rejects_a_library_from_before_the_split() { fn abi_gate_rejects_a_library_from_before_the_split() {
// The releases that predate drmtap_grab_desc. Accepting any of these means the // 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 // privileged service has no export-only path and converts in-process, which is
// the whole thing the split was built to prevent. // the whole thing the split was built to prevent. 0.4.9 is in the list on
for (minor, patch) in [(3, 3), (4, 0), (4, 8)] { // purpose: it introduced the convert half of the split but not the export half,
// so it cannot serve the privileged side either.
for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] {
assert!( assert!(
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} predates the split-capture API and must be refused" "v0.{minor}.{patch} predates the split-capture API and must be refused"

View File

@@ -13,7 +13,9 @@ mod ipc_drm;
// `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two // `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two
// payload types. // payload types.
#[cfg(all(target_os = "linux", feature = "drm"))] #[cfg(all(target_os = "linux", feature = "drm"))]
pub use ipc_drm::{start_drm, DmabufDesc, DrmConn, DrmDisplayInfo}; pub use ipc_drm::{start_drm, DmabufDesc, DrmDisplayInfo};
#[cfg(all(target_os = "linux", feature = "drm"))]
pub(crate) use ipc_drm::DrmConn;
#[cfg(all(target_os = "linux", feature = "drm"))] #[cfg(all(target_os = "linux", feature = "drm"))]
pub(crate) use ipc_drm::connect_drm; pub(crate) use ipc_drm::connect_drm;

View File

@@ -211,7 +211,9 @@ pub(crate) fn active_uid() -> Option<u32> {
/// The active session uid read ONLY from the service-loop cache, never from a fresh (blocking) seat0 /// The active session uid read ONLY from the service-loop cache, never from a fresh (blocking) seat0
/// lookup. `None` on a cache miss. For hot, latency-sensitive, fail-closed re-auth on an async runtime /// lookup. `None` on a cache miss. For hot, latency-sensitive, fail-closed re-auth on an async runtime
/// thread (the `_drm` per-frame re-auth), where a blocking `loginctl` per frame would stall the stream. /// thread (the `_drm` per-frame re-auth), where a blocking `loginctl` per frame would stall the stream.
#[cfg(target_os = "linux")] // Gated with the feature, not just the OS: the `_drm` per-frame re-auth is its only caller, so a
// drm-off Linux build would carry it as dead code and warn about it.
#[cfg(all(target_os = "linux", feature = "drm"))]
#[inline] #[inline]
pub(crate) fn active_uid_cached() -> Option<u32> { pub(crate) fn active_uid_cached() -> Option<u32> {
crate::platform::linux::get_active_userid_cached() crate::platform::linux::get_active_userid_cached()

View File

@@ -106,7 +106,7 @@ pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType<DrmConn> {
// The producer MUST be root. DRM/KMS scanout export is a root-service capability, and the DRM // The producer MUST be root. DRM/KMS scanout export is a root-service capability, and the DRM
// path outranks PipeWire (an available DRM stream suppresses the portal consent prompt), so a // path outranks PipeWire (an available DRM stream suppresses the portal consent prompt), so a
// non-root peer that won a socket-path race must not be trusted to supply the display list, // non-root peer that won a socket-path race must not be trusted to supply the display list,
// frames and an arbitrary dma-buf fd (review 4.1). The producer direction is authorized in // frames and an arbitrary dma-buf fd. The producer direction is authorized in
// handle_drm_conn; this closes the same gap on the consumer direction. // handle_drm_conn; this closes the same gap on the consumer direction.
if peer_uid_from_fd(stream.as_raw_fd()) != Some(0) { if peer_uid_from_fd(stream.as_raw_fd()) != Some(0) {
bail!("drm: _drm producer is not root; refusing to consume"); bail!("drm: _drm producer is not root; refusing to consume");
@@ -119,7 +119,7 @@ pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType<DrmConn> {
/// drm-off build needs no hbb_common change. The socket is 0666 (world-connectable) so the /// drm-off build needs no hbb_common change. The socket is 0666 (world-connectable) so the
/// unprivileged `--server` can reach it; every accepted peer is still authorized in /// unprivileged `--server` can reach it; every accepted peer is still authorized in
/// `handle_drm_conn` (root or the active session uid + exe identity), so connectable != authorized. /// `handle_drm_conn` (root or the active session uid + exe identity), so connectable != authorized.
async fn new_drm_listener() -> ResultType<Incoming> { fn new_drm_listener() -> ResultType<Incoming> {
let path = drm_ipc_path(); let path = drm_ipc_path();
// Ensure the shared service dir exists at its hardened (0711) mode. Passing the `_service` // Ensure the shared service dir exists at its hardened (0711) mode. Passing the `_service`
// postfix reuses hbb_common's expected mode for that directory; it only creates/chmods the // postfix reuses hbb_common's expected mode for that directory; it only creates/chmods the
@@ -144,10 +144,8 @@ enum DrmProducerMsg {
/// Enumerated displays, sent once before any frame so the task can answer the handshake. /// Enumerated displays, sent once before any frame so the task can answer the handshake.
Displays(Vec<DrmDisplayInfo>), Displays(Vec<DrmDisplayInfo>),
/// A captured frame (split/zero-copy path): the serializable dma-buf descriptor plus the (owned) /// A captured frame (split/zero-copy path): the serializable dma-buf descriptor plus the (owned)
/// scanout fd to hand to the peer via SCM_RIGHTS. The worker always produces a real `fd` here; the /// scanout fd to hand to the peer via SCM_RIGHTS. The `OwnedFd` is closed once the send has dup'd
/// async task's `ExportLedger` decides whether to actually attach it (`desc.has_fd`) or elide it as /// it into the peer.
/// an import-once cache hit. The `OwnedFd` is closed once the send has dup'd it into the peer (or
/// immediately, when elided).
Frame { Frame {
desc: DmabufDesc, desc: DmabufDesc,
fd: Option<OwnedFd>, fd: Option<OwnedFd>,
@@ -182,85 +180,6 @@ impl Drop for DrmStopGuard {
} }
} }
/// Producer-side fd-elision ledger (root `--service`, one per `_drm` connection). Decides, per
/// exported frame, whether the scanout dma-buf fd must ride an SCM_RIGHTS cmsg (`has_fd = true`) or
/// can be elided as an import-once cache hit (`has_fd = false`) because the peer's converter already
/// imported that `fb_id`. Keyed by `fb_id -> (modifier, dims)`; a change in any of those (a resize,
/// a modifier/tiling change, or a recycled fb_id that also changed geometry) forces a real fd, and a
/// modeset/hotplug that invalidates the CRTC ends the connection (so a reconnect starts with a fresh,
/// empty ledger — matching the peer's fresh, empty converter cache).
///
/// SAFETY / CORRECTNESS: eliding relies solely on `(fb_id, modifier, dims)` uniquely identifying a
/// buffer, but the kernel can recycle an `fb_id` onto a *different* buffer with identical geometry
/// and modifier; eliding then would serve a stale EGLImage. libdrmtap's own import cache keys on
/// `fb_id + dma-buf inode` and can re-import ONLY when it is handed a real fd. Because always sending
/// the fd is cheap (the converter still imports once per `fb_id` and closes the surplus fd) and is
/// strictly safe, `DRM_FD_ELISION` defaults to `false` for v1 (always send). The ledger's `epoch`
/// tracks `DRM_DISPLAY_GENERATION` (bumped by the udev listener on a connector-topology change), so a
/// hotplug/modeset invalidates every cached buffer and forces a real fd; but the ledger still cannot
/// see the dma-buf inode, so a recycled fb_id within the SAME generation (identical geometry +
/// modifier) would elide onto a stale EGLImage. Enabling elision needs that inode case validated
/// first.
const DRM_FD_ELISION: bool = false;
struct SeenBuf {
modifier: u64,
dims: (u32, u32),
epoch: u64,
}
struct ExportLedger {
seen: HashMap<u32, SeenBuf>,
order: std::collections::VecDeque<u32>, // insertion order, for evict-oldest
epoch: u64,
}
impl ExportLedger {
// Grow-once, hard-capped (preallocated model): a hostile/buggy peer or a fb_id churn cannot grow
// this unbounded; oldest keys are evicted so a real fd is simply re-sent for them later.
const MAX_LEDGER: usize = 32;
fn new() -> Self {
Self {
seen: HashMap::new(),
order: std::collections::VecDeque::new(),
epoch: 0,
}
}
/// Returns true if this frame's fd must be attached (new/changed/recycled buffer, caching
/// disabled, or elision off), false if the converter already holds `fb_id` imported.
fn should_send_fd(&mut self, desc: &DmabufDesc) -> bool {
// fb_id == 0 disables caching for that frame; elision-off always sends.
if !DRM_FD_ELISION || desc.fb_id == 0 {
return true;
}
let ident = SeenBuf {
modifier: desc.modifier,
dims: (desc.width, desc.height),
epoch: self.epoch,
};
if let Some(prev) = self.seen.get(&desc.fb_id) {
if prev.modifier == ident.modifier
&& prev.dims == ident.dims
&& prev.epoch == ident.epoch
{
return false; // import-once cache hit: elide the fd
}
} else {
// New key: record insertion order and evict the oldest if at capacity.
if self.order.len() >= Self::MAX_LEDGER {
if let Some(old) = self.order.pop_front() {
self.seen.remove(&old);
}
}
self.order.push_back(desc.fb_id);
}
self.seen.insert(desc.fb_id, ident);
true
}
}
/// Build a [`DrmConn`] from an already-authorized `_drm` `Connection` (root `--service` side). The /// Build a [`DrmConn`] from an already-authorized `_drm` `Connection` (root `--service` side). The
/// parity `Connection` wraps a tokio `UnixStream` but exposes no way to move it out, so we `dup()` /// parity `Connection` wraps a tokio `UnixStream` but exposes no way to move it out, so we `dup()`
/// its fd into a fresh, independently-owned tokio `UnixStream` for the bespoke SCM_RIGHTS framing. /// its fd into a fresh, independently-owned tokio `UnixStream` for the bespoke SCM_RIGHTS framing.
@@ -589,7 +508,7 @@ fn drm_prewarm() {
/// thread while the workers capture in parallel. /// thread while the workers capture in parallel.
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
pub async fn start_drm() { pub async fn start_drm() {
match new_drm_listener().await { match new_drm_listener() {
Ok(mut incoming) => { Ok(mut incoming) => {
// Warm libdrmtap/EGL + enumeration off-thread so the first consumer does not pay that // Warm libdrmtap/EGL + enumeration off-thread so the first consumer does not pay that
// one-time cost on its critical path. // one-time cost on its critical path.
@@ -644,7 +563,7 @@ fn drm_conn_admitted(prev_count: usize) -> bool {
prev_count < MAX_DRM_CONNS prev_count < MAX_DRM_CONNS
} }
/// Whether a `_drm` peer may keep receiving frames (review 3.3): root (uid 0) always, any other peer /// Whether a `_drm` peer may keep receiving frames: root (uid 0) always, any other peer
/// only while it still matches the active-session uid, and an unknown peer never (fail closed). Pure, /// only while it still matches the active-session uid, and an unknown peer never (fail closed). Pure,
/// so the per-frame re-authorization decision is unit-testable without a live logind session. /// so the per-frame re-authorization decision is unit-testable without a live logind session.
fn drm_peer_authorized(peer_uid: Option<u32>, active_uid: Option<u32>) -> bool { fn drm_peer_authorized(peer_uid: Option<u32>, active_uid: Option<u32>) -> bool {
@@ -707,7 +626,7 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
// physical scanout of a CRTC regardless of which session currently owns the display. So a stream // physical scanout of a CRTC regardless of which session currently owns the display. So a stream
// authorized for one session must stop the moment the active session changes, or the outgoing // authorized for one session must stop the moment the active session changes, or the outgoing
// user's --server keeps receiving the incoming user's screen (and the greeter in between) until // user's --server keeps receiving the incoming user's screen (and the greeter in between) until
// the socket dies (review 3.3). `peer_uid` is the --server's fixed uid. // the socket dies. `peer_uid` is the --server's fixed uid.
let peer_uid = stream.peer_uid(); let peer_uid = stream.peer_uid();
// Move the authorized `_drm` stream onto the bespoke SCM_RIGHTS framing (see `DrmConn`). ALL // Move the authorized `_drm` stream onto the bespoke SCM_RIGHTS framing (see `DrmConn`). ALL
@@ -792,17 +711,14 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
} }
// Forward frames + cursor updates until the worker ends or the client disconnects (a wire send // Forward frames + cursor updates until the worker ends or the client disconnects (a wire send
// error on a dropped client propagates out and tears the worker down via the guard). The // error on a dropped client propagates out and tears the worker down via the guard).
// per-connection `ExportLedger` decides, for the zero-copy path, whether each frame's fd must ride
// an SCM_RIGHTS cmsg or can be elided as an import-once cache hit.
let mut ledger = ExportLedger::new();
// Live hotplug: the udev listener bumps DRM_DISPLAY_GENERATION when the connector topology changes. // Live hotplug: the udev listener bumps DRM_DISPLAY_GENERATION when the connector topology changes.
// Seed from the value current at handshake (the list already sent reflects it) and, whenever it // Seed from the value current at handshake (the list already sent reflects it) and, whenever it
// moves, push the fresh list to this consumer. Piggybacked on the frame cadence so it costs only one // moves, push the fresh list to this consumer. Piggybacked on the frame cadence so it costs only one
// atomic load per frame; a genuinely idle stream tears down after MAX_STALLED and the consumer // atomic load per frame; a genuinely idle stream tears down after MAX_STALLED and the consumer
// reconnects to a fresh list anyway. // reconnects to a fresh list anyway.
let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire);
// Flow control (review P1-2): allow at most DRM_FRAME_CREDIT frames in flight on the socket. The // Flow control: allow at most DRM_FRAME_CREDIT frames in flight on the socket. The
// consumer acks each converted frame (send_frame_ack) and the producer only sends while it has // consumer acks each converted frame (send_frame_ack) and the producer only sends while it has
// credit, so a slow convert bounds the socket FIFO to a couple of frames instead of accumulating // credit, so a slow convert bounds the socket FIFO to a couple of frames instead of accumulating
// seconds of stale descriptors (a permanently-behind desktop). Backpressure on the capture // seconds of stale descriptors (a permanently-behind desktop). Backpressure on the capture
@@ -875,7 +791,7 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
None => break, None => break,
} }
}; };
// Re-authorize per frame (review 3.3): root (0) is always allowed; any other peer must still be // Re-authorize per frame: root (0) is always allowed; any other peer must still be
// the active-session uid. Use the CACHE-ONLY active uid (never a blocking loginctl lookup): this // the active-session uid. Use the CACHE-ONLY active uid (never a blocking loginctl lookup): this
// runs on the single-threaded `_drm` runtime, so a per-frame seat0 subprocess -- which is // runs on the single-threaded `_drm` runtime, so a per-frame seat0 subprocess -- which is
// exactly what a fresh lookup does during a session switch, when the cache is momentarily empty // exactly what a fresh lookup does during a session switch, when the cache is momentarily empty
@@ -889,10 +805,6 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
break; break;
} }
let gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); let gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire);
// Keep the ledger's epoch at the live generation so a hotplug/modeset (which may recycle an
// fb_id onto a new buffer) invalidates every cached buffer and forces a real fd on the next
// frame. Cheap (one field write) and only observable when DRM_FD_ELISION is enabled.
ledger.epoch = gen;
if gen != seen_gen { if gen != seen_gen {
seen_gen = gen; seen_gen = gen;
let fresh = DRM_DISPLAY_CACHE.lock().unwrap().clone(); let fresh = DRM_DISPLAY_CACHE.lock().unwrap().clone();
@@ -901,7 +813,7 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
// advertising the removed displays indefinitely. // advertising the removed displays indefinitely.
conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?; conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?;
} }
// Coalesce to latest-wins at the source (review 4.8). The `_drm` socket is a FIFO, so a // Coalesce to latest-wins at the source. The `_drm` socket is a FIFO, so a
// consumer that drains slower than we produce (a 4K convert on a modest GPU) would fall // consumer that drains slower than we produce (a 4K convert on a modest GPU) would fall
// seconds behind stale frames. Drain everything already queued without blocking and forward // seconds behind stale frames. Drain everything already queued without blocking and forward
// only the NEWEST frame; each replaced frame drops here, closing its OwnedFd (zero-copy path) // only the NEWEST frame; each replaced frame drops here, closing its OwnedFd (zero-copy path)
@@ -954,8 +866,14 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
} }
match latest_frame { match latest_frame {
Some(DrmProducerMsg::Frame { mut desc, fd }) => { Some(DrmProducerMsg::Frame { mut desc, fd }) => {
// The worker always supplies a real fd; the ledger decides whether to attach it. // Every exported frame carries its fd. Eliding it on an fb_id the converter has
let send_fd = fd.is_some() && ledger.should_send_fd(&desc); // already imported looks free, but the kernel can recycle an fb_id onto a different
// buffer with identical geometry and modifier, and this side cannot see the dma-buf
// inode that would tell the difference, so an elision can serve a stale EGLImage.
// Sending it is cheap: the converter imports once per buffer and closes the surplus
// fd. libdrmtap's own import cache keys on fb_id AND inode, and can only re-import
// when it is handed a real fd.
let send_fd = fd.is_some();
desc.has_fd = send_fd; desc.has_fd = send_fd;
let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None }; let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None };
conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?; conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?;
@@ -1009,7 +927,7 @@ fn drm_capture_worker(
drm_enumerate_all_displays() drm_enumerate_all_displays()
}; };
// Send even an empty list: the consumer treats "0 displays" as Unavailable and falls back // Send even an empty list: the consumer treats "0 displays" as Unavailable and falls back
// promptly (zhou's empty-topology finding), rather than waiting out repeated probe failures. // promptly, rather than waiting out repeated probe failures.
if frame_tx if frame_tx
.blocking_send(DrmProducerMsg::Displays(displays)) .blocking_send(DrmProducerMsg::Displays(displays))
.is_err() .is_err()
@@ -1102,7 +1020,7 @@ fn drm_capture_worker(
pitches: d.pitches, pitches: d.pitches,
hdr_eotf: d.hdr_eotf, hdr_eotf: d.hdr_eotf,
hdr_max_nits: d.hdr_max_nits, hdr_max_nits: d.hdr_max_nits,
has_fd: true, // the async task's ExportLedger may downgrade this has_fd: true, // every exported frame carries its fd; see the send below
}, },
fd: Some(fd), fd: Some(fd),
}), }),
@@ -1200,7 +1118,7 @@ fn drm_capture_worker(
/// SCM_RIGHTS cmsg bound to the frame's first (prefix) byte, so reading the prefix with a control /// SCM_RIGHTS cmsg bound to the frame's first (prefix) byte, so reading the prefix with a control
/// buffer reliably collects it (`MSG_CTRUNC` is rejected). Reads use exact-length loops so they never /// buffer reliably collects it (`MSG_CTRUNC` is rejected). Reads use exact-length loops so they never
/// cross a frame boundary and thus never discard a following frame's ancillary fd. /// cross a frame boundary and thus never discard a following frame's ancillary fd.
pub struct DrmConn { pub(crate) struct DrmConn {
/// The raw stream. Obtained from `connect_drm` (client) or the accepted `_drm` listener stream /// The raw stream. Obtained from `connect_drm` (client) or the accepted `_drm` listener stream
/// (service). All framing is done by hand on this fd; there is no `Framed` codec. /// (service). All framing is done by hand on this fd; there is no `Framed` codec.
stream: tokio::net::UnixStream, stream: tokio::net::UnixStream,
@@ -1593,7 +1511,7 @@ impl DrmConn {
} }
} }
// Pure-userspace coverage for the bespoke `_drm` SCM_RIGHTS framing (review 6). The wire format is // Pure-userspace coverage for the bespoke `_drm` SCM_RIGHTS framing. The wire format is
// hand-rolled (length prefix + an fd bound to the frame's first byte) because `Framed`/`BytesCodec` // hand-rolled (length prefix + an fd bound to the frame's first byte) because `Framed`/`BytesCodec`
// cannot carry ancillary data, so it gets direct tests over a socketpair instead of only live runs. // cannot carry ancillary data, so it gets direct tests over a socketpair instead of only live runs.
#[cfg(test)] #[cfg(test)]
@@ -1824,7 +1742,7 @@ mod drm_conn_tests {
assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid));
} }
// Per-frame _drm re-auth decision (review 3.3): root always passes; a non-root peer passes only // Per-frame _drm re-auth decision: root always passes; a non-root peer passes only
// while it still equals the active-session uid; an unknown peer or active session fails closed. // while it still equals the active-session uid; an unknown peer or active session fails closed.
#[test] #[test]
fn drm_peer_authorized_matrix() { fn drm_peer_authorized_matrix() {
@@ -1842,7 +1760,7 @@ mod drm_conn_tests {
assert!(!drm_peer_authorized(None, None)); assert!(!drm_peer_authorized(None, None));
} }
// _drm admission bound (review 6): admit strictly below MAX_DRM_CONNS, reject at and above it. // _drm admission bound: admit strictly below MAX_DRM_CONNS, reject at and above it.
// `prev_count` is the live count taken before this connection (what fetch_add returns). // `prev_count` is the live count taken before this connection (what fetch_add returns).
#[test] #[test]
fn drm_conn_admission_bound() { fn drm_conn_admission_bound() {

View File

@@ -22,7 +22,7 @@
// thread-local, so both convert and close must run on the same thread. // thread-local, so both convert and close must run on the same thread.
use crate::ipc::{connect_drm, Data, DrmDisplayInfo}; use crate::ipc::{connect_drm, Data, DrmDisplayInfo};
use hbb_common::{anyhow::anyhow, log, message_proto::DisplayInfo, tokio, ResultType}; use hbb_common::{anyhow::anyhow, bail, log, message_proto::DisplayInfo, tokio, ResultType};
use scrap::drm_render::RenderConverter; use scrap::drm_render::RenderConverter;
use scrap::drmtap_dl::drmtap_dmabuf_desc; use scrap::drmtap_dl::drmtap_dmabuf_desc;
use scrap::{Frame, Pixfmt, PixelBuffer, TraitCapturer}; use scrap::{Frame, Pixfmt, PixelBuffer, TraitCapturer};
@@ -239,7 +239,7 @@ impl IpcDrmCapturer {
// with no owning capturer (our Drop never runs — the capturer was never built), so // with no owning capturer (our Drop never runs — the capturer was never built), so
// signal it to stop before giving up. // signal it to stop before giving up.
stop.store(true, Ordering::SeqCst); stop.store(true, Ordering::SeqCst);
return Err(anyhow!("drm capture handshake timed out")); bail!("drm capture handshake timed out");
} }
}; };
Ok(( Ok((
@@ -420,10 +420,10 @@ async fn recv_thread(
.get(display.max(0) as usize) .get(display.max(0) as usize)
.map(|d| (d.device.clone(), d.crtc_id)); .map(|d| (d.device.clone(), d.crtc_id));
let our_key = displays.get(display.max(0) as usize).map(connector_key); let our_key = displays.get(display.max(0) as usize).map(connector_key);
// Open the unprivileged render-node convert context ONCE, on THIS thread, BEFORE the handshake; it // Open the unprivileged render-node convert context ONCE, on THIS thread, before we answer the
// is dropped on this same thread when the loop exits (its EGL state + import-once cache are // display list with DrmStart; it is dropped on this same thread when the loop exits (its EGL
// thread-local). `None` means no usable render node (a locked-down seat, or an old `.so` without // state + import-once cache are thread-local). `None` means no usable render node (a locked-down
// the split symbols): we then ask the service for the CPU-converted `DrmFrame` path via // seat with no /dev/dri/renderD* access): we then ask the service for the CPU-converted `DrmFrame` path via
// `need_cpu`, so a render-node-less seat still captures instead of the service streaming a dma-buf // `need_cpu`, so a render-node-less seat still captures instead of the service streaming a dma-buf
// fd we cannot detile (which would lose the stream and force a PipeWire fallback nobody may be // fd we cannot detile (which would lose the stream and force a PipeWire fallback nobody may be
// present to approve on an unattended seat). // present to approve on an unattended seat).
@@ -448,7 +448,7 @@ async fn recv_thread(
// SUCCEEDS and yields corrupted pixels, so there is no convert error for the prefer-cpu bit above // 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 // 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 // libdrmtap without `drmtap_render_node` (we dlopen by soname, so the runtime .so can be older
// than the one this was built against, anywhere in 0.4.9..0.4.14 -- below that it does not load // than the one this was built against, anywhere in 0.4.10..0.4.14 -- below that it does not load
// at all). Ask for the CPU path instead: the service converts on the // 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 // device it already has open, which is correct by construction. Single-render-node hosts (the
// common case) keep the dma-buf fast path untouched. // common case) keep the dma-buf fast path untouched.
@@ -530,7 +530,10 @@ async fn recv_thread(
format: desc.format, format: desc.format,
modifier: desc.modifier, modifier: desc.modifier,
fb_id: desc.fb_id, fb_id: desc.fb_id,
num_planes: desc.num_planes, // Clamped although the producer already normalizes it and must be root: this
// value indexes offsets/pitches inside libdrmtap, and the wire is the one place
// it arrives from another process.
num_planes: desc.num_planes.clamp(1, 4),
offsets: desc.offsets, offsets: desc.offsets,
pitches: desc.pitches, pitches: desc.pitches,
hdr_eotf: desc.hdr_eotf, hdr_eotf: desc.hdr_eotf,
@@ -797,27 +800,32 @@ fn remove_drm_cursor(display: i32, epoch: u64) {
} }
} }
// Pick the cursor to present: prefer the visible one (the pointer is over exactly one captured CRTC // Which cursor to present: prefer the visible one (the pointer is over exactly one captured CRTC at
// at a time), else fall back to any (hidden) entry so the client still gets the hidden sentinel when // a time), else fall back to any (hidden) entry so the client still gets the hidden sentinel when the
// the pointer is off every captured monitor. `None` only when no stream is active. // pointer is off every captured monitor. Returns what `f` extracts from it, so a caller that only
fn pick_drm_cursor() -> Option<DrmCursorData> { // wants the id does not pay for a clone of the pixels. `None` only when no stream is active.
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
let map = DRM_CURSOR.lock().unwrap(); let map = DRM_CURSOR.lock().unwrap();
map.values() map.values()
.map(|(_, c)| c) .map(|(_, c)| c)
.find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID) .find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID)
.or_else(|| map.values().map(|(_, c)| c).next()) .or_else(|| map.values().map(|(_, c)| c).next())
.cloned() .map(f)
} }
/// The id of the current DRM hardware cursor (None if no stream). The cursor service polls this to /// The id of the current DRM hardware cursor (None if no stream). The cursor service polls this to
/// detect shape changes (a change triggers a `get_cursor_data` fetch). /// detect shape changes (a change triggers a `get_cursor_data` fetch), so it runs at frame cadence
/// and deliberately reads the id WITHOUT copying the pixels: a 256x256 cursor is 256 KiB, and
/// cloning that 30 times a second to look at 8 bytes of it is pure waste.
pub fn drm_cursor_id() -> Option<u64> { pub fn drm_cursor_id() -> Option<u64> {
pick_drm_cursor().map(|c| c.id) with_drm_cursor(|c| c.id)
} }
/// The current DRM hardware-cursor snapshot (RGBA), or None. /// The current DRM hardware-cursor snapshot (RGBA), or None. The pixels are premultiplied ARGB and
/// are passed through as-is, which is exactly what the XFixes path does (`platform/linux.rs`
/// `get_cursor_data`), so the client sees one cursor format whichever backend produced it.
pub fn drm_cursor() -> Option<DrmCursorData> { pub fn drm_cursor() -> Option<DrmCursorData> {
pick_drm_cursor() with_drm_cursor(|c| c.clone())
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1100,6 +1108,12 @@ fn refresh_available_async() {
/// retries (the "connects on the Nth try" symptom). Probes with a short retry budget and only caches /// retries (the "connects on the Nth try" symptom). Probes with a short retry budget and only caches
/// the positive result; a genuinely DRM-less host just falls through to the lazy `is_available()`. /// the positive result; a genuinely DRM-less host just falls through to the lazy `is_available()`.
pub(super) fn warm_availability() { pub(super) fn warm_availability() {
// Nothing on X11 can consume a DRM stream, and probing makes the ROOT service open DRM readers,
// so an X11 host running a drm build would pay that at every startup for a path it can never
// take. The lazy probe behind is_available is reached only from the Wayland paths already.
if crate::platform::linux::is_x11() {
return;
}
for _ in 0..10 { for _ in 0..10 {
if matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) { if matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) {
return; return;
@@ -1126,7 +1140,7 @@ pub(super) fn get_display_infos() -> Option<Vec<DisplayInfo>> {
}; };
let multi = list.len() > 1; let multi = list.len() > 1;
let mut infos = augment_with_wayland_geometry(&list); let mut infos = augment_with_wayland_geometry(&list);
// review 4.5: on a multi-monitor host a display demoted to PipeWire has no geometry-consistent // On a multi-monitor host a display demoted to PipeWire has no geometry-consistent
// per-connector stream to fall through to -- the portal exposes a single whole-desktop stream, so // per-connector stream to fall through to -- the portal exposes a single whole-desktop stream, so
// serving it for one connector would stretch the frame and offset all input. Advertise such a // serving it for one connector would stretch the frame and offset all input. Advertise such a
// display OFFLINE while keeping its list position, so the index space stays aligned with // display OFFLINE while keeping its list position, so the index space stays aligned with
@@ -1369,9 +1383,9 @@ pub(super) fn get_capturer_info(
if since.elapsed() >= demote_cooldown(demotes) { if since.elapsed() >= demote_cooldown(demotes) {
map.insert(key.clone(), (0, Instant::now(), demotes)); map.insert(key.clone(), (0, Instant::now(), demotes));
} else { } else {
return Err(anyhow!( bail!(
"drm capture for display {display_idx} repeatedly produced no frame; using PipeWire" "drm capture for display {display_idx} repeatedly produced no frame; using PipeWire"
)); );
} }
} }
} }
@@ -1408,9 +1422,7 @@ pub(super) fn get_capturer_info(
e.1 = Instant::now(); e.1 = Instant::now();
e.2 += 1; e.2 += 1;
} }
return Err(anyhow!( bail!("drm capture for display {display_idx} is flapping; using PipeWire");
"drm capture for display {display_idx} is flapping; using PipeWire"
));
} }
} }
let ndisplay = displays.len(); let ndisplay = displays.len();

View File

@@ -400,17 +400,17 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()>
// requested `hcursor` (it returns the latest hardware cursor); file it in the cache AND // requested `hcursor` (it returns the latest hardware cursor); file it in the cache AND
// record state.hcursor under the id ACTUALLY served, so a later reappearance of that exact // record state.hcursor under the id ACTUALLY served, so a later reappearance of that exact
// shape dedupes correctly instead of being suppressed. Everything below is fully // shape dedupes correctly instead of being suppressed. Everything below is fully
// `#[cfg(feature = "drm")]`-gated so the drm-off build stays byte-identical to upstream. // gated on the drm feature, so the drm-off build stays byte-identical to upstream.
#[cfg(feature = "drm")] #[cfg(all(target_os = "linux", feature = "drm"))]
let mut drm_served_id = hcursor; let mut drm_served_id = hcursor;
if let Some(cached) = state.cached_cursor_data.get(&hcursor) { if let Some(cached) = state.cached_cursor_data.get(&hcursor) {
super::log::trace!("Cursor data cached, hcursor: {}", hcursor); super::log::trace!("Cursor data cached, hcursor: {}", hcursor);
msg = cached.clone(); msg = cached.clone();
} else { } else {
let mut data = crate::get_cursor_data(hcursor)?; let mut data = crate::get_cursor_data(hcursor)?;
#[cfg(feature = "drm")] #[cfg(all(target_os = "linux", feature = "drm"))]
let hcursor = data.id; let hcursor = data.id;
#[cfg(feature = "drm")] #[cfg(all(target_os = "linux", feature = "drm"))]
{ {
drm_served_id = hcursor; drm_served_id = hcursor;
} }
@@ -421,11 +421,11 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()>
state.cached_cursor_data.insert(hcursor, msg.clone()); state.cached_cursor_data.insert(hcursor, msg.clone());
super::log::trace!("Cursor data updated, hcursor: {}", hcursor); super::log::trace!("Cursor data updated, hcursor: {}", hcursor);
} }
#[cfg(not(feature = "drm"))] #[cfg(not(all(target_os = "linux", feature = "drm")))]
{ {
state.hcursor = hcursor; state.hcursor = hcursor;
} }
#[cfg(feature = "drm")] #[cfg(all(target_os = "linux", feature = "drm"))]
{ {
state.hcursor = drm_served_id; state.hcursor = drm_served_id;
} }

View File

@@ -426,8 +426,8 @@ pub(super) fn get_capturer_for_display(
unsafe { unsafe {
let cap_display_info = &*cap_display_info; let cap_display_info = &*cap_display_info;
let rect = cap_display_info.rects[cap_display_info.current]; let rect = cap_display_info.rects[cap_display_info.current];
// review 4.5: reaching here with DRM active means get_capturer_info bailed (a demoted // Reaching here with DRM active means get_capturer_info bailed (a demoted display) and
// display) and we fell through to PipeWire. Serve this stream ONLY if its rect matches the // we fell through to PipeWire. Serve this stream ONLY if its rect matches the
// geometry we advertised for this index. The portal typically exposes one whole-desktop // geometry we advertised for this index. The portal typically exposes one whole-desktop
// stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM // stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM
// geometry is a single connector -> serving it would stretch the frame and offset all // geometry is a single connector -> serving it would stretch the frame and offset all