Skip to main content

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}