Skip to main content

drmkit_session/
session.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! The session state machine, over whatever seat is underneath.
5
6use std::collections::VecDeque;
7use std::os::fd::RawFd;
8use std::path::{Path, PathBuf};
9
10use crate::SessionError;
11
12/// A seat's handle for one open device.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14#[allow(
15    clippy::exhaustive_structs,
16    reason = "a value defined by its fields; there is nothing to add to it"
17)]
18pub struct DeviceId(pub i32);
19
20/// A device this session is holding.
21#[derive(Debug, Clone, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct TakenDevice {
24    /// Where it was opened from. Kept because a resume reopens by path.
25    pub path: PathBuf,
26    /// The seat's handle, for closing it.
27    pub id: DeviceId,
28    /// The descriptor as it stands now. Replaced by a resume.
29    ///
30    /// Borrowed, not owned: the seat closes it, and it does so at a moment of
31    /// its choosing. Closing it here leaves the seat holding a handle to a
32    /// descriptor number the process has since reused for something else.
33    pub fd: RawFd,
34}
35
36/// What a seat has to be able to do.
37///
38/// Deliberately small. Everything about *when* to call these -- and what state
39/// the session is in between them -- is [`Session`]'s, so it can be tested
40/// against a fake on a machine with no seat.
41pub trait Backend {
42    /// Open a device the seat controls.
43    ///
44    /// # Errors
45    ///
46    /// Whatever the seat says, as [`SessionError::Seat`].
47    fn open_device(&mut self, path: &Path) -> Result<(DeviceId, RawFd), SessionError>;
48
49    /// Close one this session opened.
50    ///
51    /// # Errors
52    ///
53    /// As [`Backend::open_device`].
54    fn close_device(&mut self, id: DeviceId) -> Result<(), SessionError>;
55
56    /// Tell the seat the pause has been acted on.
57    ///
58    /// Until this returns, the seat is waiting: the switch does not complete
59    /// and the user is looking at a display that has not changed.
60    ///
61    /// # Errors
62    ///
63    /// As [`Backend::open_device`].
64    fn acknowledge_pause(&mut self) -> Result<(), SessionError>;
65
66    /// Ask to switch to another session, by VT number.
67    ///
68    /// # Errors
69    ///
70    /// As [`Backend::open_device`].
71    fn switch_session(&mut self, session: i32) -> Result<(), SessionError>;
72
73    /// A descriptor that becomes readable when the seat has something to say.
74    fn poll_fd(&self) -> Option<RawFd>;
75
76    /// Let the seat deliver whatever it has, appending to `events`.
77    ///
78    /// # Errors
79    ///
80    /// As [`Backend::open_device`].
81    fn dispatch(&mut self, timeout_ms: i32, events: &mut Vec<RawEvent>)
82    -> Result<(), SessionError>;
83}
84
85/// What a seat says, before the session has made sense of it.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum RawEvent {
89    /// The session has become active.
90    Enable,
91    /// The session is about to become inactive.
92    Disable,
93}
94
95/// What a caller needs to act on.
96#[derive(Debug, Clone, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum SessionEvent {
99    /// Stop touching every device now.
100    ///
101    /// The descriptors are about to stop working. Anything mapped from them
102    /// has to be released by the caller -- closing a descriptor does not unmap
103    /// it, so a compositor that skips that leaks per switch.
104    Paused,
105    /// The devices are back, on new descriptors.
106    ///
107    /// Every per-descriptor kernel object is gone: framebuffers, property
108    /// blobs, client capabilities, GEM handles. All of it is rebuilt.
109    Resumed {
110        /// The devices as they now stand, in the order they were taken --
111        /// those that came back. One that could not be reopened is reported
112        /// by a [`SessionEvent::DeviceLost`] queued ahead of this, and is no
113        /// longer held.
114        devices: Vec<TakenDevice>,
115    },
116    /// A device could not be reopened on resume.
117    ///
118    /// The descriptor the caller held for it was closed with the pause, and
119    /// there is no new one: everything on that device is gone until the
120    /// caller takes it again. The other devices are unaffected and come back
121    /// in the [`SessionEvent::Resumed`] that follows. The reference reports the
122    /// same through its `resume_failed_cb` (drm-cxx#246).
123    DeviceLost {
124        /// Where it was opened from.
125        path: PathBuf,
126        /// What the seat said when asked to reopen it.
127        error: String,
128    },
129}
130
131/// Devices held across VT switches.
132#[derive(Debug)]
133pub struct Session<B: Backend> {
134    backend: B,
135    devices: Vec<TakenDevice>,
136    active: bool,
137    pending: VecDeque<SessionEvent>,
138    raw: Vec<RawEvent>,
139}
140
141impl<B: Backend> Session<B> {
142    /// Wrap a seat that is currently active.
143    pub fn new(backend: B) -> Self {
144        Self {
145            backend,
146            devices: Vec::new(),
147            active: true,
148            pending: VecDeque::new(),
149            raw: Vec::new(),
150        }
151    }
152
153    /// Whether the session currently owns the display.
154    #[must_use]
155    pub const fn is_active(&self) -> bool {
156        self.active
157    }
158
159    /// The devices being held.
160    #[must_use]
161    pub fn devices(&self) -> &[TakenDevice] {
162        &self.devices
163    }
164
165    /// The seat underneath, for tests to drive.
166    #[cfg(test)]
167    pub(crate) const fn backend(&self) -> &B {
168        &self.backend
169    }
170
171    /// As [`Session::backend`].
172    #[cfg(test)]
173    pub(crate) const fn backend_mut(&mut self) -> &mut B {
174        &mut self.backend
175    }
176
177    /// A descriptor to poll for seat activity.
178    #[must_use]
179    pub fn poll_fd(&self) -> Option<RawFd> {
180        self.backend.poll_fd()
181    }
182
183    /// Open a device through the seat and hold it.
184    ///
185    /// Opening the same path twice returns what is already held rather than a
186    /// second descriptor: the seat would give one, and then a resume would
187    /// have two to reopen and the caller two to keep straight.
188    ///
189    /// # Errors
190    ///
191    /// - [`SessionError::Inactive`] while the session does not own the
192    ///   display. The seat would refuse anyway; failing here says why.
193    /// - [`SessionError::Seat`] if the seat refuses.
194    pub fn take_device(&mut self, path: impl AsRef<Path>) -> Result<TakenDevice, SessionError> {
195        let path = path.as_ref();
196        if !self.active {
197            return Err(SessionError::Inactive);
198        }
199        if let Some(existing) = self.devices.iter().find(|device| device.path == path) {
200            return Ok(existing.clone());
201        }
202        let (id, fd) = self.backend.open_device(path)?;
203        let device = TakenDevice {
204            path: path.to_path_buf(),
205            id,
206            fd,
207        };
208        self.devices.push(device.clone());
209        Ok(device)
210    }
211
212    /// Give a device back to the seat.
213    ///
214    /// # Errors
215    ///
216    /// - [`SessionError::UnknownDevice`] if this session did not open it.
217    /// - [`SessionError::Seat`] if the seat refuses.
218    pub fn release_device(&mut self, path: impl AsRef<Path>) -> Result<(), SessionError> {
219        let path = path.as_ref();
220        let index = self
221            .devices
222            .iter()
223            .position(|device| device.path == path)
224            .ok_or(SessionError::UnknownDevice)?;
225        let device = self.devices.remove(index);
226        self.backend.close_device(device.id)
227    }
228
229    /// Ask to switch to another session, by VT number.
230    ///
231    /// Asynchronous: the seat pauses this session when it honors the request,
232    /// and resumes it when the user comes back. Useful where the process owns
233    /// the TTY in graphics mode and the kernel is no longer handling
234    /// `Ctrl+Alt+F<n>` itself.
235    ///
236    /// # Errors
237    ///
238    /// [`SessionError::Seat`] if the seat refuses.
239    pub fn switch_session(&mut self, session: i32) -> Result<(), SessionError> {
240        self.backend.switch_session(session)
241    }
242
243    /// Let the seat deliver what it has, turning it into events to act on.
244    ///
245    /// A negative timeout blocks. Call [`Session::next_event`] afterwards.
246    ///
247    /// The reference runs the caller's callbacks synchronously from inside
248    /// this call, which means the caller is re-entering the seat from inside
249    /// the seat's own dispatch. Queuing instead keeps that from being possible.
250    ///
251    /// A device that cannot be reopened on resume is not an error here: it is
252    /// reported as a [`SessionEvent::DeviceLost`], and the rest come back.
253    ///
254    /// # Errors
255    ///
256    /// [`SessionError::Seat`] if the seat refuses to dispatch or to
257    /// acknowledge a pause.
258    pub fn dispatch(&mut self, timeout_ms: i32) -> Result<(), SessionError> {
259        self.raw.clear();
260        let mut raw = std::mem::take(&mut self.raw);
261        let result = self.backend.dispatch(timeout_ms, &mut raw);
262        for event in raw.drain(..) {
263            match event {
264                RawEvent::Disable => self.on_disable()?,
265                RawEvent::Enable => self.on_enable(),
266            }
267        }
268        self.raw = raw;
269        result
270    }
271
272    /// The next thing the caller has to act on.
273    pub fn next_event(&mut self) -> Option<SessionEvent> {
274        self.pending.pop_front()
275    }
276
277    /// Handle the seat going away.
278    fn on_disable(&mut self) -> Result<(), SessionError> {
279        if !self.active {
280            // A seat that says it twice is not a reason to tell the caller
281            // twice; the caller has already stopped.
282            return Ok(());
283        }
284        self.active = false;
285        drmkit_log::log_info!(
286            "session paused: {} device(s) are no longer ours",
287            self.devices.len()
288        );
289        self.pending.push_back(SessionEvent::Paused);
290        // Acknowledged immediately. The queued event is what the caller acts
291        // on, and holding the acknowledgment until they have would stall the
292        // switch on a display that has already stopped being ours.
293        self.backend.acknowledge_pause()
294    }
295
296    /// Handle the seat coming back.
297    fn on_enable(&mut self) {
298        if self.active {
299            return;
300        }
301        self.active = true;
302
303        // Every descriptor is replaced. The pessimistic reading, and the right
304        // one: the capability-revoking backends leave the descriptor valid but
305        // reset per-descriptor state anyway, so a caller that reused it would
306        // be working from objects the kernel has forgotten.
307        let mut refreshed = Vec::with_capacity(self.devices.len());
308        for device in std::mem::take(&mut self.devices) {
309            // Closing first: the seat tracks one open device per handle, and
310            // opening the same path again without closing leaks its handle for
311            // as long as the session runs. A refusal to close changes nothing
312            // about the reopen, so it is noted and passed over, as the
313            // reference ignores it.
314            if let Err(error) = self.backend.close_device(device.id) {
315                drmkit_log::log_debug!(
316                    "closing {} before reopening it failed: {error}",
317                    device.path.display()
318                );
319            }
320            // One device that will not come back is not a reason to leave the
321            // others dark: the reference logs it, reports it for that device,
322            // and goes on to the rest.
323            match self.backend.open_device(&device.path) {
324                Ok((id, fd)) => refreshed.push(TakenDevice {
325                    path: device.path,
326                    id,
327                    fd,
328                }),
329                Err(error) => {
330                    drmkit_log::log_error!(
331                        "reopening {} on resume failed: {error}",
332                        device.path.display()
333                    );
334                    self.pending.push_back(SessionEvent::DeviceLost {
335                        path: device.path,
336                        error: error.to_string(),
337                    });
338                }
339            }
340        }
341        drmkit_log::log_info!("session resumed: {} device(s) reopened", refreshed.len());
342        self.devices.clone_from(&refreshed);
343        self.pending
344            .push_back(SessionEvent::Resumed { devices: refreshed });
345    }
346}