Recover known GPUs across DRM node renumbering

This commit is contained in:
Samuel Onoja committed 2026-10-02 09:52:18 +01:00
1 parent 428267a714
commit bbb1eac189
3 files changed
+130 -46

No files matched your search

+10 -4
View File
@@ -266,7 +266,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
.unwrap() .unwrap()
.topology .topology
.devices .devices
.insert(primary_path, Default::default()); .insert(primary_path.clone(), topology::DeviceState::known(&primary_path));
lid::request_refresh(state, false); lid::request_refresh(state, false);
event_loop event_loop
@@ -508,9 +508,6 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
.and_then(Result::ok) .and_then(Result::ok)
.unwrap_or(node); .unwrap_or(node);
let feedback = DmabufFeedbackBuilder::new(render_node.dev_id(), dmabuf_formats).build()?; let feedback = DmabufFeedbackBuilder::new(render_node.dev_id(), dmabuf_formats).build()?;
let dmabuf_global = state
.dmabuf_state
.create_global_with_default_feedback::<Ferese>(&display_handle, &feedback);
let registration = state let registration = state
.loop_handle .loop_handle
.insert_source(notifier, move |event, metadata, state| match event { .insert_source(notifier, move |event, metadata, state| match event {
@@ -633,6 +630,9 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
} }
})?; })?;
let dmabuf_global = state
.dmabuf_state
.create_global_with_default_feedback::<Ferese>(&display_handle, &feedback);
state state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -1311,6 +1311,12 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) -> Result<bool, ()> {
} }
{ {
// 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() { for output in device.outputs.values() {
if let Some(info) = device if let Some(info) = device
.connected_outputs .connected_outputs
+89 -42
View File
@@ -13,6 +13,7 @@ pub(super) enum Availability {
pub(super) struct DeviceState { pub(super) struct DeviceState {
pub availability: Availability, pub availability: Availability,
identity: Option<PathBuf>,
failures: u32, failures: u32,
retry_at: Option<Instant>, retry_at: Option<Instant>,
} }
@@ -21,13 +22,36 @@ impl Default for DeviceState {
fn default() -> Self { fn default() -> Self {
Self { Self {
availability: Availability::Missing, availability: Availability::Missing,
identity: None,
failures: 0, failures: 0,
retry_at: None, retry_at: None,
} }
} }
} }
fn device_identity(path: &Path) -> Option<PathBuf> {
// 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 { 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<PathBuf>)]) -> 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) { pub fn failed(&mut self, now: Instant) {
self.availability = Availability::Unavailable; self.availability = Availability::Unavailable;
self.failures = self.failures.saturating_add(1); self.failures = self.failures.saturating_add(1);
@@ -41,7 +65,9 @@ impl DeviceState {
} }
pub fn missing(&mut self) { 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)); 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] #[test]
fn unavailable_devices_retry_independently_with_capped_backoff() { fn unavailable_devices_retry_independently_with_capped_backoff() {
let now = Instant::now(); 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. /// The only entry point for applying the current output configuration to DRM.
pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
let Some(backend) = state.direct_backend.as_mut() else { 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.reconciling = true;
backend.topology.dirty = false; backend.topology.dirty = false;
if reactivate { 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() { if let Some(token) = backend.topology.retry_timer.take() {
state.loop_handle.remove(token); state.loop_handle.remove(token);
} }
let paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>(); let paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
let invalidated = std::mem::take(&mut backend.topology.invalidated); 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::<Vec<_>>()
});
let automatic = state let automatic = state
.output_profiles .output_profiles
.iter() .iter()
@@ -141,27 +214,24 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
} }
for path in paths { 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() .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)); .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 slot.availability {
let previous = match state.direct_backend.as_ref().unwrap().topology.devices[&path].availability {
Availability::Ready(node) | Availability::Degraded(node) => Some(node), Availability::Ready(node) | Availability::Degraded(node) => Some(node),
_ => existing, _ => 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 !matches!(found, Ok(true)) {
if let Some(node) = previous { if let Some(node) = previous {
remove_device(state, node); remove_device(state, node);
} }
let entry = state let entry = slot_mut(state, &path);
.direct_backend
.as_mut()
.unwrap()
.topology
.devices
.get_mut(&path)
.unwrap();
match found { match found {
Ok(false) => entry.missing(), Ok(false) => entry.missing(),
Err(error) => { Err(error) => {
@@ -178,19 +248,11 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
} }
let node = match existing { let node = match existing {
Some(node) => node, Some(node) => node,
None => match open_device(state, &path) { None => match open_device(state, current_path.unwrap()) {
Ok(node) => node, Ok(node) => node,
Err(error) => { Err(error) => {
tracing::warn!(%error, ?path, "DRM device unavailable; will retry"); tracing::warn!(%error, ?path, "DRM device unavailable; will retry");
state slot_mut(state, &path).failed(Instant::now());
.direct_backend
.as_mut()
.unwrap()
.topology
.devices
.get_mut(&path)
.unwrap()
.failed(Instant::now());
continue; continue;
} }
}, },
@@ -223,15 +285,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
if let Err(error) = activation { if let Err(error) = activation {
tracing::warn!(%error, ?node, "DRM reactivation failed; retiring unavailable outputs"); tracing::warn!(%error, ?node, "DRM reactivation failed; retiring unavailable outputs");
remove_device(state, node); remove_device(state, node);
state slot_mut(state, &path).failed(Instant::now());
.direct_backend
.as_mut()
.unwrap()
.topology
.devices
.get_mut(&path)
.unwrap()
.failed(Instant::now());
continue; continue;
} }
@@ -261,14 +315,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
if complete.is_err() { if complete.is_err() {
remove_device(state, node); remove_device(state, node);
} }
let entry = state let entry = slot_mut(state, &path);
.direct_backend
.as_mut()
.unwrap()
.topology
.devices
.get_mut(&path)
.unwrap();
if complete == Ok(true) { if complete == Ok(true) {
entry.ready(node); entry.ready(node);
} else { } else {
+31
View File
@@ -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 Include the commit or installed release, reproduction steps, backend (nested or
direct), monitor scales/transforms, and relevant logs. Avoid publishing passwords, direct), monitor scales/transforms, and relevant logs. Avoid publishing passwords,
access tokens, or private window content. 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.