fix(vdisplay): first-frame guarantee — republish a retained frame at ring attach

DWM composes a display only when something dirties it, so a session opened
onto an idle desktop never produced a first frame: the host's synthetic-input
"compose kick" (cursor wiggle / sibling-display jump) was the only source, and
it is inherently unreliable — blocked on the secure desktop, defeated by a
fullscreen game's ClipCursor, user-visible, and dead in service contexts. The
field symptom: connect → black stream until something repaints the desktop.

Reconstruct DDA's first-frame semantics at the driver instead (DDA seeds a new
duplication with the current desktop image; IDD-push never had an equivalent):

* frame_transport.rs: new FrameStash — the retained last composed frame, a
  driver-private copy-only texture. publish() now reports Published /
  DescMismatch / Dropped, and harvest_into() pulls the last-published ring
  slot into the stash (keyed-mutex guarded, freshness-checked) before a
  superseded publisher is dropped — between sessions the driver keeps writing
  the host-side-dead old ring, so that slot IS the current desktop image.
* swap_chain_processor.rs: the worker stashes every frame the ring can NOT
  take (unattached, or descriptor-mismatched during a mode/HDR-flip race),
  harvests before a supersede, and REPUBLISHES the stash into every freshly
  attached ring — the host sees a normal seq=1 publish milliseconds after
  channel delivery, no compose needed. Zero steady-state cost: matched
  publishes touch only the ring. The frame-channel stash is now polled every
  iteration (attach latency = first-frame latency; it was 1-in-30).
* monitor.rs: preserved_stash (LUID-tagged) so the retained frame survives
  swap-chain unassign→reassign flaps, alongside the preserved publisher.
* host idd_push.rs: kick_dwm_compose demoted to documented last-resort
  fallback for pre-stash drivers; a debug log now fires when a kick actually
  runs so field logs show whether the stash path is working.

No proto change: the republish is an ordinary publish, so old host + new
driver and new host + old driver both keep working (the latter via the kick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:16:36 +02:00
parent 5748706631
commit 89a08f83af
4 changed files with 362 additions and 56 deletions
@@ -18,8 +18,15 @@
//! layout, the [`FrameToken`] packing, the `MAGIC`/`RING_LEN`, the `DRV_STATUS_*` codes and the
//! channel-delivery struct are NOT hand-duplicated here: both sides `use pf_driver_proto::{control,
//! frame}`, which OWNS the contract (with `const` size asserts so any drift is a compile error).
//!
//! This module also owns the [`FrameStash`] — the driver-retained copy of the most recent composed
//! frame that the swap-chain worker republishes into every freshly-attached ring, so a session
//! opening onto an IDLE desktop gets its first frame immediately instead of waiting for something
//! to dirty the display (the first-frame guarantee; see the type docs). Purely driver-internal
//! behavior — no proto change: the host just sees a normal `seq = 1` publish right after attach.
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
use pf_driver_proto::control::SetFrameChannelRequest;
use pf_driver_proto::frame::{
@@ -28,8 +35,10 @@ use pf_driver_proto::frame::{
};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Graphics::Direct3D11::{
D3D11_TEXTURE2D_DESC, ID3D11Device, ID3D11Device1, ID3D11DeviceContext, ID3D11Texture2D,
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, ID3D11Device, ID3D11Device1, ID3D11DeviceContext,
ID3D11Texture2D,
};
use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC;
use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex;
use windows::Win32::System::Memory::{
FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile,
@@ -124,6 +133,137 @@ struct Slot {
mutex: IDXGIKeyedMutex,
}
/// The driver-retained copy of the most recent composed frame — the FIRST-FRAME GUARANTEE.
///
/// DWM presents a display only when something DIRTIES it, so a ring freshly attached over an idle
/// desktop could wait forever for its first frame (session open onto a static desktop = black
/// stream until something repaints). DXGI Desktop Duplication never had this problem because the
/// OS seeds a new duplication with the CURRENT desktop image; this stash reconstructs that
/// guarantee for the IDD-push path. The swap-chain worker copies into it every composed frame it
/// canNOT deliver to a live ring (no publisher attached, or a size/format-mismatched surface
/// racing the host's ring recreate) and HARVESTS a superseded publisher's last-published slot, so
/// at every attach the freshest desktop image is republished into the new ring immediately — no
/// compose to wait for, no synthetic-input "kick" needed on the host.
///
/// Costs nothing at steady state: a matched publish goes ONLY into the ring, and between sessions
/// the still-attached publisher keeps writing the (dead) previous ring, which the harvest then
/// reads — the extra `CopyResource` happens only for unattached/mismatched frames, which are
/// damage-driven and rare.
pub struct FrameStash {
/// Lazily (re)created at the source's size/format: a plain default-usage texture (no bind or
/// misc flags — a pure copy source/target) on the worker's pooled device.
tex: Option<ID3D11Texture2D>,
/// When the retained content was captured (monotonic). Harvest only overwrites OLDER content,
/// so a superseded publisher's last frame can never clobber a fresher mismatch-stashed surface
/// (e.g. the first FP16 frames of an HDR flip stashed while the ring was still BGRA).
stored_at: Option<Instant>,
}
// SAFETY: like `FramePublisher` — created and used only on the swap-chain worker thread; the
// preserved hand-off across workers is serialized by the monitor stash's Mutex.
unsafe impl Send for FrameStash {}
impl Default for FrameStash {
fn default() -> Self {
Self::new()
}
}
impl FrameStash {
pub const fn new() -> Self {
Self {
tex: None,
stored_at: None,
}
}
/// The retained frame, if any content has been stored.
pub fn texture(&self) -> Option<&ID3D11Texture2D> {
if self.stored_at.is_some() {
self.tex.as_ref()
} else {
None
}
}
/// When the retained content was captured (`None` = empty).
pub fn stored_at(&self) -> Option<Instant> {
self.stored_at
}
/// Copy `src` into the stash — (re)creating the stash texture if the size/format differ — and
/// stamp `at` as the content's capture instant. Best-effort: a failed texture create leaves the
/// stash empty (the attach republish then simply has nothing, which is the old behavior).
///
/// The CALLER owns `src`'s synchronization: a ring slot's keyed mutex must be held across this
/// call (harvest), and a swap-chain surface is exclusively ours pre-`FinishedProcessingFrame`.
pub fn store(
&mut self,
device: &ID3D11Device,
context: &ID3D11DeviceContext,
src: &ID3D11Texture2D,
at: Instant,
) {
let mut desc = D3D11_TEXTURE2D_DESC::default();
// SAFETY: `src` is a live texture per the caller's contract; `desc` is a valid local out-param.
unsafe { src.GetDesc(&mut desc) };
let matches = self.tex.as_ref().is_some_and(|t| {
let mut d = D3D11_TEXTURE2D_DESC::default();
// SAFETY: `t` is the live stash texture; `d` is a valid local out-param.
unsafe { t.GetDesc(&mut d) };
d.Width == desc.Width && d.Height == desc.Height && d.Format == desc.Format
});
if !matches {
self.tex = None;
self.stored_at = None;
// Struct-update from `default()` so the flag fields keep their zero default whatever
// their windows-crate type — a copy-only texture wants no bind/misc/CPU flags (in
// particular NOT the source's SHARED_KEYEDMUTEX, which would gate every copy).
let make = D3D11_TEXTURE2D_DESC {
Width: desc.Width,
Height: desc.Height,
MipLevels: 1,
ArraySize: 1,
Format: desc.Format,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
..Default::default()
};
let mut t: Option<ID3D11Texture2D> = None;
// SAFETY: `device` is the worker's live pooled device; `make` is a fully-initialized
// local desc and `t` a valid out-param, checked below (best-effort on failure).
if unsafe { device.CreateTexture2D(&make, None, Some(&mut t)) }.is_err() {
return;
}
self.tex = t;
}
if let Some(t) = self.tex.as_ref() {
// SAFETY: `t` and `src` are live, size/format-matched textures on the same (pooled)
// device; the pooled immediate context is multithread-protected (`Direct3DDevice`).
unsafe { context.CopyResource(t, src) };
self.stored_at = Some(at);
}
}
}
/// What [`FramePublisher::publish`] did with a surface — the worker feeds the [`FrameStash`] on
/// the outcomes where the ring did NOT take the frame.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PublishOutcome {
/// Copied into a slot + signaled the host.
Published,
/// The surface's size/format doesn't match the ring (a display mode-set / HDR flip racing the
/// host's ring recreate) — worth stashing: it is the freshest desktop image, in exactly the
/// descriptor the recreated ring will want.
DescMismatch,
/// Dropped without publishing (all slots busy / device error) — the host is alive and
/// consuming, so there is nothing to retain.
Dropped,
}
/// Publishes acquired swap-chain surfaces into the HOST-created ring. Owned by the swap-chain processor
/// thread; attached lazily once the host's channel delivery lands in the monitor stash.
pub struct FramePublisher {
@@ -146,6 +286,9 @@ pub struct FramePublisher {
/// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a
/// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1).
mismatch_logged: bool,
/// The slot of the most recent successful publish + when it happened — what [`Self::harvest_into`]
/// reads when this publisher is superseded. `None` until the first publish.
last_published: Option<(u32, Instant)>,
/// The render adapter (LUID) this publisher's device + opened ring textures live on. A worker
/// re-adopts a publisher preserved across a swap-chain unassign→reassign flap ONLY when the
/// freshly-assigned swap-chain renders on this SAME adapter (else the opened textures would be
@@ -362,6 +505,7 @@ impl FramePublisher {
ring_format: unsafe { (*header).dxgi_format },
generation: header_gen,
mismatch_logged: false,
last_published: None,
render_luid_low,
render_luid_high,
})
@@ -394,11 +538,50 @@ impl FramePublisher {
(self.render_luid_low, self.render_luid_high)
}
/// Copy the most recently PUBLISHED frame out of the ring into `stash` — called just before this
/// publisher is dropped for a supersede (a mid-session ring recreate, or a new session's channel
/// delivery), when the ring it wrote is about to become unreachable. Between sessions the driver
/// keeps publishing into the previous (host-side dead) ring, so the last-written slot IS the
/// current desktop image — harvesting it seeds the next attach's first-frame republish. Skips
/// when the stash already holds fresher content (see [`FrameStash::stored_at`]) or the slot's
/// keyed mutex can't be had within 8 ms (a live host mid-consume — frames are flowing anyway).
pub fn harvest_into(&self, device: &ID3D11Device, stash: &mut FrameStash) {
let Some((slot, at)) = self.last_published else {
return;
};
if stash.stored_at().is_some_and(|s| s >= at) {
return;
}
let Some(s) = self.slots.get(slot as usize) else {
return;
};
// SAFETY: `s.mutex` is the live keyed mutex on this ring slot's shared texture; an 8 ms
// try-acquire of key 0. Raw vtable call for the same reason as `publish` below: the `Result`
// wrapper erases the success-severity WAIT_TIMEOUT/WAIT_ABANDONED codes.
let hr =
unsafe { (Interface::vtable(&s.mutex).AcquireSync)(Interface::as_raw(&s.mutex), 0, 8) };
match hr.0 {
// Acquired — S_OK, or WAIT_ABANDONED (the host died holding the slot: ownership
// transferred to us; exactly the between-sessions case the harvest exists for).
0 | WAIT_ABANDONED_HRESULT => {
// STRAIGHT-LINE between acquire and release (`store` is infallible-by-contract:
// best-effort, no early return propagates past it), so the lock cannot leak.
stash.store(device, &self.context, &s.tex, at);
// SAFETY: the keyed mutex is held (acquired above); release it exactly once.
unsafe {
let _ = s.mutex.ReleaseSync(0);
}
}
// Busy or a genuine error — keep whatever the stash had.
_ => {}
}
}
/// Copy `surface` into the next free ring slot and signal the host. Never blocks (0 ms try-acquire).
pub fn publish(&mut self, surface: &ID3D11Texture2D) {
pub fn publish(&mut self, surface: &ID3D11Texture2D) -> PublishOutcome {
let ring_len = self.slots.len() as u32;
if ring_len == 0 {
return;
return PublishOutcome::Dropped;
}
// Format guard: `CopyResource` needs the surface + ring textures to share a DXGI format. Drop a
// frame that doesn't match (e.g. an FP16 HDR surface arriving while the ring is still BGRA, before
@@ -425,7 +608,7 @@ impl FramePublisher {
self.ring_format
);
}
return;
return PublishOutcome::DescMismatch;
}
self.mismatch_logged = false;
let start = self.next;
@@ -474,15 +657,17 @@ impl FramePublisher {
let _ = SetEvent(self.event);
}
self.next = (slot + 1) % ring_len;
return;
self.last_published = Some((slot, Instant::now()));
return PublishOutcome::Published;
}
// Busy — the host holds this slot (the designed backpressure): try the next one.
WAIT_TIMEOUT_HRESULT => continue,
// Genuine failure (negative HRESULT — device removed / invalid call): drop the frame.
_ => return,
_ => return PublishOutcome::Dropped,
}
}
// All slots busy — drop this frame (never block the swap-chain thread).
PublishOutcome::Dropped
}
}
@@ -69,6 +69,12 @@ pub struct MonitorObject {
/// ([`take_preserved_publisher`]) and resumes publishing into the same ring. Dropped with the
/// `MonitorObject` on teardown (closing its ring handles) if no worker ever reclaims it.
pub preserved_publisher: Option<crate::frame_transport::FramePublisher>,
/// The worker's [`FrameStash`](crate::frame_transport::FrameStash) (the retained last composed
/// frame — the first-frame guarantee) preserved across a swap-chain unassign→reassign flap,
/// tagged with the render-adapter LUID its texture lives on: the next worker re-adopts it only
/// on the SAME adapter (same pooled device — a cross-device texture would be unusable). Dropped
/// with the `MonitorObject` on teardown (it holds only a driver-private texture, no handles).
pub preserved_stash: Option<(crate::frame_transport::FrameStash, u32, i32)>,
/// When the entry was created — the watchdog skips still-initializing monitors.
pub created_at: Instant,
}
@@ -372,6 +378,46 @@ pub fn take_preserved_publisher(
.take()
}
/// Preserve an EXITING worker's [`FrameStash`](crate::frame_transport::FrameStash) on its monitor
/// across a swap-chain unassign→reassign flap, tagged with the render-adapter LUID it lives on
/// (see [`MonitorObject::preserved_stash`]). An empty stash, or one for a monitor that no longer
/// exists (genuine teardown), is simply dropped — unlike a publisher it owns no handles, so there
/// is nothing to hand back.
pub fn preserve_stash(
target_id: u32,
luid_low: u32,
luid_high: i32,
stash: crate::frame_transport::FrameStash,
) {
if target_id == 0 || stash.texture().is_none() {
return;
}
let mut lock = lock_monitors();
if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) {
m.preserved_stash = Some((stash, luid_low, luid_high));
}
}
/// Take (remove) the preserved [`FrameStash`](crate::frame_transport::FrameStash) for a freshly-
/// (re)assigned swap-chain worker — returned only when the worker's render adapter matches the one
/// the stash was preserved on (same pooled device); a mismatched stash is dropped (its texture
/// would be cross-device).
pub fn take_preserved_stash(
target_id: u32,
luid_low: u32,
luid_high: i32,
) -> Option<crate::frame_transport::FrameStash> {
if target_id == 0 {
return None;
}
let (stash, low, high) = lock_monitors()
.iter_mut()
.find(|m| m.target_id == target_id)?
.preserved_stash
.take()?;
((low, high) == (luid_low, luid_high)).then_some(stash)
}
/// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor
/// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must
/// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a
@@ -461,6 +507,7 @@ pub fn create_monitor(
swap_chain_processor: None,
frame_channel: None,
preserved_publisher: None,
preserved_stash: None,
created_at: Instant::now(),
});
id
@@ -7,7 +7,9 @@
//! (`IddCxSwapChainSetDevice`) and loops acquire/finish. STEP 6 lazily attaches a [`FramePublisher`] to
//! the host's shared ring and, on each acquired frame, `CopyResource`s `out.MetaData.pSurface` into the
//! next ring slot before finishing the frame (a non-IDD-push session simply never attaches and keeps
//! draining).
//! draining). Frames the ring can NOT take feed the [`FrameStash`] instead, which every fresh attach
//! republishes as its instant first frame — the first-frame guarantee that makes a session opened onto
//! an IDLE desktop show a picture without waiting for anything to dirty the display.
//!
//! Ported from the proven oracle (`packaging/windows/vdisplay-driver/pf-vdisplay/src/
//! swap_chain_processor.rs`) onto wdk-sys + wdk-iddcx. The oracle's `wdf_umdf`/`wdf_umdf_sys` are
@@ -23,7 +25,7 @@ use std::{
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
time::{Duration, Instant},
};
use wdk_sys::iddcx::{
@@ -48,7 +50,10 @@ use windows::{
core::{Interface, w},
};
use crate::{direct_3d_device::Direct3DDevice, frame_transport::FramePublisher};
use crate::{
direct_3d_device::Direct3DDevice,
frame_transport::{FramePublisher, FrameStash, PublishOutcome},
};
/// E_PENDING — `ReleaseAndAcquireBuffer2` returns this (HRESULT-shaped) when the swap-chain is valid but
/// DWM has composed no new frame yet; wait on the surface-available event and retry.
@@ -240,7 +245,8 @@ impl SwapChainProcessor {
// frame objects are unnamed — the host duplicates their handles into this process and delivers
// the values via IOCTL_SET_FRAME_CHANNEL, which the control plane stashes on our monitor
// (`monitor::take_frame_channel`). Until a delivery lands we just drain — exactly the STEP-5
// behaviour — so a non-IDD-push session never stalls. The stash is polled every ~30 iterations.
// behaviour — so a non-IDD-push session never stalls. The frame-channel stash is polled every
// iteration (attach latency = first-frame latency, since attach republishes the FrameStash).
// STEP 6 sibling-join fix: re-adopt a FramePublisher PRESERVED across a swap-chain
// unassign→reassign flap. When a SIBLING display churns the desktop topology (a second client
// joining / leaving / resizing), the OS reassigns THIS monitor's swap-chain and the previous
@@ -266,7 +272,13 @@ impl SwapChainProcessor {
None
}
});
let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration
// The FIRST-FRAME stash (see `FrameStash`): the retained last composed frame, republished
// into every fresh ring at attach so a session opening onto an idle desktop is never black.
// Re-adopted across a swap-chain flap on the same adapter (`take_preserved_stash` drops a
// cross-adapter one — its texture lives on the other adapter's pooled device).
let mut stash: FrameStash =
crate::monitor::take_preserved_stash(target_id, render_luid_low, render_luid_high)
.unwrap_or_default();
let mut logged_pending = false;
let mut logged_frame = false;
@@ -293,37 +305,49 @@ impl SwapChainProcessor {
if publisher.as_ref().is_some_and(FramePublisher::is_stale)
|| (publisher.is_some() && crate::monitor::has_frame_channel(target_id))
{
publisher = None;
frames_since_try = u32::MAX; // re-attach immediately
// Harvest the superseded ring's last-published frame into the stash BEFORE dropping
// the publisher: between sessions the driver keeps publishing into the (host-side
// dead) previous ring, so that slot holds the CURRENT desktop image — exactly what
// the new ring's attach below republishes as its instant first frame.
if let Some(p) = publisher.take() {
p.harvest_into(&device.device, &mut stash);
}
}
// Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is idle
// (E_PENDING / no frames presented yet), not only when a frame is acquired. Checking the
// stash is a cheap mutex peek that stays empty until the host's channel delivery lands; a
// Lazy-attach at the loop TOP so we keep trying even while the display is idle (E_PENDING /
// no frames presented yet), not only when a frame is acquired. Polled EVERY iteration (a
// cheap mutex peek, the same cost the pending-delivery check above already pays): attach
// latency is now first-frame latency, since the attach itself republishes the stash. A
// taken delivery is consumed whether the attach succeeds or not (on failure its handles are
// closed, the host's wait-for-attach reads the status code, and any retry is a NEW delivery).
if publisher.is_none() {
if frames_since_try >= 30 {
frames_since_try = 0;
if let Some(channel) = crate::monitor::take_frame_channel(target_id) {
// `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match`
// happy under `-D warnings`; attach on success, drop the delivery on Err.
// `target_id` binds the attach: the mapped ring must name THIS monitor
// (proto v3 validation inside from_channel — a cross-delivered ring is
// refused, never published into).
if let Ok(p) = FramePublisher::from_channel(
channel,
target_id,
render_luid_low,
render_luid_high,
&device.device,
&device.device_context,
) {
publisher = Some(p);
}
}
} else {
frames_since_try += 1;
// A failed attach only drops the delivery (its handles are closed inside from_channel;
// the host's wait-for-attach reads the status code, and any retry is a NEW delivery).
// `target_id` binds the attach: the mapped ring must name THIS monitor (proto v3
// validation inside from_channel — a cross-delivered ring is refused, never published
// into).
if publisher.is_none()
&& let Some(channel) = crate::monitor::take_frame_channel(target_id)
&& let Ok(mut p) = FramePublisher::from_channel(
channel,
target_id,
render_luid_low,
render_luid_high,
&device.device,
&device.device_context,
)
{
// FIRST-FRAME GUARANTEE: republish the retained desktop image into the fresh ring
// immediately. On an idle desktop DWM composes nothing, so without this the host
// would wait (and kick synthetic input) for a frame that may never come. A
// stale-descriptor stash (e.g. pre-HDR-flip) is rejected by publish()'s guard —
// at worst the old wait-for-compose path.
if let Some(t) = stash.texture()
&& p.publish(t) == PublishOutcome::Published
{
dbglog!(
"[pf-vd] frame-push(driver): republished the retained frame into the fresh ring (target={target_id}) — instant first frame, no compose needed"
);
}
publisher = Some(p);
}
// ...Buffer2 is required once CAN_PROCESS_FP16 is set. AcquireSystemMemoryBuffer=FALSE keeps
@@ -397,10 +421,24 @@ impl SwapChainProcessor {
// drop, below — the queued GPU copy is unaffected: D3D defers destruction, and
// the copy is ordered before the consumer via the slot keyed mutex).
let res = unsafe { IDXGIResource::from_raw(raw) };
if let Some(p) = publisher.as_mut()
&& let Ok(tex) = res.cast::<ID3D11Texture2D>()
{
p.publish(&tex);
if let Ok(tex) = res.cast::<ID3D11Texture2D>() {
match publisher.as_mut().map(|p| p.publish(&tex)) {
// Ring took it (or the host is alive and busy) — nothing to retain.
Some(PublishOutcome::Published | PublishOutcome::Dropped) => {}
// No ring, or the surface's descriptor doesn't match it (a mode-set /
// HDR flip racing the host's ring recreate): RETAIN the frame — it is
// the desktop image the next attach republishes as its first frame.
// Unattached/mismatched composes are damage-driven and transient, so
// the extra copy costs nothing at steady state.
Some(PublishOutcome::DescMismatch) | None => {
stash.store(
&device.device,
&device.device_context,
&tex,
Instant::now(),
);
}
}
}
// `res` drops here → the acquire's surface reference is released, pre-Finished.
}
@@ -427,6 +465,10 @@ impl SwapChainProcessor {
if let Some(p) = publisher.take() {
let _ = crate::monitor::preserve_publisher(target_id, p);
}
// Preserve the first-frame stash alongside (dropped inside if empty or the monitor is gone
// — it owns no handles, just a driver-private texture). The next worker on this adapter
// re-adopts it, so the retained frame survives the flap too.
crate::monitor::preserve_stash(target_id, render_luid_low, render_luid_high, stash);
}
}