7ab97bb1a3
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m18s
ci / bench (push) Successful in 5m17s
decky / build-publish (push) Successful in 36s
windows-host / package (push) Successful in 8m13s
arch / build-publish (push) Successful in 11m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
apple / screenshots (push) Successful in 5m38s
android / android (push) Successful in 16m12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish (push) Successful in 15m13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13m43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m46s
ci / rust (push) Successful in 22m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m10s
Field repro (Mounjay, still present on 0.9.0): the ~4 s double-jolt stutter appears ONLY while the virtual display is the sole active display (Exclusive topology) and stops the instant Windows switches to Extend — live, both ways. Cross-project research (Apollo #179/#358/#368/#563/#776, VDD #36, Tom's HW) points at the display/present path BELOW capture: an inactive-but-connected DisplayPort head being periodically serviced (standby HPD/AUX/link events), with a DWM software-vsync clock beat as the secondary (different-signature) class. Neither ends in anything our recovery-side detector can see unless the client actually loses data — so give the HOST a direct sensor at the ring: - StallWatch (idd_push.rs): a >150 ms hole in DWM frame delivery counts as a capture stall only when the 8 preceding frames arrived within 400 ms — sustained >=20 fps flow, so an idle desktop, a caret blink, or a paused video can never trip it. Per-stall debug line; when stalls settle into an evenly-spaced multi-second cycle, one rate-limited WARN names the class: 'capture stalls are METRONOMIC', with the topology=primary/extend and refresh-rate leads. Ring-recreate recovery gaps reset the watch (self- inflicted, already logged by the recreate path). - The evenly-spaced-cycle detector moves out of punktfunk1.rs into metronome.rs (RecoveryCadence -> Metronome, unchanged logic + tests) so the IDR-serve detector and the stall watch share one implementation; the recovery WARN now cross-references the capture-stall lines. Diagnosis map for an Exclusive-mode stutter log: 'slow display-descriptor poll' = something holds the win32k display lock; 'capture stalls are METRONOMIC' without it = DWM stopped composing (DP servicing / present clock, below us); recovery-IDR METRONOMIC alone = frames flowed but clients lost data. Verified: Linux tests+clippy+fmt clean; Windows (RTX box) 220/220 + clippy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
5.5 KiB
Rust
152 lines
5.5 KiB
Rust
//! Detector for METRONOMIC event cycles — evenly-spaced disturbances repeating every few seconds.
|
|
//!
|
|
//! The "periodic double-jolt" symptom class field reports keep describing is a host/display-side
|
|
//! disturbance on a stable multi-second period (display-topology churn, display-poller software,
|
|
//! virtual-display present timing). Random network loss is bursty and irregular; a stable period is
|
|
//! a machine, and saying so in the host log turns a "nothing in the logs :/" report into a
|
|
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`punktfunk1`) and IDD-push capture
|
|
//! stalls (`capture::windows::idd_push`).
|
|
|
|
use std::collections::VecDeque;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Pure evenly-spaced-events detector (unit-tested below).
|
|
///
|
|
/// Events within [`Self::COALESCE`] count as ONE (a double-jolt's paired disturbances — e.g. the
|
|
/// cooldown re-issue of a lost keyframe ~0.7 s after the first — are one user-visible cycle). When
|
|
/// the gaps between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their
|
|
/// mean, [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
|
|
/// [`Self::REWARN`] while the cycle persists.
|
|
pub(crate) struct Metronome {
|
|
events: VecDeque<Instant>,
|
|
last_warn: Option<Instant>,
|
|
}
|
|
|
|
impl Metronome {
|
|
/// Events closer together than this are the same user-visible disturbance.
|
|
const COALESCE: Duration = Duration::from_millis(1500);
|
|
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
|
|
const STREAK: usize = 4;
|
|
/// "Evenly spaced" = every gap within this fraction of the mean gap.
|
|
const TOLERANCE: f64 = 0.2;
|
|
/// Once warned, re-warn at most this often while the cycle persists.
|
|
const REWARN: Duration = Duration::from_secs(30);
|
|
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
events: VecDeque::new(),
|
|
last_warn: None,
|
|
}
|
|
}
|
|
|
|
/// Record a disturbance at `now`; `Some(mean period)` exactly when the metronomic-cycle
|
|
/// warning should fire.
|
|
pub(crate) fn note(&mut self, now: Instant) -> Option<Duration> {
|
|
if self
|
|
.events
|
|
.back()
|
|
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
|
|
{
|
|
return None;
|
|
}
|
|
self.events.push_back(now);
|
|
if self.events.len() > Self::STREAK {
|
|
self.events.pop_front();
|
|
}
|
|
if self.events.len() < Self::STREAK {
|
|
return None;
|
|
}
|
|
let gaps: Vec<f64> = self
|
|
.events
|
|
.iter()
|
|
.zip(self.events.iter().skip(1))
|
|
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
|
|
.collect();
|
|
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
|
|
if mean <= 0.0
|
|
|| gaps
|
|
.iter()
|
|
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
|
|
{
|
|
return None;
|
|
}
|
|
if self
|
|
.last_warn
|
|
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
|
|
{
|
|
return None;
|
|
}
|
|
self.last_warn = Some(now);
|
|
Some(Duration::from_secs_f64(mean))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Feed a [`Metronome`] a schedule of event offsets (ms from a common origin) and return
|
|
/// what each `note` produced.
|
|
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<Duration>> {
|
|
let base = Instant::now();
|
|
let mut c = Metronome::new();
|
|
offsets_ms
|
|
.iter()
|
|
.map(|ms| c.note(base + Duration::from_millis(*ms)))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn cadence_detects_metronomic_events() {
|
|
// Four events ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
|
|
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
|
|
assert_eq!(out[..3], [None, None, None]);
|
|
let period = out[3].expect("metronomic series must be detected");
|
|
assert!(
|
|
(period.as_secs_f64() - 3.98).abs() < 0.2,
|
|
"period={period:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cadence_coalesces_double_jolt_pairs() {
|
|
// The field signature: a jolt pair (second event ~0.7 s after the first, e.g. the IDR
|
|
// cooldown re-issue) every ~4 s. Each pair is ONE event; detection still lands on the
|
|
// ~4 s cycle.
|
|
let out = cadence_run(&[
|
|
0, 700, // pair 1
|
|
4_000, 4_700, // pair 2
|
|
8_000, 8_650, // pair 3
|
|
12_000, // pair 4 (first event trips it)
|
|
]);
|
|
assert!(out[..6].iter().all(Option::is_none));
|
|
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
|
|
assert!(
|
|
(period.as_secs_f64() - 4.0).abs() < 0.2,
|
|
"period={period:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cadence_ignores_irregular_bursts() {
|
|
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
|
|
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
|
|
.iter()
|
|
.all(Option::is_none));
|
|
}
|
|
|
|
#[test]
|
|
fn cadence_rewarns_at_most_every_30s() {
|
|
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
|
|
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
|
|
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
|
|
let out = cadence_run(&offsets);
|
|
let warned: Vec<usize> = out
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, o)| o.map(|_| i))
|
|
.collect();
|
|
assert_eq!(warned, vec![3, 11], "warn indices");
|
|
}
|
|
}
|