fix(host/windows): release the old duplication before re-duplicating (THE born-lost bug)

DuplicateOutput1 returned E_ACCESSDENIED ~8815x even with PER_MONITOR_AWARE_V2
confirmed on the capture thread (thread_is_v2=true) — so DPI was NOT the cause.
The real cause: DXGI permits only ONE IDXGIOutputDuplication per output, and on
ACCESS_LOST you MUST release the old one before re-duplicating. Our recovery
(try_reduplicate / recreate_dupl) created the NEW duplication while the OLD
self.dupl was still alive → the output stayed held → DuplicateOutput1
E_ACCESSDENIED and the legacy fallback returned a BORN-LOST dup. It never
converged because there was always exactly one stale dup alive at creation
time. The initial open() works precisely because there's no prior dup; Apollo
is clean because it releases (dup.reset()) before every re-DuplicateOutput.

Fix: make self.dupl an Option and set it to None (drop → release the output)
BEFORE duplicate_output in try_reduplicate and before reopen_duplication in
recreate_dupl, then Some(new). acquire() gets a None-guard that synthesizes
ACCESS_LOST (routes into recovery) so a transient None can't panic. All
ReleaseFrame/AcquireNextFrame sites updated for the Option.

This is the documented DDA recovery requirement and the one thing that
distinguished our failing DuplicateOutput1 from Apollo's working one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:40:50 +00:00
parent c8fb4822a2
commit da43b5e8d3
+36 -15
View File
@@ -937,7 +937,12 @@ pub struct DuplCapturer {
device: ID3D11Device,
context: ID3D11DeviceContext,
output: IDXGIOutput1,
dupl: IDXGIOutputDuplication,
/// The output duplication. `Option` so recovery can RELEASE it (set `None`) BEFORE re-duplicating:
/// DXGI permits only ONE `IDXGIOutputDuplication` per output, and a stale one (incl. an ACCESS_LOST
/// one) keeps holding the output, so a re-`DuplicateOutput1` returns E_ACCESSDENIED and legacy
/// `DuplicateOutput` returns a BORN-LOST dup — the storm. Apollo releases before re-duplicating; so
/// do we now. `None` only transiently during recovery (acquire routes None → recovery).
dupl: Option<IDXGIOutputDuplication>,
/// The output's GDI name — re-resolved on ACCESS_LOST (a mode change can stale the cached handle).
gdi_name: String,
/// Stable SudoVDA target id, used to re-resolve `gdi_name` during recovery.
@@ -1206,7 +1211,7 @@ impl DuplCapturer {
device,
context,
output,
dupl,
dupl: Some(dupl),
target_id: target.target_id,
gdi_name: target.gdi_name,
width,
@@ -1542,9 +1547,13 @@ impl DuplCapturer {
/// (like recreate_dupl) so a born-lost one is rejected rather than adopted.
unsafe fn try_reduplicate(&mut self) -> bool {
if self.holding_frame {
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
self.holding_frame = false;
}
// RELEASE the old duplication FIRST (drop it → frees the output) before re-duplicating. DXGI
// allows one duplication per output; leaving the stale one alive is exactly why DuplicateOutput1
// returned E_ACCESSDENIED and the legacy fallback produced a born-lost dup.
self.dupl = None;
let dupl = match duplicate_output(&self.output, &self.device) {
Ok(d) => d,
Err(_) => return false,
@@ -1553,10 +1562,10 @@ impl DuplCapturer {
// + CAPTURE the frame: a born-lost duplication returns ACCESS_LOST immediately; alive-but-idle
// waits the full 16ms. On a real frame we present it (so a static desktop keeps a real
// last_present instead of the discarded one); idle keeps the existing last_present.
self.dupl = dupl;
self.dupl = Some(dupl);
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut res: Option<IDXGIResource> = None;
match self.dupl.AcquireNextFrame(16, &mut info, &mut res) {
match self.dupl.as_ref().unwrap().AcquireNextFrame(16, &mut info, &mut res) {
Ok(()) => {
self.update_cursor(&info);
if let Some(r) = res {
@@ -1580,7 +1589,7 @@ impl DuplCapturer {
/// frame and retries on a throttle, so the session survives an arbitrarily long secure visit.
unsafe fn recreate_dupl(&mut self) -> Result<()> {
if self.holding_frame {
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
self.holding_frame = false;
}
// The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild —
@@ -1600,6 +1609,11 @@ impl DuplCapturer {
attach_input_desktop();
crate::vdisplay::sudovda::reassert_isolation(&self.gdi_name);
}
// RELEASE the old duplication FIRST (frees the output). reopen_duplication creates a NEW device
// and re-DuplicateOutputs the output; if the stale duplication is still alive it holds the output
// and the new one is born-lost / E_ACCESSDENIED. (On reopen failure self.dupl stays None and
// acquire's None-guard re-drives recovery.)
self.dupl = None;
let (dev, ctx, out, dupl) = reopen_duplication(&self.gdi_name)?; // Err → caller repeats + retries
// (The born-lost guard is now the capture-acquire at the end: we adopt, then grab the current
@@ -1626,7 +1640,7 @@ impl DuplCapturer {
self.device = dev;
self.context = ctx;
self.output = out;
self.dupl = dupl;
self.dupl = Some(dupl);
self.gpu_copy = None; // stale: belonged to the old device
self.cursor = None; // shaders/textures belonged to the old device; rebuilt on demand
self.last_present = None; // belonged to the old device; reseeded below
@@ -1648,7 +1662,7 @@ impl DuplCapturer {
nudge_cursor_onto(&self.output); // kick a change so a static desktop yields its first frame
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut res: Option<IDXGIResource> = None;
let captured = match self.dupl.AcquireNextFrame(120, &mut info, &mut res) {
let captured = match self.dupl.as_ref().unwrap().AcquireNextFrame(120, &mut info, &mut res) {
Ok(()) => {
self.update_cursor(&info);
match res {
@@ -1693,7 +1707,7 @@ impl DuplCapturer {
/// Acquire one frame: `Some` on a fresh image, `None` on timeout (no change → caller reuses last).
unsafe fn acquire(&mut self) -> Result<Option<CapturedFrame>> {
if self.holding_frame {
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
self.holding_frame = false;
}
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
@@ -1703,7 +1717,14 @@ impl DuplCapturer {
} else {
self.timeout_ms
};
match self.dupl.AcquireNextFrame(timeout, &mut info, &mut res) {
// If a prior recovery released the old duplication but couldn't create a new one yet (output
// gone during a secure dwell, etc.), self.dupl is None — synthesize ACCESS_LOST so we flow into
// the recovery path below instead of panicking.
let acq = match self.dupl.as_ref() {
Some(d) => d.AcquireNextFrame(timeout, &mut info, &mut res),
None => Err(windows::core::Error::from_hresult(DXGI_ERROR_ACCESS_LOST)),
};
match acq {
Ok(()) => {
if self.first_frame {
tracing::info!(w = self.width, h = self.height, "DXGI first frame acquired");
@@ -1840,7 +1861,7 @@ impl DuplCapturer {
new = format!("{}x{}", d.Width, d.Height),
"DXGI capture size changed mid-stream — rebuilding"
);
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
let now = Instant::now();
let due = self
.last_rebuild
@@ -1874,7 +1895,7 @@ impl DuplCapturer {
self.ensure_fp16_src()?;
let src = self.fp16_src.clone().context("fp16 src texture")?;
self.context.CopyResource(&src, &tex);
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
self.holding_frame = false;
self.composite_cursor_gpu(&src, true)?; // onto the FP16 surface (HDR: decode + nits scale)
self.ensure_hdr10_out()?;
@@ -1912,7 +1933,7 @@ impl DuplCapturer {
self.ensure_gpu_copy()?;
let gpu = self.gpu_copy.clone().context("gpu copy texture")?;
self.context.CopyResource(&gpu, &tex);
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
self.holding_frame = false;
self.composite_cursor_gpu(&gpu, false)?;
self.last_present = Some((gpu.clone(), PixelFormat::Bgra));
@@ -1939,7 +1960,7 @@ impl DuplCapturer {
let src = std::slice::from_raw_parts(map.pData as *const u8, pitch * h);
let mut tight = depad_bgra(src, pitch, w, h);
self.context.Unmap(&staging, 0);
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
self.holding_frame = false;
if self.cursor_visible {
if let Some(shape) = &self.cursor_shape {
@@ -2054,7 +2075,7 @@ impl Drop for DuplCapturer {
fn drop(&mut self) {
if self.holding_frame {
unsafe {
let _ = self.dupl.ReleaseFrame();
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
}
}
// Release the display/system-required execution state we took at open().