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 other than [`CoreError::NotMaster`], and on the first
200 /// request accepted after one: a rejected
201 /// request beside an accepted one is what shows the difference, where a
202 /// rejection alone is read as whatever looks suspicious. Upstream wires
203 /// the same two dumps into its allocator and scene; here every request
204 /// goes through these two methods, so wiring them once covers the
205 /// allocator, the present backend and the cursor alike.
206 pub fn dump(&self, device: &Device, why: &str) {
207 if !*ATOMIC_DEBUG {
208 return;
209 }
210 let name_of = |property_id: u32| {
211 let handle = drm::control::property::Handle::from(
212 drm::control::RawResourceHandle::new(property_id)?,
213 );
214 let info = device.get_property(handle).ok()?;
215 Some(info.name().to_string_lossy().into_owned())
216 };
217 for line in self.dump_lines(why, name_of) {
218 drmkit_log::log_channel!(drmkit_log::LogLevel::Debug, "{line}");
219 }
220 }
221
222 /// The dump's text, with names supplied by `name_of`.
223 pub(crate) fn dump_lines(
224 &self,
225 why: &str,
226 name_of: impl Fn(u32) -> Option<String>,
227 ) -> Vec<String> {
228 let count = self.writes.len();
229 let mut lines = Vec::with_capacity(count + 1);
230 lines.push(format!(
231 "[atomic] {why}: {count} propert{} in this request",
232 if count == 1 { "y" } else { "ies" }
233 ));
234 for write in &self.writes {
235 let name = name_of(write.property_id).unwrap_or_else(|| "?".to_owned());
236 lines.push(format!(
237 "[atomic] obj={:<4} {name:<24} = {}",
238 write.object_id, write.value
239 ));
240 }
241 lines
242 }
243
244 /// Dump a rejection, and the first acceptance that follows one.
245 fn trace(&self, device: &Device, result: &Result<()>, what: &str) {
246 if !*ATOMIC_DEBUG {
247 return;
248 }
249 match result {
250 Ok(()) => {
251 if SAW_REJECTION.swap(false, Ordering::Relaxed) {
252 self.dump(
253 device,
254 &format!("{what} accepted (first after a rejection)"),
255 );
256 }
257 }
258 // Losing master is not a property problem; the dump would only
259 // bury the one that is.
260 Err(CoreError::NotMaster) => {}
261 Err(error) => {
262 SAW_REJECTION.store(true, Ordering::Relaxed);
263 self.dump(device, &format!("{what} rejected ({error})"));
264 }
265 }
266 }
267}
268
269/// Whether request dumps are on: `DRM_ATOMIC_DEBUG`, or `DRM_ALLOC_DEBUG`,
270/// which turns on both so the allocator's trace and the requests arrive
271/// together. Read once.
272static ATOMIC_DEBUG: LazyLock<bool> = LazyLock::new(|| {
273 std::env::var_os("DRM_ATOMIC_DEBUG").is_some() || std::env::var_os("DRM_ALLOC_DEBUG").is_some()
274});
275
276/// Set by a rejected request, cleared by the next accepted one, so that one
277/// can be dumped alongside it.
278static SAW_REJECTION: AtomicBool = AtomicBool::new(false);
279
280/// Commit `build`'s request and collect the CRTC's out-fence.
281///
282/// `OUT_FENCE_PTR` is unlike every other atomic property: its value is not
283/// data, it is the address of a signed 32-bit slot the **kernel writes into**.
284/// Getting a fence back therefore means handing the kernel a pointer that has
285/// to stay put from the moment the property is added until the ioctl returns.
286///
287/// The closure is why this is a function rather than a property write the
288/// caller makes itself. The slot lives on this frame's stack for exactly the
289/// commit, `build` receives only the value to write, and the fence is taken
290/// afterwards — so there is no arrangement of the safe API that lets the
291/// pointer outlive what it points at, or that lets a caller commit a request
292/// carrying a stale one.
293///
294/// Whatever `build` returns is held until the commit has been issued, and
295/// dropped only then. That is where property blobs the request names belong
296/// -- `FB_DAMAGE_CLIPS` above all: the request carries only their ids, so a
297/// blob that dropped inside the closure would be destroyed before the ioctl,
298/// and the kernel refuses a commit naming a blob it no longer has.
299///
300/// Returns `None` when the commit succeeded but the kernel left the slot
301/// alone: a `TEST_ONLY` never produces a fence, and neither does a driver
302/// without `OUT_FENCE_PTR`.
303///
304/// # Errors
305///
306/// As [`AtomicRequest::commit`], plus whatever `build` returns.
307pub fn commit_with_out_fence<F, K>(
308 device: &Device,
309 flags: AtomicCommitFlags,
310 build: F,
311) -> Result<Option<OwnedFd>>
312where
313 F: FnOnce(&mut AtomicRequest, u64) -> Result<K>,
314{
315 // -1 is the kernel's "nothing here" value, and stays that way if the
316 // driver has no OUT_FENCE_PTR or the commit was a test.
317 let mut slot: i32 = -1;
318 let mut request = AtomicRequest::new();
319 let kept = build(&mut request, (&raw mut slot) as u64)?;
320 request.commit(device, flags)?;
321 drop(kept);
322
323 if slot < 0 {
324 return Ok(None);
325 }
326 // SAFETY: the kernel returned a fresh sync_file descriptor in `slot` and
327 // nothing else holds it, so this is the sole owner.
328 Ok(Some(unsafe { OwnedFd::from_raw_fd(slot) }))
329}