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, 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}