drmkit_scene_sources/external.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! A source wrapping a caller-owned, externally allocated DMA-BUF.
5//!
6//! Port of `src/scene/external_dma_buf_source.{hpp,cpp}`.
7
8use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
9
10use drm::control::{Device as ControlDevice, FbCmd2Flags, framebuffer};
11use drmkit_core::Device;
12use drmkit_scene::{
13 AcquiredBuffer, BindingModel, DmaBufDesc, LayerBufferSource, SourceError, SourceFormat,
14};
15use drmkit_sync::SyncFence;
16
17/// One plane of an externally allocated buffer.
18///
19/// `fd` is caller-owned — the source duplicates it, so the caller may close
20/// theirs the moment the constructor returns.
21#[derive(Debug, Clone, Copy)]
22#[allow(
23 clippy::exhaustive_structs,
24 reason = "a value defined by its fields; there is nothing to add to it"
25)]
26pub struct ExternalPlane<'a> {
27 /// The DMA-BUF descriptor.
28 pub fd: BorrowedFd<'a>,
29 /// Byte offset of this plane within the descriptor.
30 pub offset: u32,
31 /// Row stride in bytes.
32 pub pitch: u32,
33}
34
35/// Why wrapping an external buffer failed.
36#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum ExternalError {
39 /// A dimension, format, or plane description was unusable.
40 #[error("invalid external buffer: {reason}")]
41 Invalid {
42 /// What was wrong.
43 reason: &'static str,
44 },
45 /// An ioctl or syscall failed — duplicating or importing a descriptor, or
46 /// registering the framebuffer.
47 #[error("external buffer operation failed: {0}")]
48 Io(#[from] rustix::io::Errno),
49 /// The key was [`retire`](crate::ExternalDmaBufPool::retire)d and its old
50 /// import is not torn down yet, so the frame was not taken. Try again once
51 /// a later frame has displaced it.
52 #[error("buffer key retired and not yet torn down")]
53 Retired,
54}
55
56/// Up to four planes, as KMS accepts.
57const MAX_PLANES: usize = 4;
58
59/// A DMA-BUF imported into the kernel and registered as a framebuffer.
60///
61/// Split out of [`ExternalDmaBufSource`] because a ring needs one of these per
62/// slot: the import, its duplicated descriptors and the framebuffer's teardown
63/// are the same work whether one buffer is presented or eight are rotated.
64pub(crate) struct ImportedFramebuffer {
65 /// Kept alive so the framebuffer's backing descriptors outlive the
66 /// caller's — the caller may close theirs the moment this returns.
67 ///
68 /// Lent back out by [`dma_buf_planes`](Self::dma_buf_planes), which is what
69 /// makes an imported layer compositable: these sources expose no CPU
70 /// mapping, so the descriptors are the only view of the pixels there is.
71 duped_fds: Vec<OwnedFd>,
72 /// Per-plane byte offset and row stride, as imported.
73 offsets: Vec<u32>,
74 pitches: Vec<u32>,
75 fb: Option<framebuffer::Handle>,
76 /// The GEM handles the import made, closed after the framebuffer.
77 ///
78 /// Each holds a reference to its dma-buf, so one left open pins the
79 /// buffer until the device descriptor closes, however long after the
80 /// producer freed it.
81 handles: Vec<drm::buffer::Handle>,
82 /// The device file the framebuffer was registered on, held for teardown.
83 ///
84 /// A duplicate rather than the number: after a session loss the caller's
85 /// descriptor closes and the next open may take its number, and the kernel
86 /// may hand out the freed framebuffer id again. A teardown through the
87 /// number would then remove the new file's framebuffer. Through this, it
88 /// reaches the file these objects belong to, whatever became of the
89 /// number. `None` once forgotten or taken, when there is nothing to tear
90 /// down.
91 device: Option<OwnedFd>,
92}
93
94impl ImportedFramebuffer {
95 /// The imported planes: their descriptors, offsets, and row strides.
96 ///
97 /// Empty for an import that has been taken (`into_imported`) or never
98 /// made, which is what a caller checks before offering a descriptor.
99 pub(crate) fn dma_buf_planes(&self) -> (&[OwnedFd], &[u32], &[u32]) {
100 (&self.duped_fds, &self.offsets, &self.pitches)
101 }
102
103 /// Import `planes` and register a framebuffer over them.
104 ///
105 /// # Errors
106 ///
107 /// [`ExternalError`] if a descriptor cannot be duplicated or imported, or
108 /// the kernel refuses the framebuffer.
109 pub(crate) fn import(
110 device: &Device,
111 format: SourceFormat,
112 planes: &[ExternalPlane<'_>],
113 ) -> Result<Self, ExternalError> {
114 let source = ExternalDmaBufSource::create(device, format, planes, None)?;
115 Ok(source.into_imported())
116 }
117
118 /// The registered framebuffer id, or `None` once forgotten.
119 pub(crate) fn fb_id(&self) -> Option<u32> {
120 self.fb.map(Into::into)
121 }
122
123 /// Abandon the framebuffer and its GEM handles without issuing an ioctl.
124 ///
125 /// For a revoked descriptor, which cannot service the teardown and took
126 /// both with it. Leaves `fb_id` reporting `None`, so nothing commits an id
127 /// the kernel has forgotten.
128 pub(crate) fn forget(&mut self) {
129 self.fb = None;
130 self.handles.clear();
131 // The objects belong to the old file, which reclaims them when its
132 // last descriptor closes; this one is not needed to tear them down.
133 self.device = None;
134 }
135
136 /// Import the kept descriptors again on `device`, after a session resume.
137 ///
138 /// The old framebuffer and handles belong to the old file, which reclaims
139 /// them when its last descriptor closes, so they are forgotten rather than
140 /// torn down on a file the session has revoked. The buffer itself is
141 /// unchanged; only its import is new. On failure
142 /// nothing is left imported, and `fb_id` reports `None`.
143 ///
144 /// # Errors
145 ///
146 /// [`ExternalError`] if the format is not a recognized DRM format, a
147 /// descriptor cannot be imported, or the kernel refuses the framebuffer.
148 pub(crate) fn reimport(
149 &mut self,
150 device: &Device,
151 format: SourceFormat,
152 ) -> Result<(), ExternalError> {
153 self.forget();
154 if self.duped_fds.is_empty() {
155 return Ok(());
156 }
157 let fourcc = drm::buffer::DrmFourcc::try_from(format.fourcc).map_err(|_| {
158 ExternalError::Invalid {
159 reason: "format is not a recognized DRM FourCC",
160 }
161 })?;
162 let device_duplicate = duplicate(device.as_fd())?;
163 let (fb, handles) = import_planes(
164 device,
165 format,
166 fourcc,
167 &self.duped_fds,
168 &self.offsets,
169 &self.pitches,
170 )?;
171 self.fb = Some(fb);
172 self.handles = handles;
173 self.device = Some(device_duplicate);
174 Ok(())
175 }
176
177 /// Tear the import down on its own descriptor, as a drop would, keeping
178 /// the descriptors for another try.
179 pub(crate) fn release(&mut self) {
180 self.tear_down();
181 }
182
183 /// Remove the framebuffer, then close the handles, on the file they were
184 /// made on, as the reference's `teardown_slot` does.
185 fn tear_down(&mut self) {
186 let Some(file) = self.device.take() else {
187 self.fb = None;
188 self.handles.clear();
189 return;
190 };
191 let device = RawDevice { fd: file.as_fd() };
192 if let Some(fb) = self.fb.take() {
193 let _ = device.destroy_framebuffer(fb);
194 }
195 close_handles(&device, &mut self.handles);
196 }
197}
198
199impl Drop for ImportedFramebuffer {
200 fn drop(&mut self) {
201 self.tear_down();
202 }
203}
204
205/// A descriptor of our own on `fd`'s file.
206fn duplicate(fd: BorrowedFd<'_>) -> Result<OwnedFd, ExternalError> {
207 fd.try_clone_to_owned().map_err(|error| {
208 ExternalError::Io(
209 rustix::io::Errno::from_io_error(&error).unwrap_or(rustix::io::Errno::MFILE),
210 )
211 })
212}
213
214/// Close each distinct GEM handle in `handles`, leaving it empty.
215///
216/// Planes that share a dma-buf import to the same handle, and the kernel
217/// takes a handle back once: closing it per plane, as the reference does, only
218/// adds refused ioctls.
219fn close_handles(device: &impl drm::control::Device, handles: &mut Vec<drm::buffer::Handle>) {
220 handles.sort_unstable_by_key(|handle| u32::from(*handle));
221 handles.dedup();
222 for handle in handles.drain(..) {
223 let _ = device.close_buffer(handle);
224 }
225}
226
227/// Import `fds` into GEM handles on `device` and register a framebuffer over
228/// them: the kernel half of an import, shared by a first import and a
229/// re-import after a session resume.
230///
231/// On failure every handle imported so far is closed again, as the
232/// reference's `import_slot` unwinds.
233fn import_planes(
234 device: &Device,
235 format: SourceFormat,
236 fourcc: drm::buffer::DrmFourcc,
237 fds: &[OwnedFd],
238 offsets: &[u32],
239 pitches: &[u32],
240) -> Result<(framebuffer::Handle, Vec<drm::buffer::Handle>), ExternalError> {
241 let mut layout_handles = [None; MAX_PLANES];
242 let mut layout_offsets = [0u32; MAX_PLANES];
243 let mut layout_pitches = [0u32; MAX_PLANES];
244 let mut imported = Vec::with_capacity(fds.len());
245 for (index, fd) in fds.iter().enumerate() {
246 let handle = match device.prime_fd_to_buffer(fd.as_fd()) {
247 Ok(handle) => handle,
248 Err(e) => {
249 close_handles(device, &mut imported);
250 return Err(ExternalError::Io(errno_of(&e)));
251 }
252 };
253 imported.push(handle);
254 layout_handles[index] = Some(handle);
255 layout_offsets[index] = offsets[index];
256 layout_pitches[index] = pitches[index];
257 }
258
259 // Which path the modifier takes is the device's to decide; see
260 // `drmkit_core::framebuffer_modifier`.
261 let declared = drmkit_core::framebuffer_modifier(device, format.modifier);
262 let layout = ImportedLayout {
263 width: format.width,
264 height: format.height,
265 fourcc,
266 modifier: declared,
267 handles: layout_handles,
268 offsets: layout_offsets,
269 pitches: layout_pitches,
270 };
271 let flags = if declared.is_some() {
272 FbCmd2Flags::MODIFIERS
273 } else {
274 FbCmd2Flags::empty()
275 };
276
277 match device.add_planar_framebuffer(&layout, flags) {
278 Ok(fb) => Ok((fb, imported)),
279 Err(e) => {
280 close_handles(device, &mut imported);
281 Err(ExternalError::Io(errno_of(&e)))
282 }
283 }
284}
285
286/// A caller-owned DMA-BUF wrapped as a scanout-ready KMS framebuffer.
287///
288/// The motivating consumer is a zero-copy capture pipeline — libcamera, V4L2,
289/// an accelerator — where the buffer is allocated upstream and reaches the
290/// scene as a descriptor plus a shape, rather than as something the scene
291/// allocated.
292///
293/// # Single-use semantics
294///
295/// One buffer, one cached framebuffer id, returned from every acquire. The
296/// release callback fires **exactly once** — from the first release, or from
297/// the drop if the source is torn down before ever being released. A caller
298/// uses it to re-queue the upstream request that owns the foreign buffer, so
299/// firing twice would re-queue a buffer that is still in use and firing never
300/// would stall the pipeline.
301///
302/// # Modifiers are passed through, not validated
303///
304/// Whatever modifier the caller supplies goes straight to the kernel, which
305/// validates it against its driver tables and rejects an unsupported tiling.
306/// The kernel is the ground truth, not this type. A caller that wants to know
307/// in advance whether a tiled or compressed modifier will land should consult
308/// the plane's `IN_FORMATS` or a `TEST_ONLY` commit rather than discover it
309/// here.
310pub struct ExternalDmaBufSource {
311 imported: ImportedFramebuffer,
312 format: SourceFormat,
313 /// The producer's render-done fence, duplicated on each acquire.
314 pending_fence: Option<SyncFence>,
315 on_release: Option<Box<dyn FnOnce()>>,
316}
317
318impl std::fmt::Debug for ExternalDmaBufSource {
319 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320 f.debug_struct("ExternalDmaBufSource")
321 .field("format", &self.format)
322 .field("fb", &self.imported.fb_id())
323 .field("has_fence", &self.pending_fence.is_some())
324 .finish_non_exhaustive()
325 }
326}
327
328impl ExternalDmaBufSource {
329 /// Wrap the given planes as a KMS framebuffer.
330 ///
331 /// Duplicates each descriptor so the source's lifetime is independent of
332 /// the caller's, imports them into GEM handles, and registers a
333 /// framebuffer. On any failure the partial state is unwound and the release
334 /// callback is **not** fired — the caller still owns the upstream buffer
335 /// and can re-queue or drop it themselves.
336 ///
337 /// Plane-count-versus-format consistency is left to the kernel: `NV12`
338 /// wants two planes, `YUV420` three, and the kernel returns `EINVAL` on a
339 /// mismatch.
340 ///
341 /// # Errors
342 ///
343 /// [`ExternalError::Invalid`] for a zero dimension, a zero or unrecognized
344 /// format, a plane count outside 1..=4, or a zero pitch;
345 /// [`ExternalError::Io`] if a descriptor cannot be duplicated or an ioctl
346 /// fails.
347 pub fn create(
348 device: &Device,
349 format: SourceFormat,
350 planes: &[ExternalPlane<'_>],
351 on_release: Option<Box<dyn FnOnce()>>,
352 ) -> Result<Self, ExternalError> {
353 if format.width == 0 || format.height == 0 {
354 return Err(ExternalError::Invalid {
355 reason: "width and height must be non-zero",
356 });
357 }
358 if format.fourcc == 0 {
359 return Err(ExternalError::Invalid {
360 reason: "format must be non-zero",
361 });
362 }
363 if planes.is_empty() || planes.len() > MAX_PLANES {
364 return Err(ExternalError::Invalid {
365 reason: "expected between 1 and 4 planes",
366 });
367 }
368 if planes.iter().any(|plane| plane.pitch == 0) {
369 return Err(ExternalError::Invalid {
370 reason: "every plane needs a non-zero pitch",
371 });
372 }
373
374 let fourcc = drm::buffer::DrmFourcc::try_from(format.fourcc).map_err(|_| {
375 ExternalError::Invalid {
376 reason: "format is not a recognized DRM FourCC",
377 }
378 })?;
379
380 // Duplicate first: from here the source owns descriptors of its own and
381 // the caller may close theirs immediately.
382 let mut duped = Vec::with_capacity(planes.len());
383 for plane in planes {
384 duped.push(plane.fd.try_clone_to_owned().map_err(|e| {
385 ExternalError::Io(
386 rustix::io::Errno::from_io_error(&e).unwrap_or(rustix::io::Errno::MFILE),
387 )
388 })?);
389 }
390
391 let device_duplicate = duplicate(device.as_fd())?;
392 let offsets: Vec<u32> = planes.iter().map(|plane| plane.offset).collect();
393 let pitches: Vec<u32> = planes.iter().map(|plane| plane.pitch).collect();
394 let (fb, imported) = import_planes(device, format, fourcc, &duped, &offsets, &pitches)?;
395
396 Ok(Self {
397 imported: ImportedFramebuffer {
398 duped_fds: duped,
399 offsets,
400 pitches,
401 fb: Some(fb),
402 handles: imported,
403 device: Some(device_duplicate),
404 },
405 format,
406 pending_fence: None,
407 on_release,
408 })
409 }
410
411 /// Set the producer's render-done fence for subsequent acquires.
412 ///
413 /// Replaces any previous one. The buffer's pixels are valid only once it
414 /// signals. Each acquire hands back a duplicate rather than the fence
415 /// itself, because the scene acquires twice per frame -- a TEST commit,
416 /// then the real one -- and a fence moved out on the first would leave the
417 /// real commit unsynced.
418 pub fn set_acquire_fence(&mut self, fence: SyncFence) {
419 self.pending_fence = Some(fence);
420 }
421
422 /// Whether a producer fence is currently set.
423 #[must_use]
424 pub const fn has_acquire_fence(&self) -> bool {
425 self.pending_fence.is_some()
426 }
427
428 /// The registered framebuffer id.
429 #[must_use]
430 pub fn fb_id(&self) -> Option<u32> {
431 self.imported.fb_id()
432 }
433
434 /// Take the imported framebuffer, leaving the source inert.
435 ///
436 /// Used by the ring, which wants the import without the single-shot
437 /// release semantics wrapped around it.
438 fn into_imported(mut self) -> ImportedFramebuffer {
439 // The callback must not fire: nothing was ever presented, and the
440 // import is being kept rather than torn down.
441 self.on_release = None;
442 std::mem::replace(
443 &mut self.imported,
444 ImportedFramebuffer {
445 duped_fds: Vec::new(),
446 offsets: Vec::new(),
447 pitches: Vec::new(),
448 fb: None,
449 handles: Vec::new(),
450 device: None,
451 },
452 )
453 }
454
455 /// Fire the release callback if it has not fired yet.
456 fn fire_on_release_once(&mut self) {
457 if let Some(callback) = self.on_release.take() {
458 callback();
459 }
460 }
461}
462
463impl LayerBufferSource for ExternalDmaBufSource {
464 fn acquire(&mut self) -> Result<AcquiredBuffer, SourceError> {
465 let fb_id = self
466 .imported
467 .fb_id()
468 .ok_or(SourceError::Failed(rustix::io::Errno::INVAL))?;
469
470 // Hand back a **duplicate** of the producer's fence, without clearing
471 // it (upstream PR #230).
472 //
473 // The scene acquires each source twice per frame -- a TEST commit, then
474 // the real one. An owned fence handed over on the first acquire would
475 // be gone by the second, so the real commit would go out unsynced and
476 // the display engine could sample the buffer before the producer's
477 // writes land. Duplicating keeps the pending fence live for the second
478 // acquire; the next frame's `set_acquire_fence` replaces it, and
479 // re-arming an already-signaled fence is harmless because the kernel
480 // proceeds immediately.
481 //
482 // Each duplicate rides its own `AcquiredBuffer` and closes when that is
483 // released, so two acquires yield independently closeable descriptors.
484 let acquire_fence = self
485 .pending_fence
486 .as_ref()
487 .and_then(SyncFence::as_fd)
488 .and_then(|fd| SyncFence::import(fd).ok());
489
490 Ok(AcquiredBuffer {
491 fb_id,
492 token: 0,
493 acquire_fence,
494 damage: Vec::new(),
495 })
496 }
497
498 /// Lend the imported descriptors back, so the layer can be composited.
499 ///
500 /// A foreign buffer exposes no CPU mapping, so `map` is correctly
501 /// `Unsupported` -- and that alone would make this layer *uncompositable*:
502 /// it would blank whenever the allocator could not find it a plane, which
503 /// is the case composition exists to rescue. The descriptors are the only
504 /// view of these pixels there is, so lending them is what keeps the layer
505 /// recoverable. Upstream's `ExternalDmaBufSource` does the same from its
506 /// per-plane `duped_fd`.
507 ///
508 /// # Errors
509 ///
510 /// [`SourceError::Unsupported`] once the import has been taken by a ring,
511 /// which leaves nothing to lend.
512 fn export_dma_buf(&mut self) -> Result<DmaBufDesc<'_>, SourceError> {
513 let (fds, offsets, pitches) = self.imported.dma_buf_planes();
514 if fds.is_empty() {
515 return Err(SourceError::Unsupported);
516 }
517 Ok(DmaBufDesc {
518 fds: fds.iter().map(std::os::fd::AsFd::as_fd).collect(),
519 offsets: offsets.to_vec(),
520 pitches: pitches.to_vec(),
521 format: self.format,
522 })
523 }
524
525 fn release(&mut self, _acquired: AcquiredBuffer) {
526 // Single-use: this source is one upstream request's worth of pixels.
527 // The first retire re-queues it upstream; later releases are no-ops, in
528 // case the source is still attached when the scene tears down.
529 self.fire_on_release_once();
530 }
531
532 fn binding_model(&self) -> BindingModel {
533 BindingModel::SceneSubmitsFbId
534 }
535
536 fn format(&self) -> SourceFormat {
537 self.format
538 }
539
540 // `map` keeps its default: the pixels live behind a descriptor this type
541 // does not mmap, and composition reaches them through `export_dma_buf`.
542
543 /// Re-import the buffer on the new descriptor, as the reference does.
544 ///
545 /// The buffer is the producer's and survives the pause; its framebuffer
546 /// and GEM handles do not. Without this, every acquire after a resume
547 /// would hand out a framebuffer id the new descriptor has never heard of.
548 fn on_session_resumed(&mut self, device: &Device) -> Result<(), SourceError> {
549 self.imported
550 .reimport(device, self.format)
551 .map_err(|error| source_error(&error))
552 }
553}
554
555/// An import failure as the scene reports it.
556pub(crate) fn source_error(error: &ExternalError) -> SourceError {
557 match error {
558 ExternalError::Io(errno) => SourceError::Failed(*errno),
559 ExternalError::Invalid { .. } => SourceError::Failed(rustix::io::Errno::INVAL),
560 // Only a pool's submit returns this, never an import.
561 ExternalError::Retired => SourceError::WouldBlock,
562 }
563}
564
565impl ExternalDmaBufSource {
566 /// Abandon the import without an ioctl: for an owner replacing this
567 /// source after a resume, whose descriptor took the import with it.
568 #[cfg(feature = "gbm")]
569 pub(crate) fn forget(&mut self) {
570 self.imported.forget();
571 }
572}
573
574impl Drop for ExternalDmaBufSource {
575 fn drop(&mut self) {
576 // The framebuffer goes with `imported`'s own drop.
577 //
578 // Torn down without ever being released: the upstream buffer still
579 // needs re-queuing, or the producer's pipeline stalls waiting for a
580 // slot that will never come back.
581 self.fire_on_release_once();
582 }
583}
584
585/// Minimal adapter so `Drop` can issue one ioctl without holding a `&Device`.
586struct RawDevice<'a> {
587 fd: BorrowedFd<'a>,
588}
589
590impl AsFd for RawDevice<'_> {
591 fn as_fd(&self) -> BorrowedFd<'_> {
592 self.fd
593 }
594}
595
596impl drm::Device for RawDevice<'_> {}
597impl drm::control::Device for RawDevice<'_> {}
598
599/// The plane layout handed to `add_planar_framebuffer`.
600struct ImportedLayout {
601 width: u32,
602 height: u32,
603 fourcc: drm::buffer::DrmFourcc,
604 modifier: Option<u64>,
605 handles: [Option<drm::buffer::Handle>; MAX_PLANES],
606 offsets: [u32; MAX_PLANES],
607 pitches: [u32; MAX_PLANES],
608}
609
610impl drm::buffer::PlanarBuffer for ImportedLayout {
611 fn size(&self) -> (u32, u32) {
612 (self.width, self.height)
613 }
614
615 fn format(&self) -> drm::buffer::DrmFourcc {
616 self.fourcc
617 }
618
619 fn modifier(&self) -> Option<drm::buffer::DrmModifier> {
620 self.modifier.map(drm::buffer::DrmModifier::from)
621 }
622
623 fn pitches(&self) -> [u32; 4] {
624 self.pitches
625 }
626
627 fn handles(&self) -> [Option<drm::buffer::Handle>; 4] {
628 self.handles
629 }
630
631 fn offsets(&self) -> [u32; 4] {
632 self.offsets
633 }
634}
635
636fn errno_of(error: &std::io::Error) -> rustix::io::Errno {
637 rustix::io::Errno::from_io_error(error).unwrap_or(rustix::io::Errno::IO)
638}