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 /// - [`SessionError::Seat`] if a dispatch fails.
72 pub fn open() -> Result<Self, SessionError> {
73 Self::open_within(Some(DEFAULT_ENABLE_TIMEOUT))
74 }
75
76 /// As [`open`](Self::open), waiting up to `timeout` for the seat to
77 /// activate; `None` waits indefinitely.
78 ///
79 /// Without a bound, a backend that connects but never activates the seat
80 /// blocked forever, and SIGINT could not break the wait, so the process
81 /// kept DRM master (drm-cxx `60c6f10`). On timeout the seat is closed.
82 ///
83 /// # Errors
84 ///
85 /// As [`open`](Self::open).
86 pub fn open_within(timeout: Option<Duration>) -> Result<Self, SessionError> {
87 let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
88 let listener = {
89 let inbox = Rc::clone(&inbox);
90 move |_: &mut libseat::SeatRef, event: libseat::SeatEvent| {
91 // Nothing is done here beyond recording it. libseat is calling
92 // us from inside its own dispatch, and acting now would mean
93 // re-entering it; the session picks these up afterwards.
94 inbox.borrow_mut().push(match event {
95 libseat::SeatEvent::Enable => RawEvent::Enable,
96 libseat::SeatEvent::Disable => RawEvent::Disable,
97 });
98 }
99 };
100
101 // Before the open, so libseat's reasons for refusing one are heard.
102 crate::seat_log::install();
103 let mut seat = libseat::Seat::open(listener).map_err(|errno| {
104 // Which backend refused, and why, is the whole of the diagnosis
105 // when a seat is expected and does not appear.
106 drmkit_log::log_debug!("no seat: {errno}");
107 SessionError::NoBackend
108 })?;
109 let poll_fd = seat.get_fd().ok().map(|fd| fd.as_raw_fd());
110 let mut backend = Self {
111 seat,
112 devices: HashMap::new(),
113 next_id: 1,
114 inbox,
115 poll_fd,
116 };
117 // Dropped on a timeout, which closes the seat.
118 backend.wait_for_activation(timeout)?;
119 Ok(backend)
120 }
121
122 /// The seat's name, for logging.
123 pub fn name(&mut self) -> &str {
124 self.seat.name()
125 }
126
127 /// Block until the seat says it is ours.
128 ///
129 /// A freshly opened seat is not active: libseat sends the first enable over
130 /// the connection, so it only arrives once something dispatches. Skipping
131 /// this leaves the first `open_device` racing the activation, which is a
132 /// startup that fails on a loaded machine and works on an idle one.
133 ///
134 /// The enable is swallowed rather than queued. [`Session`](crate::Session)
135 /// starts active, and a resume it never paused for would have it close and
136 /// reopen every device for nothing.
137 fn wait_for_activation(&mut self, timeout: Option<Duration>) -> Result<(), SessionError> {
138 let Self { seat, inbox, .. } = self;
139 wait_until_active(
140 |timeout_ms| {
141 seat.dispatch(timeout_ms)
142 .map(|_| ())
143 .map_err(|errno| SessionError::Seat(format!("dispatch: {errno}")))
144 },
145 inbox,
146 timeout,
147 )?;
148 drmkit_log::log_info!("seat {} is ours", self.seat.name());
149 Ok(())
150 }
151}
152
153/// Dispatch until the seat's first enable arrives in `inbox`, or `timeout`
154/// passes; `None` waits indefinitely.
155///
156/// `dispatch` is the seat's, taking a timeout in milliseconds with `-1` to
157/// block. While bounded, no single dispatch outlasts [`DISPATCH_SLICE_MS`] or
158/// the time left, so the deadline is kept. A timeout is
159/// [`SessionError::NoBackend`], the "no seat" a caller already falls back on.
160fn wait_until_active(
161 mut dispatch: impl FnMut(i32) -> Result<(), SessionError>,
162 inbox: &Inbox,
163 timeout: Option<Duration>,
164) -> Result<(), SessionError> {
165 let deadline = timeout.map(|timeout| Instant::now() + timeout);
166 loop {
167 let timeout_ms = match deadline {
168 None => -1,
169 Some(deadline) => {
170 let left = deadline.saturating_duration_since(Instant::now());
171 if left.is_zero() {
172 drmkit_log::log_warn!(
173 "seat not enabled within {} ms; open devices directly instead",
174 timeout.unwrap_or_default().as_millis()
175 );
176 return Err(SessionError::NoBackend);
177 }
178 // At most DISPATCH_SLICE_MS, so it fits.
179 i32::try_from(left.as_millis().min(DISPATCH_SLICE_MS)).unwrap_or(0)
180 }
181 };
182 dispatch(timeout_ms)?;
183 if drain_through_activation(&mut inbox.borrow_mut()) {
184 return Ok(());
185 }
186 }
187}
188
189/// Drop everything up to and including the seat's first enable.
190///
191/// Returns whether one was there. What is left is what happened *after* the
192/// seat became ours, which the session does have to hear about -- a seat that
193/// enables and immediately disables is a switch away that has already
194/// happened, and swallowing it wholesale would leave a session that thinks it
195/// owns a display it does not.
196fn drain_through_activation(events: &mut Vec<RawEvent>) -> bool {
197 if let Some(enable) = events.iter().position(|event| *event == RawEvent::Enable) {
198 events.drain(..=enable);
199 true
200 } else {
201 // A disable before the first enable describes a seat that was never
202 // ours. There is nothing for the session to stop doing.
203 events.clear();
204 false
205 }
206}
207
208impl Drop for LibseatBackend {
209 fn drop(&mut self) {
210 // Closing the seat releases these anyway. Done explicitly so that stays
211 // true of a backend that outlives its devices for a while, and so the
212 // count the seat is holding matches the count this thinks it has.
213 for (_, device) in self.devices.drain() {
214 let _ = self.seat.close_device(device);
215 }
216 }
217}
218
219impl Backend for LibseatBackend {
220 fn open_device(&mut self, path: &Path) -> Result<(DeviceId, RawFd), SessionError> {
221 // The crate builds the C string with `unwrap`, so a path that is not
222 // UTF-8, or has a NUL in it, panics inside the binding. Neither can
223 // come from `/dev/dri`, but neither has to reach it either.
224 if path.to_str().is_none_or(|path| path.contains('\0')) {
225 return Err(SessionError::Seat(format!(
226 "{} is not a path libseat can take",
227 path.display()
228 )));
229 }
230 let device = self
231 .seat
232 .open_device(&path)
233 .map_err(|errno| SessionError::Seat(format!("open {}: {errno}", path.display())))?;
234 let fd = device.as_fd().as_raw_fd();
235 let id = self.next_id;
236 self.next_id += 1;
237 self.devices.insert(id, device);
238 Ok((DeviceId(id), fd))
239 }
240
241 fn close_device(&mut self, id: DeviceId) -> Result<(), SessionError> {
242 // One this session does not hold is not an error to pass on: it is
243 // already closed, which is what was asked for.
244 let Some(device) = self.devices.remove(&id.0) else {
245 return Ok(());
246 };
247 self.seat
248 .close_device(device)
249 .map_err(|errno| SessionError::Seat(format!("close: {errno}")))
250 }
251
252 fn acknowledge_pause(&mut self) -> Result<(), SessionError> {
253 self.seat
254 .disable()
255 .map_err(|errno| SessionError::Seat(format!("disable: {errno}")))
256 }
257
258 fn switch_session(&mut self, session: i32) -> Result<(), SessionError> {
259 self.seat
260 .switch_session(session)
261 .map_err(|errno| SessionError::Seat(format!("switch to {session}: {errno}")))
262 }
263
264 fn poll_fd(&self) -> Option<RawFd> {
265 self.poll_fd
266 }
267
268 fn dispatch(
269 &mut self,
270 timeout_ms: i32,
271 events: &mut Vec<RawEvent>,
272 ) -> Result<(), SessionError> {
273 self.seat
274 .dispatch(timeout_ms)
275 .map_err(|errno| SessionError::Seat(format!("dispatch: {errno}")))?;
276 // Whatever the listener recorded while that ran.
277 events.append(&mut self.inbox.borrow_mut());
278 Ok(())
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn activation_swallows_the_enable_that_announced_it() {
288 // The session starts active. A resume it never paused for would have it
289 // close and reopen every device for nothing.
290 let mut events = vec![RawEvent::Enable];
291 assert!(drain_through_activation(&mut events));
292 assert!(events.is_empty());
293 }
294
295 #[test]
296 fn a_switch_away_during_startup_still_reaches_the_session() {
297 // Rare, but it is the case that matters: swallow this and the session
298 // thinks it owns a display that now belongs to someone else.
299 let mut events = vec![RawEvent::Enable, RawEvent::Disable];
300 assert!(drain_through_activation(&mut events));
301 assert_eq!(events, vec![RawEvent::Disable]);
302 }
303
304 #[test]
305 fn without_an_enable_the_seat_is_not_ours_yet() {
306 let mut events = vec![RawEvent::Disable];
307 assert!(!drain_through_activation(&mut events));
308 assert!(events.is_empty(), "and nothing is left to act on");
309 }
310
311 /// A seat that answers but never enables -- seatd over SSH, with no VT to
312 /// take -- gives up within the bound, as "no seat", and never blocks
313 /// longer than the time left. Port of upstream's
314 /// `OpenTimesOutWhenSeatNeverEnabled` (`60c6f10`).
315 #[test]
316 fn a_seat_that_never_enables_times_out_as_no_seat() {
317 let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
318 let mut waits = Vec::new();
319 let started = Instant::now();
320 let result = wait_until_active(
321 |timeout_ms| {
322 waits.push(timeout_ms);
323 std::thread::sleep(Duration::from_millis(
324 u64::try_from(timeout_ms.max(0)).unwrap_or(0),
325 ));
326 Ok(())
327 },
328 &inbox,
329 Some(Duration::from_millis(50)),
330 );
331 assert_eq!(result, Err(SessionError::NoBackend));
332 assert!(started.elapsed() < Duration::from_secs(2));
333 assert!(!waits.is_empty());
334 assert!(
335 waits.iter().all(|ms| (0..=50).contains(ms)),
336 "no dispatch may block past the deadline: {waits:?}"
337 );
338 }
339
340 /// The first enable ends the wait, and what came after it stays for the
341 /// session.
342 #[test]
343 fn the_first_enable_ends_the_wait() {
344 let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
345 let mut calls = 0;
346 let result = wait_until_active(
347 |_| {
348 calls += 1;
349 if calls == 3 {
350 inbox
351 .borrow_mut()
352 .extend([RawEvent::Enable, RawEvent::Disable]);
353 }
354 Ok(())
355 },
356 &inbox,
357 Some(DEFAULT_ENABLE_TIMEOUT),
358 );
359 assert_eq!(result, Ok(()));
360 assert_eq!(calls, 3);
361 assert_eq!(*inbox.borrow(), [RawEvent::Disable]);
362 }
363
364 /// Unbounded, each dispatch blocks until the seat says something.
365 #[test]
366 fn an_unbounded_wait_blocks_in_dispatch() {
367 let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
368 let mut waits = Vec::new();
369 let result = wait_until_active(
370 |timeout_ms| {
371 waits.push(timeout_ms);
372 inbox.borrow_mut().push(RawEvent::Enable);
373 Ok(())
374 },
375 &inbox,
376 None,
377 );
378 assert_eq!(result, Ok(()));
379 assert_eq!(waits, [-1]);
380 }
381
382 /// A failed dispatch ends the wait with its error, not a timeout.
383 #[test]
384 fn a_failed_dispatch_is_reported() {
385 let inbox: Inbox = Rc::new(RefCell::new(Vec::new()));
386 let result = wait_until_active(
387 |_| Err(SessionError::Seat("dispatch: EIO".to_owned())),
388 &inbox,
389 Some(DEFAULT_ENABLE_TIMEOUT),
390 );
391 assert_eq!(result, Err(SessionError::Seat("dispatch: EIO".to_owned())));
392 }
393}