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:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user