diff options
| author | srdusr <[email protected]> | 2024-04-12 22:35:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-04-12 22:35:00 +0200 |
| commit | a9dd8a6d4947cb537f7919c5a66ba4624af61800 (patch) | |
| tree | d4731cded04ce159ebf3547d4dfe95ca24f4bb02 | |
| parent | 709333908d5b7d3157165d1811c8bc1795ab0028 (diff) | |
| download | srdwm-a9dd8a6d4947cb537f7919c5a66ba4624af61800.tar.gz srdwm-a9dd8a6d4947cb537f7919c5a66ba4624af61800.zip | |
udev: connector hotplug, and rescue windows on an unplugged monitor
Monitors were probed once at startup, so plugging or unplugging one while
srdwm was running went unnoticed. A UdevBackend event source now watches for
the kernel's `change` uevent and reconciles the head list against a fresh
connector probe - forcing a re-probe rather than trusting cached status,
since on a hotplug the cache is exactly what has gone stale.
Removing a head tears down everything it owned: the wl_output global, its
place in the Space, its DRM framebuffers and dumb buffers (dropping the Rust
structs alone leaks the kernel-side objects, which matters when a cable is
plugged repeatedly), and any lock surface for it - otherwise
confirm_lock_if_presented would wait forever on a monitor that no longer
exists. New connectors go through the same bring_up_head path as startup, so
a monitor plugged in later is set up identically to one present at boot.
Heads are then repositioned left-to-right, since removing one shifts the
rest, and layer maps re-arranged so bars follow their moved output.
set_monitors rehomes windows stranded by the change, and main.rs re-queries
the whole monitor list on MonitorAdded/MonitorRemoved rather than applying
the single monitor in the event, because the others' positions move too.
The rehoming had a bug that unit tests missed and live testing caught.
It originally keyed off Window::monitor, but that field records the monitor
a window was *assigned* at creation, not where it is: add_window always sets
it from the primary monitor, so a window placed on the second monitor by a
rule - or dragged there - still reads monitor == 0. The field-only check
saw a valid id, skipped the window, and left it at coordinates that no
longer existed: invisible and unreachable. Found by unplugging a monitor out
from under a real xterm and watching it vanish from both heads. It now keys
off geometry, with a regression test that fails against the old logic.
Verified in the QEMU VM, booting with one connector and toggling the second
at runtime: plug in -> head added and rendering at its own resolution;
unplug -> head removed cleanly; and an xterm at global x=1500 survived its
monitor being unplugged, reappearing at x=680 (= min(1500, 1280-600)) with
its size intact. Writing to /sys/class/drm/<connector>/status changes the
connector but emits no uevent on this kernel, so the signal the kernel would
send is synthesized with `udevadm trigger`; the whole reaction path is
genuinely exercised.
| -rw-r--r-- | crates/core/src/geometry.rs | 17 | ||||
| -rw-r--r-- | crates/core/src/manager.rs | 164 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 14 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 341 | ||||
| -rw-r--r-- | docs/IMPLEMENTATION_STATUS.md | 55 |
5 files changed, 499 insertions, 92 deletions
diff --git a/crates/core/src/geometry.rs b/crates/core/src/geometry.rs index 75feeef..3dd205a 100644 --- a/crates/core/src/geometry.rs +++ b/crates/core/src/geometry.rs @@ -42,6 +42,23 @@ impl Rect { pub fn center(&self) -> (i32, i32) { (self.x + self.width as i32 / 2, self.y + self.height as i32 / 2) } + + /// Moves this rect so it lies inside `bounds`, shrinking it only if it + /// is genuinely larger than `bounds`. + /// + /// Used when a monitor is unplugged and its windows have to be rehomed: + /// a window at coordinates that no longer exist would otherwise be + /// off-screen and unreachable. Position is adjusted in preference to + /// size so a window keeps the dimensions the user gave it. + pub fn clamped_into(&self, bounds: Rect) -> Rect { + let width = self.width.min(bounds.width); + let height = self.height.min(bounds.height); + // `max(bounds.x)` after `min` so that a bounds smaller than the rect + // still yields the bounds' own origin rather than a negative offset. + let x = (self.x).min(bounds.right() - width as i32).max(bounds.x); + let y = (self.y).min(bounds.bottom() - height as i32).max(bounds.y); + Rect { x, y, width, height } + } } #[cfg(test)] diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs index 1f69413..d261c13 100644 --- a/crates/core/src/manager.rs +++ b/crates/core/src/manager.rs @@ -98,8 +98,55 @@ impl WindowManager { // ---- Monitors ---------------------------------------------------- + /// Replaces the monitor list, rehoming any window left stranded. + /// + /// Called at startup and again on every hotplug. Unplugging a monitor + /// would otherwise leave its windows pointing at a `monitor` id that no + /// longer exists: `arrange_workspace` skips those (it looks the monitor + /// up to get a rectangle), so they would stop being tiled, and a + /// floating window would sit at coordinates that are no longer on any + /// screen - unreachable, with no way to drag it back. + /// + /// Stranded windows are moved to the primary monitor and, if their + /// geometry falls outside it, nudged back inside. + /// + /// This keys off **geometry**, not just the `monitor` field. That field + /// records which monitor a window was *assigned* at creation and does + /// not track where the window actually is: a floating window dragged -- + /// or placed by a rule - onto a second monitor keeps `monitor` + /// pointing at the first. Trusting the field alone left such a window + /// at coordinates that no longer existed once its real monitor was + /// unplugged: off-screen and unreachable, with no way to drag it back. + /// Found by unplugging a monitor out from under a window in the QEMU VM + /// and watching it vanish; the field-only check had passed its unit + /// tests because those set `monitor` explicitly. pub fn set_monitors(&mut self, monitors: Vec<Monitor>) { self.monitors = monitors; + + let Some(primary) = self.primary_monitor().cloned() else { + // No monitors at all (every output unplugged): leave windows + // as-is rather than collapsing them onto nothing, so they are + // restored intact when an output comes back. + return; + }; + let live = self.monitors.clone(); + for window in self.windows.values_mut() { + let visible_on = live.iter().find(|m| m.geometry.overlaps(&window.geometry)); + match visible_on { + // Still on screen: just make sure its monitor id points at a + // monitor that exists, so tiling keeps working. + Some(monitor) => { + if !live.iter().any(|m| m.id == window.monitor) { + window.monitor = monitor.id; + } + } + // Nothing on screen shows this window any more. + None => { + window.geometry = window.geometry.clamped_into(primary.geometry); + window.monitor = primary.id; + } + } + } } pub fn monitors(&self) -> &[Monitor] { @@ -796,4 +843,121 @@ mod tests { assert_ne!(wm.window(a).unwrap().workspace, ws2); assert!(wm.workspace(ws2).is_none()); } + + // ---- Monitor hotplug ------------------------------------------------- + + fn two_monitors() -> Vec<Monitor> { + let mut a = Monitor::new(0, "primary", Rect::new(0, 0, 1280, 800)); + a.primary = true; + let b = Monitor::new(1, "secondary", Rect::new(1280, 0, 1920, 1080)); + vec![a, b] + } + + #[test] + fn unplugging_a_monitor_rehomes_its_windows_to_the_primary() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "on-second-monitor"); + w.geometry = Rect::new(1500, 200, 600, 400); // inside monitor 1 only + wm.add_window(w); + wm.window_mut(id).unwrap().monitor = 1; + + // Monitor 1 goes away. + wm.set_monitors(vec![two_monitors().remove(0)]); + + let w = wm.window(id).unwrap(); + assert_eq!(w.monitor, 0, "window should be rehomed to the primary monitor"); + assert!( + Rect::new(0, 0, 1280, 800).overlaps(&w.geometry), + "rehomed window should be on-screen, got {:?}", + w.geometry + ); + } + + #[test] + fn windows_already_on_a_surviving_monitor_are_left_alone() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "on-primary"); + w.geometry = Rect::new(10, 20, 300, 200); + wm.add_window(w); + wm.window_mut(id).unwrap().monitor = 0; + wm.window_mut(id).unwrap().geometry = Rect::new(10, 20, 300, 200); + + wm.set_monitors(vec![two_monitors().remove(0)]); + + let w = wm.window(id).unwrap(); + assert_eq!(w.monitor, 0); + assert_eq!(w.geometry, Rect::new(10, 20, 300, 200), "untouched window must not move"); + } + + #[test] + fn a_window_still_overlapping_the_primary_keeps_its_geometry() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "straddling"); + wm.add_window(w.clone()); + // Straddles the boundary, so it still overlaps the primary. + w.geometry = Rect::new(1200, 100, 400, 300); + wm.window_mut(id).unwrap().monitor = 1; + wm.window_mut(id).unwrap().geometry = w.geometry; + + wm.set_monitors(vec![two_monitors().remove(0)]); + + let got = wm.window(id).unwrap(); + assert_eq!(got.monitor, 0, "monitor id must still be remapped"); + assert_eq!(got.geometry, Rect::new(1200, 100, 400, 300), "already-visible geometry should be kept"); + } + + #[test] + fn losing_every_monitor_leaves_windows_intact_for_when_one_returns() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "orphan"); + w.geometry = Rect::new(1500, 200, 600, 400); + wm.add_window(w); + wm.window_mut(id).unwrap().monitor = 1; + wm.window_mut(id).unwrap().geometry = Rect::new(1500, 200, 600, 400); + + wm.set_monitors(Vec::new()); + + let got = wm.window(id).unwrap(); + assert_eq!(got.geometry, Rect::new(1500, 200, 600, 400)); + assert_eq!(got.monitor, 1); + } + + #[test] + fn a_window_whose_monitor_field_is_stale_is_still_rescued() { + // Regression: `add_window` assigns `monitor` from the *primary* + // monitor, so a window placed on the second monitor by a rule (or + // dragged there) keeps `monitor == 0`. Rehoming that keyed off the + // field alone skipped this window entirely and left it off-screen. + // Reproduced live by unplugging a monitor out from under an xterm. + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + + let id = wm.alloc_window_id(); + let w = Window::new(id, "placed-by-rule"); + wm.add_window(w); + // Geometry on monitor 1, but `monitor` still says 0 - exactly what + // add_window + a geometry rule produce. + wm.window_mut(id).unwrap().geometry = Rect::new(1500, 200, 600, 400); + assert_eq!(wm.window(id).unwrap().monitor, 0, "precondition: stale field"); + + wm.set_monitors(vec![two_monitors().remove(0)]); + + let got = wm.window(id).unwrap(); + assert!( + Rect::new(0, 0, 1280, 800).overlaps(&got.geometry), + "window must be pulled back on-screen, got {:?}", + got.geometry + ); + } } diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs index cc7755e..8847631 100644 --- a/crates/srdwm/src/main.rs +++ b/crates/srdwm/src/main.rs @@ -136,6 +136,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { dirty = true; } Event::WindowMoved { .. } | Event::WindowResized { .. } => dirty = true, + // A monitor was plugged in or unplugged. Re-query the whole + // list rather than applying the single monitor in the event: + // outputs are laid out left-to-right, so adding or removing + // one shifts the positions of the others too. + Event::MonitorAdded(_) | Event::MonitorRemoved(_) => { + match platform.monitors() { + Ok(monitors) => { + log::info!("monitor layout changed: {} monitor(s)", monitors.len()); + wm.borrow_mut().set_monitors(monitors); + } + Err(e) => log::warn!("failed to re-query monitors after hotplug: {e}"), + } + dirty = true; + } _ => {} } } diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs index 2b491e7..aca4865 100644 --- a/crates/wayland/src/udev.rs +++ b/crates/wayland/src/udev.rs @@ -6,8 +6,8 @@ //! - Single primary GPU, but **every** connected connector on it: each //! becomes a [`UdevHead`] with its own scanout buffers, damage tracker //! and page-flip state, laid out left-to-right in the global coordinate -//! space. Connectors are probed once at startup - no hotplug, and no -//! second GPU. +//! space. Connectors are re-probed on hotplug (see `reprobe_outputs`); +//! a second GPU is not supported. //! - Rendering is **software**, via smithay's `PixmanRenderer` compositing //! into plain KMS "dumb buffers" through the legacy (non-atomic) mode-set //! API (`set_crtc`/`page_flip`). This deliberately avoids the @@ -43,9 +43,9 @@ use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::pixman::PixmanRenderer; use smithay::backend::renderer::Bind; use smithay::backend::session::{libseat::LibSeatSession, libseat::LibSeatSessionNotifier, Event as SessionEvent, Session}; -use smithay::backend::udev; +use smithay::backend::udev::{self, UdevBackend, UdevEvent}; use smithay::desktop::space::render_output; -use smithay::desktop::Space; +use smithay::desktop::{layer_map_for_output, Space}; use smithay::input::pointer::AxisFrame; use smithay::input::SeatState; use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel}; @@ -60,7 +60,8 @@ use smithay::reexports::drm::Device as BasicDevice; use smithay::reexports::input::Libinput; use smithay::reexports::pixman::{FormatCode, Image}; use smithay::reexports::rustix; -use smithay::reexports::wayland_server::{Client, Display, ListeningSocket}; +use smithay::reexports::wayland_server::backend::GlobalId; +use smithay::reexports::wayland_server::{Client, Display, DisplayHandle, ListeningSocket}; use smithay::utils::{Logical, Point, Transform}; use smithay::wayland::compositor::CompositorState; use smithay::wayland::selection::data_device::DataDeviceState; @@ -103,7 +104,13 @@ pub(crate) struct DrmBuffer { /// here but on [`UdevState`], since all heads on one GPU share it. pub(crate) struct UdevHead { pub(crate) crtc: crtc::Handle, + /// Which connector this head drives - the key hotplug diffs against. + pub(crate) connector: connector::Handle, pub(crate) output: Output, + /// The `wl_output` global, kept so it can be destroyed when the monitor + /// is unplugged; leaving it advertised would show clients a screen that + /// no longer exists. + pub(crate) global: GlobalId, pub(crate) damage_tracker: OutputDamageTracker, pub(crate) buffers: [DrmBuffer; 2], pub(crate) front: usize, @@ -275,7 +282,126 @@ impl CompState { } } +impl CompState { + /// Re-probes connectors after a hotplug and reconciles the head list. + /// + /// Connectors that vanished have their head torn down (global removed, + /// output unmapped, DRM buffers freed); newly connected ones are brought + /// up exactly as they would have been at startup. Every head is then + /// repositioned left-to-right, because removing a monitor shifts the + /// ones after it. + pub(crate) fn reprobe_outputs(&mut self) { + let Some(udev) = self.udev.as_ref() else { return }; + let card = udev.card.clone(); + + let probes = match probe_connected(&card) { + Ok(p) => p, + Err(e) => { + log::warn!("udev: hotplug re-probe failed: {e}"); + return; + } + }; + let present: Vec<connector::Handle> = probes.iter().map(|p| p.connector).collect(); + let existing: Vec<connector::Handle> = udev.heads.iter().map(|h| h.connector).collect(); + + let gone: Vec<connector::Handle> = existing.iter().copied().filter(|c| !present.contains(c)).collect(); + let added: Vec<usize> = probes + .iter() + .enumerate() + .filter(|(_, p)| !existing.contains(&p.connector)) + .map(|(i, _)| i) + .collect(); + if gone.is_empty() && added.is_empty() { + return; // a "changed" event that didn't change the connector set + } + log::info!("udev: hotplug - {} output(s) removed, {} added", gone.len(), added.len()); + + // ---- removals ---- + for connector in &gone { + let Some(udev) = self.udev.as_mut() else { return }; + let Some(index) = udev.heads.iter().position(|h| h.connector == *connector) else { continue }; + let head = udev.heads.remove(index); + log::info!("udev: output {} disconnected", head.output.name()); + self.dh.remove_global::<CompState>(head.global.clone()); + self.space.unmap_output(&head.output); + self.outputs.retain(|e| e.output != head.output); + // A lock surface for a monitor that no longer exists would keep + // `confirm_lock_if_presented` waiting forever otherwise. + self.lock.surfaces.remove(&head.output.name()); + self.lock.presented.remove(&head.output.name()); + head.release(&card); + self.pending.borrow_mut().push(CoreEvent::MonitorRemoved(index as u32)); + } + + // ---- additions ---- + for i in added { + let probe = &probes[i]; + let used: Vec<crtc::Handle> = + self.udev.as_ref().map(|u| u.heads.iter().map(|h| h.crtc).collect()).unwrap_or_default(); + let Some(crtc) = pick_crtc(&card, probe, &used) else { + log::warn!("udev: no free CRTC for newly connected {}; not driving it", probe.name); + continue; + }; + // Placed at 0 for now; the re-layout below assigns real offsets. + match bring_up_head(&card, &self.dh.clone(), probe, crtc, 0) { + Ok((head, entry)) => { + log::info!("udev: output {} connected ({}x{})", probe.name, head.size.0, head.size.1); + let monitor_id = self.outputs.len() as u32; + let geometry = srdwm_core::Rect::new(0, 0, head.size.0 as u32, head.size.1 as u32); + if let Some(udev) = self.udev.as_mut() { + udev.heads.push(head); + } + self.outputs.push(entry); + self.pending + .borrow_mut() + .push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(monitor_id, probe.name.clone(), geometry))); + } + Err(e) => log::warn!("udev: failed to bring up {}: {e}", probe.name), + } + } + + self.relayout_outputs(); + } + + /// Repositions every head left-to-right and republishes the new + /// positions to the output globals, the `Space`, and the layer maps. + fn relayout_outputs(&mut self) { + let Some(udev) = self.udev.as_mut() else { return }; + let mut x = 0; + let mut placed: Vec<(Output, Point<i32, Logical>)> = Vec::new(); + for head in &mut udev.heads { + head.location = (x, 0).into(); + head.output.change_current_state(None, None, None, Some((x, 0).into())); + placed.push((head.output.clone(), head.location)); + x += head.size.0; + } + for (output, location) in placed { + if let Some(entry) = self.outputs.iter_mut().find(|e| e.output == output) { + entry.location = location; + } + self.space.map_output(&output, (location.x, location.y)); + // Bars are anchored to their output, so their geometry has to be + // recomputed against the moved output rectangle. + layer_map_for_output(&output).arrange(); + } + } +} + impl UdevHead { + /// Frees the DRM resources this head owns. Dropping the Rust structs + /// alone would leak the kernel-side framebuffers and dumb buffers, + /// which matters when a monitor is plugged and unplugged repeatedly. + fn release(self, card: &Card) { + for buffer in self.buffers { + if let Err(e) = card.destroy_framebuffer(buffer.fb) { + log::warn!("udev: destroy_framebuffer failed: {e}"); + } + if let Err(e) = card.destroy_dumb_buffer(buffer.dumb) { + log::warn!("udev: destroy_dumb_buffer failed: {e}"); + } + } + } + /// Copies the just-rendered pixman image into buffer `back`'s dumb /// buffer (software rendering writes into its own owned image, not the /// scanout memory directly, to avoid tying that image's lifetime to an @@ -328,7 +454,7 @@ impl UdevPlatform { let card = Rc::new(Card(fd)); // Every connected connector becomes a head, laid out left-to-right. - let connected = find_connected_outputs(&card)?; + let connected = probe_connected(&card)?; log::info!("udev: {} connected output(s)", connected.len()); let renderer = PixmanRenderer::new().map_err(err)?; @@ -337,46 +463,21 @@ impl UdevPlatform { let mut heads: Vec<UdevHead> = Vec::new(); let mut output_entries: Vec<crate::state::OutputEntry> = Vec::new(); + let mut used_crtcs: Vec<crtc::Handle> = Vec::new(); let mut x_offset = 0; - for (index, probe) in connected.iter().enumerate() { - let (width, height) = probe.mode.size(); - let (width, height) = (width as i32, height as i32); - - let buffers = [make_drm_buffer(&card, width, height)?, make_drm_buffer(&card, width, height)?]; - card.set_crtc(probe.crtc, Some(buffers[0].fb), (0, 0), &[probe.connector], Some(probe.mode)) - .map_err(err)?; - - // Named after the real connector (eDP-1, HDMI-A-1, ...) so - // clients and the user can tell monitors apart; `wl_output.name` - // is what a bar's per-monitor config keys off. - let output = Output::new( - probe.name.clone(), - PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() }, - ); - output.change_current_state( - Some(OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) }), - Some(Transform::Normal), - None, - Some((x_offset, 0).into()), - ); - output.set_preferred(OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) }); - output.create_global::<CompState>(&display_handle); - - let location: Point<i32, Logical> = (x_offset, 0).into(); - heads.push(UdevHead { - crtc: probe.crtc, - output: output.clone(), - damage_tracker: OutputDamageTracker::from_output(&output), - buffers, - front: 0, - flip_pending: false, - location, - size: (width, height), - }); - output_entries.push(crate::state::OutputEntry { output, location }); - log::info!("udev: head {index}: {} {width}x{height} at x={x_offset}", probe.name); - x_offset += width; + for probe in &connected { + let Some(crtc) = pick_crtc(&card, probe, &used_crtcs) else { + log::warn!("udev: no free CRTC left for connector {}; not driving it", probe.name); + continue; + }; + let (head, entry) = bring_up_head(&card, &display_handle, probe, crtc, x_offset)?; + log::info!("udev: head {}: {} {}x{} at x={x_offset}", heads.len(), probe.name, head.size.0, head.size.1); + used_crtcs.push(crtc); + x_offset += head.size.0; + heads.push(head); + output_entries.push(entry); } + let Some(first) = heads.first() else { return Err(PlatformError::Other("udev: no usable outputs".into())); }; @@ -461,6 +562,9 @@ impl UdevPlatform { register_drm_fd(&handle, &card)?; register_libinput(&handle, &session, &seat_name)?; register_session_notifier(&handle, notifier)?; + if let Err(e) = register_udev_monitor(&handle, &seat_name) { + log::warn!("udev: connector hotplug unavailable ({e}); monitors are fixed at startup"); + } if let Err(e) = crate::xwayland::spawn(&handle, &display_handle) { log::warn!("XWayland unavailable ({e}); X11-only clients will not run"); } @@ -486,72 +590,113 @@ fn mode_refresh_mhz(mode: &DrmMode) -> i32 { } } -/// A connector we intend to drive, paired with the CRTC that will scan it -/// out. Produced once at startup by [`find_connected_outputs`]. -struct OutputProbe { +/// Brings one connector up: allocates its scanout buffers, sets the mode, +/// and creates the `wl_output` global. Shared by startup and hotplug so a +/// monitor plugged in later is set up exactly like one present at boot. +fn bring_up_head( + card: &Card, + dh: &DisplayHandle, + probe: &ConnectorProbe, crtc: crtc::Handle, + x_offset: i32, +) -> PlatformResult<(UdevHead, crate::state::OutputEntry)> { + let (width, height) = probe.mode.size(); + let (width, height) = (width as i32, height as i32); + + let buffers = [make_drm_buffer(card, width, height)?, make_drm_buffer(card, width, height)?]; + card.set_crtc(crtc, Some(buffers[0].fb), (0, 0), &[probe.connector], Some(probe.mode)).map_err(err)?; + + // Named after the real connector (eDP-1, HDMI-A-1, ...) so clients and + // the user can tell monitors apart; `wl_output.name` is what a bar's + // per-monitor config keys off. + let output = Output::new( + probe.name.clone(), + PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() }, + ); + let mode = OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) }; + output.change_current_state(Some(mode), Some(Transform::Normal), None, Some((x_offset, 0).into())); + output.set_preferred(mode); + let global = output.create_global::<CompState>(dh); + + let location: Point<i32, Logical> = (x_offset, 0).into(); + let head = UdevHead { + crtc, + connector: probe.connector, + output: output.clone(), + global, + damage_tracker: OutputDamageTracker::from_output(&output), + buffers, + front: 0, + flip_pending: false, + location, + size: (width, height), + }; + Ok((head, crate::state::OutputEntry { output, location })) +} + +/// A connected connector and the mode we intend to drive it at. CRTC +/// assignment is deliberately separate ([`pick_crtc`]) so a hotplug re-probe +/// can leave surviving heads on the CRTCs they already hold. +struct ConnectorProbe { connector: connector::Handle, + info: connector::Info, mode: DrmMode, /// Connector name as the kernel reports it (`eDP-1`, `HDMI-A-1`, ...). name: String, } -/// Every connected connector, each assigned a distinct CRTC. -/// -/// CRTCs are a finite hardware resource and cannot be shared, so a CRTC -/// already claimed by an earlier connector is skipped - a machine with more -/// connected monitors than CRTCs drives as many as the hardware allows and -/// logs the rest rather than failing outright. +/// Every connector currently reporting `Connected`, with its preferred mode. /// -/// Still no hotplug: connectors are probed once at startup. Plugging a -/// monitor in later needs a udev event handler, which this backend does not -/// register yet (see `docs/IMPLEMENTATION_STATUS.md`). -fn find_connected_outputs(card: &Card) -> PlatformResult<Vec<OutputProbe>> { +/// Forces a fresh probe (`get_connector(.., true)`) rather than trusting +/// cached state - on a hotplug the cached status is exactly what has gone +/// stale. +fn probe_connected(card: &Card) -> PlatformResult<Vec<ConnectorProbe>> { let res = card.resource_handles().map_err(err)?; - let connectors: Vec<connector::Info> = res.connectors().iter().flat_map(|&h| card.get_connector(h, true)).collect(); - - let mut used: Vec<crtc::Handle> = Vec::new(); let mut probes = Vec::new(); - for con in connectors.iter().filter(|c| c.state() == connector::State::Connected) { - let name = format!("{:?}-{}", con.interface(), con.interface_id()); + for handle in res.connectors() { + let Ok(info) = card.get_connector(*handle, true) else { continue }; + if info.state() != connector::State::Connected { + continue; + } + let name = format!("{:?}-{}", info.interface(), info.interface_id()); // Prefer the mode the display advertises as PREFERRED (its native // resolution) rather than whatever happens to be listed first -- // the list order is not guaranteed, and picking wrong means running - // a monitor at the wrong resolution. Falls back to the first mode - // for connectors that flag none. - let Some(&mode) = con + // a monitor at the wrong resolution. + let Some(&mode) = info .modes() .iter() .find(|m| m.mode_type().contains(ModeTypeFlags::PREFERRED)) - .or_else(|| con.modes().first()) + .or_else(|| info.modes().first()) else { log::warn!("udev: connector {name} is connected but reports no modes; skipping"); continue; }; - // Prefer the CRTC already driving this connector, else any free one - // the encoder can reach. - let candidates: Vec<crtc::Handle> = con - .current_encoder() - .and_then(|enc| card.get_encoder(enc).ok()) - .map(|enc| res.filter_crtcs(enc.possible_crtcs())) - .unwrap_or_default() - .into_iter() - .chain(res.crtcs().iter().copied()) - .collect(); - let Some(crtc) = candidates.into_iter().find(|c| !used.contains(c)) else { - log::warn!("udev: no free CRTC left for connector {name}; not driving it"); - continue; - }; - used.push(crtc); - probes.push(OutputProbe { crtc, connector: con.handle(), mode, name }); - } - - if probes.is_empty() { - return Err(PlatformError::Other("udev: no connected connector found".into())); + probes.push(ConnectorProbe { connector: *handle, info, mode, name }); } Ok(probes) } +/// Picks a CRTC for `probe` that is not in `used`. +/// +/// CRTCs are a finite hardware resource and cannot be shared, so a machine +/// with more connected monitors than CRTCs drives as many as the hardware +/// allows and logs the rest rather than failing outright. +fn pick_crtc(card: &Card, probe: &ConnectorProbe, used: &[crtc::Handle]) -> Option<crtc::Handle> { + let res = card.resource_handles().ok()?; + // Prefer the CRTC already driving this connector, else any free one the + // encoder can reach, else anything free at all. + probe + .info + .current_encoder() + .and_then(|enc| card.get_encoder(enc).ok()) + .map(|enc| res.filter_crtcs(enc.possible_crtcs())) + .unwrap_or_default() + .into_iter() + .chain(res.crtcs().iter().copied()) + .find(|c| !used.contains(c)) +} + fn make_drm_buffer(card: &Card, width: i32, height: i32) -> PlatformResult<DrmBuffer> { let dumb = card.create_dumb_buffer((width as u32, height as u32), DrmFourcc::Xrgb8888, 32).map_err(err)?; let fb = card.add_framebuffer(&dumb, 24, 32).map_err(err)?; @@ -643,6 +788,30 @@ fn register_session_notifier(handle: &LoopHandle<'static, CompState>, notifier: Ok(()) } +/// Watches udev for DRM device changes. The kernel emits a `change` uevent +/// on the card when a connector is plugged or unplugged, which smithay +/// surfaces as [`UdevEvent::Changed`] - that is the hotplug signal. +/// +/// `Added`/`Removed` refer to whole GPUs appearing or disappearing, which +/// this backend does not support (it binds one primary GPU at startup), so +/// they are logged and ignored rather than silently dropped. +fn register_udev_monitor(handle: &LoopHandle<'static, CompState>, seat_name: &str) -> PlatformResult<()> { + let backend = UdevBackend::new(seat_name).map_err(err)?; + handle + .insert_source(backend, move |event, _, data: &mut CompState| match event { + UdevEvent::Changed { .. } => { + data.reprobe_outputs(); + data.render_udev_frame(); + } + UdevEvent::Added { path, .. } => { + log::info!("udev: new GPU {} appeared; multi-GPU is not supported, ignoring", path.display()) + } + UdevEvent::Removed { .. } => log::info!("udev: a GPU was removed; multi-GPU is not supported, ignoring"), + }) + .map_err(|e| PlatformError::Other(format!("failed to register udev monitor: {e}")))?; + Ok(()) +} + fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputBackend>) { match event { InputEvent::Keyboard { event } => handle_keyboard_key_event(state, &event), diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ae78cd0..35635c0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -389,6 +389,53 @@ once, so splitting it across three files would have hidden it. pixels differed from the clear colour). The nested winit backend remains single-output by construction (it is one window on a host compositor). +- ✅ **Connector hotplug**. A `UdevBackend` event source watches for the + kernel's `change` uevent on the DRM device; `CompState::reprobe_outputs` + then re-probes connectors (forcing a fresh probe - on a hotplug the + cached status is exactly what has gone stale) and reconciles the head + list. Vanished connectors have their head torn down: `wl_output` global + removed, output unmapped from the `Space`, DRM framebuffers and dumb + buffers explicitly freed (dropping the Rust structs alone leaks the + kernel-side objects, which matters when a cable is plugged repeatedly), + and any lock surface for that output dropped - otherwise + `confirm_lock_if_presented` would wait forever for a monitor that no + longer exists. New connectors are brought up through the same + `bring_up_head` path used at startup. Every head is then repositioned + left-to-right, since removing one shifts the rest, and the layer maps are + re-arranged so bars follow their moved output. + `WindowManager::set_monitors` rehomes windows left stranded, and + `main.rs` re-queries the whole monitor list on `MonitorAdded`/ + `MonitorRemoved` rather than applying the single monitor in the event + (positions of the others change too). + **Verified live in the QEMU VM**, booting with one connector and toggling + the second at runtime: + - plug in → `hotplug - 0 output(s) removed, 1 added`, + `output Virtual-2 connected (1024x768)`, `monitor layout changed: + 2 monitor(s)`, and a screendump of the new head showed it really + rendering at its own resolution; + - unplug → `1 output(s) removed, 0 added`, back to 1 monitor, compositor + healthy; + - **window rescue**: an xterm placed by rule at global x=1500 (on the + second monitor) was still visible after that monitor was unplugged -- + it reappeared on the remaining head at x=680, exactly + `min(1500, 1280-600)`, keeping its 600x400 size. + Caveat on method: writing to `/sys/class/drm/<connector>/status` changes + the connector but emits **no uevent** on this kernel, so the uevent the + kernel would send on real hardware is synthesized with `udevadm trigger + --subsystem-match=drm --action=change`. The reaction path - re-probe, + diff, bring up/tear down, re-layout, rehome - is genuinely exercised; + only the initial signal is injected. + + This turned up a real bug that the unit tests had **missed**: rehoming + originally keyed off `Window::monitor`, but that field records the + monitor a window was *assigned* at creation, not where it actually is -- + `add_window` always sets it from the primary monitor, so a window placed + on the second monitor by a rule (or dragged there) still reads + `monitor == 0`. The field-only check saw a valid id, skipped the window, + and left it at coordinates that no longer existed: invisible and + unreachable. Caught by unplugging a monitor out from under a real xterm + and watching it vanish from both heads. `set_monitors` now keys off + geometry, with a regression test that fails against the old logic. **Known limitation of the nested (winit) backend**: it renders through the host compositor's frame callbacks, so if the srdwm window is occluded or on @@ -473,12 +520,8 @@ being a real daily-driver session (bars/launchers/notifications/lock UIs, clipboard, screen locking) are now implemented and verified - see the Wayland backend section above. What is left: -- **Connector hotplug** - connectors are probed once at startup, so - plugging a monitor in (or unplugging one) while srdwm is running is not - noticed. Needs a udev event source, which this backend does not register - yet. Multi-monitor itself *is* implemented (see above); only hotplug is - missing. -- **Multi-GPU** - only the primary GPU's connectors are driven. +- **Multi-GPU** - only the primary GPU's connectors are driven. A GPU + appearing or disappearing is logged and ignored. - Animations (`general.animations`/`animation_duration` config keys exist and are read into defaults, but nothing consumes them yet). - A native GUI settings app (the legacy project's `GUI_SETTINGS.md` was |