fix(host/windows): secure-desktop black screen — capture the real frame, don't seed black
apple / swift (push) Successful in 56s
android / android (push) Failing after 54s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 31s
ci / rust (push) Failing after 2m15s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m52s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m11s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m29s
docker / deploy-docs (push) Failing after 6s

Root cause (confirmed live: "black until I pressed a key, then the image came
back"): the secure desktop (lock/login/UAC) is STATIC, and DXGI Desktop
Duplication only emits a frame on CHANGE. On the normal→secure switch the
duplication is rebuilt (recreate_dupl / try_reduplicate), and we then SEEDED A
BLACK frame as last_present — which the static secure desktop never replaced
(no change-frame) until the user pressed a key. So we streamed black.

Fix: after rebuilding the duplication, CAPTURE the current desktop frame instead
of seeding black. A freshly-created duplication's first AcquireNextFrame returns
the full current desktop; grab it and present it. New `present_acquired` factors
the frame-processing out of `acquire`; both recovery paths now call it:
- recreate_dupl: after adopting the new duplication, acquire+present the real
  frame (born-lost ACCESS_LOST / no-initial-frame → seed black as fallback and
  let the 250ms-throttled caller retry — a brief flash, then real content).
- try_reduplicate: adopt-first, then capture its probe frame (was discarded).

Also (independently-correct safe fixes, per the adversarial review):
- DesktopWatcher computes the current desktop synchronously in start() before
  returning, so a session that begins on the secure desktop (reconnect to a
  locked box) doesn't relay one stale normal-desktop frame (the "flash").
- DuplCapturer::open reasserts SudoVDA isolation at open time (mirrors
  recreate_dupl) — forces the secure desktop back onto the virtual output if a
  lock/UAC re-attached a physical monitor.
- Instrumentation: dbg_black_seeds counter + a throttled warn when black is
  seeded, and an info when a real secure-desktop frame is captured on recovery.

Pending: the user's real-lock smoke test on the 4090 (a headless PsExec
LockWorkStation runs as SYSTEM and can't lock an interactive session, so this
must be validated with an actual lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 09:15:33 +00:00
parent cbeece119f
commit 7bf2899301
2 changed files with 94 additions and 44 deletions
@@ -29,14 +29,23 @@ pub struct DesktopWatcher {
impl DesktopWatcher { impl DesktopWatcher {
pub fn start() -> Self { pub fn start() -> Self {
let state = Arc::new(AtomicU8::new(DESKTOP_NORMAL)); // Compute the CURRENT desktop synchronously before returning, so the first reader (the capture
// mux) sees the real state immediately. Otherwise a session that begins already on the secure
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
let initial = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE
} else {
DESKTOP_NORMAL
};
let state = Arc::new(AtomicU8::new(initial));
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let s = state.clone(); let s = state.clone();
let st = stop.clone(); let st = stop.clone();
let _ = std::thread::Builder::new() let _ = std::thread::Builder::new()
.name("desktop-watch".into()) .name("desktop-watch".into())
.spawn(move || { .spawn(move || {
let mut last = u8::MAX; let mut last = initial;
while !st.load(Ordering::Relaxed) { while !st.load(Ordering::Relaxed) {
let v = if unsafe { is_secure_desktop() } { let v = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE DESKTOP_SECURE
+83 -42
View File
@@ -720,6 +720,7 @@ pub struct DuplCapturer {
first_frame: bool, first_frame: bool,
dbg_timeouts: u32, dbg_timeouts: u32,
dbg_lost: u32, dbg_lost: u32,
dbg_black_seeds: u32,
last: Option<Vec<u8>>, last: Option<Vec<u8>>,
/// GPU-output mode (zero-copy → NVENC): produce `FramePayload::D3d11` instead of CPU BGRA. /// GPU-output mode (zero-copy → NVENC): produce `FramePayload::D3d11` instead of CPU BGRA.
/// Selected by `PUNKTFUNK_ENCODER=nvenc` so the capturer's output matches the encoder's input. /// Selected by `PUNKTFUNK_ENCODER=nvenc` so the capturer's output matches the encoder's input.
@@ -878,8 +879,13 @@ impl DuplCapturer {
let device = device.context("null D3D11 device")?; let device = device.context("null D3D11 device")?;
let context = context.context("null D3D11 context")?; let context = context.context("null D3D11 context")?;
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can // 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can
// be the Winlogon secure desktop) so a session that starts at the lock/login screen works. // be the Winlogon secure desktop) so a session that starts at the lock/login screen works,
// and re-assert display isolation at OPEN time (not just in recovery): a lock/UAC switch can
// re-attach a physical monitor and route the secure desktop THERE, leaving our virtual
// output perpetually idle/lost — re-isolating forces the secure desktop back onto it. Cheap
// + idempotent (a no-op when nothing else is attached).
attach_input_desktop(); attach_input_desktop();
crate::vdisplay::sudovda::reassert_isolation(&target.gdi_name);
let dupl = output let dupl = output
.DuplicateOutput(&device) .DuplicateOutput(&device)
.context("DuplicateOutput (already duplicated by another app?)")?; .context("DuplicateOutput (already duplicated by another app?)")?;
@@ -933,6 +939,7 @@ impl DuplCapturer {
first_frame: true, first_frame: true,
dbg_timeouts: 0, dbg_timeouts: 0,
dbg_lost: 0, dbg_lost: 0,
dbg_black_seeds: 0,
last: None, last: None,
gpu_mode, gpu_mode,
gpu_copy: None, gpu_copy: None,
@@ -1079,6 +1086,16 @@ impl DuplCapturer {
/// HDR mode it seeds the 10-bit output (black = PQ 0); otherwise the BGRA copy. One-shot: the next /// HDR mode it seeds the 10-bit output (black = PQ 0); otherwise the BGRA copy. One-shot: the next
/// real frame overwrites the texture in place. /// real frame overwrites the texture in place.
unsafe fn seed_black_gpu_frame(&mut self) -> Result<()> { unsafe fn seed_black_gpu_frame(&mut self) -> Result<()> {
// Instrumentation: a BLACK seed means we have no real desktop frame to show — if the client
// streams black, this is why. On the secure (Winlogon) desktop this fires when the duplication
// came back born-lost / idle. Counted + logged (throttled) so a real-lock repro shows the mode.
self.dbg_black_seeds += 1;
if self.dbg_black_seeds % 32 == 1 {
tracing::warn!(
black_seeds = self.dbg_black_seeds,
"DDA: seeding BLACK frame — no real desktop frame available (secure desktop idle/born-lost?)"
);
}
if self.hdr_fp16 { if self.hdr_fp16 {
self.ensure_hdr10_out()?; self.ensure_hdr10_out()?;
let out = self.hdr10_out.clone().context("hdr10 out texture")?; let out = self.hdr10_out.clone().context("hdr10 out texture")?;
@@ -1225,18 +1242,23 @@ impl DuplCapturer {
Ok(d) => d, Ok(d) => d,
Err(_) => return false, Err(_) => return false,
}; };
// Short probe (hot path): a born-lost duplication returns ACCESS_LOST immediately regardless // Adopt first (SAME device → existing gpu_copy/HDR textures/last_present stay valid), then probe
// of the timeout; only the alive-but-idle case waits the full 16ms, and idle = nothing moving. // + 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;
let mut info = DXGI_OUTDUPL_FRAME_INFO::default(); let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut res: Option<IDXGIResource> = None; let mut res: Option<IDXGIResource> = None;
match dupl.AcquireNextFrame(16, &mut info, &mut res) { match self.dupl.AcquireNextFrame(16, &mut info, &mut res) {
Ok(()) => { Ok(()) => {
let _ = dupl.ReleaseFrame(); self.update_cursor(&info);
if let Some(r) = res {
let _ = self.present_acquired(r);
}
} }
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => {} Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => {}
Err(_) => return false, // born-lost on the same output → need the full rebuild Err(_) => return false, // born-lost on the same output → need the full rebuild
} }
self.dupl = dupl;
true true
} }
@@ -1267,31 +1289,8 @@ impl DuplCapturer {
crate::vdisplay::sudovda::reassert_isolation(&self.gdi_name); crate::vdisplay::sudovda::reassert_isolation(&self.gdi_name);
let (dev, ctx, out, dupl) = reopen_duplication(&self.gdi_name)?; // Err → caller repeats + retries let (dev, ctx, out, dupl) = reopen_duplication(&self.gdi_name)?; // Err → caller repeats + retries
// PROBE before adopting. During the unsettled Winlogon switch DuplicateOutput SUCCEEDS but the // (The born-lost guard is now the capture-acquire at the end: we adopt, then grab the current
// duplication is "born-lost" — the first AcquireNextFrame immediately returns ACCESS_LOST. // frame; ACCESS_LOST there means born-lost, and we seed black + let the throttled caller retry.)
// Adopting it (swapping into self + seeding black) is exactly what produced the perpetual
// rebuild→born-lost storm (lost=2097) where the secure desktop never appeared. So gate adoption
// on a probe: Ok (a frame) or WAIT_TIMEOUT (alive but idle) ⇒ live, adopt; any other error ⇒
// born-lost, drop the locals and bail so the caller repeats the last frame and retries on the
// 250ms throttle. Once the topology settles (and reassert_isolation has taken), a probe passes
// and we adopt a LIVE duplication of the secure desktop.
{
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut res: Option<IDXGIResource> = None;
match dupl.AcquireNextFrame(50, &mut info, &mut res) {
Ok(()) => {
let _ = dupl.ReleaseFrame();
}
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => {}
Err(e) => {
return Err(anyhow!(
"rebuilt duplication is born-lost (probe AcquireNextFrame: {:#x}) — \
topology not settled yet",
e.code().0
));
}
}
}
// A desktop switch can come back at a different size (e.g. the user session applies its own // A desktop switch can come back at a different size (e.g. the user session applies its own
// resolution on login). Adopt it: update dimensions and drop the staging/gpu copies so they // resolution on login). Adopt it: update dimensions and drop the staging/gpu copies so they
// reallocate. NVENC re-inits at the new size when it sees the frame. // reallocate. NVENC re-inits at the new size when it sees the frame.
@@ -1326,14 +1325,47 @@ impl DuplCapturer {
self.hdr10_out = None; self.hdr10_out = None;
self.hdr_conv = None; self.hdr_conv = None;
self.first_frame = true; self.first_frame = true;
// Seed a black frame on the NEW device so next_frame always has something to repeat (and the // Capture the CURRENT desktop frame as `last_present` (instead of seeding black). The secure
// encoder re-inits) until real frames resume. // (lock/login/UAC) desktop is STATIC, so DDA only emits a frame on change — if we seeded black
if self.gpu_mode { // we'd stream black until the user pressed a key (the reported bug). A freshly-created
// duplication's first AcquireNextFrame returns the full current desktop; grab it and present it,
// so the client shows the real (frozen-until-it-changes) secure desktop. Born-lost (ACCESS_LOST
// here) or no-initial-frame (timeout) → seed black as a fallback and let the throttled caller
// retry — a brief black flash during the unsettled switch, then real content.
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) {
Ok(()) => {
self.update_cursor(&info);
match res {
Some(r) => match self.present_acquired(r) {
Ok(_) => {
self.first_frame = false;
tracing::info!("DXGI recovery: captured real secure-desktop frame");
true
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "recovery: present_acquired failed");
false
}
},
None => false,
}
}
Err(e) => {
tracing::warn!(
code = format!("{:#x}", e.code().0),
"DXGI recovery: no initial frame (born-lost/idle) — seeding black, will retry"
);
false
}
};
if !captured && self.gpu_mode {
if let Err(e) = self.seed_black_gpu_frame() { if let Err(e) = self.seed_black_gpu_frame() {
tracing::warn!(error = %format!("{e:#}"), "seed black frame after recovery failed"); tracing::warn!(error = %format!("{e:#}"), "seed black frame after recovery failed");
} }
} }
nudge_cursor_onto(&self.output); // re-kick after recovery
Ok(()) Ok(())
} }
@@ -1422,8 +1454,17 @@ impl DuplCapturer {
} }
Err(e) => return Err(e).context("AcquireNextFrame"), Err(e) => return Err(e).context("AcquireNextFrame"),
} }
self.holding_frame = true;
let res = res.context("AcquireNextFrame: null resource")?; let res = res.context("AcquireNextFrame: null resource")?;
Ok(Some(self.present_acquired(res)?))
}
/// Turn a freshly-acquired duplication resource into a `CapturedFrame` and record it as
/// `last_present`. Factored out of [`acquire`] so the recovery path ([`recreate_dupl`]) can grab
/// the CURRENT desktop frame instead of seeding black: the secure (lock/login/UAC) desktop is
/// static, so DDA emits no change-frame to replace a black seed — the cause of the black-screen-
/// until-you-press-a-key bug. The caller has already `AcquireNextFrame`d; this releases it.
unsafe fn present_acquired(&mut self, res: IDXGIResource) -> Result<CapturedFrame> {
self.holding_frame = true;
let tex: ID3D11Texture2D = res.cast().context("resource -> Texture2D")?; let tex: ID3D11Texture2D = res.cast().context("resource -> Texture2D")?;
if self.gpu_mode && self.hdr_fp16 { if self.gpu_mode && self.hdr_fp16 {
// HDR zero-copy path: the duplication surface is scRGB FP16 (R16G16B16A16_FLOAT) — it can't // HDR zero-copy path: the duplication surface is scRGB FP16 (R16G16B16A16_FLOAT) — it can't
@@ -1455,7 +1496,7 @@ impl DuplCapturer {
self.height, self.height,
); );
self.last_present = Some((out.clone(), PixelFormat::Rgb10a2)); self.last_present = Some((out.clone(), PixelFormat::Rgb10a2));
return Ok(Some(CapturedFrame { return Ok(CapturedFrame {
width: self.width, width: self.width,
height: self.height, height: self.height,
pts_ns: now_ns(), pts_ns: now_ns(),
@@ -1464,7 +1505,7 @@ impl DuplCapturer {
texture: out, texture: out,
device: self.device.clone(), device: self.device.clone(),
}), }),
})); });
} }
if self.gpu_mode { if self.gpu_mode {
// Zero-copy path: keep the frame on the GPU for NVENC. Copy the transient duplication // Zero-copy path: keep the frame on the GPU for NVENC. Copy the transient duplication
@@ -1476,7 +1517,7 @@ impl DuplCapturer {
self.holding_frame = false; self.holding_frame = false;
self.composite_cursor_gpu(&gpu, false)?; self.composite_cursor_gpu(&gpu, false)?;
self.last_present = Some((gpu.clone(), PixelFormat::Bgra)); self.last_present = Some((gpu.clone(), PixelFormat::Bgra));
return Ok(Some(CapturedFrame { return Ok(CapturedFrame {
width: self.width, width: self.width,
height: self.height, height: self.height,
pts_ns: now_ns(), pts_ns: now_ns(),
@@ -1485,7 +1526,7 @@ impl DuplCapturer {
texture: gpu, texture: gpu,
device: self.device.clone(), device: self.device.clone(),
}), }),
})); });
} }
self.ensure_staging()?; self.ensure_staging()?;
let staging = self.staging.clone().context("staging texture")?; let staging = self.staging.clone().context("staging texture")?;
@@ -1517,13 +1558,13 @@ impl DuplCapturer {
} }
} }
self.last = Some(tight.clone()); self.last = Some(tight.clone());
Ok(Some(CapturedFrame { Ok(CapturedFrame {
width: self.width, width: self.width,
height: self.height, height: self.height,
pts_ns: now_ns(), pts_ns: now_ns(),
format: PixelFormat::Bgra, format: PixelFormat::Bgra,
payload: FramePayload::Cpu(tight), payload: FramePayload::Cpu(tight),
})) })
} }
} }