From b7cb75a48aa167b25a064fa96d723b8d0ece9dfe Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 10 Jul 2026 02:06:08 +0200 Subject: [PATCH] =?UTF-8?q?fix(host):=20dual=20identical=20GPUs=20stream?= =?UTF-8?q?=20again=20=E2=80=94=20self-heal=20the=20IDD-push=20ring=20onto?= =?UTF-8?q?=20the=20driver's=20real=20render=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A box with two identical GPUs (twin RTX 4090s in the field report) died out of pipeline retries with driver_status=2 (DRV_STATUS_TEX_FAIL, detail 0x80070057): SET_RENDER_ADAPTER pinned the driver to one twin at monitor ADD, but the ring open re-ran the GPU picker, whose max-VRAM tie is settled by DXGI enumeration order — which follows the primary display that the vdisplay flow itself moves mid-session. Ring on one twin, swap-chain on the other → the driver can't open the shared textures, and every retry repeats the same wrong pick. - wait_for_attach: TEX_FAIL becomes a typed AttachTexFail carrying the render LUID the driver reports in the shared frame header (written before the texture opens, so valid on failure) — the error now names both adapters instead of guessing "mismatch?". - open_inner: on AttachTexFail with a different, non-zero driver LUID, rebind the ring there and reopen ONCE. Both candidates are real GPU adapters, so NVENC keeps getting a device it accepts. - gpu::enumerate (Windows): sort the inventory by LUID so the max-VRAM tie, the env-substring first match, and twin occurrence numbering are stable for the boot instead of tracking the primary display. - manager: record the ADD-time render pin on Monitor and warn on reuse when the current pick has moved away from it (the pick only takes effect on the next monitor create; /display/release forces one). - pf_vdisplay: drop the "ADD render adapter DIFFERS from pinned" warn — the ADD reply carries IDARG_OUT_MONITORARRIVAL.OsAdapterLuid (the IddCx DISPLAY adapter, verified on-glass), not the render GPU, so the check compared unrelated LUIDs and fired on every ADD. Verified on the RTX 4090 box: single-GPU streaming is unchanged (IddPush + NVENC AV1 session, no new warnings). The rebind path needs a twin-GPU box — until validated there, /display/release remains the manual recovery. Co-Authored-By: Claude Fable 5 --- .../src/capture/windows/idd_push.rs | 110 +++++++++++++++--- crates/punktfunk-host/src/gpu.rs | 10 +- .../src/vdisplay/windows/manager.rs | 51 +++++++- .../src/vdisplay/windows/pf_vdisplay.rs | 17 +-- 4 files changed, 158 insertions(+), 30 deletions(-) diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index d3c08d01..dd7ea675 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -760,6 +760,56 @@ impl IddPushCapturer { target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>, client_10bit: bool, + ) -> Result { + // The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the + // selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor + // ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter. + // (`target.adapter_luid` is NOT that adapter: the ADD reply carries + // `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified + // on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and + // the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and + // this open, or a stale kept monitor across an adapter re-init — the driver reports + // TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that. + let luid = crate::win_adapter::resolve_render_adapter_luid().unwrap_or(LUID { + LowPart: (target.adapter_luid & 0xffff_ffff) as u32, + HighPart: (target.adapter_luid >> 32) as i32, + }); + match Self::open_on(target.clone(), preferred, client_10bit, luid) { + Ok(me) => Ok(me), + Err(e) => { + // Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the + // adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter + // re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that + // adapter beats failing the session — the outer pipeline retries would repeat the + // exact same mismatch. + let driver_luid = e + .downcast_ref::() + .map(|tf| tf.driver_luid) + .filter(|d| *d != 0 && *d != crate::capture::dxgi::pack_luid(luid)); + let Some(packed) = driver_luid else { + return Err(e); + }; + let drv = LUID { + LowPart: (packed & 0xffff_ffff) as u32, + HighPart: (packed >> 32) as i32, + }; + tracing::warn!( + ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), + driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart), + "IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \ + driver's reported adapter" + ); + Self::open_on(target, preferred, client_10bit, drv) + .context("IDD-push rebind to the driver's reported render adapter") + } + } + } + + fn open_on( + target: WinCaptureTarget, + preferred: Option<(u32, u32, u32)>, + client_10bit: bool, + luid: LUID, ) -> Result { let (pw, ph, _hz) = preferred .context("IDD push needs the negotiated mode (WxH) to size the shared ring")?; @@ -829,10 +879,10 @@ impl IddPushCapturer { } else { DXGI_FORMAT_B8G8R8A8_UNORM }; - // Create our device on the discrete render GPU (where NVENC runs); the driver must render - // the swap-chain on the SAME adapter for the shared textures to open (it reports its actual - // render LUID into the header so we can detect a mismatch). - let luid = resolve_render_adapter_luid_or(target.adapter_luid); + // Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per + // `open_inner`; the driver must render the swap-chain on the SAME adapter for the + // shared textures to open (it reports its actual render LUID into the header, which + // `open_inner` uses to rebind once if this mismatches). let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?; let adapter: IDXGIAdapter1 = factory .EnumAdapterByLuid(luid) @@ -991,13 +1041,31 @@ impl IddPushCapturer { // diagnostics — the real handshake is the atomic `magic`/`latest` (same access as // log_driver_status_once). let st = unsafe { (*self.header).driver_status }; - if matches!(st, DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1) { + if st == DRV_STATUS_TEX_FAIL { + // SAFETY: as above — in-bounds, aligned word reads of best-effort diagnostic fields + // through the owned, live header mapping; no reference into the shared region is formed. + // The driver wrote its render LUID BEFORE attempting the texture opens + // (frame_transport.rs step 2), so it is valid here. + let (detail, lo, hi) = unsafe { + ( + (*self.header).driver_status_detail, + (*self.header).driver_render_luid_low, + (*self.header).driver_render_luid_high, + ) + }; + // Typed so `open_inner` can rebind the ring to the driver's adapter once. + return Err(anyhow::Error::new(AttachTexFail { + detail, + driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff), + })); + } + if st == DRV_STATUS_NO_DEVICE1 { // SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field // through the owned, live header mapping; no reference into the shared region is formed. let detail = unsafe { (*self.header).driver_status_detail }; bail!( "IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \ - render-adapter mismatch?)" + the driver has no ID3D11Device1 to open shared resources)" ); } // Attached AND a frame has been published — the publish token's seq advances past 0. @@ -1415,17 +1483,31 @@ impl IddPushCapturer { } } -/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`. -fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID { - if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() { - return l; - } - LUID { - LowPart: (fallback_packed & 0xffff_ffff) as u32, - HighPart: (fallback_packed >> 32) as i32, +/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring +/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain +/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once. +#[derive(Debug)] +struct AttachTexFail { + detail: u32, + driver_luid: i64, +} + +impl std::fmt::Display for AttachTexFail { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \ + detail=0x{:08x}): it could not open the ring textures — its swap-chain renders on \ + adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)", + self.detail, + (self.driver_luid >> 32) as i32, + (self.driver_luid & 0xffff_ffff) as u32, + ) } } +impl std::error::Error for AttachTexFail {} + impl Capturer for IddPushCapturer { fn next_frame(&mut self) -> Result { let deadline = Instant::now() + Duration::from_secs(20); diff --git a/crates/punktfunk-host/src/gpu.rs b/crates/punktfunk-host/src/gpu.rs index b8aca52f..f5f2ae3e 100644 --- a/crates/punktfunk-host/src/gpu.rs +++ b/crates/punktfunk-host/src/gpu.rs @@ -91,7 +91,8 @@ impl GpuInfo { } /// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among -/// same-(vendor,device) twins, in enumeration order). +/// same-(vendor,device) twins, in inventory order — Windows sorts the inventory by LUID first so +/// twin numbering is stable for the boot, see [`enumerate`]). fn assign_ids(gpus: &mut [GpuInfo]) { for i in 0..gpus.len() { let occ = gpus[..i] @@ -268,6 +269,13 @@ pub(crate) fn enumerate() -> Vec { }); } } + // Deterministic inventory order. DXGI enumerates the adapter owning the primary display first, + // and the vdisplay flow itself MOVES the primary mid-session (exclusive mode turns the physical + // screens off) — which flipped every order-relative selection between IDENTICAL twin GPUs from + // one call to the next (the max-VRAM tie, the env-substring first match, occurrence numbering): + // monitor ADD pinned the driver to one twin, the ring open picked the other → TEX_FAIL. LUIDs + // are creation-ordered and stable for the boot, so sorting by them makes selection stable too. + out.sort_by_key(|g| ((g.handle.luid_high as i64) << 32) | g.handle.luid_low as i64); assign_ids(&mut out); out } diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 462c9ee8..46008f5a 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -71,7 +71,9 @@ pub(crate) trait VdisplayDriver: Send + Sync { unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>; /// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and /// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE - /// key + target id + the adapter LUID the driver actually used. + /// key + target id + the IddCx DISPLAY adapter LUID from the ADD reply + /// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the driver reports its render + /// adapter only in the shared frame header). /// /// # Safety /// `dev` must be the live control handle from [`open`](Self::open). @@ -100,7 +102,14 @@ pub(crate) trait VdisplayDriver: Send + Sync { struct Monitor { key: MonitorKey, target_id: u32, + /// IddCx DISPLAY adapter LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`) — + /// NOT the render GPU the driver renders on (the driver reports that one in the shared frame + /// header only). Do not compare it against render-GPU picks. luid: LUID, + /// The render-GPU pin handed to SET_RENDER_ADAPTER at this monitor's ADD (`None` = no GPU was + /// selectable). The pin is never re-issued on reuse, so this is what the driver still renders + /// on — [`warn_if_pick_moved`] compares the CURRENT pick against it. + render_pin: Option, /// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the /// IDD-push capturer knows where to duplicate the sealed frame channel's handles. wudf_pid: u32, @@ -475,6 +484,7 @@ impl VirtualDisplayManager { backend = self.driver.name(), "virtual monitor reused (concurrent / reconfigure session)" ); + warn_if_pick_moved(mon); return Ok(self.output_for(mon, quit)); } @@ -487,6 +497,7 @@ impl VirtualDisplayManager { backend = self.driver.name(), "virtual monitor reused (reconnect to a kept monitor)" ); + warn_if_pick_moved(&mon); if mon.mode != mode { // SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live // display topology. `mon` is the local monitor just moved out of the `Lingering` @@ -562,13 +573,14 @@ impl VirtualDisplayManager { crate::vdisplay::policy::Identity::PerClient, ) .unwrap_or(0); + let render_pin = resolve_render_pin(); // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. - // `resolve_render_pin()` returns an `Option` by value (plain `Copy`), so no borrowed - // memory crosses the call. + // `render_pin` is an `Option` by value (plain `Copy`), so no borrowed memory + // crosses the call. let added = unsafe { self.driver - .add_monitor(dev, mode, resolve_render_pin(), preferred_id)? + .add_monitor(dev, mode, render_pin, preferred_id)? }; // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. @@ -724,6 +736,7 @@ impl VirtualDisplayManager { key: added.key, target_id: added.target_id, luid: added.luid, + render_pin, wudf_pid: added.wudf_pid, gdi_name, mode, @@ -1033,6 +1046,36 @@ fn resolve_render_pin() -> Option { crate::win_adapter::resolve_render_adapter_luid() } +/// A reused monitor keeps the render GPU the driver was pinned to at its ADD — the pin is never +/// re-issued on reuse. When the current pick has moved since then (an operator preference change, +/// or the max-VRAM tie shifting on identical twin GPUs), say so: the session self-heals (the +/// IDD-push open rebinds its ring to the driver's actual adapter on a mismatch), but the new pick +/// only takes effect on the next monitor CREATE, which the mgmt `/display/release` forces. +/// Compares the pick against the ADD-time PIN — `mon.luid` is the IddCx display adapter and must +/// not be compared with render-GPU picks. +fn warn_if_pick_moved(mon: &Monitor) { + let Some(pin) = mon.render_pin else { return }; + let Some(sel) = crate::gpu::selected_gpu() else { + return; + }; + let pick = sel.info.luid(); + if (pick.LowPart, pick.HighPart) != (pin.LowPart, pin.HighPart) { + tracing::warn!( + pinned_adapter = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart), + current_pick = format!( + "{:08x}:{:08x} ({}, {})", + pick.HighPart, + pick.LowPart, + sel.info.name, + sel.source.tag() + ), + "reused virtual monitor is pinned to a different render GPU than the current pick — \ + the session follows the pinned GPU; free the display (mgmt /display/release) to \ + recreate it on the picked one" + ); + } +} + /// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal: /// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade /// maps it into the wire shape. diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index 0561ac92..7bde49b6 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -539,17 +539,12 @@ impl VdisplayDriver for PfVdisplayDriver { ); } } - if let Some(pin) = render_luid { - if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart { - tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)"); - } else { - tracing::warn!( - add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), - pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart), - "pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?" - ); - } - } + // NOTE: `reply.adapter_luid` is the IddCx DISPLAY adapter + // (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`), NOT the render GPU, so it can NOT validate + // SET_RENDER_ADAPTER — a comparison against the pin here fired "DIFFERS from pinned" on + // every ADD (verified on-glass: reply 0x22c05 vs pin 0x15b05 on a single-4090 box). The + // driver reports its ACTUAL render adapter in the shared frame header; the IDD-push + // capturer checks it there and rebinds on a mismatch. Ok(AddedMonitor { key: MonitorKey::Session(session_id), target_id: reply.target_id,