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}