drmkit_modeset/page_flip.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Page-flip event dispatch.
5//!
6//! Port of `src/modeset/page_flip.{hpp,cpp}`. Home of **invariant 3**.
7
8use std::collections::HashMap;
9use std::os::fd::{AsFd as _, BorrowedFd, OwnedFd, RawFd};
10use std::time::Duration;
11
12use drm::control::Device as _;
13use drmkit_core::Device;
14use drmkit_time::{Clock, SteadyClock, Timestamp};
15use rustix::event::epoll;
16
17use crate::error::{ModesetError, Result};
18
19/// Maximum events drained from one `epoll_wait`.
20const MAX_DISPATCH_EVENTS: usize = 16;
21
22/// Called when a page-flip event completes.
23pub type Handler = Box<dyn FnMut(PageFlipEvent) + Send>;
24
25/// Called when a foreign source descriptor becomes readable.
26///
27/// The dispatcher never reads from the descriptor itself: the callback must
28/// drain it (`eventfd_read`, `signalfd_siginfo`, `recvmsg`, …) before
29/// returning, or the next dispatch fires the same callback again.
30pub type SourceCallback = Box<dyn FnMut() + Send>;
31
32/// A completed page flip.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[non_exhaustive]
35pub struct PageFlipEvent {
36 /// The CRTC whose flip completed.
37 pub crtc_id: u32,
38 /// The vblank sequence number.
39 pub sequence: u64,
40 /// When the flip landed, on the monotonic timeline.
41 pub timestamp: Timestamp,
42}
43
44/// How a dispatch wait is bounded.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46#[non_exhaustive]
47pub enum Timeout {
48 /// Block until an event arrives.
49 #[default]
50 Infinite,
51 /// Return immediately if nothing is ready.
52 Poll,
53 /// Wait at most this long.
54 Bounded(Duration),
55}
56
57/// Dispatches page-flip events, and optionally foreign event sources, from one
58/// epoll instance.
59///
60/// # Single-threaded contract
61///
62/// Do not call [`dispatch`](Self::dispatch), [`add_source`](Self::add_source),
63/// or [`remove_source`](Self::remove_source) concurrently on the same
64/// instance. `&mut self` enforces this in Rust; the C++ can only document it.
65pub struct PageFlip {
66 epoll: OwnedFd,
67 drm_fd: Option<RawFd>,
68 handler: Option<Handler>,
69 sources: HashMap<RawFd, SourceCallback>,
70 clock: &'static SteadyClock,
71}
72
73impl core::fmt::Debug for PageFlip {
74 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75 f.debug_struct("PageFlip")
76 .field("drm_fd", &self.drm_fd)
77 .field("has_handler", &self.handler.is_some())
78 .field("sources", &self.sources.len())
79 .finish_non_exhaustive()
80 }
81}
82
83impl PageFlip {
84 /// Build a dispatcher watching `device`'s descriptor.
85 ///
86 /// # Errors
87 ///
88 /// [`ModesetError::Io`] if the epoll instance cannot be created or the DRM
89 /// descriptor cannot be registered.
90 pub fn new(device: &Device) -> Result<Self> {
91 let mut flip = Self::without_device()?;
92 flip.register_drm_fd(device.raw_fd())?;
93 Ok(flip)
94 }
95
96 /// Build a dispatcher with no DRM descriptor, for folding foreign sources
97 /// into one loop before a device exists — or in tests.
98 ///
99 /// The C++ has no such constructor; its tests fabricate a `Device` holding
100 /// fd `-1` to reach the same state. Naming the state is clearer than
101 /// smuggling an invalid descriptor through a type that promises a valid
102 /// one.
103 ///
104 /// # Errors
105 ///
106 /// [`ModesetError::Io`] if the epoll instance cannot be created.
107 pub fn without_device() -> Result<Self> {
108 Ok(Self {
109 epoll: epoll::create(epoll::CreateFlags::CLOEXEC)?,
110 drm_fd: None,
111 handler: None,
112 sources: HashMap::new(),
113 clock: drmkit_time::default_clock(),
114 })
115 }
116
117 /// Install the page-flip handler, replacing any previous one.
118 pub fn set_handler(&mut self, handler: Handler) {
119 self.handler = Some(handler);
120 }
121
122 /// The epoll descriptor this dispatcher waits on.
123 ///
124 /// Exposed so a host event loop can watch it for readability and drive
125 /// `dispatch(Timeout::Poll)` itself instead of blocking. Level-triggered:
126 /// readable whenever the DRM descriptor or any source has a pending event,
127 /// quiescent once dispatch has drained them.
128 ///
129 /// The dispatcher retains ownership — do not read, close, or `epoll_ctl`
130 /// it directly.
131 #[must_use]
132 pub fn epoll_fd(&self) -> BorrowedFd<'_> {
133 self.epoll.as_fd()
134 }
135
136 fn register_drm_fd(&mut self, fd: RawFd) -> Result<()> {
137 // SAFETY: `fd` comes from a live `Device`, whose descriptor the caller
138 // keeps open for at least as long as this dispatcher; the borrow ends
139 // when `epoll::add` returns.
140 let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
141 epoll::add(
142 &self.epoll,
143 borrowed,
144 epoll::EventData::new_u64(fd_key(fd)),
145 epoll::EventFlags::IN,
146 )?;
147 self.drm_fd = Some(fd);
148 Ok(())
149 }
150
151 /// Register a foreign descriptor on this dispatcher.
152 ///
153 /// Folds external sources — a libcamera completion eventfd, a signalfd for
154 /// SIGINT, a udev monitor — into the same loop the page-flip events ride
155 /// on, so a caller need not run a parallel epoll alongside dispatch.
156 ///
157 /// The dispatcher takes **no** ownership: the caller closes `fd`.
158 /// [`remove_source`](Self::remove_source) must run, or this dispatcher be
159 /// dropped, before the descriptor is closed — otherwise the callback stays
160 /// registered: adding the recycled descriptor number later is refused as a
161 /// duplicate, and if the file is still open through a `dup`, its events
162 /// keep firing the old callback.
163 ///
164 /// # Errors
165 ///
166 /// - [`ModesetError::InvalidSource`] for a negative or already-registered
167 /// descriptor.
168 /// - [`ModesetError::Io`] if epoll refuses the descriptor.
169 pub fn add_source(&mut self, fd: RawFd, callback: SourceCallback) -> Result<()> {
170 if fd < 0 || self.sources.contains_key(&fd) || self.drm_fd == Some(fd) {
171 return Err(ModesetError::InvalidSource);
172 }
173
174 // SAFETY: the caller owns `fd` and contracts to keep it open until
175 // `remove_source` or drop; the borrow ends when `epoll::add` returns.
176 let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
177 epoll::add(
178 &self.epoll,
179 borrowed,
180 epoll::EventData::new_u64(fd_key(fd)),
181 epoll::EventFlags::IN,
182 )?;
183
184 self.sources.insert(fd, callback);
185 Ok(())
186 }
187
188 /// Unregister a descriptor. Idempotent: unregistering one that was never
189 /// added, or doing it twice, is a no-op.
190 pub fn remove_source(&mut self, fd: RawFd) {
191 if self.sources.remove(&fd).is_some() {
192 // SAFETY: the caller contracts to keep `fd` open until this call
193 // returns; the borrow ends here.
194 let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
195 // Best-effort: the kernel drops the registration when the
196 // descriptor closes anyway, so ENOENT here is not an error.
197 let _ = epoll::delete(&self.epoll, borrowed);
198 }
199 }
200
201 /// Re-bind to a new device after a libseat or VT pause-and-resume.
202 ///
203 /// The dispatcher caches the DRM descriptor and registers it with its
204 /// epoll instance. libseat closes that descriptor on pause and hands back a
205 /// fresh one on resume, leaving the registration pointing at a dead — or
206 /// kernel-recycled — descriptor. Without a rebind the dispatcher would
207 /// either silently miss flip events or fire against a stranger's
208 /// descriptor.
209 ///
210 /// Removing the old registration is best-effort. Adding the new one is
211 /// required: on failure the dispatcher is left with no DRM descriptor, so
212 /// later dispatches report [`ModesetError::NothingToWaitOn`] rather than
213 /// blocking on a half-registered epoll.
214 ///
215 /// Foreign sources are **not** re-bound: their descriptors are caller-owned
216 /// and may or may not survive the resume. A caller whose sources did not
217 /// survive must `remove_source` the old descriptor — its epoll
218 /// registration went with the close, but its callback did not — and
219 /// `add_source` the new one.
220 ///
221 /// # Errors
222 ///
223 /// [`ModesetError::Io`] if the new descriptor cannot be registered.
224 pub fn rebind(&mut self, device: &Device) -> Result<()> {
225 if let Some(old) = self.drm_fd.take() {
226 // SAFETY: borrowed only for this call. The descriptor may already
227 // be closed, which is why the result is discarded.
228 let borrowed = unsafe { BorrowedFd::borrow_raw(old) };
229 let _ = epoll::delete(&self.epoll, borrowed);
230 }
231 // `drm_fd` is already None; it is restored only on success.
232 self.register_drm_fd(device.raw_fd())
233 }
234
235 /// Wait for and dispatch events.
236 ///
237 /// # Invariant 3: never returns `Interrupted`
238 ///
239 /// `EINTR` is retried internally, so a signal landing mid-wait — SIGCHLD,
240 /// an interval timer, a profiler — never abandons a flip still in flight.
241 /// For [`Timeout::Bounded`] the remaining budget is recomputed from a
242 /// monotonic clock across retries, so a signal storm cannot stretch the
243 /// wait past the requested duration.
244 ///
245 /// The Rust-specific hazard the C++ does not have: `rustix`'s epoll wait
246 /// returns `Errno::INTR`, and a bare `?` on it would leak `Interrupted`
247 /// straight through this contract. The retry loop below is the only thing
248 /// standing between that and a broken invariant.
249 ///
250 /// A foreign source's own callback may still observe `EINTR` on its own
251 /// reads — that remains the caller's.
252 ///
253 /// # Errors
254 ///
255 /// - [`ModesetError::NothingToWaitOn`] if there is no DRM descriptor and no
256 /// sources.
257 /// - [`ModesetError::TimedOut`] if the budget elapsed with no events.
258 /// - [`ModesetError::Io`] for any other failure. Never `Errno::INTR`.
259 pub fn dispatch(&mut self, timeout: Timeout) -> Result<()> {
260 if self.drm_fd.is_none() && self.sources.is_empty() {
261 return Err(ModesetError::NothingToWaitOn);
262 }
263
264 let ready = self.wait_retrying_eintr(timeout)?;
265 if ready.is_empty() {
266 return Err(ModesetError::TimedOut);
267 }
268
269 self.route(&ready);
270 Ok(())
271 }
272
273 /// The `epoll_wait` half of [`Self::dispatch`], with the invariant-3 retry
274 /// loop. Separated so the loop is readable on its own.
275 fn wait_retrying_eintr(&self, timeout: Timeout) -> Result<Vec<(RawFd, epoll::EventFlags)>> {
276 let mut events: Vec<epoll::Event> = Vec::with_capacity(MAX_DISPATCH_EVENTS);
277
278 // A bounded wait needs a deadline so the budget can be recomputed
279 // across retries; an infinite or polling wait is retried verbatim.
280 let deadline = match timeout {
281 Timeout::Bounded(budget) => Some(self.clock.now() + budget),
282 Timeout::Infinite | Timeout::Poll => None,
283 };
284
285 loop {
286 let remaining = remaining_budget(timeout, deadline, self.clock.now());
287
288 let spec = remaining.map(|d| rustix::event::Timespec {
289 tv_sec: d.as_secs().try_into().unwrap_or(i64::MAX),
290 tv_nsec: d.subsec_nanos().into(),
291 });
292
293 events.clear();
294 // `spare_capacity`, not `&mut events`: the `Buffer` impl for
295 // `&mut Vec<T>` uses the vector's *length* as `maxevents`, so a
296 // freshly cleared vector asks the kernel for zero events and
297 // `epoll_wait` rejects that with EINVAL.
298 match epoll::wait(
299 &self.epoll,
300 rustix::buffer::spare_capacity(&mut events),
301 spec.as_ref(),
302 ) {
303 Ok(_ready) => {
304 return Ok(events
305 .iter()
306 .map(|event| (key_to_fd(event.data.u64()), event.flags))
307 .collect());
308 }
309 // The invariant. Retry rather than propagate.
310 Err(rustix::io::Errno::INTR) => {}
311 Err(e) => return Err(ModesetError::Io(e)),
312 }
313 }
314 }
315
316 /// Route ready descriptors to the page-flip handler or a source callback.
317 fn route(&mut self, ready: &[(RawFd, epoll::EventFlags)]) {
318 for (fd, _flags) in ready {
319 if Some(*fd) == self.drm_fd {
320 self.handle_drm_events(*fd);
321 continue;
322 }
323 // A callback may have removed its own registration mid-dispatch;
324 // skipping is correct, and the next dispatch will not see it.
325 if let Some(callback) = self.sources.get_mut(fd) {
326 callback();
327 }
328 }
329 }
330
331 /// Read and dispatch pending DRM events on the device descriptor.
332 fn handle_drm_events(&mut self, fd: RawFd) {
333 let Some(handler) = self.handler.as_mut() else {
334 // No handler installed. The events still have to be drained, or
335 // epoll reports the descriptor readable forever.
336 drain_drm_events(fd);
337 return;
338 };
339
340 // SAFETY: `fd` is the registered DRM descriptor, alive for as long as
341 // the `Device` the caller holds; the borrow ends with this call.
342 let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
343 let reader = DrmEventReader { fd: borrowed };
344
345 let Ok(events) = reader.receive_events() else {
346 // A read error here means the descriptor is gone or the kernel gave
347 // us a short read; either way the next dispatch re-observes it.
348 return;
349 };
350
351 for event in events {
352 if let drm::control::Event::PageFlip(flip) = event {
353 handler(PageFlipEvent {
354 crtc_id: flip.crtc.into(),
355 sequence: u64::from(flip.frame),
356 timestamp: Timestamp::from_nanos(
357 u64::try_from(flip.duration.as_nanos()).unwrap_or(u64::MAX),
358 ),
359 });
360 }
361 }
362 }
363}
364
365/// The timeout to pass to the next `epoll_wait` attempt.
366///
367/// This is the budget-recomputation half of **invariant 3**, factored out so it
368/// can be tested without signals. An infinite wait stays infinite and a poll
369/// stays a poll — both are retried verbatim. A bounded wait returns what is
370/// left of its deadline, saturating at zero once the deadline has passed, so a
371/// retry after the budget expired becomes a poll rather than a fresh
372/// full-length wait.
373///
374/// The saturation is the whole point. Returning the full budget on every retry
375/// would mean a sub-millisecond signal cadence restarts a 50 ms wait forever
376/// and `dispatch` never returns at all — a hang, not a wrong answer, which is
377/// why this needs a test that cannot itself hang.
378#[expect(
379 clippy::match_same_arms,
380 reason = "a poll and an unmeasurable bounded wait both yield zero for \
381 different reasons; merging them would erase why"
382)]
383pub(crate) fn remaining_budget(
384 timeout: Timeout,
385 deadline: Option<Timestamp>,
386 now: Timestamp,
387) -> Option<Duration> {
388 match (timeout, deadline) {
389 (Timeout::Infinite, _) => None,
390 (Timeout::Poll, _) => Some(Duration::ZERO),
391 (Timeout::Bounded(_), Some(deadline)) => Some(deadline.saturating_duration_since(now)),
392 // A bounded wait with no deadline cannot happen -- `dispatch` always
393 // computes one -- but treating it as expired is the safe reading: it
394 // ends the wait rather than blocking indefinitely on a budget nobody
395 // can measure.
396 (Timeout::Bounded(_), None) => Some(Duration::ZERO),
397 }
398}
399
400/// Drain DRM events without dispatching them, so a descriptor with no
401/// installed handler does not stay permanently readable.
402fn drain_drm_events(fd: RawFd) {
403 // SAFETY: as in `handle_drm_events`.
404 let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
405 let reader = DrmEventReader { fd: borrowed };
406 let _ = reader.receive_events();
407}
408
409/// Minimal adapter giving the `drm` crate's event reader something to borrow.
410struct DrmEventReader<'a> {
411 fd: BorrowedFd<'a>,
412}
413
414impl std::os::fd::AsFd for DrmEventReader<'_> {
415 fn as_fd(&self) -> BorrowedFd<'_> {
416 self.fd
417 }
418}
419
420impl drm::Device for DrmEventReader<'_> {}
421impl drm::control::Device for DrmEventReader<'_> {}
422
423/// epoll carries a `u64` cookie; descriptors are `i32`. Round-tripping through
424/// the unsigned 32-bit representation is exact for every descriptor the kernel
425/// can hand out, and keeps a hypothetical negative value from aliasing another
426/// key.
427const fn fd_key(fd: RawFd) -> u64 {
428 fd.cast_unsigned() as u64
429}
430
431/// Inverse of [`fd_key`]. Only ever called on a key this module wrote.
432const fn key_to_fd(key: u64) -> RawFd {
433 #[expect(
434 clippy::cast_possible_truncation,
435 reason = "the key was produced by fd_key from a 32-bit descriptor, so \
436 the high half is always zero"
437 )]
438 let narrowed = key as u32;
439 narrowed.cast_signed()
440}
441
442impl Drop for PageFlip {
443 fn drop(&mut self) {
444 // Registrations die with the epoll descriptor; nothing to unwind. The
445 // caller still owns every source descriptor and closes them itself.
446 }
447}