audit / bun-audit (push) Successful in 14s
ci / web (push) Successful in 52s
windows-drivers / probe-and-proto (push) Successful in 44s
ci / docs-site (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 2m18s
windows-drivers / driver-build (push) Successful in 2m21s
apple / swift (push) Successful in 4m54s
ci / bench (push) Successful in 6m41s
ci / rust-arm64 (push) Successful in 10m4s
decky / build-publish (push) Successful in 42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
deb / build-publish-host (push) Successful in 10m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
ci / rust (push) Failing after 13m9s
deb / build-publish (push) Successful in 12m14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
android / android (push) Successful in 16m5s
arch / build-publish (push) Successful in 17m22s
deb / build-publish-client-arm64 (push) Successful in 9m46s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m42s
release / apple (push) Successful in 27m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m34s
docker / deploy-docs (push) Successful in 54s
docker / build-push-arm64cross (push) Successful in 9m53s
apple / screenshots (push) Successful in 23m52s
Field A/B (reporter, Legion 5 Pro hybrid, 2026-07-27): the ~2 s capture-stall metronome IS the exclusive isolate's own doing — deactivating the laptop panel leaves a dark-but-connected eDP head whose driver-level servicing disturbs the capture path (~1.7-2.8 s period); `topology: primary` (panel stays active) stopped it outright (16.3 stalls/min → 0). Our own .221 logged the identical signature the day before. The detector then steered AWAY from the cause: connected_inactive filtered to EXTERNAL physicals only, so the one display that mattered — on a hybrid laptop, the default config — reported as `none`. - TargetInventory: new `internal_panel` class bit (eDP/LVDS/embedded). - connected_inactive_externals → connected_inactive_physicals: internal panels join the suspect list (virtual/indirect stay excluded); a nameless panel renders as "laptop panel". - The below-OS METRONOMIC warn names the dark-panel case and its actual cures (`topology: primary`, `pnp_disable_monitors`) — the old text's "unplug it" prescriptions are impossible for an internal panel. - ddc_power_off: a zero-ack sweep now says at INFO that the axis did nothing (internal panels have no DDC/CI) instead of silently no-op'ing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
9.0 KiB
Rust
166 lines
9.0 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_physicals();
|
||
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 servicing is the prime suspect. For a LAPTOP \
|
||
PANEL (the exclusive isolate deactivated it — the dark-but-connected \
|
||
head is itself the disturbance on hybrid laptops): keep it active \
|
||
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
|
||
For an external display: 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"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|