Skip to main content

drmkit_scene/
frame.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! What a commit does to scene state once the kernel has answered.
5//!
6//! Ports the `finalize_frame` reconciliation from `src/scene/layer_scene.cpp`.
7//!
8//! # Why this is a separate type
9//!
10//! The C++ `finalize_frame` takes the kernel's result as a parameter, so the
11//! scene's reaction to success, rejection, and lost master is separable from
12//! actually talking to a device. Keeping that shape means every rule below —
13//! the release timing, the suspend, the cache invalidation — is testable by
14//! passing a different result, with no kernel involved.
15
16use drmkit_planes::LayerId;
17
18use crate::release::{Acquisition, ReleaseQueue, ReleaseReason, Released, ScenePendingFlip};
19use crate::report::CommitReport;
20
21/// What the kernel said about a commit.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum KernelResult {
25    /// Applied. For a real commit this means a flip is on its way.
26    Ok,
27    /// Rejected because the caller is not DRM master.
28    ///
29    /// The **only** failure that suspends the scene.
30    NotMaster,
31    /// Rejected for any other reason.
32    ///
33    /// The frame is dropped and heals on the next one. The scene keeps running.
34    Rejected,
35}
36
37/// What one commit was.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum CommitKind {
41    /// A real commit. May arm a page-flip event.
42    Real {
43        /// Whether `DRM_MODE_PAGE_FLIP_EVENT` was armed.
44        arms_flip: bool,
45    },
46    /// A `TEST_ONLY` commit, which applies nothing to hardware.
47    Test,
48}
49
50/// What the scene must do after a commit.
51#[derive(Debug)]
52#[non_exhaustive]
53pub struct FrameOutcome {
54    /// Buffers to hand back to their sources, and why.
55    pub released: Released,
56    /// The report for this commit.
57    pub report: CommitReport,
58    /// Whether the scene is now suspended.
59    pub suspended: bool,
60    /// Whether the allocator's cached assignment must be dropped.
61    ///
62    /// Set when the kernel rejected a frame whose `TEST_ONLY` was skipped —
63    /// invariant 4's safety net.
64    pub invalidate_allocation: bool,
65}
66
67/// Owns the state a commit reconciles: the release ring, the armed-flip
68/// tripwire, and whether the scene is suspended.
69#[derive(Debug, Default)]
70pub struct FrameLifecycle {
71    releases: ReleaseQueue,
72    pending_flip: ScenePendingFlip,
73    suspended: bool,
74    /// Whether the fast-path rejection warning has already been emitted.
75    warned_fast_path_reject: bool,
76    /// [`CommitReport::commit_seq`] of the most recent real commit; 0 before
77    /// one.
78    last_commit_seq: u64,
79}
80
81impl FrameLifecycle {
82    /// A fresh lifecycle with nothing in flight.
83    #[must_use]
84    pub const fn new() -> Self {
85        Self {
86            releases: ReleaseQueue::new(),
87            pending_flip: ScenePendingFlip::new(),
88            suspended: false,
89            warned_fast_path_reject: false,
90            last_commit_seq: 0,
91        }
92    }
93
94    /// Whether the scene is suspended and short-circuiting commits.
95    #[must_use]
96    pub const fn is_suspended(&self) -> bool {
97        self.suspended
98    }
99
100    /// Whether a page-flip event is armed and undispatched.
101    ///
102    /// **Invariant 5.** Dropping the scene while this is true tears down a
103    /// framebuffer the flip still references.
104    #[must_use]
105    pub const fn has_pending_flip(&self) -> bool {
106        self.pending_flip.is_armed()
107    }
108
109    /// How many buffers are held in flight.
110    #[must_use]
111    pub fn buffers_in_flight(&self) -> usize {
112        self.releases.in_flight()
113    }
114
115    /// Record that the armed page-flip event was dispatched.
116    pub const fn flip_landed(&mut self) {
117        self.pending_flip.landed();
118    }
119
120    /// Lift a suspension after the session resumes.
121    pub const fn resume(&mut self) {
122        self.suspended = false;
123    }
124
125    /// Reconcile scene state with the kernel's answer.
126    ///
127    /// # The rules, and why each is where it is
128    ///
129    /// - **Success on a real commit** rotates the release ring and returns the
130    ///   buffers two commits deep — invariant 1.
131    /// - **Any failure** returns this frame's buffers at once, because no flip
132    ///   is in flight to gate them on — invariant 2.
133    /// - **Only `NotMaster` suspends.** A non-`EACCES` rejection drops the
134    ///   frame and heals on the next one; suspending on it would take a
135    ///   recoverable stumble and stop the scene. The C++ states this as an
136    ///   external contract, and self-healing producer rings depend on it.
137    /// - **A rejected fast-path frame invalidates the cached allocation.**
138    ///   Invariant 4's safety net: the kernel refused a frame whose `TEST_ONLY`
139    ///   was skipped, so the cache is no longer trustworthy. The frame is
140    ///   dropped but heals — the failure mode is never corruption. A recurring
141    ///   rejection here means a property was misclassified as content, which is
142    ///   why it is worth warning about once.
143    /// - **A test commit** returns its buffers at once and changes nothing
144    ///   else: it applied nothing to hardware.
145    pub fn finalize(
146        &mut self,
147        kind: CommitKind,
148        result: KernelResult,
149        acquisitions: Vec<Acquisition>,
150        mut report: CommitReport,
151        release_fence: Option<&drmkit_sync::SyncFence>,
152        wants_fence: impl FnMut(LayerId) -> bool,
153    ) -> FrameOutcome {
154        if let CommitKind::Test = kind {
155            return FrameOutcome {
156                released: self.releases.test_committed(acquisitions),
157                report,
158                suspended: self.suspended,
159                invalidate_allocation: false,
160            };
161        }
162
163        match result {
164            KernelResult::Ok => {
165                if matches!(kind, CommitKind::Real { arms_flip: true }) {
166                    self.pending_flip.arm();
167                }
168                self.last_commit_seq += 1;
169                report.commit_seq = self.last_commit_seq;
170                let released =
171                    self.releases
172                        .commit_succeeded(acquisitions, release_fence, wants_fence);
173                FrameOutcome {
174                    released,
175                    report,
176                    suspended: self.suspended,
177                    invalidate_allocation: false,
178                }
179            }
180            KernelResult::NotMaster => {
181                self.suspended = true;
182                FrameOutcome {
183                    released: self.releases.commit_failed(acquisitions),
184                    report,
185                    suspended: true,
186                    invalidate_allocation: false,
187                }
188            }
189            KernelResult::Rejected => {
190                // Invariant 4's safety net. Only meaningful when the TEST was
191                // skipped -- an ordinary rejection after a real TEST says
192                // nothing new about the cache.
193                let invalidate = report.fb_delta_fast_path;
194                if invalidate {
195                    // Once per scene, as upstream does: a rejection that keeps
196                    // coming back means a property was misclassified as
197                    // content, and saying so every frame would bury it.
198                    if !self.warned_fast_path_reject {
199                        self.warned_fast_path_reject = true;
200                        drmkit_log::log_warn!(
201                            "scene: FB-only fast-path commit rejected by kernel; \
202                             invalidating cached allocation and re-testing next frame"
203                        );
204                    }
205                    // The frame is dropped, not corrupted: the cache is
206                    // dropped, the next frame re-searches, and the report no
207                    // longer claims a fast path that did not hold.
208                    report.fb_delta_fast_path = false;
209                }
210                FrameOutcome {
211                    released: self.releases.commit_failed(acquisitions),
212                    report,
213                    suspended: self.suspended,
214                    invalidate_allocation: invalidate,
215                }
216            }
217        }
218    }
219
220    /// Whether a fast-path rejection has been warned about yet. Set by
221    /// [`finalize`](Self::finalize), which logs the first one and no other.
222    #[must_use]
223    pub const fn has_warned_fast_path_reject(&self) -> bool {
224        self.warned_fast_path_reject
225    }
226
227    /// The buffers the flip of real commit `commit_seq` took off screen, for
228    /// handing back at that flip rather than at the next commit.
229    ///
230    /// Nothing unless `commit_seq` names the most recent real commit: after
231    /// an older one, a newer commit has rotated buffers still on screen into
232    /// the generation this would release. So a late or repeated call cannot
233    /// release a buffer still on screen. Port of upstream's
234    /// `release_displaced` (`ee90026`).
235    pub fn release_displaced(&mut self, commit_seq: u64) -> Released {
236        if commit_seq == 0 || commit_seq != self.last_commit_seq {
237            return Released {
238                acquisitions: Vec::new(),
239                reason: ReleaseReason::Retired,
240            };
241        }
242        self.releases.release_displaced()
243    }
244
245    /// Hand back every held buffer, for teardown, pause, or rebind.
246    ///
247    /// **Invariant 5.** Does not wait on the kernel. Landing an armed flip
248    /// first is the caller's job — [`has_pending_flip`](Self::has_pending_flip)
249    /// says whether one is outstanding.
250    pub fn drain(&mut self) -> Released {
251        self.releases.drain()
252    }
253}
254
255/// Accounting for one build pass's acquire loop.
256///
257/// Separated so the EAGAIN rule is testable on its own: a source with nothing
258/// to contribute is **skipped**, counted, and retried next frame — it is not a
259/// failure, and it must not abort the commit.
260#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
261#[non_exhaustive]
262pub struct AcquireTally {
263    /// Layers that produced a buffer.
264    pub acquired: usize,
265    /// Layers whose source reported [`WouldBlock`](crate::SourceError::WouldBlock).
266    pub skipped_no_frame: usize,
267}
268
269impl AcquireTally {
270    /// Fold one source's outcome into the tally.
271    ///
272    /// Returns whether the layer contributed a buffer.
273    pub const fn record(&mut self, acquired: bool) -> bool {
274        if acquired {
275            self.acquired += 1;
276        } else {
277            self.skipped_no_frame += 1;
278        }
279        acquired
280    }
281
282    /// How many layers the loop considered.
283    #[must_use]
284    pub const fn considered(&self) -> usize {
285        self.acquired + self.skipped_no_frame
286    }
287}