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:
@@ -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().
|
||||
|
||||
Reference in New Issue
Block a user