drmkit_display/hotplug.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Noticing that a display was plugged in or pulled out.
5//!
6//! The kernel says so through a udev uevent rather than through anything on
7//! the DRM descriptor, so this is a second thing to poll alongside the card.
8
9use std::path::{Path, PathBuf};
10
11use udev::{EventType, MonitorBuilder, MonitorSocket};
12
13use drmkit_core::{CoreError, Result};
14
15/// One connector changing.
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct HotplugEvent {
19 /// The card the kernel named, usually `/dev/dri/cardN`.
20 ///
21 /// Absent when the uevent carried no `DEVNAME`. Owned rather than
22 /// borrowed from the udev device, which is freed as soon as the event has
23 /// been read.
24 pub card: Option<PathBuf>,
25
26 /// Which connector, when the kernel says.
27 ///
28 /// The `CONNECTOR=<id>` hint arrived in 4.16. Absent on older kernels,
29 /// and absent on the blanket events a GPU reset produces -- and absent is
30 /// not "nothing changed", it is "re-enumerate everything", because the
31 /// alternative is a display that was plugged in and never noticed.
32 pub connector_id: Option<u32>,
33}
34
35/// Where the uevents are read from.
36///
37/// Not a detail on the machines drmkit is for. A desktop has udevd, which
38/// applies its rules and only then republishes the event -- by which point the
39/// device node exists with the right permissions. A minimal embedded init
40/// often has no udevd at all, and a monitor waiting for one to republish
41/// waits forever, so the display that was plugged in is never noticed.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43#[non_exhaustive]
44pub enum Source {
45 /// What udevd republishes. The right answer where udevd runs.
46 #[default]
47 Udev,
48 /// The kernel's own netlink broadcast, which arrives whether or not
49 /// anything else is listening.
50 Kernel,
51}
52
53/// A pollable watch on DRM hotplug.
54///
55/// Add [`AsFd`](std::os::fd::AsFd) to the poll set that already has the input
56/// devices and the card in it, and call [`dispatch`](Self::dispatch) when it
57/// is readable.
58pub struct HotplugMonitor {
59 socket: MonitorSocket,
60}
61
62impl HotplugMonitor {
63 /// Start watching.
64 ///
65 /// # Errors
66 ///
67 /// [`CoreError::Io`] if udev is unavailable -- no `/run/udev`, or a
68 /// container without the netlink socket. A caller that cannot watch can
69 /// still poll connectors on a timer; what it loses is knowing *when*.
70 pub fn open(source: Source) -> Result<Self> {
71 let builder = match source {
72 Source::Udev => MonitorBuilder::new(),
73 Source::Kernel => MonitorBuilder::new_kernel(),
74 };
75 let socket = builder
76 .and_then(|builder| builder.match_subsystem("drm"))
77 .and_then(MonitorBuilder::listen)
78 .map_err(|error| CoreError::Io(io_errno(&error)))?;
79 Ok(Self { socket })
80 }
81
82 /// Read every pending uevent, appending the ones that are hotplug.
83 ///
84 /// Everything is drained whether or not it is kept, so the descriptor
85 /// goes back to unreadable and a caller polling in a loop does not spin.
86 pub fn dispatch(&mut self, out: &mut Vec<HotplugEvent>) {
87 for event in self.socket.iter() {
88 // The netlink filter matches on subsystem alone. DRM's `change`
89 // also fires for lease updates and property-blob swaps, and
90 // `HOTPLUG=1` is what separates a display being plugged in from
91 // a compositor having set a property.
92 let device = event.device();
93 let hotplug = device
94 .property_value("HOTPLUG")
95 .and_then(std::ffi::OsStr::to_str);
96 if !is_hotplug(event.event_type(), hotplug) {
97 continue;
98 }
99 out.push(HotplugEvent {
100 card: device.devnode().map(Path::to_path_buf),
101 connector_id: parse_connector_id(
102 device
103 .property_value("CONNECTOR")
104 .and_then(std::ffi::OsStr::to_str),
105 ),
106 });
107 }
108 }
109}
110
111impl std::fmt::Debug for HotplugMonitor {
112 /// The socket has nothing printable in it; the descriptor is what a
113 /// caller debugging a poll set is looking for.
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 use std::os::fd::AsFd as _;
116 f.debug_struct("HotplugMonitor")
117 .field("fd", &self.socket.as_fd())
118 .finish()
119 }
120}
121
122impl std::os::fd::AsFd for HotplugMonitor {
123 fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
124 self.socket.as_fd()
125 }
126}
127
128/// Whether one uevent is a display being plugged in or pulled out.
129///
130/// The netlink filter matches on subsystem alone, and DRM's `change` also
131/// fires for lease updates and property-blob swaps -- several times a second
132/// on a running compositor. `HOTPLUG=1` is what separates them, and acting on
133/// the rest means re-enumerating every connector on every frame.
134fn is_hotplug(event_type: EventType, hotplug: Option<&str>) -> bool {
135 event_type == EventType::Change && hotplug == Some("1")
136}
137
138/// The kernel's `CONNECTOR=<id>` hint, if it is one.
139///
140/// Anything that is not a whole number is treated as absent rather than
141/// guessed at: absent means re-enumerate, which is correct but slow, while a
142/// wrong id means looking at a connector that did not change and missing the
143/// one that did.
144fn parse_connector_id(value: Option<&str>) -> Option<u32> {
145 value?.parse().ok()
146}
147
148fn io_errno(error: &std::io::Error) -> rustix::io::Errno {
149 rustix::io::Errno::from_io_error(error).unwrap_or(rustix::io::Errno::IO)
150}
151
152#[cfg(test)]
153mod tests {
154 use super::{EventType, is_hotplug, parse_connector_id};
155
156 #[test]
157 fn only_a_change_carrying_hotplug_counts() {
158 assert!(is_hotplug(EventType::Change, Some("1")));
159 }
160
161 #[test]
162 fn an_ordinary_change_is_ignored() {
163 // A compositor setting a CRTC property produces one of these, and on
164 // a running system that is several a second. Re-enumerating every
165 // connector each time is a round trip to every sink on the machine.
166 assert!(!is_hotplug(EventType::Change, None));
167 assert!(!is_hotplug(EventType::Change, Some("0")));
168 }
169
170 #[test]
171 fn an_add_or_remove_is_not_a_hotplug() {
172 // Those are the card itself appearing or going away, which is a
173 // different event with different handling -- the descriptor a caller
174 // holds is about to stop working, not just its connector list.
175 assert!(!is_hotplug(EventType::Add, Some("1")));
176 assert!(!is_hotplug(EventType::Remove, Some("1")));
177 assert!(!is_hotplug(EventType::Bind, Some("1")));
178 }
179
180 #[test]
181 fn the_connector_hint_is_read_when_it_is_a_number() {
182 assert_eq!(parse_connector_id(Some("393")), Some(393));
183 assert_eq!(parse_connector_id(Some("0")), Some(0));
184 }
185
186 #[test]
187 fn anything_else_means_re_enumerate() {
188 // Absent on kernels before 4.16 and on the blanket events a GPU reset
189 // produces. Guessing an id from a malformed one would send a caller
190 // to look at a connector that did not change.
191 assert_eq!(parse_connector_id(None), None);
192 assert_eq!(parse_connector_id(Some("")), None);
193 assert_eq!(parse_connector_id(Some("393x")), None);
194 assert_eq!(parse_connector_id(Some("-1")), None);
195 assert_eq!(parse_connector_id(Some("4294967296")), None);
196 }
197}