diff --git a/src/ipc.rs b/src/ipc.rs index 1699011bd..5c357a621 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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::())` control buffer that collects the fd bound /// to the frame's first byte, rejecting `MSG_CTRUNC`), then the payload into the reusable diff --git a/src/platform/linux.rs b/src/platform/linux.rs index d4c9e5ca2..be1662c44 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -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" diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 4a6ded0c3..18c1239d6 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -456,7 +456,16 @@ pub(super) fn get_display_info(idx: usize) -> Option { #[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, } } diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index 9f803f591..d47efdc21 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -98,6 +98,39 @@ static DRM_DISPLAY_REBUILDS: Mutex> = 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) { 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); + } } } diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 9f7ac25f8..8256b25d4 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -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()