From f9c2f1685733af8373f549b0ab9a9505932dec61 Mon Sep 17 00:00:00 2001 From: Joss Gray Date: Tue, 15 Sep 2026 22:18:44 -0700 Subject: [PATCH] fix: recover DXGI capture after access loss (#16024) * fix: recover DXGI capture after access loss * fix: track DXGI frame ownership * fix: only recover DXGI access loss * fix: stabilize DXGI recovery after mode switches * fix: preserve DXGI recovery budget * refactor: model DXGI frame lifecycle as enum * docs: clarify DXGI frame cleanup order * fix: scope DXGI frame grace to access loss recovery * fix: preserve DXGI recovery across display changes * chore: log DXGI recovery attempts at debug level --- libs/scrap/src/dxgi/mod.rs | 61 ++++++++++++++++---- src/server/video_service.rs | 112 ++++++++++++++++++++++++++++++++++-- 2 files changed, 156 insertions(+), 17 deletions(-) diff --git a/libs/scrap/src/dxgi/mod.rs b/libs/scrap/src/dxgi/mod.rs index 1f5296954..77ae70cd0 100644 --- a/libs/scrap/src/dxgi/mod.rs +++ b/libs/scrap/src/dxgi/mod.rs @@ -41,6 +41,13 @@ impl Drop for ComPtr { } } +#[derive(Clone, Copy, PartialEq)] +enum FrameState { + Idle, + Acquired, + Mapped, +} + pub struct Capturer { device: ComPtr, display: Display, @@ -58,6 +65,7 @@ pub struct Capturer { output_texture: bool, adapter_desc1: DXGI_ADAPTER_DESC1, rotate: Rotate, + frame_state: FrameState, } impl Capturer { @@ -174,6 +182,7 @@ impl Capturer { output_texture: false, adapter_desc1, rotate, + frame_state: FrameState::Idle, }) } @@ -335,6 +344,7 @@ impl Capturer { let mut info = mem::MaybeUninit::uninit().assume_init(); wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?; + self.frame_state = FrameState::Acquired; let frame = ComPtr(frame); if *info.LastPresentTime.QuadPart() == 0 { @@ -345,9 +355,11 @@ impl Capturer { let mut rect = mem::MaybeUninit::uninit().assume_init(); if self.fastlane { wrap_hresult((*self.duplication.0).MapDesktopSurface(&mut rect))?; + self.frame_state = FrameState::Mapped; } else { self.surface = ComPtr(self.ohgodwhat(frame.0)?); wrap_hresult((*self.surface.0).Map(&mut rect, DXGI_MAP_READ))?; + self.frame_state = FrameState::Mapped; } Ok((rect.pBits, rect.Pitch)) } @@ -424,7 +436,7 @@ impl Capturer { } } } else { - self.unmap(); + self.release_frame()?; let r = self.load_frame(timeout)?; let rotate = match self.display.rotation() { DXGI_MODE_ROTATION_IDENTITY | DXGI_MODE_ROTATION_UNSPECIFIED => kRotate0, @@ -472,12 +484,13 @@ impl Capturer { if self.duplication.0.is_null() { return Err(std::io::ErrorKind::AddrNotAvailable.into()); } - (*self.duplication.0).ReleaseFrame(); + self.release_frame()?; let mut frame = ptr::null_mut(); #[allow(invalid_value)] let mut info = mem::MaybeUninit::uninit().assume_init(); wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?; + self.frame_state = FrameState::Acquired; let frame = ComPtr(frame); if info.AccumulatedFrames == 0 || *info.LastPresentTime.QuadPart() == 0 { @@ -574,16 +587,42 @@ impl Capturer { } } - fn unmap(&self) { + fn release_frame(&mut self) -> io::Result<()> { + if self.duplication.is_null() { + return Ok(()); + } + let mut first_error = None; + // Unmap before ReleaseFrame invalidates the desktop surface; use the same + // order for staging surfaces. Cleanup advances Mapped -> Acquired -> Idle, + // while Idle is a no-op. Advance state even on errors to avoid retrying + // cleanup, but still attempt ReleaseFrame if unmapping fails. unsafe { - (*self.duplication.0).ReleaseFrame(); - if self.fastlane { - (*self.duplication.0).UnMapDesktopSurface(); - } else { - if !self.surface.is_null() { - (*self.surface.0).Unmap(); + if self.frame_state == FrameState::Mapped { + let result = if self.fastlane { + wrap_hresult((*self.duplication.0).UnMapDesktopSurface()) + } else if !self.surface.is_null() { + wrap_hresult((*self.surface.0).Unmap()) + } else { + Ok(()) + }; + self.frame_state = FrameState::Acquired; + if let Err(err) = result { + first_error = Some(err); } } + if self.frame_state == FrameState::Acquired { + let result = wrap_hresult((*self.duplication.0).ReleaseFrame()); + self.frame_state = FrameState::Idle; + if first_error.is_none() { + if let Err(err) = result { + first_error = Some(err); + } + } + } + } + match first_error { + Some(err) => Err(err), + None => Ok(()), } } @@ -599,8 +638,8 @@ impl Capturer { impl Drop for Capturer { fn drop(&mut self) { - if !self.duplication.is_null() { - self.unmap(); + if let Err(err) = self.release_frame() { + eprintln!("DXGI frame cleanup failed: {err}"); } } } diff --git a/src/server/video_service.rs b/src/server/video_service.rs index e91ecc7d4..b07098f3f 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -52,6 +52,8 @@ use scrap::{ CodecFormat, Display, EncodeInput, TraitCapturer, TraitPixelBuffer, }; #[cfg(windows)] +use std::io::ErrorKind::ConnectionReset; +#[cfg(windows)] use std::sync::Once; use std::{ collections::HashSet, @@ -62,6 +64,59 @@ use std::{ pub const OPTION_REFRESH: &'static str = "refresh"; +#[cfg(windows)] +const DXGI_RECOVERY_LIMIT: usize = 3; +#[cfg(windows)] +const DXGI_RECOVERY_WINDOW: Duration = Duration::from_secs(10); +#[cfg(windows)] +const DXGI_RECOVERY_FRAME_GRACE: Duration = Duration::from_secs(2); + +#[cfg(windows)] +struct DxgiRecoveryState { + attempts: usize, + window_started: Option, + restart_pending: bool, + fallback_pending: bool, +} + +#[cfg(windows)] +impl DxgiRecoveryState { + fn new() -> Self { + Self { + attempts: 0, + window_started: None, + restart_pending: false, + fallback_pending: false, + } + } + + fn next_attempt(&mut self) -> Option { + if self + .window_started + .map(|started| started.elapsed() > DXGI_RECOVERY_WINDOW) + .unwrap_or(true) + { + self.attempts = 0; + self.window_started = Some(Instant::now()); + } + if self.attempts >= DXGI_RECOVERY_LIMIT { + self.fallback_pending = true; + return None; + } + self.attempts += 1; + self.restart_pending = true; + Some(self.attempts) + } + + fn take_restart_pending(&mut self) -> bool { + std::mem::take(&mut self.restart_pending) + } + + fn take_fallback_pending(&mut self) -> bool { + std::mem::take(&mut self.fallback_pending) + } +} + type FrameFetchedNotifierSender = UnboundedSender<(i32, Option)>; type FrameFetchedNotifierReceiver = Arc)>>>; @@ -220,6 +275,8 @@ pub struct VideoService { sp: GenericService, idx: usize, source: VideoSource, + #[cfg(windows)] + dxgi_recovery_state: Arc>, } impl Deref for VideoService { @@ -253,6 +310,8 @@ pub fn new(source: VideoSource, idx: usize) -> GenericService { sp: GenericService::new(get_service_name(source, idx), true), idx, source, + #[cfg(windows)] + dxgi_recovery_state: Arc::new(Mutex::new(DxgiRecoveryState::new())), }; GenericService::run(&vs, run); vs.sp @@ -565,9 +624,25 @@ fn run(vs: VideoService) -> ResultType<()> { let last_portable_service_running = false; let display_idx = vs.idx; + #[cfg(windows)] + let dxgi_recovery_state = vs.dxgi_recovery_state.clone(); let sp = vs.sp; let mut c = get_capturer(vs.source, display_idx, last_portable_service_running)?; #[cfg(windows)] + // ACCESS_LOST marks the next successful capturer creation as a recovery. This timestamp is + // consumed once and temporarily holds off the normal WouldBlock-to-GDI fallback, giving the + // replacement DXGI capturer time to produce its first frame. Normal startup is unaffected. + let dxgi_recovery_started = dxgi_recovery_state + .lock() + .unwrap() + .take_restart_pending() + .then(Instant::now); + #[cfg(windows)] + if dxgi_recovery_state.lock().unwrap().take_fallback_pending() { + c.set_gdi(); + log::info!("dxgi recovery exhausted, fall back to gdi"); + } + #[cfg(windows)] if !scrap::codec::enable_directx_capture() && !c.is_gdi() { log::info!("disable dxgi with option, fall back to gdi"); c.set_gdi(); @@ -804,13 +879,18 @@ fn run(vs: VideoService) -> ResultType<()> { match res { Err(ref e) if e.kind() == WouldBlock => { #[cfg(windows)] - if try_gdi > 0 && !c.is_gdi() { - if try_gdi > 3 { - c.set_gdi(); - try_gdi = 0; - log::info!("No image, fall back to gdi"); + if dxgi_recovery_started + .map(|started| started.elapsed() >= DXGI_RECOVERY_FRAME_GRACE) + .unwrap_or(true) + { + if try_gdi > 0 && !c.is_gdi() { + if try_gdi > 3 { + c.set_gdi(); + try_gdi = 0; + log::info!("No image, fall back to gdi"); + } + try_gdi += 1; } - try_gdi += 1; } #[cfg(target_os = "linux")] { @@ -850,6 +930,13 @@ fn run(vs: VideoService) -> ResultType<()> { } } Err(err) => { + #[cfg(windows)] + // The display-change check can restart capture before error handling below. + let recovery_attempt = if !c.is_gdi() && err.kind() == ConnectionReset { + dxgi_recovery_state.lock().unwrap().next_attempt() + } else { + None + }; // This check may be redundant, but it is better to be safe. // The previous check in `sp.is_option_true(OPTION_REFRESH)` block may be enough. if vs.source.is_monitor() { @@ -858,6 +945,19 @@ fn run(vs: VideoService) -> ResultType<()> { #[cfg(windows)] if !c.is_gdi() { + if err.kind() == ConnectionReset { + if let Some(attempt) = recovery_attempt { + log::debug!( + "dxgi access lost, restart capture: attempt {attempt}, error: {err:?}" + ); + bail!("SWITCH"); + } + log::warn!( + "dxgi access lost after {DXGI_RECOVERY_LIMIT} restarts in {} seconds, fall back to gdi: {err:?}", + DXGI_RECOVERY_WINDOW.as_secs() + ); + dxgi_recovery_state.lock().unwrap().take_fallback_pending(); + } c.set_gdi(); log::info!("dxgi error, fall back to gdi: {:?}", err); continue;