Merge perf/first-frame-latency: driver proto v4 + first-frame/resize latency (P0-P2)
Brings the first-frame-latency branch (P0.1 transition tracing, P1.1/P1.2 Welcome-time display prep, P2 in-place resize; pf-driver-proto v3 -> v4 with IOCTL_UPDATE_MODES) onto current main. The branch predates the W6.2/W7 splits, so git's rename detection carried most of it into the moved crates (pf-capture idd_push, pf-vdisplay manager/pf_vdisplay, pf-win-display, pf-driver-proto, the driver workspace) and the punktfunk1.rs remainder was re-homed by hand: - native/handshake.rs: welcome/start trace marks + the Welcome-time display prep spawn (the prep thread BECOMES the stream thread; hand-off via a SyncSender<SessionContext>). negotiate() gains bringup/quit/stop and returns the PrepHandle. - native.rs: bringup/resize_ms creation + the stop/quit flags hoisted BEFORE the handshake (the close watcher splits: flags pre-handshake, lifecycle events post-handshake where `hello` exists); punch_done stamp; the data plane adopts the prep thread's result or builds inline. - native/stream.rs: SessionContext/SendStats carry the trace; send_loop finishes it on the first video packet; the resize path gains the in-place fast path (try_inplace_resize) with the full rebuild as fallback, restructured so both share the post-rebuild bookkeeping; prepare_display/PreparedDisplay/ PrepHandle; build_pipeline(+retry) thread the stage marks. - session_status/mgmt: ttff_ms + last_resize_ms per session (union with the lifecycle-events fields main added to the same spots). - pf-capture: Capturer gains capture_target_id() + resize_output() defaults. - pf-vdisplay manager: perf's faster activation poll (60x50ms) + the settle floor before the PnP sweep, on main's knobs/no-trait shape. Also: packaging/windows/build-gamepad-drivers.ps1 is ASCII again (an em-dash from the pf-mouse work tripped windows-host.yml's locale-safety gate on main). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4617,6 +4617,15 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
|
"last_resize_ms": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Most recent mid-stream resize total, reconfigure → pipeline rebuilt, in ms (native sessions;\n`null` when no resize happened / GameStream).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"min_fec": {
|
"min_fec": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
@@ -4629,6 +4638,15 @@
|
|||||||
"description": "Video payload size per packet (bytes).",
|
"description": "Video payload size per packet (bytes).",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
|
"time_to_first_frame_ms": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Session bring-up total, hello → first video packet, in ms (native sessions; `null` on the\nGameStream plane or while the session is still bringing up).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
|
|||||||
@@ -56,6 +56,24 @@ pub trait Capturer: Send {
|
|||||||
fn pipeline_depth(&self) -> usize {
|
fn pipeline_depth(&self) -> usize {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
||||||
|
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
||||||
|
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
||||||
|
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
||||||
|
fn capture_target_id(&self) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HOST-INITIATED output resize (latency plan P2.3): the session's resize handler has ALREADY
|
||||||
|
/// committed the display's new mode (the manager's in-place mode set), so a capable capturer
|
||||||
|
/// re-sizes its capture surface NOW — no descriptor-poll debounce (that machinery stays, for
|
||||||
|
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
||||||
|
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
||||||
|
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
||||||
|
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||||
|
|||||||
@@ -698,8 +698,24 @@ impl IddPushCapturer {
|
|||||||
let enabled_hdr = client_10bit
|
let enabled_hdr = client_10bit
|
||||||
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||||
if enabled_hdr {
|
if enabled_hdr {
|
||||||
// Let the colorspace change settle before the driver composes + we size the ring.
|
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||||
std::thread::sleep(Duration::from_millis(250));
|
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
|
||||||
|
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
|
||||||
|
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
|
||||||
|
// either way (the set succeeded; only the driver's compose flip may lag, which the
|
||||||
|
// stash/format-guard machinery absorbs).
|
||||||
|
let hdr_settle = Instant::now();
|
||||||
|
while hdr_settle.elapsed() < Duration::from_millis(250) {
|
||||||
|
if crate::win_display::advanced_color_enabled(target.target_id) == Some(true) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
target_id = target.target_id,
|
||||||
|
settle_ms = hdr_settle.elapsed().as_millis() as u64,
|
||||||
|
"IDD push: advanced-color (HDR) enable settle"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||||
@@ -983,7 +999,14 @@ impl IddPushCapturer {
|
|||||||
self.no_first_frame_diagnosis(st)
|
self.no_first_frame_diagnosis(st)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
|
||||||
|
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
|
||||||
|
// driver_status polls above live (status writes don't signal the event). Consuming a
|
||||||
|
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
|
||||||
|
// event, for truth.
|
||||||
|
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
|
||||||
|
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
|
||||||
|
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1615,6 +1638,39 @@ impl Capturer for IddPushCapturer {
|
|||||||
// always has its own texture).
|
// always has its own texture).
|
||||||
pf_host_config::config().idd_depth.clamp(1, OUT_RING)
|
pf_host_config::config().idd_depth.clamp(1, OUT_RING)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn capture_target_id(&self) -> Option<u32> {
|
||||||
|
Some(self.target_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_output(&mut self, width: u32, height: u32) -> bool {
|
||||||
|
// Host-initiated resize (latency plan P2.3): the session's resize handler has already
|
||||||
|
// committed the display's new mode (the manager's in-place mode set), so recreate the ring
|
||||||
|
// at the new size NOW — no DescriptorPoller two-strike debounce (that stays, unchanged,
|
||||||
|
// for EXTERNAL changes: HDR flips, game mode-sets). The driver re-attaches to the fresh
|
||||||
|
// ring and republishes; on an in-place mode set the OS's mode-set full redraw gives the
|
||||||
|
// stash/first frame within the recover window. Same recover-or-drop arming as the
|
||||||
|
// poller-driven recreate, so a ring that can't re-attach still fails the session cleanly
|
||||||
|
// instead of freezing.
|
||||||
|
if (width, height) == (self.width, self.height) {
|
||||||
|
return true; // already at the requested size (refresh-only change) — nothing to do
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
target_id = self.target_id,
|
||||||
|
from = format!("{}x{}", self.width, self.height),
|
||||||
|
to = format!("{width}x{height}"),
|
||||||
|
"IDD push: host-initiated resize — recreating the ring at the new mode"
|
||||||
|
);
|
||||||
|
self.recovering_since.get_or_insert_with(Instant::now);
|
||||||
|
if let Err(e) = self.recreate_ring(self.display_hdr, width, height) {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"IDD push: host-initiated ring recreate failed — falling back to a full rebuild"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
|
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
|
||||||
|
|||||||
@@ -59,7 +59,19 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
|||||||
/// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's
|
/// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's
|
||||||
/// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver
|
/// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver
|
||||||
/// against a v2 host would refuse every attach — lockstep by the handshake, as ever.
|
/// against a v2 host would refuse every attach — lockstep by the handshake, as ever.
|
||||||
pub const PROTOCOL_VERSION: u32 = 3;
|
/// v4: ADDITIVE — [`control::IOCTL_UPDATE_MODES`] (the in-place mid-stream resize,
|
||||||
|
/// `design/first-frame-and-resize-latency.md` P2): the driver refreshes a LIVE monitor's advertised
|
||||||
|
/// target-mode list (`IddCxMonitorUpdateModes2`) so the OS can mode-set to an arbitrary new mode
|
||||||
|
/// without a REMOVE→ADD monitor hotplug. Nothing existing changed, so the host accepts a v3 driver
|
||||||
|
/// too ([`MIN_DRIVER_PROTOCOL_VERSION`]) and simply falls back to the re-arrival resize against it;
|
||||||
|
/// a v4 driver serving an older (v3-asserting) host fails that host's strict handshake — ship
|
||||||
|
/// driver+host together, as ever.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 4;
|
||||||
|
|
||||||
|
/// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on
|
||||||
|
/// [`PROTOCOL_VERSION`]): a v3 driver lacks only `IOCTL_UPDATE_MODES`, which the host gates on the
|
||||||
|
/// handshake-reported version and covers with the re-arrival fallback.
|
||||||
|
pub const MIN_DRIVER_PROTOCOL_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
||||||
pub const fn ctl_code(func: u32) -> u32 {
|
pub const fn ctl_code(func: u32) -> u32 {
|
||||||
@@ -91,6 +103,13 @@ pub mod control {
|
|||||||
/// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once
|
/// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once
|
||||||
/// after the ring is created and again on every mid-session ring recreate (HDR-mode flip).
|
/// after the ring is created and again on every mid-session ring recreate (HDR-mode flip).
|
||||||
pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906);
|
pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906);
|
||||||
|
/// Refresh a LIVE monitor's advertised target-mode list to a new preferred mode (+ the built-in
|
||||||
|
/// fallbacks) via `IddCxMonitorUpdateModes2` — the in-place mid-stream resize (v4,
|
||||||
|
/// `design/first-frame-and-resize-latency.md` P2). Input [`UpdateModesRequest`]. The host then
|
||||||
|
/// CCD-forces the new mode active on the SAME monitor: no REMOVE→ADD hotplug, the monitor's OS
|
||||||
|
/// identity (saved per-monitor DPI) and the driver's swap-chain/stash machinery survive. A v3
|
||||||
|
/// driver fails this unknown IOCTL → the host falls back to the re-arrival resize.
|
||||||
|
pub const IOCTL_UPDATE_MODES: u32 = ctl_code(0x907);
|
||||||
|
|
||||||
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
||||||
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
||||||
@@ -164,6 +183,22 @@ pub mod control {
|
|||||||
pub session_id: u64,
|
pub session_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_UPDATE_MODES` input (v4): the live monitor (by its ADD `session_id`) and the new
|
||||||
|
/// preferred mode its target-mode list should lead with. The driver replaces the stored list
|
||||||
|
/// (new mode first, then its built-in fallbacks — the same shape ADD produces) and pushes it to
|
||||||
|
/// the OS via `IddCxMonitorUpdateModes2`; success means the OS accepted the new list, after
|
||||||
|
/// which the host force-sets the mode via CCD/GDI as usual.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct UpdateModesRequest {
|
||||||
|
pub session_id: u64,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub refresh_hz: u32,
|
||||||
|
/// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding).
|
||||||
|
pub _reserved: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on).
|
/// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on).
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
@@ -253,6 +288,12 @@ pub mod control {
|
|||||||
assert!(size_of::<RemoveRequest>() == 8);
|
assert!(size_of::<RemoveRequest>() == 8);
|
||||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||||
|
|
||||||
|
assert!(size_of::<UpdateModesRequest>() == 24);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, session_id) == 0);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, width) == 8);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, height) == 12);
|
||||||
|
assert!(offset_of!(UpdateModesRequest, refresh_hz) == 16);
|
||||||
|
|
||||||
assert!(size_of::<SetRenderAdapterRequest>() == 8);
|
assert!(size_of::<SetRenderAdapterRequest>() == 8);
|
||||||
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
|
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
|
||||||
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
|
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
|
||||||
@@ -1104,6 +1145,27 @@ mod tests {
|
|||||||
assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes());
|
assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_modes_request_roundtrips_and_versions_cohere() {
|
||||||
|
let req = control::UpdateModesRequest {
|
||||||
|
session_id: 42,
|
||||||
|
width: 2560,
|
||||||
|
height: 1409, // deliberately arbitrary — the in-place path serves window-drag modes
|
||||||
|
refresh_hz: 120,
|
||||||
|
_reserved: 0,
|
||||||
|
};
|
||||||
|
let bytes = bytemuck::bytes_of(&req);
|
||||||
|
assert_eq!(bytes.len(), 24);
|
||||||
|
assert_eq!(
|
||||||
|
*bytemuck::from_bytes::<control::UpdateModesRequest>(bytes),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
assert_eq!(bytes[8..12], 2560u32.to_le_bytes());
|
||||||
|
// The compat window: v4 is additive over v3, so the host floor stays one below.
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 4);
|
||||||
|
assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gamepad_names_and_magics_are_stable() {
|
fn gamepad_names_and_magics_are_stable() {
|
||||||
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
||||||
@@ -1145,6 +1207,7 @@ mod tests {
|
|||||||
control::IOCTL_GET_INFO,
|
control::IOCTL_GET_INFO,
|
||||||
control::IOCTL_CLEAR_ALL,
|
control::IOCTL_CLEAR_ALL,
|
||||||
control::IOCTL_SET_FRAME_CHANNEL,
|
control::IOCTL_SET_FRAME_CHANNEL,
|
||||||
|
control::IOCTL_UPDATE_MODES,
|
||||||
];
|
];
|
||||||
for (i, a) in all.iter().enumerate() {
|
for (i, a) in all.iter().enumerate() {
|
||||||
for b in &all[i + 1..] {
|
for b in &all[i + 1..] {
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ use windows::Win32::System::Threading::{
|
|||||||
use super::{DisplayOwnership, Mode, VirtualOutput};
|
use super::{DisplayOwnership, Mode, VirtualOutput};
|
||||||
use pf_win_display::win_display::{
|
use pf_win_display::win_display::{
|
||||||
count_other_active, force_extend_topology, isolate_displays_ccd, resolve_gdi_name,
|
count_other_active, force_extend_topology, isolate_displays_ccd, resolve_gdi_name,
|
||||||
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig,
|
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, wait_mode_settled,
|
||||||
|
wait_target_departed, SavedConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "manager/driver.rs"]
|
#[path = "manager/driver.rs"]
|
||||||
@@ -204,6 +205,16 @@ pub struct VirtualDisplayManager {
|
|||||||
/// `&'static` singleton with no raw-handle smuggling.
|
/// `&'static` singleton with no raw-handle smuggling.
|
||||||
device: Mutex<DeviceSlot>,
|
device: Mutex<DeviceSlot>,
|
||||||
watchdog_s: AtomicU32,
|
watchdog_s: AtomicU32,
|
||||||
|
/// The driver's handshake-reported protocol version (0 until the first open). The in-place
|
||||||
|
/// resize (latency plan P2) gates its UPDATE_MODES attempt on `>= 4`; a v3 driver keeps the
|
||||||
|
/// already-advertised fast path + the re-arrival fallback.
|
||||||
|
driver_proto: AtomicU32,
|
||||||
|
/// Latched `true` after an UPDATE_MODES round-trip failed to make the new mode settable —
|
||||||
|
/// on-glass (build 26200) the OS pins a monitor's settable set at ARRIVAL (it re-parses our
|
||||||
|
/// description + re-queries target modes, then ignores both), so every further attempt for an
|
||||||
|
/// out-of-arrival-list mode would only waste ~1 s per resize before the same re-arrival
|
||||||
|
/// fallback. One attempt per process, in case a future OS build honors the refresh.
|
||||||
|
update_modes_futile: AtomicBool,
|
||||||
/// Monotonic lease-generation counter (was the `MON_GEN` global).
|
/// Monotonic lease-generation counter (was the `MON_GEN` global).
|
||||||
gen: AtomicU64,
|
gen: AtomicU64,
|
||||||
state: Mutex<MgrInner>,
|
state: Mutex<MgrInner>,
|
||||||
@@ -234,6 +245,8 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
|
|||||||
driver,
|
driver,
|
||||||
device: Mutex::new(DeviceSlot::default()),
|
device: Mutex::new(DeviceSlot::default()),
|
||||||
watchdog_s: AtomicU32::new(3),
|
watchdog_s: AtomicU32::new(3),
|
||||||
|
driver_proto: AtomicU32::new(0),
|
||||||
|
update_modes_futile: AtomicBool::new(false),
|
||||||
gen: AtomicU64::new(1),
|
gen: AtomicU64::new(1),
|
||||||
state: Mutex::new(MgrInner::default()),
|
state: Mutex::new(MgrInner::default()),
|
||||||
setup_lock: Mutex::new(()),
|
setup_lock: Mutex::new(()),
|
||||||
@@ -321,9 +334,10 @@ impl VirtualDisplayManager {
|
|||||||
// FFI in the caller's apartment; the `device` mutex (held here) serializes it, so there is no
|
// FFI in the caller's apartment; the `device` mutex (held here) serializes it, so there is no
|
||||||
// concurrent open. `open` has no handle precondition to uphold, and the `OwnedHandle` it
|
// concurrent open. `open` has no handle precondition to uphold, and the `OwnedHandle` it
|
||||||
// returns is the sole owner of the device.
|
// returns is the sole owner of the device.
|
||||||
let (handle, watchdog_s) = unsafe { self.driver.open(reap)? };
|
let (handle, watchdog_s, driver_proto) = unsafe { self.driver.open(reap)? };
|
||||||
slot.opened_once = true;
|
slot.opened_once = true;
|
||||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||||
|
self.driver_proto.store(driver_proto, Ordering::Relaxed);
|
||||||
let raw = HANDLE(handle.as_raw_handle());
|
let raw = HANDLE(handle.as_raw_handle());
|
||||||
slot.current = Some(Arc::new(handle));
|
slot.current = Some(Arc::new(handle));
|
||||||
if !reap {
|
if !reap {
|
||||||
@@ -418,9 +432,10 @@ impl VirtualDisplayManager {
|
|||||||
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
|
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
|
||||||
inner.slots.remove(&slot)
|
inner.slots.remove(&slot)
|
||||||
{
|
{
|
||||||
|
let old_target = mon.target_id;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
slot,
|
slot,
|
||||||
old_target = mon.target_id,
|
old_target,
|
||||||
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
"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
|
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the
|
||||||
@@ -430,7 +445,16 @@ impl VirtualDisplayManager {
|
|||||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||||
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
// 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.
|
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||||
thread::sleep(Duration::from_millis(400));
|
// Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3).
|
||||||
|
// SAFETY: CCD query FFI over a `Copy` target id, under the held `state` lock.
|
||||||
|
let departed =
|
||||||
|
unsafe { wait_target_departed(old_target, Duration::from_millis(400)) };
|
||||||
|
if !departed {
|
||||||
|
tracing::debug!(
|
||||||
|
old_target,
|
||||||
|
"preempted monitor still in the active CCD set after the departure ceiling"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,9 +470,10 @@ impl VirtualDisplayManager {
|
|||||||
if matches!(inner.slots.get(&slot), Some(SlotState::Active { mon, .. }) if !wudf_alive(mon.wudf_pid))
|
if matches!(inner.slots.get(&slot), Some(SlotState::Active { mon, .. }) if !wudf_alive(mon.wudf_pid))
|
||||||
{
|
{
|
||||||
if let Some(SlotState::Active { mon, .. }) = inner.slots.remove(&slot) {
|
if let Some(SlotState::Active { mon, .. }) = inner.slots.remove(&slot) {
|
||||||
|
let old_target = mon.target_id;
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
slot,
|
slot,
|
||||||
old_target = mon.target_id,
|
old_target,
|
||||||
wudf_pid = mon.wudf_pid,
|
wudf_pid = mon.wudf_pid,
|
||||||
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
|
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
|
||||||
);
|
);
|
||||||
@@ -457,8 +482,9 @@ impl VirtualDisplayManager {
|
|||||||
// retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
// retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
||||||
// is exclusively owned here — no aliasing.
|
// is exclusively owned here — no aliasing.
|
||||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||||
// Same async-departure settle as the reconnect preempt above.
|
// Same async-departure settle as the reconnect preempt above (verified wait, P0.3).
|
||||||
thread::sleep(Duration::from_millis(400));
|
// SAFETY: CCD query FFI over a `Copy` target id, under the held `state` lock.
|
||||||
|
let _ = unsafe { wait_target_departed(old_target, Duration::from_millis(400)) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +501,57 @@ impl VirtualDisplayManager {
|
|||||||
_ => unreachable!("just matched Active"),
|
_ => unreachable!("just matched Active"),
|
||||||
};
|
};
|
||||||
if cur_mode != mode {
|
if cur_mode != mode {
|
||||||
|
// IN-PLACE mode set first (latency plan P2): an already-advertised resolution
|
||||||
|
// (arrival list + the driver's same-id mode history) is CCD-forced on the SAME
|
||||||
|
// monitor — no REMOVE→ADD, so the monitor's OS identity (saved per-monitor DPI),
|
||||||
|
// the driver-side swap-chain machinery and the retained frame stash all survive,
|
||||||
|
// and the whole hotplug cost (departure settle + activation ladder + re-isolate)
|
||||||
|
// disappears. An out-of-list mode fails FAST (see `resize_in_place`) and falls
|
||||||
|
// through to the proven re-arrival below.
|
||||||
|
{
|
||||||
|
let in_place = {
|
||||||
|
let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot)
|
||||||
|
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) } {
|
||||||
|
Ok(()) => {
|
||||||
|
// Same join semantics as the re-arrival: +1 ref for the new
|
||||||
|
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
||||||
|
// session's lease stays valid.
|
||||||
|
*refs += 1;
|
||||||
|
let refs = *refs;
|
||||||
|
let out = self.output_for(slot, mon, quit.clone());
|
||||||
|
tracing::info!(
|
||||||
|
slot,
|
||||||
|
refs,
|
||||||
|
backend = self.driver.name(),
|
||||||
|
"virtual monitor resized IN PLACE (identity + swap-chain kept)"
|
||||||
|
);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Expected-normal for a first-seen arbitrary size (the OS pins
|
||||||
|
// settable modes at arrival; the re-arrival teaches it) — info,
|
||||||
|
// not warn.
|
||||||
|
tracing::info!(
|
||||||
|
slot,
|
||||||
|
reason = %format!("{e:#}"),
|
||||||
|
"in-place resize not possible — monitor re-arrival"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(out) = in_place {
|
||||||
|
// The width changed — re-arrange the group so auto-row siblings don't
|
||||||
|
// overlap the resized display (no-op for a single member).
|
||||||
|
self.apply_group_layout(&mut inner);
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
||||||
unreachable!("just matched Active");
|
unreachable!("just matched Active");
|
||||||
};
|
};
|
||||||
@@ -736,8 +813,12 @@ impl VirtualDisplayManager {
|
|||||||
/// # Safety
|
/// # Safety
|
||||||
/// Runs the CCD (QueryDisplayConfig / SetDisplayConfig) FFI; call under the `state` lock.
|
/// Runs the CCD (QueryDisplayConfig / SetDisplayConfig) FFI; call under the `state` lock.
|
||||||
unsafe fn resolve_target_gdi(&self, target_id: u32) -> Option<String> {
|
unsafe fn resolve_target_gdi(&self, target_id: u32) -> Option<String> {
|
||||||
for _ in 0..15 {
|
// 50 ms sampling (latency plan P0.5): the SAME 3 s per-stage ceilings — the 3-stage ladder
|
||||||
thread::sleep(Duration::from_millis(200));
|
// structure encodes real failure modes (headless auto-activate, integrated-panel clone,
|
||||||
|
// lid-closed path activation) and is untouched — but a typical activation resolves on an
|
||||||
|
// early poll, so finer sampling shaves ~150 ms off every stage crossing.
|
||||||
|
for _ in 0..60 {
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes a plain `Copy` `u32`
|
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes a plain `Copy` `u32`
|
||||||
// target id by value and returns an owned `String`, so no caller memory is borrowed.
|
// target id by value and returns an owned `String`, so no caller memory is borrowed.
|
||||||
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
||||||
@@ -746,8 +827,8 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
// SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no borrowed memory.
|
// SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no borrowed memory.
|
||||||
unsafe { force_extend_topology() };
|
unsafe { force_extend_topology() };
|
||||||
for _ in 0..15 {
|
for _ in 0..60 {
|
||||||
thread::sleep(Duration::from_millis(200));
|
thread::sleep(Duration::from_millis(50));
|
||||||
// SAFETY: as the resolve loop above.
|
// SAFETY: as the resolve loop above.
|
||||||
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
||||||
return Some(n);
|
return Some(n);
|
||||||
@@ -756,8 +837,8 @@ impl VirtualDisplayManager {
|
|||||||
// SAFETY: `activate_target_path` runs the CCD query/apply FFI with owned local buffers; the
|
// SAFETY: `activate_target_path` runs the CCD query/apply FFI with owned local buffers; the
|
||||||
// `Copy` target id is passed by value, under the `state` lock — the sole topology mutator.
|
// `Copy` target id is passed by value, under the `state` lock — the sole topology mutator.
|
||||||
if unsafe { pf_win_display::win_display::activate_target_path(target_id) } {
|
if unsafe { pf_win_display::win_display::activate_target_path(target_id) } {
|
||||||
for _ in 0..15 {
|
for _ in 0..60 {
|
||||||
thread::sleep(Duration::from_millis(200));
|
thread::sleep(Duration::from_millis(50));
|
||||||
// SAFETY: as the resolve loops above.
|
// SAFETY: as the resolve loops above.
|
||||||
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
||||||
return Some(n);
|
return Some(n);
|
||||||
@@ -919,19 +1000,40 @@ impl VirtualDisplayManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
// Topology settle before capture opens: verified-state wait (latency plan P0.2) —
|
||||||
|
// poll until the target's path + active mode are committed, ceiling = the old fixed
|
||||||
|
// 1500 ms sleep (a rejected mode / slow third-party CCD-lock holder burns the
|
||||||
|
// ceiling and proceeds, exactly like the sleep it replaces).
|
||||||
|
let settle_start = std::time::Instant::now();
|
||||||
|
// SAFETY: CCD/GDI query FFI over a `Copy` target id, under the held `state` lock.
|
||||||
|
let settled = unsafe {
|
||||||
|
wait_mode_settled(added.target_id, mode, Duration::from_millis(1500))
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
settle_ms = settle_start.elapsed().as_millis() as u64,
|
||||||
|
verified = settled,
|
||||||
|
"topology settle (verified-state wait)"
|
||||||
|
);
|
||||||
|
|
||||||
// EXPERIMENTAL `pnp_disable_monitors`, second selector (ANY topology): monitors
|
// EXPERIMENTAL `pnp_disable_monitors`, second selector (ANY topology): monitors
|
||||||
// that are connected but NOT part of the desktop — the standby TV/monitor the
|
// that are connected but NOT part of the desktop — the standby TV/monitor the
|
||||||
// deactivated-set selector above structurally misses (it never had an active path
|
// deactivated-set selector above structurally misses (it never had an active path
|
||||||
// to deactivate), yet whose periodic standby wake events drive the same Windows
|
// to deactivate), yet whose periodic standby wake events drive the same Windows
|
||||||
// reaction cascade (rationale in `windows/monitor_devnode.rs`). Runs AFTER the
|
// reaction cascade (rationale in `windows/monitor_devnode.rs`). Runs AFTER the
|
||||||
// settle sleep so the active flags it reads are the committed ones (a display
|
// settle so the active flags it reads are the committed ones (a display still
|
||||||
// still mid-activation from the primary topology's force-EXTEND must not read as
|
// mid-activation from the primary topology's force-EXTEND must not read as
|
||||||
// inactive and get disabled); in Extend the active physical panels are untouched
|
// inactive and get disabled) — and since the verified wait above only confirms
|
||||||
// by construction. First member only — the sweep is group-scoped like the
|
// OUR target (not a physical still lighting up from force-EXTEND), this opt-in
|
||||||
// isolate; later members join an already-swept desktop.
|
// sweep keeps the old FULL settle as its floor before reading those flags.
|
||||||
|
// In Extend the active physical panels are untouched by construction. First
|
||||||
|
// member only — the sweep is group-scoped like the isolate; later members join
|
||||||
|
// an already-swept desktop.
|
||||||
if first_member && crate::policy::prefs().pnp_disable_monitors() {
|
if first_member && crate::policy::prefs().pnp_disable_monitors() {
|
||||||
|
if let Some(rest) =
|
||||||
|
Duration::from_millis(1500).checked_sub(settle_start.elapsed())
|
||||||
|
{
|
||||||
|
thread::sleep(rest);
|
||||||
|
}
|
||||||
let mut keep = inner.target_ids();
|
let mut keep = inner.target_ids();
|
||||||
keep.push(added.target_id);
|
keep.push(added.target_id);
|
||||||
for id in pf_win_display::monitor_devnode::disable_connected_inactive(&keep) {
|
for id in pf_win_display::monitor_devnode::disable_connected_inactive(&keep) {
|
||||||
@@ -962,6 +1064,101 @@ impl VirtualDisplayManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mid-stream resize IN PLACE (latency plan P2): the driver refreshes the LIVE monitor's
|
||||||
|
/// advertised target-mode list to lead with `mode` (`IOCTL_UPDATE_MODES` →
|
||||||
|
/// `IddCxMonitorUpdateModes2`, protocol v4), the OS re-enumerates the target's settable modes
|
||||||
|
/// (waited on, bounded), and the usual CCD/GDI force-set + verified settle (P0.2) commit it —
|
||||||
|
/// on the SAME monitor: target id, GDI name, saved per-monitor DPI, the driver's swap-chain
|
||||||
|
/// worker and its retained frame stash all survive (the OS reassigns the swap-chain across a
|
||||||
|
/// mode set; the preserved-publisher/stash hand-off covers that flap — what it was built for).
|
||||||
|
/// On success `mon.mode` is updated in place; any failure leaves `mon` untouched (still at the
|
||||||
|
/// old mode) and the caller falls back to [`re_add`](Self::re_add).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be the live control handle; runs the CCD/GDI FFI under the `state` lock.
|
||||||
|
unsafe fn resize_in_place(&self, dev: HANDLE, mon: &mut Monitor, mode: Mode) -> Result<()> {
|
||||||
|
let gdi = mon
|
||||||
|
.gdi_name
|
||||||
|
.clone()
|
||||||
|
.context("in-place resize needs a resolved GDI name")?;
|
||||||
|
let t0 = Instant::now();
|
||||||
|
// FAST PATH (driver-independent): the OS already offers this resolution — the monitor's
|
||||||
|
// arrival list, which since the driver's mode-history union contains every size this
|
||||||
|
// identity ever served — so a plain CCD mode set reaches it with no driver round-trip.
|
||||||
|
let already = crate::win_display::wait_mode_advertised(&gdi, mode, Duration::ZERO);
|
||||||
|
if !already {
|
||||||
|
// Out-of-arrival-list mode. On-glass (build 26200) the OS re-parses our description
|
||||||
|
// AND re-queries target modes after UpdateModes2 — our callbacks served the fresh
|
||||||
|
// list — yet the SETTABLE set stays pruned to the arrival list: the monitor
|
||||||
|
// source-mode set is pinned at arrival. So one bounded UPDATE_MODES attempt per
|
||||||
|
// process (in case a future build honors the refresh), then latch it futile and fail
|
||||||
|
// fast to the re-arrival — whose same-id history union makes THIS size settable in
|
||||||
|
// place from then on.
|
||||||
|
if self.driver_proto.load(Ordering::Relaxed) < 4 {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{}x{} is not in the advertised mode set (v3 driver: in-place reaches only \
|
||||||
|
arrival-list modes)",
|
||||||
|
mode.width,
|
||||||
|
mode.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.update_modes_futile.load(Ordering::Relaxed) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{}x{} is not in the advertised mode set (UPDATE_MODES latched futile — the \
|
||||||
|
OS pins settable modes at monitor arrival; the re-arrival teaches this size \
|
||||||
|
to the identity's history)",
|
||||||
|
mode.width,
|
||||||
|
mode.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
old = format!(
|
||||||
|
"{}x{}@{}",
|
||||||
|
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
|
||||||
|
),
|
||||||
|
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||||
|
target = mon.target_id,
|
||||||
|
"virtual-display: updating the live monitor's modes for an in-place resize"
|
||||||
|
);
|
||||||
|
// SAFETY: `dev` is the live control handle (this fn's contract); `update_modes`
|
||||||
|
// forwards it to a synchronous IOCTL with owned/borrowed locals only.
|
||||||
|
unsafe { self.driver.update_modes(dev, &mon.key, mode) }?;
|
||||||
|
// SAFETY: CCD query/apply FFI under the held `state` lock (this fn's contract).
|
||||||
|
unsafe { crate::win_display::force_mode_reenumeration() };
|
||||||
|
if !crate::win_display::wait_mode_advertised(&gdi, mode, Duration::from_millis(800)) {
|
||||||
|
self.update_modes_futile.store(true, Ordering::Relaxed);
|
||||||
|
anyhow::bail!(
|
||||||
|
"OS did not advertise {}x{} within {}ms of the driver mode-list update \
|
||||||
|
(offers: {:?}) — latching UPDATE_MODES off for this process",
|
||||||
|
mode.width,
|
||||||
|
mode.height,
|
||||||
|
t0.elapsed().as_millis(),
|
||||||
|
crate::win_display::advertised_resolutions(&gdi)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let advertised_ms = t0.elapsed().as_millis() as u64;
|
||||||
|
set_active_mode(&gdi, mode);
|
||||||
|
// Verified-state settle (P0.2): the same committed-state predicate as the create paths. A
|
||||||
|
// mode set that did not commit within the ceiling routes to the re-arrival fallback.
|
||||||
|
let settle_start = Instant::now();
|
||||||
|
// SAFETY: CCD/GDI query FFI over a `Copy` target id, under the held `state` lock.
|
||||||
|
let settled =
|
||||||
|
unsafe { wait_mode_settled(mon.target_id, mode, Duration::from_millis(1500)) };
|
||||||
|
if !settled {
|
||||||
|
anyhow::bail!(
|
||||||
|
"in-place mode set did not commit within 1.5s (advertised after {advertised_ms} ms)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
advertised_ms,
|
||||||
|
settle_ms = settle_start.elapsed().as_millis() as u64,
|
||||||
|
"in-place resize committed (verified-state wait)"
|
||||||
|
);
|
||||||
|
mon.mode = mode;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Mid-stream resize by monitor RE-ARRIVAL (`design/midstream-resolution-resize.md` Fix 1).
|
/// Mid-stream resize by monitor RE-ARRIVAL (`design/midstream-resolution-resize.md` Fix 1).
|
||||||
///
|
///
|
||||||
/// The pf-vdisplay driver freezes a monitor's advertised mode list at `IOCTL_ADD` time (the
|
/// The pf-vdisplay driver freezes a monitor's advertised mode list at `IOCTL_ADD` time (the
|
||||||
@@ -1013,9 +1210,17 @@ impl VirtualDisplayManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Let the OS finish the ASYNC monitor departure before the ADD — a back-to-back REMOVE→ADD
|
// Let the OS finish the ASYNC monitor departure before the ADD — a back-to-back REMOVE→ADD
|
||||||
// races the teardown and the ADD is rejected under churn (same 400 ms settle as the reconnect
|
// races the teardown and the ADD is rejected under churn. Verified departure wait, ceiling =
|
||||||
// preempt path).
|
// the old fixed 400 ms settle (latency plan P0.3); the driver's ghost-reap ADD retry remains
|
||||||
thread::sleep(Duration::from_millis(400));
|
// the backstop for a departure the CCD reports early.
|
||||||
|
let depart_start = std::time::Instant::now();
|
||||||
|
// SAFETY: CCD query FFI over a `Copy` target id, under the held `state` lock.
|
||||||
|
let departed = unsafe { wait_target_departed(old.target_id, Duration::from_millis(400)) };
|
||||||
|
tracing::info!(
|
||||||
|
depart_ms = depart_start.elapsed().as_millis() as u64,
|
||||||
|
verified = departed,
|
||||||
|
"re-arrival: old monitor departure settle"
|
||||||
|
);
|
||||||
// 2. ADD a fresh monitor at the NEW mode, reusing the slot as the preferred (stable) id.
|
// 2. ADD a fresh monitor at the NEW mode, reusing the slot as the preferred (stable) id.
|
||||||
let render_pin = resolve_render_pin();
|
let render_pin = resolve_render_pin();
|
||||||
// SAFETY: `dev` is the live control handle; `render_pin`/`client_hdr` are owned `Copy`/`Option`
|
// SAFETY: `dev` is the live control handle; `render_pin`/`client_hdr` are owned `Copy`/`Option`
|
||||||
@@ -1042,7 +1247,18 @@ impl VirtualDisplayManager {
|
|||||||
// the group's first-member restore snapshot.
|
// the group's first-member restore snapshot.
|
||||||
// SAFETY: CCD FFI over borrowed Copy target ids, under the `state` lock.
|
// SAFETY: CCD FFI over borrowed Copy target ids, under the `state` lock.
|
||||||
unsafe { self.reisolate_after_swap(inner, added.target_id) };
|
unsafe { self.reisolate_after_swap(inner, added.target_id) };
|
||||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture reopens
|
// Topology settle before capture reopens: verified-state wait, ceiling = the old
|
||||||
|
// fixed 1500 ms sleep (latency plan P0.2 — the re-arrival twin).
|
||||||
|
let settle_start = std::time::Instant::now();
|
||||||
|
// SAFETY: CCD/GDI query FFI over a `Copy` target id, under the held `state` lock.
|
||||||
|
let settled = unsafe {
|
||||||
|
wait_mode_settled(added.target_id, mode, Duration::from_millis(1500))
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
settle_ms = settle_start.elapsed().as_millis() as u64,
|
||||||
|
verified = settled,
|
||||||
|
"re-arrival topology settle (verified-state wait)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
None => tracing::warn!(
|
None => tracing::warn!(
|
||||||
"re-arrival target {} not yet an active display path (auto-activate, EXTEND preset \
|
"re-arrival target {} not yet an active display path (auto-activate, EXTEND preset \
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
"pf-vdisplay"
|
"pf-vdisplay"
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)> {
|
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
|
||||||
// SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW`
|
// SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW`
|
||||||
// FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the
|
// FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the
|
||||||
// backend-init thread, with no precondition beyond a valid thread context.
|
// backend-init thread, with no precondition beyond a valid thread context.
|
||||||
@@ -397,27 +397,43 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
}
|
}
|
||||||
let info: control::InfoReply =
|
let info: control::InfoReply =
|
||||||
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
||||||
if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION {
|
// HARD floor/ceiling instead of strict equality since v4: v4 is ADDITIVE over v3
|
||||||
|
// (IOCTL_UPDATE_MODES — the in-place resize), so this host still drives a v3 driver and
|
||||||
|
// simply gates the in-place path on the reported version (re-arrival fallback). Anything
|
||||||
|
// below the floor or ABOVE this host's own version stays a loud failure.
|
||||||
|
if info.protocol_version < pf_driver_proto::MIN_DRIVER_PROTOCOL_VERSION
|
||||||
|
|| info.protocol_version > pf_driver_proto::PROTOCOL_VERSION
|
||||||
|
{
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
|
"pf-vdisplay protocol mismatch: host drives {}..={}, driver reports {} — install \
|
||||||
host + driver",
|
matching host + driver",
|
||||||
|
pf_driver_proto::MIN_DRIVER_PROTOCOL_VERSION,
|
||||||
pf_driver_proto::PROTOCOL_VERSION,
|
pf_driver_proto::PROTOCOL_VERSION,
|
||||||
info.protocol_version
|
info.protocol_version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let watchdog_s = info.watchdog_timeout_s.max(1);
|
let watchdog_s = info.watchdog_timeout_s.max(1);
|
||||||
tracing::info!(
|
if info.protocol_version < pf_driver_proto::PROTOCOL_VERSION {
|
||||||
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
tracing::warn!(
|
||||||
info.protocol_version,
|
"pf-vdisplay protocol {} (host supports {}): driver lacks the in-place resize — \
|
||||||
watchdog_s
|
mid-stream resizes use the monitor re-arrival path until the driver is updated",
|
||||||
);
|
info.protocol_version,
|
||||||
|
pf_driver_proto::PROTOCOL_VERSION
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
||||||
|
info.protocol_version,
|
||||||
|
watchdog_s
|
||||||
|
);
|
||||||
|
}
|
||||||
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns
|
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns
|
||||||
// SUCCESS). FIRST open of the process only: a REOPEN (the manager retired a dead handle after
|
// SUCCESS). FIRST open of the process only: a REOPEN (the manager retired a dead handle after
|
||||||
// a driver upgrade / WUDFHost restart) can race sessions that still believe they are live, and
|
// a driver upgrade / WUDFHost restart) can race sessions that still believe they are live, and
|
||||||
// an unconditional CLEAR_ALL there would raze them.
|
// an unconditional CLEAR_ALL there would raze them.
|
||||||
if !reap_orphans {
|
if !reap_orphans {
|
||||||
reap_ghost_monitors();
|
reap_ghost_monitors();
|
||||||
return Ok((device, watchdog_s));
|
return Ok((device, watchdog_s, info.protocol_version));
|
||||||
}
|
}
|
||||||
let mut none: [u8; 0] = [];
|
let mut none: [u8; 0] = [];
|
||||||
// SAFETY: `raw` borrows the live `OwnedHandle` above. `IOCTL_CLEAR_ALL` has no input and no
|
// SAFETY: `raw` borrows the live `OwnedHandle` above. `IOCTL_CLEAR_ALL` has no input and no
|
||||||
@@ -434,7 +450,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
|
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
|
||||||
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
|
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
|
||||||
reap_ghost_monitors();
|
reap_ghost_monitors();
|
||||||
Ok((device, watchdog_s))
|
Ok((device, watchdog_s, info.protocol_version))
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn add_monitor(
|
unsafe fn add_monitor(
|
||||||
@@ -592,6 +608,38 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> {
|
||||||
|
let MonitorKey::Session(session_id) = key else {
|
||||||
|
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
||||||
|
};
|
||||||
|
let req = control::UpdateModesRequest {
|
||||||
|
session_id: *session_id,
|
||||||
|
width: mode.width,
|
||||||
|
height: mode.height,
|
||||||
|
refresh_hz: mode.refresh_hz,
|
||||||
|
_reserved: 0,
|
||||||
|
};
|
||||||
|
let mut none: [u8; 0] = [];
|
||||||
|
// SAFETY: per `update_modes`'s contract `dev` is the live control handle. `bytes_of(&req)`
|
||||||
|
// borrows the local `UpdateModesRequest` for the duration of this synchronous call as the
|
||||||
|
// input bytes; `none` is empty, so there is no output buffer.
|
||||||
|
unsafe {
|
||||||
|
ioctl(
|
||||||
|
dev,
|
||||||
|
control::IOCTL_UPDATE_MODES,
|
||||||
|
bytemuck::bytes_of(&req),
|
||||||
|
&mut none,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map(|_| ())
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"pf-vdisplay UPDATE_MODES {}x{}@{}",
|
||||||
|
mode.width, mode.height, mode.refresh_hz
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
|
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
|
||||||
let MonitorKey::Session(session_id) = key else {
|
let MonitorKey::Session(session_id) = key else {
|
||||||
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
||||||
@@ -740,4 +788,80 @@ mod tests {
|
|||||||
thread::sleep(Duration::from_secs(3));
|
thread::sleep(Duration::from_secs(3));
|
||||||
drop(vout); // triggers REMOVE + stops the pinger
|
drop(vout); // triggers REMOVE + stops the pinger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live in-place resize spike — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs a v4
|
||||||
|
/// pf-vdisplay driver installed + the host service STOPPED, single-instance guard). Answers the
|
||||||
|
/// P2 open questions on real glass with no streaming client: create at one mode, then acquire
|
||||||
|
/// the SAME session's slot at a DIFFERENT mode — the manager's resize branch runs UPDATE_MODES
|
||||||
|
/// → mode-advertised wait → set_active_mode → verified settle. In-place success is visible as
|
||||||
|
/// the SAME OS target id on the second output (a re-arrival fallback mints a new one) plus the
|
||||||
|
/// committed active resolution; the test reports which path ran and asserts the mode landed.
|
||||||
|
#[test]
|
||||||
|
fn live_inplace_resize() {
|
||||||
|
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle
|
||||||
|
// waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the
|
||||||
|
// first on-glass run blind.
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "debug".into()),
|
||||||
|
)
|
||||||
|
.try_init();
|
||||||
|
// Context probe: can this process see the CCD active-path set at all? (`None` = the query
|
||||||
|
// itself fails in this session/window-station — the whole ladder would be blind, and a
|
||||||
|
// "monitor never activated" verdict would be an artifact of the test context.)
|
||||||
|
// SAFETY: CCD query over an owned empty slice (test-only diagnostics).
|
||||||
|
let active0 = unsafe { crate::win_display::count_other_active(&[]) };
|
||||||
|
println!("spike: CCD active paths visible before create: {active0:?}");
|
||||||
|
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
|
||||||
|
let first = vd
|
||||||
|
.create(Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
})
|
||||||
|
.expect("create virtual display");
|
||||||
|
let t1 = first
|
||||||
|
.win_capture
|
||||||
|
.as_ref()
|
||||||
|
.expect("no capture target")
|
||||||
|
.target_id;
|
||||||
|
thread::sleep(Duration::from_secs(2)); // let the activation/settle fully quiesce
|
||||||
|
// A deliberately arbitrary (window-drag-shaped) mode the ADD never advertised.
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let second = vd
|
||||||
|
.create(Mode {
|
||||||
|
width: 2356,
|
||||||
|
height: 1332,
|
||||||
|
refresh_hz: 60,
|
||||||
|
})
|
||||||
|
.expect("in-place resize acquire");
|
||||||
|
let resize_ms = t0.elapsed().as_millis();
|
||||||
|
let t2 = second
|
||||||
|
.win_capture
|
||||||
|
.as_ref()
|
||||||
|
.expect("no capture target")
|
||||||
|
.target_id;
|
||||||
|
let in_place = t1 == t2;
|
||||||
|
// SAFETY: CCD query over a Copy target id (test-only diagnostics).
|
||||||
|
let active = unsafe { crate::win_display::active_resolution(t2) };
|
||||||
|
println!(
|
||||||
|
"in-place resize spike: in_place={in_place} (target {t1} -> {t2}) took {resize_ms} ms, \
|
||||||
|
active resolution now {active:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
active,
|
||||||
|
Some((2356, 1332)),
|
||||||
|
"the new mode did not become the active resolution"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
in_place,
|
||||||
|
"the resize fell back to re-arrival (target id changed) — UPDATE_MODES path not taken"
|
||||||
|
);
|
||||||
|
drop(second);
|
||||||
|
drop(first);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,169 @@ pub unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> {
|
|||||||
Some((dm.dmPelsWidth, dm.dmPelsHeight))
|
Some((dm.dmPelsWidth, dm.dmPelsHeight))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verified-state topology-settle wait (latency plan P0.2): poll the CCD state until the target is
|
||||||
|
/// actually COMMITTED — an active path exists (the GDI name resolves) and the active resolution
|
||||||
|
/// equals the requested one — instead of sleeping a fixed interval. The conditions are exactly what
|
||||||
|
/// `resolve_gdi_name`/`set_active_mode` already established once; this waits until the OS reports
|
||||||
|
/// them stable. `ceiling` (the old fixed sleep) is the worst-case bound: a mode the driver rejected
|
||||||
|
/// (`set_active_mode` left the OS default) or a slow third-party CCD-lock holder (SteelSeries
|
||||||
|
/// class) burns the ceiling and proceeds — behavior identical to the fixed sleep it replaces.
|
||||||
|
/// Returns `true` when the state verified (typical: one or two 25 ms polls), `false` on ceiling.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Runs the CCD/GDI query FFI; call under the manager `state` lock like the callers it serves.
|
||||||
|
pub(crate) unsafe fn wait_mode_settled(
|
||||||
|
target_id: u32,
|
||||||
|
mode: Mode,
|
||||||
|
ceiling: std::time::Duration,
|
||||||
|
) -> bool {
|
||||||
|
let deadline = std::time::Instant::now() + ceiling;
|
||||||
|
loop {
|
||||||
|
// SAFETY (both calls): CCD/GDI FFI over a `Copy` target id, owned returns — the callers'
|
||||||
|
// own safety contract (under the `state` lock) covers them.
|
||||||
|
if resolve_gdi_name(target_id).is_some()
|
||||||
|
&& active_resolution(target_id) == Some((mode.width, mode.height))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-commit the CURRENT active config with `SDC_FORCE_MODE_ENUMERATION` — the nudge that makes
|
||||||
|
/// the OS re-query an indirect display's target modes. Observed on-glass (P2): after
|
||||||
|
/// `IddCxMonitorUpdateModes2` the OS did NOT re-enumerate on its own within 2 s, so a freshly
|
||||||
|
/// advertised mode never became settable; the isolate/layout paths already re-commit with this
|
||||||
|
/// flag for the same "the OS won't re-evaluate unless told" class. Best-effort.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Runs the CCD query/apply FFI; call under the manager `state` lock (sole topology mutator).
|
||||||
|
pub(crate) unsafe fn force_mode_reenumeration() -> bool {
|
||||||
|
let Some((paths, modes)) = query_active_config() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let rc = SetDisplayConfig(
|
||||||
|
Some(paths.as_slice()),
|
||||||
|
Some(modes.as_slice()),
|
||||||
|
SDC_APPLY
|
||||||
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
|
| SDC_ALLOW_CHANGES
|
||||||
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
|
);
|
||||||
|
if rc != 0 {
|
||||||
|
tracing::debug!("force mode re-enumeration: SetDisplayConfig rc={rc:#x}");
|
||||||
|
}
|
||||||
|
rc == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct resolutions `gdi_name` currently advertises (diagnostics for the in-place-resize
|
||||||
|
/// path: what the OS actually offers when a requested mode never shows up).
|
||||||
|
pub(crate) fn advertised_resolutions(gdi_name: &str) -> Vec<(u32, u32)> {
|
||||||
|
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let mut set = std::collections::BTreeSet::new();
|
||||||
|
let mut i = 0u32;
|
||||||
|
loop {
|
||||||
|
let mut dm = DEVMODEW {
|
||||||
|
dmSize: size_of::<DEVMODEW>() as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name; `&mut dm` is a live,
|
||||||
|
// size-stamped DEVMODEW the API fills for mode index `i`. Both outlive the call.
|
||||||
|
let ok = unsafe {
|
||||||
|
EnumDisplaySettingsW(
|
||||||
|
PCWSTR(wname.as_ptr()),
|
||||||
|
ENUM_DISPLAY_SETTINGS_MODE(i),
|
||||||
|
&mut dm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.as_bool();
|
||||||
|
if !ok {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
set.insert((dm.dmPelsWidth, dm.dmPelsHeight));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait (bounded) until `gdi_name` ADVERTISES `mode`'s resolution in its display-mode list — the
|
||||||
|
/// gate between a driver-side mode-list refresh (`IOCTL_UPDATE_MODES`, latency plan P2) and the
|
||||||
|
/// CCD/GDI force-set: the OS re-evaluates an indirect display's settable modes asynchronously after
|
||||||
|
/// `IddCxMonitorUpdateModes2`, so an immediate `set_active_mode` could race the re-enumeration and
|
||||||
|
/// silently leave the old mode. Returns `true` once any refresh at the requested WxH is enumerable.
|
||||||
|
pub(crate) fn wait_mode_advertised(
|
||||||
|
gdi_name: &str,
|
||||||
|
mode: Mode,
|
||||||
|
ceiling: std::time::Duration,
|
||||||
|
) -> bool {
|
||||||
|
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let deadline = std::time::Instant::now() + ceiling;
|
||||||
|
loop {
|
||||||
|
let mut i = 0u32;
|
||||||
|
loop {
|
||||||
|
let mut dm = DEVMODEW {
|
||||||
|
dmSize: size_of::<DEVMODEW>() as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name whose pointer stays valid
|
||||||
|
// for the call; `&mut dm` is a live, size-stamped DEVMODEW the API fills for mode index
|
||||||
|
// `i`. Both outlive this synchronous call.
|
||||||
|
let ok = unsafe {
|
||||||
|
EnumDisplaySettingsW(
|
||||||
|
PCWSTR(wname.as_ptr()),
|
||||||
|
ENUM_DISPLAY_SETTINGS_MODE(i),
|
||||||
|
&mut dm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.as_bool();
|
||||||
|
if !ok {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if dm.dmPelsWidth == mode.width && dm.dmPelsHeight == mode.height {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitor-departure wait (latency plan P0.3): after a REMOVE, poll until the target has left the
|
||||||
|
/// ACTIVE CCD set — two consecutive absent samples, so one transient query failure mid-teardown
|
||||||
|
/// can't read as "gone" — instead of sleeping the fixed departure settle. `ceiling` (the old fixed
|
||||||
|
/// sleep) bounds the worst case. The OS-side departure may still be finishing driver-side when the
|
||||||
|
/// CCD stops listing the target; the ADD path's ghost-reap retry (pf_vdisplay) remains the backstop
|
||||||
|
/// for that rare race, exactly as it was for a settle that expired. Returns `true` when departure
|
||||||
|
/// was observed, `false` on ceiling.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Runs the CCD query FFI; call under the manager `state` lock like the callers it serves.
|
||||||
|
pub(crate) unsafe fn wait_target_departed(target_id: u32, ceiling: std::time::Duration) -> bool {
|
||||||
|
let deadline = std::time::Instant::now() + ceiling;
|
||||||
|
let mut absent_streak = 0u32;
|
||||||
|
loop {
|
||||||
|
// SAFETY: CCD FFI over a `Copy` target id, owned return, under the caller's `state` lock.
|
||||||
|
if resolve_gdi_name(target_id).is_none() {
|
||||||
|
absent_streak += 1;
|
||||||
|
if absent_streak >= 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
absent_streak = 0;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle the virtual-display target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
/// Toggle the virtual-display target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
||||||
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
||||||
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Session-transition latency trace (design/first-frame-and-resize-latency.md P0.1).
|
||||||
|
//!
|
||||||
|
//! One [`Trace`] per transition — session bring-up (`hello → … → first_packet`) or a mid-stream
|
||||||
|
//! resize (`reconfigure_received → … → pipeline_rebuilt`) — collects millisecond stage stamps
|
||||||
|
//! across the threads a transition crosses (handshake task, display-prep/encode thread, send
|
||||||
|
//! thread) and emits ONE summary `info!` line when the transition completes, so every landed
|
||||||
|
//! latency change is measured against a number instead of vibes. The completed total also lands
|
||||||
|
//! in a shared slot [`crate::session_status`] exposes (`time_to_first_frame_ms` /
|
||||||
|
//! `last_resize_ms`), so the web-console Dashboard and future regressions can read it per session.
|
||||||
|
//!
|
||||||
|
//! Deliberately coarse: stages are stamped where the session layer can see them; layers the trace
|
||||||
|
//! doesn't reach (the Windows display manager's activation ladder / settle waits) log their own
|
||||||
|
//! per-stage deltas and correlate by wall clock.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// A single transition's stage trace. Cheap and thread-safe: `mark` is a mutex push, `finish`
|
||||||
|
/// emits the one summary line (exactly once — later calls no-op, so an abandoned trace stays
|
||||||
|
/// silent).
|
||||||
|
pub(crate) struct Trace {
|
||||||
|
/// Which transition this traces (`"bringup"` / `"resize"`) — the summary line's `kind`.
|
||||||
|
kind: &'static str,
|
||||||
|
origin: Instant,
|
||||||
|
/// `(stage, ms since origin)` in stamp order.
|
||||||
|
stages: Mutex<Vec<(&'static str, u32)>>,
|
||||||
|
finished: AtomicBool,
|
||||||
|
/// Where the completed total lands — shared with [`crate::session_status`].
|
||||||
|
total_ms: Arc<AtomicU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Trace {
|
||||||
|
/// Start a trace at "now" (= the first stage's zero point). `total_ms` is the shared slot the
|
||||||
|
/// completed total is stored into (0 until the transition finishes).
|
||||||
|
pub(crate) fn start(kind: &'static str, total_ms: Arc<AtomicU32>) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
kind,
|
||||||
|
origin: Instant::now(),
|
||||||
|
stages: Mutex::new(Vec::new()),
|
||||||
|
finished: AtomicBool::new(false),
|
||||||
|
total_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared slot the completed total is stored into (for `session_status::register`).
|
||||||
|
pub(crate) fn total_slot(&self) -> Arc<AtomicU32> {
|
||||||
|
self.total_ms.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp a stage at "now" — first occurrence only (a retried build re-crosses its stamp
|
||||||
|
/// points; the first crossing is the one the transition timeline wants). No-op after
|
||||||
|
/// [`finish`](Self::finish), so steady-state paths that also cross a stamped point stay free.
|
||||||
|
pub(crate) fn mark(&self, stage: &'static str) {
|
||||||
|
if self.finished.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ms = self.origin.elapsed().as_millis().min(u32::MAX as u128) as u32;
|
||||||
|
let mut stages = self.stages.lock().unwrap();
|
||||||
|
if stages.iter().any(|(s, _)| *s == stage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stages.push((stage, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp the final stage and emit the one-line summary (first call only). The final stage's
|
||||||
|
/// offset is the transition total, stored into the shared slot.
|
||||||
|
pub(crate) fn finish(&self, stage: &'static str) {
|
||||||
|
if self.finished.swap(true, Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let total = self.origin.elapsed().as_millis().min(u32::MAX as u128) as u32;
|
||||||
|
let mut stages = self.stages.lock().unwrap();
|
||||||
|
stages.push((stage, total));
|
||||||
|
let line = stages
|
||||||
|
.iter()
|
||||||
|
.map(|(s, ms)| format!("{s}+{ms}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
drop(stages);
|
||||||
|
self.total_ms.store(total.max(1), Ordering::Relaxed);
|
||||||
|
tracing::info!(
|
||||||
|
kind = self.kind,
|
||||||
|
total_ms = total,
|
||||||
|
stages = %line,
|
||||||
|
"session-transition trace"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod bringup;
|
||||||
mod capture;
|
mod capture;
|
||||||
mod detect;
|
mod detect;
|
||||||
mod devtest;
|
mod devtest;
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ pub(crate) struct StreamInfo {
|
|||||||
/// Client's parity floor per FEC block (`minRequiredFecPackets`).
|
/// Client's parity floor per FEC block (`minRequiredFecPackets`).
|
||||||
min_fec: u8,
|
min_fec: u8,
|
||||||
codec: ApiCodec,
|
codec: ApiCodec,
|
||||||
|
/// Session bring-up total, hello → first video packet, in ms (native sessions; `null` on the
|
||||||
|
/// GameStream plane or while the session is still bringing up).
|
||||||
|
time_to_first_frame_ms: Option<u32>,
|
||||||
|
/// Most recent mid-stream resize total, reconfigure → pipeline rebuilt, in ms (native sessions;
|
||||||
|
/// `null` when no resize happened / GameStream).
|
||||||
|
last_resize_ms: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
|
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
|
||||||
@@ -332,6 +338,9 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
|||||||
packet_size: c.packet_size as u32,
|
packet_size: c.packet_size as u32,
|
||||||
min_fec: c.min_fec,
|
min_fec: c.min_fec,
|
||||||
codec: c.codec.into(),
|
codec: c.codec.into(),
|
||||||
|
// Transition latencies are traced on the native plane only (latency plan P0.1).
|
||||||
|
time_to_first_frame_ms: None,
|
||||||
|
last_resize_ms: None,
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
native.first().map(|s| StreamInfo {
|
native.first().map(|s| StreamInfo {
|
||||||
@@ -344,6 +353,9 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
|||||||
packet_size: 0,
|
packet_size: 0,
|
||||||
min_fec: 0,
|
min_fec: 0,
|
||||||
codec: s.codec.into(),
|
codec: s.codec.into(),
|
||||||
|
time_to_first_frame_ms: (s.time_to_first_frame_ms > 0)
|
||||||
|
.then_some(s.time_to_first_frame_ms),
|
||||||
|
last_resize_ms: (s.last_resize_ms > 0).then_some(s.last_resize_ms),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
Json(RuntimeStatus {
|
Json(RuntimeStatus {
|
||||||
|
|||||||
@@ -777,14 +777,56 @@ async fn serve_session(
|
|||||||
let source = opts.source;
|
let source = opts.source;
|
||||||
let frames = opts.frames;
|
let frames = opts.frames;
|
||||||
let data_port = opts.data_port;
|
let data_port = opts.data_port;
|
||||||
let (hello, welcome, udp_port, data_sock, direct, start, compositor) = tokio::time::timeout(
|
// Session-transition trace (latency plan P0.1): zeroed here — the Hello is in hand, pairing
|
||||||
HANDSHAKE_TIMEOUT,
|
// gates are behind us — and finished by the send thread when the FIRST video packet leaves.
|
||||||
handshake::negotiate(
|
// The completed totals surface per session in `session_status` (→ mgmt `/status`).
|
||||||
&conn, &mut send, &mut recv, &first, source, frames, data_port,
|
let bringup = crate::bringup::Trace::start("bringup", Arc::new(AtomicU32::new(0)));
|
||||||
),
|
// The mid-stream resize counterpart: each accepted Reconfigure runs its own trace into this
|
||||||
)
|
// shared slot (latest wins), registered alongside the bring-up total.
|
||||||
.await
|
let resize_ms: Arc<AtomicU32> = Arc::new(AtomicU32::new(0));
|
||||||
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
|
||||||
|
// Stop signal: stream duration elapsed or the client went away. Created (with its watcher)
|
||||||
|
// BEFORE the handshake so the Welcome-time display prep can already observe a client that
|
||||||
|
// vanished mid-handshake (its build-retry loop aborts on `stop`).
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when
|
||||||
|
// the client closed the connection with `QUIT_CODE` — a user "stop", which skips the
|
||||||
|
// keep-alive linger. A bare disconnect / idle timeout leaves it false → the display lingers
|
||||||
|
// for a reconnect.
|
||||||
|
let quit = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let stop = stop.clone();
|
||||||
|
let quit = quit.clone();
|
||||||
|
let conn = conn.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let reason = conn.closed().await;
|
||||||
|
if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac)
|
||||||
|
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE))
|
||||||
|
{
|
||||||
|
quit.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let (hello, welcome, udp_port, data_sock, direct, start, compositor, prep) =
|
||||||
|
tokio::time::timeout(
|
||||||
|
HANDSHAKE_TIMEOUT,
|
||||||
|
handshake::negotiate(
|
||||||
|
&conn,
|
||||||
|
&mut send,
|
||||||
|
&mut recv,
|
||||||
|
&first,
|
||||||
|
source,
|
||||||
|
frames,
|
||||||
|
data_port,
|
||||||
|
&bringup,
|
||||||
|
quit.clone(),
|
||||||
|
stop.clone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
||||||
let (ctrl_send, ctrl_recv) = (send, recv);
|
let (ctrl_send, ctrl_recv) = (send, recv);
|
||||||
// Can this session's backend live-reconfigure (mid-stream Reconfigure)? Gated OFF for:
|
// Can this session's backend live-reconfigure (mid-stream Reconfigure)? Gated OFF for:
|
||||||
// * gamescope (all sub-modes): a spawn respawn restarts the game, managed restarts the box's
|
// * gamescope (all sub-modes): a spawn respawn restarts the game, managed restarts the box's
|
||||||
@@ -949,12 +991,8 @@ async fn serve_session(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop signal: stream duration elapsed or the client went away.
|
// (The stop/quit flags + their disconnect watcher are created above, before the handshake, so
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
// the Welcome-time display prep can observe a mid-handshake disconnect.)
|
||||||
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the
|
|
||||||
// client closed the connection with `QUIT_CODE` — a user "stop", which skips the keep-alive linger.
|
|
||||||
// A bare disconnect / idle timeout leaves it false → the display lingers for a reconnect.
|
|
||||||
let quit = Arc::new(AtomicBool::new(false));
|
|
||||||
// Lifecycle events (RFC §4): this point — handshake complete, pairing/admission passed — is
|
// Lifecycle events (RFC §4): this point — handshake complete, pairing/admission passed — is
|
||||||
// where the client counts as CONNECTED; the close watcher below pairs it with the
|
// where the client counts as CONNECTED; the close watcher below pairs it with the
|
||||||
// disconnect + its decoded reason. A client rejected earlier never emits either.
|
// disconnect + its decoded reason. A client rejected earlier never emits either.
|
||||||
@@ -967,17 +1005,9 @@ async fn serve_session(
|
|||||||
client: event_client.clone(),
|
client: event_client.clone(),
|
||||||
});
|
});
|
||||||
{
|
{
|
||||||
let stop = stop.clone();
|
|
||||||
let quit = quit.clone();
|
|
||||||
let conn = conn.clone();
|
let conn = conn.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let reason = conn.closed().await;
|
let reason = conn.closed().await;
|
||||||
if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac)
|
|
||||||
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE))
|
|
||||||
{
|
|
||||||
quit.store(true, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
stop.store(true, Ordering::SeqCst);
|
|
||||||
let why = match &reason {
|
let why = match &reason {
|
||||||
quinn::ConnectionError::ApplicationClosed(ac)
|
quinn::ConnectionError::ApplicationClosed(ac)
|
||||||
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE) =>
|
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE) =>
|
||||||
@@ -1175,6 +1205,10 @@ async fn serve_session(
|
|||||||
let client_label = endpoint::peer_fingerprint(&conn)
|
let client_label = endpoint::peer_fingerprint(&conn)
|
||||||
.map(|fp| fingerprint_hex(&fp)[..12].to_string())
|
.map(|fp| fingerprint_hex(&fp)[..12].to_string())
|
||||||
.unwrap_or_else(|| conn.remote_address().ip().to_string());
|
.unwrap_or_else(|| conn.remote_address().ip().to_string());
|
||||||
|
// Transition-trace handles for the data plane (P0.1): the punch stamp + the virtual-stream
|
||||||
|
// stages ride the same per-session trace; resizes write their totals into the shared slot.
|
||||||
|
let bringup_dp = bringup.clone();
|
||||||
|
let resize_ms_dp = resize_ms.clone();
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||||
@@ -1204,6 +1238,7 @@ async fn serve_session(
|
|||||||
return Err(anyhow::Error::new(e)).context("bind data plane");
|
return Err(anyhow::Error::new(e)).context("bind data plane");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
bringup_dp.mark("punch_done");
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
%client_udp,
|
%client_udp,
|
||||||
udp_port,
|
udp_port,
|
||||||
@@ -1229,7 +1264,7 @@ async fn serve_session(
|
|||||||
Punktfunk1Source::Virtual => {
|
Punktfunk1Source::Virtual => {
|
||||||
let compositor = compositor
|
let compositor = compositor
|
||||||
.expect("the Virtual source resolves a compositor during the handshake");
|
.expect("the Virtual source resolves a compositor during the handshake");
|
||||||
virtual_stream(SessionContext {
|
let ctx = SessionContext {
|
||||||
session,
|
session,
|
||||||
mode,
|
mode,
|
||||||
seconds,
|
seconds,
|
||||||
@@ -1256,7 +1291,29 @@ async fn serve_session(
|
|||||||
client_label,
|
client_label,
|
||||||
launch: launch_for_dp,
|
launch: launch_for_dp,
|
||||||
client_hdr,
|
client_hdr,
|
||||||
})
|
bringup: bringup_dp,
|
||||||
|
resize_ms: resize_ms_dp,
|
||||||
|
};
|
||||||
|
match prep {
|
||||||
|
// P1.1: the display prep started at Welcome on its own thread — hand it
|
||||||
|
// the post-punch context and adopt its result as the stream result (that
|
||||||
|
// thread runs `virtual_stream` on the pipeline it already built).
|
||||||
|
Some((ctx_tx, prep_thread)) => match ctx_tx.send(ctx) {
|
||||||
|
Ok(()) => match prep_thread.join() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => Err(anyhow!("prepared stream thread panicked")),
|
||||||
|
},
|
||||||
|
// The prep thread died before the hand-off (panicked during prep —
|
||||||
|
// its guard/lease unwound): run the stream inline instead.
|
||||||
|
Err(std::sync::mpsc::SendError(ctx)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"display-prep thread gone before hand-off — building inline"
|
||||||
|
);
|
||||||
|
virtual_stream(ctx, None)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => virtual_stream(ctx, None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ pub(super) async fn negotiate(
|
|||||||
source: Punktfunk1Source,
|
source: Punktfunk1Source,
|
||||||
frames: u32,
|
frames: u32,
|
||||||
data_port: Option<u16>,
|
data_port: Option<u16>,
|
||||||
|
// Session bring-up trace (latency plan P0.1): `welcome`/`start` stamps land here, and the
|
||||||
|
// Welcome-time display prep threads it into the pipeline-build stages.
|
||||||
|
bringup: &Arc<crate::bringup::Trace>,
|
||||||
|
// The session's quit/stop flags — created BEFORE the handshake so the Welcome-time display
|
||||||
|
// prep below can observe a client that vanished mid-handshake (its build retry aborts on
|
||||||
|
// `stop`; `quit` rides into the display lease).
|
||||||
|
quit: Arc<AtomicBool>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
) -> Result<(
|
) -> Result<(
|
||||||
Hello,
|
Hello,
|
||||||
Welcome,
|
Welcome,
|
||||||
@@ -27,6 +35,7 @@ pub(super) async fn negotiate(
|
|||||||
bool,
|
bool,
|
||||||
Start,
|
Start,
|
||||||
Option<crate::vdisplay::Compositor>,
|
Option<crate::vdisplay::Compositor>,
|
||||||
|
Option<super::stream::PrepHandle>,
|
||||||
)> {
|
)> {
|
||||||
let peer = conn.remote_address();
|
let peer = conn.remote_address();
|
||||||
let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||||
@@ -377,10 +386,74 @@ pub(super) async fn negotiate(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
io::write_msg(send, &welcome.encode()).await?;
|
io::write_msg(send, &welcome.encode()).await?;
|
||||||
|
bringup.mark("welcome");
|
||||||
|
|
||||||
|
// P1.1/P1.2 (latency plan): kick the display prep NOW — the negotiated mode is final in
|
||||||
|
// the Welcome just sent, and nothing in monitor create → activation → settle → capture
|
||||||
|
// attach → encoder open needs the client's Start or the punched socket. The prep thread
|
||||||
|
// BECOMES the stream thread: the data plane hands it the post-punch SessionContext and it
|
||||||
|
// runs `virtual_stream` on the warm pipeline, so the whole display bring-up hides behind
|
||||||
|
// the Start RTT + the (up to 2.5 s) hole-punch wait. If the session dies before its data
|
||||||
|
// plane comes up (handshake timeout, client vanished), the channel drops and the prep
|
||||||
|
// result is released — the monitor lands in the keep-alive machinery exactly like a
|
||||||
|
// normal session end (and `stop`, watched by the caller, aborts a still-running build
|
||||||
|
// retry). Windows native path only: the Linux backends bind launch semantics before create
|
||||||
|
// (gamescope nests the launch command), which must not run for a client that never sends
|
||||||
|
// Start; GameStream has neither a Start gate nor a punch.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let prep: Option<super::stream::PrepHandle> = match (source, compositor) {
|
||||||
|
(Punktfunk1Source::Virtual, Some(comp)) => {
|
||||||
|
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
|
||||||
|
let client_identity = endpoint::peer_fingerprint(conn);
|
||||||
|
let client_hdr = hello.display_hdr;
|
||||||
|
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
|
||||||
|
let trace = bringup.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("punktfunk1-stream".into())
|
||||||
|
.spawn(move || -> Result<()> {
|
||||||
|
let prepared = super::stream::prepare_display(
|
||||||
|
comp,
|
||||||
|
mode,
|
||||||
|
client_identity,
|
||||||
|
client_hdr,
|
||||||
|
bitrate_kbps,
|
||||||
|
bit_depth,
|
||||||
|
chroma,
|
||||||
|
codec,
|
||||||
|
shard_payload,
|
||||||
|
&quit,
|
||||||
|
&stop,
|
||||||
|
&trace,
|
||||||
|
);
|
||||||
|
let Ok(ctx) = ctx_rx.recv() else {
|
||||||
|
// No data plane ever came (handshake abort / punch failure): drop
|
||||||
|
// `prepared` — its lease release hands the monitor to keep-alive
|
||||||
|
// policy, exactly like a normal session end.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
match prepared {
|
||||||
|
Ok(p) => virtual_stream(ctx, Some(p)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|handle| (ctx_tx, handle))
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::warn!(error = %e,
|
||||||
|
"display-prep thread spawn failed — falling back to inline bring-up")
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let prep: Option<super::stream::PrepHandle> = None;
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let _ = (quit, stop);
|
||||||
|
|
||||||
let start =
|
let start =
|
||||||
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
||||||
|
bringup.mark("start");
|
||||||
Ok::<_, anyhow::Error>((
|
Ok::<_, anyhow::Error>((
|
||||||
hello, welcome, udp_port, data_sock, direct, start, compositor,
|
hello, welcome, udp_port, data_sock, direct, start, compositor, prep,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ struct SendStats {
|
|||||||
/// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive
|
/// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive
|
||||||
/// bitrate change, so the web-console sample reports what the encoder is ACTUALLY targeting.
|
/// bitrate change, so the web-console sample reports what the encoder is ACTUALLY targeting.
|
||||||
bitrate_kbps: Arc<AtomicU32>,
|
bitrate_kbps: Arc<AtomicU32>,
|
||||||
|
/// The session's bring-up trace (P0.1): the send thread FINISHES it — `first_packet` — the
|
||||||
|
/// moment the first video AU's packets have fully left the socket (finish is once-only, so
|
||||||
|
/// the per-frame call is a cheap no-op afterwards).
|
||||||
|
bringup: Arc<crate::bringup::Trace>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a session on `compositor` (`None` = the synthetic source) with a `per_client_mode`
|
/// Whether a session on `compositor` (`None` = the synthetic source) with a `per_client_mode`
|
||||||
@@ -389,6 +393,11 @@ fn send_loop(
|
|||||||
burst_cap,
|
burst_cap,
|
||||||
) {
|
) {
|
||||||
Ok(stat) => {
|
Ok(stat) => {
|
||||||
|
// First VIDEO packets are on the wire — complete the bring-up trace (P0.1;
|
||||||
|
// once-only, no-op on every later frame). Speed-test filler isn't video.
|
||||||
|
if msg.flags & FLAG_PROBE as u32 == 0 {
|
||||||
|
stats.bringup.finish("first_packet");
|
||||||
|
}
|
||||||
// Host timing (0xCF): stamped now — the AU's packets have fully left the
|
// Host timing (0xCF): stamped now — the AU's packets have fully left the
|
||||||
// socket — against the same capture anchor the wire pts carries, so the
|
// socket — against the same capture anchor the wire pts carries, so the
|
||||||
// client's per-frame math tiles exactly (network = its host+network − this).
|
// client's per-frame math tiles exactly (network = its host+network − this).
|
||||||
@@ -743,9 +752,15 @@ pub(super) struct SessionContext {
|
|||||||
/// so host apps tone-map to the client's real panel) and preferred over the generic baseline
|
/// so host apps tone-map to the client's real panel) and preferred over the generic baseline
|
||||||
/// for the 0xCE mastering metadata.
|
/// for the 0xCE mastering metadata.
|
||||||
pub(super) client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
pub(super) client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
|
/// The session's bring-up trace (latency plan P0.1): the pipeline-build stages stamp into it
|
||||||
|
/// and the send thread finishes it when the first video packet leaves.
|
||||||
|
pub(super) bringup: Arc<crate::bringup::Trace>,
|
||||||
|
/// Shared slot the latest completed mid-stream resize total (ms) lands in — registered with
|
||||||
|
/// `session_status` so the Dashboard shows it.
|
||||||
|
pub(super) resize_ms: Arc<AtomicU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDisplay>) -> Result<()> {
|
||||||
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
||||||
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
||||||
// submission.
|
// submission.
|
||||||
@@ -792,6 +807,8 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
client_label,
|
client_label,
|
||||||
launch,
|
launch,
|
||||||
client_hdr,
|
client_hdr,
|
||||||
|
bringup,
|
||||||
|
resize_ms,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
@@ -800,54 +817,79 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
"punktfunk/1 virtual display"
|
"punktfunk/1 virtual display"
|
||||||
);
|
);
|
||||||
// Open the backend FIRST — on Windows this constructs the vdisplay backend, which initialises the
|
// The vdisplay backend + built pipeline: either PREPARED at Welcome time on this very thread
|
||||||
// host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push
|
// (P1.1/P1.2 — the display bring-up already overlapped the Start RTT + hole-punch), or built
|
||||||
// preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics.
|
// inline now (Linux, synthetic-adjacent paths, prep fallback).
|
||||||
let mut vd = crate::vdisplay::open(compositor)?;
|
let (mut vd, pipe) = match prepared {
|
||||||
// Per-client STABLE monitor identity (Phase 2): hand the backend the connecting client's cert
|
Some(p) => (p.vd, p.pipeline),
|
||||||
// fingerprint so a freshly CREATED virtual monitor gets this client's persistent id — Windows then
|
None => {
|
||||||
// reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends
|
// Open the backend FIRST — on Windows this constructs the vdisplay backend, which
|
||||||
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
|
// initialises the host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work,
|
||||||
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
|
// so it must precede the IDD-push preempt below (which reaches the manager) —
|
||||||
// The client display's HDR volume (Hello) → a freshly created virtual monitor's EDID CTA HDR
|
// otherwise `vdm()` is called before init and panics.
|
||||||
// block (pf-vdisplay), so host apps + the OS tone-map to the client's real panel instead of the
|
let mut vd = crate::vdisplay::open(compositor)?;
|
||||||
// driver's built-in ~1000-nit placeholder. No-op on Linux backends and for older/SDR clients.
|
// Per-client STABLE monitor identity (Phase 2): hand the backend the connecting
|
||||||
vd.set_client_hdr(client_hdr);
|
// client's cert fingerprint so a freshly CREATED virtual monitor gets this client's
|
||||||
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the backend mints —
|
// persistent id — Windows then reapplies the client's saved per-monitor config (DPI
|
||||||
// the retry-hold below AND the capturer's — carries the session's quit flag, so a user "stop"
|
// scaling) on reconnect. No-op on Linux backends and for anonymous/GameStream clients
|
||||||
// (⌘D → the QUIT close code) tears the virtual monitor down the moment the pipeline drops instead
|
// (no fingerprint → the driver auto-allocates).
|
||||||
// of lingering 10 s. The reconnect then finds the manager Idle and does a clean fresh ADD (with
|
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
|
||||||
// the user's think-time as driver settle) rather than the Lingering-preempt's REMOVE→ADD churn.
|
// The client display's HDR volume (Hello) → a freshly created virtual monitor's EDID
|
||||||
// `keep_alive = forever` (gaming-rig) outranks the quit — the monitor pins as before.
|
// CTA HDR block (pf-vdisplay), so host apps + the OS tone-map to the client's real
|
||||||
vd.set_quit_flag(quit.clone());
|
// panel instead of the driver's built-in ~1000-nit placeholder. No-op on Linux
|
||||||
// Per-session launch (non-Windows): hand the resolved command to the backend instance so
|
// backends and for older/SDR clients.
|
||||||
// gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions
|
vd.set_client_hdr(client_hdr);
|
||||||
// can't stomp each other's launch target. The other backends' default `set_launch_command` is a
|
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the
|
||||||
// no-op; they get the command spawned into the live session after capture is up (below).
|
// backend mints — the retry-hold below AND the capturer's — carries the session's quit
|
||||||
#[cfg(not(target_os = "windows"))]
|
// flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the
|
||||||
vd.set_launch_command(launch.clone());
|
// moment the pipeline drops instead of lingering 10 s. The reconnect then finds the
|
||||||
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a
|
// manager Idle and does a clean fresh ADD (with the user's think-time as driver
|
||||||
// reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it
|
// settle) rather than the Lingering-preempt's REMOVE→ADD churn. `keep_alive = forever`
|
||||||
// to release its monitor (instead of tearing a monitor out from under a still-live session), and
|
// (gaming-rig) outranks the quit — the monitor pins as before.
|
||||||
// register THIS session's stop. The returned guard holds the setup lock across the pipeline build;
|
vd.set_quit_flag(quit.clone());
|
||||||
// dropping it lets the next reconnect begin (and preempt us). Held BEFORE the monitor is created
|
// Per-session launch (non-Windows): hand the resolved command to the backend instance
|
||||||
// (build_pipeline → vd.create), so the preempt still precedes this session's monitor creation.
|
// so gamescope's bare spawn nests it — per-instance, no process-global env, so
|
||||||
// SLOT-scoped (Stage W1): the preempt targets only a prior session holding THIS client's slot —
|
// concurrent sessions can't stomp each other's launch target. The other backends'
|
||||||
// a different identity's session is an admission question, never a preempt.
|
// default `set_launch_command` is a no-op; they get the command spawned into the live
|
||||||
#[cfg(target_os = "windows")]
|
// session after capture is up (below).
|
||||||
let _idd_setup_guard =
|
#[cfg(not(target_os = "windows"))]
|
||||||
(plan.capture == crate::session_plan::CaptureBackend::IddPush).then(|| {
|
vd.set_launch_command(launch.clone());
|
||||||
let slot = crate::vdisplay::manager::slot_id_for(
|
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5):
|
||||||
endpoint::peer_fingerprint(&conn),
|
// serialize setup so a reconnect FLOOD can't run concurrent monitor create/teardown,
|
||||||
(mode.width, mode.height),
|
// STOP the prior session + WAIT for it to release its monitor (instead of tearing a
|
||||||
);
|
// monitor out from under a still-live session), and register THIS session's stop. The
|
||||||
crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone())
|
// returned guard holds the setup lock across the pipeline build; dropping it (end of
|
||||||
});
|
// this arm) lets the next reconnect begin (and preempt us). Held BEFORE the monitor is
|
||||||
|
// created (build_pipeline → vd.create), so the preempt still precedes this session's
|
||||||
|
// monitor creation. SLOT-scoped (Stage W1): the preempt targets only a prior session
|
||||||
|
// holding THIS client's slot — a different identity's session is an admission
|
||||||
|
// question, never a preempt.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
|
||||||
|
.then(|| {
|
||||||
|
let slot = crate::vdisplay::manager::slot_id_for(
|
||||||
|
endpoint::peer_fingerprint(&conn),
|
||||||
|
(mode.width, mode.height),
|
||||||
|
);
|
||||||
|
crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone())
|
||||||
|
});
|
||||||
|
let pipe = build_pipeline_with_retry(
|
||||||
|
&mut vd,
|
||||||
|
mode,
|
||||||
|
bitrate_kbps,
|
||||||
|
bit_depth,
|
||||||
|
plan,
|
||||||
|
&quit,
|
||||||
|
&stop,
|
||||||
|
Some(bringup.as_ref()),
|
||||||
|
)?;
|
||||||
|
// Setup done — the IDD-push setup lock releases as the guard leaves this arm's scope,
|
||||||
|
// so the next reconnect can begin (and preempt us).
|
||||||
|
(vd, pipe)
|
||||||
|
}
|
||||||
|
};
|
||||||
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) =
|
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) =
|
||||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?;
|
pipe;
|
||||||
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
drop(_idd_setup_guard);
|
|
||||||
|
|
||||||
// Capture is live — launch the requested title so it renders onto the streamed output and
|
// Capture is live — launch the requested title so it renders onto the streamed output and
|
||||||
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
||||||
@@ -914,6 +956,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
codec: plan.codec.label(),
|
codec: plan.codec.label(),
|
||||||
client: client_label.clone(),
|
client: client_label.clone(),
|
||||||
bitrate_kbps: live_bitrate.clone(),
|
bitrate_kbps: live_bitrate.clone(),
|
||||||
|
bringup: bringup.clone(),
|
||||||
};
|
};
|
||||||
let send_thread = std::thread::Builder::new()
|
let send_thread = std::thread::Builder::new()
|
||||||
.name("punktfunk-send".into())
|
.name("punktfunk-send".into())
|
||||||
@@ -949,6 +992,8 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
force_idr.clone(),
|
force_idr.clone(),
|
||||||
client_label,
|
client_label,
|
||||||
plan.hdr,
|
plan.hdr,
|
||||||
|
bringup.total_slot(),
|
||||||
|
resize_ms.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit
|
// Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit
|
||||||
@@ -1081,6 +1126,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
plan,
|
plan,
|
||||||
&quit,
|
&quit,
|
||||||
&stop,
|
&stop,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
Ok((new_vd, pipe))
|
Ok((new_vd, pipe))
|
||||||
})();
|
})();
|
||||||
@@ -1131,6 +1177,10 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
}
|
}
|
||||||
if let Some(new_mode) = want {
|
if let Some(new_mode) = want {
|
||||||
tracing::info!(?new_mode, "rebuilding pipeline for mode switch");
|
tracing::info!(?new_mode, "rebuilding pipeline for mode switch");
|
||||||
|
// Resize trace (P0.1): reconfigure-received → pipeline rebuilt (incl. the first
|
||||||
|
// new-mode frame — `build_pipeline` waits for it). Total lands in the shared
|
||||||
|
// `resize_ms` slot (→ `session_status`); a failed rebuild abandons it silently.
|
||||||
|
let resize_trace = crate::bringup::Trace::start("resize", resize_ms.clone());
|
||||||
// PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) —
|
// PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) —
|
||||||
// a resolution change moves the operating point (1080p→4K quadruples the pixel rate),
|
// a resolution change moves the operating point (1080p→4K quadruples the pixel rate),
|
||||||
// so re-resolve it for the new mode. Explicit client rates stay put (the operator knows
|
// so re-resolve it for the new mode. Explicit client rates stay put (the operator knows
|
||||||
@@ -1140,72 +1190,111 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
bitrate_kbps
|
bitrate_kbps
|
||||||
};
|
};
|
||||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
// IN-PLACE fast path first (latency plan P2.3, Windows IDD-push): keep the capturer +
|
||||||
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
// send thread, mode-set the SAME monitor in place (P2.1/P2.2), resize the ring, swap
|
||||||
|
// only the encoder. Any decline (v3 driver → the manager re-arrived, ring recreate
|
||||||
|
// failed, no new-size frame) falls through to the full rebuild below.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let fast_done = plan.capture == crate::session_plan::CaptureBackend::IddPush
|
||||||
|
&& try_inplace_resize(
|
||||||
|
&mut vd,
|
||||||
|
&mut capturer,
|
||||||
|
&mut enc,
|
||||||
|
&mut frame,
|
||||||
|
&mut interval,
|
||||||
|
new_mode,
|
||||||
|
mode_bitrate,
|
||||||
|
bit_depth,
|
||||||
|
plan,
|
||||||
|
&quit,
|
||||||
|
resize_trace.as_ref(),
|
||||||
|
);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let fast_done = false;
|
||||||
|
// Full rebuild — build the new pipeline BEFORE dropping the old one: the host already
|
||||||
|
// acked the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||||
// healthy session — keep streaming the current mode and log instead.
|
// healthy session — keep streaming the current mode and log instead.
|
||||||
match build_pipeline(&mut vd, new_mode, mode_bitrate, bit_depth, plan, &quit) {
|
let rebuilt = fast_done
|
||||||
Ok(next_pipe) => {
|
|| match build_pipeline(
|
||||||
if mode_bitrate != bitrate_kbps {
|
&mut vd,
|
||||||
tracing::info!(
|
new_mode,
|
||||||
from_kbps = bitrate_kbps,
|
mode_bitrate,
|
||||||
to_kbps = mode_bitrate,
|
bit_depth,
|
||||||
"pinned PyroWave bitrate re-resolved for the new mode"
|
plan,
|
||||||
);
|
&quit,
|
||||||
bitrate_kbps = mode_bitrate;
|
Some(resize_trace.as_ref()),
|
||||||
live_bitrate.store(mode_bitrate, Ordering::Relaxed);
|
) {
|
||||||
|
Ok(next_pipe) => {
|
||||||
|
let old_display_gen = cur_display_gen;
|
||||||
|
// The destructuring assignment drops the OLD capturer (→ its display lease)
|
||||||
|
// as each binding is replaced — the new pipeline is already up
|
||||||
|
// (create-before-drop).
|
||||||
|
(capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe;
|
||||||
|
// H4: the old display's lease drop above is indistinguishable from a
|
||||||
|
// disconnect to the keep-alive machinery — under linger/forever policies
|
||||||
|
// every resize would ACCUMULATE kept monitors at stale modes. Retire the
|
||||||
|
// superseded entry now (a no-op when it was already torn down under
|
||||||
|
// `immediate`, or off Linux; the in-place fast path keeps the SAME display,
|
||||||
|
// so it has nothing to retire).
|
||||||
|
if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) {
|
||||||
|
crate::vdisplay::registry::retire(g);
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
let old_display_gen = cur_display_gen;
|
Err(e) => {
|
||||||
// The destructuring assignment drops the OLD capturer (→ its display lease) as
|
tracing::warn!(error = %format!("{e:#}"), ?new_mode,
|
||||||
// each binding is replaced — the new pipeline is already up (create-before-drop).
|
"mode-switch rebuild failed — staying on the current mode");
|
||||||
(capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe;
|
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
|
||||||
cur_mode = new_mode;
|
// client's mode slot already flipped to `new_mode`. A second accepted ack
|
||||||
next = std::time::Instant::now();
|
// carrying the still-live mode corrects it (any accepted ack means "the
|
||||||
// H4: the old display's lease drop above is indistinguishable from a disconnect
|
// active mode is now X" client-side; old clients just log it). `frame` is
|
||||||
// to the keep-alive machinery — under linger/forever policies every resize would
|
// untouched here (the fast path returned false before swapping anything and
|
||||||
// ACCUMULATE kept monitors at stale modes. Retire the superseded entry now (a
|
// the destructure only runs on the Ok arm), so it's still the OLD
|
||||||
// no-op when it was already torn down under `immediate`, or off Linux).
|
// pipeline's frame — its real dims + interval are what's still on glass.
|
||||||
if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) {
|
|
||||||
crate::vdisplay::registry::retire(g);
|
|
||||||
}
|
|
||||||
// H2/H3: the backend may have honored a different mode than requested — KWin
|
|
||||||
// caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place
|
|
||||||
// SetMode to a resolution its running monitor doesn't advertise and the host
|
|
||||||
// falls back to the actual display mode. `frame` is the NEW pipeline's first
|
|
||||||
// frame (just rebound above), so its dims are what the client actually decodes.
|
|
||||||
// Publish that ACTUAL mode to the live stats slot, and correct the client's mode
|
|
||||||
// slot when it differs from the accept ack it already got.
|
|
||||||
let actual = delivered_mode(frame.width, frame.height, interval);
|
|
||||||
live_mode.store(
|
|
||||||
pack_mode(actual.width, actual.height, actual.refresh_hz),
|
|
||||||
Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
if actual != new_mode {
|
|
||||||
let _ = reconfig_result_tx.send(Reconfigured {
|
let _ = reconfig_result_tx.send(Reconfigured {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
mode: actual,
|
mode: delivered_mode(frame.width, frame.height, interval),
|
||||||
});
|
});
|
||||||
|
false
|
||||||
}
|
}
|
||||||
// The owed AUs died with the old encoder — drop their in-flight records
|
};
|
||||||
// and restart the encode-stall clock for the fresh one.
|
if rebuilt {
|
||||||
inflight.clear();
|
if mode_bitrate != bitrate_kbps {
|
||||||
last_au_at = std::time::Instant::now();
|
tracing::info!(
|
||||||
encoder_resets = 0;
|
from_kbps = bitrate_kbps,
|
||||||
last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown
|
to_kbps = mode_bitrate,
|
||||||
|
"pinned PyroWave bitrate re-resolved for the new mode"
|
||||||
|
);
|
||||||
|
bitrate_kbps = mode_bitrate;
|
||||||
|
live_bitrate.store(mode_bitrate, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
cur_mode = new_mode;
|
||||||
tracing::warn!(error = %format!("{e:#}"), ?new_mode,
|
next = std::time::Instant::now();
|
||||||
"mode-switch rebuild failed — staying on the current mode");
|
// H2/H3: the backend may have honored a different mode than requested — KWin caps
|
||||||
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
|
// a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its
|
||||||
// client's mode slot already flipped to `new_mode`. A second accepted ack
|
// running monitor doesn't advertise and the host falls back to the actual display
|
||||||
// carrying the still-live mode corrects it (any accepted ack means "the active
|
// mode. `frame` is the NEW pipeline's first frame (just rebound above), so its
|
||||||
// mode is now X" client-side; old clients just log it). `frame` is untouched
|
// dims are what the client actually decodes. Publish that ACTUAL mode to the live
|
||||||
// here (the destructure only runs on the Ok arm), so it's still the OLD
|
// stats slot, and correct the client's mode slot when it differs from the accept
|
||||||
// pipeline's frame — its real dims + interval are exactly what's still on glass.
|
// ack it already got.
|
||||||
|
let actual = delivered_mode(frame.width, frame.height, interval);
|
||||||
|
live_mode.store(
|
||||||
|
pack_mode(actual.width, actual.height, actual.refresh_hz),
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
if actual != new_mode {
|
||||||
let _ = reconfig_result_tx.send(Reconfigured {
|
let _ = reconfig_result_tx.send(Reconfigured {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
mode: delivered_mode(frame.width, frame.height, interval),
|
mode: actual,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The owed AUs died with the old encoder — drop their in-flight records
|
||||||
|
// and restart the encode-stall clock for the fresh one.
|
||||||
|
inflight.clear();
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
encoder_resets = 0;
|
||||||
|
last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown
|
||||||
|
resize_trace.finish("pipeline_rebuilt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
||||||
@@ -1499,6 +1588,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
plan,
|
plan,
|
||||||
&quit,
|
&quit,
|
||||||
&stop,
|
&stop,
|
||||||
|
None,
|
||||||
) {
|
) {
|
||||||
Ok(p) => break p,
|
Ok(p) => break p,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
@@ -1743,6 +1833,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
};
|
};
|
||||||
// Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it
|
// Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it
|
||||||
// exited (send failure / stop) — end the encode loop too.
|
// exited (send failure / stop) — end the encode loop too.
|
||||||
|
bringup.mark("first_au"); // P0.1 (first-crossing only; free afterwards)
|
||||||
if frame_tx.send(msg).is_err() {
|
if frame_tx.send(msg).is_err() {
|
||||||
send_gone = true;
|
send_gone = true;
|
||||||
break;
|
break;
|
||||||
@@ -1849,6 +1940,191 @@ type Pipeline = (
|
|||||||
Option<u64>,
|
Option<u64>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// The in-place resize fast path (latency plan P2.3, Windows IDD-push): the manager mode-sets the
|
||||||
|
/// SAME monitor in place (driver protocol v4 — `IOCTL_UPDATE_MODES`; internally falls back to
|
||||||
|
/// re-arrival against an older driver), then the existing capturer re-sizes its ring immediately
|
||||||
|
/// (no descriptor-poll debounce) and only the ENCODER is swapped once the first new-size frame
|
||||||
|
/// arrives — the capture pipeline, its send thread and the whole session transport survive.
|
||||||
|
/// Returns `true` when the stream is now delivering the new mode on the same capturer; `false`
|
||||||
|
/// routes the caller to the full rebuild (which is also the correct path when the manager had to
|
||||||
|
/// re-arrive a fresh monitor — this capturer's ring/broker are bound to the departed target).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn try_inplace_resize(
|
||||||
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
|
capturer: &mut Box<dyn crate::capture::Capturer>,
|
||||||
|
enc: &mut Box<dyn crate::encode::Encoder>,
|
||||||
|
frame: &mut crate::capture::CapturedFrame,
|
||||||
|
interval: &mut std::time::Duration,
|
||||||
|
new_mode: punktfunk_core::Mode,
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
bit_depth: u8,
|
||||||
|
plan: crate::session_plan::SessionPlan,
|
||||||
|
quit: &Arc<AtomicBool>,
|
||||||
|
trace: &crate::bringup::Trace,
|
||||||
|
) -> bool {
|
||||||
|
let Some(cur_target) = capturer.capture_target_id() else {
|
||||||
|
return false; // not an IDD-push capturer — nothing to reuse
|
||||||
|
};
|
||||||
|
// Acquire at the new mode: the manager's resize branch runs the in-place mode set (or its
|
||||||
|
// re-arrival fallback) and returns a +1-ref lease, released again when `vout` drops below —
|
||||||
|
// the capturer keeps holding its own original lease (`gen` is preserved by both paths).
|
||||||
|
let vout = match crate::vdisplay::registry::acquire(vd, new_mode, quit.clone()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "in-place resize: acquire failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trace.mark("display_resized");
|
||||||
|
let effective_hz = vout
|
||||||
|
.preferred_mode
|
||||||
|
.map(|(_, _, hz)| hz)
|
||||||
|
.filter(|&hz| hz > 0)
|
||||||
|
.unwrap_or(new_mode.refresh_hz);
|
||||||
|
if vout.win_capture.as_ref().map(|t| t.target_id) != Some(cur_target) {
|
||||||
|
// The manager re-arrived a fresh monitor (old driver / in-place failure): this capturer is
|
||||||
|
// bound to the departed target. The full rebuild re-acquires (JOINing the already-resized
|
||||||
|
// monitor) with a fresh capturer.
|
||||||
|
tracing::info!(
|
||||||
|
"resize: monitor re-arrived (no in-place support) — running the full pipeline rebuild"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !capturer.resize_output(new_mode.width, new_mode.height) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
trace.mark("ring_recreated");
|
||||||
|
// Bounded wait for the first frame at the new size (the driver re-attaches to the fresh ring;
|
||||||
|
// the mode-set full redraw composes promptly). Mirrors the capturer's own 3 s recover-or-drop.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||||
|
let new_frame = loop {
|
||||||
|
match capturer.try_latest() {
|
||||||
|
Ok(Some(f)) if (f.width, f.height) == (new_mode.width, new_mode.height) => break f,
|
||||||
|
Ok(_) => {
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
tracing::warn!(
|
||||||
|
"resize: no new-size frame within 3s of the in-place mode set — running \
|
||||||
|
the full pipeline rebuild"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"resize: capture failed after the in-place mode set — running the full rebuild");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trace.mark("first_new_frame");
|
||||||
|
// Fresh encoder at the delivered size — the one component that can't follow a resolution
|
||||||
|
// change in place today (P2.4 stays unimplemented: `open_video` is ms-scale, measured).
|
||||||
|
let mut new_enc = match crate::encode::open_video(
|
||||||
|
plan.codec,
|
||||||
|
new_frame.format,
|
||||||
|
new_frame.width,
|
||||||
|
new_frame.height,
|
||||||
|
effective_hz,
|
||||||
|
bitrate_kbps as u64 * 1000,
|
||||||
|
new_frame.is_cuda(),
|
||||||
|
bit_depth,
|
||||||
|
plan.chroma,
|
||||||
|
) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"resize: encoder open failed after the in-place mode set — running the full rebuild");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(c) = plan.wire_chunk {
|
||||||
|
new_enc.set_wire_chunking(c);
|
||||||
|
}
|
||||||
|
*enc = new_enc;
|
||||||
|
*frame = new_frame;
|
||||||
|
*interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
|
||||||
|
trace.mark("encoder_open");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Welcome-time display-prep hand-off (latency plan P1.1/P1.2): the opened vdisplay backend +
|
||||||
|
/// the fully built pipeline — monitor create, activation, settle, capture attach, first frame,
|
||||||
|
/// encoder open — produced on the prep/stream thread while the client's Start round-trip and the
|
||||||
|
/// UDP hole-punch are still in flight, so the entire display bring-up hides behind the network
|
||||||
|
/// waits. Constructed on the Windows native path only today: the Linux backends bind launch
|
||||||
|
/// semantics before create (gamescope nests the launch command), which must not run for a client
|
||||||
|
/// that never sends Start.
|
||||||
|
pub(super) struct PreparedDisplay {
|
||||||
|
pub(super) vd: Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
|
pub(super) pipeline: Pipeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prep thread's hand-off pair: the sender delivers the post-punch [`SessionContext`] to the
|
||||||
|
/// thread (which then runs [`virtual_stream`] on its prepared display); the join handle returns
|
||||||
|
/// the stream result. Dropping the sender un-received aborts the prep cleanly (the prepared
|
||||||
|
/// display's lease releases into keep-alive policy).
|
||||||
|
pub(super) type PrepHandle = (
|
||||||
|
std::sync::mpsc::SyncSender<SessionContext>,
|
||||||
|
std::thread::JoinHandle<Result<()>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Build the session's display + pipeline at Welcome time (latency plan P1.1/P1.2), before the
|
||||||
|
/// client's `Start` and the hole-punch — the negotiated mode is final once the Welcome is built,
|
||||||
|
/// and nothing in monitor create → activation → settle → capture attach → encoder open needs the
|
||||||
|
/// punched socket. Mirrors `virtual_stream`'s inline bring-up exactly: same backend setters, same
|
||||||
|
/// slot-scoped `begin_idd_setup` serialization (the guard releases when this returns), same
|
||||||
|
/// retry-wrapped build. The caller threads the SAME values the Welcome committed, so the prepared
|
||||||
|
/// pipeline and the later `SessionContext` can never disagree.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn prepare_display(
|
||||||
|
compositor: crate::vdisplay::Compositor,
|
||||||
|
mode: punktfunk_core::Mode,
|
||||||
|
client_identity: Option<[u8; 32]>,
|
||||||
|
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
bit_depth: u8,
|
||||||
|
chroma: crate::encode::ChromaFormat,
|
||||||
|
codec: crate::encode::Codec,
|
||||||
|
shard_payload: u16,
|
||||||
|
quit: &Arc<AtomicBool>,
|
||||||
|
stop: &Arc<AtomicBool>,
|
||||||
|
trace: &crate::bringup::Trace,
|
||||||
|
) -> Result<PreparedDisplay> {
|
||||||
|
// Same plan resolution as `virtual_stream` (pure in these inputs + host config), including
|
||||||
|
// PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated
|
||||||
|
// Welcome value passed here.
|
||||||
|
let mut plan = crate::session_plan::SessionPlan::resolve(bit_depth, chroma, codec);
|
||||||
|
if codec == crate::encode::Codec::PyroWave {
|
||||||
|
plan.wire_chunk = Some(shard_payload as usize);
|
||||||
|
}
|
||||||
|
let mut vd = crate::vdisplay::open(compositor)?;
|
||||||
|
vd.set_client_identity(client_identity);
|
||||||
|
vd.set_client_hdr(client_hdr);
|
||||||
|
vd.set_quit_flag(quit.clone());
|
||||||
|
// Slot-scoped setup serialization + reconnect preempt — see the inline arm in
|
||||||
|
// `virtual_stream` for the full rationale; released when this fn returns.
|
||||||
|
let _idd_setup_guard =
|
||||||
|
(plan.capture == crate::session_plan::CaptureBackend::IddPush).then(|| {
|
||||||
|
let slot =
|
||||||
|
crate::vdisplay::manager::slot_id_for(client_identity, (mode.width, mode.height));
|
||||||
|
crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone())
|
||||||
|
});
|
||||||
|
let pipeline = build_pipeline_with_retry(
|
||||||
|
&mut vd,
|
||||||
|
mode,
|
||||||
|
bitrate_kbps,
|
||||||
|
bit_depth,
|
||||||
|
plan,
|
||||||
|
quit,
|
||||||
|
stop,
|
||||||
|
Some(trace),
|
||||||
|
)?;
|
||||||
|
Ok(PreparedDisplay { vd, pipeline })
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
|
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
|
||||||
///
|
///
|
||||||
/// Bringing a virtual output to first-frame races several async steps — the compositor parenting
|
/// Bringing a virtual output to first-frame races several async steps — the compositor parenting
|
||||||
@@ -1859,6 +2135,7 @@ type Pipeline = (
|
|||||||
/// error chain is classified and permanent ones short-circuit. Each failed attempt drops its
|
/// error chain is classified and permanent ones short-circuit. Each failed attempt drops its
|
||||||
/// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down
|
/// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down
|
||||||
/// before the next attempt — no leak across retries.
|
/// before the next attempt — no leak across retries.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_pipeline_with_retry(
|
fn build_pipeline_with_retry(
|
||||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
@@ -1867,6 +2144,9 @@ fn build_pipeline_with_retry(
|
|||||||
plan: crate::session_plan::SessionPlan,
|
plan: crate::session_plan::SessionPlan,
|
||||||
quit: &Arc<AtomicBool>,
|
quit: &Arc<AtomicBool>,
|
||||||
stop: &Arc<AtomicBool>,
|
stop: &Arc<AtomicBool>,
|
||||||
|
// Transition trace (P0.1): `Some` for the traced builds (bring-up, resize); each stage stamps
|
||||||
|
// once (first crossing) so the retry loop can pass it through unconditionally.
|
||||||
|
trace: Option<&crate::bringup::Trace>,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||||
@@ -1904,7 +2184,7 @@ fn build_pipeline_with_retry(
|
|||||||
attempt - 1
|
attempt - 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
|
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit, trace) {
|
||||||
Ok(pipe) => {
|
Ok(pipe) => {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
tracing::info!(attempt, "pipeline up after retry");
|
tracing::info!(attempt, "pipeline up after retry");
|
||||||
@@ -1979,6 +2259,7 @@ fn reset_stalled_encoder(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_pipeline(
|
fn build_pipeline(
|
||||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
@@ -1986,6 +2267,9 @@ fn build_pipeline(
|
|||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
plan: crate::session_plan::SessionPlan,
|
plan: crate::session_plan::SessionPlan,
|
||||||
quit: &Arc<AtomicBool>,
|
quit: &Arc<AtomicBool>,
|
||||||
|
// Transition trace (P0.1): stamps the build's stages (display acquire, capture attach, first
|
||||||
|
// frame, encoder open) into the bring-up/resize timeline. `None` on untraced rebuilds.
|
||||||
|
trace: Option<&crate::bringup::Trace>,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
// Acquire through the registry (design/display-management.md): on Linux this pools the display
|
// Acquire through the registry (design/display-management.md): on Linux this pools the display
|
||||||
// for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the
|
// for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the
|
||||||
@@ -1994,6 +2278,9 @@ fn build_pipeline(
|
|||||||
// `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger.
|
// `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger.
|
||||||
let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone())
|
let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone())
|
||||||
.context("create virtual output")?;
|
.context("create virtual output")?;
|
||||||
|
if let Some(t) = trace {
|
||||||
|
t.mark("display_acquired");
|
||||||
|
}
|
||||||
// A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down
|
// A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down
|
||||||
// so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the
|
// so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the
|
||||||
// gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.)
|
// gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.)
|
||||||
@@ -2031,6 +2318,9 @@ fn build_pipeline(
|
|||||||
let mut capturer =
|
let mut capturer =
|
||||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||||
.context("capture virtual output")?;
|
.context("capture virtual output")?;
|
||||||
|
if let Some(t) = trace {
|
||||||
|
t.mark("capture_attached");
|
||||||
|
}
|
||||||
capturer.set_active(true);
|
capturer.set_active(true);
|
||||||
let frame = match capturer.next_frame().context("first frame") {
|
let frame = match capturer.next_frame().context("first frame") {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
@@ -2043,6 +2333,9 @@ fn build_pipeline(
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if let Some(t) = trace {
|
||||||
|
t.mark("first_frame");
|
||||||
|
}
|
||||||
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
|
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
|
||||||
// advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome.
|
// advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome.
|
||||||
let mut enc = crate::encode::open_video(
|
let mut enc = crate::encode::open_video(
|
||||||
@@ -2057,6 +2350,9 @@ fn build_pipeline(
|
|||||||
plan.chroma,
|
plan.chroma,
|
||||||
)
|
)
|
||||||
.context("open video encoder")?;
|
.context("open video encoder")?;
|
||||||
|
if let Some(t) = trace {
|
||||||
|
t.mark("encoder_open");
|
||||||
|
}
|
||||||
if let Some(c) = plan.wire_chunk {
|
if let Some(c) = plan.wire_chunk {
|
||||||
enc.set_wire_chunking(c);
|
enc.set_wire_chunking(c);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ struct LiveSession {
|
|||||||
client: String,
|
client: String,
|
||||||
/// Whether the session negotiated HDR — carried on the lifecycle events.
|
/// Whether the session negotiated HDR — carried on the lifecycle events.
|
||||||
hdr: bool,
|
hdr: bool,
|
||||||
|
/// Completed bring-up total (hello → first packet), ms; 0 until the first packet left. Written
|
||||||
|
/// once by the session's [`crate::bringup::Trace`] (latency plan P0.1).
|
||||||
|
ttff_ms: Arc<AtomicU32>,
|
||||||
|
/// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet.
|
||||||
|
last_resize_ms: Arc<AtomicU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A resolved read of one live session, for the `/status` view.
|
/// A resolved read of one live session, for the `/status` view.
|
||||||
@@ -50,6 +55,10 @@ pub struct SessionSnapshot {
|
|||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_kbps: u32,
|
||||||
pub codec: Codec,
|
pub codec: Codec,
|
||||||
|
/// Bring-up total (hello → first packet), ms; 0 while still bringing up (latency plan P0.1).
|
||||||
|
pub time_to_first_frame_ms: u32,
|
||||||
|
/// Most recent mid-stream resize total, ms; 0 = no resize this session.
|
||||||
|
pub last_resize_ms: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry() -> &'static Mutex<Vec<LiveSession>> {
|
fn registry() -> &'static Mutex<Vec<LiveSession>> {
|
||||||
@@ -84,6 +93,8 @@ pub fn register(
|
|||||||
force_idr: Arc<AtomicBool>,
|
force_idr: Arc<AtomicBool>,
|
||||||
client: String,
|
client: String,
|
||||||
hdr: bool,
|
hdr: bool,
|
||||||
|
ttff_ms: Arc<AtomicU32>,
|
||||||
|
last_resize_ms: Arc<AtomicU32>,
|
||||||
) -> LiveSessionGuard {
|
) -> LiveSessionGuard {
|
||||||
let id = next_id();
|
let id = next_id();
|
||||||
let session = LiveSession {
|
let session = LiveSession {
|
||||||
@@ -95,6 +106,8 @@ pub fn register(
|
|||||||
force_idr,
|
force_idr,
|
||||||
client,
|
client,
|
||||||
hdr,
|
hdr,
|
||||||
|
ttff_ms,
|
||||||
|
last_resize_ms,
|
||||||
};
|
};
|
||||||
crate::events::emit(crate::events::EventKind::SessionStarted {
|
crate::events::emit(crate::events::EventKind::SessionStarted {
|
||||||
session: session_ref(&session),
|
session: session_ref(&session),
|
||||||
@@ -140,6 +153,8 @@ pub fn snapshot() -> Vec<SessionSnapshot> {
|
|||||||
fps,
|
fps,
|
||||||
bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed),
|
bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed),
|
||||||
codec: s.codec,
|
codec: s.codec,
|
||||||
|
time_to_first_frame_ms: s.ttff_ms.load(Ordering::Relaxed),
|
||||||
|
last_resize_ms: s.last_resize_ms.load(Ordering::Relaxed),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
--gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer).
|
--gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer).
|
||||||
|
|
||||||
Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} +
|
Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} +
|
||||||
punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad — it shares
|
punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad - it shares
|
||||||
this pipeline + the --gamepad install path.)
|
this pipeline + the --gamepad install path.)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
|
|||||||
@@ -129,6 +129,16 @@ pub unsafe extern "C" fn parse_monitor_description2(
|
|||||||
return STATUS_NOT_FOUND;
|
return STATUS_NOT_FOUND;
|
||||||
};
|
};
|
||||||
let count = crate::monitor::flatten(&modes).count() as u32;
|
let count = crate::monitor::flatten(&modes).count() as u32;
|
||||||
|
// Bring-up/diagnostic visibility (P2): does the OS ever RE-parse the description after an
|
||||||
|
// UPDATE_MODES? The head mode names which list generation this call served.
|
||||||
|
if let Some(head) = crate::monitor::flatten(&modes).next() {
|
||||||
|
dbglog!(
|
||||||
|
"[pf-vd] parse_monitor_description2(id={id}): {count} modes, head {}x{}@{}",
|
||||||
|
head.width,
|
||||||
|
head.height,
|
||||||
|
head.refresh_rate
|
||||||
|
);
|
||||||
|
}
|
||||||
out_args.MonitorModeBufferOutputCount = count;
|
out_args.MonitorModeBufferOutputCount = count;
|
||||||
if in_args.MonitorModeBufferInputCount < count {
|
if in_args.MonitorModeBufferInputCount < count {
|
||||||
// A zero input count is a count-only probe (success); a non-zero too-small buffer is an error.
|
// A zero input count is a count-only probe (success); a non-zero too-small buffer is an error.
|
||||||
@@ -204,6 +214,17 @@ pub unsafe extern "C" fn monitor_query_modes2(
|
|||||||
return STATUS_NOT_FOUND;
|
return STATUS_NOT_FOUND;
|
||||||
};
|
};
|
||||||
let count = crate::monitor::flatten(&modes).count() as u32;
|
let count = crate::monitor::flatten(&modes).count() as u32;
|
||||||
|
// Diagnostic visibility (P2): shows whether/when the OS re-queries target modes after an
|
||||||
|
// UPDATE_MODES (the head mode names the list generation this call served).
|
||||||
|
if let Some(head) = crate::monitor::flatten(&modes).next() {
|
||||||
|
dbglog!(
|
||||||
|
"[pf-vd] monitor_query_modes2: {count} modes, head {}x{}@{} (fill={})",
|
||||||
|
head.width,
|
||||||
|
head.height,
|
||||||
|
head.refresh_rate,
|
||||||
|
in_args.TargetModeBufferInputCount >= count
|
||||||
|
);
|
||||||
|
}
|
||||||
out_args.TargetModeBufferOutputCount = count;
|
out_args.TargetModeBufferOutputCount = count;
|
||||||
if in_args.TargetModeBufferInputCount >= count {
|
if in_args.TargetModeBufferInputCount >= count {
|
||||||
// SAFETY: `pTargetModes` points to >= `count` IDDCX_TARGET_MODE2 entries.
|
// SAFETY: `pTargetModes` points to >= `count` IDDCX_TARGET_MODE2 entries.
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
|
|||||||
control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) },
|
control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) },
|
||||||
// SAFETY: `request` is the framework WDFREQUEST.
|
// SAFETY: `request` is the framework WDFREQUEST.
|
||||||
control::IOCTL_SET_FRAME_CHANNEL => unsafe { set_frame_channel(request) },
|
control::IOCTL_SET_FRAME_CHANNEL => unsafe { set_frame_channel(request) },
|
||||||
|
// SAFETY: `request` is the framework WDFREQUEST.
|
||||||
|
control::IOCTL_UPDATE_MODES => unsafe { update_modes(request) },
|
||||||
_ => complete(request, STATUS_NOT_FOUND),
|
_ => complete(request, STATUS_NOT_FOUND),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +200,28 @@ unsafe fn set_frame_channel(request: WDFREQUEST) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_UPDATE_MODES` (v4): refresh a LIVE monitor's target-mode list to a new preferred mode —
|
||||||
|
/// the in-place mid-stream resize (`design/first-frame-and-resize-latency.md` P2). The monitor is
|
||||||
|
/// NOT departed: its OS identity, swap-chain machinery and retained frame stash all survive; the
|
||||||
|
/// host force-sets the freshly-advertised mode afterwards.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `request` is the framework `WDFREQUEST`.
|
||||||
|
unsafe fn update_modes(request: WDFREQUEST) {
|
||||||
|
// SAFETY: `request` is the framework WDFREQUEST.
|
||||||
|
let Some(req) = (unsafe { read_input::<control::UpdateModesRequest>(request) }) else {
|
||||||
|
complete(request, STATUS_INVALID_PARAMETER);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !valid_mode(req.width, req.height, req.refresh_hz) {
|
||||||
|
complete(request, STATUS_INVALID_PARAMETER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let st =
|
||||||
|
crate::monitor::update_monitor_modes(req.session_id, req.width, req.height, req.refresh_hz);
|
||||||
|
complete(request, st);
|
||||||
|
}
|
||||||
|
|
||||||
/// `IOCTL_REMOVE`: depart + drop the monitor for the given session id.
|
/// `IOCTL_REMOVE`: depart + drop the monitor for the given session id.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use wdk_sys::{WDFOBJECT, call_unsafe_wdf_function_binding, iddcx};
|
use wdk_sys::{NTSTATUS, WDFOBJECT, call_unsafe_wdf_function_binding, iddcx};
|
||||||
|
|
||||||
/// One resolution with the refresh rates it supports.
|
/// One resolution with the refresh rates it supports.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -146,6 +146,41 @@ pub fn reap_orphaned(grace: Duration) -> usize {
|
|||||||
n
|
n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append `from`'s modes to `into`, skipping resolutions already present, capped at
|
||||||
|
/// [`MODE_LIST_CAP`] — the accumulate half of the union semantics (see [`update_monitor_modes`]).
|
||||||
|
fn union_modes(into: &mut Vec<Mode>, from: &[Mode]) {
|
||||||
|
for m in from {
|
||||||
|
if into.len() >= MODE_LIST_CAP {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !into
|
||||||
|
.iter()
|
||||||
|
.any(|e| (e.width, e.height) == (m.width, m.height))
|
||||||
|
{
|
||||||
|
into.push(m.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last advertised mode list of a DEPARTED monitor, per monitor id — consumed by the next
|
||||||
|
/// same-id [`create_monitor`] so a re-arrived monitor's ARRIVAL list already contains every mode
|
||||||
|
/// its predecessor ever served. The OS pins a monitor's settable set at arrival (see
|
||||||
|
/// [`update_monitor_modes`]), so this is what makes a windowed↔fullscreen cycle (or any return to
|
||||||
|
/// a previously-used size) an IN-PLACE mode set instead of another hotplug. In-process only (a
|
||||||
|
/// WUDFHost restart forgets it — harmless, the next resizes re-teach it); bounded: ≤ 16 ids ×
|
||||||
|
/// [`MODE_LIST_CAP`] modes.
|
||||||
|
static MODE_HISTORY: Mutex<Vec<(u32, Vec<Mode>)>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Record a departing monitor's advertised list for its id ([`MODE_HISTORY`]).
|
||||||
|
fn remember_modes(id: u32, modes: &[Mode]) {
|
||||||
|
let mut hist = MODE_HISTORY.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(slot) = hist.iter_mut().find(|(i, _)| *i == id) {
|
||||||
|
slot.1 = modes.to_vec();
|
||||||
|
} else {
|
||||||
|
hist.push((id, modes.to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fallback modes appended after the requested mode, so a topology change still has options.
|
/// Fallback modes appended after the requested mode, so a topology change still has options.
|
||||||
fn default_modes() -> Vec<Mode> {
|
fn default_modes() -> Vec<Mode> {
|
||||||
vec![
|
vec![
|
||||||
@@ -365,9 +400,7 @@ pub fn preserve_publisher(
|
|||||||
/// swap-chain's render adapter matches the publisher's ([`FramePublisher::render_adapter`]) — same
|
/// swap-chain's render adapter matches the publisher's ([`FramePublisher::render_adapter`]) — same
|
||||||
/// pooled device, so its context + opened ring textures are still valid; on a mismatch the caller drops
|
/// pooled device, so its context + opened ring textures are still valid; on a mismatch the caller drops
|
||||||
/// it and waits for a fresh channel delivery instead. `None` until a worker has stashed one.
|
/// it and waits for a fresh channel delivery instead. `None` until a worker has stashed one.
|
||||||
pub fn take_preserved_publisher(
|
pub fn take_preserved_publisher(target_id: u32) -> Option<crate::frame_transport::FramePublisher> {
|
||||||
target_id: u32,
|
|
||||||
) -> Option<crate::frame_transport::FramePublisher> {
|
|
||||||
if target_id == 0 {
|
if target_id == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -496,6 +529,15 @@ pub fn create_monitor(
|
|||||||
let id = {
|
let id = {
|
||||||
let mut lock = lock_monitors();
|
let mut lock = lock_monitors();
|
||||||
let id = resolve_id(&lock, preferred_id);
|
let id = resolve_id(&lock, preferred_id);
|
||||||
|
// Same-id mode history (P2 union semantics): a RE-ARRIVED monitor advertises every mode
|
||||||
|
// its departed predecessor served, so the OS's arrival-pinned settable set already
|
||||||
|
// contains them — a return to any previously-used size is then an IN-PLACE mode set.
|
||||||
|
{
|
||||||
|
let hist = MODE_HISTORY.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some((_, prev)) = hist.iter().find(|(i, _)| *i == id) {
|
||||||
|
union_modes(&mut modes, prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
lock.push(MonitorObject {
|
lock.push(MonitorObject {
|
||||||
object: None,
|
object: None,
|
||||||
id,
|
id,
|
||||||
@@ -600,6 +642,77 @@ pub fn create_monitor(
|
|||||||
Some((id, target_id, luid_low, luid_high))
|
Some((id, target_id, luid_low, luid_high))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many distinct resolutions a monitor's advertised list may accumulate (the requested head +
|
||||||
|
/// history + the built-in fallbacks). Bounds the union growth across many resizes; the OLDEST
|
||||||
|
/// history entries fall off first.
|
||||||
|
const MODE_LIST_CAP: usize = 12;
|
||||||
|
|
||||||
|
/// `IOCTL_UPDATE_MODES` (v4): refresh the LIVE monitor's advertised mode list to lead with a new
|
||||||
|
/// preferred mode and push the new TARGET mode list to the OS via `IddCxMonitorUpdateModes2` —
|
||||||
|
/// the in-place mid-stream resize (`design/first-frame-and-resize-latency.md` P2). No departure:
|
||||||
|
/// the monitor's OS identity, its swap-chain worker and the retained frame stash all survive.
|
||||||
|
/// The `*2` (HDR) DDI matches the `*2` mode/buffer family this driver already requires
|
||||||
|
/// (IddCx 1.10), so it adds no new OS floor.
|
||||||
|
///
|
||||||
|
/// UNION semantics (on-glass finding, build 26200): the OS re-parses the description AND
|
||||||
|
/// re-queries target modes after `UpdateModes2` — our callbacks served the fresh list — yet the
|
||||||
|
/// SETTABLE set stays pruned to the modes known at monitor ARRIVAL (the monitor source-mode set
|
||||||
|
/// is pinned then). So replacing the list can only ever LOSE settable modes (v1 of this op
|
||||||
|
/// dropped the arrival mode from the target list, breaking even a resize BACK to it); the update
|
||||||
|
/// therefore accumulates — new mode first, every previously-advertised mode kept (deduped by
|
||||||
|
/// resolution, capped at [`MODE_LIST_CAP`]) — and the real payoff is at the NEXT re-arrival,
|
||||||
|
/// where [`create_monitor`]'s same-id history union makes every previously-used mode settable.
|
||||||
|
///
|
||||||
|
/// The stored list is updated FIRST (under the lock) so any OS re-query through the mode DDIs
|
||||||
|
/// ([`modes_for_object`]/[`modes_for_id`]) sees the new list, and REVERTED if the DDI fails — the
|
||||||
|
/// OS then still holds the old list and the two stay coherent. The DDI itself is called OUTSIDE
|
||||||
|
/// the lock (it may re-enter the mode-query callbacks, which lock [`MONITOR_MODES`]).
|
||||||
|
pub fn update_monitor_modes(session_id: u64, width: u32, height: u32, refresh: u32) -> NTSTATUS {
|
||||||
|
// Swap the stored list (union — see above) + grab the live handle under the lock.
|
||||||
|
let (object, old_modes, new_modes) = {
|
||||||
|
let mut lock = lock_monitors();
|
||||||
|
let Some(m) = lock.iter_mut().find(|m| m.session_id == session_id) else {
|
||||||
|
return crate::STATUS_NOT_FOUND;
|
||||||
|
};
|
||||||
|
let Some(object) = m.object else {
|
||||||
|
return crate::STATUS_NOT_FOUND; // created but not yet arrived — nothing to update
|
||||||
|
};
|
||||||
|
let mut new_modes = vec![Mode {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
refresh_rates: vec![refresh],
|
||||||
|
}];
|
||||||
|
union_modes(&mut new_modes, &m.modes);
|
||||||
|
let old = core::mem::replace(&mut m.modes, new_modes.clone());
|
||||||
|
(object, old, new_modes)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The OS's target-mode list for this monitor (the `*2`/HDR shape, like `monitor_query_modes2`).
|
||||||
|
let mut targets: Vec<iddcx::IDDCX_TARGET_MODE2> = flatten(&new_modes)
|
||||||
|
.map(|item| target_mode2(item.width, item.height, item.refresh_rate))
|
||||||
|
.collect();
|
||||||
|
let mut in_args = pod_init!(iddcx::IDARG_IN_UPDATEMODES2);
|
||||||
|
in_args.Reason = iddcx::IDDCX_UPDATE_REASON::IDDCX_UPDATE_REASON_OTHER;
|
||||||
|
in_args.TargetModeCount = targets.len() as u32;
|
||||||
|
in_args.pTargetModes = targets.as_mut_ptr();
|
||||||
|
// SAFETY: `object` is a live IddCx monitor handle (arrived — checked above; a concurrent REMOVE
|
||||||
|
// is serialized by the host, which only ever resizes a monitor its own session holds a lease
|
||||||
|
// on). `in_args` points at valid local storage (`targets` outlives the synchronous DDI call).
|
||||||
|
let st = unsafe { wdk_iddcx::IddCxMonitorUpdateModes2(object, &in_args) };
|
||||||
|
dbglog!(
|
||||||
|
"[pf-vd] IddCxMonitorUpdateModes2(session={session_id}, {width}x{height}@{refresh}) -> {st:#x}"
|
||||||
|
);
|
||||||
|
if !wdk_iddcx::nt_success(st) {
|
||||||
|
// Keep the stored list coherent with what the OS actually holds (the old one).
|
||||||
|
let mut lock = lock_monitors();
|
||||||
|
if let Some(m) = lock.iter_mut().find(|m| m.session_id == session_id) {
|
||||||
|
m.modes = old_modes;
|
||||||
|
}
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
crate::STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
/// `IOCTL_REMOVE`: depart + drop the monitor for `session_id`. Returns true if one was removed.
|
/// `IOCTL_REMOVE`: depart + drop the monitor for `session_id`. Returns true if one was removed.
|
||||||
pub fn remove_monitor(session_id: u64) -> bool {
|
pub fn remove_monitor(session_id: u64) -> bool {
|
||||||
// Pull out the IddCx handle AND the swap-chain processor under the lock, but drop the processor
|
// Pull out the IddCx handle AND the swap-chain processor under the lock, but drop the processor
|
||||||
@@ -611,6 +724,9 @@ pub fn remove_monitor(session_id: u64) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let mut entry = lock.remove(pos);
|
let mut entry = lock.remove(pos);
|
||||||
|
// Keep the departing monitor's advertised list for its id — the next same-id create
|
||||||
|
// unions it back in (P2 mode history; see MODE_HISTORY).
|
||||||
|
remember_modes(entry.id, &entry.modes);
|
||||||
(entry.object, entry.swap_chain_processor.take())
|
(entry.object, entry.swap_chain_processor.take())
|
||||||
};
|
};
|
||||||
// Drop the worker FIRST (it joins + deletes the swap-chain), THEN depart the monitor.
|
// Drop the worker FIRST (it joins + deletes the swap-chain), THEN depart the monitor.
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ iddcx_ddi!(
|
|||||||
in_args: *const iddcx::IDARG_IN_ADAPTERSETRENDERADAPTER,
|
in_args: *const iddcx::IDARG_IN_ADAPTERSETRENDERADAPTER,
|
||||||
) @ IddCxAdapterSetRenderAdapterTableIndex as PFN_IDDCXADAPTERSETRENDERADAPTER -> ()
|
) @ IddCxAdapterSetRenderAdapterTableIndex as PFN_IDDCXADAPTERSETRENDERADAPTER -> ()
|
||||||
);
|
);
|
||||||
|
iddcx_ddi!(
|
||||||
|
/// Refresh a LIVE monitor's target-mode list (the HDR `*2` variant, IddCx 1.10 — the same API
|
||||||
|
/// family as the `*2` mode/buffer DDIs this driver already requires): the OS re-evaluates which
|
||||||
|
/// modes the target supports WITHOUT a monitor departure, so the host can then mode-set to a
|
||||||
|
/// freshly-advertised mode in place (the mid-stream resize, latency plan P2).
|
||||||
|
IddCxMonitorUpdateModes2(
|
||||||
|
monitor: iddcx::IDDCX_MONITOR,
|
||||||
|
in_args: *const iddcx::IDARG_IN_UPDATEMODES2,
|
||||||
|
) @ IddCxMonitorUpdateModes2TableIndex as PFN_IDDCXMONITORUPDATEMODES2
|
||||||
|
);
|
||||||
iddcx_ddi!(
|
iddcx_ddi!(
|
||||||
/// Bind a D3D device to an assigned swap-chain. HRESULT-shaped (0x887A0026 → retry on monitor flap).
|
/// Bind a D3D device to an assigned swap-chain. HRESULT-shaped (0x887A0026 → retry on monitor flap).
|
||||||
IddCxSwapChainSetDevice(
|
IddCxSwapChainSetDevice(
|
||||||
|
|||||||
Reference in New Issue
Block a user