fix(pf-capture): a sweep found nine real defects behind comments that asserted the opposite

Reviewed the whole crate (15.6 kloc) for bugs, safety, structure and comment truth.
Both compile gates are green: `scripts/xcheck.sh windows clippy` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` in the amd64 CI
image (the Linux half needs libpipewire, so it cannot ride xcheck).

Code defects, each one contradicted by a comment sitting next to it:

* `pipeline_depth` clamped to `OUT_RING` (3) while both `repeat_last` and `OUT_RING`
  state the safe maximum is 2. `d` frames in flight need `d + 1` textures, so
  `PUNKTFUNK_IDD_DEPTH=3` rotated onto the slot NVENC was still reading and the convert
  overwrote it in place — torn frames, silently. Now `OUT_RING - 1`.
* The GDI cursor poller published `visible: true` for a NULL `hCursor` carrying
  `CURSOR_SHOWING` — how an app hides the pointer for its own window. The last
  rasterised arrow was then blended into a game that had hidden its cursor. Every
  rasterise gate already tested `handle != 0`; the published verdict now agrees.
* The ETW event callback did `RING.lock().unwrap()`. That is an `extern "system"` fn, so
  a poisoned lock panicked across an FFI boundary and ABORTED the host — a diagnostic
  taking down capture. Poison-tolerant now, which also makes the poison unreachable.
* `ChannelBroker::send` bounded the ring with `debug_assert`, so a release build instead
  panicked mid-`duplicate_and_deliver`, unwinding past the reap and leaking every handle
  already planted in the driver's WUDFHost. Refuses before the first duplication.
* `set_active(false)` did not clear `stall_since`, so a pooled capturer carried a stale
  stall clock into its next stream and reported capture loss microseconds in.
* `attach_gamescope_cursor` evaluated `spawn` before dropping the old source: two readers
  published into one slot, and a failed spawn destroyed a working reader. Idempotent now.
* `PUNKTFUNK_FORCE_SHM` used a bare `== "1"` compare, silently ignoring `=true`/`=on`.
* `spa_meta_bitmap.offset == 0` is SPA's "no image data" signal, distinct from the
  `bitmap_offset == 0` position-only case. Unhandled, it decoded the header's own words
  as cursor pixels and cached them.
* A `VideoInfoRaw::parse` failure was swallowed, so a malformed Format pod surfaced as
  the generic "no acceptable format" timeout. It is logged, and parsed once, not twice.

Comment corrections, all verified against the code they describe: four claims that a
failed open falls back to DDA (removed — the caller drops the keepalive under
"no fallback"); three comparisons to the removed WGC path; "we do NOT gate HDR on the
client's VIDEO_CAP_10BIT" (it does, in three places); the P010 sampler's "4 explicit
taps / 2x2 box" (two taps, left-cosited — the box was the bug it replaced); the cursor
meta cap quoted as 256x256 (1024, and 256 is the value that cost the whole Linux cursor
channel on-glass); the poller's "~60 Hz" (4 ms, ~250 Hz); "several minutes of coverage"
(~26 s); "8 frames in 400 ms >= 20 fps" (7 intervals, so 17.5); three "process-wide" HDR
latch claims (per-source, which is why HdrSource exists); a SAFETY proof claiming a view
is "unmapped never" (Drop unmaps it); the Linux module header describing a bounded
channel and BGRx-only frames (one-deep overwriting slot, several formats); and a doc
line stranded on `DisplayDescriptor` by an earlier split, restored to `IddPushCapturer`,
which had none.
This commit is contained in:
2026-08-11 10:04:58 +02:00
parent d715225960
commit 8b98d0b3ec
14 changed files with 130 additions and 41 deletions
+34 -10
View File
@@ -1,4 +1,4 @@
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`), CPU-copy path.
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`).
//!
//! Two dedicated threads, because both stacks are tied to their thread:
//! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime
@@ -7,9 +7,13 @@
//! drops; ashpd's `Session` has no `Drop`);
//! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames.
//!
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; decoded BGRx
//! frames leave the pipewire thread over a bounded channel. The authoritative frame size
//! comes from the negotiated PipeWire format, not the portal's size hint.
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; frames leave that
//! thread through a ONE-DEEP OVERWRITING slot (`FrameSlot`) plus a wakeup edge — not the bounded
//! `sync_channel(8)` this once used, which was drop-NEWEST and so handed a stalled consumer stale
//! frames (see `FrameSlot`'s own note). The payload is not necessarily BGRx either: the negotiation
//! can settle on packed RGB, NV12, YUV444 or 10-bit PQ, and on a dmabuf passthrough it never touches
//! the CPU. The authoritative frame size comes from the negotiated PipeWire format, not the portal's
//! size hint.
//!
//! Cleanup: BOTH threads are stopped deterministically — [`PortalCapturer`]'s `Drop` sends a
//! pipewire `channel` quit and joins that thread (releasing its EGL importer / CUDA context
@@ -18,7 +22,9 @@
//! connection and so ENDS the compositor's ScreenCast session. Dropping a capturer (session end,
//! or a retried/failed pipeline build) therefore leaves nothing behind on either side.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). This file itself has none — the FFI lives in the child modules declared at the bottom
// (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
@@ -173,8 +179,9 @@ pub struct PortalCapturer {
/// capture, not per frame.
negotiation_confirmed: bool,
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the SDR
/// downgrade for THIS [`Self::hdr_source`] only, not process-wide) and by
/// [`hdr_meta`](Capturer::hdr_meta).
hdr_offer: bool,
/// Which HDR source this capturer is — the latch a failed [`hdr_offer`](Self::hdr_offer)
/// belongs to. See [`super::HdrSource`] for why the latch is not one process-wide flag.
@@ -463,7 +470,10 @@ fn spawn_pipewire(
let zerocopy = allow_zerocopy && pf_zerocopy::enabled();
// HDR cannot ride the SHM path (see `want_hdr` above): under PUNKTFUNK_FORCE_SHM the HDR
// offer is dropped — SDR capture, loudly.
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
// The shared parser, not a bare `== "1"` compare — matching `PUNKTFUNK_PIPEWIRE_NV12` below.
// A bare compare silently ignored `PUNKTFUNK_FORCE_SHM=true`/`=on`/`=yes`, so the knob looked
// set and did nothing.
let force_shm = pf_host_config::env_on("PUNKTFUNK_FORCE_SHM").unwrap_or(false);
let want_hdr = if want_hdr && force_shm {
tracing::warn!(
"HDR capture requested but PUNKTFUNK_FORCE_SHM=1 — the SHM path is 8-bit only; \
@@ -555,6 +565,14 @@ impl Capturer for PortalCapturer {
// every nested Xwayland the provider reports, RE-RUNS the provider so a game's Xwayland
// that appears later is adopted, and follows whichever one gamescope draws the pointer on.
// `frame_size` lets it map root-space coordinates into frame space.
//
// Idempotent by construction. The contract says "called once", but nothing enforced it, and a
// second call evaluated `spawn` BEFORE dropping the old source: two readers then published
// into the same slot for the construction window, and a `spawn` that returned `None` destroyed
// a perfectly good reader outright.
if self._gs_cursor.is_some() {
return;
}
self._gs_cursor = xfixes_cursor::XFixesCursorSource::spawn(
targets,
Arc::clone(&self.signals.cursor_live),
@@ -655,6 +673,11 @@ impl Capturer for PortalCapturer {
if let Ok(mut slot) = self.slot.lock() {
*slot = None;
}
// Clear the stall clock for the same reason the mailbox is flushed: a pooled capturer
// whose previous stream ended mid-stall carried that `Instant` into the next one, so the
// first `try_latest` that saw `!streaming` found the 1500 ms grace already expired and
// reported capture loss on a stream that had been running for microseconds.
self.stall_since = None;
}
}
@@ -776,8 +799,9 @@ impl PortalCapturer {
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
// mode between the probe and the negotiation, the compositor pre-dates the
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the process-wide SDR downgrade so the next session (Moonlight
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
// Latch the SDR downgrade for THIS source (`HdrSource`, not process-wide — one
// shared flag let either Linux HDR source disable the other) so the next session
// (Moonlight auto-reconnects) negotiates SDR instead of re-running this timeout.
super::note_hdr_capture_failed(self.hdr_source);
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
+14 -1
View File
@@ -1506,7 +1506,20 @@ pub fn pipewire_thread(
{
return;
}
if ud.info.parse(param).is_ok() {
// Parse ONCE — `parse` takes `&mut self` — and report a failure instead of swallowing it.
// On `Err`, `negotiated` stays false and `format`/`modifier`/`frame_size` keep their
// previous values, so the capture dies on the generic "the compositor offered no format
// this consumer accepts" timeout — sending the operator hunting a format mismatch when
// the real fault was a malformed Format pod we DID accept.
let parsed = ud.info.parse(param);
if let Err(e) = &parsed {
tracing::error!(
error = %e,
"pipewire: failed to parse the negotiated Format pod — capture will time out \
with no usable format"
);
}
if parsed.is_ok() {
ud.signals.negotiated.store(true, Ordering::Relaxed);
// A (re)negotiation replaces the buffer pool: every cached per-buffer import
// (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on
+11 -1
View File
@@ -197,6 +197,15 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
return;
}
// SPA's second "no image data" signal, distinct from the `bitmap_offset == 0` position-only
// case above: `spa_meta_bitmap.offset` is the offset of the PIXELS within the bitmap struct,
// and 0 means there are none. Without this, `pix_off == 0` made the pixel extent start at the
// `spa_meta_bitmap` header itself, so a producer signalling an invisible pointer got its own
// header words (format/size/stride/offset) decoded and cached as the cursor bitmap. In bounds,
// so not unsound — just garbage pixels blitted into every later frame.
if pix_off == 0 {
return;
}
let row = bw as usize * 4;
let stride = if stride < row { row } else { stride };
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
@@ -327,7 +336,8 @@ pub(super) fn composite_cursor_rgb10(
}
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
/// position. Cheap: a straight-alpha blit over at most 1024×1024 pixels (the accepted cap; real
/// cursors are ≤96 px), clipped to the frame —
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
pub(super) fn composite_cursor(
tight: &mut [u8],
+2 -1
View File
@@ -377,7 +377,8 @@ pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
/// attach it (harmless). Size is a range up to a 1024×1024 bitmap — see the note on `max` below for
/// why this is not the "bigger than any real cursor" 256² it used to be.
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
fn meta_size(w: u32, h: u32) -> i32 {
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
+5 -3
View File
@@ -554,9 +554,11 @@ impl HdrP010Converter {
let mut ps_uv = None;
device.CreatePixelShader(&uvb, None, Some(&mut ps_uv))?;
let sd = D3D11_SAMPLER_DESC {
// POINT: the Y pass samples a single texel centre exactly, and the UV pass does its OWN
// 2x2 box average via 4 explicit taps at texel centres (offset half a texel). Point
// sampling keeps each tap exact; the averaging is in the shader, not the sampler.
// POINT: the Y pass samples a single texel centre exactly, and the UV pass takes its OWN
// two explicit taps on the 2x2 block's LEFT column (left-cositing) and averages them.
// Point sampling keeps each tap exact; the averaging is in the shader, not the sampler.
// (It was a 4-tap CENTER-sited 2x2 box until that was found to shift chroma by half a
// luma pixel — see `HDR_P010_UV_PS`.)
Filter: D3D11_FILTER_MIN_MAG_MIP_POINT,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
+17 -9
View File
@@ -337,6 +337,7 @@ use channel::ChannelBroker;
use descriptor::{DescriptorPoller, DisplayDescriptor};
use stall::{StallEvidence, StallWatch};
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
pub struct IddPushCapturer {
device: ID3D11Device,
context: ID3D11DeviceContext,
@@ -652,14 +653,18 @@ impl IddPushCapturer {
}
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven by the DISPLAY's HDR
/// state (like the WGC path) plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// state plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// 10-bit limited) → NVENC Main10, and the client auto-detects PQ from the HEVC VUI; SDR →
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
/// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
/// auto-switch, exactly as on the WGC path. HDR and 4:4:4 now COMPOSE: an HDR display that
/// SM-side CSC, because the video processor can only produce subsampled output). The
/// composition depth DOES follow the session's negotiated `client_10bit` — pinned at open
/// (`open.rs`, the `!client_10bit` force-off and the 10-bit enable) and re-pinned every sample
/// by [`Self::poll_display_hdr`], because a PQ stream sent to a client that advertised SDR-only
/// lands on an SDR desktop and blows out. (The older note here claimed the opposite — that the
/// advertised `VIDEO_CAP_10BIT` was ignored because clients under-report it. That reasoning
/// survives only in the CODEC choice: an HDR-negotiated H.26x session still follows a host
/// "Use HDR" flip in either direction.) HDR and 4:4:4 now COMPOSE: an HDR display that
/// negotiated full chroma emits packed 10-bit BT.2020 PQ RGB (`Rgb10a2`) for NVENC to CSC to
/// YUV 4:4:4 — HEVC Main 4:4:4 10. (Before, HDR won and the stream silently downgraded to
/// 4:2:0 *after* the Welcome had already promised 4:4:4.)
@@ -969,7 +974,7 @@ impl IddPushCapturer {
},
Usage: D3D11_USAGE_DEFAULT,
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
// NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own
// NVENC registers it as encode input. (PyroWave uses its own
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0,
@@ -1970,9 +1975,12 @@ impl Capturer for IddPushCapturer {
fn pipeline_depth(&self) -> usize {
// 2 = one frame deferred: submit N+1 (capture + convert/copy into a fresh out-ring texture) while
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
// always has its own texture).
pf_host_config::config().idd_depth.clamp(1, OUT_RING)
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining). The ceiling is `OUT_RING - 1`, NOT
// `OUT_RING`: `d` frames in flight need `d + 1` textures, because the rotation has to hand out a
// slot that is not one of the `d` still being encoded. Clamping to `OUT_RING` admitted depth 3 on
// a 3-slot ring, where `repeat_last`'s rotation lands back on the slot NVENC is reading and the
// convert overwrites it in place — torn frames, silently, with no error anywhere.
pf_host_config::config().idd_depth.clamp(1, OUT_RING - 1)
}
fn capture_target_id(&self) -> Option<u32> {
@@ -160,7 +160,18 @@ impl ChannelBroker {
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
// An ERROR, not a `debug_assert`: in a release build the assert is compiled out and the
// over-long slice instead panics on `req.texture_handles[k]` in the middle of
// `duplicate_and_deliver` — after handles have already been planted in WUDFHost. That panic
// unwinds straight past the reap below, leaking every duplicate made so far into the driver
// process. Refuse before the first duplication, while there is nothing to reap.
if slots.len() > control::RING_LEN_USIZE {
anyhow::bail!(
"frame channel: {} ring slots exceeds the wire limit of {}",
slots.len(),
control::RING_LEN_USIZE
);
}
let mut req = control::SetFrameChannelRequest {
target_id,
generation,
@@ -42,7 +42,9 @@ impl CursorShared {
/// the section itself (owned by `self`); the caller duplicates it into the WUDFHost.
pub(super) fn create(target_id: u32) -> Result<CursorShared> {
// SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is
// mapped once and unmapped never (the capturer's life = the session's life).
// mapped once here and unmapped exactly once by `MappedSection::drop` (which unmaps before
// closing the mapping handle). No borrow into the view outlives the `MappedSection`: every
// access goes through `&self` accessors on the owner.
let section = unsafe {
let map = CreateFileMappingW(
INVALID_HANDLE_VALUE,
@@ -55,8 +55,10 @@ struct Shape {
serial: u64,
}
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` at ~60 Hz, rasterises the `HCURSOR` only
/// when its handle value changes, and publishes a ready [`pf_frame::CursorOverlay`] snapshot; the
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` every [`Self::INTERVAL`] (4 ms, ~250 Hz —
/// see that constant for why 16 ms was the bug), rasterises the `HCURSOR` when its handle value
/// changes and when [`Self::EXTENT_PROBE`] catches a resize under a STABLE handle, and publishes a
/// ready [`pf_frame::CursorOverlay`] snapshot; the
/// capture thread's per-tick cost is one uncontended mutex read + an `Arc` clone
/// (same split as [`DescriptorPoller`], and for the same reason: user32/gdi32 calls have no place
/// on the capture/encode thread).
@@ -186,7 +188,6 @@ fn run(
// against, and this poller outlives all of them. `None` keeps the last good value — a
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
// report every position invisible.
//
let fresh = pf_win_display::win_display::source_desktop_rect(target_id);
if let Some(fresh) = fresh {
if fresh != rect {
@@ -302,7 +303,14 @@ fn run(
serial: s.serial,
hot_x: s.hot_x,
hot_y: s.hot_y,
visible: showing && in_rect,
// `handle != 0` is part of "visible", not just of "worth rasterising": `SetCursor(NULL)`
// — how a game or a video player hides the pointer for its own window — leaves
// `CURSOR_SHOWING` set with a NULL `hCursor`. Judging on the flags alone published
// `visible: true` carrying the last shape we rasterised, so the composite path blended a
// ghost arrow into a game that had hidden its cursor, and the forward path told the
// client to draw one too. Every rasterise gate below already tests this; the published
// verdict has to agree with them.
visible: showing && in_rect && handle != 0,
}
});
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
@@ -6,7 +6,6 @@
use super::*;
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
/// virtual target.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -142,7 +142,12 @@ unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
(*record).EventHeader.ProcessId,
)
};
let mut ring = RING.lock().unwrap();
// Poison-tolerant, and that is load-bearing rather than tidy: this is an `extern "system"`
// callback invoked from an OS thread, so a panic here unwinds across an FFI boundary and
// ABORTS the host process. `unwrap()` made a single poisoned lock turn every subsequent event
// delivery into a hard abort — a diagnostic taking down capture. Nothing else under this lock
// can panic, so recovering the guard also makes the poison unreachable in the first place.
let mut ring = RING.lock().unwrap_or_else(|e| e.into_inner());
if ring.len() == RING_CAP {
ring.pop_front();
}
@@ -152,8 +152,11 @@ impl IddPushCapturer {
}
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller decides the display's fate
/// itself — retire it, or reuse the monitor for a retry — instead of this function tearing it down
/// (audit §5.1 — no more 20 s black bail). There is no second capture path to fall back TO: DDA was
/// removed (see `lib.rs`), and `punktfunk-host`'s caller drops the returned keepalive under
/// `.context("IDD-push capture open (no fallback)")`. "Failure" includes the
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
#[allow(clippy::too_many_arguments)]
pub fn open(
@@ -666,7 +669,7 @@ impl IddPushCapturer {
// wait for the first compose) until the capturer drops with the session.
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
// it back to the caller for the DDA fallback (audit §5.1).
// it back to the caller to retire or reuse the display (audit §5.1).
_keepalive: Box::new(()),
};
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
@@ -675,15 +678,15 @@ impl IddPushCapturer {
me.refresh_sdr_white_scale();
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
// instead of next_frame's 20 s black-then-bail.
// format/size the ring can't match) becomes an open failure the caller handles by retiring the
// display, instead of next_frame's 20 s black-then-bail.
me.wait_for_attach()?;
Ok(me)
}
}
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
/// a first frame**, else fail so the caller can retire the display and rebuild (audit §5.1 +
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
///
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
@@ -53,7 +53,9 @@ use super::stall::ProbeWindow;
/// One probe's sample ring: `(completed_at, span, value_us)` — `value` is the measurement (a call
/// latency or a frozen-span/overshoot), `span` the wall interval it describes ending at
/// `completed_at`. Capped; ~20 Hz per probe → several minutes of coverage.
/// `completed_at`. Capped at 512 samples: at the fastest producer's ~20 Hz that is ~26 s of
/// coverage, ~51 s for the 100 ms loops — comfortably longer than the seconds-old windows a stall
/// report asks for, but NOT the "several minutes" this used to claim.
struct Ring {
samples: Mutex<VecDeque<(Instant, Duration, u64)>>,
}
@@ -317,7 +317,8 @@ impl StallWatch {
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
const RECENT: usize = 8;
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
/// The RECENT pre-gap frames must all fit in this span (8 frames spanning 400 ms is 7 intervals,
/// so the real bar is ≈ ≥ 17.5 fps flow —
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the