drmkit_core/atomic.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Atomic modeset requests.
5//!
6//! Port of `src/modeset/atomic.{hpp,cpp}`.
7
8use std::os::fd::{FromRawFd as _, OwnedFd};
9use std::sync::LazyLock;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12use drm::control::{AtomicCommitFlags, Device as ControlDevice, atomic::AtomicModeReq};
13
14use crate::device::Device;
15use crate::error::{CoreError, Result};
16
17/// One accumulated `(object, property, value)` write.
18///
19/// This is the DR-9 seam. The C++ exposes `AtomicRequest::native_handle()`
20/// returning a raw `drmModeAtomicReq*`, because NVIDIA's
21/// `eglStreamConsumerAcquireAttribEXT` takes one through
22/// `EGL_DRM_ATOMIC_REQUEST_NV` and submits the commit itself. That pointer
23/// cannot exist here: `drm-ffi` builds the kernel struct from flat slices at
24/// commit time and never materializes a libdrm request object.
25///
26/// Exporting the writes instead lets `drmkit-scene-streams` replay them into a
27/// libdrm request it owns, so libdrm stays out of `drmkit-core` and out of
28/// GPU-less builds. The same ordered view is what T7's property-write trace
29/// differ consumes, so the seam has a consumer in the minimal cut rather than
30/// lying dormant until the streams tier lands.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[allow(
33 clippy::exhaustive_structs,
34 reason = "a value defined by its fields; there is nothing to add to it"
35)]
36pub struct PropertyWrite {
37 /// The DRM object being programmed.
38 pub object_id: u32,
39 /// The property id on that object.
40 pub property_id: u32,
41 /// The value to write.
42 pub value: u64,
43}
44
45/// An atomic modeset request under construction.
46///
47/// # Deviation: no libdrm request object
48///
49/// The C++ allocates a `drmModeAtomicReq` up front and appends to it, so its
50/// `valid()` reports whether that allocation succeeded. Here the request is a
51/// plain vector of [`PropertyWrite`]s and the `drm` crate's [`AtomicModeReq`]
52/// is built at commit time. There is no allocation to fail, so there is no
53/// `valid()` — construction is infallible.
54#[derive(Debug, Clone, Default)]
55pub struct AtomicRequest {
56 writes: Vec<PropertyWrite>,
57}
58
59impl AtomicRequest {
60 /// An empty request.
61 #[must_use]
62 pub const fn new() -> Self {
63 Self { writes: Vec::new() }
64 }
65
66 /// An empty request with room for `capacity` writes.
67 #[must_use]
68 pub fn with_capacity(capacity: usize) -> Self {
69 Self {
70 writes: Vec::with_capacity(capacity),
71 }
72 }
73
74 /// Append a property write.
75 ///
76 /// Order is preserved and duplicates are kept: the kernel applies writes in
77 /// order, so a later write to the same property wins, and collapsing them
78 /// here would change what a replayed trace means.
79 ///
80 /// # Errors
81 ///
82 /// [`CoreError::Io`] with `EINVAL` if either id is zero. libdrm's
83 /// `drmModeAtomicAddProperty` rejects a zero id the same way, and rejecting
84 /// at insertion is what keeps the commit path total: DRM resource handles
85 /// are non-zero by construction, so a zero id has no representation to
86 /// build a request from and could only be dropped silently — a commit going
87 /// out one property short, which is far worse than an error here.
88 ///
89 /// Note that non-zero ids are **not** checked against real kernel objects.
90 /// libdrm does not either; that check happens at commit time.
91 pub fn add_property(&mut self, object_id: u32, property_id: u32, value: u64) -> Result<()> {
92 if object_id == 0 || property_id == 0 {
93 return Err(CoreError::Io(rustix::io::Errno::INVAL));
94 }
95 self.writes.push(PropertyWrite {
96 object_id,
97 property_id,
98 value,
99 });
100 Ok(())
101 }
102
103 /// The accumulated writes, in submission order.
104 ///
105 /// See [`PropertyWrite`] for why this exists rather than a raw request
106 /// pointer.
107 #[must_use]
108 pub fn writes(&self) -> &[PropertyWrite] {
109 &self.writes
110 }
111
112 /// How many writes are queued.
113 #[must_use]
114 pub fn len(&self) -> usize {
115 self.writes.len()
116 }
117
118 /// Whether nothing is queued.
119 #[must_use]
120 pub fn is_empty(&self) -> bool {
121 self.writes.is_empty()
122 }
123
124 /// Drop every queued write, keeping the allocation for the next frame.
125 pub fn clear(&mut self) {
126 self.writes.clear();
127 }
128
129 /// Build the `drm` crate request from the accumulated writes.
130 ///
131 /// Fallible rather than skipping: `RawResourceHandle` is a `NonZeroU32`, so
132 /// a zero id has no representation. Skipping one would submit a commit
133 /// missing a property and report success. [`AtomicRequest::add_property`]
134 /// already rejects zero ids, so this is unreachable in practice — but the
135 /// failure mode it guards against is silent corruption, which is worth a
136 /// branch.
137 fn build(&self) -> Result<AtomicModeReq> {
138 let mut req = AtomicModeReq::new();
139 for write in &self.writes {
140 let (Some(object), Some(property)) = (
141 drm::control::RawResourceHandle::new(write.object_id),
142 drm::control::RawResourceHandle::new(write.property_id),
143 ) else {
144 return Err(CoreError::Io(rustix::io::Errno::INVAL));
145 };
146 req.add_raw_property(object, property.into(), write.value);
147 }
148 Ok(req)
149 }
150
151 /// Validate the request without applying it (`DRM_MODE_ATOMIC_TEST_ONLY`).
152 ///
153 /// `PAGE_FLIP_EVENT` is masked out of `flags`. The kernel rejects
154 /// `TEST_ONLY | PAGE_FLIP_EVENT` with `EINVAL` — a test applies nothing to
155 /// hardware, so there is no flip to queue an event on — and callers
156 /// routinely forward the same flags they would pass to
157 /// [`commit`](Self::commit). The allocator replays caller flags for its
158 /// internal test commits, so masking here beats making every caller
159 /// remember.
160 ///
161 /// # Errors
162 ///
163 /// [`CoreError::NotMaster`] if the caller lost DRM master, otherwise
164 /// [`CoreError::Io`] with the kernel's rejection reason.
165 pub fn test(&self, device: &Device, flags: AtomicCommitFlags) -> Result<()> {
166 let test_flags =
167 (flags - AtomicCommitFlags::PAGE_FLIP_EVENT) | AtomicCommitFlags::TEST_ONLY;
168 let result = device
169 .atomic_commit(test_flags, self.build()?)
170 .map_err(|e| CoreError::from_io(&e));
171 self.trace(device, &result, "test");
172 result
173 }
174
175 /// Apply the request.
176 ///
177 /// # Errors
178 ///
179 /// [`CoreError::NotMaster`] if the caller is not DRM master — the one
180 /// failure a scene suspends on (invariant 2) — otherwise [`CoreError::Io`].
181 pub fn commit(&self, device: &Device, flags: AtomicCommitFlags) -> Result<()> {
182 let result = device
183 .atomic_commit(flags, self.build()?)
184 .map_err(|e| CoreError::from_io(&e));
185 self.trace(device, &result, "commit");
186 result
187 }
188
189 /// Log every write this request carries, property ids resolved to names.
190 ///
191 /// A rejected atomic request says only that the kernel disliked
192 /// *something*, and nothing in the error names which property. No-op
193 /// unless `DRM_ATOMIC_DEBUG` or `DRM_ALLOC_DEBUG` is set -- upstream's
194 /// switches, so one variable works on both implementations -- and then
195 /// routed through [`log_channel!`](drmkit_log::log_channel), since the
196 /// variable is already the opt-in. `why` labels the dump.
197 ///
198 /// [`test`](Self::test) and [`commit`](Self::commit) call it themselves on
199 /// a rejection, and on the first request accepted after one: a rejected
200 /// request beside an accepted one is what shows the difference, where a
201 /// rejection alone is read as whatever looks suspicious. Upstream wires
202 /// the same two dumps into its allocator and scene; here every request
203 /// goes through these two methods, so wiring them once covers the
204 /// allocator, the present backend and the cursor alike.
205 pub fn dump(&self, device: &Device, why: &str) {
206 if !*ATOMIC_DEBUG {
207 return;
208 }
209 let name_of = |property_id: u32| {
210 let handle = drm::control::property::Handle::from(
211 drm::control::RawResourceHandle::new(property_id)?,
212 );
213 let info = device.get_property(handle).ok()?;
214 Some(info.name().to_string_lossy().into_owned())
215 };
216 for line in self.dump_lines(why, name_of) {
217 drmkit_log::log_channel!(drmkit_log::LogLevel::Debug, "{line}");
218 }
219 }
220
221 /// The dump's text, with names supplied by `name_of`.
222 pub(crate) fn dump_lines(
223 &self,
224 why: &str,
225 name_of: impl Fn(u32) -> Option<String>,
226 ) -> Vec<String> {
227 let count = self.writes.len();
228 let mut lines = Vec::with_capacity(count + 1);
229 lines.push(format!(
230 "[atomic] {why}: {count} propert{} in this request",
231 if count == 1 { "y" } else { "ies" }
232 ));
233 for write in &self.writes {
234 let name = name_of(write.property_id).unwrap_or_else(|| "?".to_owned());
235 lines.push(format!(
236 "[atomic] obj={:<4} {name:<24} = {}",
237 write.object_id, write.value
238 ));
239 }
240 lines
241 }
242
243 /// Dump a rejection, and the first acceptance that follows one.
244 fn trace(&self, device: &Device, result: &Result<()>, what: &str) {
245 if !*ATOMIC_DEBUG {
246 return;
247 }
248 match result {
249 Ok(()) => {
250 if SAW_REJECTION.swap(false, Ordering::Relaxed) {
251 self.dump(
252 device,
253 &format!("{what} accepted (first after a rejection)"),
254 );
255 }
256 }
257 // Losing master is not a property problem; the dump would only
258 // bury the one that is.
259 Err(CoreError::NotMaster) => {}
260 Err(error) => {
261 SAW_REJECTION.store(true, Ordering::Relaxed);
262 self.dump(device, &format!("{what} rejected ({error})"));
263 }
264 }
265 }
266}
267
268/// Whether request dumps are on: `DRM_ATOMIC_DEBUG`, or `DRM_ALLOC_DEBUG`,
269/// which turns on both so the allocator's trace and the requests arrive
270/// together. Read once.
271static ATOMIC_DEBUG: LazyLock<bool> = LazyLock::new(|| {
272 std::env::var_os("DRM_ATOMIC_DEBUG").is_some() || std::env::var_os("DRM_ALLOC_DEBUG").is_some()
273});
274
275/// Set by a rejected request, cleared by the next accepted one, so that one
276/// can be dumped alongside it.
277static SAW_REJECTION: AtomicBool = AtomicBool::new(false);
278
279/// Commit `build`'s request and collect the CRTC's out-fence.
280///
281/// `OUT_FENCE_PTR` is unlike every other atomic property: its value is not
282/// data, it is the address of a signed 32-bit slot the **kernel writes into**.
283/// Getting a fence back therefore means handing the kernel a pointer that has
284/// to stay put from the moment the property is added until the ioctl returns.
285///
286/// The closure is why this is a function rather than a property write the
287/// caller makes itself. The slot lives on this frame's stack for exactly the
288/// commit, `build` receives only the value to write, and the fence is taken
289/// afterwards — so there is no arrangement of the safe API that lets the
290/// pointer outlive what it points at, or that lets a caller commit a request
291/// carrying a stale one.
292///
293/// Whatever `build` returns is held until the commit has been issued, and
294/// dropped only then. That is where property blobs the request names belong
295/// -- `FB_DAMAGE_CLIPS` above all: the request carries only their ids, so a
296/// blob that dropped inside the closure would be destroyed before the ioctl,
297/// and the kernel refuses a commit naming a blob it no longer has.
298///
299/// Returns `None` when the commit succeeded but the kernel left the slot
300/// alone: a `TEST_ONLY` never produces a fence, and neither does a driver
301/// without `OUT_FENCE_PTR`.
302///
303/// # Errors
304///
305/// As [`AtomicRequest::commit`], plus whatever `build` returns.
306pub fn commit_with_out_fence<F, K>(
307 device: &Device,
308 flags: AtomicCommitFlags,
309 build: F,
310) -> Result<Option<OwnedFd>>
311where
312 F: FnOnce(&mut AtomicRequest, u64) -> Result<K>,
313{
314 // -1 is the kernel's "nothing here" value, and stays that way if the
315 // driver has no OUT_FENCE_PTR or the commit was a test.
316 let mut slot: i32 = -1;
317 let mut request = AtomicRequest::new();
318 let kept = build(&mut request, (&raw mut slot) as u64)?;
319 request.commit(device, flags)?;
320 drop(kept);
321
322 if slot < 0 {
323 return Ok(None);
324 }
325 // SAFETY: the kernel returned a fresh sync_file descriptor in `slot` and
326 // nothing else holds it, so this is the sole owner.
327 Ok(Some(unsafe { OwnedFd::from_raw_fd(slot) }))
328}