Refactors LAST, after every defect fix, so no behaviour change hides in a move diff — and so the newly-exposed review surface was already clean when it landed (the WP7 discipline). 28f8fc71's recorded trap (an inline `use super::X` inside a moved body silently changing meaning) was audited first: every `use super::` in the moved code is either at module level — where `super` still means `linux` — or inside a `mod tests` that moved with its parent. 5.1 `mod pipewire` → `linux/pipewire.rs`. It was 1,900 of that file's lines. `mod.rs` is a directory module, so a plain `mod pipewire;` resolves with no `#[path]`. 5.2 out of that, `linux/pw_pods.rs` (the 7 param-pod builders + `serialize_pod` + the PQ constant and its test) and `linux/pw_cursor.rs` (`CursorState`, `decode_bitmap_pixel`, `update_cursor_meta`, `dst_offsets`, both `composite_cursor*`). The pods are the crate's WIRE surface — a missing property is not a compile error but a link that stalls in `negotiating`; the cursor half is producer-driven and bounds-critical. Both are now pure enough to unit-test without a compositor, which is what Phase 6 needs. 5.3 `linux/portal.rs`: the ScreenCast/RemoteDesktop handshake, the cursor-mode choice and GNOME's BT.2100 probe — the async/tokio/zbus control plane, separated from the realtime half. Zero re-exports changed: `mod.rs` re-exports `gnome_hdr_monitor_active` at its old path. 5.4 `idd_push/open.rs` (the whole one-time construction path + `SharedObjectSa` + `AttachTexFail`) and `idd_push/compose_kick.rs`. Read `open.rs` when a session will not START and the parent when one stops flowing; the steady state stays with the parent by decision. Also moved the ~65-line stall REPORTING block out of `try_consume` (the hot loop) into `StallWatch::report`, taking its two correlation counters with it — they were capturer fields nothing else touched. 5.5 `windows/dxgi/selftest.rs`: `p010_reference`, both self-tests, `hdr_p010_convert_bars_on_luid`, `f32_to_f16` and the two test modules — the validation path, none of which runs in a session. The two `pub` entry points are re-exported, so `main.rs`'s subcommand and pf-encode's live e2e keep their paths. (An explicit `#[path]`, like `idd_push`'s children: this file is itself reached through a `#[path]`, so a bare `mod` would resolve to `windows/selftest.rs`.) 5.6 `CaptureSignals`. The seven shared flags/slots were created in `spawn_pipewire`, `_cb`-cloned one by one, passed as seven of `pipewire_thread`'s parameters, and then re-declared as fields on `PwHandles`, `PortalCapturer` AND `UserData` — the same list written four times, each with its own drifting copy of the doc comment. One `#[derive(Clone)]` struct instead (a refcount bump per field), which also makes it structurally obvious that producer and consumer share ONE set. And `CaptureOpts` for the four trailing `bool`s: four adjacent same-typed positional arguments are a silent-transposition footgun — swap `want_444` and `want_hdr` and it compiles, negotiates the wrong pod family, and surfaces as a black screen ten seconds later. 5.7 trait shape, targeted: `set_active(&self)` → `&mut self` (it took `&self` only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking into the contract); `capture_target_id` ⇒ `resize_output`'s pairing rule stated on both methods and in a cluster note; one-line cluster headers over the 13 methods. NOT done from 5.7: taking the gamescope targets as an `open_*` option instead of the trait method. It would put a Linux-only parameter on the cross-platform `open_virtual_output` and on the host's `capture_virtual_output` facade — a `#[cfg]`-shaped wart in two shared signatures to delete one already-`cfg`'d defaulted method whose lifecycle rule 2.5c had already made harmless. Wrong trade under 5.7's own "no churn for no defect fixed" principle. File sizes: `linux/mod.rs` 2,778 → 770 (target ≤900 ✓), `windows/dxgi.rs` 1,374 → 954, and `windows/idd_push.rs` 2,694 → 1,922. The last two miss the plan's ≤850/≤1,300 targets, which were computed against the ORIGINAL line counts — Phases 1–4 added several hundred lines of SAFETY proofs and defect rationale to exactly these files before the split. The moves themselves are the ones the plan specifies; closing the rest would mean inventing new splits it does not call for. Zero behaviour delta. pf-capture 20/20; workspace clippy --all-targets clean on Linux and on windows-msvc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
8.7 KiB
Rust
163 lines
8.7 KiB
Rust
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms
|
||
//! holes in DWM frame delivery that open while the desktop was actively composing.
|
||
|
||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||
|
||
use super::*;
|
||
|
||
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
||
/// desktop was actively composing right beforehand (see [`StallWatch`]).
|
||
pub(super) struct Stall {
|
||
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
||
pub(super) gap: Duration,
|
||
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
||
/// [`pf_frame::metronome::Metronome`]).
|
||
pub(super) metronomic: Option<Duration>,
|
||
}
|
||
|
||
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
|
||
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
|
||
/// path BELOW capture and only while no physical output is active).
|
||
///
|
||
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
|
||
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
|
||
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
|
||
/// mouse twitch. Each stall feeds a [`pf_frame::metronome::Metronome`], so periodic stalls self-diagnose
|
||
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
|
||
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
|
||
/// below; the caller does the logging.
|
||
pub(super) struct StallWatch {
|
||
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||
recent: std::collections::VecDeque<Instant>,
|
||
cadence: pf_frame::metronome::Metronome,
|
||
/// Stalls seen this session, and how many had a coinciding OS display event — the discriminator
|
||
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
|
||
seen: u32,
|
||
with_os_events: u32,
|
||
}
|
||
|
||
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 —
|
||
/// 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
|
||
/// reported 300–700 ms freezes, above encode/present jitter.
|
||
const STALL_MIN: Duration = Duration::from_millis(150);
|
||
|
||
pub(super) fn new() -> Self {
|
||
Self {
|
||
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||
cadence: pf_frame::metronome::Metronome::new(),
|
||
seen: 0,
|
||
with_os_events: 0,
|
||
}
|
||
}
|
||
|
||
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
||
/// the reset the first post-recreate frame would read as one).
|
||
pub(super) fn reset(&mut self) {
|
||
self.recent.clear();
|
||
}
|
||
|
||
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
||
pub(super) fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
|
||
let was_active = self.recent.len() == Self::RECENT
|
||
&& self
|
||
.recent
|
||
.back()
|
||
.zip(self.recent.front())
|
||
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
|
||
let gap = self.recent.back().map(|last| now.duration_since(*last));
|
||
self.recent.push_back(now);
|
||
if self.recent.len() > Self::RECENT {
|
||
self.recent.pop_front();
|
||
}
|
||
let gap = gap?;
|
||
if !was_active || gap < Self::STALL_MIN {
|
||
return None;
|
||
}
|
||
Some(Stall {
|
||
gap,
|
||
metronomic: self.cadence.note(now),
|
||
})
|
||
}
|
||
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
|
||
/// metronomic — name the class of disturbance and its cures.
|
||
///
|
||
/// Lives here rather than in `try_consume` (sweep Phase 5.4): it is ~65 lines of log prose plus
|
||
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
|
||
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
|
||
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
|
||
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
|
||
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||
let window = stall.gap + Duration::from_millis(300);
|
||
let events = now
|
||
.checked_sub(window)
|
||
.map(|from| pf_win_display::display_events::events_between(from, now))
|
||
.unwrap_or_default();
|
||
self.seen = self.seen.saturating_add(1);
|
||
if !events.is_empty() {
|
||
self.with_os_events = self.with_os_events.saturating_add(1);
|
||
}
|
||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||
// at debug level, and the web-console debug ring captures these.
|
||
tracing::debug!(
|
||
gap_ms = stall.gap.as_millis() as u64,
|
||
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||
delivered no frame for the gap; the present path stalled below capture"
|
||
);
|
||
if let Some(period) = stall.metronomic {
|
||
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||
let suspects = if suspects.is_empty() {
|
||
"none".to_string()
|
||
} else {
|
||
suspects.join(", ")
|
||
};
|
||
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||
// driver. Different classes, different cures — say which one this box has.
|
||
if self.with_os_events * 2 >= self.seen {
|
||
tracing::warn!(
|
||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||
os_correlated = correlated,
|
||
connected_inactive = %suspects,
|
||
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||
hot-plug/re-enumeration events — a connected display (or its \
|
||
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||
each time. Cures, best-first: that display's OSD 'auto input \
|
||
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
|
||
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
|
||
keep it active while streaming; the pnp_disable_monitors policy axis \
|
||
suppresses the Windows-side reaction (see connected_inactive for the \
|
||
suspects)"
|
||
);
|
||
} else {
|
||
tracing::warn!(
|
||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||
os_correlated = correlated,
|
||
connected_inactive = %suspects,
|
||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||
clock (try a different refresh rate). If connected_inactive lists a \
|
||
display, its standby probing is the prime suspect: unplug it at the \
|
||
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||
streaming"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|