Merge pull request 'Post-sleep sessions still failed on 0.25.0 — the host was holding open the very device its recovery asks PnP to cycle' (#119) from worktree-vdisplay-reap-pnputil into main
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m46s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 26s
android / android (push) Successful in 6m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / screenshots (push) Successful in 6m8s
deb / build-publish-host (push) Successful in 4m27s
deb / build-publish-client-arm64 (push) Successful in 2m0s
deb / build-publish (push) Successful in 5m36s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / rust (push) Successful in 9m44s
docker / deploy-docs (push) Successful in 36s
arch / build-publish (push) Successful in 12m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m14s
windows-host / package (push) Canceled after 11m59s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m46s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 26s
android / android (push) Successful in 6m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / screenshots (push) Successful in 6m8s
deb / build-publish-host (push) Successful in 4m27s
deb / build-publish-client-arm64 (push) Successful in 2m0s
deb / build-publish (push) Successful in 5m36s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / rust (push) Successful in 9m44s
docker / deploy-docs (push) Successful in 36s
arch / build-publish (push) Successful in 12m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m14s
windows-host / package (push) Canceled after 11m59s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #119
This commit was merged in pull request #119.
This commit is contained in:
@@ -547,7 +547,8 @@ pub struct IddPushCapturer {
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
||||
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
|
||||
// COM interfaces; the frame/cursor delivery closures own `Arc` clones of the control device and are
|
||||
// `Send + Sync` on their own). It is
|
||||
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
|
||||
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
|
||||
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
|
||||
|
||||
@@ -299,16 +299,21 @@ struct Pinger {
|
||||
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
||||
/// cached handle (every IOCTL fails with a gone-class code forever), so such a failure RETIRES it and
|
||||
/// the next [`VirtualDisplayManager::ensure_device`] reopens the (new) device interface, re-running
|
||||
/// the version handshake. Retired handles are deliberately kept alive — never closed — for the
|
||||
/// process lifetime: the pinger/linger threads and every capturer's `ChannelBroker` hold BARE
|
||||
/// `HANDLE` copies whose soundness contract is "never closed"; a retired handle only ever FAILS
|
||||
/// IOCTLs, which every holder already tolerates. Reopens are rare (a driver restart), so the retained
|
||||
/// list is bounded in practice.
|
||||
/// the version handshake.
|
||||
///
|
||||
/// Ownership is `Arc` all the way out: every consumer — `acquire`'s IOCTL runs, the pinger/linger
|
||||
/// threads, the capture layer's delivery closures — holds its OWN clone across its use, so retiring
|
||||
/// here merely drops the manager's reference and the handle CLOSES when the last in-flight user
|
||||
/// drains. That close is load-bearing, not housekeeping: an open control handle is exactly what
|
||||
/// vetoes the PnP disable/restart the wake-from-sleep recovery leans on (field 2026-08-08 — every
|
||||
/// reload REFUSED `Generic failure`; `reset-pf-vdisplay.ps1` stops the whole host service precisely
|
||||
/// to get its handles closed, and Arc ownership buys the same release without dying). The previous
|
||||
/// contract kept retired handles open for the process lifetime because bare `HANDLE` copies were
|
||||
/// smuggled into threads and closures; those copies are gone, and nothing may rely on a dead
|
||||
/// handle staying open again.
|
||||
#[derive(Default)]
|
||||
struct DeviceSlot {
|
||||
current: Option<Arc<OwnedHandle>>,
|
||||
/// Never dropped — see the type doc (bare-`HANDLE` holders rely on no-close).
|
||||
retired: Vec<Arc<OwnedHandle>>,
|
||||
/// `CLEAR_ALL` (crashed-host orphan reap) runs only on the FIRST open of the process; a reopen
|
||||
/// races sessions this process still considers live and must not raze them.
|
||||
opened_once: bool,
|
||||
@@ -397,11 +402,6 @@ pub fn vdm() -> &'static VirtualDisplayManager {
|
||||
.expect("VirtualDisplayManager used before a backend initialised it")
|
||||
}
|
||||
|
||||
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
|
||||
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: cached handles are never closed
|
||||
/// for the process lifetime — a dead one is RETIRED (kept alive, see [`DeviceSlot`]), so a stale copy
|
||||
/// can only fail IOCTLs, never dangle. `None` before the first backend open — impossible for a
|
||||
/// capturer, which only exists on a monitor the manager created.
|
||||
/// Can this host's pf-vdisplay driver run the v5 hardware-cursor channel? Reads the
|
||||
/// handshake-latched protocol version, opening the control device once if no session has
|
||||
/// opened it yet this service run (the same open every session performs anyway) — so the
|
||||
@@ -421,7 +421,13 @@ pub fn hw_cursor_capable() -> bool {
|
||||
m.driver_proto.load(Ordering::Relaxed) >= 5
|
||||
}
|
||||
|
||||
pub fn control_device_handle() -> Option<HANDLE> {
|
||||
/// The live pf-vdisplay control device, for the IDD-push capturer's sealed-channel delivery
|
||||
/// (`IOCTL_SET_FRAME_CHANNEL`) — an `Arc` clone the caller (and every closure it builds) holds for
|
||||
/// as long as it may issue IOCTLs: the handle stays open while any holder lives and closes when the
|
||||
/// last drains, which is what lets the wake-from-sleep recovery's PnP disable proceed once the
|
||||
/// manager retires it (see [`DeviceSlot`]). `None` before the first backend open — impossible for a
|
||||
/// capturer, which only exists on a monitor the manager created.
|
||||
pub fn control_device_handle() -> Option<Arc<OwnedHandle>> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
@@ -497,17 +503,28 @@ fn is_device_gone(e: &anyhow::Error) -> bool {
|
||||
GONE.contains(&w.code().0)
|
||||
}
|
||||
|
||||
/// The transient raw `HANDLE` view of an Arc-held control device, for the backend IOCTL surface.
|
||||
/// Sound only while the `Arc` it borrows from is held — which the borrow makes structural: every
|
||||
/// use site necessarily has the owning clone alive across the call, so a concurrent retire (which
|
||||
/// now really closes the handle once its users drain — see [`DeviceSlot`]) can never close it
|
||||
/// mid-IOCTL.
|
||||
fn dev_raw(dev: &OwnedHandle) -> HANDLE {
|
||||
HANDLE(dev.as_raw_handle())
|
||||
}
|
||||
|
||||
impl VirtualDisplayManager {
|
||||
pub(crate) fn backend_name(&self) -> &'static str {
|
||||
self.driver.name()
|
||||
}
|
||||
|
||||
/// Open + cache the control device; REOPEN when a gone-classified failure retired the cached one
|
||||
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens.
|
||||
fn ensure_device(&self) -> Result<HANDLE> {
|
||||
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens. Returns an
|
||||
/// `Arc` clone the caller holds across every IOCTL it derives from it — a concurrent retire then
|
||||
/// drops only the manager's reference and closes nothing under the caller (see [`DeviceSlot`]).
|
||||
fn ensure_device(&self) -> Result<Arc<OwnedHandle>> {
|
||||
let mut slot = self.device.lock().unwrap();
|
||||
if let Some(d) = &slot.current {
|
||||
return Ok(HANDLE(d.as_raw_handle()));
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let reap = !slot.opened_once;
|
||||
claim_instance()?;
|
||||
@@ -519,35 +536,33 @@ impl VirtualDisplayManager {
|
||||
slot.opened_once = true;
|
||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||
self.driver_proto.store(driver_proto, Ordering::Relaxed);
|
||||
let raw = HANDLE(handle.as_raw_handle());
|
||||
slot.current = Some(Arc::new(handle));
|
||||
let dev = Arc::new(handle);
|
||||
slot.current = Some(dev.clone());
|
||||
if !reap {
|
||||
tracing::info!("virtual-display control device reopened (retired handle replaced)");
|
||||
}
|
||||
Ok(raw)
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
/// The live control handle for the pinger/linger threads. `None` before the first acquire opened
|
||||
/// it, or between a retire and the next reopen.
|
||||
fn device_handle(&self) -> Option<HANDLE> {
|
||||
self.device
|
||||
.lock()
|
||||
.unwrap()
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|d| HANDLE(d.as_raw_handle()))
|
||||
/// The live control device for the pinger/linger threads — an `Arc` clone the caller holds
|
||||
/// across its IOCTLs. `None` before the first acquire opened it, or between a retire and the
|
||||
/// next reopen.
|
||||
fn device_handle(&self) -> Option<Arc<OwnedHandle>> {
|
||||
self.device.lock().unwrap().current.clone()
|
||||
}
|
||||
|
||||
/// Retire the cached control handle after a gone-classified IOCTL failure. The handle is retained
|
||||
/// un-closed (see [`DeviceSlot`]); the next [`ensure_device`](Self::ensure_device) reopens the
|
||||
/// (new) device interface and re-runs the version handshake.
|
||||
/// Retire the cached control handle after a gone-classified IOCTL failure: drop the manager's
|
||||
/// reference, so the handle CLOSES once the last in-flight user drains (see [`DeviceSlot`]) —
|
||||
/// the release the wake-from-sleep recovery needs before it can cycle the adapter devnode. The
|
||||
/// next [`ensure_device`](Self::ensure_device) reopens the (new) device interface and re-runs
|
||||
/// the version handshake.
|
||||
fn invalidate_device(&self, why: &anyhow::Error) {
|
||||
let mut slot = self.device.lock().unwrap();
|
||||
if let Some(cur) = slot.current.take() {
|
||||
if slot.current.take().is_some() {
|
||||
tracing::warn!(
|
||||
"virtual-display control device retired — reopening on next use (cause: {why:#})"
|
||||
"virtual-display control device retired — closes when its last user drains, \
|
||||
reopening on next use (cause: {why:#})"
|
||||
);
|
||||
slot.retired.push(cur);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,11 +635,11 @@ impl VirtualDisplayManager {
|
||||
old_target,
|
||||
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the
|
||||
// value `ensure_device()` returned above (cached handles are never closed — a dead one
|
||||
// is retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
||||
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; the `dev`
|
||||
// Arc `ensure_device()` returned above is held across this call, so the handle stays
|
||||
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||
// is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||
// Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3).
|
||||
@@ -657,11 +672,11 @@ impl VirtualDisplayManager {
|
||||
wudf_pid = mon.wudf_pid,
|
||||
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is the value
|
||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
||||
// retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev` Arc
|
||||
// `ensure_device()` returned above is held across this call, so the handle stays
|
||||
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||
// is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Same async-departure settle as the reconnect preempt above (verified wait, P0.3).
|
||||
let _ = wait_target_departed(old_target, Duration::from_millis(400));
|
||||
}
|
||||
@@ -693,9 +708,10 @@ impl VirtualDisplayManager {
|
||||
else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; the CCD
|
||||
// waits inside run under the held `state` lock (this fn's discipline).
|
||||
match unsafe { self.resize_in_place(dev, mon, mode) } {
|
||||
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across
|
||||
// this call (so the handle stays open); the CCD waits inside run under
|
||||
// the held `state` lock (this fn's discipline).
|
||||
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
|
||||
Ok(()) => {
|
||||
// Same join semantics as the re-arrival: +1 ref for the new
|
||||
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
||||
@@ -734,10 +750,11 @@ impl VirtualDisplayManager {
|
||||
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; `re_add` touches the
|
||||
// live topology under the held `state` lock. `mon` is owned here (removed from the map).
|
||||
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across this call
|
||||
// (so the handle stays open); `re_add` touches the live topology under the held
|
||||
// `state` lock. `mon` is owned here (removed from the map).
|
||||
let new_mon = match unsafe {
|
||||
self.re_add(dev, &mut inner, slot, &mon, mode, client_hdr)
|
||||
self.re_add(dev_raw(&dev), &mut inner, slot, &mon, mode, client_hdr)
|
||||
} {
|
||||
ReAdd::Arrived(m) => *m,
|
||||
ReAdd::RolledBack {
|
||||
@@ -815,11 +832,11 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
|
||||
// The slot is empty: create a fresh monitor for it.
|
||||
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the handle
|
||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is retired,
|
||||
// kept alive; see `DeviceSlot`), and we hold the `state` lock.
|
||||
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; the `dev` Arc
|
||||
// `ensure_device()` returned above is held across this call (so the handle stays open even
|
||||
// against a concurrent retire), and we hold the `state` lock.
|
||||
let mon = match unsafe {
|
||||
self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)
|
||||
self.create_monitor(dev_raw(&dev), mode, slot, client_hdr, hw_cursor, &mut inner)
|
||||
} {
|
||||
// The cached device died under us (driver upgrade / WUDFHost restart, detected only
|
||||
// now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and
|
||||
@@ -831,9 +848,18 @@ impl VirtualDisplayManager {
|
||||
tracing::info!(
|
||||
"virtual-display control device reopened — retrying the monitor create"
|
||||
);
|
||||
// SAFETY: as above — `dev` is the handle the reopening `ensure_device` just
|
||||
// returned, and the `state` lock is still held.
|
||||
unsafe { self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)? }
|
||||
// SAFETY: as above — the `dev` Arc the reopening `ensure_device` just returned is
|
||||
// held across this call, and the `state` lock is still held.
|
||||
unsafe {
|
||||
self.create_monitor(
|
||||
dev_raw(&dev),
|
||||
mode,
|
||||
slot,
|
||||
client_hdr,
|
||||
hw_cursor,
|
||||
&mut inner,
|
||||
)?
|
||||
}
|
||||
}
|
||||
r => r?,
|
||||
};
|
||||
@@ -887,13 +913,12 @@ impl VirtualDisplayManager {
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
if let Some(h) = vdm().device_handle() {
|
||||
// SAFETY: `ping` requires `dev` to be a valid control handle. `h` is from
|
||||
// `device_handle()` (the `Some` branch) — cached handles are NEVER closed for the
|
||||
// process lifetime (a dead one is retired, kept alive; see `DeviceSlot`), so the
|
||||
// handle stays valid for this call even if it was retired concurrently — at worst
|
||||
// the IOCTL fails. The pinger thread only spins while the `&'static` manager
|
||||
// singleton lives.
|
||||
match unsafe { vdm().driver.ping(h) } {
|
||||
// SAFETY: `ping` requires `dev` to be a valid control handle. The `h` Arc from
|
||||
// `device_handle()` is held across this call, so the handle stays open even if
|
||||
// it is retired concurrently — at worst the IOCTL fails (the retire drops only
|
||||
// the manager's reference; see `DeviceSlot`). The pinger thread only spins
|
||||
// while the `&'static` manager singleton lives.
|
||||
match unsafe { vdm().driver.ping(dev_raw(&h)) } {
|
||||
Ok(()) => warned = false,
|
||||
Err(e) if is_device_gone(&e) => {
|
||||
// The device itself is gone (driver upgrade / WUDFHost restart) — pings
|
||||
@@ -1897,12 +1922,11 @@ impl VirtualDisplayManager {
|
||||
slot,
|
||||
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; `dev`
|
||||
// is the cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some`
|
||||
// checked above; cached handles are never closed — a dead one is retired, kept
|
||||
// alive). `mon` was moved out of the map under the `state` lock, so it is
|
||||
// exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; the
|
||||
// `dev` Arc from `device_handle()` (the `Some` checked above) is held across
|
||||
// this call, so the handle stays open. `mon` was moved out of the map under the
|
||||
// `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
}
|
||||
None => {
|
||||
inner.slots.insert(
|
||||
@@ -1980,10 +2004,10 @@ impl VirtualDisplayManager {
|
||||
"IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle;
|
||||
// `dev` is the cached process-lifetime `OwnedHandle` from `device_handle()`
|
||||
// (the `Some` checked above). `mon` was moved out of the map under the
|
||||
// `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// the `dev` Arc from `device_handle()` (the `Some` checked above) is held
|
||||
// across this call, so the handle stays open. `mon` was moved out of the
|
||||
// map under the `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Let the OS finish the ASYNC departure before the next ADD (mirrors the
|
||||
// acquire() Lingering-preempt settle).
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
@@ -2051,11 +2075,12 @@ impl VirtualDisplayManager {
|
||||
// its session. Lock order stays state → device (teardown's invalidate
|
||||
// path), same as every other holder; the pinger takes only the device
|
||||
// lock — no inversion.
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is
|
||||
// from `self.device_handle()` (cached handles are never closed — a dead
|
||||
// one is retired, kept alive; see `DeviceSlot`). `mon` was moved out of
|
||||
// the map under the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev, &mut g, mon) };
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
|
||||
// Arc from `self.device_handle()` is held across this call, so the
|
||||
// handle stays open (a concurrent retire drops only the manager's
|
||||
// reference; see `DeviceSlot`). `mon` was moved out of the map under
|
||||
// the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2218,11 +2243,11 @@ impl VirtualDisplayManager {
|
||||
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
|
||||
inner.slots.remove(&k)
|
||||
{
|
||||
// SAFETY: `teardown_removed` needs a live control handle; `dev` is from
|
||||
// `device_handle()` (cached handles are never closed — a dead one is retired, kept
|
||||
// alive; see `DeviceSlot`). `mon` was moved out of the map under the `state` lock,
|
||||
// so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// SAFETY: `teardown_removed` needs a live control handle; the `dev` Arc from
|
||||
// `device_handle()` is held across this call, so the handle stays open (see
|
||||
// `DeviceSlot`). `mon` was moved out of the map under the `state` lock, so it is
|
||||
// exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
released += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +100,27 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
|
||||
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
|
||||
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
|
||||
/// failure is logged and swallowed. Returns the number removed.
|
||||
///
|
||||
/// The outcome is logged UNCONDITIONALLY, as found + removed: the old script counted only removals
|
||||
/// and the host spoke only when that count was positive, so a reap whose pnputil never launched and
|
||||
/// a box with no ghosts produced byte-identical logs (silence) — the same vacuous-signal family as
|
||||
/// the `status=OK` trap [`reload_vdisplay_adapter`] answers — while ghosts ratcheted toward the
|
||||
/// wedge with every sleep cycle.
|
||||
fn reap_ghost_monitors() -> u32 {
|
||||
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
|
||||
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
|
||||
// non-English box (unlike a .ps1 *file* read in the machine codepage).
|
||||
//
|
||||
// pnputil is resolved by full path and `$LASTEXITCODE` pre-seeded to failure before every
|
||||
// launch, exactly like the reload path below: a LocalSystem service's PATH need not include
|
||||
// System32 (and a SYSTEM process must not trust PATH anyway — a planted `pnputil.exe` would run
|
||||
// elevated), and the old bare-name call failed INVISIBLY there — `SilentlyContinue` swallowed
|
||||
// the miss, no exit code was written, and the ghosts stayed to wedge `IOCTL_ADD` at 0x80070490.
|
||||
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$g = Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }; \
|
||||
$n = 0; foreach ($d in $g) { pnputil /remove-device $d.InstanceId *> $null; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||
Write-Output $n";
|
||||
$g = @(Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }); \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); \
|
||||
$n = 0; foreach ($d in $g) { $LASTEXITCODE = 1; if (Test-Path $pnp) { & $pnp /remove-device $d.InstanceId *> $null }; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||
Write-Output ($g.Count.ToString() + ' ' + $n)";
|
||||
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
|
||||
// System32 — with a bare-name fallback.
|
||||
let ps = std::env::var("SystemRoot")
|
||||
@@ -125,17 +138,29 @@ fn reap_ghost_monitors() -> u32 {
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let n = String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
if n > 0 {
|
||||
let raw = String::from_utf8_lossy(&o.stdout);
|
||||
let Some((found, removed)) = parse_reap_output(&raw) else {
|
||||
tracing::warn!(
|
||||
reaped = n,
|
||||
output = %raw.trim(),
|
||||
"pf-vdisplay: ghost-monitor reap died before reporting — ghost nodes (if any) still pin IddCx monitor slots"
|
||||
);
|
||||
return 0;
|
||||
};
|
||||
if found == 0 {
|
||||
tracing::info!("pf-vdisplay: no ghost (not-present) virtual-monitor nodes to reap");
|
||||
} else if removed < found {
|
||||
tracing::warn!(
|
||||
found,
|
||||
removed,
|
||||
"pf-vdisplay: ghost-monitor reap could NOT remove every ghost node — the leftovers keep pinning IddCx monitor slots toward the 0x80070490 wedge"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
reaped = removed,
|
||||
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
|
||||
);
|
||||
}
|
||||
n
|
||||
removed
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
|
||||
@@ -144,6 +169,18 @@ fn reap_ghost_monitors() -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse [`reap_ghost_monitors`]'s script output — `"<found> <removed>"`. Split out to be testable
|
||||
/// without a box, like [`classify_reload_output`]: the field failure this answers was a reap whose
|
||||
/// outcome could not be decoded from the log at all, so the decoding is worth pinning down. `None`
|
||||
/// = the script died before reporting (callers treat that as "removed nothing", loudly).
|
||||
fn parse_reap_output(out: &str) -> Option<(u32, u32)> {
|
||||
let mut it = out.split_whitespace().map(str::parse::<u32>);
|
||||
match (it.next(), it.next()) {
|
||||
(Some(Ok(found)), Some(Ok(removed))) => Some((found, removed)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
||||
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
||||
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
||||
@@ -178,6 +215,14 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
||||
// reported tokens are ours, so parsing them is locale-invariant too.
|
||||
//
|
||||
// The selector prefers LIVE devnodes: `Get-PnpDevice` also lists not-present PHANTOMS (an
|
||||
// upgrade/reinstall leftover), and the old `Select-Object -First 1` could hand every recovery
|
||||
// attempt a phantom — whose disable AND restart both fail — while a live node sat unexamined.
|
||||
// A phantom-only state gets its own truthful refusal: no reload lever can revive a devnode
|
||||
// record whose device is GONE; only re-creating the node (reinstall) can. `Present` is the
|
||||
// authoritative bit, with `Status -ne 'Unknown'` as the fallback should it read null; live
|
||||
// `OK` nodes sort ahead of problem-state ones.
|
||||
//
|
||||
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
||||
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
||||
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
||||
@@ -188,10 +233,19 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
||||
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
||||
// System32.
|
||||
//
|
||||
// The REFUSED line carries the evidence a field log needs to tell the failure modes apart
|
||||
// (2026-08-08: a woken box logged only `REFUSED Generic failure` — the WMI catch-all — leaving
|
||||
// handle-veto vs phantom vs problem-state undecidable): how many devnodes matched and how many
|
||||
// are live, the chosen node's PnP Status + ConfigManager problem code, and the pnputil
|
||||
// /restart-device exit code the old script threw away (3010 = needs a reboot, which is its own
|
||||
// diagnosis).
|
||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
||||
$id = $ad.InstanceId; $err = ''; \
|
||||
$all = @(Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' }); \
|
||||
if ($all.Count -eq 0) { Write-Output 'ABSENT'; exit }; \
|
||||
$live = @($all | Where-Object { $_.Present -or $_.Status -ne 'Unknown' } | Sort-Object { $_.Status -ne 'OK' }); \
|
||||
if ($live.Count -eq 0) { Write-Output ('REFUSED only phantom (not-present) adapter devnodes remain (' + $all.Count + ') - the device node itself is gone and no reload can revive it; reinstalling the host re-creates it'); exit }; \
|
||||
$ad = $live[0]; $id = $ad.InstanceId; $err = ''; \
|
||||
try { \
|
||||
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
||||
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
||||
@@ -201,9 +255,11 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
||||
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
|
||||
$rx = $LASTEXITCODE; \
|
||||
if ($rx -eq 0) { Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; \
|
||||
Write-Output ('REFUSED devnodes=' + $all.Count + ' live=' + $live.Count + ' status=' + $ad.Status + ' problem=' + $ad.ConfigManagerErrorCode + ' restart_exit=' + $rx + ' ' + $err) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
@@ -1050,10 +1106,12 @@ const BRIEF_RETRY: Duration = Duration::from_secs(3);
|
||||
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
||||
/// on. The second caller through typically finds the interface already up and returns at once.
|
||||
///
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
|
||||
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
|
||||
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
|
||||
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock. The lock order is one-way —
|
||||
/// `RECOVERY` → `device`: the recovery's handle-release hooks (`invalidate_cached_device`, which
|
||||
/// drops the manager's reference so the control handle can CLOSE before the PnP cycle) take the
|
||||
/// `device` mutex while this is held. It must stay one-way: [`VdisplayDriver::open`] runs *inside*
|
||||
/// that same `device` mutex, so if it could also take this lock the two orders would invert and
|
||||
/// deadlock. It cannot — it never reloads.
|
||||
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
||||
@@ -1069,10 +1127,11 @@ pub fn ensure_available() -> Result<()> {
|
||||
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
wait_for_interface(NOT_READY_GRACE, true)
|
||||
};
|
||||
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
|
||||
// stack down and back up, so any control handle a previous session cached is dead by
|
||||
// construction — retire it while we know that for certain, rather than leaving the next session
|
||||
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
|
||||
// A reload tore the driver stack down and back up, so any control handle cached MEANWHILE (a
|
||||
// racing open during the arrival window) is dead by construction — retire it while we know
|
||||
// that for certain, rather than leaving the next session to discover it by having an IOCTL
|
||||
// fail. Usually a no-op now: the recovery path already released the manager's reference
|
||||
// before the reload (the handle-drain that lets the PnP cycle proceed at all).
|
||||
if reloaded {
|
||||
super::manager::invalidate_cached_device(
|
||||
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
||||
@@ -1119,12 +1178,33 @@ fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedH
|
||||
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
||||
// between absent and not-ready is treated as the transition it is.
|
||||
if probe.is_absent() {
|
||||
if absent_since.is_none() && reload {
|
||||
// First absent sighting on the recovery path: drop the manager's reference to the
|
||||
// (dead) control device NOW, so the ABSENT_SETTLE below doubles as the drain window
|
||||
// for every outstanding `Arc` clone — the handle then actually CLOSES before the
|
||||
// reload runs. An open control handle is exactly what vetoes the PnP disable (and
|
||||
// can wedge the pnputil restart) that the reload leans on; reset-pf-vdisplay.ps1
|
||||
// stops the whole host service to get the same release (field 2026-08-08: every
|
||||
// reload on a woken box came back REFUSED `Generic failure`). Gated on `reload`:
|
||||
// the BRIEF_RETRY caller runs inside the manager's `device` mutex, where taking it
|
||||
// again would deadlock — and that caller never reloads anyway.
|
||||
super::manager::invalidate_cached_device(
|
||||
"control interface absent — releasing the host's own device handle ahead of a \
|
||||
possible adapter reload",
|
||||
);
|
||||
}
|
||||
absent_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
absent_since = None;
|
||||
}
|
||||
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
||||
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
||||
// The not-ready path reaches here without the absent-sighting release above — drop the
|
||||
// manager's reference now for the same reason (idempotent: a second call is a no-op).
|
||||
super::manager::invalidate_cached_device(
|
||||
"adapter reload imminent — releasing the host's own device handle (open handles \
|
||||
veto the PnP cycle)",
|
||||
);
|
||||
match reload_vdisplay_adapter() {
|
||||
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
||||
// burning the arrival window on a box that simply does not have it installed.
|
||||
@@ -1195,6 +1275,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal must carry evidence, not just a verdict. The 2026-08-08 field log showed only
|
||||
/// `REFUSED Generic failure` — the WMI catch-all — leaving handle-veto vs phantom vs
|
||||
/// problem-state undecidable from the log. The enriched line's tokens (devnode counts, PnP
|
||||
/// status, problem code, the pnputil restart exit code the old script discarded) must survive
|
||||
/// decoding verbatim, and the phantom-only state must decode as a refusal too — a reload
|
||||
/// cannot revive a devnode record whose device is gone.
|
||||
#[test]
|
||||
fn a_refusal_keeps_its_evidence() {
|
||||
let why = match classify_reload_output(
|
||||
"REFUSED devnodes=2 live=1 status=OK problem=0 restart_exit=3010 Generic failure",
|
||||
) {
|
||||
AdapterCycle::Refused(why) => why,
|
||||
other => panic!("expected Refused, got {}", variant(&other)),
|
||||
};
|
||||
for token in ["devnodes=2", "live=1", "status=OK", "restart_exit=3010"] {
|
||||
assert!(why.contains(token), "{token} must survive: {why:?}");
|
||||
}
|
||||
assert!(matches!(
|
||||
classify_reload_output(
|
||||
"REFUSED only phantom (not-present) adapter devnodes remain (2) - the device node \
|
||||
itself is gone and no reload can revive it; reinstalling the host re-creates it"
|
||||
),
|
||||
AdapterCycle::Refused(why) if why.contains("phantom")
|
||||
));
|
||||
}
|
||||
|
||||
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
||||
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
||||
/// disable was refused and something still holds the device open).
|
||||
@@ -1226,6 +1332,29 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// The reap's outcome must decode losslessly — the field ratchet (0.23→0.25) was a reap whose
|
||||
/// bare-named pnputil never launched under the LocalSystem PATH while the host stayed silent:
|
||||
/// "no ghosts" and "removed nothing" were byte-identical. Found and removed now travel
|
||||
/// separately so a leftover ghost is loud, and the old single-number output (or a powershell
|
||||
/// that died before reporting) must not decode as anything.
|
||||
#[test]
|
||||
fn reap_output_decodes_found_and_removed() {
|
||||
assert_eq!(parse_reap_output("3 3\r\n"), Some((3, 3)));
|
||||
assert_eq!(
|
||||
parse_reap_output("4 0"),
|
||||
Some((4, 0)),
|
||||
"pnputil unlaunchable"
|
||||
);
|
||||
assert_eq!(parse_reap_output("0 0"), Some((0, 0)), "clean box");
|
||||
for dead in ["5", "", " ", "garbage", "OK"] {
|
||||
assert_eq!(
|
||||
parse_reap_output(dead),
|
||||
None,
|
||||
"{dead:?} is not a reap report"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
||||
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
||||
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
||||
|
||||
@@ -194,27 +194,29 @@ pub fn capture_virtual_output(
|
||||
crate::inject::set_stream_target(Some(target.target_id));
|
||||
let pref = vout.preferred_mode;
|
||||
let keep = vout.keepalive;
|
||||
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is
|
||||
// process-global — a dead one is retired, kept alive — so the raw value is stable for the
|
||||
// process) and wrap `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls
|
||||
// at ring attach. This is the ONE reach into `crate::vdisplay` the capturer would otherwise make;
|
||||
// building it here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
|
||||
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE and wrap
|
||||
// `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls at ring attach.
|
||||
// This is the ONE reach into `crate::vdisplay` the capturer would otherwise make; building it
|
||||
// here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
|
||||
let control = crate::vdisplay::manager::control_device_handle().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"pf-vdisplay control device not open (monitor not created via the manager?)"
|
||||
)
|
||||
})?;
|
||||
// `HANDLE` is not `Send`; capture the raw value and rebuild it inside the closure (the control
|
||||
// device is never closed for the process lifetime, so the value stays valid).
|
||||
let control_raw = control.0 as isize;
|
||||
// Each closure keeps its own `Arc<OwnedHandle>` clone (`Send + Sync`), so the handle is open
|
||||
// for exactly as long as any delivery closure lives — and CLOSES once the manager retires it
|
||||
// and the last session drops, which is what lets the wake-from-sleep recovery's PnP device
|
||||
// cycle proceed (an open control handle vetoes it).
|
||||
let control_frame = control.clone();
|
||||
let sender: pf_capture::FrameChannelSender = std::sync::Arc::new(
|
||||
move |req: &pf_driver_proto::control::SetFrameChannelRequest| {
|
||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is never
|
||||
// closed for the process lifetime, so reconstructing the `HANDLE` and issuing the
|
||||
// `IOCTL_SET_FRAME_CHANNEL` is sound (`send_frame_channel`'s precondition).
|
||||
// SAFETY: the captured `control_frame` Arc keeps the control handle open across this
|
||||
// call — `send_frame_channel`'s precondition.
|
||||
unsafe {
|
||||
crate::vdisplay::driver::send_frame_channel(
|
||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
||||
windows::Win32::Foundation::HANDLE(
|
||||
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_frame),
|
||||
),
|
||||
req,
|
||||
)
|
||||
}
|
||||
@@ -231,14 +233,17 @@ pub fn capture_virtual_output(
|
||||
// Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure —
|
||||
// its presence opts the session in (the capturer creates + delivers the CursorShm section,
|
||||
// the driver declares the IddCx hardware cursor). Built exactly like `sender` above.
|
||||
let control_cursor = control.clone();
|
||||
let cursor_sender: Option<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
|
||||
std::sync::Arc::new(
|
||||
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
|
||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
||||
// never closed for the process lifetime (`send_cursor_channel`'s precondition).
|
||||
// SAFETY: the captured `control_cursor` Arc keeps the control handle open across
|
||||
// this call (`send_cursor_channel`'s precondition).
|
||||
unsafe {
|
||||
crate::vdisplay::driver::send_cursor_channel(
|
||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
||||
windows::Win32::Foundation::HANDLE(
|
||||
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_cursor),
|
||||
),
|
||||
req,
|
||||
)
|
||||
}
|
||||
@@ -261,11 +266,13 @@ pub fn capture_virtual_output(
|
||||
target_id,
|
||||
enable: enable as u32,
|
||||
};
|
||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
||||
// never closed for the process lifetime (`send_cursor_forward`'s precondition).
|
||||
// SAFETY: the captured `control` Arc keeps the control handle open across this call
|
||||
// (`send_cursor_forward`'s precondition).
|
||||
unsafe {
|
||||
crate::vdisplay::driver::send_cursor_forward(
|
||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
||||
windows::Win32::Foundation::HANDLE(
|
||||
std::os::windows::io::AsRawHandle::as_raw_handle(&*control),
|
||||
),
|
||||
&req,
|
||||
)?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user