Files
rustdesk/libs/scrap/src/common/drm_render.rs
RustDesk 9a81c8a138 Drm deb in release workflow (#15776)
* docs(agents): add a comment-length rule

Comments were growing to document rejected alternatives, past bugs and
measurements. That belongs in the commit message, not the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(drm): build the unattended-wayland deb in the release workflow

The deb was built by a separate drm-capture workflow on a plain runner,
so it diverged from every other Linux deb: different base, different
vcpkg/ffmpeg, different toolchain. Move it into flutter-build.yml as
build-rustdesk-linux-drm, mirroring build-rustdesk-linux's x86_64 path --
same ubuntu18.04 container, same vcpkg install, same rust and flutter.
libdrmtap is built on the runner first and handed to the container via
DRMTAP_PREBUILT_DIR, because bionic's meson is too old to build it.

The job is ungated, so the --drm packaging path is exercised on every PR;
only publishing stays gated on upload-artifact. drm-capture.yml is
deleted along with docs/DRM_CAPTURE_SECURITY.md -- the 29 drm unit tests
that workflow ran are no longer executed by CI.

Three bugs the move exposed:

- build.py anchored the libdrmtap paths on abspath(__file__), which is
  only cwd-independent on Python >= 3.9 (bpo-20443). The packaging
  container runs 3.6 and chdir's into flutter/, so the ABI-gate
  cross-check resolved one directory off and every --drm packaging run
  would have died with FileNotFoundError. Captured as REPO_ROOT at
  import instead.
- DRMTAP_PREBUILT_DIR no longer needs DRMTAP_ALLOW_UNPINNED. A prebuilt
  dir inside the repo's own third_party/libdrmtap at the pinned sha is
  the pinned object, not an override, and is now verified as such.
- The variant's Depends carried a bare libdrm2. libdrmtap needs
  drmModeGetFB2, so it is libdrm2 (>= 2.4.95); below that the package
  installed and could never capture.

The loader also logs the dlerror now instead of discarding it, so a
soname or glibc mismatch is named rather than surfacing as a generic
"libdrmtap not available".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(drm): declare the unattended-wayland deb's real libc6 and libdrm floors

libdrmtap is built on the ubuntu-22.04 runner while the rest of the deb comes
from the ubuntu18.04 container, so the package has a mixed glibc floor and
declared neither half. It installed happily on Ubuntu 20.04 / Debian 11
(glibc 2.31), then dlopen failed on GLIBC_2.34 and capture degraded to the
PipeWire portal -- the one thing this variant exists to avoid. Measure the
floor off the staged objects and put it in Depends, so apt refuses with a
reason instead of handing over a package that can never capture.

Measured rather than written down: the number moves whenever either base does,
and it lands exactly on RHEL/Rocky 9 (glibc 2.34), where one off-by-one decides
whether that whole family can install.

drmModeGetFB2 landed in libdrm 2.4.101, not 2.4.95 -- checked against the
libdrm tags, xf86drmMode.h first declares it in 2.4.101. The old floor admitted
Debian 10 (2.4.97), where the .so is linked -z now and dies on an undefined
symbol at dlopen. libdrmtap's own meson.build carries the same wrong number.

Upload the deb on always(): the run that fails the drm check is the one whose
artifact is most worth downloading. Publish stays gated on success, so an
unverified build still cannot reach a release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:31:09 +08:00

185 lines
8.1 KiB
Rust

// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout
// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd
// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to
// its own CPU-mapped grab (`drmtap_grab_mapped`).
use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib};
use super::Pixfmt;
use hbb_common::log;
use std::ffi::CString;
use std::io;
use std::os::fd::RawFd;
use super::drm_reader::{
DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888,
MAX_DIM, MAX_FRAME_BYTES,
};
/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context
/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread.
pub struct RenderConverter {
lib: &'static DrmtapLib,
ctx: *mut drmtap_ctx,
}
impl RenderConverter {
/// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection.
pub fn open_render(node: Option<&str>) -> Option<RenderConverter> {
let lib = drmtap_dl::get()?;
let open_render = lib.open_render;
let node_cstr = match node.filter(|n| !n.is_empty()) {
None => None,
// Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check.
Some(n) => match super::drm_reader::device_under_dev_dri(n) {
None => {
log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting");
None
}
Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()),
},
};
// SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection.
let ctx = unsafe {
open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()))
};
if ctx.is_null() {
log::info!(
"drmtap_open_render({}) failed; no usable DRM render node",
node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}"))
);
return None;
}
match node_cstr {
Some(c) => log::info!(
"drm: opened unprivileged convert context on the exporting GPU ({c:?})"
),
None => log::info!(
"drm: opened unprivileged render-node convert context (auto-selected)"
),
}
Some(RenderConverter { lib, ctx })
}
/// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`.
pub fn convert(
&mut self,
desc: &mut drmtap_dmabuf_desc,
received_fd: RawFd,
) -> io::Result<(&[u8], u32, u32, Pixfmt)> {
{
let (w, h) = (desc.width, desc.height);
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"),
));
}
// Reject, do not clamp, and write the normalized count back so the C reads the count bounded here.
let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes };
if planes > 4 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"drm: refusing a dma-buf descriptor with num_planes {} (1..=4)",
desc.num_planes
),
));
}
desc.num_planes = planes;
let planes = planes as usize;
for p in 0..planes {
let extent = (desc.pitches[p] as usize)
.checked_mul(h as usize)
.and_then(|rows| rows.checked_add(desc.offsets[p] as usize));
match extent {
Some(end) if end <= MAX_FRAME_BYTES => {}
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})",
desc.offsets[p], desc.pitches[p]
),
));
}
}
}
}
let convert_dmabuf = self.lib.convert_dmabuf;
// LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`.
desc.dma_buf_fd = received_fd;
// SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed
// before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h).
unsafe {
let mut frame: drmtap_frame_info = std::mem::zeroed();
let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame);
if ret < 0 {
let errno = -ret;
if errno == hbb_common::libc::EAGAIN
|| errno == hbb_common::libc::EBUSY
|| errno == hbb_common::libc::EINTR
{
return Err(io::ErrorKind::WouldBlock.into());
}
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_convert_dmabuf failed: errno {errno}"),
));
}
if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 {
return Err(io::Error::new(
io::ErrorKind::Other,
"drmtap_convert_dmabuf produced an empty frame",
));
}
let w = frame.width;
let h = frame.height;
let stride = frame.stride as usize;
// A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory.
if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}",
frame.format
),
));
}
let len = match stride.checked_mul(h as usize) {
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz,
other => {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"),
));
}
};
let pixfmt = match frame.format {
DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA,
DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA,
// Unset by an older convert -> libdrmtap's normalized BGRA.
0 => Pixfmt::BGRA,
other => {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"),
));
}
};
let data = std::slice::from_raw_parts(frame.data as *const u8, len);
Ok((data, w, h, pixfmt))
}
}
}
impl Drop for RenderConverter {
fn drop(&mut self) {
if !self.ctx.is_null() {
// SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps
// this drop on the thread that created and used it (thread-local EGL + cached imports).
unsafe { (self.lib.close)(self.ctx) };
self.ctx = std::ptr::null_mut();
}
}
}