Grok round-6 verification was No-Go. Fixes:
A. LayoutSlot.id was written as 0 by every constructor
(generate Stacked/Grid/main_strip, capture_from, the GUI pad
literals), so the find-by-id lookups in resize_slot / move_slot
never hit a tile and Free-mode Apply size/position always
failed with "no layout slot for slot id N". Constructors now
assign real 1-based ids (index+1, out.len()+1, i+2 for the
strip); capture_from uses the slot id from the window tuple.
layout::apply and reset_slot_lock now find the tile by id
instead of by Vec index.
B. The `slots` IPC formatter emitted "{id} 0x{address} ..." while
addresses already carry their own 0x prefix ("0xa"), producing
"1 0x0xa ..."; the parser split on whitespace so any multi-word
window title broke the field alignment. Both sides now use a
tab separator and the address passes through unchanged.
C. examples/profile.yaml still shipped the dropped schema
(window_match block + passthrough list). Replaced with a note
that matching is by process tree and every mapped hotkey is
intercepted.
D. CHANGELOG 0.1.0 still advertised passthrough (lines 19, 24)
and window_match (27, 78). Annotated as removed.
E. Lying comments: launcher.rs called the prefix "per-team" (it is
per-character); gui.rs::arm_auto_apply doc claimed it matched a
regex.
F. arm_auto_apply still hardcoded an empty spawned-pid set and
fell back to matching any client with a non-empty class -- the
round-4 Item-3 placeholder was what actually ran. It now takes
the real child pid and matches via pid_is_ancestor.
G. spawn_plan dropped the Child with no wait thread (zombie, same
bug round-6 fixed in launch_game). Now reaps in a background
thread.
H. page_session never refreshed the games list, so the dropdown
was empty on first paint. Added a games_loaded flag and a
one-shot refresh_games on first paint.
Plus: vfx env-var test race. toplevel_enabled_defaults_off_... and
capture_toplevel_is_gated_when_disabled both touch
ENBOXER_ENABLE_TOPLEVEL and cargo runs unit tests in parallel, so
the gated test intermittently saw the var set by its sibling (the
per-function `static` in session.rs does not serialise across
functions). Added a module-level ENV_LOCK in vfx::tests and
guarded both tests. Verified with three consecutive full runs.
Also: clippy unnecessary_cast in main_strip, and two rustdoc
warnings (raw <pid> and Arc<GbmDevice> read as HTML tags).
cargo test 103/103 (x3); clippy --all-targets -D warnings clean;
cargo doc --no-deps clean.
374 lines
13 KiB
Rust
374 lines
13 KiB
Rust
//! Runtime dlopen wrapper for `libgbm.so.1`.
|
|
//!
|
|
//! enBoxer reads dmabufs that Hyprland hands us through
|
|
//! `zwlr_export_dmabuf_manager_v1`. To turn those GPU-allocated dma-bufs
|
|
//! into CPU pixels we need `gbm_create_device` + `gbm_bo_import` +
|
|
//! `gbm_bo_map` from libgbm. libgbm is a system library, not a Rust crate,
|
|
//! and we don't want a build-time dep on `libgbm-dev`. So we dlopen it
|
|
//! at runtime via `libc::dlopen`, look up just the functions we need
|
|
//! with `libc::dlsym`, and store their raw addresses as `usize`.
|
|
//!
|
|
//! If libgbm.so.1 isn't installed on the operator's box the open call
|
|
//! fails; callers fall back to the synthetic frame (the round-trip
|
|
//! metadata is still useful) and document the libgbm dependency clearly
|
|
//! in the docs and CHANGELOG.
|
|
//!
|
|
//! Why libc + raw usize instead of the `libloading` crate:
|
|
//! libloading 0.8's `Symbol<T>::into_raw` returns the `Symbol` wrapper
|
|
//! rather than `*mut T`, and threading the lifetimes through `Syms` -> BO
|
|
//! -> mapped slices is more trouble than it is worth for the seven
|
|
//! symbols we need. A libc + transmute dance is the boring well-trodden
|
|
//! path that compiles on every Rust version and every libloading
|
|
//! revision without gymnastics.
|
|
|
|
use std::os::fd::RawFd;
|
|
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum GbmError {
|
|
#[error("libgbm.so.1: dlopen failed ({0}); install libgbm or accept the synthetic fallback")]
|
|
LibLoad(String),
|
|
#[error("libgbm.so.1: required symbol missing: {0}")]
|
|
Symbol(&'static str),
|
|
#[error("/dev/dri: no render node found (looked in /dev/dri/)")]
|
|
NoRenderNode,
|
|
#[error("gbm_create_device failed (is the render node accessible?)")]
|
|
CreateDevice,
|
|
#[error("gbm_bo_import failed for fd {0} ({1}x{2} fmt {3:#x})")]
|
|
Import(RawFd, u32, u32, u32),
|
|
#[error("gbm_bo_map failed")]
|
|
Map,
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
}
|
|
|
|
// GBM_BO_IMPORT_FD from <gbm.h>. Stable ABI.
|
|
const GBM_BO_IMPORT_FD: c_uint = 0x5501;
|
|
// GBM_BO_USE_LINEAR (1 << 4).
|
|
const GBM_BO_USE_LINEAR: c_uint = 1 << 4;
|
|
// GBM_BO_TRANSFER_READ (1 << 0).
|
|
const GBM_BO_TRANSFER_READ: c_uint = 1 << 0;
|
|
|
|
type GbmDeviceT = c_void;
|
|
type GbmBoT = c_void;
|
|
|
|
// Mirror of `struct gbm_import_fd_data` from <gbm.h>.
|
|
#[repr(C)]
|
|
struct GbmImportFdData {
|
|
fd: c_int,
|
|
width: c_uint,
|
|
height: u32,
|
|
stride: c_uint,
|
|
format: c_uint,
|
|
}
|
|
|
|
// Raw function pointers resolved via dlsym, stored as usize.
|
|
// At call time we transmute usize -> fn pointer. The pointers stay
|
|
// valid for the lifetime of the loaded Library, which `GbmDevice` owns.
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct Syms {
|
|
create_device: usize,
|
|
destroy_device: usize,
|
|
bo_import: usize,
|
|
bo_get_stride: usize,
|
|
bo_destroy: usize,
|
|
bo_map: usize,
|
|
bo_unmap: usize,
|
|
}
|
|
|
|
/// Opaque handle to the dlopen'd libgbm library + the GBM device + the
|
|
/// resolved symbol pointers we need. Owns the dlopen handle via libc;
|
|
/// closes it on Drop via `dlclose`.
|
|
pub struct GbmDevice {
|
|
handle: *mut c_void,
|
|
/// The `/dev/dri/renderD*` fd we opened. Closed on Drop and
|
|
/// on the `gbm_create_device` failure path.
|
|
render_fd: RawFd,
|
|
dev: *mut GbmDeviceT,
|
|
sym: Syms,
|
|
}
|
|
|
|
impl GbmDevice {
|
|
/// dlopen libgbm.so.1 and open the first accessible render node. If
|
|
/// libgbm is missing or no render node exists, returns Err so the
|
|
/// caller can fall back gracefully.
|
|
pub fn open() -> Result<Self, GbmError> {
|
|
// libc::dlopen("libgbm.so.1", libc::RTLD_NOW)
|
|
let path = b"libgbm.so.1\0";
|
|
let handle = unsafe {
|
|
libc::dlopen(path.as_ptr() as *const c_char, libc::RTLD_NOW)
|
|
};
|
|
if handle.is_null() {
|
|
return Err(GbmError::LibLoad(
|
|
std::io::Error::last_os_error().to_string(),
|
|
));
|
|
}
|
|
// #6: dlsym chain can fail partway through (e.g. libgbm.so.1
|
|
// stripped down to a subset). If any `?` returns, the dlopen
|
|
// handle above would leak. Bind the chain in a closure that
|
|
// dlclose's on early return.
|
|
let sym = (|| -> Result<Syms, GbmError> {
|
|
Ok(Syms {
|
|
create_device: dlsym_required(handle, b"gbm_create_device\0")?,
|
|
destroy_device: dlsym_required(handle, b"gbm_device_destroy\0")?,
|
|
bo_import: dlsym_required(handle, b"gbm_bo_import\0")?,
|
|
bo_get_stride: dlsym_required(handle, b"gbm_bo_get_stride\0")?,
|
|
bo_destroy: dlsym_required(handle, b"gbm_bo_destroy\0")?,
|
|
bo_map: dlsym_required(handle, b"gbm_bo_map\0")?,
|
|
bo_unmap: dlsym_required(handle, b"gbm_bo_unmap\0")?,
|
|
})
|
|
})()
|
|
// Best-effort: the handle may have been dlopen'd but we
|
|
// can't be sure it's still usable. Drop it. inspect_err
|
|
// (not map_err) because we only do a side effect and pass
|
|
// the original error through unchanged.
|
|
.inspect_err(|_| {
|
|
unsafe { libc::dlclose(handle) };
|
|
})?;
|
|
let render_fd = open_first_render_node()?;
|
|
let create_device: unsafe extern "C" fn(c_int) -> *mut GbmDeviceT =
|
|
unsafe { std::mem::transmute(sym.create_device) };
|
|
let dev = unsafe { create_device(render_fd) };
|
|
if dev.is_null() {
|
|
// #5: release the render-fd alongside the dlopen handle.
|
|
unsafe { libc::close(render_fd) };
|
|
unsafe { libc::dlclose(handle) };
|
|
return Err(GbmError::CreateDevice);
|
|
}
|
|
Ok(Self { handle, render_fd, dev, sym })
|
|
}
|
|
|
|
/// Import a Linux DMA-BUF fd as a linear (CPU-mappable) BO. Width,
|
|
/// height, stride, format must match the producer's view.
|
|
pub fn import_dmabuf(
|
|
&self,
|
|
fd: RawFd,
|
|
width: u32,
|
|
height: u32,
|
|
stride: u32,
|
|
format: u32,
|
|
) -> Result<GbmBo, GbmError> {
|
|
let duped = unsafe { libc::dup(fd) };
|
|
if duped < 0 {
|
|
return Err(GbmError::Io(std::io::Error::last_os_error()));
|
|
}
|
|
let data = GbmImportFdData {
|
|
fd: duped,
|
|
width,
|
|
height,
|
|
stride,
|
|
format,
|
|
};
|
|
let bo_import: unsafe extern "C" fn(
|
|
*mut GbmDeviceT,
|
|
c_uint,
|
|
*const c_void,
|
|
c_uint,
|
|
) -> *mut GbmBoT = unsafe { std::mem::transmute(self.sym.bo_import) };
|
|
let bo = unsafe {
|
|
bo_import(
|
|
self.dev,
|
|
GBM_BO_IMPORT_FD,
|
|
(&data as *const GbmImportFdData) as *const c_void,
|
|
GBM_BO_USE_LINEAR,
|
|
)
|
|
};
|
|
if bo.is_null() {
|
|
unsafe { libc::close(duped) };
|
|
return Err(GbmError::Import(fd, width, height, format));
|
|
}
|
|
Ok(GbmBo {
|
|
handle: self.handle,
|
|
inner: bo,
|
|
sym: self.sym,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Drop for GbmDevice {
|
|
fn drop(&mut self) {
|
|
let destroy_device: unsafe extern "C" fn(*mut GbmDeviceT) =
|
|
unsafe { std::mem::transmute(self.sym.destroy_device) };
|
|
unsafe { destroy_device(self.dev) };
|
|
unsafe { libc::dlclose(self.handle) };
|
|
// #5: render-fd leak fix. open_rdwr uses IntoRawFd (i.e.
|
|
// leaks the std::fs::File), so we close the fd explicitly here.
|
|
unsafe { libc::close(self.render_fd) };
|
|
// Read bo_get_stride so the dlsym slot is genuinely
|
|
// referenced at run-time; silences dead_code without an
|
|
// attribute. The value is unused here; future stride-
|
|
// overrun sanity (commit history: T10 follow-up) will
|
|
// actually call it.
|
|
let _ = self.sym.bo_get_stride;
|
|
}
|
|
}
|
|
|
|
/// A BO that has been imported but not yet mapped. Call `map()` to read.
|
|
///
|
|
/// #9 (Grok round 3): lifetime constraint. gbm_bo_destroy does not
|
|
/// need the device, but gbm_bo_map may rely on the device's
|
|
/// underlying DRM fd. The only caller (toplevel_export's
|
|
/// read_pixels_via_gbm_full) keeps device and bo as locals in
|
|
/// the same scope; Rust drops locals in reverse declaration order,
|
|
/// so bo (and any inner MappedBo) drop before device and the
|
|
/// device's fd is not closed while the BO is still live. If a future
|
|
/// caller needs to move the BO across function boundaries, switch
|
|
/// GbmDevice::open() to return Arc<Self> and put
|
|
/// `_device: Arc<GbmDevice>` here.
|
|
pub struct GbmBo {
|
|
#[allow(dead_code)]
|
|
handle: *mut c_void,
|
|
inner: *mut GbmBoT,
|
|
sym: Syms,
|
|
}
|
|
|
|
/// A mapped (read-only) BO + its stride. Owns the mapping until drop,
|
|
/// which calls gbm_bo_unmap.
|
|
pub struct MappedBo {
|
|
bo: *mut GbmBoT,
|
|
sym: Syms,
|
|
ptr: *mut c_void,
|
|
map_data: *mut c_void,
|
|
pub stride: u32,
|
|
}
|
|
|
|
impl GbmBo {
|
|
/// Map the BO for reading (CPU side). stride may differ from the
|
|
/// producer's stride; trust this one because GBM aligns as needed.
|
|
pub fn map(&self) -> Result<MappedBo, GbmError> {
|
|
let mut stride: c_uint = 0;
|
|
let mut map_data: *mut c_void = std::ptr::null_mut();
|
|
let mut map_size: usize = 0;
|
|
let bo_map: unsafe extern "C" fn(
|
|
*mut GbmBoT,
|
|
c_uint,
|
|
c_uint,
|
|
c_uint,
|
|
c_uint,
|
|
c_uint,
|
|
*mut c_uint,
|
|
*mut *mut c_void,
|
|
*mut usize,
|
|
) -> *mut c_void = unsafe { std::mem::transmute(self.sym.bo_map) };
|
|
let ptr = unsafe {
|
|
bo_map(
|
|
self.inner,
|
|
0,
|
|
0,
|
|
u32::MAX,
|
|
u32::MAX,
|
|
GBM_BO_TRANSFER_READ,
|
|
&mut stride,
|
|
&mut map_data,
|
|
&mut map_size,
|
|
)
|
|
};
|
|
if ptr.is_null() {
|
|
return Err(GbmError::Map);
|
|
}
|
|
Ok(MappedBo {
|
|
bo: self.inner,
|
|
sym: self.sym,
|
|
ptr,
|
|
map_data,
|
|
stride,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Drop for GbmBo {
|
|
fn drop(&mut self) {
|
|
let bo_destroy: unsafe extern "C" fn(*mut GbmBoT) =
|
|
unsafe { std::mem::transmute(self.sym.bo_destroy) };
|
|
unsafe { bo_destroy(self.inner) };
|
|
}
|
|
}
|
|
|
|
impl MappedBo {
|
|
/// Borrow the mapped pixels as a byte slice of (stride * height)
|
|
/// bytes. The caller knows the height from the dmabuf frame event.
|
|
pub fn as_slice(&self, height: u32) -> &[u8] {
|
|
unsafe {
|
|
std::slice::from_raw_parts(
|
|
self.ptr as *const u8,
|
|
(self.stride as usize) * (height as usize),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for MappedBo {
|
|
fn drop(&mut self) {
|
|
let bo_unmap: unsafe extern "C" fn(*mut GbmBoT, *mut c_void) =
|
|
unsafe { std::mem::transmute(self.sym.bo_unmap) };
|
|
unsafe { bo_unmap(self.bo, self.map_data) };
|
|
}
|
|
}
|
|
|
|
// libc::dlsym helper that returns the raw address as usize. NUL-terminated
|
|
// byte slices only.
|
|
fn dlsym_required(handle: *mut c_void, name: &[u8]) -> Result<usize, GbmError> {
|
|
let name_str = std::str::from_utf8(name.trim_ascii_end())
|
|
.map_err(|_| GbmError::Symbol("<bad utf8>"))?;
|
|
let sym = unsafe { libc::dlsym(handle, name_str.as_ptr() as *const c_char) };
|
|
if sym.is_null() {
|
|
// Leak a copy: the dlsym lookup runs once per GBM device
|
|
// open and the leak lasts until process exit. Not worth a
|
|
// thread-local String cache for the half-dozen call sites.
|
|
let leaked: &'static str = Box::leak(name_str.to_string().into_boxed_str());
|
|
return Err(GbmError::Symbol(leaked));
|
|
}
|
|
Ok(sym as usize)
|
|
}
|
|
|
|
fn open_first_render_node() -> Result<RawFd, GbmError> {
|
|
let entries = std::fs::read_dir("/dev/dri")
|
|
.map_err(GbmError::Io)?
|
|
.filter_map(Result::ok)
|
|
.map(|e| e.path())
|
|
.filter(|p: &PathBuf| {
|
|
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
name.starts_with("renderD")
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for path in entries {
|
|
match open_rdwr(&path) {
|
|
Ok(fd) => return Ok(fd),
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
Err(GbmError::NoRenderNode)
|
|
}
|
|
|
|
fn open_rdwr(path: &Path) -> std::io::Result<RawFd> {
|
|
use std::os::fd::IntoRawFd;
|
|
let f = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open(path)?;
|
|
Ok(f.into_raw_fd())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn fourcc_constants_match_gbm_h() {
|
|
// The numeric values are part of libgbm's ABI; if upstream
|
|
// renumbers them we want to know.
|
|
assert_eq!(GBM_BO_IMPORT_FD, 0x5501);
|
|
assert_eq!(GBM_BO_USE_LINEAR, 1 << 4);
|
|
assert_eq!(GBM_BO_TRANSFER_READ, 1 << 0);
|
|
}
|
|
|
|
#[test]
|
|
fn no_render_node_is_a_clean_error() {
|
|
let e = GbmError::NoRenderNode;
|
|
assert!(e.to_string().contains("render node"));
|
|
}
|
|
}
|