Skip to main content

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}