use frust_core::anim::FrameTime;
pub const WARMUP_FRAMES: u8 = 3;
pub const NO_FRAME_GATE_VAR: &str = "FRUST_NO_FRAME_GATE";
pub const NO_ANIM_PACING_VAR: &str = "FRUST_NO_ANIM_PACING";
#[derive(Debug, Clone, Copy)]
pub struct FramePacing {
pub now: FrameTime,
pub interval: std::time::Duration,
pub requested_interval: Option<std::time::Duration>,
}
impl FramePacing {
pub fn effective_interval(&self) -> std::time::Duration {
match self.requested_interval {
Some(requested) => self.interval.max(requested),
None => self.interval,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameDecision {
Run,
Skip,
}
impl FrameDecision {
pub fn is_run(self) -> bool {
matches!(self, FrameDecision::Run)
}
pub fn is_skip(self) -> bool {
matches!(self, FrameDecision::Skip)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FrameInputs {
pub signals_dirty: bool,
pub events_since_last_frame: bool,
pub pointer_capture_active: bool,
pub focus_or_ime_changed: bool,
pub last_needs_frame: bool,
pub last_needs_frame_paced_only: bool,
pub change_flags_pending: bool,
pub deferred_callbacks_pending: bool,
pub theme_or_appearance_changed: bool,
pub surface_changed_or_resized: bool,
pub a11y_action_performed: bool,
pub resumed_recently: bool,
}
impl FrameInputs {
pub fn any_set(&self) -> bool {
self.signals_dirty
|| self.events_since_last_frame
|| self.pointer_capture_active
|| self.focus_or_ime_changed
|| self.last_needs_frame
|| self.last_needs_frame_paced_only
|| self.change_flags_pending
|| self.deferred_callbacks_pending
|| self.theme_or_appearance_changed
|| self.surface_changed_or_resized
|| self.a11y_action_performed
|| self.resumed_recently
}
pub fn is_paced_only_frame(&self) -> bool {
self.last_needs_frame
&& self.last_needs_frame_paced_only
&& !self.signals_dirty
&& !self.events_since_last_frame
&& !self.pointer_capture_active
&& !self.change_flags_pending
&& !self.deferred_callbacks_pending
&& !self.theme_or_appearance_changed
&& !self.surface_changed_or_resized
&& !self.a11y_action_performed
&& !self.resumed_recently
}
}
#[derive(Debug)]
pub struct FrameGate {
enabled: bool,
anim_pacing: bool,
warmup_remaining: u8,
last_paced_run: Option<FrameTime>,
last_paced_interval: Option<std::time::Duration>,
}
impl FrameGate {
pub fn new() -> Self {
Self::with_flags(!kill_switch_engaged(), !anim_pacing_kill_switch_engaged())
}
pub fn disabled() -> Self {
Self::with_flags(false, false)
}
pub fn with_enabled(enabled: bool) -> Self {
Self::with_flags(enabled, enabled)
}
pub fn with_flags(enabled: bool, anim_pacing: bool) -> Self {
Self {
enabled,
anim_pacing,
warmup_remaining: 0,
last_paced_run: None,
last_paced_interval: None,
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn note_resumed(&mut self) {
self.warmup_remaining = WARMUP_FRAMES;
}
pub fn warmup_remaining(&self) -> u8 {
self.warmup_remaining
}
pub fn decide(&mut self, inputs: FrameInputs) -> FrameDecision {
self.decide_impl(inputs, None)
}
pub fn decide_paced(&mut self, inputs: FrameInputs, pacing: FramePacing) -> FrameDecision {
self.decide_impl(inputs, Some(pacing))
}
fn decide_impl(&mut self, inputs: FrameInputs, pacing: Option<FramePacing>) -> FrameDecision {
if !self.enabled {
return FrameDecision::Run;
}
let warming = self.warmup_remaining > 0;
if warming {
self.warmup_remaining -= 1;
}
let paced_case =
!warming && self.anim_pacing && pacing.is_some() && inputs.is_paced_only_frame();
if warming {
self.anchor_non_paced(pacing);
return FrameDecision::Run;
}
if paced_case {
let p = pacing.expect("paced_case implies pacing.is_some()");
let interval = if inputs.focus_or_ime_changed {
p.interval
} else {
p.effective_interval()
};
return match self.last_paced_run {
Some(last) if p.now.saturating_sub(last) < interval => FrameDecision::Skip,
_ => {
self.anchor_paced(p.now, interval);
FrameDecision::Run
}
};
}
if inputs.any_set() {
self.anchor_non_paced(pacing);
FrameDecision::Run
} else {
FrameDecision::Skip
}
}
fn anchor_non_paced(&mut self, pacing: Option<FramePacing>) {
if let Some(p) = pacing {
self.last_paced_run = Some(p.now);
self.last_paced_interval = Some(p.effective_interval());
}
}
fn anchor_paced(&mut self, now: FrameTime, interval: std::time::Duration) {
let next = match (self.last_paced_run, self.last_paced_interval) {
(Some(last), Some(previous))
if previous == interval && now.saturating_sub(last) < interval * 2 =>
{
FrameTime::from_nanos(last.as_nanos().saturating_add(interval.as_nanos() as u64))
}
_ => now,
};
self.last_paced_run = Some(next);
self.last_paced_interval = Some(interval);
}
}
impl Default for FrameGate {
fn default() -> Self {
Self::new()
}
}
fn kill_switch_engaged() -> bool {
kill_switch(
option_env!("FRUST_NO_FRAME_GATE"),
std::env::var(NO_FRAME_GATE_VAR).ok().as_deref(),
)
}
pub fn anim_pacing_kill_switch_engaged() -> bool {
kill_switch(
option_env!("FRUST_NO_ANIM_PACING"),
std::env::var(NO_ANIM_PACING_VAR).ok().as_deref(),
)
}
fn kill_switch(compile_time: Option<&str>, runtime: Option<&str>) -> bool {
fn is_set_non_zero(value: Option<&str>) -> bool {
matches!(value, Some(v) if v != "0")
}
is_set_non_zero(compile_time) || is_set_non_zero(runtime)
}
#[cfg(test)]
mod tests {
use super::*;
fn only(field: &str) -> FrameInputs {
let mut i = FrameInputs::default();
match field {
"signals_dirty" => i.signals_dirty = true,
"events_since_last_frame" => i.events_since_last_frame = true,
"pointer_capture_active" => i.pointer_capture_active = true,
"focus_or_ime_changed" => i.focus_or_ime_changed = true,
"last_needs_frame" => i.last_needs_frame = true,
"last_needs_frame_paced_only" => i.last_needs_frame_paced_only = true,
"change_flags_pending" => i.change_flags_pending = true,
"deferred_callbacks_pending" => i.deferred_callbacks_pending = true,
"theme_or_appearance_changed" => i.theme_or_appearance_changed = true,
"surface_changed_or_resized" => i.surface_changed_or_resized = true,
"a11y_action_performed" => i.a11y_action_performed = true,
"resumed_recently" => i.resumed_recently = true,
other => panic!("unknown FrameInputs field {other:?}"),
}
i
}
const ALL_INPUTS: &[&str] = &[
"signals_dirty",
"events_since_last_frame",
"pointer_capture_active",
"focus_or_ime_changed",
"last_needs_frame",
"last_needs_frame_paced_only",
"change_flags_pending",
"deferred_callbacks_pending",
"theme_or_appearance_changed",
"surface_changed_or_resized",
"a11y_action_performed",
"resumed_recently",
];
#[test]
fn kill_switch_off_when_neither_set() {
assert!(!kill_switch(None, None));
}
#[test]
fn kill_switch_on_when_compile_time_set_non_zero() {
assert!(kill_switch(Some("1"), None));
}
#[test]
fn kill_switch_on_when_runtime_set_non_zero() {
assert!(kill_switch(None, Some("1")));
}
#[test]
fn kill_switch_off_when_either_is_literal_zero_and_other_unset() {
assert!(!kill_switch(Some("0"), None));
assert!(!kill_switch(None, Some("0")));
}
#[test]
fn kill_switch_on_when_either_source_wins() {
assert!(kill_switch(Some("0"), Some("1")));
assert!(kill_switch(Some("1"), Some("0")));
}
#[test]
fn each_single_input_forces_run() {
assert_eq!(
ALL_INPUTS.len(),
12,
"the OR-list must have all eleven wake inputs plus the \
paced-only signal"
);
for field in ALL_INPUTS {
let mut gate = FrameGate::with_enabled(true);
let decision = gate.decide(only(field));
assert_eq!(
decision,
FrameDecision::Run,
"input {field:?} alone must force a Run"
);
}
}
#[test]
fn all_false_inputs_skip() {
let mut gate = FrameGate::with_enabled(true);
assert_eq!(
gate.decide(FrameInputs::default()),
FrameDecision::Skip,
"no input set (and no warmup) must skip"
);
}
#[test]
fn any_set_matches_decide_for_all_false() {
let inputs = FrameInputs::default();
assert!(!inputs.any_set());
}
#[test]
fn warmup_forces_run_for_n_frames_then_skips() {
let mut gate = FrameGate::with_enabled(true);
gate.note_resumed();
assert_eq!(gate.warmup_remaining(), WARMUP_FRAMES);
for frame in 0..WARMUP_FRAMES {
assert_eq!(
gate.decide(FrameInputs::default()),
FrameDecision::Run,
"warmup frame {frame} must run despite no inputs"
);
}
assert_eq!(gate.warmup_remaining(), 0);
assert_eq!(
gate.decide(FrameInputs::default()),
FrameDecision::Skip,
"after the warmup window, an all-false frame skips again"
);
}
#[test]
fn note_resumed_reopens_the_warmup_window() {
let mut gate = FrameGate::with_enabled(true);
gate.note_resumed();
for _ in 0..WARMUP_FRAMES {
gate.decide(FrameInputs::default());
}
assert_eq!(gate.warmup_remaining(), 0);
gate.note_resumed();
assert_eq!(
gate.decide(FrameInputs::default()),
FrameDecision::Run,
"a fresh note_resumed reopens the warmup window"
);
}
#[test]
fn disabled_gate_always_runs() {
let mut gate = FrameGate::disabled();
assert!(!gate.is_enabled());
assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Run);
assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Run);
}
#[test]
fn enabled_gate_can_skip() {
let mut gate = FrameGate::with_enabled(true);
assert!(gate.is_enabled());
assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Skip);
}
#[test]
fn frame_decision_predicates() {
assert!(FrameDecision::Run.is_run());
assert!(!FrameDecision::Run.is_skip());
assert!(FrameDecision::Skip.is_skip());
assert!(!FrameDecision::Skip.is_run());
}
use std::time::Duration;
fn step_nanos(hz: f64) -> u64 {
(1_000_000_000.0 / hz) as u64
}
fn paced_only() -> FrameInputs {
FrameInputs {
last_needs_frame: true,
last_needs_frame_paced_only: true,
..FrameInputs::default()
}
}
fn interval_30hz() -> Duration {
Duration::from_secs_f64(1.0 / 30.0)
}
fn pacing(now: FrameTime, interval: Duration) -> FramePacing {
FramePacing {
now,
interval,
requested_interval: None,
}
}
fn pacing_at(now: FrameTime, interval: Duration, requested: Duration) -> FramePacing {
FramePacing {
now,
interval,
requested_interval: Some(requested),
}
}
fn interval_500ms() -> Duration {
Duration::from_millis(500)
}
#[test]
fn is_paced_only_frame_requires_last_needs_frame_and_no_other_input() {
assert!(paced_only().is_paced_only_frame());
let only_flag = FrameInputs {
last_needs_frame_paced_only: true,
..FrameInputs::default()
};
assert!(!only_flag.is_paced_only_frame());
let mut with_event = paced_only();
with_event.events_since_last_frame = true;
assert!(!with_event.is_paced_only_frame());
let mut with_flush = paced_only();
with_flush.deferred_callbacks_pending = true;
assert!(!with_flush.is_paced_only_frame());
let steady_focus = FrameInputs {
focus_or_ime_changed: false,
..paced_only()
};
assert!(
steady_focus.is_paced_only_frame(),
"a steady focus session must not block paced-loop throttling"
);
let mut gate = FrameGate::with_flags(true, true);
let interval = interval_30hz();
assert!(
gate.decide_paced(steady_focus, pacing(FrameTime::from_nanos(0), interval),)
.is_run()
);
assert_eq!(
gate.decide_paced(
steady_focus,
pacing(FrameTime::from_nanos(step_nanos(120.0)), interval),
),
FrameDecision::Skip,
"a paced loop under a steady focus session must throttle to the cap"
);
let edge_mid_loop = FrameInputs {
focus_or_ime_changed: true,
..paced_only()
};
assert!(edge_mid_loop.is_paced_only_frame());
assert!(
FrameInputs {
focus_or_ime_changed: true,
..FrameInputs::default()
}
.any_set()
);
}
struct FocusEdgeCache {
last_seen: u64,
}
impl FocusEdgeCache {
fn new(seed: u64) -> Self {
Self { last_seen: seed }
}
fn peek(&self, generation: u64) -> bool {
generation != self.last_seen
}
fn commit(&mut self, generation: u64) {
self.last_seen = generation;
}
}
#[test]
fn focus_edge_fires_once_per_transition_and_goes_quiet() {
let script: &[(u64, bool, &str)] = &[
(0, false, "idle before anything is focused"),
(0, false, "still idle"),
(1, true, "focus gained"),
(1, false, "steady focus (caret blinking)"),
(1, false, "still steady"),
(2, true, "IME surface published"),
(2, false, "steady IME session"),
(3, true, "IME surface cleared"),
(4, true, "focus lost (blur)"),
(4, false, "idle again"),
];
let mut cache = FocusEdgeCache::new(0);
let mut gate = FrameGate::with_enabled(true);
for (generation, expected_edge, what) in script {
let inputs = FrameInputs {
focus_or_ime_changed: cache.peek(*generation),
..FrameInputs::default()
};
assert_eq!(
inputs.focus_or_ime_changed, *expected_edge,
"edge for {what:?}"
);
let expect = if *expected_edge {
FrameDecision::Run
} else {
FrameDecision::Skip
};
let decision = gate.decide(inputs);
assert_eq!(decision, expect, "decision for {what:?}");
if decision.is_run() {
cache.commit(*generation);
}
}
}
#[test]
fn a_long_steady_focus_session_produces_no_frames() {
let mut cache = FocusEdgeCache::new(7);
let mut gate = FrameGate::with_enabled(true);
let mut runs = 0usize;
for _ in 0..600 {
let inputs = FrameInputs {
focus_or_ime_changed: cache.peek(7),
..FrameInputs::default()
};
if gate.decide(inputs).is_run() {
cache.commit(7);
runs += 1;
}
}
assert_eq!(runs, 0, "a steady focus session must produce no frames");
}
#[test]
fn a_focus_edge_persists_across_paced_skips_and_lands_within_one_cap_interval() {
let tick = step_nanos(120.0);
let cap = interval_30hz();
let at = |i: u64| FrameTime::from_nanos(i * tick);
let caret = |i: u64| FramePacing {
now: at(i),
interval: cap,
requested_interval: Some(interval_500ms()),
};
let inputs = |edge: bool| FrameInputs {
focus_or_ime_changed: edge,
..paced_only()
};
let mut gate = FrameGate::with_flags(true, true);
let mut cache = FocusEdgeCache::new(5);
assert!(!cache.peek(5));
assert!(gate.decide_paced(inputs(false), caret(0)).is_run());
cache.commit(5);
let mut landed_tick = None;
for i in 1..120u64 {
assert!(
cache.peek(6),
"the edge must still be pending at tick {i} — a Skip consumes nothing"
);
if gate.decide_paced(inputs(true), caret(i)).is_run() {
cache.commit(6);
landed_tick = Some(i);
break;
}
}
let landed_tick =
landed_tick.expect("the deferred edge must be consumed by a tick that runs");
let landed_ns = landed_tick * tick;
assert!(
landed_ns <= cap.as_nanos() as u64 + tick,
"the edge repaint must land within one cap interval of the loop's \
last produced frame (plus one tick of 120Hz grid rounding); landed \
at {landed_ns}ns"
);
assert!(
landed_ns < interval_500ms().as_nanos() as u64,
"…and nowhere near the caret's own 500ms request"
);
assert!(!cache.peek(6), "a produced frame clears the edge");
assert_eq!(
gate.decide_paced(inputs(false), caret(landed_tick + 1)),
FrameDecision::Skip
);
let mut drained_gate = FrameGate::with_flags(true, true);
let mut drained_cache = FocusEdgeCache::new(5);
let mut first_run_after_bump: Option<u64> = None;
for i in 0..120u64 {
let generation = if i >= 1 { 6 } else { 5 };
let edge = drained_cache.peek(generation);
drained_cache.commit(generation); if drained_gate.decide_paced(inputs(edge), caret(i)).is_run() && i >= 1 {
first_run_after_bump = Some(i * tick);
break;
}
}
assert!(
first_run_after_bump.is_some_and(|ns| ns >= interval_500ms().as_nanos() as u64),
"drain-on-gather erases the deferred edge, so the repaint falls \
through to the 500ms per-request interval; observed \
{first_run_after_bump:?}ns"
);
}
#[test]
fn a_warmup_run_commits_the_focus_edge_too() {
let cap = interval_30hz();
let mut gate = FrameGate::with_flags(true, true);
let mut cache = FocusEdgeCache::new(1);
gate.note_resumed();
assert!(cache.peek(2));
let decision = gate.decide_paced(
FrameInputs {
focus_or_ime_changed: cache.peek(2),
..paced_only()
},
pacing(FrameTime::from_nanos(0), cap),
);
assert_eq!(
decision,
FrameDecision::Run,
"the resume warmup forces this frame to run"
);
assert_eq!(gate.warmup_remaining(), WARMUP_FRAMES - 1);
assert!(decision.is_run());
cache.commit(2);
assert!(!cache.peek(2), "the warmup frame consumed the edge");
for i in 1..=u64::from(WARMUP_FRAMES - 1) {
assert!(!cache.peek(2));
assert!(
gate.decide_paced(
FrameInputs {
focus_or_ime_changed: cache.peek(2),
..paced_only()
},
pacing(FrameTime::from_nanos(i * step_nanos(120.0)), cap),
)
.is_run()
);
cache.commit(2);
}
assert_eq!(gate.warmup_remaining(), 0);
}
#[test]
fn a_marked_flush_alone_forces_a_run() {
let mut gate = FrameGate::with_enabled(true);
let owed = FrameInputs {
deferred_callbacks_pending: true,
..FrameInputs::default()
};
assert_eq!(gate.decide(owed), FrameDecision::Run);
assert_eq!(gate.decide(owed), FrameDecision::Run);
assert_eq!(
gate.decide(FrameInputs::default()),
FrameDecision::Skip,
"once the rebuild drained the mark the gate idles again"
);
}
#[test]
fn paced_only_stream_runs_at_the_cap_not_every_vsync() {
let mut gate = FrameGate::with_flags(true, true);
let tick = step_nanos(120.0);
let interval = interval_30hz();
let mut runs = 0usize;
for i in 0..120u64 {
let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
if gate.decide_paced(paced_only(), pacing).is_run() {
runs += 1;
}
}
assert!(
(28..=32).contains(&runs),
"paced-only 120Hz stream should run ~30x/s, ran {runs}"
);
}
#[test]
fn transition_input_mid_interval_runs_immediately() {
let mut gate = FrameGate::with_flags(true, true);
let interval = interval_30hz();
let run0 = gate.decide_paced(paced_only(), pacing(FrameTime::from_nanos(0), interval));
assert!(run0.is_run());
let mut transition = FrameInputs {
last_needs_frame: true,
last_needs_frame_paced_only: false,
..FrameInputs::default()
};
assert!(!transition.is_paced_only_frame());
let decision = gate.decide_paced(
transition,
pacing(FrameTime::from_nanos(step_nanos(120.0)), interval),
);
assert_eq!(
decision,
FrameDecision::Run,
"a transition mid-interval must run immediately, not pace"
);
transition = FrameInputs {
events_since_last_frame: true,
..FrameInputs::default()
};
assert_eq!(
gate.decide_paced(
transition,
pacing(FrameTime::from_nanos(2 * step_nanos(120.0)), interval),
),
FrameDecision::Run,
"an event mid-interval must run immediately"
);
}
#[test]
fn paced_skip_leaves_the_request_alive_and_never_starves() {
let mut gate = FrameGate::with_flags(true, true);
let tick = step_nanos(120.0);
let interval = interval_30hz();
let mut last_run_tick: Option<u64> = None;
let mut max_gap = 0u64;
let mut total_runs = 0usize;
for i in 0..600u64 {
let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
if gate.decide_paced(paced_only(), pacing).is_run() {
total_runs += 1;
if let Some(prev) = last_run_tick {
max_gap = max_gap.max(i - prev);
}
last_run_tick = Some(i);
}
}
assert!(
total_runs > 0,
"paced stream must keep running (no starvation)"
);
assert!(
max_gap <= 5,
"paced runs must stay periodic; observed max gap of {max_gap} ticks"
);
}
#[test]
fn anim_pacing_kill_switch_runs_every_tick() {
let mut gate = FrameGate::with_flags(true, false);
let tick = step_nanos(120.0);
let interval = interval_30hz();
for i in 0..120u64 {
let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
assert_eq!(
gate.decide_paced(paced_only(), pacing),
FrameDecision::Run,
"with pacing disabled every paced tick must run"
);
}
}
#[test]
fn disabled_gate_ignores_pacing() {
let mut gate = FrameGate::disabled();
for i in 0..10u64 {
let pacing = pacing(
FrameTime::from_nanos(i * step_nanos(120.0)),
interval_30hz(),
);
assert_eq!(gate.decide_paced(paced_only(), pacing), FrameDecision::Run);
}
}
#[test]
fn non_paced_decide_runs_paced_only_every_tick() {
let mut gate = FrameGate::with_flags(true, true);
for _ in 0..10 {
assert_eq!(gate.decide(paced_only()), FrameDecision::Run);
}
}
fn paced_runs(
gate: &mut FrameGate,
ticks: u64,
interval: Duration,
requested: Option<Duration>,
) -> usize {
let tick = step_nanos(120.0);
(0..ticks)
.filter(|i| {
let now = FrameTime::from_nanos(i * tick);
let p = FramePacing {
now,
interval,
requested_interval: requested,
};
gate.decide_paced(paced_only(), p).is_run()
})
.count()
}
#[test]
fn effective_interval_folds_the_request_against_the_theme_cap() {
let cap = interval_30hz();
assert_eq!(pacing(FrameTime::ZERO, cap).effective_interval(), cap);
assert_eq!(
pacing_at(FrameTime::ZERO, cap, Duration::ZERO).effective_interval(),
cap
);
assert_eq!(
pacing_at(FrameTime::ZERO, cap, interval_500ms()).effective_interval(),
interval_500ms()
);
assert_eq!(
pacing_at(FrameTime::ZERO, cap, Duration::from_millis(8)).effective_interval(),
cap
);
}
#[test]
fn a_frame_requesting_33ms_and_500ms_paces_at_33ms() {
let mut paint =
frust_core::PaintCtx::new(kurbo::Point::ZERO, kurbo::Size::new(100.0, 100.0));
paint.request_frame_paced_at(Duration::from_millis(33));
paint.request_frame_paced_at(interval_500ms());
let requested = paint.paced_interval();
assert_eq!(requested, Some(Duration::from_millis(33)));
let mut gate = FrameGate::with_flags(true, true);
let runs = paced_runs(&mut gate, 120, interval_30hz(), requested);
assert!(
(28..=32).contains(&runs),
"a 33ms+500ms frame must pace at 33ms (~30 runs/s), ran {runs}"
);
let mut slow_gate = FrameGate::with_flags(true, true);
let slow_runs = paced_runs(&mut slow_gate, 120, interval_30hz(), Some(interval_500ms()));
assert!(
(1..=3).contains(&slow_runs),
"the 500ms request alone should run ~2x/s, ran {slow_runs}"
);
}
#[test]
fn a_500ms_request_paces_far_slower_than_the_theme_cap() {
let mut gate = FrameGate::with_flags(true, true);
let runs = paced_runs(&mut gate, 120, interval_30hz(), Some(interval_500ms()));
assert!(
(1..=3).contains(&runs),
"a 500ms paced request should run ~2x/s, ran {runs}"
);
let mut cap_gate = FrameGate::with_flags(true, true);
let cap_runs = paced_runs(&mut cap_gate, 120, interval_30hz(), None);
assert!(
(28..=32).contains(&cap_runs),
"an interval-less paced stream still runs at the cap, ran {cap_runs}"
);
}
#[test]
fn a_changed_interval_neither_bursts_nor_stalls_the_cadence() {
let tick = step_nanos(120.0);
let fast = interval_30hz();
let slow = interval_500ms();
const FLIP_PERIOD: u64 = 137;
for offset in 0..FLIP_PERIOD {
let mut gate = FrameGate::with_flags(true, true);
let mut last_run: Option<u64> = None;
let mut min_gap_ns = u64::MAX;
let mut max_gap_ns = 0u64;
for i in 0..720u64 {
let now_ns = i * tick;
let shimmer_onscreen = ((i + offset) / FLIP_PERIOD).is_multiple_of(2);
let requested = if shimmer_onscreen {
Duration::ZERO
} else {
slow
};
let p = FramePacing {
now: FrameTime::from_nanos(now_ns),
interval: fast,
requested_interval: Some(requested),
};
if gate.decide_paced(paced_only(), p).is_run() {
if let Some(prev) = last_run {
let gap = now_ns - prev;
min_gap_ns = min_gap_ns.min(gap);
max_gap_ns = max_gap_ns.max(gap);
}
last_run = Some(now_ns);
}
}
assert!(
min_gap_ns + tick >= fast.as_nanos() as u64,
"no burst (flip offset {offset}): the tightest observed gap \
({min_gap_ns}ns) must not undercut the fast interval by more \
than one tick"
);
assert!(
max_gap_ns <= slow.as_nanos() as u64 + tick,
"no stall (flip offset {offset}): the widest observed gap \
({max_gap_ns}ns) must not exceed the slow interval by more than \
one tick"
);
}
}
#[test]
fn a_shortening_interval_flip_never_double_fires() {
let cap = interval_30hz();
let slow = interval_500ms();
let mut gate = FrameGate::with_flags(true, true);
let paced_at = |now_ns: u64, requested: Duration| FramePacing {
now: FrameTime::from_nanos(now_ns),
interval: cap,
requested_interval: Some(requested),
};
assert!(
gate.decide_paced(paced_only(), paced_at(0, Duration::ZERO))
.is_run()
);
assert!(
gate.decide_paced(paced_only(), paced_at(500_000_000, slow))
.is_run()
);
assert!(
gate.decide_paced(paced_only(), paced_at(900_000_000, slow))
.is_skip()
);
assert!(
gate.decide_paced(paced_only(), paced_at(1_000_000_000, slow))
.is_run()
);
assert!(
gate.decide_paced(paced_only(), paced_at(1_058_333_333, Duration::ZERO))
.is_run()
);
for (n, now_ns) in [1_066_666_666u64, 1_075_000_000, 1_083_333_333]
.into_iter()
.enumerate()
{
assert_eq!(
gate.decide_paced(paced_only(), paced_at(now_ns, Duration::ZERO)),
FrameDecision::Skip,
"tick {n} after a shortening flip must not double-fire"
);
}
assert_eq!(
gate.decide_paced(paced_only(), paced_at(1_091_666_666, Duration::ZERO)),
FrameDecision::Run,
"the fast cadence resumes one interval after the flip frame"
);
}
#[test]
fn the_anchor_adopts_the_interval_in_force_at_the_last_run() {
let cap = Duration::from_millis(10);
let fast = Duration::from_millis(33);
let slow = interval_500ms();
let mut gate = FrameGate::with_flags(true, true);
let at = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
let paced = |now: FrameTime, requested: Duration| FramePacing {
now,
interval: cap,
requested_interval: Some(requested),
};
assert!(gate.decide_paced(paced_only(), paced(at(0), fast)).is_run());
assert!(
gate.decide_paced(paced_only(), paced(at(20), fast))
.is_skip()
);
assert!(
gate.decide_paced(paced_only(), paced(at(40), fast))
.is_run()
);
assert!(
gate.decide_paced(paced_only(), paced(at(100), slow))
.is_skip()
);
assert!(
gate.decide_paced(paced_only(), paced(at(545), slow))
.is_run()
);
assert!(
gate.decide_paced(paced_only(), paced(at(800), slow))
.is_skip()
);
assert!(
gate.decide_paced(paced_only(), paced(at(1045), slow))
.is_run()
);
assert!(
gate.decide_paced(paced_only(), paced(at(1060), fast))
.is_skip()
);
assert!(
gate.decide_paced(paced_only(), paced(at(1080), fast))
.is_run()
);
assert!(
gate.decide_paced(paced_only(), paced(at(1090), fast))
.is_skip(),
"no double-fire immediately after a shortening flip"
);
assert!(
gate.decide_paced(paced_only(), paced(at(1115), fast))
.is_run()
);
}
#[test]
fn a_focus_ime_edge_is_bounded_by_the_theme_cap_not_a_long_request() {
let cap = interval_30hz();
let mut gate = FrameGate::with_flags(true, true);
let at = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
let caret = |now: FrameTime| FramePacing {
now,
interval: cap,
requested_interval: Some(interval_500ms()),
};
let edge = FrameInputs {
focus_or_ime_changed: true,
..paced_only()
};
assert!(gate.decide_paced(paced_only(), caret(at(0))).is_run());
assert_eq!(
gate.decide_paced(edge, caret(at(20))),
FrameDecision::Skip,
"an edge inside the cap interval is still absorbed by the pacing"
);
assert_eq!(
gate.decide_paced(edge, caret(at(40))),
FrameDecision::Run,
"a focus/IME edge must not wait out a long per-request interval"
);
assert!(cap <= Duration::from_millis(100));
assert_eq!(
gate.decide_paced(paced_only(), caret(at(80))),
FrameDecision::Skip,
"the edge tick must not permanently re-tighten the caret's cadence"
);
assert_eq!(
gate.decide_paced(paced_only(), caret(at(560))),
FrameDecision::Run
);
}
}