Skip to main content

drmkit_session/
seat.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! The libseat backend.
5//!
6//! A thin adapter. Everything about *when* the seat is called lives in
7//! [`Session`](crate::Session), which is tested against a fake; what is here is
8//! the translation, and it is the part that cannot be exercised on a machine
9//! with no seat -- which is every machine drmkit is currently built on.
10
11use std::cell::RefCell;
12use std::collections::HashMap;
13use std::os::fd::{AsFd as _, AsRawFd as _, RawFd};
14use std::path::Path;
15use std::rc::Rc;
16use std::time::{Duration, Instant};
17
18use crate::SessionError;
19use crate::session::{Backend, DeviceId, RawEvent};
20
21/// How long [`LibseatBackend::open`] waits for the seat to activate.
22///
23/// Port of upstream's `Seat::k_default_enable_timeout` (`60c6f10`).
24pub const DEFAULT_ENABLE_TIMEOUT: Duration = Duration::from_secs(3);
25
26/// The longest single dispatch while the wait is bounded, so the deadline is
27/// checked often enough to be kept.
28const DISPATCH_SLICE_MS: u128 = 100;
29
30/// Events the seat has delivered but the session has not collected.
31///
32/// Shared with the listener libseat calls. Shared rather than owned because the
33/// crate takes a `'static` callback at open, before there is a backend for it
34/// to belong to.
35type Inbox = Rc<RefCell<Vec<RawEvent>>>;
36
37/// A session backed by libseat.
38///
39/// Whichever backend libseat finds: logind, seatd, or the builtin fallback for
40/// a process running as root on a TTY. `LIBSEAT_BACKEND` picks one explicitly.
41#[derive(Debug)]
42pub struct LibseatBackend {
43    seat: libseat::Seat,
44    /// Open devices, by the id handed out to the session.
45    ///
46    /// The crate's `close_device` consumes the `Device`, so they are held here
47    /// rather than handed to the caller, who only needs the descriptor.
48    devices: HashMap<i32, libseat::Device>,
49    next_id: i32,
50    inbox: Inbox,
51    /// The connection's descriptor, read once at open.
52    ///
53    /// The crate's accessor needs `&mut`, and a caller polling in a loop wants
54    /// `&self`. It does not change for the life of the connection.
55    poll_fd: Option<RawFd>,
56}
57
58impl LibseatBackend {
59    /// Connect to whatever seat is available, and wait for it to activate.
60    ///
61    /// # Errors
62    ///
63    /// - [`SessionError::NoBackend`] when there is no seat. That is the
64    ///   ordinary case outside a login session -- over SSH, in a container, on
65    ///   a CI runner -- and a caller that only wants to look at a display can
66    ///   open devices directly instead. What it gives up is surviving a VT
67    ///   switch.
68    ///   The same when the backend connects but does not activate the seat
69    ///   within [`DEFAULT_ENABLE_TIMEOUT`] -- seatd over SSH, with no VT to
70    ///   take -- so the caller falls back the same way.
71    ///   And the same when a dispatch fails before the seat activates, as the
72    ///   reference falls back on it.
73    pub fn open() -> Result<Self, SessionError> {
74        Self::open_within(Some(DEFAULT_ENABLE_TIMEOUT))
75    }
76
77    /// As [`open`](Self::open), waiting up to `timeout` for the seat to
78    /// activate; `None` waits indefinitely.
79    ///
80    /// Without a bound, a backend that connects but never activates the seat
81    /// blocked forever, and SIGINT could not break the wait, so the process
82    /// kept DRM master (drm-cxx `60c6f10`). On timeout the seat is closed.
83    ///
84    /// # Errors
85    ///
86    /// As [`open`](Self::open).
87    pub fn open_within(timeout: Option<Duration>) -> Result<Self, SessionError> {
88        let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
89        let listener = {
90            let inbox = Rc::clone(&inbox);
91            move |_: &mut libseat::SeatRef, event: libseat::SeatEvent| {
92                // Nothing is done here beyond recording it. libseat is calling
93                // us from inside its own dispatch, and acting now would mean
94                // re-entering it; the session picks these up afterwards.
95                inbox.borrow_mut().push(match event {
96                    libseat::SeatEvent::Enable => RawEvent::Enable,
97                    libseat::SeatEvent::Disable => RawEvent::Disable,
98                });
99            }
100        };
101
102        // Before the open, so libseat's reasons for refusing one are heard.
103        crate::seat_log::install();
104        let mut seat = libseat::Seat::open(listener).map_err(|errno| {
105            // Which backend refused, and why, is the whole of the diagnosis
106            // when a seat is expected and does not appear.
107            drmkit_log::log_error!("seat: opening a seat failed: {errno}");
108            SessionError::NoBackend
109        })?;
110        let poll_fd = seat.get_fd().ok().map(|fd| fd.as_raw_fd());
111        let mut backend = Self {
112            seat,
113            devices: HashMap::new(),
114            next_id: 1,
115            inbox,
116            poll_fd,
117        };
118        // Dropped on a timeout, which closes the seat.
119        backend.wait_for_activation(timeout)?;
120        Ok(backend)
121    }
122
123    /// The seat's name, for logging.
124    pub fn name(&mut self) -> &str {
125        self.seat.name()
126    }
127
128    /// Block until the seat says it is ours.
129    ///
130    /// A freshly opened seat is not active: libseat sends the first enable over
131    /// the connection, so it only arrives once something dispatches. Skipping
132    /// this leaves the first `open_device` racing the activation, which is a
133    /// startup that fails on a loaded machine and works on an idle one.
134    ///
135    /// The enable is swallowed rather than queued. [`Session`](crate::Session)
136    /// starts active, and a resume it never paused for would have it close and
137    /// reopen every device for nothing.
138    fn wait_for_activation(&mut self, timeout: Option<Duration>) -> Result<(), SessionError> {
139        let Self { seat, inbox, .. } = self;
140        wait_until_active(
141            |timeout_ms| {
142                seat.dispatch(timeout_ms)
143                    .map(|_| ())
144                    .map_err(|errno| errno.to_string())
145            },
146            inbox,
147            timeout,
148        )?;
149        drmkit_log::log_info!("seat {} is ours", self.seat.name());
150        Ok(())
151    }
152}
153
154/// Dispatch until the seat's first enable arrives in `inbox`, or `timeout`
155/// passes; `None` waits indefinitely.
156///
157/// `dispatch` is the seat's, taking a timeout in milliseconds with `-1` to
158/// block. While bounded, no single dispatch outlasts [`DISPATCH_SLICE_MS`] or
159/// the time left, so the deadline is kept. A timeout is
160/// [`SessionError::NoBackend`], the "no seat" a caller already falls back on,
161/// and so is a dispatch that fails before the seat is ours, logged as an
162/// error, as the reference treats both.
163fn wait_until_active(
164    mut dispatch: impl FnMut(i32) -> Result<(), String>,
165    inbox: &Inbox,
166    timeout: Option<Duration>,
167) -> Result<(), SessionError> {
168    let deadline = timeout.map(|timeout| Instant::now() + timeout);
169    loop {
170        let timeout_ms = match deadline {
171            None => -1,
172            Some(deadline) => {
173                let left = deadline.saturating_duration_since(Instant::now());
174                if left.is_zero() {
175                    drmkit_log::log_warn!(
176                        "seat not enabled within {} ms; open devices directly instead",
177                        timeout.unwrap_or_default().as_millis()
178                    );
179                    return Err(SessionError::NoBackend);
180                }
181                // At most DISPATCH_SLICE_MS, so it fits.
182                i32::try_from(left.as_millis().min(DISPATCH_SLICE_MS)).unwrap_or(0)
183            }
184        };
185        if let Err(error) = dispatch(timeout_ms) {
186            drmkit_log::log_error!("seat: dispatch before the seat was enabled failed: {error}");
187            return Err(SessionError::NoBackend);
188        }
189        if drain_through_activation(&mut inbox.borrow_mut()) {
190            return Ok(());
191        }
192    }
193}
194
195/// Drop everything up to and including the seat's first enable.
196///
197/// Returns whether one was there. What is left is what happened *after* the
198/// seat became ours, which the session does have to hear about -- a seat that
199/// enables and immediately disables is a switch away that has already
200/// happened, and swallowing it wholesale would leave a session that thinks it
201/// owns a display it does not.
202fn drain_through_activation(events: &mut Vec<RawEvent>) -> bool {
203    if let Some(enable) = events.iter().position(|event| *event == RawEvent::Enable) {
204        events.drain(..=enable);
205        true
206    } else {
207        // A disable before the first enable describes a seat that was never
208        // ours. There is nothing for the session to stop doing.
209        events.clear();
210        false
211    }
212}
213
214impl Drop for LibseatBackend {
215    fn drop(&mut self) {
216        // Closing the seat releases these anyway. Done explicitly so that stays
217        // true of a backend that outlives its devices for a while, and so the
218        // count the seat is holding matches the count this thinks it has.
219        for (_, device) in self.devices.drain() {
220            let _ = self.seat.close_device(device);
221        }
222    }
223}
224
225impl Backend for LibseatBackend {
226    fn open_device(&mut self, path: &Path) -> Result<(DeviceId, RawFd), SessionError> {
227        // The crate builds the C string with `unwrap`, so a path that is not
228        // UTF-8, or has a NUL in it, panics inside the binding. Neither can
229        // come from `/dev/dri`, but neither has to reach it either.
230        if path.to_str().is_none_or(|path| path.contains('\0')) {
231            return Err(SessionError::Seat(format!(
232                "{} is not a path libseat can take",
233                path.display()
234            )));
235        }
236        let device = self
237            .seat
238            .open_device(&path)
239            .map_err(|errno| SessionError::Seat(format!("open {}: {errno}", path.display())))?;
240        let fd = device.as_fd().as_raw_fd();
241        let id = self.next_id;
242        self.next_id += 1;
243        self.devices.insert(id, device);
244        Ok((DeviceId(id), fd))
245    }
246
247    fn close_device(&mut self, id: DeviceId) -> Result<(), SessionError> {
248        // One this session does not hold is not an error to pass on: it is
249        // already closed, which is what was asked for.
250        let Some(device) = self.devices.remove(&id.0) else {
251            return Ok(());
252        };
253        self.seat
254            .close_device(device)
255            .map_err(|errno| SessionError::Seat(format!("close: {errno}")))
256    }
257
258    fn acknowledge_pause(&mut self) -> Result<(), SessionError> {
259        self.seat
260            .disable()
261            .map_err(|errno| SessionError::Seat(format!("disable: {errno}")))
262    }
263
264    fn switch_session(&mut self, session: i32) -> Result<(), SessionError> {
265        self.seat
266            .switch_session(session)
267            .map_err(|errno| SessionError::Seat(format!("switch to {session}: {errno}")))
268    }
269
270    fn poll_fd(&self) -> Option<RawFd> {
271        self.poll_fd
272    }
273
274    fn dispatch(
275        &mut self,
276        timeout_ms: i32,
277        events: &mut Vec<RawEvent>,
278    ) -> Result<(), SessionError> {
279        self.seat
280            .dispatch(timeout_ms)
281            .map_err(|errno| SessionError::Seat(format!("dispatch: {errno}")))?;
282        // Whatever the listener recorded while that ran.
283        events.append(&mut self.inbox.borrow_mut());
284        Ok(())
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn activation_swallows_the_enable_that_announced_it() {
294        // The session starts active. A resume it never paused for would have it
295        // close and reopen every device for nothing.
296        let mut events = vec![RawEvent::Enable];
297        assert!(drain_through_activation(&mut events));
298        assert!(events.is_empty());
299    }
300
301    #[test]
302    fn a_switch_away_during_startup_still_reaches_the_session() {
303        // Rare, but it is the case that matters: swallow this and the session
304        // thinks it owns a display that now belongs to someone else.
305        let mut events = vec![RawEvent::Enable, RawEvent::Disable];
306        assert!(drain_through_activation(&mut events));
307        assert_eq!(events, vec![RawEvent::Disable]);
308    }
309
310    #[test]
311    fn without_an_enable_the_seat_is_not_ours_yet() {
312        let mut events = vec![RawEvent::Disable];
313        assert!(!drain_through_activation(&mut events));
314        assert!(events.is_empty(), "and nothing is left to act on");
315    }
316
317    /// A seat that answers but never enables -- seatd over SSH, with no VT to
318    /// take -- gives up within the bound, as "no seat", and never blocks
319    /// longer than the time left. Port of upstream's
320    /// `OpenTimesOutWhenSeatNeverEnabled` (`60c6f10`).
321    #[test]
322    fn a_seat_that_never_enables_times_out_as_no_seat() {
323        let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
324        let mut waits = Vec::new();
325        let started = Instant::now();
326        let result = wait_until_active(
327            |timeout_ms| {
328                waits.push(timeout_ms);
329                std::thread::sleep(Duration::from_millis(
330                    u64::try_from(timeout_ms.max(0)).unwrap_or(0),
331                ));
332                Ok(())
333            },
334            &inbox,
335            Some(Duration::from_millis(50)),
336        );
337        assert_eq!(result, Err(SessionError::NoBackend));
338        assert!(started.elapsed() < Duration::from_secs(2));
339        assert!(!waits.is_empty());
340        assert!(
341            waits.iter().all(|ms| (0..=50).contains(ms)),
342            "no dispatch may block past the deadline: {waits:?}"
343        );
344    }
345
346    /// The first enable ends the wait, and what came after it stays for the
347    /// session.
348    #[test]
349    fn the_first_enable_ends_the_wait() {
350        let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
351        let mut calls = 0;
352        let result = wait_until_active(
353            |_| {
354                calls += 1;
355                if calls == 3 {
356                    inbox
357                        .borrow_mut()
358                        .extend([RawEvent::Enable, RawEvent::Disable]);
359                }
360                Ok(())
361            },
362            &inbox,
363            Some(DEFAULT_ENABLE_TIMEOUT),
364        );
365        assert_eq!(result, Ok(()));
366        assert_eq!(calls, 3);
367        assert_eq!(*inbox.borrow(), [RawEvent::Disable]);
368    }
369
370    /// Unbounded, each dispatch blocks until the seat says something.
371    #[test]
372    fn an_unbounded_wait_blocks_in_dispatch() {
373        let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
374        let mut waits = Vec::new();
375        let result = wait_until_active(
376            |timeout_ms| {
377                waits.push(timeout_ms);
378                inbox.borrow_mut().push(RawEvent::Enable);
379                Ok(())
380            },
381            &inbox,
382            None,
383        );
384        assert_eq!(result, Ok(()));
385        assert_eq!(waits, [-1]);
386    }
387
388    /// A dispatch that fails before the seat is ours ends the wait at once as
389    /// "no seat", the answer a caller falls back on, as the reference gives
390    /// it -- and the failure is logged as an error, so the fallback is not
391    /// silent.
392    #[test]
393    fn a_failed_dispatch_is_reported() {
394        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
395        let sink = std::sync::Arc::clone(&captured);
396        drmkit_log::set_log_sink(move |level, message| {
397            sink.lock()
398                .unwrap_or_else(std::sync::PoisonError::into_inner)
399                .push((level, message.to_owned()));
400        });
401        let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
402        let started = Instant::now();
403        let result = wait_until_active(
404            |_| Err("EIO".to_owned()),
405            &inbox,
406            Some(DEFAULT_ENABLE_TIMEOUT),
407        );
408        drmkit_log::reset_log_sink();
409
410        assert_eq!(result, Err(SessionError::NoBackend));
411        assert!(started.elapsed() < DEFAULT_ENABLE_TIMEOUT, "not a timeout");
412        let captured = captured
413            .lock()
414            .unwrap_or_else(std::sync::PoisonError::into_inner);
415        assert!(
416            captured.iter().any(|(level, message)| {
417                *level == drmkit_log::LogLevel::Error && message.contains("EIO")
418            }),
419            "{captured:?}"
420        );
421    }
422}