From bbb1eac189f473f708061aa7a8e46da7ecacdf9d Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 09:52:18 +0100 Subject: [PATCH] Recover known GPUs across DRM node renumbering --- compositor/src/backends/direct.rs | 14 ++- compositor/src/backends/direct/topology.rs | 131 ++++++++++++++------- docs/development.md | 31 +++++ 3 files changed, 130 insertions(+), 46 deletions(-) diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 850704c..6b8750a 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -266,7 +266,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() .unwrap() .topology .devices - .insert(primary_path, Default::default()); + .insert(primary_path.clone(), topology::DeviceState::known(&primary_path)); lid::request_refresh(state, false); event_loop @@ -508,9 +508,6 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result(&display_handle, &feedback); let registration = state .loop_handle .insert_source(notifier, move |event, metadata, state| match event { @@ -633,6 +630,9 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result(&display_handle, &feedback); state .direct_backend .as_mut() @@ -1311,6 +1311,12 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) -> Result { } { + // Connector detection is not proof that its requested mode was applied. + for info in &mut device.connected_outputs { + info.enabled = false; + info.current_mode = None; + } + for output in device.outputs.values() { if let Some(info) = device .connected_outputs diff --git a/compositor/src/backends/direct/topology.rs b/compositor/src/backends/direct/topology.rs index 2b1926f..5cfe1a2 100644 --- a/compositor/src/backends/direct/topology.rs +++ b/compositor/src/backends/direct/topology.rs @@ -13,6 +13,7 @@ pub(super) enum Availability { pub(super) struct DeviceState { pub availability: Availability, + identity: Option, failures: u32, retry_at: Option, } @@ -21,13 +22,36 @@ impl Default for DeviceState { fn default() -> Self { Self { availability: Availability::Missing, + identity: None, failures: 0, retry_at: None, } } } +fn device_identity(path: &Path) -> Option { + // The parent hardware identity survives cardN renumbering after unplug. + std::fs::canonicalize(Path::new("/sys/class/drm").join(path.file_name()?).join("device")).ok() +} + impl DeviceState { + pub fn known(path: &Path) -> Self { + Self { + identity: device_identity(path), + ..Self::default() + } + } + + fn current_path<'a>(&self, original: &Path, observed: &'a [(PathBuf, Option)]) -> Option<&'a Path> { + observed + .iter() + .find(|(path, identity)| match &self.identity { + Some(expected) => identity.as_ref() == Some(expected), + None => path == original, + }) + .map(|(path, _)| path.as_path()) + } + pub fn failed(&mut self, now: Instant) { self.availability = Availability::Unavailable; self.failures = self.failures.saturating_add(1); @@ -41,7 +65,9 @@ impl DeviceState { } pub fn missing(&mut self) { - *self = Self::default(); + self.availability = Availability::Missing; + self.failures = 0; + self.retry_at = None; } } @@ -82,6 +108,31 @@ mod tests { assert!(!topology.request(true)); } + #[test] + fn replug_matches_hardware_even_when_card_numbers_change() { + let original = Path::new("/dev/dri/card0"); + let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0"); + let mut slot = DeviceState { + identity: Some(identity.clone()), + ..DeviceState::default() + }; + slot.failed(Instant::now()); + slot.missing(); + let observed = vec![ + ( + PathBuf::from("/dev/dri/card0"), + Some(PathBuf::from("/sys/devices/other-gpu")), + ), + (PathBuf::from("/dev/dri/card2"), Some(identity)), + ]; + assert_eq!( + slot.current_path(original, &observed), + Some(Path::new("/dev/dri/card2")) + ); + assert!(slot.current_path(original, &observed[..1]).is_none()); + assert!(slot.current_path(original, &[]).is_none()); + } + #[test] fn unavailable_devices_retry_independently_with_capped_backoff() { let now = Instant::now(); @@ -109,6 +160,17 @@ mod tests { } } +fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState { + state + .direct_backend + .as_mut() + .unwrap() + .topology + .devices + .get_mut(path) + .unwrap() +} + /// The only entry point for applying the current output configuration to DRM. pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { let Some(backend) = state.direct_backend.as_mut() else { @@ -121,14 +183,25 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { backend.topology.reconciling = true; backend.topology.dirty = false; if reactivate { - backend.presentation.values_mut().for_each(PresentationClock::reset_timing); + backend + .presentation + .values_mut() + .for_each(PresentationClock::reset_timing); } if let Some(token) = backend.topology.retry_timer.take() { state.loop_handle.remove(token); } let paths = backend.topology.devices.keys().cloned().collect::>(); let invalidated = std::mem::take(&mut backend.topology.invalidated); - let observed = all_gpus(backend.session.seat()); + let observed = all_gpus(backend.session.seat()).map(|paths| { + paths + .into_iter() + .map(|path| { + let identity = device_identity(&path); + (path, identity) + }) + .collect::>() + }); let automatic = state .output_profiles .iter() @@ -141,27 +214,24 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { } for path in paths { - let existing = DrmNode::from_path(&path) + let slot = &state.direct_backend.as_ref().unwrap().topology.devices[&path]; + let current_path = observed + .as_ref() .ok() + .and_then(|devices| slot.current_path(&path, devices)); + let existing = current_path + .and_then(|path| DrmNode::from_path(path).ok()) .filter(|node| state.direct_backend.as_ref().unwrap().devices.contains_key(node)); - // If the path vanished, its retained identity still identifies the old fd. - let previous = match state.direct_backend.as_ref().unwrap().topology.devices[&path].availability { + let previous = match slot.availability { Availability::Ready(node) | Availability::Degraded(node) => Some(node), _ => existing, }; - let found = observed.as_ref().map(|paths| paths.contains(&path)); + let found = observed.as_ref().map(|_| current_path.is_some()); if !matches!(found, Ok(true)) { if let Some(node) = previous { remove_device(state, node); } - let entry = state - .direct_backend - .as_mut() - .unwrap() - .topology - .devices - .get_mut(&path) - .unwrap(); + let entry = slot_mut(state, &path); match found { Ok(false) => entry.missing(), Err(error) => { @@ -178,19 +248,11 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { } let node = match existing { Some(node) => node, - None => match open_device(state, &path) { + None => match open_device(state, current_path.unwrap()) { Ok(node) => node, Err(error) => { tracing::warn!(%error, ?path, "DRM device unavailable; will retry"); - state - .direct_backend - .as_mut() - .unwrap() - .topology - .devices - .get_mut(&path) - .unwrap() - .failed(Instant::now()); + slot_mut(state, &path).failed(Instant::now()); continue; } }, @@ -223,15 +285,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { if let Err(error) = activation { tracing::warn!(%error, ?node, "DRM reactivation failed; retiring unavailable outputs"); remove_device(state, node); - state - .direct_backend - .as_mut() - .unwrap() - .topology - .devices - .get_mut(&path) - .unwrap() - .failed(Instant::now()); + slot_mut(state, &path).failed(Instant::now()); continue; } @@ -261,14 +315,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { if complete.is_err() { remove_device(state, node); } - let entry = state - .direct_backend - .as_mut() - .unwrap() - .topology - .devices - .get_mut(&path) - .unwrap(); + let entry = slot_mut(state, &path); if complete == Ok(true) { entry.ready(node); } else { diff --git a/docs/development.md b/docs/development.md index 2d067b9..892a561 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,3 +74,34 @@ multiple monitors, display sleep, and live authentication still need session tes Include the commit or installed release, reproduction steps, backend (nested or direct), monitor scales/transforms, and relevant logs. Avoid publishing passwords, access tokens, or private window content. + +## Resume recovery + +The direct backend retains known GPU identities while their DRM resources are +unavailable. Hotplug, configuration and lid changes reconcile through one path; +inactive sessions defer that work. Activation reads the lid asynchronously, then +re-enumerates devices and connectors before allowing presentation. A logind +system-wake signal follows the same path, including when seat ownership did not +change. Newer libinput observations supersede in-flight lid reads. A failed read +preserves the last observation; a three-second deadline prevents a stalled bus +from blocking recovery indefinitely. + +Device failures retire their output globals and retry with exponential backoff +capped at 32 seconds. Working devices remain usable. Known hardware can return +under a different `cardN` path. This does not expand support to previously +unmanaged secondary GPUs. Connected-output reports mark a mode active only when +output creation succeeded. Lock ownership is retained throughout recovery. + +Run the ordering and private D-Bus checks with: + +```sh +cargo test --locked -p ferese backends::direct::topology +cargo test --locked -p ferese backends::direct::lid -- --include-ignored +``` + +The D-Bus test requires `dbus-daemon` and permission to create private sockets. +Hardware validation still needs a direct session: change monitors while on +another VT, suspend/resume with a dock disconnected, change the lid while +suspended, and unplug/reconnect the managed GPU where supported. Verify current +output geometry, idle notifications, recovery after failures and continued lock +protection. Nested sessions do not validate DRM reacquisition.