Skip to main content

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}