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}