drm: address the consumer review (login-screen uid, frame flow control, hotplug)

- Start the login-screen --server as the active seat0 greeter account instead
  of root, so the DRM capture GPU/EGL convert never loads the vendor GPU
  userspace in a privileged process. A genuine root graphical session has no
  lower uid to drop to and stays root, and if the greeter spawn fails we fall
  back to a root --server so the login screen stays remotable. Gated on the drm
  feature so the non-drm build is unchanged.
- Bound the number of frames in flight on the `_drm` channel: the consumer acks
  each frame it finishes converting and the producer only sends while it holds
  credit, waiting on the socket otherwise. Without this the producer kept
  writing descriptors into the socket faster than a slow convert drained them
  and the consumer worked through an ever-growing backlog of stale frames. A
  zero-byte read or write on the ack path is treated as a closed peer rather
  than as success.
- Forward a display list that became empty (last monitor unplugged) instead of
  dropping it, so the availability cache leaves Available rather than keep
  advertising removed displays.
- On a topology change, invalidate the Wayland geometry cache and reapply the
  uinput mouse range for the new layout. The refresh runs off the frame-receive
  loop and is coalesced across the per-display receivers, so a multi-monitor
  hotplug runs one worker and the final layout wins.
- Clear the prefer-CPU-convert hints on a topology change: display indices can
  be renumbered, so a hint learned for an old index no longer refers to the same
  physical display. Re-learned on the next convert failure.
- Report a non-DRM-backed display when the DRM list is shorter than the sync
  list or any entry is offline, covering the present-but-demoted case.
This commit is contained in:
Mariano Abad
2026-07-23 21:21:33 -03:00
parent dd6ee24833
commit 1fcc15488c
5 changed files with 232 additions and 15 deletions

View File

@@ -2152,7 +2152,24 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
// atomic load per frame; a genuinely idle stream tears down after MAX_STALLED and the consumer
// reconnects to a fresh list anyway.
let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire);
while let Some(first) = frame_rx.recv().await {
// Flow control (review P1-2): 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
// 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). The bounded frame_rx backpressures
// the capture worker while we wait for an ack. Cursors and topology updates are not credit-gated.
const DRM_FRAME_CREDIT: i32 = 2;
let mut credit: i32 = DRM_FRAME_CREDIT;
loop {
// Replenish credit from any acks the consumer has finished; if none is left, wait for one.
conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?;
if credit <= 0 {
conn.wait_readable().await?;
continue;
}
let first = match frame_rx.recv().await {
Some(f) => f,
None => break,
};
// Re-authorize per frame (review 3.3): 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
// runs on the single-threaded `_drm` runtime, so a per-frame seat0 subprocess -- which is
@@ -2174,9 +2191,10 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
if gen != seen_gen {
seen_gen = gen;
let fresh = DRM_DISPLAY_CACHE.lock().unwrap().clone();
if !fresh.is_empty() {
conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?;
}
// Send even an EMPTY list: when the last active CRTC disappears (all monitors
// unplugged) the consumer must learn the topology is now empty, otherwise it keeps
// advertising the removed displays indefinitely.
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
// consumer that drains slower than we produce (a 4K convert on a modest GPU) would fall
@@ -2223,6 +2241,7 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
desc.has_fd = send_fd;
let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None };
conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?;
credit -= 1; // one frame in flight until the consumer acks it
// `fd` (OwnedFd) is closed here whether or not it was attached (the cmsg dup'd it into
// the peer). Closing immediately bounds our fd usage to ~1 in flight per frame.
}
@@ -2234,6 +2253,7 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
// CPU-mapped fallback: pixels cross the wire, exactly like the pre-split protocol.
conn.send_msg(&Data::DrmFrame { width, height }, None).await?;
conn.send_raw(data).await?;
credit -= 1; // one frame in flight until the consumer acks it
}
_ => {}
}
@@ -2773,6 +2793,49 @@ impl DrmConn {
drm_send_frame(&self.stream, &payload, pass_fd).await
}
/// Consumer -> producer: one-byte frame ack ("I finished converting one frame"), on the reverse
/// direction (unused for messages after the handshake). It replenishes the producer's send credit
/// so only a bounded number of frames are ever in flight on the socket. Without it, the producer
/// keeps writing descriptors into the socket FIFO faster than a slow convert drains them, and the
/// consumer processes an ever-growing backlog of stale frames (a permanently-behind desktop).
/// Uses `&self.stream` directly, so it never conflicts with a concurrent `send_msg`/`recv_msg`.
pub async fn send_frame_ack(&self) -> ResultType<()> {
loop {
self.stream.writable().await?;
match self.stream.try_write(&[1u8]) {
Ok(n) if n > 0 => return Ok(()),
// A non-empty write returning 0 means the write half is shut down (the producer is
// gone); surface it instead of "succeeding" without ever delivering the ack byte,
// which would silently starve the producer of a send credit.
Ok(_) => bail!("drm: _drm frame-ack write returned 0 (peer closed)"),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(e) => return Err(e.into()),
}
}
}
/// Producer: non-blockingly drain the frame-ack bytes the consumer has written, adding one send
/// credit per byte (capped at `max`). A read of 0 means the consumer closed. Cheap when idle
/// (a single `try_read` that returns WouldBlock).
pub fn drain_frame_acks(&self, credit: &mut i32, max: i32) -> ResultType<()> {
let mut buf = [0u8; 64];
loop {
match self.stream.try_read(&mut buf) {
Ok(0) => bail!("drm: _drm frame-ack peer closed"),
Ok(n) => *credit = (*credit + n as i32).min(max),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(()),
Err(e) => return Err(e.into()),
}
}
}
/// Producer: await until the socket is readable (a frame ack arrived, or the peer errored/closed).
/// Cancel-safe (readiness only, consumes no bytes), so it is safe in a `select!`.
pub async fn wait_readable(&self) -> ResultType<()> {
self.stream.readable().await?;
Ok(())
}
/// Receive one `Data` message plus any dma-buf fd delivered via SCM_RIGHTS. Reads the 4-byte
/// length prefix (with a `CMSG_SPACE(size_of::<c_int>())` control buffer that collects the fd bound
/// to the frame's first byte, rejecting `MSG_CTRUNC`), then the payload into the reusable

View File

@@ -897,7 +897,34 @@ pub fn start_os_service() {
) {
stop_subprocess();
force_stop_server();
start_server(None, &mut server);
// Run the login-screen --server as the active seat0 session user (the greeter
// account) rather than root, so the DRM capture GPU/EGL convert never loads the
// vendor GPU userspace in a privileged process. is_login_wayland() matches a GDM or
// SDDM Wayland greeter (is_gdm_user covers both), and desktop.uid is that greeter's
// uid, so this drops to whichever greeter owns seat0. A greeter is_gdm_user does not
// recognize (e.g. LightDM) never reaches this branch -- it takes the unprivileged
// else-branch below already. A genuine root graphical session (username=="root")
// has no lower uid to drop to, so it stays root. Gated on the drm feature so the
// non-drm build stays byte-identical to upstream.
#[cfg(feature = "drm")]
let run_as_greeter = desktop.username != "root" && !desktop.uid.is_empty();
#[cfg(not(feature = "drm"))]
let run_as_greeter = false;
if run_as_greeter {
start_server(Some(&desktop), &mut server);
// If dropping to the greeter uid did not produce a running server (spawn/exec
// failure), fall back to a root --server so the login screen stays remotable
// instead of looping on a failing greeter spawn. This pays the GPU-in-root
// tradeoff only on that failure path, never in the normal greeter case.
if server.is_none() {
log::warn!(
"greeter --server did not start; falling back to a root --server"
);
start_server(None, &mut server);
}
} else {
start_server(None, &mut server);
}
}
} else if desktop.username != "" {
// try kill subprocess "--server"

View File

@@ -456,7 +456,16 @@ pub(super) fn get_display_info(idx: usize) -> Option<DisplayInfo> {
#[cfg(all(target_os = "linux", feature = "drm"))]
pub fn has_non_drm_backed_display() -> bool {
match super::drm_capturer::get_display_infos() {
Some(drm) => drm.len() < SYNC_DISPLAYS.lock().unwrap().displays.len(),
// A display served by PipeWire is either ABSENT from the DRM list (a shorter list, e.g. a
// pure-portal display) or PRESENT-BUT-DEMOTED (kept in place at the same index and marked
// offline so the index space stays aligned -- see get_display_infos). The length check alone
// misses the demotion case (same length), so a display that is not online-DRM (`!online`) is
// treated as non-DRM-backed too. This is what gates the hidden-cursor sentinel: it stays
// authoritative only in a pure-DRM session.
Some(drm) => {
drm.len() < SYNC_DISPLAYS.lock().unwrap().displays.len()
|| drm.iter().any(|d| !d.online)
}
None => false,
}
}

View File

@@ -98,6 +98,39 @@ static DRM_DISPLAY_REBUILDS: Mutex<BTreeMap<i32, (Instant, u32)>> = Mutex::new(B
const RAPID_REBUILD_WINDOW: Duration = Duration::from_secs(3);
const RAPID_REBUILD_MAX: u32 = 6;
// Displays whose consumer-side dma-buf convert failed (the common multi-GPU cause: the auto-selected
// render node is not the GPU that exported the scanout, so cross-device import fails permanently).
// Set on a convert failure; the next connection then requests the CPU-converted path so the service
// converts on the exporting GPU, instead of the stream flapping until it demotes to PipeWire. One bit
// per display index. Set-only within a process run (the mismatch is a stable property of the host).
static DRM_PREFER_CPU: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn drm_prefer_cpu(display: i32) -> bool {
(0..64).contains(&display)
&& DRM_PREFER_CPU.load(Ordering::Relaxed) & (1u64 << display) != 0
}
fn drm_set_prefer_cpu(display: i32) {
if (0..64).contains(&display) {
DRM_PREFER_CPU.fetch_or(1u64 << display, Ordering::Relaxed);
}
}
// A connector-topology change (hotplug/modeset) can renumber the display indices, so a prefer-cpu bit
// learned for an old index may now refer to a different physical display (or none). Clear the whole
// mask on DrmDisplaysChanged and re-learn on the next convert failure, rather than force the CPU path
// onto a reindexed display. Costs at most one convert-failure retry per affected display after a rare
// topology change.
fn drm_clear_prefer_cpu() {
DRM_PREFER_CPU.store(0, Ordering::Relaxed);
}
// Coalesce the uinput-range refresh that every DrmDisplaysChanged triggers. A multi-monitor hotplug
// delivers that message once PER captured display (one recv_thread each), but the uinput desktop rect
// is global and idempotent, so one refresh serves the whole burst. UINPUT_REFRESH_GEN records the
// newest topology; UINPUT_REFRESH_BUSY lets only the first handler spawn a worker, which then keeps
// refreshing until it has served the latest generation. Net: one worker thread per burst, and the
// final layout always wins (no lost update from an out-of-order per-display thread).
static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
impl IpcDrmCapturer {
/// Connect to the service `_drm` channel, complete the handshake (receive the display list, then
/// request `display`), and start streaming on a background thread. Returns the capturer plus the
@@ -259,12 +292,24 @@ async fn recv_thread(
// `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
// present to approve on an unattended seat).
let mut converter = RenderConverter::open_render();
// 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.
let force_cpu = drm_prefer_cpu(display);
let mut converter = if force_cpu {
None
} else {
RenderConverter::open_render()
};
let need_cpu = converter.is_none();
if need_cpu {
log::info!(
"drm: no render-node convert context (drmtap_open_render failed or old .so); \
requesting the CPU-converted frame path for this stream"
"drm: requesting the CPU-converted frame path for display {display} ({})",
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"
}
);
}
if let Err(err) = conn
@@ -338,10 +383,23 @@ async fn recv_thread(
// Transient convert contention: skip this frame (latest-wins keeps the newest),
// do not tear the stream down.
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
Err(err) => break format!("convert: {err}"),
Err(err) => {
// The consumer render node could not import this buffer. A common multi-GPU
// cause: the auto-selected renderD* is not the GPU that exported the scanout,
// so cross-device import fails permanently. Prefer the CPU path on reconnect
// (service converts on the exporting GPU) instead of flapping to PipeWire.
drm_set_prefer_cpu(display);
break format!("convert: {err}");
}
}
// `recv_fd` (the OwnedFd, if any) is dropped/closed at the end of this iteration, AFTER
// convert has imported it (the EGLImage import holds its own reference to the buffer).
// Ack this frame so the producer releases one send credit and forwards the next: we
// have consumed it (converted, or skipped on transient contention -- ready either way).
// This bounds the socket to a couple of in-flight frames instead of a stale backlog.
if let Err(err) = conn.send_frame_ack().await {
break format!("frame ack: {err}");
}
}
// CPU-fallback path (old `.so` / no transferable dma-buf): the producer packed BGRA and
// sent it over the wire after the header. Store it as-is (BGRA); no convert needed.
@@ -371,6 +429,10 @@ async fn recv_thread(
}
Err(err) => break format!("frame body: {err}"),
}
// Ack this CPU frame too (flow control; see the dma-buf arm above).
if let Err(err) = conn.send_frame_ack().await {
break format!("frame ack: {err}");
}
}
Data::DrmCursor {
id,
@@ -415,8 +477,55 @@ async fn recv_thread(
// this never trips the wayland::clear() re-probe restart loop). A subsequent
// get_display_infos()/get_primary_index() then reports the fresh geometry.
Data::DrmDisplaysChanged(list) => {
if !list.is_empty() {
swap_available_displays(list);
// Forward the fresh list INCLUDING an empty one (last monitor unplugged): the
// availability cache must transition out of Available rather than keep advertising
// the removed displays. See swap_available_displays.
swap_available_displays(list);
// The topology changed: a prefer-cpu bit learned for an old display index may now
// point at a different physical display, so clear the mask and re-learn.
drm_clear_prefer_cpu();
// The raw DRM list is not the whole story: the Wayland LOGICAL geometry cache and
// the uinput absolute range are both set once at init, so after a hotplug/modeset the
// augmented geometry and injected-coordinate range are stale. Invalidate the cache so
// the next augmentation re-reads fresh geometry, and reapply the uinput mouse range
// for the new desktop layout.
scrap::wayland::display::clear_wayland_displays_cache();
// Reapply the uinput range OFF this recv loop, coalesced across the per-display
// recv_threads (see UINPUT_REFRESH_*). Awaiting update_uinput_resolution inline would
// stall frame reception for the whole hotplug (it does a Wayland geometry roundtrip),
// and this recv_thread is a current-thread runtime -- so the worker builds its own.
// Bump the generation, then let only the first caller spawn the single worker; it
// refreshes until it has served the newest generation, so a multi-monitor hotplug
// runs ONE thread and the final layout wins. Not ordered against frame delivery.
UINPUT_REFRESH_GEN.fetch_add(1, Ordering::AcqRel);
if !UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel) {
std::thread::spawn(|| {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
UINPUT_REFRESH_BUSY.store(false, Ordering::Release);
return;
};
let mut served = 0u64;
loop {
let g = UINPUT_REFRESH_GEN.load(Ordering::Acquire);
if g != served {
served = g;
rt.block_on(super::wayland::update_uinput_resolution());
continue;
}
// Caught up: release, then re-check for a request that raced in after our
// load but before the release, taking the worker role back if so.
UINPUT_REFRESH_BUSY.store(false, Ordering::Release);
if UINPUT_REFRESH_GEN.load(Ordering::Acquire) == served {
break;
}
if UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel) {
break; // another handler already started a fresh worker
}
}
});
}
}
_ => {} // ignore any unexpected control message
@@ -862,8 +971,17 @@ fn normalize_connector(name: &str) -> String {
fn swap_available_displays(list: Vec<DrmDisplayInfo>) {
let mut st = DRM_STATE.lock().unwrap();
if matches!(&*st, ProbeState::Available(..)) {
log::info!("drm: hotplug refresh -> {} display(s)", list.len());
*st = ProbeState::Available(Instant::now(), list);
if list.is_empty() {
// The last active CRTC disappeared (all monitors unplugged). Do NOT keep an
// Available-but-empty verdict advertising displays that are gone; drop to Unavailable
// so consumers stop reporting them and can fall back. The probe path re-establishes
// Available if a monitor comes back.
log::info!("drm: hotplug refresh -> 0 displays, marking DRM unavailable");
*st = ProbeState::Unavailable(Instant::now());
} else {
log::info!("drm: hotplug refresh -> {} display(s)", list.len());
*st = ProbeState::Available(Instant::now(), list);
}
}
}

View File

@@ -116,7 +116,7 @@ struct CapDisplayInfo {
/// independent of the capture backend. DRM-only: check_init keeps its own inline copy so the
/// drm-off build stays byte-identical to upstream.
#[cfg(feature = "drm")]
async fn update_uinput_resolution() {
pub(super) async fn update_uinput_resolution() {
if crate::input_service::wayland_use_uinput() {
if let Some((minx, maxx, miny, maxy)) =
scrap::wayland::display::get_desktop_rect_for_uinput()