Skip to main content

pixelflux/x11/
mod.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! X11 host capture: grab the root window into host memory as BGRA, composite the XFixes hardware
8//! cursor and the watermark on the CPU, and feed each frame to [`pipeline::X11Pipeline`], which owns
9//! damage/stripe/encode. The grab goes through a shared-memory segment (XShm via x11rb) rather than
10//! a plain `GetImage` for one reason: a full-screen frame is far too large to copy through the X
11//! protocol socket every tick, so XShm has the server write the pixels straight into memory this
12//! process already has mapped.
13//!
14//! `run_capture` splits the work across two threads because grabbing the next frame and encoding
15//! the previous one have no reason to wait on each other: the caller's thread grabs frames and owns
16//! the x11rb connection and the pool of shm surfaces, while a spawned encode thread owns the
17//! [`pipeline::X11Pipeline`] (and thus the encoder) and runs the delivery callback, so the two overlap for
18//! throughput. Frames hand off through a bounded `FramePool` that carries only a raw pointer and
19//! geometry — never an X object, none of which is safe to share — so nothing X-related ever crosses
20//! the thread boundary and the encoder never has to touch X. Multi-instance safety for the encoders
21//! is handled inside them (e.g. the libx264 open/close lock); each capture owns its own private xcb
22//! connection, so there is no shared X state to serialize here.
23
24use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering};
25use std::sync::mpsc::Sender;
26use std::sync::{Arc, Condvar, Mutex};
27use std::thread;
28use std::time::{Duration, Instant};
29
30use x11rb::connection::Connection;
31use x11rb::protocol::shm::ConnectionExt as ShmExt;
32use x11rb::protocol::xfixes::ConnectionExt as XfixesExt;
33use x11rb::protocol::xproto::{ConnectionExt as XprotoExt, ImageFormat};
34use x11rb::rust_connection::RustConnection;
35
36use crate::encoders::overlay::blend_pixel_premultiplied;
37use crate::encoders::software::EncodedStripe;
38use crate::pipeline::X11Pipeline;
39use crate::recording_sink::RecordingSink;
40use crate::RustCaptureSettings;
41
42pub mod computer_use;
43pub mod cursor;
44
45/// Cross-thread controls for a running capture: a bag of atomics (plus two mutex-guarded
46/// payloads) the owning `ScreenCapture` pyclass flips from the Python thread and the capture thread
47/// reads at the top of each iteration.
48///
49/// It exists so Python never has to reach into the pipeline. The `X11Pipeline` and its encoder are
50/// only safe to touch from the capture and encode threads, so `request_idr` / rate / fps / region
51/// changes are posted here as atomic flags and applied later on the thread that owns the pipeline,
52/// rather than mutating encoder state across the thread boundary from Python.
53///
54/// 1. **Lifecycle**: `stop` ends the capture loop; `force_idr` requests an on-demand keyframe on the
55///    next processed frame.
56/// 2. **Rate control** (gated by `rate_dirty`): `bitrate_kbps`, `vbv_mult_milli` (the VBV frame-time
57///    multiplier * 1000, held as an integer for atomics; `<= 0` selects the policy default), and
58///    `fps_milli` (target fps * 1000, re-read every frame for dynamic pacing and rate control). These
59///    atomics always hold the CURRENT values, so a pipeline rebuild can carry live rates forward.
60/// 3. **Tunables** (gated by `tunables_dirty`): one `LiveTunables` struct behind `tunables`, set
61///    rarely, carrying the per-frame quality knobs for the encode thread.
62/// 4. **Capture geometry**: `capture_cursor` toggles the cursor overlay (read on every grab);
63///    `region_dirty` guards `region` = `(x, y, w, h)` (w/h `<= 0` extends to the root edge), applied
64///    by the capture thread with the same drain/recreate machinery as auto-adjust.
65pub struct Controls {
66    pub stop: AtomicBool,
67    /// Set by the capture thread as it returns — after it has joined its encode thread and
68    /// so dropped the NVENC/CUDA session. The atexit sweep waits on this (bounded) before
69    /// letting the interpreter finalize, because that hardware-session drop racing process
70    /// exit segfaults, exactly as the Wayland branch fences with a Barrier.
71    pub finished: AtomicBool,
72    pub force_idr: AtomicBool,
73    pub rate_dirty: AtomicBool,
74    pub bitrate_kbps: AtomicI32,
75    pub vbv_mult_milli: AtomicI32,
76    pub fps_milli: AtomicU64,
77    pub tunables_dirty: AtomicBool,
78    pub tunables: Mutex<Option<crate::LiveTunables>>,
79    pub capture_cursor: AtomicBool,
80    pub region_dirty: AtomicBool,
81    pub region: Mutex<(i32, i32, i32, i32)>,
82}
83
84impl Controls {
85    /// Seed the controls from the initial settings so the first loop iteration reads the
86    /// configured bitrate / VBV / fps / region / cursor state rather than defaults.
87    pub fn new(s: &RustCaptureSettings) -> Self {
88        Self {
89            stop: AtomicBool::new(false),
90            finished: AtomicBool::new(false),
91            force_idr: AtomicBool::new(false),
92            rate_dirty: AtomicBool::new(false),
93            bitrate_kbps: AtomicI32::new(s.video_bitrate_kbps),
94            vbv_mult_milli: AtomicI32::new((s.video_vbv_multiplier * 1000.0).round() as i32),
95            fps_milli: AtomicU64::new((s.target_fps.max(1.0) * 1000.0) as u64),
96            tunables_dirty: AtomicBool::new(false),
97            tunables: Mutex::new(None),
98            capture_cursor: AtomicBool::new(s.capture_cursor),
99            region_dirty: AtomicBool::new(false),
100            region: Mutex::new((s.capture_x, s.capture_y, s.width, s.height)),
101        }
102    }
103}
104
105/// A shared-memory image surface: a POSIX shm segment mapped into both this process and the
106/// X server, into which `shm_get_image` writes one BGRA frame.
107///
108/// Both ends map the same physical pages, which is the whole point: a full-screen grab then costs no
109/// per-frame copy across the X socket, because the server's blit lands directly in memory the
110/// capture thread already reads. The surface owns the server-side segment id (`shmseg`), the local
111/// mapping (`addr` / `size`), and the geometry (`width` / `height` / `stride`) needed to interpret
112/// the bytes.
113struct ShmSurface {
114    shmseg: u32,
115    addr: *mut u8,
116    size: usize,
117    width: u16,
118    height: u16,
119    stride: usize,
120}
121
122impl ShmSurface {
123    /// Allocate a shm segment of `width*height*4` bytes, attach it both locally and to the X
124    /// server, and hand back a surface both ends can read.
125    ///
126    /// The segment is created with `shmget(IPC_PRIVATE)` and mapped locally via `shmat`, then
127    /// attached on the server (`shm_attach`). That attach is confirmed with a round-trip `check()`
128    /// BEFORE the segment is marked `IPC_RMID`: deleting only after both ends hold a reference means
129    /// the kernel frees the segment once this process and the server have both detached, never while
130    /// either still needs it. Every failure path unwinds the partial state (local detach and/or
131    /// `IPC_RMID`) so a failed allocation leaks neither memory nor a server attachment; a zero-sized
132    /// request is rejected up front.
133    fn create(conn: &RustConnection, width: u16, height: u16) -> Result<Self, String> {
134        let stride = width as usize * 4;
135        let size = stride * height as usize;
136        if size == 0 {
137            return Err("zero-sized capture surface".into());
138        }
139        unsafe {
140            let shmid = libc::shmget(libc::IPC_PRIVATE, size, libc::IPC_CREAT | 0o600);
141            if shmid < 0 {
142                return Err("shmget failed".into());
143            }
144            let addr = libc::shmat(shmid, std::ptr::null(), 0);
145            if addr == (-1isize) as *mut libc::c_void {
146                libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut());
147                return Err("shmat failed".into());
148            }
149            let shmseg = match conn.generate_id() {
150                Ok(id) => id,
151                Err(e) => {
152                    libc::shmdt(addr);
153                    libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut());
154                    return Err(format!("generate_id: {e}"));
155                }
156            };
157            let attach = conn
158                .shm_attach(shmseg, shmid as u32, false)
159                .map_err(|e| format!("shm_attach: {e}"))
160                .and_then(|c| c.check().map_err(|e| format!("shm_attach check: {e}")));
161            libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut());
162            if let Err(e) = attach {
163                libc::shmdt(addr);
164                return Err(e);
165            }
166            Ok(Self {
167                shmseg,
168                addr: addr as *mut u8,
169                size,
170                width,
171                height,
172                stride,
173            })
174        }
175    }
176
177    /// Borrow the mapped segment as a mutable byte slice for the capture blit and overlays.
178    fn as_mut_slice(&mut self) -> &mut [u8] {
179        unsafe { std::slice::from_raw_parts_mut(self.addr, self.size) }
180    }
181
182    /// Borrow the mapped segment read-only, for the stability comparison.
183    fn as_slice(&self) -> &[u8] {
184        unsafe { std::slice::from_raw_parts(self.addr, self.size) }
185    }
186
187    /// Detach the segment from the X server and then locally, clearing the mapped pointer.
188    ///
189    /// This is a manual method rather than a `Drop` impl because releasing the server-side
190    /// attachment is an X protocol request that needs the live connection, which a `Drop` could not
191    /// reach; the owner of the connection must call it before the surface is dropped. The order —
192    /// server `shm_detach` first, then the local `shmdt` — completes the release of the segment that
193    /// was marked `IPC_RMID` at creation, so the kernel reclaims it once neither end holds it.
194    fn destroy(&mut self, conn: &RustConnection) {
195        let _ = conn.shm_detach(self.shmseg);
196        let _ = conn.flush();
197        unsafe {
198            libc::shmdt(self.addr as *mut libc::c_void);
199        }
200        self.addr = std::ptr::null_mut();
201    }
202}
203
204/// Reject a server whose root visual is not 32 bits per pixel.
205///
206/// Every `ShmSurface` is sized at `width * 4` bytes per row and the capture is read as packed
207/// BGRA, so a root of any other depth would be grabbed as garbage rather than failing. Checked
208/// on the initial connection and again on every reconnect, since a restarted server can come
209/// back with a different visual.
210fn require_32bpp(conn: &RustConnection, screen: usize) -> Result<(), String> {
211    let root_depth = conn.setup().roots[screen].root_depth;
212    let bpp = conn
213        .setup()
214        .pixmap_formats
215        .iter()
216        .find(|f| f.depth == root_depth)
217        .map(|f| f.bits_per_pixel)
218        .unwrap_or(32);
219    if bpp != 32 {
220        return Err(format!(
221            "unsupported root depth {root_depth} ({bpp} bpp); only 32-bpp BGRA is supported"
222        ));
223    }
224    Ok(())
225}
226
227/// Clamp a capture offset to the live root so a grab always has at least one root pixel in
228/// range: a past-end offset fails every `shm_get_image` with BadMatch and would burn out the
229/// failure budget with no chance of recovery. The upper clamp also keeps protocol-range roots
230/// (INT16 coordinates) from truncating a wider value through the `as i16` cast.
231fn clamp_offset(x: i32, root: u16) -> i16 {
232    let upper = (root as i32).saturating_sub(1).min(i16::MAX as i32 - 1);
233    x.max(0).min(upper) as i16
234}
235
236/// Resolve the capture dimensions `shm_get_image` will read, bounded so the region can never
237/// run past the live root from the (clamped) capture offset: with auto-adjust (or unset
238/// width/height `<= 0`) the capture tracks the full root minus the offset, otherwise the
239/// requested size is clamped to what is available from it. Saturating subtraction plus a final
240/// `u16` clamp (minimum 2) keep pathological settings from overflowing or collapsing to an
241/// unusable surface. H.264 (`output_mode == 1`) requires even dimensions, so both are then
242/// rounded down to a multiple of two.
243fn resolve_dims(root_w: u16, root_h: u16, s: &RustCaptureSettings) -> (u16, u16) {
244    let cap_x = clamp_offset(s.capture_x, root_w) as i32;
245    let cap_y = clamp_offset(s.capture_y, root_h) as i32;
246    let avail_w = (root_w as i32).saturating_sub(cap_x).max(2);
247    let avail_h = (root_h as i32).saturating_sub(cap_y).max(2);
248    let mut w = if s.auto_adjust_screen_capture_size || s.width <= 0 {
249        avail_w
250    } else {
251        s.width.min(avail_w)
252    };
253    let mut h = if s.auto_adjust_screen_capture_size || s.height <= 0 {
254        avail_h
255    } else {
256        s.height.min(avail_h)
257    };
258    w = w.clamp(2, u16::MAX as i32);
259    h = h.clamp(2, u16::MAX as i32);
260    if s.output_mode == 1 {
261        w &= !1;
262        h &= !1;
263    }
264    (w as u16, h as u16)
265}
266
267/// Rebuild the X channel + capture surfaces after a connection-level failure (Xorg restart,
268/// VT switch), refreshing the offset/dims state against the live root. The pool is drained
269/// first so the encode thread can never be reading a surface as it is destroyed, and its
270/// generation is bumped so the encoder rebuilds against the new layout. Every fallible step
271/// completes before the swap, and the old channel/surfaces are destroyed best-effort after
272/// it (their server is likely gone; the local detaches still run, and the kernel frees
273/// each old segment once nothing maps it).
274#[allow(clippy::too_many_arguments)]
275fn try_rebuild_channel(
276    conn: &mut RustConnection,
277    root: &mut u32,
278    rsettings: &RustCaptureSettings,
279    cap_x: &mut i16,
280    cap_y: &mut i16,
281    cap_w: &mut u16,
282    cap_h: &mut u16,
283    root_w: &mut u16,
284    root_h: &mut u16,
285    pool_n: usize,
286    pool: &FramePool,
287    surfaces: &mut Vec<ShmSurface>,
288    stop: &AtomicBool,
289) -> Result<(), String> {
290    let (new_conn, new_screen) =
291        x11rb::connect(None).map_err(|e| format!("X11 reconnect failed: {e}"))?;
292    require_32bpp(&new_conn, new_screen)?;
293    new_conn
294        .shm_query_version()
295        .map_err(|e| format!("shm_query_version: {e}"))?
296        .reply()
297        .map_err(|e| format!("XShm unavailable on reconnect: {e}"))?;
298    new_conn
299        .xfixes_query_version(5, 0)
300        .map_err(|e| format!("xfixes_query_version: {e}"))?
301        .reply()
302        .map_err(|e| format!("XFixes unavailable on reconnect: {e}"))?;
303    let new_root = new_conn.setup().roots[new_screen].root;
304    let geo = new_conn
305        .get_geometry(new_root)
306        .map_err(|e| format!("get_geometry: {e}"))?
307        .reply()
308        .map_err(|e| format!("get_geometry reply on reconnect: {e}"))?;
309    if !pool.drain_for_resize(pool_n, stop) {
310        return Err("pool drain interrupted by stop during reconnect".to_string());
311    }
312    let (fw, fh) = resolve_dims(geo.width, geo.height, rsettings);
313    let mut fresh: Vec<ShmSurface> = Vec::with_capacity(pool_n);
314    for _ in 0..pool_n {
315        match ShmSurface::create(&new_conn, fw, fh) {
316            Ok(s) => fresh.push(s),
317            Err(e) => {
318                for s in fresh.iter_mut() {
319                    s.destroy(&new_conn);
320                }
321                return Err(e);
322            }
323        }
324    }
325    for s in surfaces.iter_mut() {
326        s.destroy(conn);
327    }
328    *conn = new_conn;
329    *root = new_root;
330    *cap_x = clamp_offset(rsettings.capture_x, geo.width);
331    *cap_y = clamp_offset(rsettings.capture_y, geo.height);
332    *cap_w = fw;
333    *cap_h = fh;
334    *root_w = geo.width;
335    *root_h = geo.height;
336    *surfaces = fresh;
337    pool.bump_generation();
338    Ok(())
339}
340
341/// Grab one frame of the capture region into `surface` with a single XShm round-trip.
342///
343/// One `shm_get_image` request is atomic — the server never interleaves another client
344/// inside it — so the grab is a coherent snapshot of whatever the screen held at that
345/// instant. Every grabbed frame is published; whether any of it changed is decided
346/// downstream by the encoder's own per-stripe damage detection.
347fn grab_frame(
348    conn: &RustConnection,
349    root: u32,
350    surface: &ShmSurface,
351    cap_x: i16,
352    cap_y: i16,
353    cap_w: u16,
354    cap_h: u16,
355) -> Result<(), String> {
356    conn.shm_get_image(
357        root,
358        cap_x,
359        cap_y,
360        cap_w,
361        cap_h,
362        !0u32,
363        ImageFormat::Z_PIXMAP.into(),
364        surface.shmseg,
365        0,
366    )
367    .map_err(|e| format!("shm_get_image: {e}"))?
368    .reply()
369    .map_err(|e| format!("shm_get_image reply: {e}"))?;
370    Ok(())
371}
372
373/// Frame-space top-left of the cursor image, given the XFixes hotspot position.
374///
375/// XFixes reports the cursor position at its HOTSPOT; X draws the image with its top-left at
376/// `(pos - hot)`, and the capture origin `(cap_x, cap_y)` is subtracted to move it into frame space.
377/// The result may go negative near the frame edges, which `overlay_cursor` clips per pixel to match
378/// the server's own edge clipping.
379#[inline]
380pub(crate) fn cursor_image_origin(x: i16, y: i16, xhot: u16, yhot: u16, cap_x: i32, cap_y: i32) -> (i32, i32) {
381    (x as i32 - xhot as i32 - cap_x, y as i32 - yhot as i32 - cap_y)
382}
383
384/// Composite the XFixes cursor (ARGB `u32` per pixel, premultiplied by the XFixes
385/// format definition) onto the BGRA frame with its top-left at `(img_x, img_y)`,
386/// blending each pixel through `blend_pixel_premultiplied` with per-pixel bounds
387/// clipping so an image straddling a frame edge writes only its in-frame portion.
388#[allow(clippy::too_many_arguments)]
389pub(crate) fn overlay_cursor(
390    frame: &mut [u8],
391    stride: usize,
392    frame_w: i32,
393    frame_h: i32,
394    cur_w: i32,
395    cur_h: i32,
396    pixels: &[u32],
397    img_x: i32,
398    img_y: i32,
399) {
400    for y in 0..cur_h {
401        let ty = img_y + y;
402        if ty < 0 || ty >= frame_h {
403            continue;
404        }
405        for x in 0..cur_w {
406            let tx = img_x + x;
407            if tx < 0 || tx >= frame_w {
408                continue;
409            }
410            let px = pixels[(y * cur_w + x) as usize];
411            let a = ((px >> 24) & 0xFF) as u8;
412            let r = ((px >> 16) & 0xFF) as u8;
413            let g = ((px >> 8) & 0xFF) as u8;
414            let b = (px & 0xFF) as u8;
415            let off = ty as usize * stride + tx as usize * 4;
416            blend_pixel_premultiplied(&mut frame[off..off + 4], r, g, b, a);
417        }
418    }
419}
420
421/// A captured raw BGRA frame held in a pooled shm surface, ready to encode.
422///
423/// It carries the surface pointer plus geometry so the encode thread reads the pixels directly (no
424/// copy) and can rebuild its pipeline when the capture size changed (auto-adjust). It also carries
425/// the pool's surface `generation`, so a rebuild is still triggered when the surfaces were recreated
426/// at the SAME size (a resize flap) and the new segments happen to reuse the old virtual addresses.
427struct RawFrame {
428    idx: usize,
429    ptr: *mut u8,
430    len: usize,
431    width: u16,
432    height: u16,
433    stride: usize,
434    generation: u64,
435}
436/// `RawFrame` is `Send`: its raw pointer addresses a pooled shm surface that the pool
437/// guarantees is not reused until the encode thread recycles this frame, so the handle is safe to
438/// move across the capture -> encode thread boundary.
439unsafe impl Send for RawFrame {}
440
441/// Mutex-guarded interior of `FramePool`: the free-surface index list and the single
442/// capture -> encode handoff slot, under one lock so moving a surface between them — acquire,
443/// publish, take, recycle — is always a single atomic step.
444struct PoolInner {
445    free: Vec<usize>,
446    slot: Option<RawFrame>,
447}
448
449/// Demand-driven capture -> encode handoff: a bounded single-slot channel over a fixed set of
450/// pooled shm surfaces.
451///
452/// The capture thread writes into a pooled surface and `publish`es it into the single `slot`; the
453/// encode thread `take`s it. Capture stays at most one frame ahead of encode because `acquire` and
454/// `publish` BLOCK (bounded) until the encoder frees a surface / drains the slot, throttling capture
455/// to the encode rate. Since X11 capture is pull-based (no backlog), throttling — rather than
456/// capturing-then-dropping — means a full-resolution shm round-trip is never spent on a frame that
457/// would be discarded, while the next capture still overlaps the current encode (the throughput win).
458/// The encoder therefore only ever sees a contiguous frame stream, keeping the H.264 reference chain
459/// valid.
460///
461/// `generation` is bumped by the capture thread each time the backing shm surfaces are destroyed and
462/// recreated. Published frames carry it so the encode thread rebuilds its pipeline — dropping encoder
463/// state keyed to surface base pointers (e.g. NVENC's pinned-host registrations) — even when a resize
464/// flap lands back on the old dimensions and the recreated segments reuse the old virtual addresses.
465struct FramePool {
466    inner: Mutex<PoolInner>,
467    cv: Condvar,
468    stop: AtomicBool,
469    generation: AtomicU64,
470}
471
472impl FramePool {
473    /// Create a pool with `n` free surfaces, an empty handoff slot, and generation 0.
474    fn new(n: usize) -> Self {
475        Self {
476            inner: Mutex::new(PoolInner { free: (0..n).collect(), slot: None }),
477            cv: Condvar::new(),
478            stop: AtomicBool::new(false),
479            generation: AtomicU64::new(0),
480        }
481    }
482
483    /// Capture: record that the surfaces were recreated by advancing the generation. Only
484    /// called after `drain_for_resize` succeeded, so no frame from the previous generation is still
485    /// in flight.
486    fn bump_generation(&self) {
487        self.generation.fetch_add(1, Ordering::Relaxed);
488    }
489
490    /// Read the current surface generation, stamped onto each published frame.
491    fn generation(&self) -> u64 {
492        self.generation.load(Ordering::Relaxed)
493    }
494
495    /// Capture: claim a free surface to write the next frame into, or `None` on stop.
496    ///
497    /// Blocks (bounded, re-checking `stop` every 20ms) until a surface is free, which throttles
498    /// capture to the encode rate so a full-resolution capture is never spent on a frame that would
499    /// be dropped. The bounded re-check means a stop that races the wait cannot hang the thread.
500    fn acquire(&self, stop: &AtomicBool) -> Option<usize> {
501        let mut g = self.inner.lock().unwrap();
502        loop {
503            if let Some(idx) = g.free.pop() {
504                return Some(idx);
505            }
506            if stop.load(Ordering::Relaxed) {
507                return None;
508            }
509            let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
510            g = gg;
511        }
512    }
513
514    /// Capture: publish the just-captured frame into the single slot, or `false` (frame
515    /// discarded) on stop.
516    ///
517    /// Blocks (bounded, re-checking `stop` every 20ms) until the encode thread has taken the previous
518    /// frame, so capture stays at most one frame ahead and never drops under normal flow.
519    fn publish(&self, frame: RawFrame, stop: &AtomicBool) -> bool {
520        let mut g = self.inner.lock().unwrap();
521        loop {
522            if g.slot.is_none() {
523                g.slot = Some(frame);
524                drop(g);
525                self.cv.notify_all();
526                return true;
527            }
528            if stop.load(Ordering::Relaxed) {
529                return false;
530            }
531            let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
532            g = gg;
533        }
534    }
535
536    /// Encode: block until a frame is available (`Some`) or stop is signalled (`None`).
537    ///
538    /// The wait is bounded (re-checking `stop` every 20ms) as defense-in-depth against a lost wakeup,
539    /// so a stop that races the park can never leave the encode thread blocked forever.
540    fn take(&self) -> Option<RawFrame> {
541        let mut g = self.inner.lock().unwrap();
542        loop {
543            if let Some(f) = g.slot.take() {
544                return Some(f);
545            }
546            if self.stop.load(Ordering::Acquire) {
547                return None;
548            }
549            let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
550            g = gg;
551        }
552    }
553
554    /// Encode: return a surface to the free list after it has been encoded, waking any waiter
555    /// parked in `acquire` / `publish` / `drain_for_resize`.
556    fn recycle(&self, idx: usize) {
557        self.inner.lock().unwrap().free.push(idx);
558        self.cv.notify_all();
559    }
560
561    /// Capture: before recreating surfaces (auto-adjust / region resize), reclaim the pending
562    /// slot and wait until every surface is back in the free list, so no surface is destroyed while
563    /// the encode thread is still reading it.
564    ///
565    /// Reclaiming the slot returns any un-taken frame to the free list; the loop then waits until
566    /// `free.len() == n`, i.e. the encode thread has finished any in-flight frame. The wait is bounded
567    /// and also breaks on stop — pool shutdown OR the external `stop` — so a panicked or dead encode
568    /// thread that never recycles cannot wedge the capture thread here forever; a requested stop
569    /// unblocks it. Returns `true` if it fully drained (safe to recreate surfaces), or `false` if it
570    /// aborted on stop — in which case the caller tears down, which joins the encode thread before
571    /// destroying surfaces, so the resize-safety guarantee still holds.
572    fn drain_for_resize(&self, n: usize, stop: &AtomicBool) -> bool {
573        let mut g = self.inner.lock().unwrap();
574        if let Some(old) = g.slot.take() {
575            g.free.push(old.idx);
576        }
577        while g.free.len() < n {
578            if self.stop.load(Ordering::Acquire) || stop.load(Ordering::Relaxed) {
579                return false;
580            }
581            let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
582            g = gg;
583        }
584        true
585    }
586
587    /// Signal every waiter to stop and wake them, ending the encode thread's `take` loop.
588    ///
589    /// The inner mutex is acquired BEFORE storing `stop` and notifying, which closes the lost-wakeup
590    /// window: with the lock held, `take` is either still before its `stop` check or already parked on
591    /// the condvar (and will receive the notify), never in the gap between the two. The notify is
592    /// issued after the guard is dropped so the woken thread does not immediately re-block on the lock
593    /// this call holds.
594    fn shutdown(&self) {
595        let g = self.inner.lock().unwrap();
596        self.stop.store(true, Ordering::Release);
597        drop(g);
598        self.cv.notify_all();
599    }
600}
601
602/// Encode thread body: consume captured frames from the pool, keep the [`pipeline::X11Pipeline`] in
603/// step with the capture size and cross-thread controls, encode, recycle, and deliver.
604///
605/// The loop runs until `FramePool::take` returns `None` (stop). For each frame:
606///
607/// 1. **(Re)build the pipeline** when it is missing, the frame size changed, or the surface
608///    generation changed. A generation change alone forces the rebuild path because recreated shm
609///    segments often reuse the old virtual base addresses, so encoder state keyed to base pointers
610///    (NVENC's pinned-host cache) must be dropped even at identical dimensions. An in-place
611///    [`X11Pipeline::reshape`] is preferred — NVENC reconfigures its live session and the striped
612///    software path just re-derives stripe state — so a resize never stalls on a full encoder
613///    re-init; encoders that cannot follow in place (VAAPI) report `false` and the pipeline
614///    is rebuilt. On a rebuild the old pipeline (with its GPU session/surfaces) is dropped BEFORE the
615///    new one is built, so an auto-adjust resize never holds two full encoder allocations at once
616///    (transient 2x GPU memory). The rebuilt settings pull the CURRENT bitrate / VBV / fps from the
617///    controls atomics, carrying live rate changes forward instead of reverting to the capture-start
618///    values.
619/// 2. **Apply cross-thread controls** here, on the thread that owns the pipeline: a pending
620///    `force_idr` requests a keyframe; a `rate_dirty` swap (Acquire, pairing with the setters' Release
621///    stores so the bitrate / VBV / fps payload is never seen half-applied) pushes a live rate change;
622///    a `tunables_dirty` swap applies per-frame tunables into both the local settings copy and the
623///    pipeline.
624/// 3. **Encode and hand off**: read the pooled surface directly through its raw pointer — sound
625///    because the pool guarantees the surface is not reused until it is recycled — run
626///    [`X11Pipeline::process`], recycle the surface BEFORE delivering so a slow consumer never holds a
627///    capture surface, then deliver any encoded stripes through `on_frame`.
628///
629/// The optional Unix-socket recording sink (parity with the Wayland path) is bound ONCE here and
630/// owned outside the pipeline, so pipeline rebuilds on resize keep the socket listener and any
631/// attached recorders alive. It can only carry a single full-frame H.264 stream, so configurations
632/// that cannot produce one (JPEG output, or a striped CPU encoder) are warned about up front.
633fn encode_loop<F>(pool: &FramePool, controls: &Controls, settings: &RustCaptureSettings, on_frame: &mut F)
634where
635    F: FnMut(Vec<EncodedStripe>),
636{
637    let mut psettings = settings.clone();
638    let recording_sink = RecordingSink::try_bind(&settings.recording_socket);
639    if recording_sink.is_some() {
640        if settings.output_mode == 0 {
641            eprintln!("[recording_sink] recording_socket set but output_mode is JPEG; no recordable H.264 stream.");
642        } else if settings.use_cpu && !settings.video_fullframe {
643            eprintln!("[recording_sink] recording_socket set but the CPU encoder is striped; set video_fullframe=true for a recordable stream.");
644        }
645    }
646    let mut pipeline: Option<X11Pipeline> = None;
647    let (mut pw, mut ph) = (0i32, 0i32);
648    let mut pgen = 0u64;
649    let mut last_log_time = Instant::now();
650    let mut frame_count: u64 = 0;
651    let mut stripe_count: u64 = 0;
652
653    while let Some(frame) = pool.take() {
654        let (fw, fh) = (frame.width as i32, frame.height as i32);
655        if pipeline.is_none() || pw != fw || ph != fh || pgen != frame.generation {
656            let size_changed = pw != fw || ph != fh;
657            psettings.width = fw;
658            psettings.height = fh;
659            psettings.video_bitrate_kbps = controls.bitrate_kbps.load(Ordering::Relaxed);
660            psettings.video_vbv_multiplier =
661                controls.vbv_mult_milli.load(Ordering::Relaxed) as f64 / 1000.0;
662            psettings.target_fps =
663                (controls.fps_milli.load(Ordering::Relaxed).max(1) as f64) / 1000.0;
664            let reshaped = pipeline
665                .as_mut()
666                .is_some_and(|pl| pl.reshape(&psettings, size_changed));
667            if !reshaped {
668                drop(pipeline.take());
669                pipeline = Some(X11Pipeline::new(psettings.clone()));
670                if let Some(pl) = &pipeline {
671                    let mut log_msg = format!(
672                        "[x11] Stream settings active -> Res: {}x{} | FPS: {:.1} | Encoder: {}",
673                        psettings.width, psettings.height, psettings.target_fps, pl.encoder_name()
674                    );
675                    if psettings.output_mode == 1 && pl.encoder_name() == "CPU" {
676                        log_msg.push_str(&format!(" ({})", crate::encoders::SOFTWARE_H264_ENCODER));
677                    }
678                    if psettings.output_mode == 0 {
679                        log_msg.push_str(&format!(" | Mode: JPEG | Quality: {}", psettings.jpeg_quality));
680                    } else {
681                        if psettings.video_cbr_mode {
682                            log_msg.push_str(&format!(" | Mode: H264 CBR {}", psettings.video_bitrate_kbps));
683                        } else {
684                            log_msg.push_str(&format!(" | Mode: H264 | CRF: {}", psettings.video_crf));
685                            if psettings.video_bitrate_kbps > 0 {
686                                log_msg.push_str(&format!(" | VBV: {} kbps", psettings.video_bitrate_kbps));
687                            }
688                        }
689                        log_msg.push_str(&format!(" | Colorspace: {}", pl.colorspace_desc()));
690                    }
691                    log_msg.push_str(&format!(
692                        " | Damage Thresh: {}f | Damage Dur: {}f",
693                        psettings.damage_block_threshold, psettings.damage_block_duration
694                    ));
695                    println!("{}", log_msg);
696                }
697            }
698            pw = fw;
699            ph = fh;
700            pgen = frame.generation;
701        }
702        let pl = pipeline.as_mut().unwrap();
703
704        // A recorder connecting to the socket sink needs a fresh decode entry point; it is
705        // folded into the same standard request-IDR path as a client-driven force_idr.
706        let sink_idr = recording_sink
707            .as_ref()
708            .map(|s| s.should_force_idr())
709            .unwrap_or(false);
710        if controls.force_idr.swap(false, Ordering::Relaxed) || sink_idr {
711            pl.request_idr();
712        }
713        if controls.rate_dirty.swap(false, Ordering::Acquire) {
714            let b = controls.bitrate_kbps.load(Ordering::Relaxed);
715            let v = controls.vbv_mult_milli.load(Ordering::Relaxed) as f64 / 1000.0;
716            let fps = (controls.fps_milli.load(Ordering::Relaxed).max(1) as f64) / 1000.0;
717            pl.update_rate(b, v, fps);
718        }
719        if controls.tunables_dirty.swap(false, Ordering::Acquire)
720            && let Some(t) = controls.tunables.lock().unwrap().take() {
721                t.apply_to(&mut psettings);
722                pl.update_tunables(&t);
723            }
724
725        let buf = unsafe { std::slice::from_raw_parts(frame.ptr, frame.len) };
726        let stripes = pl.process(buf, frame.stride);
727        pool.recycle(frame.idx);
728        if !stripes.is_empty() {
729            frame_count += 1;
730            stripe_count += stripes.len() as u64;
731            // IDR arming is consumed inside X11Pipeline::process; this tap only fans out.
732            if let Some(ref socket) = recording_sink {
733                socket.write_frame(&stripes, psettings.height);
734            }
735            on_frame(stripes);
736        }
737
738        let now = Instant::now();
739        let elapsed = now.duration_since(last_log_time).as_secs_f64();
740        if elapsed >= 1.0 {
741            if settings.debug_logging {
742                let actual_fps = frame_count as f64 / elapsed;
743                let stripes_per_sec = stripe_count as f64 / elapsed;
744                println!(
745                    "[x11] Res: {}x{} Encoder: {} EncFPS: {:.2} EncStripes/s: {:.2}",
746                    psettings.width, psettings.height, pl.encoder_name(), actual_fps, stripes_per_sec
747                );
748            }
749            frame_count = 0;
750            stripe_count = 0;
751            last_log_time = now;
752        }
753    }
754}
755
756/// Run the X11 capture pipeline until `stop` is set, splitting capture and encode across two
757/// threads that overlap for throughput.
758///
759/// This (the caller's) thread performs setup and then the capture loop; a spawned encode thread runs
760/// `encode_loop` and invokes `on_frame(stripes)` once per encoded frame. The split lets capture and
761/// encode overlap, and because frames are throttled/dropped as RAW frames before encode, the delivered
762/// H.264 stays a valid contiguous reference chain.
763///
764/// 1. **Setup**: connect to X (a private connection) and require a 32-bpp BGRA root — the Z-pixmap
765///    byte depth must be 4, which modern servers use for depth 24/32 — then negotiate XShm and
766///    XFixes. XFixes is always negotiated (one round-trip) so the cursor overlay can be toggled on
767///    live even when capture started without it. Initial dimensions and origin are resolved from the
768///    settings and the live root geometry, and a `FramePool` of `POOL_N` = 3 shm surfaces is
769///    allocated — the working set (one in-capture, one in-slot, one in-encode) that a one-frame-ahead
770///    demand-driven capture needs, which also keeps the memory cost (`3 * W*H*4`, significant at 4K)
771///    bounded.
772/// 2. **Encode thread**: spawned with a raised scheduling priority; it reports its thread id back
773///    through `encode_tid_tx` so the caller can detect a re-entrant stop issued from inside the
774///    delivery callback.
775/// 3. **Capture loop** (until `stop`): fps is re-read each iteration for live pacing — sleep to the
776///    next frame deadline, or `yield_now` when already behind instead of busy-spinning.
777///    - **Live region change** (`region_dirty`): re-target the grab origin immediately (an x/y pan
778///      needs no surface work); a size change reuses the drain/recreate path below.
779///    - **Auto-adjust**: on a root geometry change, drain in-flight frames then recreate the surfaces
780///      and bump the generation (which the encode thread turns into a reshape or rebuild). The geometry
781///      is re-resolved AFTER the drain, because a slow drain can outlast another geometry change (a
782///      fast flap) and recreating at a stale size would make the next `shm_get_image` exceed the root;
783///      a fully-reverted flap keeps its surfaces as-is. A stop that races the drain breaks out to
784///      teardown.
785///    - **Grab**: acquire a pooled surface (blocks until the encoder frees one), then `shm_get_image`
786///      the region into it synchronously (`reply()` waits).
787///    - **Overlays**: composite the XFixes cursor (drawn at its hotspot-offset origin; live-toggleable)
788///      and the watermark onto the BGRA pixels on the CPU.
789///    - **Publish**: hand the finished frame to the encode thread (blocks until the slot is free;
790///      never drops). A stop observed while waiting in acquire/publish exits the loop.
791/// 4. **Teardown**: stop and JOIN the encode thread BEFORE destroying the shm surfaces it may still be
792///    reading, preserving the resize-safety guarantee.
793///
794/// Blocking; intended to run on a dedicated thread. The X connection and shm surfaces live on this
795/// thread and the encoder lives on the encode thread — nothing X-related crosses the boundary.
796pub fn run_capture<F>(
797    settings: RustCaptureSettings,
798    controls: Arc<Controls>,
799    encode_tid_tx: Sender<thread::ThreadId>,
800    on_frame: F,
801) -> Result<(), String>
802where
803    F: FnMut(Vec<EncodedStripe>) + Send + 'static,
804{
805    let (mut conn, screen_num) =
806        x11rb::connect(None).map_err(|e| format!("X11 connect failed: {e}"))?;
807    let mut root = conn.setup().roots[screen_num].root;
808    require_32bpp(&conn, screen_num)?;
809
810    conn.shm_query_version()
811        .map_err(|e| format!("shm_query_version: {e}"))?
812        .reply()
813        .map_err(|e| format!("XShm unavailable: {e}"))?;
814    conn.xfixes_query_version(5, 0)
815        .map_err(|e| format!("xfixes_query_version: {e}"))?
816        .reply()
817        .map_err(|e| format!("XFixes unavailable: {e}"))?;
818
819    let geo = conn
820        .get_geometry(root)
821        .map_err(|e| format!("get_geometry: {e}"))?
822        .reply()
823        .map_err(|e| format!("get_geometry reply: {e}"))?;
824
825    let mut rsettings = settings.clone();
826    let (mut cap_w, mut cap_h) = resolve_dims(geo.width, geo.height, &rsettings);
827    let mut cap_x = clamp_offset(rsettings.capture_x, geo.width);
828    let mut cap_y = clamp_offset(rsettings.capture_y, geo.height);
829    // Last root size seen. A live pan has to clamp against something even when the geometry
830    // round-trip fails, or the requested origin would be dropped instead of applied.
831    let (mut root_w, mut root_h) = (geo.width, geo.height);
832
833    const POOL_N: usize = 3;
834    let mut surfaces: Vec<ShmSurface> = Vec::with_capacity(POOL_N);
835    for _ in 0..POOL_N {
836        surfaces.push(ShmSurface::create(&conn, cap_w, cap_h)?);
837    }
838    // Consecutive channel rebuilds without one good grab in between: geometry
839    // errors can burn out the grab budget repeatedly under a dead region, so
840    // recovery only gets a bounded number of channel rebuilds.
841    let mut reconnect_streak = 0u32;
842    let pool = Arc::new(FramePool::new(POOL_N));
843
844    let mut watermark = crate::encoders::overlay::OverlayState::default();
845    if !settings.watermark_path.is_empty() {
846        watermark.load_watermark(&settings.watermark_path, 1.0);
847    }
848
849    let enc_pool = pool.clone();
850    let enc_controls = controls.clone();
851    let enc_settings = settings.clone();
852    let encode_panicked = Arc::new(AtomicBool::new(false));
853    let guard_panicked = encode_panicked.clone();
854    let encode_thread = thread::spawn(move || {
855        crate::boost_thread_priority(-10);
856        let _ = encode_tid_tx.send(thread::current().id());
857        let mut on_frame = on_frame;
858        // An encoder panic must never wedge the capture: without the pool shutdown
859        // the capture thread would block in publish forever while is_capturing still
860        // reports true on this thread's handle. Shutting the pool unblocks it through
861        // the same path a clean stop takes, so the panic is also recorded — otherwise
862        // the capture would return Ok and Python could not tell the two apart.
863        let guard_pool = enc_pool.clone();
864        let guard_controls = enc_controls.clone();
865        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
866            encode_loop(&enc_pool, &enc_controls, &enc_settings, &mut on_frame);
867        }));
868        if result.is_err() {
869            eprintln!("[pixelflux x11] encode thread panicked; shutting the pool to fail the capture");
870            guard_panicked.store(true, Ordering::Release);
871            guard_pool.shutdown();
872            guard_controls.stop.store(true, Ordering::Relaxed);
873        }
874    });
875
876    let mut next_frame = Instant::now();
877    // Frames between root-geometry polls (auto-adjust); ~0.5s at 60fps.
878    const GEOMETRY_POLL_FRAMES: i32 = 30;
879    let mut geometry_check = 0i32;
880    let mut grab_failures = 0u32;
881
882    let result = (|| -> Result<(), String> {
883        while !controls.stop.load(Ordering::Relaxed) {
884            let fps = (controls.fps_milli.load(Ordering::Relaxed).max(1) as f64) / 1000.0;
885            let frame_dur = Duration::from_secs_f64(1.0 / fps.max(1.0));
886            let now = Instant::now();
887            if now < next_frame {
888                std::thread::sleep(next_frame - now);
889            } else {
890                std::thread::yield_now();
891            }
892            next_frame += frame_dur;
893            let now = Instant::now();
894            if next_frame < now {
895                next_frame = now;
896            }
897            if controls.stop.load(Ordering::Relaxed) {
898                break;
899            }
900
901            if controls.region_dirty.swap(false, Ordering::Acquire) {
902                let (nx, ny, nw, nh) = *controls.region.lock().unwrap();
903                rsettings.capture_x = nx;
904                rsettings.capture_y = ny;
905                rsettings.width = nw;
906                rsettings.height = nh;
907                // An explicit live size pins the region: auto-adjust (from the start
908                // settings) would glue resolve_dims back to the ROOT size, undoing the
909                // re-target on the next geometry poll. w/h <= 0 keep following the root.
910                rsettings.auto_adjust_screen_capture_size = nw <= 0 || nh <= 0;
911                cap_x = clamp_offset(nx, root_w);
912                cap_y = clamp_offset(ny, root_h);
913                if let Some(g) = conn.get_geometry(root).ok().and_then(|c| c.reply().ok()) {
914                    root_w = g.width;
915                    root_h = g.height;
916                    cap_x = clamp_offset(nx, root_w);
917                    cap_y = clamp_offset(ny, root_h);
918                    let (fw, fh) = resolve_dims(g.width, g.height, &rsettings);
919                    if fw != cap_w || fh != cap_h {
920                        if !pool.drain_for_resize(POOL_N, &controls.stop) {
921                            break;
922                        }
923                        for s in surfaces.iter_mut() {
924                            s.destroy(&conn);
925                        }
926                        surfaces.clear();
927                        cap_w = fw;
928                        cap_h = fh;
929                        for _ in 0..POOL_N {
930                            surfaces.push(ShmSurface::create(&conn, cap_w, cap_h)?);
931                        }
932                        pool.bump_generation();
933                    }
934                }
935            }
936
937            // Root geometry is polled on a cadence, not per frame: the reply costs a
938            // round-trip that would otherwise precede every grab, and external size
939            // changes are rare (live resizes arrive through region_dirty instead).
940            // The gate reads the live copy, so a region re-targeted to the root edge
941            // starts following it from that point on. Grab-failure recovery enters
942            // regardless of auto_adjust: a fixed-size region has no other re-clamp
943            // path after an external root shrink, and without one the capture dies at
944            // the failure cap instead of recovering.
945            geometry_check = geometry_check.saturating_sub(1);
946            if (rsettings.auto_adjust_screen_capture_size || grab_failures > 0)
947                && geometry_check <= 0
948            {
949                geometry_check = GEOMETRY_POLL_FRAMES;
950                if let Some(g) = conn.get_geometry(root).ok().and_then(|c| c.reply().ok()) {
951                    // A root shrink can strand even a previously-valid offset past
952                    // the new edge; re-clamp it against the live root.
953                    root_w = g.width;
954                    root_h = g.height;
955                    cap_x = clamp_offset(rsettings.capture_x, root_w);
956                    cap_y = clamp_offset(rsettings.capture_y, root_h);
957                    let (nw, nh) = resolve_dims(g.width, g.height, &rsettings);
958                    if nw != cap_w || nh != cap_h {
959                        if !pool.drain_for_resize(POOL_N, &controls.stop) {
960                            break;
961                        }
962                        let (fw, fh) = conn
963                            .get_geometry(root)
964                            .ok()
965                            .and_then(|c| c.reply().ok())
966                            .map(|g| resolve_dims(g.width, g.height, &rsettings))
967                            .unwrap_or((nw, nh));
968                        if fw != cap_w || fh != cap_h {
969                            for s in surfaces.iter_mut() {
970                                s.destroy(&conn);
971                            }
972                            surfaces.clear();
973                            cap_w = fw;
974                            cap_h = fh;
975                            for _ in 0..POOL_N {
976                                surfaces.push(ShmSurface::create(&conn, cap_w, cap_h)?);
977                            }
978                            pool.bump_generation();
979                        }
980                    }
981                }
982            }
983
984            let idx = match pool.acquire(&controls.stop) {
985                Some(i) => i,
986                None => break,
987            };
988            let grab_result = {
989                let surface = &mut surfaces[idx];
990                grab_frame(&conn, root, surface, cap_x, cap_y, cap_w, cap_h)
991            };
992            if let Err(e) = grab_result {
993                // Most likely an external root resize between geometry polls made
994                // the grab run past the root edge: return the surface, re-poll
995                // geometry immediately, and only give up if it never recovers.
996                pool.recycle(idx);
997                grab_failures += 1;
998                geometry_check = 0;
999                if grab_failures > 120 {
1000                    // A burn-out usually means the X connection itself is gone
1001                    // (Xorg restart, VT switch): every request fails until the
1002                    // server is back. Rebuild the channel a few times before
1003                    // declaring the capture dead — a recovered server resumes the
1004                    // stream where a dead thread would leave a black screen until
1005                    // the Python watchdog restarts everything.
1006                    let mut recovered = false;
1007                    for _attempt in 0..5 {
1008                        if controls.stop.load(Ordering::Relaxed) {
1009                            break;
1010                        }
1011                        std::thread::sleep(Duration::from_secs(1));
1012                        match try_rebuild_channel(
1013                            &mut conn, &mut root, &rsettings,
1014                            &mut cap_x, &mut cap_y, &mut cap_w, &mut cap_h,
1015                            &mut root_w, &mut root_h,
1016                            POOL_N, &pool, &mut surfaces, &controls.stop,
1017                        ) {
1018                            Ok(()) => { recovered = true; break; }
1019                            Err(re) => {
1020                                eprintln!("[pixelflux x11] reconnect attempt failed: {re}");
1021                            }
1022                        }
1023                    }
1024                    if !recovered {
1025                        return Err(format!("capture could not recover: {e}"));
1026                    }
1027                    grab_failures = 0;
1028                    geometry_check = 0;
1029                    reconnect_streak += 1;
1030                    if reconnect_streak >= 4 {
1031                        return Err("capture recovered its channel but the region never grabbed again".to_string());
1032                    }
1033                }
1034                continue;
1035            }
1036            grab_failures = 0;
1037            reconnect_streak = 0;
1038            let surface = &mut surfaces[idx];
1039
1040            let frame_w = cap_w as i32;
1041            let frame_h = cap_h as i32;
1042            let stride = surface.stride;
1043            let buf = surface.as_mut_slice();
1044
1045            if controls.capture_cursor.load(Ordering::Relaxed)
1046                && let Some(c) = conn
1047                    .xfixes_get_cursor_image()
1048                    .ok()
1049                    .and_then(|c| c.reply().ok())
1050                    && c.width > 0 && c.height > 0 {
1051                        // Translate from root coordinates using the LIVE grab origin
1052                        // (cap_x/cap_y follow region_dirty pans and clamp negatives
1053                        // exactly like the grab itself), never the immutable startup
1054                        // settings — those go stale on the first live region move.
1055                        let (img_x, img_y) = cursor_image_origin(
1056                            c.x,
1057                            c.y,
1058                            c.xhot,
1059                            c.yhot,
1060                            cap_x as i32,
1061                            cap_y as i32,
1062                        );
1063                        overlay_cursor(
1064                            buf,
1065                            stride,
1066                            frame_w,
1067                            frame_h,
1068                            c.width as i32,
1069                            c.height as i32,
1070                            &c.cursor_image,
1071                            img_x,
1072                            img_y,
1073                        );
1074                    }
1075
1076            if watermark.is_active() {
1077                watermark.update_position(frame_w, frame_h, settings.watermark_location_enum);
1078                watermark.blend_bgra(buf, stride, frame_w, frame_h);
1079            }
1080
1081            let published = pool.publish(
1082                RawFrame {
1083                    idx,
1084                    ptr: surface.addr,
1085                    len: surface.size,
1086                    width: cap_w,
1087                    height: cap_h,
1088                    stride,
1089                    generation: pool.generation(),
1090                },
1091                &controls.stop,
1092            );
1093            if !published {
1094                break;
1095            }
1096        }
1097        Ok(())
1098    })();
1099
1100    pool.shutdown();
1101    let _ = encode_thread.join();
1102    for s in surfaces.iter_mut() {
1103        s.destroy(&conn);
1104    }
1105    match result {
1106        // A real capture error is the more specific diagnosis, so it outranks the panic flag.
1107        Err(e) => Err(e),
1108        Ok(()) if encode_panicked.load(Ordering::Acquire) => Err("encode thread panicked".into()),
1109        Ok(()) => Ok(()),
1110    }
1111}
1112
1113#[cfg(test)]
1114mod pool_tests {
1115    use super::*;
1116
1117    fn dummy(idx: usize) -> RawFrame {
1118        RawFrame {
1119            idx,
1120            ptr: std::ptr::null_mut(),
1121            len: 0,
1122            width: 0,
1123            height: 0,
1124            stride: 0,
1125            generation: 0,
1126        }
1127    }
1128
1129    /// A full acquire -> publish -> take -> recycle round-trip returns the same surface, and
1130    /// afterwards all three pool surfaces are acquirable again and distinct.
1131    #[test]
1132    fn roundtrip_then_recycle_returns_all_surfaces() {
1133        let p = FramePool::new(3);
1134        let stop = AtomicBool::new(false);
1135        let a = p.acquire(&stop).unwrap();
1136        assert!(p.publish(dummy(a), &stop));
1137        let f = p.take().unwrap();
1138        assert_eq!(f.idx, a);
1139        p.recycle(f.idx);
1140        let (x, y, z) = (
1141            p.acquire(&stop).unwrap(),
1142            p.acquire(&stop).unwrap(),
1143            p.acquire(&stop).unwrap(),
1144        );
1145        assert!(x != y && y != z && x != z);
1146    }
1147
1148    /// With every surface held and `stop` set, `acquire` returns `None` after its bounded wait
1149    /// rather than blocking forever.
1150    #[test]
1151    fn acquire_returns_none_when_exhausted_and_stopped() {
1152        let p = FramePool::new(2);
1153        let stop = AtomicBool::new(false);
1154        let _a = p.acquire(&stop).unwrap();
1155        let _b = p.acquire(&stop).unwrap();
1156        stop.store(true, Ordering::Relaxed);
1157        assert!(p.acquire(&stop).is_none());
1158    }
1159
1160    /// With the handoff slot already occupied and `stop` set, `publish` returns `false` (frame
1161    /// discarded) instead of blocking.
1162    #[test]
1163    fn publish_returns_false_when_slot_full_and_stopped() {
1164        let p = FramePool::new(3);
1165        let stop = AtomicBool::new(false);
1166        let a = p.acquire(&stop).unwrap();
1167        assert!(p.publish(dummy(a), &stop));
1168        let b = p.acquire(&stop).unwrap();
1169        stop.store(true, Ordering::Relaxed);
1170        assert!(!p.publish(dummy(b), &stop));
1171    }
1172
1173    /// `drain_for_resize` blocks until every held surface has been recycled (`free == n`), then
1174    /// reports a full drain and leaves the pool whole; a helper thread recycles the three held surfaces
1175    /// shortly after the drain begins.
1176    #[test]
1177    fn drain_for_resize_waits_until_all_free() {
1178        let p = Arc::new(FramePool::new(3));
1179        let stop = AtomicBool::new(false);
1180        let held = [
1181            p.acquire(&stop).unwrap(),
1182            p.acquire(&stop).unwrap(),
1183            p.acquire(&stop).unwrap(),
1184        ];
1185        let p2 = p.clone();
1186        let t = thread::spawn(move || {
1187            thread::sleep(Duration::from_millis(30));
1188            for idx in held {
1189                p2.recycle(idx);
1190            }
1191        });
1192        assert!(p.drain_for_resize(3, &stop));
1193        t.join().unwrap();
1194        assert!(p.acquire(&stop).is_some());
1195    }
1196
1197    /// A surface is held and never recycled (as a dead encode thread would leave it): setting
1198    /// `stop` unblocks the bounded drain, which reports it did NOT fully drain instead of hanging.
1199    #[test]
1200    fn drain_for_resize_aborts_on_stop() {
1201        let p = FramePool::new(3);
1202        let stop = AtomicBool::new(false);
1203        let _held = p.acquire(&stop).unwrap();
1204        stop.store(true, Ordering::Relaxed);
1205        assert!(!p.drain_for_resize(3, &stop));
1206    }
1207
1208    /// A W1 -> W2 -> W1 resize flap where the encoder never takes the W2 frame:
1209    /// `drain_for_resize` reclaims the un-taken frame from the slot, the surfaces recreate twice, and
1210    /// the next taken frame lands back on the ORIGINAL dimensions — so the bumped generation is the
1211    /// only rebuild signal that invalidates encoder state keyed to the reused surface addresses.
1212    #[test]
1213    fn resize_flap_reclaim_changes_generation_at_identical_dims() {
1214        let p = FramePool::new(3);
1215        let stop = AtomicBool::new(false);
1216        let a = p.acquire(&stop).unwrap();
1217        assert!(p.publish(
1218            RawFrame { generation: p.generation(), width: 1920, height: 1080, ..dummy(a) },
1219            &stop
1220        ));
1221        let f = p.take().unwrap();
1222        let (last_gen, last_dims) = (f.generation, (f.width, f.height));
1223        p.recycle(f.idx);
1224        assert!(p.drain_for_resize(3, &stop));
1225        p.bump_generation();
1226        let b = p.acquire(&stop).unwrap();
1227        assert!(p.publish(
1228            RawFrame { generation: p.generation(), width: 2560, height: 1600, ..dummy(b) },
1229            &stop
1230        ));
1231        assert!(p.drain_for_resize(3, &stop));
1232        p.bump_generation();
1233        let c = p.acquire(&stop).unwrap();
1234        assert!(p.publish(
1235            RawFrame { generation: p.generation(), width: 1920, height: 1080, ..dummy(c) },
1236            &stop
1237        ));
1238        let g = p.take().unwrap();
1239        assert_eq!((g.width, g.height), last_dims, "flap lands on identical dimensions");
1240        assert_eq!(g.generation, 2);
1241        assert_ne!(g.generation, last_gen, "generation is the only rebuild signal");
1242    }
1243
1244    /// Recreating the surfaces bumps the generation, and frames published afterwards carry the
1245    /// new value — the signal the encode thread uses to trigger a rebuild at identical dimensions.
1246    #[test]
1247    fn generation_bumps_on_recreate_and_rides_published_frames() {
1248        let p = FramePool::new(3);
1249        let stop = AtomicBool::new(false);
1250        assert_eq!(p.generation(), 0);
1251        let a = p.acquire(&stop).unwrap();
1252        assert!(p.publish(RawFrame { generation: p.generation(), ..dummy(a) }, &stop));
1253        let f = p.take().unwrap();
1254        assert_eq!(f.generation, 0);
1255        p.recycle(f.idx);
1256        p.bump_generation();
1257        let b = p.acquire(&stop).unwrap();
1258        assert!(p.publish(RawFrame { generation: p.generation(), ..dummy(b) }, &stop));
1259        assert_eq!(p.take().unwrap().generation, 1);
1260    }
1261}
1262
1263#[cfg(test)]
1264mod cursor_tests {
1265    use super::*;
1266
1267    /// `cursor_image_origin` subtracts both the hotspot and the capture-region offset from the
1268    /// reported pointer position, and goes negative when the hotspot sits near the frame origin (the
1269    /// result is clipped when drawn).
1270    #[test]
1271    fn origin_subtracts_hotspot_and_capture_offset() {
1272        assert_eq!(cursor_image_origin(100, 80, 4, 6, 0, 0), (96, 74));
1273        assert_eq!(cursor_image_origin(100, 80, 4, 6, 10, 20), (86, 54));
1274        assert_eq!(cursor_image_origin(1, 1, 8, 8, 0, 0), (-7, -7));
1275    }
1276
1277    /// `overlay_cursor` blits at the hotspot-offset origin, not the raw pointer position: a
1278    /// 2x2 cursor with hotspot (1,1) at pointer (4,4) lands at (3,3)..(4,4), leaving the un-offset
1279    /// (hotspot) corner untouched.
1280    #[test]
1281    fn overlay_blits_at_hotspot_offset_origin() {
1282        let stride = 8 * 4;
1283        let mut frame = vec![0u8; stride * 8];
1284        let pixels = [0xFFFF_FFFFu32; 4];
1285        let (ox, oy) = cursor_image_origin(4, 4, 1, 1, 0, 0);
1286        overlay_cursor(&mut frame, stride, 8, 8, 2, 2, &pixels, ox, oy);
1287        let px = |x: usize, y: usize| frame[y * stride + x * 4];
1288        assert_eq!(px(3, 3), 255);
1289        assert_eq!(px(4, 4), 255);
1290        assert_eq!(px(2, 2), 0);
1291        assert_eq!(px(5, 5), 0, "no pixel at the un-offset (hotspot) corner");
1292    }
1293
1294    #[test]
1295    fn offset_clamps_into_root() {
1296        assert_eq!(clamp_offset(100, 1920), 100);
1297        assert_eq!(clamp_offset(-50, 1920), 0);
1298        // Past-end offsets keep one root pixel instead of failing forever.
1299        assert_eq!(clamp_offset(100000, 1920), 1919);
1300        // A protocol-max root still maps into i16 X coordinates.
1301        assert_eq!(clamp_offset(40000, u16::MAX), 32766);
1302    }
1303
1304    #[test]
1305    fn resolve_dims_survives_past_end_offset() {
1306        let s = RustCaptureSettings {
1307            capture_x: 100000,
1308            capture_y: 100000,
1309            width: 1920,
1310            height: 1080,
1311            output_mode: 1,
1312            ..Default::default()
1313        };
1314        let (w, h) = resolve_dims(1920, 1080, &s);
1315        assert!(w >= 2 && h >= 2, "a past-end offset now degrades to a small grab, not death");
1316        assert_eq!(w % 2, 0);
1317        assert_eq!(h % 2, 0);
1318    }
1319
1320    /// `overlay_cursor` treats XFixes pixels as premultiplied: half-alpha at half
1321    /// intensity stays at that intensity over black (the straight-alpha formula
1322    /// would multiply alpha twice and darken it).
1323    #[test]
1324    fn overlay_blends_premultiplied_pixels() {
1325        let stride = 4 * 4;
1326        let mut frame = vec![0u8; stride * 4];
1327        // a=128, g=128 (premultiplied half-intensity), b=64.
1328        let pixels = [0x8000_8040u32; 4];
1329        overlay_cursor(&mut frame, stride, 4, 4, 2, 2, &pixels, 0, 0);
1330        assert_eq!(frame[0], 64, "blue channel: dst = 64 over black");
1331        assert_eq!(frame[1], 128, "premultiplied half-alpha stays 128, not darkened to 64");
1332    }
1333
1334    /// `overlay_cursor` clips a negative origin at the frame edge: with the origin at (-1,-1)
1335    /// only the in-frame quadrant is written, landing the cursor's bottom-right pixel at (0,0).
1336    #[test]
1337    fn overlay_clips_negative_origin_at_frame_edge() {
1338        let stride = 4 * 4;
1339        let mut frame = vec![0u8; stride * 4];
1340        let pixels = [0xFFFF_FFFFu32; 4];
1341        let (ox, oy) = cursor_image_origin(0, 0, 1, 1, 0, 0);
1342        overlay_cursor(&mut frame, stride, 4, 4, 2, 2, &pixels, ox, oy);
1343        assert_eq!(frame[0], 255);
1344        assert!(frame[4..].iter().all(|&b| b == 0), "no writes outside (0,0)");
1345    }
1346}