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 let value = value?;
146 // Digits only, as the reference's `std::from_chars` reads them: `parse`
147 // would also take a leading `+`.
148 if !value.bytes().all(|b| b.is_ascii_digit()) {
149 return None;
150 }
151 value.parse().ok()
152}
153
154fn io_errno(error: &std::io::Error) -> rustix::io::Errno {
155 rustix::io::Errno::from_io_error(error).unwrap_or(rustix::io::Errno::IO)
156}
157
158#[cfg(test)]
159mod tests {
160 use super::{EventType, is_hotplug, parse_connector_id};
161
162 #[test]
163 fn only_a_change_carrying_hotplug_counts() {
164 assert!(is_hotplug(EventType::Change, Some("1")));
165 }
166
167 #[test]
168 fn an_ordinary_change_is_ignored() {
169 // A compositor setting a CRTC property produces one of these, and on
170 // a running system that is several a second. Re-enumerating every
171 // connector each time is a round trip to every sink on the machine.
172 assert!(!is_hotplug(EventType::Change, None));
173 assert!(!is_hotplug(EventType::Change, Some("0")));
174 }
175
176 #[test]
177 fn an_add_or_remove_is_not_a_hotplug() {
178 // Those are the card itself appearing or going away, which is a
179 // different event with different handling -- the descriptor a caller
180 // holds is about to stop working, not just its connector list.
181 assert!(!is_hotplug(EventType::Add, Some("1")));
182 assert!(!is_hotplug(EventType::Remove, Some("1")));
183 assert!(!is_hotplug(EventType::Bind, Some("1")));
184 }
185
186 #[test]
187 fn the_connector_hint_is_read_when_it_is_a_number() {
188 assert_eq!(parse_connector_id(Some("393")), Some(393));
189 assert_eq!(parse_connector_id(Some("0")), Some(0));
190 }
191
192 #[test]
193 fn anything_else_means_re_enumerate() {
194 // Absent on kernels before 4.16 and on the blanket events a GPU reset
195 // produces. Guessing an id from a malformed one would send a caller
196 // to look at a connector that did not change.
197 assert_eq!(parse_connector_id(None), None);
198 assert_eq!(parse_connector_id(Some("")), None);
199 assert_eq!(parse_connector_id(Some("393x")), None);
200 assert_eq!(parse_connector_id(Some("-1")), None);
201 assert_eq!(
202 parse_connector_id(Some("+393")),
203 None,
204 "no sign, either way"
205 );
206 assert_eq!(parse_connector_id(Some("4294967296")), None);
207 }
208}