From e9f1e1b2f8bccf0ad3a0fa1b29603c910fc86b13 Mon Sep 17 00:00:00 2001 From: shubhook Date: Tue, 13 Oct 2026 19:02:36 +0000 Subject: [PATCH 1/5] perf(recording): capture X11 frames via MIT-SHM with GetImage fallback --- crates/recording/Cargo.toml | 2 +- .../examples/x11-capture-benchmark.rs | 187 ++++++++ .../src/sources/screen_capture/linux.rs | 213 +++++++-- .../src/sources/screen_capture/linux_shm.rs | 307 +++++++++++++ .../src/sources/screen_capture/mod.rs | 2 + crates/recording/tests/x11_shm_capture.rs | 405 ++++++++++++++++++ 6 files changed, 1068 insertions(+), 48 deletions(-) create mode 100644 crates/recording/examples/x11-capture-benchmark.rs create mode 100644 crates/recording/src/sources/screen_capture/linux_shm.rs create mode 100644 crates/recording/tests/x11_shm_capture.rs diff --git a/crates/recording/Cargo.toml b/crates/recording/Cargo.toml index 20f858ac081..5b8d16fb00f 100644 --- a/crates/recording/Cargo.toml +++ b/crates/recording/Cargo.toml @@ -103,7 +103,7 @@ scap-cpal = { path = "../scap-cpal" } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.11.0", default-features = false, features = ["tokio"] } pipewire = "0.10.0" -x11rb = { version = "0.13.2", features = ["composite", "xfixes"] } +x11rb = { version = "0.13.2", features = ["composite", "shm", "xfixes"] } [dev-dependencies] tempfile = "3.20.0" diff --git a/crates/recording/examples/x11-capture-benchmark.rs b/crates/recording/examples/x11-capture-benchmark.rs new file mode 100644 index 00000000000..89dddb02075 --- /dev/null +++ b/crates/recording/examples/x11-capture-benchmark.rs @@ -0,0 +1,187 @@ +//! Compares X11 frame grabs via MIT-SHM (`ShmGetImage`) and plain `GetImage`, +//! including the copy into the FFmpeg frame. +//! +//! xvfb-run -s "-screen 0 3840x2160x24" \ +//! cargo run --release -p cap-recording --example x11-capture-benchmark [-- --json] + +#[cfg(not(target_os = "linux"))] +fn main() { + eprintln!("x11-capture-benchmark: skipped (not Linux)"); +} + +#[cfg(target_os = "linux")] +fn main() -> anyhow::Result<()> { + linux::run() +} + +#[cfg(target_os = "linux")] +mod linux { + use anyhow::{Context as _, bail}; + use cap_recording::screen_capture::{X11Grabber, X11InputConfig}; + use clap::Parser; + use serde::Serialize; + use std::time::Instant; + use x11rb::connection::Connection as _; + + const DISABLE_SHM_ENV: &str = "CAP_X11_DISABLE_SHM"; + + #[derive(Parser)] + struct Args { + #[arg(long, default_value_t = 30)] + warmup: usize, + #[arg(long, default_value_t = 300)] + iterations: usize, + #[arg(long)] + json: bool, + } + + #[derive(Serialize)] + struct Stats { + backend: &'static str, + mean_ms: f64, + p50_ms: f64, + p95_ms: f64, + p99_ms: f64, + mpix_per_s: f64, + } + + #[derive(Serialize)] + struct SizeResult { + label: &'static str, + width: u32, + height: u32, + get_image: Stats, + shm: Stats, + speedup: f64, + } + + const SIZES: [(&str, u32, u32); 4] = [ + ("720p", 1280, 720), + ("1080p", 1920, 1080), + ("1440p", 2560, 1440), + ("4K", 3840, 2160), + ]; + + pub fn run() -> anyhow::Result<()> { + let args = Args::parse(); + let display = std::env::var("DISPLAY").context("DISPLAY is not set")?; + let (conn, screen) = x11rb::connect(Some(&display))?; + let root = &conn.setup().roots[screen]; + let (screen_width, screen_height) = ( + u32::from(root.width_in_pixels), + u32::from(root.height_in_pixels), + ); + drop(conn); + + let mut results = Vec::new(); + for (label, width, height) in SIZES { + if width > screen_width || height > screen_height { + eprintln!("skipping {label}: screen is {screen_width}x{screen_height}"); + continue; + } + let config = X11InputConfig { + display_name: display.clone(), + window_id: None, + x: 0, + y: 0, + width, + height, + fps: 60, + show_cursor: false, + }; + let get_image = measure(&config, false, &args)?; + let shm = measure(&config, true, &args)?; + results.push(SizeResult { + label, + width, + height, + speedup: get_image.mean_ms / shm.mean_ms, + get_image, + shm, + }); + } + + if args.json { + println!("{}", serde_json::to_string_pretty(&results)?); + return Ok(()); + } + + println!( + "X11 capture benchmark on {display} ({screen_width}x{screen_height}), {} warmup + {} iterations\n", + args.warmup, args.iterations + ); + println!("| size | backend | mean ms | p50 ms | p95 ms | p99 ms | Mpix/s | speedup |"); + println!("|---|---|---|---|---|---|---|---|"); + for result in &results { + for (stats, speedup) in [ + (&result.get_image, String::new()), + (&result.shm, format!("{:.2}x", result.speedup)), + ] { + println!( + "| {} ({}x{}) | {} | {:.3} | {:.3} | {:.3} | {:.3} | {:.0} | {} |", + result.label, + result.width, + result.height, + stats.backend, + stats.mean_ms, + stats.p50_ms, + stats.p95_ms, + stats.p99_ms, + stats.mpix_per_s, + speedup + ); + } + } + Ok(()) + } + + fn measure(config: &X11InputConfig, shm: bool, args: &Args) -> anyhow::Result { + // SAFETY: the benchmark is single-threaded and the grabber only reads + // the variable during construction. + unsafe { + if shm { + std::env::remove_var(DISABLE_SHM_ENV); + } else { + std::env::set_var(DISABLE_SHM_ENV, "1"); + } + } + let mut grabber = X11Grabber::new(config)?; + unsafe { std::env::remove_var(DISABLE_SHM_ENV) }; + if grabber.uses_shm() != shm { + bail!("requested shm={shm} but the grabber chose otherwise"); + } + + for _ in 0..args.warmup { + grabber.grab()?; + } + let mut samples = Vec::with_capacity(args.iterations); + for _ in 0..args.iterations { + let start = Instant::now(); + let frame = grabber.grab()?; + samples.push(start.elapsed().as_secs_f64() * 1000.0); + drop(frame); + } + if grabber.uses_shm() != shm { + bail!("MIT-SHM fell back to GetImage during the benchmark"); + } + if samples.is_empty() { + bail!("--iterations must be at least 1"); + } + + samples.sort_by(f64::total_cmp); + let percentile = |p: f64| { + let index = ((samples.len() - 1) as f64 * p).round() as usize; + samples[index] + }; + let mean_ms = samples.iter().sum::() / samples.len() as f64; + let pixels = f64::from(config.width) * f64::from(config.height); + Ok(Stats { + backend: if shm { "MIT-SHM" } else { "GetImage" }, + mean_ms, + p50_ms: percentile(0.50), + p95_ms: percentile(0.95), + p99_ms: percentile(0.99), + mpix_per_s: pixels / (mean_ms / 1000.0) / 1_000_000.0, + }) + } +} diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index e1e792a5f9e..aef9de260a8 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -1,4 +1,5 @@ use super::cadence::FrameCadenceGate; +use super::linux_shm::{self, ShmSegment}; use super::*; use crate::feeds::microphone::{self, MicrophoneFeed, MicrophoneFeedLock}; use crate::ffmpeg::FFmpegVideoFrame; @@ -70,7 +71,7 @@ enum LinuxInputConfig { Wayland(WaylandInputConfig), } -pub(crate) struct X11InputConfig { +pub struct X11InputConfig { pub display_name: String, pub window_id: Option, pub x: i32, @@ -2032,8 +2033,8 @@ fn capture_x11( /// FFmpeg (spacedrive native-deps) is built without. Capturing with `x11rb` /// keeps us off any system FFmpeg/libavdevice and adds no new runtime /// shared-library dependency (`x11rb` speaks the X11 protocol over a socket). -pub(crate) struct X11Grabber { - conn: RustConnection, +pub struct X11Grabber { + conn: Arc, root: x11rb::protocol::xproto::Window, window: Option, x: i16, @@ -2041,11 +2042,19 @@ pub(crate) struct X11Grabber { width: u16, height: u16, source_pixel: ffmpeg::format::Pixel, + bits_per_pixel: u8, + scanline_pad: u8, + backend: GrabBackend, output: VideoInfo, scaler: Option, show_cursor: bool, } +enum GrabBackend { + Shm(ShmSegment), + GetImage, +} + struct X11WindowCapture { id: u32, pixmap: u32, @@ -2053,7 +2062,7 @@ struct X11WindowCapture { } impl X11Grabber { - pub(crate) fn new(config: &X11InputConfig) -> anyhow::Result { + pub fn new(config: &X11InputConfig) -> anyhow::Result { ffmpeg::init().context("initialize FFmpeg")?; let (conn, screen_num) = x11rb::connect(Some(config.display_name.as_str())) @@ -2091,11 +2100,11 @@ impl X11Grabber { }) .ok_or_else(|| anyhow!("X11 visual {visual_id} not found"))?; - let bits_per_pixel = setup + let (bits_per_pixel, scanline_pad) = setup .pixmap_formats .iter() .find(|format| format.depth == depth) - .map(|format| format.bits_per_pixel) + .map(|format| (format.bits_per_pixel, format.scanline_pad)) .ok_or_else(|| anyhow!("X11 pixmap format for depth {depth} not found"))?; let source_pixel = x11_source_pixel( @@ -2163,7 +2172,7 @@ impl X11Grabber { }; let mut grabber = Self { - conn, + conn: Arc::new(conn), root, window, x, @@ -2171,14 +2180,71 @@ impl X11Grabber { width, height, source_pixel, + bits_per_pixel, + scanline_pad, + backend: GrabBackend::GetImage, output, scaler: None, show_cursor, }; grabber.refresh_window_pixmap()?; + grabber.backend = grabber.initial_backend(); Ok(grabber) } + fn initial_backend(&self) -> GrabBackend { + if linux_shm::shm_disabled_by_env() { + tracing::info!( + "X11 MIT-SHM capture disabled by {}", + linux_shm::DISABLE_SHM_ENV + ); + return GrabBackend::GetImage; + } + match self.create_shm_segment() { + Ok(segment) => { + tracing::info!(bytes = segment.len(), "X11 capture using MIT-SHM"); + GrabBackend::Shm(segment) + } + Err(error) => { + tracing::info!(error = %format!("{error:#}"), "X11 MIT-SHM unavailable, using GetImage"); + GrabBackend::GetImage + } + } + } + + fn create_shm_segment(&self) -> anyhow::Result { + if self.bits_per_pixel != 32 { + bail!( + "MIT-SHM capture requires 32bpp, server uses {}", + self.bits_per_pixel + ); + } + linux_shm::query_shm_support(&self.conn)?; + ShmSegment::new(&self.conn, self.shm_image_size()?) + } + + fn shm_image_size(&self) -> anyhow::Result { + linux_shm::image_size( + self.width, + self.height, + self.bits_per_pixel, + self.scanline_pad, + ) + .ok_or_else(|| { + anyhow!( + "invalid X11 image layout: {}x{} at {}bpp, pad {}", + self.width, + self.height, + self.bits_per_pixel, + self.scanline_pad + ) + }) + } + + pub fn uses_shm(&self) -> bool { + matches!(self.backend, GrabBackend::Shm(_)) + } + fn refresh_window_pixmap(&mut self) -> anyhow::Result<()> { let Some(window) = self.window.as_mut() else { return Ok(()); @@ -2250,7 +2316,7 @@ impl X11Grabber { } /// Capture one frame of the configured region as a BGRZ video frame. - pub(crate) fn grab(&mut self) -> anyhow::Result { + pub fn grab(&mut self) -> anyhow::Result { self.refresh_window_pixmap()?; let (drawable, x, y) = match &self.window { Some(window) => { @@ -2260,50 +2326,24 @@ impl X11Grabber { } None => (self.root, self.x, self.y), }; - let reply = self - .conn - .get_image( - ImageFormat::Z_PIXMAP, - drawable, - x, - y, - self.width, - self.height, - u32::MAX, - ) - .context("request X11 image")? - .reply() - .context("read X11 image")?; - - let width = usize::from(self.width); - let height = usize::from(self.height); - let row_bytes = width * 4; - let source_stride = reply - .data - .len() - .checked_div(height) - .filter(|stride| *stride >= row_bytes) - .ok_or_else(|| { - anyhow!( - "X11 image too small: {} bytes for {}x{}", - reply.data.len(), - width, - height - ) - })?; - let mut source = ffmpeg::frame::Video::new( self.source_pixel, u32::from(self.width), u32::from(self.height), ); - let dst_stride = source.stride(0); - let copy = row_bytes.min(dst_stride); - for row in 0..height { - let src_start = row * source_stride; - let dst_start = row * dst_stride; - source.data_mut(0)[dst_start..dst_start + copy] - .copy_from_slice(&reply.data[src_start..src_start + copy]); + if self.uses_shm() { + if let Err(shm_error) = self.read_shm_image(drawable, x, y, &mut source) { + // Only abandon MIT-SHM once GetImage succeeds on the same frame; + // if both fail the cause is the drawable/region, not SHM. + self.read_get_image(drawable, x, y, &mut source)?; + tracing::warn!( + error = %format!("{shm_error:#}"), + "X11 MIT-SHM capture failed, switching to GetImage" + ); + self.backend = GrabBackend::GetImage; + } + } else { + self.read_get_image(drawable, x, y, &mut source)?; } let mut frame = prepare_pipewire_frame(source, &mut self.scaler, self.output)?; @@ -2317,6 +2357,54 @@ impl X11Grabber { Ok(frame) } + fn read_shm_image( + &mut self, + drawable: u32, + x: i16, + y: i16, + dst: &mut ffmpeg::frame::Video, + ) -> anyhow::Result<()> { + let required = self.shm_image_size()?; + let GrabBackend::Shm(segment) = &mut self.backend else { + bail!("X11 MIT-SHM backend is not active"); + }; + if segment.len() != required { + *segment = ShmSegment::new(&self.conn, required) + .context("resize X11 shared-memory segment")?; + } + let data = segment.get_image(drawable, x, y, self.width, self.height)?; + copy_image_rows(data, usize::from(self.width), usize::from(self.height), dst) + } + + fn read_get_image( + &self, + drawable: u32, + x: i16, + y: i16, + dst: &mut ffmpeg::frame::Video, + ) -> anyhow::Result<()> { + let reply = self + .conn + .get_image( + ImageFormat::Z_PIXMAP, + drawable, + x, + y, + self.width, + self.height, + u32::MAX, + ) + .context("request X11 image")? + .reply() + .context("read X11 image")?; + copy_image_rows( + &reply.data, + usize::from(self.width), + usize::from(self.height), + dst, + ) + } + /// Alpha-blend the X11 cursor onto a BGRZ frame (mirrors x11grab's /// `draw_mouse`). xfixes returns premultiplied ARGB, so we composite with /// straight `src + dst * (1 - a)`. @@ -2387,6 +2475,37 @@ impl X11Grabber { } } +fn copy_image_rows( + data: &[u8], + width: usize, + height: usize, + dst: &mut ffmpeg::frame::Video, +) -> anyhow::Result<()> { + let row_bytes = width * 4; + let source_stride = data + .len() + .checked_div(height) + .filter(|stride| *stride >= row_bytes) + .ok_or_else(|| { + anyhow!( + "X11 image too small: {} bytes for {}x{}", + data.len(), + width, + height + ) + })?; + + let dst_stride = dst.stride(0); + let copy = row_bytes.min(dst_stride); + let dst_data = dst.data_mut(0); + for row in 0..height { + let src_start = row * source_stride; + let dst_start = row * dst_stride; + dst_data[dst_start..dst_start + copy].copy_from_slice(&data[src_start..src_start + copy]); + } + Ok(()) +} + /// Map an X11 32-bit TrueColor visual (byte order + RGB masks) to the matching /// packed FFmpeg pixel format. The overwhelmingly common desktop case /// (depth 24/32, little-endian, BGRX) resolves to BGRZ. diff --git a/crates/recording/src/sources/screen_capture/linux_shm.rs b/crates/recording/src/sources/screen_capture/linux_shm.rs new file mode 100644 index 00000000000..8553e82e9d9 --- /dev/null +++ b/crates/recording/src/sources/screen_capture/linux_shm.rs @@ -0,0 +1,307 @@ +//! MIT-SHM image transfer for X11 capture. All `unsafe` for the SysV segment +//! lifecycle lives here behind a safe API. + +use anyhow::{Context as _, anyhow, bail}; +use std::{ptr::NonNull, sync::Arc}; +use x11rb::connection::{Connection as _, RequestConnection as _}; +use x11rb::protocol::shm::{self, ConnectionExt as _}; +use x11rb::protocol::xproto::{Drawable, ImageFormat}; +use x11rb::rust_connection::RustConnection; +use x11rb::wrapper::ConnectionExt as _; + +pub(crate) const DISABLE_SHM_ENV: &str = "CAP_X11_DISABLE_SHM"; + +pub(crate) fn shm_disabled_by_env() -> bool { + std::env::var(DISABLE_SHM_ENV).is_ok_and(|value| is_truthy(&value)) +} + +fn is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +pub(crate) fn image_stride(width: u16, bits_per_pixel: u8, scanline_pad: u8) -> Option { + if bits_per_pixel == 0 || scanline_pad == 0 || scanline_pad % 8 != 0 { + return None; + } + let row_bits = usize::from(width).checked_mul(usize::from(bits_per_pixel))?; + let pad_bytes = usize::from(scanline_pad / 8); + row_bits.div_ceil(8).checked_next_multiple_of(pad_bytes) +} + +pub(crate) fn image_size( + width: u16, + height: u16, + bits_per_pixel: u8, + scanline_pad: u8, +) -> Option { + image_stride(width, bits_per_pixel, scanline_pad)?.checked_mul(usize::from(height)) +} + +pub(crate) fn query_shm_support(conn: &RustConnection) -> anyhow::Result<()> { + if conn + .extension_information(shm::X11_EXTENSION_NAME) + .context("query MIT-SHM extension")? + .is_none() + { + bail!("MIT-SHM extension is not available"); + } + let version = conn + .shm_query_version() + .context("request MIT-SHM version")? + .reply() + .context("read MIT-SHM version")?; + if (version.major_version, version.minor_version) < (1, 1) { + bail!( + "MIT-SHM {}.{} is too old", + version.major_version, + version.minor_version + ); + } + Ok(()) +} + +struct SysvMapping { + shmid: i32, + addr: NonNull, + size: usize, + removed: bool, +} + +// SAFETY: the mapping is exclusively owned by this value and SysV attachments +// are process-wide, so it may be used and dropped from any thread. +unsafe impl Send for SysvMapping {} + +impl SysvMapping { + fn new(size: usize) -> anyhow::Result { + if size == 0 { + bail!("shared-memory segment size must be non-zero"); + } + // SAFETY: shmget has no memory-safety preconditions; a negative return + // is handled as an error below. + let shmid = unsafe { libc::shmget(libc::IPC_PRIVATE, size, libc::IPC_CREAT | 0o600) }; + if shmid < 0 { + return Err(std::io::Error::last_os_error()).context("shmget"); + } + // SAFETY: `shmid` is a segment we just created; a null address lets + // the kernel pick the mapping location. Failure returns `(void*)-1`. + let raw = unsafe { libc::shmat(shmid, std::ptr::null(), 0) }; + if raw as isize == -1 { + let error = std::io::Error::last_os_error(); + // SAFETY: removing the unattached segment we created; no pointer + // is involved. + unsafe { libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut()) }; + return Err(error).context("shmat"); + } + let addr = NonNull::new(raw.cast::()).ok_or_else(|| anyhow!("shmat returned null"))?; + Ok(Self { + shmid, + addr, + size, + removed: false, + }) + } + + // Destruction is deferred until every attachment (ours and the X + // server's) is gone, so the segment cannot outlive a crash of this process. + fn mark_removed(&mut self) -> anyhow::Result<()> { + if self.removed { + return Ok(()); + } + // SAFETY: `shmid` refers to the segment we own; IPC_RMID takes no buffer. + if unsafe { libc::shmctl(self.shmid, libc::IPC_RMID, std::ptr::null_mut()) } != 0 { + return Err(std::io::Error::last_os_error()).context("shmctl(IPC_RMID)"); + } + self.removed = true; + Ok(()) + } + + fn bytes(&self) -> &[u8] { + // SAFETY: `addr` points to a live mapping of exactly `size` bytes that + // stays attached until `self` is dropped. The X server only writes to + // it while servicing a ShmGetImage request, and callers only read + // after that request's reply has arrived, so no write overlaps this + // borrow. + unsafe { std::slice::from_raw_parts(self.addr.as_ptr(), self.size) } + } +} + +impl Drop for SysvMapping { + fn drop(&mut self) { + if let Err(error) = self.mark_removed() { + tracing::warn!(error = %error, shmid = self.shmid, "Failed to remove X11 shared memory"); + } + // SAFETY: `addr` came from a successful shmat and is detached exactly + // once, here; no borrows of `bytes()` can outlive `self`. + if unsafe { libc::shmdt(self.addr.as_ptr().cast()) } != 0 { + tracing::warn!( + error = %std::io::Error::last_os_error(), + shmid = self.shmid, + "Failed to detach X11 shared memory" + ); + } + } +} + +pub(crate) struct ShmSegment { + conn: Arc, + seg: shm::Seg, + mapping: SysvMapping, +} + +impl ShmSegment { + pub(crate) fn new(conn: &Arc, size: usize) -> anyhow::Result { + let mapping = SysvMapping::new(size)?; + let shmid = + u32::try_from(mapping.shmid).map_err(|_| anyhow!("invalid shmid {}", mapping.shmid))?; + let seg = conn.generate_id().context("allocate MIT-SHM segment id")?; + conn.shm_attach(seg, shmid, false) + .context("request MIT-SHM attach")? + .check() + .context("X server could not attach shared memory")?; + let mut segment = Self { + conn: Arc::clone(conn), + seg, + mapping, + }; + segment.mapping.mark_removed()?; + Ok(segment) + } + + pub(crate) fn len(&self) -> usize { + self.mapping.size + } + + pub(crate) fn get_image( + &mut self, + drawable: Drawable, + x: i16, + y: i16, + width: u16, + height: u16, + ) -> anyhow::Result<&[u8]> { + let reply = self + .conn + .shm_get_image( + drawable, + x, + y, + width, + height, + u32::MAX, + ImageFormat::Z_PIXMAP.into(), + self.seg, + 0, + ) + .context("request X11 shared-memory image")? + .reply() + .context("read X11 shared-memory image")?; + let written = usize::try_from(reply.size).unwrap_or(usize::MAX); + if written > self.mapping.size { + bail!( + "X11 shared-memory image of {written} bytes overflows a {} byte segment", + self.mapping.size + ); + } + Ok(&self.mapping.bytes()[..written]) + } +} + +impl Drop for ShmSegment { + fn drop(&mut self) { + if let Ok(cookie) = self.conn.shm_detach(self.seg) { + cookie.ignore_error(); + } + // The server may still be mapping the segment while the detach request + // is in flight; a round trip guarantees it has let go before shmdt. + if let Err(error) = self.conn.sync() { + tracing::debug!(error = %error, "X11 sync after MIT-SHM detach failed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn segment_exists(shmid: i32) -> bool { + std::fs::read_to_string("/proc/sysvipc/shm") + .unwrap() + .lines() + .skip(1) + .any(|line| line.split_whitespace().nth(1) == Some(&shmid.to_string())) + } + + #[test] + fn stride_pads_rows_to_the_scanline_unit() { + assert_eq!(image_stride(1920, 32, 32), Some(7680)); + assert_eq!(image_stride(1, 32, 32), Some(4)); + assert_eq!(image_stride(3, 24, 32), Some(12)); + assert_eq!(image_stride(5, 16, 32), Some(12)); + assert_eq!(image_stride(5, 8, 32), Some(8)); + assert_eq!(image_stride(5, 8, 8), Some(5)); + assert_eq!(image_stride(0, 32, 32), Some(0)); + } + + #[test] + fn stride_rejects_invalid_formats() { + assert_eq!(image_stride(10, 0, 32), None); + assert_eq!(image_stride(10, 32, 0), None); + assert_eq!(image_stride(10, 32, 12), None); + } + + #[test] + fn size_covers_every_row() { + assert_eq!(image_size(1920, 1080, 32, 32), Some(1920 * 1080 * 4)); + assert_eq!(image_size(3840, 2160, 32, 32), Some(3840 * 2160 * 4)); + assert_eq!(image_size(3, 7, 24, 32), Some(12 * 7)); + assert_eq!( + image_size(u16::MAX, u16::MAX, 32, 32), + Some(65535 * 65535 * 4) + ); + } + + #[test] + fn mapping_is_readable_and_freed_on_drop() { + let mapping = SysvMapping::new(4096).unwrap(); + let shmid = mapping.shmid; + assert!(segment_exists(shmid)); + assert_eq!(mapping.bytes().len(), 4096); + assert!(mapping.bytes().iter().all(|byte| *byte == 0)); + drop(mapping); + assert!(!segment_exists(shmid)); + } + + #[test] + fn removed_mapping_stays_usable_until_dropped() { + let mut mapping = SysvMapping::new(8192).unwrap(); + let shmid = mapping.shmid; + mapping.mark_removed().unwrap(); + mapping.mark_removed().unwrap(); + assert_eq!(mapping.bytes().len(), 8192); + drop(mapping); + assert!(!segment_exists(shmid)); + } + + #[test] + fn zero_sized_mapping_is_rejected() { + assert!(SysvMapping::new(0).is_err()); + } + + #[test] + fn env_opt_out_accepts_common_truthy_values() { + for (value, expected) in [ + ("1", true), + ("true", true), + (" YES ", true), + ("on", true), + ("0", false), + ("", false), + ("no", false), + ] { + assert_eq!(is_truthy(value), expected, "{value:?}"); + } + } +} diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 15dfd729814..e77a7e61156 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -23,6 +23,8 @@ pub use macos::*; #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] +mod linux_shm; +#[cfg(target_os = "linux")] pub use linux::*; pub struct StopCapturing; diff --git a/crates/recording/tests/x11_shm_capture.rs b/crates/recording/tests/x11_shm_capture.rs new file mode 100644 index 00000000000..ef83547f2e2 --- /dev/null +++ b/crates/recording/tests/x11_shm_capture.rs @@ -0,0 +1,405 @@ +#![cfg(target_os = "linux")] + +use std::{ + io::{BufRead, BufReader}, + process::{Child, Command, Stdio}, + sync::{Mutex, MutexGuard}, +}; + +use cap_recording::screen_capture::{X11Grabber, X11InputConfig}; +use x11rb::{ + connection::Connection, + protocol::xproto::{ + ConfigureWindowAux, ConnectionExt, CreateGCAux, CreateWindowAux, ImageFormat, Window, + WindowClass, + }, + rust_connection::RustConnection, + wrapper::ConnectionExt as _, +}; + +const DISABLE_SHM_ENV: &str = "CAP_X11_DISABLE_SHM"; + +static SERIAL: Mutex<()> = Mutex::new(()); + +fn serial() -> MutexGuard<'static, ()> { + SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +struct Xvfb { + child: Child, + number: u32, +} + +impl Xvfb { + fn start(width: u32, height: u32, extra_args: &[&str]) -> Option { + let (reader, writer) = std::io::pipe().ok()?; + let child = Command::new("Xvfb") + .args(["-displayfd", "1", "-screen", "0"]) + .arg(format!("{width}x{height}x24")) + .args(extra_args) + .stdin(Stdio::null()) + .stdout(writer) + .stderr(Stdio::null()) + .spawn(); + let mut child = match child { + Ok(child) => child, + Err(error) => { + eprintln!("skipping X11 SHM test: cannot start Xvfb: {error}"); + return None; + } + }; + let mut line = String::new(); + let read = BufReader::new(reader).read_line(&mut line); + let Some(number) = read.ok().and_then(|_| line.trim().parse().ok()) else { + let _ = child.kill(); + let _ = child.wait(); + eprintln!("skipping X11 SHM test: Xvfb did not report a display"); + return None; + }; + Some(Self { child, number }) + } + + fn display(&self) -> String { + format!(":{}", self.number) + } +} + +impl Drop for Xvfb { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn pattern_pixel(x: u32, y: u32) -> [u8; 3] { + [ + (x.wrapping_mul(7) ^ y) as u8, + (y.wrapping_mul(3).wrapping_add(x >> 2)) as u8, + (x.wrapping_add(y.wrapping_mul(5)) >> 1) as u8, + ] +} + +fn draw_pattern(conn: &RustConnection, drawable: u32, width: u16, height: u16) { + let gc = conn.generate_id().unwrap(); + conn.create_gc(gc, drawable, &CreateGCAux::new()).unwrap(); + let rows_per_request = 32u16; + let mut top = 0u16; + while top < height { + let rows = rows_per_request.min(height - top); + let mut data = Vec::with_capacity(usize::from(width) * usize::from(rows) * 4); + for y in top..top + rows { + for x in 0..width { + let [b, g, r] = pattern_pixel(u32::from(x), u32::from(y)); + data.extend_from_slice(&[b, g, r, 0]); + } + } + conn.put_image( + ImageFormat::Z_PIXMAP, + drawable, + gc, + width, + rows, + 0, + top as i16, + 0, + 24, + &data, + ) + .unwrap() + .check() + .unwrap(); + top += rows; + } + conn.free_gc(gc).unwrap().check().unwrap(); +} + +fn connect(display: &str) -> (RustConnection, Window) { + let (conn, screen) = x11rb::connect(Some(display)).unwrap(); + let root = conn.setup().roots[screen].root; + (conn, root) +} + +fn config(display: &str, x: i32, y: i32, width: u32, height: u32) -> X11InputConfig { + X11InputConfig { + display_name: display.to_string(), + window_id: None, + x, + y, + width, + height, + fps: 30, + show_cursor: false, + } +} + +fn grabber(config: &X11InputConfig, shm: bool) -> X11Grabber { + // SAFETY: every test holds `SERIAL` and the grabber reads the variable only + // during construction, so no other thread observes the change. + unsafe { + if shm { + std::env::remove_var(DISABLE_SHM_ENV); + } else { + std::env::set_var(DISABLE_SHM_ENV, "1"); + } + } + let grabber = X11Grabber::new(config).unwrap(); + unsafe { std::env::remove_var(DISABLE_SHM_ENV) }; + grabber +} + +fn bgr_rows(frame: &ffmpeg::frame::Video) -> Vec { + let stride = frame.stride(0); + let data = frame.data(0); + let mut out = Vec::new(); + for row in 0..frame.height() as usize { + for pixel in data[row * stride..][..frame.width() as usize * 4].chunks_exact(4) { + out.extend_from_slice(&pixel[..3]); + } + } + out +} + +fn expected_bgr(x: u32, y: u32, width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + for row in y..y + height { + for column in x..x + width { + out.extend_from_slice(&pattern_pixel(column, row)); + } + } + out +} + +fn shm_segments_owned_by_this_process() -> usize { + let pid = std::process::id().to_string(); + std::fs::read_to_string("/proc/sysvipc/shm") + .unwrap() + .lines() + .skip(1) + .filter(|line| line.split_whitespace().nth(4) == Some(pid.as_str())) + .count() +} + +#[test] +fn shm_and_get_image_capture_identical_pixels() { + let _guard = serial(); + let Some(server) = Xvfb::start(1280, 720, &["-nolisten", "tcp"]) else { + return; + }; + let (conn, root) = connect(&server.display()); + draw_pattern(&conn, root, 1280, 720); + + let config = config(&server.display(), 0, 0, 1280, 720); + let mut shm = grabber(&config, true); + let mut get_image = grabber(&config, false); + assert!(shm.uses_shm()); + assert!(!get_image.uses_shm()); + + let shm_frame = bgr_rows(&shm.grab().unwrap()); + let get_image_frame = bgr_rows(&get_image.grab().unwrap()); + assert_eq!(shm_frame, expected_bgr(0, 0, 1280, 720)); + assert_eq!(shm_frame, get_image_frame); + assert!(shm.uses_shm()); +} + +#[test] +fn shm_captures_unaligned_regions() { + let _guard = serial(); + let Some(server) = Xvfb::start(800, 600, &["-nolisten", "tcp"]) else { + return; + }; + let (conn, root) = connect(&server.display()); + draw_pattern(&conn, root, 800, 600); + + for (x, y, width, height) in [(101, 53, 333, 211), (0, 599, 799, 1), (797, 0, 3, 600)] { + let config = config(&server.display(), x, y, width, height); + let mut shm = grabber(&config, true); + assert!(shm.uses_shm()); + let frame = bgr_rows(&shm.grab().unwrap()); + assert_eq!( + frame, + expected_bgr(x as u32, y as u32, width, height), + "region {x},{y} {width}x{height}" + ); + } +} + +#[test] +fn env_opt_out_forces_get_image() { + let _guard = serial(); + let Some(server) = Xvfb::start(320, 240, &["-nolisten", "tcp"]) else { + return; + }; + let (conn, root) = connect(&server.display()); + draw_pattern(&conn, root, 320, 240); + + let mut grabber = grabber(&config(&server.display(), 0, 0, 320, 240), false); + assert!(!grabber.uses_shm()); + assert_eq!( + bgr_rows(&grabber.grab().unwrap()), + expected_bgr(0, 0, 320, 240) + ); +} + +#[test] +fn missing_mit_shm_extension_falls_back_to_get_image() { + let _guard = serial(); + let Some(server) = Xvfb::start(320, 240, &["-nolisten", "tcp", "-extension", "MIT-SHM"]) else { + return; + }; + let (conn, root) = connect(&server.display()); + draw_pattern(&conn, root, 320, 240); + + let mut grabber = grabber(&config(&server.display(), 0, 0, 320, 240), true); + assert!(!grabber.uses_shm()); + assert_eq!( + bgr_rows(&grabber.grab().unwrap()), + expected_bgr(0, 0, 320, 240) + ); +} + +#[test] +fn tcp_display_falls_back_when_server_cannot_attach_segment() { + let _guard = serial(); + let Some(server) = Xvfb::start(320, 240, &["-listen", "tcp", "-ac"]) else { + return; + }; + let display = format!("localhost:{}", server.number); + let Ok((conn, root)) = x11rb::connect(Some(&display)).map(|(conn, screen)| { + let root = conn.setup().roots[screen].root; + (conn, root) + }) else { + eprintln!("skipping TCP X11 SHM test: cannot reach {display}"); + return; + }; + draw_pattern(&conn, root, 320, 240); + let baseline = shm_segments_owned_by_this_process(); + + let mut grabber = grabber(&config(&display, 0, 0, 320, 240), true); + assert!(!grabber.uses_shm()); + assert_eq!(shm_segments_owned_by_this_process(), baseline); + assert_eq!( + bgr_rows(&grabber.grab().unwrap()), + expected_bgr(0, 0, 320, 240) + ); +} + +#[test] +fn window_capture_uses_shm_and_survives_resize() { + let _guard = serial(); + let Some(server) = Xvfb::start(640, 480, &["-nolisten", "tcp"]) else { + return; + }; + let (conn, root) = connect(&server.display()); + let window = conn.generate_id().unwrap(); + conn.create_window( + 24, + window, + root, + 40, + 30, + 200, + 150, + 0, + WindowClass::INPUT_OUTPUT, + 0, + &CreateWindowAux::new().background_pixel(0), + ) + .unwrap() + .check() + .unwrap(); + conn.map_window(window).unwrap().check().unwrap(); + + let mut config = config(&server.display(), 40, 30, 200, 150); + config.window_id = Some(window); + let mut shm = grabber(&config, true); + let mut get_image = grabber(&config, false); + assert!(shm.uses_shm()); + + draw_pattern(&conn, window, 200, 150); + conn.sync().unwrap(); + let frame = bgr_rows(&shm.grab().unwrap()); + assert_eq!(frame, expected_bgr(0, 0, 200, 150)); + assert_eq!(frame, bgr_rows(&get_image.grab().unwrap())); + + for (width, height) in [(320u16, 240u16), (90, 70)] { + conn.configure_window( + window, + &ConfigureWindowAux::new() + .width(u32::from(width)) + .height(u32::from(height)), + ) + .unwrap() + .check() + .unwrap(); + draw_pattern(&conn, window, width, height); + conn.sync().unwrap(); + + let shm_frame = shm.grab().unwrap(); + assert!(shm.uses_shm(), "SHM survives a resize to {width}x{height}"); + assert_eq!((shm_frame.width(), shm_frame.height()), (200, 150)); + assert_eq!( + bgr_rows(&shm_frame), + bgr_rows(&get_image.grab().unwrap()), + "resized to {width}x{height}" + ); + } + + let mut resized = grabber( + &X11InputConfig { + width: 90, + height: 70, + ..config + }, + true, + ); + assert_eq!( + bgr_rows(&resized.grab().unwrap()), + expected_bgr(0, 0, 90, 70) + ); +} + +fn soak(frames: usize) { + let _guard = serial(); + let Some(server) = Xvfb::start(256, 256, &["-nolisten", "tcp"]) else { + return; + }; + let (conn, root) = connect(&server.display()); + draw_pattern(&conn, root, 256, 256); + let baseline = shm_segments_owned_by_this_process(); + + for cycle in 0..40 { + let size = 32 + cycle * 5; + let mut grabber = grabber(&config(&server.display(), 0, 0, size, size), true); + assert!(grabber.uses_shm()); + for _ in 0..10 { + grabber.grab().unwrap(); + } + assert_eq!(shm_segments_owned_by_this_process(), baseline + 1); + } + assert_eq!(shm_segments_owned_by_this_process(), baseline); + + let mut grabber = grabber(&config(&server.display(), 0, 0, 64, 64), true); + for _ in 0..frames { + grabber.grab().unwrap(); + } + assert!(grabber.uses_shm()); + assert_eq!( + bgr_rows(&grabber.grab().unwrap()), + expected_bgr(0, 0, 64, 64) + ); + drop(grabber); + assert_eq!(shm_segments_owned_by_this_process(), baseline); +} + +#[test] +fn repeated_grabbers_do_not_leak_shared_memory() { + soak(2_000); +} + +#[test] +#[ignore = "100k-frame soak; run explicitly"] +fn soak_100k_frames_does_not_leak_shared_memory() { + soak(100_000); +} From f92a0a532b8871e1ee351f257c0744a541a87d86 Mon Sep 17 00:00:00 2001 From: shubhook Date: Wed, 14 Oct 2026 04:21:10 +0000 Subject: [PATCH 2/5] feat(recording): benchmark the real X11Grabber with SHM/GetImage selection Port the x11-capture-benchmark example and scripts/bench-x11.sh, which time full X11Grabber::grab calls per backend, verify identical pixels, support --json, and fail if the SHM grabber falls back to GetImage. Add X11Grabber::with_shm so callers can force the GetImage backend. --- .../examples/x11-capture-benchmark.rs | 419 +++++++++++++----- .../src/sources/screen_capture/linux.rs | 14 +- scripts/bench-x11.sh | 14 + 3 files changed, 326 insertions(+), 121 deletions(-) create mode 100755 scripts/bench-x11.sh diff --git a/crates/recording/examples/x11-capture-benchmark.rs b/crates/recording/examples/x11-capture-benchmark.rs index 89dddb02075..99b5414f0c0 100644 --- a/crates/recording/examples/x11-capture-benchmark.rs +++ b/crates/recording/examples/x11-capture-benchmark.rs @@ -1,8 +1,9 @@ -//! Compares X11 frame grabs via MIT-SHM (`ShmGetImage`) and plain `GetImage`, -//! including the copy into the FFmpeg frame. +//! Compares the X11 grabber's MIT-SHM (`ShmGetImage`) and core `GetImage` +//! backends on root-window grabs. Each sample is a full `X11Grabber::grab`, +//! including the row copy into a freshly allocated ffmpeg BGRZ frame. //! -//! xvfb-run -s "-screen 0 3840x2160x24" \ -//! cargo run --release -p cap-recording --example x11-capture-benchmark [-- --json] +//! scripts/bench-x11.sh # runs under xvfb-run at 3840x2160x24 +//! scripts/bench-x11.sh --json #[cfg(not(target_os = "linux"))] fn main() { @@ -16,117 +17,347 @@ fn main() -> anyhow::Result<()> { #[cfg(target_os = "linux")] mod linux { - use anyhow::{Context as _, bail}; + use std::time::Instant; + + use anyhow::{Context, anyhow, bail}; use cap_recording::screen_capture::{X11Grabber, X11InputConfig}; use clap::Parser; - use serde::Serialize; - use std::time::Instant; - use x11rb::connection::Connection as _; + use x11rb::connection::{Connection, RequestConnection}; + use x11rb::protocol::shm::{self, ConnectionExt as _}; + use x11rb::protocol::xproto::{ + ChangeGCAux, ConnectionExt as _, CreateGCAux, Rectangle, Window, + }; + use x11rb::rust_connection::RustConnection; - const DISABLE_SHM_ENV: &str = "CAP_X11_DISABLE_SHM"; + const RESOLUTIONS: [(u16, u16); 4] = [(1280, 720), (1920, 1080), (2560, 1440), (3840, 2160)]; #[derive(Parser)] + #[command(about = "Benchmark X11 GetImage vs ShmGetImage capture")] struct Args { #[arg(long, default_value_t = 30)] warmup: usize, #[arg(long, default_value_t = 300)] - iterations: usize, + iters: usize, #[arg(long)] json: bool, } - #[derive(Serialize)] + #[derive(Clone, Copy, PartialEq)] + enum Backend { + GetImage, + ShmGetImage, + } + + impl Backend { + fn name(self) -> &'static str { + match self { + Backend::GetImage => "GetImage", + Backend::ShmGetImage => "ShmGetImage", + } + } + } + struct Stats { - backend: &'static str, - mean_ms: f64, - p50_ms: f64, - p95_ms: f64, - p99_ms: f64, + mean: f64, + p50: f64, + p95: f64, + p99: f64, mpix_per_s: f64, } - #[derive(Serialize)] + impl Stats { + fn from_samples(mut samples_ms: Vec, pixels: f64) -> Self { + samples_ms.sort_by(f64::total_cmp); + let mean = samples_ms.iter().sum::() / samples_ms.len() as f64; + let percentile = |p: f64| { + let rank = ((p / 100.0) * samples_ms.len() as f64).ceil() as usize; + samples_ms[rank.clamp(1, samples_ms.len()) - 1] + }; + Self { + mean, + p50: percentile(50.0), + p95: percentile(95.0), + p99: percentile(99.0), + mpix_per_s: pixels / (mean / 1000.0) / 1e6, + } + } + + fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "mean_ms": self.mean, + "p50_ms": self.p50, + "p95_ms": self.p95, + "p99_ms": self.p99, + "mpix_per_s": self.mpix_per_s, + }) + } + } + struct SizeResult { - label: &'static str, - width: u32, - height: u32, + width: u16, + height: u16, get_image: Stats, shm: Stats, - speedup: f64, } - const SIZES: [(&str, u32, u32); 4] = [ - ("720p", 1280, 720), - ("1080p", 1920, 1080), - ("1440p", 2560, 1440), - ("4K", 3840, 2160), - ]; + impl SizeResult { + fn speedup(&self) -> f64 { + self.get_image.mean / self.shm.mean + } + } + + fn grabber(width: u16, height: u16, backend: Backend) -> anyhow::Result { + let grabber = X11Grabber::with_shm( + &X11InputConfig { + display_name: std::env::var("DISPLAY").context("DISPLAY is not set")?, + window_id: None, + x: 0, + y: 0, + width: u32::from(width), + height: u32::from(height), + fps: 30, + show_cursor: false, + }, + backend == Backend::ShmGetImage, + )?; + if (backend == Backend::ShmGetImage) != grabber.uses_shm() { + bail!( + "{width}x{height}: X11Grabber did not select {}", + backend.name() + ); + } + Ok(grabber) + } + + fn frame_rows(frame: &ffmpeg::frame::Video) -> Vec { + let row_bytes = frame.width() as usize * 4; + let stride = frame.stride(0); + frame + .data(0) + .chunks(stride) + .take(frame.height() as usize) + .flat_map(|row| &row[..row_bytes]) + .copied() + .collect() + } + + fn paint_pattern( + conn: &RustConnection, + root: Window, + width: u16, + height: u16, + ) -> anyhow::Result<()> { + const CELL: u16 = 96; + let gc = conn.generate_id()?; + conn.create_gc(gc, root, &CreateGCAux::new())?; + for cy in 0..height.div_ceil(CELL) { + for cx in 0..width.div_ceil(CELL) { + let seed = u32::from(cx).wrapping_mul(0x9E37_79B9) + ^ u32::from(cy).wrapping_mul(0x85EB_CA6B); + conn.change_gc(gc, &ChangeGCAux::new().foreground(seed & 0x00FF_FFFF))?; + conn.poly_fill_rectangle( + root, + gc, + &[Rectangle { + x: (cx * CELL) as i16, + y: (cy * CELL) as i16, + width: CELL, + height: CELL, + }], + )?; + } + } + conn.free_gc(gc)?; + conn.get_input_focus()?.reply()?; + Ok(()) + } + + fn cpu_model() -> String { + let from_cpuinfo = std::fs::read_to_string("/proc/cpuinfo") + .ok() + .and_then(|info| { + info.lines() + .find(|line| line.starts_with("model name")) + .and_then(|line| line.split_once(':')) + .map(|(_, value)| value.trim().to_string()) + }); + let from_lscpu = || { + let output = std::process::Command::new("lscpu").output().ok()?; + String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| line.trim_start().starts_with("Model name:")) + .and_then(|line| line.split_once(':')) + .map(|(_, value)| value.trim().to_string()) + }; + let model = from_cpuinfo + .or_else(from_lscpu) + .unwrap_or_else(|| "unknown".to_string()); + format!( + "{model} ({} logical CPUs, {})", + std::thread::available_parallelism().map_or(0, |n| n.get()), + std::env::consts::ARCH + ) + } + + fn time_backend(grabber: &mut X11Grabber, args: &Args) -> anyhow::Result> { + for _ in 0..args.warmup { + grabber.grab()?; + } + let mut samples = Vec::with_capacity(args.iters); + for _ in 0..args.iters { + let start = Instant::now(); + let frame = grabber.grab()?; + samples.push(start.elapsed().as_secs_f64() * 1000.0); + drop(frame); + } + Ok(samples) + } pub fn run() -> anyhow::Result<()> { let args = Args::parse(); - let display = std::env::var("DISPLAY").context("DISPLAY is not set")?; - let (conn, screen) = x11rb::connect(Some(&display))?; - let root = &conn.setup().roots[screen]; - let (screen_width, screen_height) = ( - u32::from(root.width_in_pixels), - u32::from(root.height_in_pixels), + if args.iters == 0 { + bail!("--iters must be at least 1"); + } + ffmpeg::init().context("init ffmpeg")?; + + let (conn, screen_num) = x11rb::connect(None).context("connect to X server")?; + let setup = conn.setup(); + let screen = &setup.roots[screen_num]; + let root = screen.root; + let (screen_w, screen_h) = (screen.width_in_pixels, screen.height_in_pixels); + + let format = setup + .pixmap_formats + .iter() + .find(|f| f.depth == screen.root_depth) + .ok_or_else(|| anyhow!("no pixmap format for root depth {}", screen.root_depth))?; + if format.bits_per_pixel != 32 { + bail!( + "root depth {} uses {} bpp; only 32 bpp is supported", + screen.root_depth, + format.bits_per_pixel + ); + } + + if conn + .extension_information(shm::X11_EXTENSION_NAME)? + .is_none() + { + bail!("X server does not advertise MIT-SHM"); + } + let shm_version = conn.shm_query_version()?.reply()?; + + let cpu = cpu_model(); + let vendor = String::from_utf8_lossy(&setup.vendor).into_owned(); + let server = format!("{vendor} {}", setup.release_number); + let shm_version = format!( + "{}.{}", + shm_version.major_version, shm_version.minor_version + ); + + eprintln!("CPU: {cpu}"); + eprintln!( + "X server: {server}, screen {screen_w}x{screen_h} depth {}, MIT-SHM {shm_version}", + screen.root_depth ); - drop(conn); + + paint_pattern(&conn, root, screen_w, screen_h)?; let mut results = Vec::new(); - for (label, width, height) in SIZES { - if width > screen_width || height > screen_height { - eprintln!("skipping {label}: screen is {screen_width}x{screen_height}"); + let mut skipped = Vec::new(); + for (width, height) in RESOLUTIONS { + if width > screen_w || height > screen_h { + eprintln!("skipping {width}x{height}: larger than {screen_w}x{screen_h} screen"); + skipped.push(format!("{width}x{height}")); continue; } - let config = X11InputConfig { - display_name: display.clone(), - window_id: None, - x: 0, - y: 0, - width, - height, - fps: 60, - show_cursor: false, - }; - let get_image = measure(&config, false, &args)?; - let shm = measure(&config, true, &args)?; + + let mut get_image_grabber = grabber(width, height, Backend::GetImage)?; + let mut shm_grabber = grabber(width, height, Backend::ShmGetImage)?; + + let reference = frame_rows(&get_image_grabber.grab()?); + if frame_rows(&shm_grabber.grab()?) != reference { + bail!("{width}x{height}: ShmGetImage pixels differ from GetImage"); + } + + eprintln!("benchmarking {width}x{height} ..."); + let pixels = f64::from(width) * f64::from(height); + let get_image = + Stats::from_samples(time_backend(&mut get_image_grabber, &args)?, pixels); + let shm = Stats::from_samples(time_backend(&mut shm_grabber, &args)?, pixels); + if !shm_grabber.uses_shm() { + bail!("{width}x{height}: X11Grabber fell back to GetImage mid-run"); + } results.push(SizeResult { - label, width, height, - speedup: get_image.mean_ms / shm.mean_ms, get_image, shm, }); } if args.json { - println!("{}", serde_json::to_string_pretty(&results)?); + let rows: Vec<_> = results + .iter() + .map(|r| { + serde_json::json!({ + "width": r.width, + "height": r.height, + Backend::GetImage.name(): r.get_image.to_json(), + Backend::ShmGetImage.name(): r.shm.to_json(), + "speedup": r.speedup(), + }) + }) + .collect(); + let report = serde_json::json!({ + "cpu": cpu, + "x_server": server, + "screen": { "width": screen_w, "height": screen_h, "depth": screen.root_depth }, + "mit_shm_version": shm_version, + "pixel_format": "BGRZ", + "warmup": args.warmup, + "iters": args.iters, + "skipped": skipped, + "results": rows, + }); + println!("{}", serde_json::to_string_pretty(&report)?); return Ok(()); } + println!("# X11 capture benchmark: GetImage vs ShmGetImage\n"); + println!("- CPU: {cpu}"); println!( - "X11 capture benchmark on {display} ({screen_width}x{screen_height}), {} warmup + {} iterations\n", - args.warmup, args.iterations + "- X server: {server} ({screen_w}x{screen_h}, depth {})", + screen.root_depth ); - println!("| size | backend | mean ms | p50 ms | p95 ms | p99 ms | Mpix/s | speedup |"); - println!("|---|---|---|---|---|---|---|---|"); - for result in &results { - for (stats, speedup) in [ - (&result.get_image, String::new()), - (&result.shm, format!("{:.2}x", result.speedup)), + println!("- MIT-SHM: {shm_version}"); + println!( + "- {} warmup + {} timed grabs per backend, each a full `X11Grabber::grab` including the copy into a new ffmpeg BGRZ frame\n", + args.warmup, args.iters + ); + println!( + "| Resolution | Backend | Mean (ms) | p50 (ms) | p95 (ms) | p99 (ms) | Mpix/s | Speedup |" + ); + println!("|---|---|---:|---:|---:|---:|---:|---:|"); + for r in &results { + for (backend, stats) in [ + (Backend::GetImage, &r.get_image), + (Backend::ShmGetImage, &r.shm), ] { + let speedup = if backend == Backend::ShmGetImage { + format!("{:.2}x", r.speedup()) + } else { + "1.00x".to_string() + }; println!( - "| {} ({}x{}) | {} | {:.3} | {:.3} | {:.3} | {:.3} | {:.0} | {} |", - result.label, - result.width, - result.height, - stats.backend, - stats.mean_ms, - stats.p50_ms, - stats.p95_ms, - stats.p99_ms, + "| {}x{} | {} | {:.3} | {:.3} | {:.3} | {:.3} | {:.1} | {} |", + r.width, + r.height, + backend.name(), + stats.mean, + stats.p50, + stats.p95, + stats.p99, stats.mpix_per_s, speedup ); @@ -134,54 +365,4 @@ mod linux { } Ok(()) } - - fn measure(config: &X11InputConfig, shm: bool, args: &Args) -> anyhow::Result { - // SAFETY: the benchmark is single-threaded and the grabber only reads - // the variable during construction. - unsafe { - if shm { - std::env::remove_var(DISABLE_SHM_ENV); - } else { - std::env::set_var(DISABLE_SHM_ENV, "1"); - } - } - let mut grabber = X11Grabber::new(config)?; - unsafe { std::env::remove_var(DISABLE_SHM_ENV) }; - if grabber.uses_shm() != shm { - bail!("requested shm={shm} but the grabber chose otherwise"); - } - - for _ in 0..args.warmup { - grabber.grab()?; - } - let mut samples = Vec::with_capacity(args.iterations); - for _ in 0..args.iterations { - let start = Instant::now(); - let frame = grabber.grab()?; - samples.push(start.elapsed().as_secs_f64() * 1000.0); - drop(frame); - } - if grabber.uses_shm() != shm { - bail!("MIT-SHM fell back to GetImage during the benchmark"); - } - if samples.is_empty() { - bail!("--iterations must be at least 1"); - } - - samples.sort_by(f64::total_cmp); - let percentile = |p: f64| { - let index = ((samples.len() - 1) as f64 * p).round() as usize; - samples[index] - }; - let mean_ms = samples.iter().sum::() / samples.len() as f64; - let pixels = f64::from(config.width) * f64::from(config.height); - Ok(Stats { - backend: if shm { "MIT-SHM" } else { "GetImage" }, - mean_ms, - p50_ms: percentile(0.50), - p95_ms: percentile(0.95), - p99_ms: percentile(0.99), - mpix_per_s: pixels / (mean_ms / 1000.0) / 1_000_000.0, - }) - } } diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index aef9de260a8..f4604c44fd1 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -2063,6 +2063,12 @@ struct X11WindowCapture { impl X11Grabber { pub fn new(config: &X11InputConfig) -> anyhow::Result { + Self::with_shm(config, true) + } + + /// Like [`Self::new`], but `allow_shm: false` forces the `GetImage` + /// backend, the same as setting `CAP_X11_DISABLE_SHM=1`. + pub fn with_shm(config: &X11InputConfig, allow_shm: bool) -> anyhow::Result { ffmpeg::init().context("initialize FFmpeg")?; let (conn, screen_num) = x11rb::connect(Some(config.display_name.as_str())) @@ -2188,11 +2194,15 @@ impl X11Grabber { show_cursor, }; grabber.refresh_window_pixmap()?; - grabber.backend = grabber.initial_backend(); + grabber.backend = grabber.initial_backend(allow_shm); Ok(grabber) } - fn initial_backend(&self) -> GrabBackend { + fn initial_backend(&self, allow_shm: bool) -> GrabBackend { + if !allow_shm { + tracing::info!("X11 MIT-SHM capture disabled by caller"); + return GrabBackend::GetImage; + } if linux_shm::shm_disabled_by_env() { tracing::info!( "X11 MIT-SHM capture disabled by {}", diff --git a/scripts/bench-x11.sh b/scripts/bench-x11.sh new file mode 100755 index 00000000000..0edbdab0718 --- /dev/null +++ b/scripts/bench-x11.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v xvfb-run >/dev/null; then + echo "xvfb-run not found (install xvfb)" >&2 + exit 1 +fi + +cargo build --release -p cap-recording --example x11-capture-benchmark + +exec xvfb-run -a -s "-screen 0 3840x2160x24" \ + cargo run --release -q -p cap-recording --example x11-capture-benchmark -- "$@" From 3f329b767146d183bc2d7b5976f758df739caf9c Mon Sep 17 00:00:00 2001 From: shubhook Date: Wed, 14 Oct 2026 04:34:38 +0000 Subject: [PATCH 3/5] docs(recording): document X11Grabber::with_shm and the MIT-SHM module --- .../src/sources/screen_capture/linux.rs | 12 ++++++++-- .../src/sources/screen_capture/linux_shm.rs | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index f4604c44fd1..9bef9b5e3d2 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -2066,8 +2066,15 @@ impl X11Grabber { Self::with_shm(config, true) } - /// Like [`Self::new`], but `allow_shm: false` forces the `GetImage` - /// backend, the same as setting `CAP_X11_DISABLE_SHM=1`. + /// Like [`Self::new`], but lets the caller choose whether MIT-SHM may be + /// used. `allow_shm: false` forces the `GetImage` backend, the same as + /// setting `CAP_X11_DISABLE_SHM=1`. `allow_shm: true` still falls back to + /// `GetImage` when SHM is unavailable, so check [`Self::uses_shm`] to see + /// which backend was picked. + /// + /// If an SHM grab later fails, that frame is retried with `GetImage`. The + /// grabber logs one warning and stays on `GetImage` only if the retry + /// succeeds. pub fn with_shm(config: &X11InputConfig, allow_shm: bool) -> anyhow::Result { ffmpeg::init().context("initialize FFmpeg")?; @@ -2251,6 +2258,7 @@ impl X11Grabber { }) } + /// Whether frames are currently captured through MIT-SHM. pub fn uses_shm(&self) -> bool { matches!(self.backend, GrabBackend::Shm(_)) } diff --git a/crates/recording/src/sources/screen_capture/linux_shm.rs b/crates/recording/src/sources/screen_capture/linux_shm.rs index 8553e82e9d9..fbc0846589d 100644 --- a/crates/recording/src/sources/screen_capture/linux_shm.rs +++ b/crates/recording/src/sources/screen_capture/linux_shm.rs @@ -1,5 +1,15 @@ //! MIT-SHM image transfer for X11 capture. All `unsafe` for the SysV segment //! lifecycle lives here behind a safe API. +//! +//! [`ShmSegment`] creates a private SysV segment, maps it into this process, +//! asks the X server to attach it, then immediately marks it for removal +//! (`IPC_RMID`). The kernel frees it once both sides detach, so a crash +//! cannot leak it. Each `ShmGetImage` has the server write a frame into the +//! segment, and the caller reads it after the reply arrives. +//! +//! `X11Grabber` decides when to use this module: it falls back to plain +//! `GetImage` when the extension is missing, the server can't attach the +//! segment (remote/TCP displays), or [`DISABLE_SHM_ENV`] is set. use anyhow::{Context as _, anyhow, bail}; use std::{ptr::NonNull, sync::Arc}; @@ -9,6 +19,7 @@ use x11rb::protocol::xproto::{Drawable, ImageFormat}; use x11rb::rust_connection::RustConnection; use x11rb::wrapper::ConnectionExt as _; +/// Set to `1`/`true`/`yes`/`on` to force X11 capture onto `GetImage`. pub(crate) const DISABLE_SHM_ENV: &str = "CAP_X11_DISABLE_SHM"; pub(crate) fn shm_disabled_by_env() -> bool { @@ -22,6 +33,8 @@ fn is_truthy(value: &str) -> bool { ) } +/// Bytes per row of a Z-pixmap image, padded to `scanline_pad` bits as the +/// server lays it out. `None` for invalid formats or overflow. pub(crate) fn image_stride(width: u16, bits_per_pixel: u8, scanline_pad: u8) -> Option { if bits_per_pixel == 0 || scanline_pad == 0 || scanline_pad % 8 != 0 { return None; @@ -31,6 +44,7 @@ pub(crate) fn image_stride(width: u16, bits_per_pixel: u8, scanline_pad: u8) -> row_bits.div_ceil(8).checked_next_multiple_of(pad_bytes) } +/// Total bytes of a `width` x `height` Z-pixmap image; see [`image_stride`]. pub(crate) fn image_size( width: u16, height: u16, @@ -40,6 +54,7 @@ pub(crate) fn image_size( image_stride(width, bits_per_pixel, scanline_pad)?.checked_mul(usize::from(height)) } +/// Errors unless the server offers MIT-SHM 1.1 or newer. pub(crate) fn query_shm_support(conn: &RustConnection) -> anyhow::Result<()> { if conn .extension_information(shm::X11_EXTENSION_NAME) @@ -145,6 +160,9 @@ impl Drop for SysvMapping { } } +/// A shared-memory segment attached to both this process and the X server. +/// Dropping it detaches from the server, waits for the server to let go, then +/// unmaps it locally. pub(crate) struct ShmSegment { conn: Arc, seg: shm::Seg, @@ -152,6 +170,8 @@ pub(crate) struct ShmSegment { } impl ShmSegment { + /// Create a `size`-byte segment and attach it to the server on `conn`. + /// Fails if the server can't attach it, e.g. over a remote connection. pub(crate) fn new(conn: &Arc, size: usize) -> anyhow::Result { let mapping = SysvMapping::new(size)?; let shmid = @@ -174,6 +194,8 @@ impl ShmSegment { self.mapping.size } + /// Have the server write a Z-pixmap of the given area into the segment, + /// and return the bytes it wrote. pub(crate) fn get_image( &mut self, drawable: Drawable, From 5696e87f5daa0d3ee838d6912ee4426c1c8eaecb Mon Sep 17 00:00:00 2001 From: shubhook Date: Wed, 14 Oct 2026 04:48:55 +0000 Subject: [PATCH 4/5] refactor(recording): hide X11Grabber and X11InputConfig from public docs They are pub only so the x11-capture-benchmark example and the x11_shm_capture integration tests can use them directly. --- crates/recording/src/sources/screen_capture/linux.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index 9bef9b5e3d2..f262d0db634 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -71,6 +71,9 @@ enum LinuxInputConfig { Wayland(WaylandInputConfig), } +// Public only so `examples/x11-capture-benchmark.rs` and +// `tests/x11_shm_capture.rs` can drive the grabber directly; not a supported API. +#[doc(hidden)] pub struct X11InputConfig { pub display_name: String, pub window_id: Option, @@ -2033,6 +2036,10 @@ fn capture_x11( /// FFmpeg (spacedrive native-deps) is built without. Capturing with `x11rb` /// keeps us off any system FFmpeg/libavdevice and adds no new runtime /// shared-library dependency (`x11rb` speaks the X11 protocol over a socket). +/// +/// Public (but hidden) only for the X11 capture benchmark and integration +/// tests; not a supported API. +#[doc(hidden)] pub struct X11Grabber { conn: Arc, root: x11rb::protocol::xproto::Window, From 9771355059286a8f4963a79528092ede68d46d5e Mon Sep 17 00:00:00 2001 From: shubhook Date: Wed, 14 Oct 2026 05:09:25 +0000 Subject: [PATCH 5/5] chore(recording): trim narrating comments, run x11_shm_capture in Linux CI --- .github/workflows/ci.yml | 1 + .../src/sources/screen_capture/linux.rs | 17 ++--------- .../src/sources/screen_capture/linux_shm.rs | 29 ++++--------------- 3 files changed, 8 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43d79ddaa7a..dac701ce978 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,7 @@ jobs: cargo test --locked -p cap-utils -p cap-cli-install --lib cargo test --locked -p cap --bin cap record::tests cargo test --locked -p cap-recording --lib -- --test-threads=1 + cargo test --locked -p cap-recording --test x11_shm_capture -- --test-threads=1 cargo test --locked -p cap-enc-ffmpeg build-gpui: diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index f262d0db634..58faeb796d1 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -71,8 +71,6 @@ enum LinuxInputConfig { Wayland(WaylandInputConfig), } -// Public only so `examples/x11-capture-benchmark.rs` and -// `tests/x11_shm_capture.rs` can drive the grabber directly; not a supported API. #[doc(hidden)] pub struct X11InputConfig { pub display_name: String, @@ -2036,9 +2034,8 @@ fn capture_x11( /// FFmpeg (spacedrive native-deps) is built without. Capturing with `x11rb` /// keeps us off any system FFmpeg/libavdevice and adds no new runtime /// shared-library dependency (`x11rb` speaks the X11 protocol over a socket). -/// -/// Public (but hidden) only for the X11 capture benchmark and integration -/// tests; not a supported API. +// `pub` only so the x11-capture-benchmark example and x11_shm_capture tests can +// drive it; not a supported API. #[doc(hidden)] pub struct X11Grabber { conn: Arc, @@ -2073,15 +2070,6 @@ impl X11Grabber { Self::with_shm(config, true) } - /// Like [`Self::new`], but lets the caller choose whether MIT-SHM may be - /// used. `allow_shm: false` forces the `GetImage` backend, the same as - /// setting `CAP_X11_DISABLE_SHM=1`. `allow_shm: true` still falls back to - /// `GetImage` when SHM is unavailable, so check [`Self::uses_shm`] to see - /// which backend was picked. - /// - /// If an SHM grab later fails, that frame is retried with `GetImage`. The - /// grabber logs one warning and stays on `GetImage` only if the retry - /// succeeds. pub fn with_shm(config: &X11InputConfig, allow_shm: bool) -> anyhow::Result { ffmpeg::init().context("initialize FFmpeg")?; @@ -2265,7 +2253,6 @@ impl X11Grabber { }) } - /// Whether frames are currently captured through MIT-SHM. pub fn uses_shm(&self) -> bool { matches!(self.backend, GrabBackend::Shm(_)) } diff --git a/crates/recording/src/sources/screen_capture/linux_shm.rs b/crates/recording/src/sources/screen_capture/linux_shm.rs index fbc0846589d..2b9220b8fc5 100644 --- a/crates/recording/src/sources/screen_capture/linux_shm.rs +++ b/crates/recording/src/sources/screen_capture/linux_shm.rs @@ -1,15 +1,6 @@ -//! MIT-SHM image transfer for X11 capture. All `unsafe` for the SysV segment -//! lifecycle lives here behind a safe API. -//! -//! [`ShmSegment`] creates a private SysV segment, maps it into this process, -//! asks the X server to attach it, then immediately marks it for removal -//! (`IPC_RMID`). The kernel frees it once both sides detach, so a crash -//! cannot leak it. Each `ShmGetImage` has the server write a frame into the -//! segment, and the caller reads it after the reply arrives. -//! -//! `X11Grabber` decides when to use this module: it falls back to plain -//! `GetImage` when the extension is missing, the server can't attach the -//! segment (remote/TCP displays), or [`DISABLE_SHM_ENV`] is set. +//! All `unsafe` for the X11 MIT-SHM segment lives here. The segment is marked +//! `IPC_RMID` right after the server attaches it, so the kernel frees it once +//! both sides detach, even if this process crashes. use anyhow::{Context as _, anyhow, bail}; use std::{ptr::NonNull, sync::Arc}; @@ -19,7 +10,6 @@ use x11rb::protocol::xproto::{Drawable, ImageFormat}; use x11rb::rust_connection::RustConnection; use x11rb::wrapper::ConnectionExt as _; -/// Set to `1`/`true`/`yes`/`on` to force X11 capture onto `GetImage`. pub(crate) const DISABLE_SHM_ENV: &str = "CAP_X11_DISABLE_SHM"; pub(crate) fn shm_disabled_by_env() -> bool { @@ -33,8 +23,6 @@ fn is_truthy(value: &str) -> bool { ) } -/// Bytes per row of a Z-pixmap image, padded to `scanline_pad` bits as the -/// server lays it out. `None` for invalid formats or overflow. pub(crate) fn image_stride(width: u16, bits_per_pixel: u8, scanline_pad: u8) -> Option { if bits_per_pixel == 0 || scanline_pad == 0 || scanline_pad % 8 != 0 { return None; @@ -44,7 +32,6 @@ pub(crate) fn image_stride(width: u16, bits_per_pixel: u8, scanline_pad: u8) -> row_bits.div_ceil(8).checked_next_multiple_of(pad_bytes) } -/// Total bytes of a `width` x `height` Z-pixmap image; see [`image_stride`]. pub(crate) fn image_size( width: u16, height: u16, @@ -54,7 +41,6 @@ pub(crate) fn image_size( image_stride(width, bits_per_pixel, scanline_pad)?.checked_mul(usize::from(height)) } -/// Errors unless the server offers MIT-SHM 1.1 or newer. pub(crate) fn query_shm_support(conn: &RustConnection) -> anyhow::Result<()> { if conn .extension_information(shm::X11_EXTENSION_NAME) @@ -160,9 +146,8 @@ impl Drop for SysvMapping { } } -/// A shared-memory segment attached to both this process and the X server. -/// Dropping it detaches from the server, waits for the server to let go, then -/// unmaps it locally. +// `Drop` detaches from the server and waits for it to confirm before the +// `mapping` field drops and unmaps locally. pub(crate) struct ShmSegment { conn: Arc, seg: shm::Seg, @@ -170,8 +155,6 @@ pub(crate) struct ShmSegment { } impl ShmSegment { - /// Create a `size`-byte segment and attach it to the server on `conn`. - /// Fails if the server can't attach it, e.g. over a remote connection. pub(crate) fn new(conn: &Arc, size: usize) -> anyhow::Result { let mapping = SysvMapping::new(size)?; let shmid = @@ -194,8 +177,6 @@ impl ShmSegment { self.mapping.size } - /// Have the server write a Z-pixmap of the given area into the segment, - /// and return the bytes it wrote. pub(crate) fn get_image( &mut self, drawable: Drawable,