fix(host): dual identical GPUs stream again — self-heal the IDD-push ring onto the driver's real render adapter
ci / web (push) Successful in 50s
apple / swift (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 5m30s
docker / deploy-docs (push) Successful in 20s
apple / screenshots (push) Successful in 5m35s
windows-host / package (push) Successful in 7m57s
deb / build-publish (push) Successful in 12m0s
arch / build-publish (push) Successful in 14m51s
android / android (push) Successful in 16m21s
ci / rust (push) Successful in 17m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 02:06:08 +02:00
parent 08694b4026
commit b7cb75a48a
4 changed files with 158 additions and 30 deletions
@@ -760,6 +760,56 @@ impl IddPushCapturer {
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
) -> Result<Self> {
// 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::<AttachTexFail>()
.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<Self> {
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<CapturedFrame> {
let deadline = Instant::now() + Duration::from_secs(20);