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