frust_shell_common/frame_gate.rs
1//! [`FrameGate`]: the shared skip-frame decision the mobile shells consult
2//! each tick.
3//!
4//! # What lives here
5//!
6//! - [`FrameInputs`] — the OR-list of per-frame "something changed" signals a
7//! shell gathers from its own state (see the struct's field docs, each
8//! naming the shell-side source).
9//! - [`FrameGate`] — a plain struct owning the resume-warmup counter and the
10//! kill-switch flag; [`FrameGate::decide`] folds [`FrameInputs`] plus the
11//! warmup into one [`FrameDecision`] (`Run`/`Skip`). It also reports, without
12//! acting on it, whether the loop is *continuous* — a run of produced frames
13//! an animation asked for ([`FrameGate::is_continuous`]).
14//!
15//! # Layering choice
16//!
17//! Like [`crate::perf`], this is shell-owned by design and lives in
18//! `frust-shell-common`, not `frust-core`. The gate takes no platform
19//! dependency, no `unsafe`, and — critically — **no `frust-reactive`
20//! dependency**: the reactive "did any tracked signal change" answer arrives
21//! as the plain [`FrameInputs::signals_dirty`] bool, which the shells fill
22//! themselves by calling `frust_reactive::ReactiveRuntime::take_signals_dirty`
23//! after their per-frame `pump_local` (that crate's documented
24//! pump-first ordering contract). This keeps shell-common's compiles-everywhere,
25//! reactive-free charter intact (see `docs/ARCHITECTURE.md`'s Layer
26//! Dependencies).
27//!
28//! # The layout-skip seam
29//!
30//! [`FrameGate`] decides whether a whole frame runs at all. Within a frame it
31//! *does* run, whether the layout pass can be skipped is a second, finer gate
32//! the shell drives off `RenderRoot::take_change_flags` (exposed through
33//! [`crate::AppTree::take_change_flags`]): run `rebuild` → run `layout` iff the
34//! drained `ChangeFlags` need layout (or it's the first frame, or the surface
35//! resized / rescaled) → always `paint`. Every shell now wires this seam
36//! (Android, iOS, desktop and web); iOS drains the flags both to idle the
37//! display link and to decide layout, and the frame gate above still decides
38//! whether the frame runs at all (the layout skip only elides the layout call
39//! inside a running frame). The `set_theme ⇒ LAYOUT|PAINT` contract
40//! (`docs/ARCHITECTURE.md`'s Theme delivery) is the correctness anchor there:
41//! `Text` bakes its themed glyph color at layout time, so a bare theme swap
42//! must force relayout even though no view changed — which
43//! `RenderRoot::set_theme` guarantees by marking `LAYOUT` pending. Encode and
44//! present are frame-level only: a frame the gate runs always presents.
45//!
46//! Beneath that whole-frame skip, `RenderRoot` skips clean pods per
47//! `ChildPod`; its env knobs (plus `FRUST_NO_RETAINED_LAYERS`) are read by the shells through [`LayoutKnobs`]
48//! (`frust-core` reads no environment variable).
49//!
50//! # Animation pacing (frame-gate pacing)
51//!
52//! On top of the whole-frame skip, a frame whose *only* dirtiness source is a
53//! paced ([`frust_core::TickClass::CosmeticLoop`]) frame request — a shimmer,
54//! idle pulse, or spinner with no user-visible endpoint — is throttled to the
55//! active theme's `MotionScheme::cosmetic_loop_rate` rather than reproduced on
56//! every vsync. A paced request may also name its *own*, slower cadence
57//! (`PaintCtx::request_frame_paced_at` — a ~500ms caret blink against a 30Hz
58//! shimmer cap); the paint pass folds every such request onto a MIN-lattice and
59//! the gate resolves the result against the theme cap
60//! ([`FramePacing::effective_interval`]). The shell feeds the paced-only fact
61//! through [`FrameInputs::last_needs_frame_paced_only`] and the per-frame clock +
62//! interval pair through [`FramePacing`] to [`FrameGate::decide_paced`]; any
63//! input/signal/change-flag/transition dirtiness is **never** paced (it runs
64//! immediately, per the default-to-run rule) — with one deliberate exception,
65//! the [`FrameInputs::focus_or_ime_changed`] edge (that field's doc has the
66//! reasoning: a blinking caret in a focused field is exactly the loop this gate
67//! must be able to throttle). Because that edge *can* ride inside a paced
68//! decision, the shells **peek** it rather than drain it: they compare the live
69//! generation against their cache every tick but commit the cache only once the
70//! gate has decided to `Run`, so an edge landing on a skipped tick is deferred
71//! by the pacing — bounded by one cap interval — and never erased. Every other
72//! drained-on-gather latch is an [`FrameInputs::is_paced_only_frame`]
73//! disqualifier and so can never be true on a tick the gate skips. The
74//! `FRUST_NO_FRAME_GATE` kill
75//! switch disables pacing too (a disabled gate always runs), and
76//! [`NO_ANIM_PACING_VAR`] (`FRUST_NO_ANIM_PACING`) disables *only* the pacing
77//! while leaving the skip gate active.
78
79use frust_core::anim::FrameTime;
80
81/// The resume-warmup window length: after a [`FrameGate::note_resumed`] the
82/// next `WARMUP_FRAMES` [`decide`](FrameGate::decide) calls force a `Run`
83/// regardless of [`FrameInputs`].
84///
85/// Three frames is a deliberately small, fixed cushion: a
86/// resume/surface-recreate can leave the first tick's change signals not yet
87/// observable (a surface just became ready, the appearance poll hasn't run,
88/// the first post-resume event hasn't arrived), and painting a couple of
89/// extra frames on resume is far cheaper than showing a stale or blank one.
90/// It is not a published platform constant — it is a Frust tuning choice
91/// (the "correctness beats savings" default: when in doubt, run).
92pub const WARMUP_FRAMES: u8 = 3;
93
94/// How many back-to-back produced frames carrying an animation continuation
95/// make the frame loop *continuous* ([`FrameGate::is_continuous`]).
96///
97/// A continuation is a `Run` whose [`FrameInputs::last_needs_frame`] is set
98/// with [`FrameInputs::last_needs_frame_paced_only`] clear: the previous paint
99/// asked for this frame for a transition-class reason, not only a paced
100/// cosmetic loop. Three in a row separates a running animation from a one-off
101/// settle frame or two; it is a Frust tuning choice, not a platform constant.
102/// The count only *reports* the regime — it never changes a decision.
103pub const CONTINUOUS_RUN_FRAMES: u32 = 3;
104
105/// The kill-switch environment/compile-time variable: when set to any
106/// non-`"0"` value, [`FrameGate::new`] yields a gate that always [`Run`]s,
107/// matching pre-gate behavior verbatim.
108///
109/// [`Run`]: FrameDecision::Run
110pub const NO_FRAME_GATE_VAR: &str = "FRUST_NO_FRAME_GATE";
111
112/// The animation-pacing kill-switch variable: when set to any non-`"0"` value,
113/// [`FrameGate::new`] disables *only* the paced-loop throttling — the
114/// whole-frame skip gate stays active, but every paced ([`CosmeticLoop`])
115/// request runs on its vsync as before. Narrower than [`NO_FRAME_GATE_VAR`]
116/// (which disables the whole gate), it isolates the pacing behavior for A/B
117/// diagnosis. Parsed with the same compile-time-`option_env!` + runtime-env
118/// family as [`NO_FRAME_GATE_VAR`] / `FRUST_TRACE`.
119///
120/// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
121pub const NO_ANIM_PACING_VAR: &str = "FRUST_NO_ANIM_PACING";
122
123/// The per-frame pacing context a shell hands to [`FrameGate::decide_paced`]:
124/// this tick's frame clock, the active theme's paced-loop cap, and whatever
125/// interval the previous paint's paced request asked for.
126///
127/// All three are shell-owned: [`now`](Self::now) is the platform frame clock
128/// (Choreographer / `CADisplayLink` timestamp — never a wall clock read inside
129/// `frust-core`), [`interval`](Self::interval) is `1 /
130/// MotionScheme::cosmetic_loop_rate` resolved from the *active* theme each
131/// frame (so an app that retunes the token via `ThemeBuilder` re-paces live),
132/// and [`requested_interval`](Self::requested_interval) is the previous paint's
133/// latched `PaintOutcome::paced_interval`. [`effective_interval`](Self::effective_interval)
134/// folds the last two into the one interval this tick actually paces at.
135#[derive(Debug, Clone, Copy)]
136pub struct FramePacing {
137 /// This tick's shell frame-clock reading. Only *differences* of two
138 /// [`FrameTime`]s from the same shell carry meaning (see [`FrameTime`]).
139 pub now: FrameTime,
140 /// The theme's paced-loop **cap**: `1 / cosmetic_loop_rate` from the active
141 /// theme, and so the shortest interval any paced ([`CosmeticLoop`]) frame
142 /// may be produced at. `CosmeticLoopRate` clamps its rate up to
143 /// `FLOOR_HZ` (10Hz), so this is never longer than 100ms.
144 ///
145 /// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
146 pub interval: std::time::Duration,
147 /// The tightest interval the previous paint's paced requests named
148 /// (`frust_core::PaintOutcome::paced_interval`, latched by the shell beside
149 /// [`FrameInputs::last_needs_frame_paced_only`]), or `None` when that paint
150 /// named none.
151 ///
152 /// `None` and `Some(Duration::ZERO)` both mean "at the theme's own rate" —
153 /// a bare `PaintCtx::request_frame_paced` folds `ZERO` into the core-side
154 /// MIN-lattice — so both resolve to [`interval`](Self::interval). A longer
155 /// value (a ~500ms caret blink against a 30Hz shimmer cap) paces that loop
156 /// slower than the theme's own cadence; see
157 /// [`effective_interval`](Self::effective_interval).
158 pub requested_interval: Option<std::time::Duration>,
159}
160
161impl FramePacing {
162 /// The interval this tick actually paces at: the **longer** of the theme's
163 /// cap ([`interval`](Self::interval)) and the previous paint's requested
164 /// interval ([`requested_interval`](Self::requested_interval)).
165 ///
166 /// The two-sided contract behind that `max` (the core-side half lives on
167 /// `PaintCtx::request_frame_paced_at`):
168 ///
169 /// - **The MIN fold already happened in core.** Every paced request in a
170 /// paint pass folds to the tightest interval there, so this sees one
171 /// value: the fastest cadence anything onscreen asked for. A 30Hz shimmer
172 /// beside a 2Hz caret arrives here as `ZERO` (the shimmer's bare request)
173 /// and paces at 30Hz — the caret is simply repainted more often than it
174 /// needs, which is invisible and costs no frame the shimmer wasn't
175 /// already forcing. A slow request can never starve a fast one.
176 /// - **The theme rate is a ceiling.** `cosmetic_loop_rate` caps decorative
177 /// motion for battery's sake, so a request *tighter* than the cap is
178 /// clamped up to it rather than honored. Motion that must land every
179 /// vsync is not cosmetic — it belongs to `TickClass::Transition`, which
180 /// is never paced at all.
181 pub fn effective_interval(&self) -> std::time::Duration {
182 match self.requested_interval {
183 Some(requested) => self.interval.max(requested),
184 None => self.interval,
185 }
186 }
187}
188
189/// The outcome of [`FrameGate::decide`]: whether the shell should run this
190/// frame's rebuild/layout/paint/encode/present passes, or skip them entirely.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum FrameDecision {
193 /// Run the frame: at least one input signalled a change (or the gate is
194 /// in its resume warmup, or the gate is disabled).
195 Run,
196 /// Skip the frame: nothing changed. The continuous Choreographer/
197 /// `CADisplayLink` loop keeps re-posting callbacks — only frame
198 /// *production* stops (see the module docs).
199 Skip,
200}
201
202impl FrameDecision {
203 /// Whether this decision is [`FrameDecision::Run`].
204 pub fn is_run(self) -> bool {
205 matches!(self, FrameDecision::Run)
206 }
207
208 /// Whether this decision is [`FrameDecision::Skip`].
209 pub fn is_skip(self) -> bool {
210 matches!(self, FrameDecision::Skip)
211 }
212}
213
214/// The per-frame OR-list a shell gathers and hands to [`FrameGate::decide`].
215///
216/// Every field is a "something that needs this frame to run" signal; the gate
217/// runs the frame if **any** is `true`, with three deliberate deltas noted
218/// in the field docs:
219///
220/// - **[`focus_or_ime_changed`](Self::focus_or_ime_changed)** is an *edge*, not
221/// a level: it reports that the focus/IME session moved since the shell last
222/// looked, so a steady focus session no longer forces a frame every vsync
223/// (and, alone among the wake inputs, it does not disqualify pacing).
224///
225/// - **Semantics adapter needs** is *not* a field here:
226/// semantics-publish gating stays shell-side (both Android and iOS are
227/// generation-gated via `AppTree::semantics_if_changed`), so it never gates
228/// whole-frame production.
229/// - **[`resumed_recently`](Self::resumed_recently)** is the added input for
230/// the resume warmup (see [`WARMUP_FRAMES`]); the gate also drives this same
231/// condition internally via [`FrameGate::note_resumed`], so a shell may
232/// leave the field `false` and rely on the counter (both force a `Run`).
233///
234/// `Default` is all-`false` (the "nothing changed" baseline a
235/// [`FrameGate::decide`] turns into a [`FrameDecision::Skip`]).
236#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
237pub struct FrameInputs {
238 /// *tracked-signal dirty*. A tracked signal changed since the last
239 /// frame. Source: `frust_reactive::ReactiveRuntime::take_signals_dirty`,
240 /// read by the shell *after* its per-frame `pump_local` per
241 /// that crate's pump-first ordering contract.
242 pub signals_dirty: bool,
243 /// *events dispatched since last frame*. A pointer/scroll/key/IME
244 /// event reached `RenderRoot::event` between frames. Source: a shell-side
245 /// latch set by the `nativeOnTouch`/IME entry points and cleared each
246 /// frame.
247 pub events_since_last_frame: bool,
248 /// *active pointer capture*. A gesture is mid-drag and the captured
249 /// widget may animate/track the pointer. Source:
250 /// `AppTree`/`RenderRoot::is_pointer_captured`.
251 pub pointer_capture_active: bool,
252 /// *focus/IME session moved*. The root's focus flag or its published IME
253 /// surface changed since the shell last **produced a frame** — an **edge**,
254 /// not a level. Source:
255 /// [`AppTree::focus_ime_generation`](crate::AppTree::focus_ime_generation)
256 /// compared against the shell's cached copy, which the shell commits only
257 /// once the gate has decided to run this frame (a *peek* at gather time —
258 /// see the deferral note at the end of this doc).
259 ///
260 /// **Why an edge.** This was a *level* input
261 /// (`RenderRoot::is_focus_active || ime_state().is_some()`) — the
262 /// original conservative default. Because it forces a `Run` through
263 /// [`any_set`](Self::any_set) *and* disqualified
264 /// [`is_paced_only_frame`](Self::is_paced_only_frame), any screen holding
265 /// root focus rendered every single vsync for as long as the focus lasted,
266 /// and caret pacing was unreachable while a caret blinked: measured at
267 /// 62–120 fps on a static screen whose only live input was focus (Xiaomi
268 /// 12). As an edge it still forces one frame per transition (focus gained /
269 /// lost, IME surface published / cleared) while a *steady* focus session
270 /// leaves the gate free to idle or pace.
271 ///
272 /// **Why one frame is enough.** Every IME event the platform delivers also
273 /// trips [`events_since_last_frame`](Self::events_since_last_frame) (both
274 /// shells latch it in their `ime_apply` entry points), and the per-frame
275 /// platform IME reconcile (Kotlin `doFrame` / Swift `renderFrame`) polls the
276 /// *published Rust state* on its own cadence, independent of whether Rust
277 /// produced a frame — all it needs is that state to be current, which the
278 /// edge guarantees by forcing the frame after every change.
279 ///
280 /// Unlike the other wake inputs this one is **not** an
281 /// [`is_paced_only_frame`](Self::is_paced_only_frame) disqualifier, so an
282 /// edge landing on a tick whose only other dirtiness is a paced loop is
283 /// consumed by whichever tick's pacing decision resolves to `Run`: the
284 /// repaint then lands with the loop's next paced frame (bounded by one
285 /// `cosmetic_loop_rate` interval), and the platform's IME poll is unaffected
286 /// either way. This is exactly why the shells peek the generation rather
287 /// than draining it at gather time — an edge that a Skip erased would make
288 /// the next repaint wait out the loop's *full* effective interval instead of
289 /// the bound below. A *per-request*
290 /// paced interval ([`FramePacing::requested_interval`]) never widens that
291 /// bound — [`FrameGate::decide_paced`] tightens an edge-carrying tick back
292 /// to the theme cap on purpose, so a 500ms caret cannot turn a focus
293 /// transition into a 500ms lag.
294 pub focus_or_ime_changed: bool,
295 /// *last paint's `needs_frame`*. The previous paint advanced an
296 /// animation/transition and asked for another frame. Source:
297 /// `PaintOutcome::needs_frame`, latched by the shell from the prior
298 /// frame's `AppTree::paint` return.
299 pub last_needs_frame: bool,
300 /// *last paint's `needs_frame_paced_only`*: the prior frame's frame request
301 /// aggregated to [`frust_core::TickClass::CosmeticLoop`] alone — a pacable
302 /// decorative loop with no concurrent transition. Latched by the shell from
303 /// `PaintOutcome::needs_frame_paced_only`. Meaningful only alongside
304 /// [`last_needs_frame`](Self::last_needs_frame); with every *other* input
305 /// clear ([`is_paced_only_frame`](Self::is_paced_only_frame)) it is the sole
306 /// signal [`FrameGate::decide_paced`] throttles to the theme's cadence. Any
307 /// concurrent transition/input clears it, so the frame runs immediately.
308 pub last_needs_frame_paced_only: bool,
309 /// *pending `ChangeFlags`*. A rebuild (or `set_theme`) left layout/
310 /// paint dirtiness undrained. Source:
311 /// [`AppTree::has_pending_change_flags`](crate::AppTree::has_pending_change_flags)
312 /// (a non-draining peek, so a skipped frame preserves the flags).
313 pub change_flags_pending: bool,
314 /// *deferred callbacks owed a flush*. A widget queued a state-bearing
315 /// callback during a state-free pass and raised
316 /// [`frust_core::mark_pending_result_flush`], which only
317 /// `RenderRoot::rebuild` can drain (it holds the `&mut State` the callback
318 /// needs). Source: [`frust_core::has_pending_result_flush`], the
319 /// non-draining peek — draining stays the rebuild's job on a frame that
320 /// actually runs.
321 ///
322 /// Hardening under the default-to-run rule rather than a reproduced stall:
323 /// every mark raised today is *also* covered by another input (a mark from
324 /// inside a rebuild is drained by that same rebuild, whose leftover
325 /// `pending |= PAINT` reaches [`change_flags_pending`](Self::change_flags_pending)
326 /// and whose `deferred_frame` reaches [`last_needs_frame`](Self::last_needs_frame);
327 /// `frust-widgets`' paint-time long-press latch pairs its mark with a
328 /// `request_frame`). This input closes the general case those two happen to
329 /// cover — a mark raised with nothing else dirty must never wait for the
330 /// next stray touch.
331 pub deferred_callbacks_pending: bool,
332 /// *theme-override/appearance change*. The app-facing theme override
333 /// or the platform light/dark preference changed this tick. Source: the
334 /// shell's per-frame `ThemeOverrideWatcher`/appearance poll (see
335 /// [`crate::theme_override`]).
336 pub theme_or_appearance_changed: bool,
337 /// *surface resize/recreation*. The GPU surface was created, resized,
338 /// or recreated (rotation/backgrounding). Source: the shell's
339 /// `nativeOnSurfaceChanged`/`frust_resize` path.
340 pub surface_changed_or_resized: bool,
341 /// *accessibility actions*. A platform `accesskit_*` action was
342 /// performed this tick, mutating state. Source: the shell's a11y-action
343 /// drain feeding `AppTree::perform_accessibility_action`.
344 pub a11y_action_performed: bool,
345 /// Added input: *resume warmup*. The app resumed / the surface was
346 /// (re)created within the last [`WARMUP_FRAMES`] frames. A shell may set
347 /// this explicitly, or leave it `false` and let [`FrameGate::note_resumed`]
348 /// drive the same condition through the gate's own countdown — both force
349 /// a `Run`.
350 pub resumed_recently: bool,
351}
352
353impl FrameInputs {
354 /// Whether any input signals that this frame must run. The gate ORs the
355 /// full field set — the single decision rule the whole type exists to
356 /// feed (see [`FrameGate::decide`]).
357 pub fn any_set(&self) -> bool {
358 self.signals_dirty
359 || self.events_since_last_frame
360 || self.pointer_capture_active
361 || self.focus_or_ime_changed
362 || self.last_needs_frame
363 || self.last_needs_frame_paced_only
364 || self.change_flags_pending
365 || self.deferred_callbacks_pending
366 || self.theme_or_appearance_changed
367 || self.surface_changed_or_resized
368 || self.a11y_action_performed
369 || self.resumed_recently
370 }
371
372 /// Whether the *only* dirtiness this frame is a paced ([`CosmeticLoop`])
373 /// frame request — the throttleable case [`FrameGate::decide_paced`] paces.
374 ///
375 /// True iff [`last_needs_frame`](Self::last_needs_frame) and
376 /// [`last_needs_frame_paced_only`](Self::last_needs_frame_paced_only) are
377 /// both set and **every other** OR-list input is clear. Any
378 /// input/signal/change-flag/transition alongside it makes this `false`, so
379 /// the gate runs the frame immediately rather than pacing it (the
380 /// default-to-run rule — see `docs/CODE_STANDARDS.md`'s Frame-Gate
381 /// conventions).
382 ///
383 /// One deliberate exception:
384 /// [`focus_or_ime_changed`](Self::focus_or_ime_changed) is **not** a
385 /// disqualifier. Its level-input predecessor was one, and — being true for
386 /// a whole focus session — that is what made caret pacing unreachable: a
387 /// blinking caret in a focused field is precisely the paced loop this gate
388 /// must be able to throttle (that field's doc has the device measurement).
389 /// The edge that replaced it reports one transition, not a session, so an
390 /// edge arriving mid-loop is simply carried by the loop's next paced frame
391 /// instead of pre-empting it. It is one repaint *per transition*, not
392 /// per tick: the shells peek it, so it keeps reporting across every skipped
393 /// tick in between and is cleared by the frame that finally runs.
394 ///
395 /// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
396 pub fn is_paced_only_frame(&self) -> bool {
397 self.last_needs_frame
398 && self.last_needs_frame_paced_only
399 && !self.signals_dirty
400 && !self.events_since_last_frame
401 && !self.pointer_capture_active
402 && !self.change_flags_pending
403 && !self.deferred_callbacks_pending
404 && !self.theme_or_appearance_changed
405 && !self.surface_changed_or_resized
406 && !self.a11y_action_performed
407 && !self.resumed_recently
408 }
409}
410
411/// The per-shell skip-frame gate: a plain struct — no
412/// globals — a shell constructs once and drives each frame via
413/// [`decide`](Self::decide).
414///
415/// Owns two pieces of state: whether the gate is enabled at all (the
416/// [`NO_FRAME_GATE_VAR`] kill switch, resolved once at construction) and the
417/// resume-warmup countdown ([`WARMUP_FRAMES`], seeded by
418/// [`note_resumed`](Self::note_resumed)) — the standalone, host-testable
419/// decision type the mobile shells wire in.
420#[derive(Debug)]
421pub struct FrameGate {
422 /// When `false`, [`decide`](Self::decide) always returns
423 /// [`FrameDecision::Run`] — the kill switch and [`disabled`](Self::disabled)
424 /// path, pre-gate behavior verbatim.
425 enabled: bool,
426 /// When `false`, [`decide_paced`](Self::decide_paced) never throttles a
427 /// paced-only frame (it runs on its vsync as before) — the
428 /// [`NO_ANIM_PACING_VAR`] kill switch, resolved once at construction. The
429 /// whole-frame skip gate stays active regardless.
430 anim_pacing: bool,
431 /// Frames left in the resume-warmup window; while `> 0`,
432 /// [`decide`](Self::decide) forces a `Run` and decrements it.
433 warmup_remaining: u8,
434 /// The frame clock reading of the last *produced* frame while pacing, used
435 /// to measure the paced-loop interval. `None` until the first paced
436 /// decision; re-anchored to `now` on every produced frame (see
437 /// [`decide_paced`](Self::decide_paced)).
438 last_paced_run: Option<FrameTime>,
439 /// The paced interval in force at the last produced frame — the cadence
440 /// [`last_paced_run`](Self::last_paced_run) was anchored *for*.
441 ///
442 /// Per-request intervals ([`FramePacing::requested_interval`]) make the
443 /// active interval a per-tick value rather than a constant, and
444 /// [`anchor_paced`](Self::anchor_paced)'s drift-free `last + interval`
445 /// arithmetic is only meaningful while that value holds still. Comparing
446 /// against this is how the anchor detects a changed cadence and re-anchors
447 /// to `now` instead — "the anchor adopts the interval in force at the last
448 /// Run". `None` until the first pacing-aware decision.
449 last_paced_interval: Option<std::time::Duration>,
450 /// Consecutive decisions that produced a frame carrying a transition-class
451 /// continuation — see [`CONTINUOUS_RUN_FRAMES`]. Any `Skip`, or a `Run`
452 /// without that continuation, resets it to zero. Bookkeeping only: no
453 /// decision reads it.
454 continuation_streak: u32,
455}
456
457impl FrameGate {
458 /// A gate honoring the [`NO_FRAME_GATE_VAR`] kill switch — what every
459 /// shell constructs. When the variable is set (compile-time `--define` or
460 /// runtime env, any non-`"0"` value), this is equivalent to
461 /// [`disabled`](Self::disabled).
462 pub fn new() -> Self {
463 Self::with_flags(!kill_switch_engaged(), !anim_pacing_kill_switch_engaged())
464 }
465
466 /// A gate that always [`Run`](FrameDecision::Run)s regardless of inputs —
467 /// the explicit disabled/kill-switch form (and a test seam bypassing the
468 /// env read). Mirrors [`FrameGate::new`]'s behavior when
469 /// [`NO_FRAME_GATE_VAR`] is set.
470 pub fn disabled() -> Self {
471 Self::with_flags(false, false)
472 }
473
474 /// Construct with an explicit enabled flag, bypassing the env read — the
475 /// test/advanced seam (mirrors [`crate::perf::FrameStats::new_enabled`]).
476 /// Animation pacing follows `enabled` (a disabled gate never paces because
477 /// it never skips); use [`with_flags`](Self::with_flags) to vary the two
478 /// independently.
479 pub fn with_enabled(enabled: bool) -> Self {
480 Self::with_flags(enabled, enabled)
481 }
482
483 /// Construct with explicit `enabled` (whole-frame skip) and `anim_pacing`
484 /// (paced-loop throttling) flags, bypassing both env reads — the test seam
485 /// for the pacing behavior in isolation.
486 pub fn with_flags(enabled: bool, anim_pacing: bool) -> Self {
487 Self {
488 enabled,
489 anim_pacing,
490 warmup_remaining: 0,
491 last_paced_run: None,
492 last_paced_interval: None,
493 continuation_streak: 0,
494 }
495 }
496
497 /// Whether the gate is active (can ever return [`FrameDecision::Skip`]).
498 /// `false` for a [`disabled`](Self::disabled) gate or when the kill switch
499 /// is engaged.
500 pub fn is_enabled(&self) -> bool {
501 self.enabled
502 }
503
504 /// Open the resume-warmup window: the next [`WARMUP_FRAMES`]
505 /// [`decide`](Self::decide) calls force a [`FrameDecision::Run`].
506 ///
507 /// A shell calls this on resume and on surface (re)creation, where the
508 /// first tick's change signals may not yet be observable (see
509 /// [`WARMUP_FRAMES`]).
510 pub fn note_resumed(&mut self) {
511 self.warmup_remaining = WARMUP_FRAMES;
512 }
513
514 /// Frames left in the resume-warmup window (`0` when not warming up).
515 /// Exposed for tests/diagnostics.
516 pub fn warmup_remaining(&self) -> u8 {
517 self.warmup_remaining
518 }
519
520 /// Decide whether this frame runs.
521 ///
522 /// Returns [`FrameDecision::Run`] when **any** of:
523 /// - the gate is disabled (kill switch / [`disabled`](Self::disabled)),
524 /// - the resume warmup is active (decrementing it by one), or
525 /// - any [`FrameInputs`] field is set ([`FrameInputs::any_set`]) —
526 ///
527 /// otherwise [`FrameDecision::Skip`]. See [`FrameInputs`]'s docs for what
528 /// each input signal means.
529 ///
530 /// Takes `&mut self` because it advances the resume-warmup countdown.
531 ///
532 /// This is the non-paced entry: a paced-only frame runs on every tick (no
533 /// throttling), the conservative pre-pacing behavior. Use
534 /// [`decide_paced`](Self::decide_paced) to honor the theme's cosmetic-loop
535 /// cadence.
536 pub fn decide(&mut self, inputs: FrameInputs) -> FrameDecision {
537 let decision = self.decide_impl(inputs, None);
538 self.note_continuation(&inputs, decision);
539 decision
540 }
541
542 /// Decide whether this frame runs, honoring animation pacing.
543 ///
544 /// Identical to [`decide`](Self::decide) except that when the *only*
545 /// dirtiness is a paced ([`CosmeticLoop`]) frame request
546 /// ([`FrameInputs::is_paced_only_frame`]) and pacing is enabled, the frame
547 /// is throttled to [`FramePacing::effective_interval`] (the theme cap folded
548 /// with the previous paint's requested interval): it runs only once
549 /// `pacing.now - last_paced_run >= interval`, otherwise [`Skip`]s. A skip
550 /// leaves `last_needs_frame` alive (the shell doesn't repaint, so it never
551 /// re-latches), so the gate keeps waking and never starves the loop; the
552 /// interval is re-anchored to the clock of every *produced* frame (whatever
553 /// its cause), so a transition frame mid-loop resets the cadence.
554 ///
555 /// Any non-paced input (an event, a signal write, a transition request,
556 /// pending change flags, …) makes [`is_paced_only_frame`] `false`, so the
557 /// frame runs immediately — pacing never delays real work.
558 ///
559 /// **The focus/IME edge tightens the tick back to the theme cap.** The one
560 /// wake input that rides *inside* a paced decision
561 /// ([`FrameInputs::focus_or_ime_changed`]) has its deferral bounded by the
562 /// interval in force, so honoring a long per-request interval on that tick
563 /// would stretch a focus/IME transition's repaint out to (say) a caret's
564 /// 500ms. Instead a tick carrying the edge paces at
565 /// [`FramePacing::interval`] — the theme's own cap — leaving the edge's
566 /// worst-case deferral exactly what it was before per-request intervals
567 /// existed: one `cosmetic_loop_rate` interval (33ms at the 30Hz default;
568 /// ≤100ms at `CosmeticLoopRate::FLOOR_HZ`). The cost is at most one extra
569 /// frame per focus/IME *transition* — an edge reporting one transition, not
570 /// a per-tick level (see `docs/LIMITATIONS.md`'s
571 /// `focus-ime-edge-paced-deferral`).
572 ///
573 /// That bound only holds end-to-end because the shells **peek** the edge:
574 /// the tightening itself resolves most edge-carrying ticks to `Skip` (the
575 /// anchor is typically one vsync old, well inside the cap), so a shell that
576 /// drained its generation cache at gather time would erase the edge on the
577 /// very tick the tightening deferred it, and the repaint would fall back to
578 /// the full [`FramePacing::effective_interval`]. The edge must keep being
579 /// reported until a tick actually runs — see
580 /// [`FrameInputs::focus_or_ime_changed`].
581 ///
582 /// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
583 /// [`Skip`]: FrameDecision::Skip
584 /// [`is_paced_only_frame`]: FrameInputs::is_paced_only_frame
585 pub fn decide_paced(&mut self, inputs: FrameInputs, pacing: FramePacing) -> FrameDecision {
586 let decision = self.decide_impl(inputs, Some(pacing));
587 self.note_continuation(&inputs, decision);
588 decision
589 }
590
591 /// Whether the frame loop is *continuous*: the last
592 /// [`CONTINUOUS_RUN_FRAMES`] decisions all produced a frame that an
593 /// in-flight transition-class animation asked for (see that constant).
594 ///
595 /// A report about the decisions already made, for a shell that scales
596 /// something to a running animation — the Android shell's performance-hint
597 /// session opens on it. It never feeds back into [`decide`](Self::decide).
598 pub fn is_continuous(&self) -> bool {
599 self.continuation_streak >= CONTINUOUS_RUN_FRAMES
600 }
601
602 /// The current run of continuation-carrying produced frames behind
603 /// [`is_continuous`](Self::is_continuous). Exposed for tests/diagnostics.
604 pub fn continuation_streak(&self) -> u32 {
605 self.continuation_streak
606 }
607
608 /// Advance or reset [`continuation_streak`](Self::continuation_streak)
609 /// after a decision: a `Run` whose inputs carry a transition-class
610 /// continuation extends it, anything else ends it.
611 fn note_continuation(&mut self, inputs: &FrameInputs, decision: FrameDecision) {
612 let continuation = inputs.last_needs_frame && !inputs.last_needs_frame_paced_only;
613 self.continuation_streak = if decision.is_run() && continuation {
614 self.continuation_streak.saturating_add(1)
615 } else {
616 0
617 };
618 }
619
620 /// The shared decision body behind [`decide`](Self::decide) (no pacing) and
621 /// [`decide_paced`](Self::decide_paced) (pacing context supplied).
622 fn decide_impl(&mut self, inputs: FrameInputs, pacing: Option<FramePacing>) -> FrameDecision {
623 if !self.enabled {
624 return FrameDecision::Run;
625 }
626 // Resume warmup: force a Run for the first WARMUP_FRAMES after a
627 // note_resumed(), independent of the FrameInputs the shell gathered.
628 let warming = self.warmup_remaining > 0;
629 if warming {
630 self.warmup_remaining -= 1;
631 }
632
633 // A paced-only frame (and pacing enabled, and not warming) is the sole
634 // throttleable case; every other Run resets the pace cadence to its
635 // own clock (see the anchor calls below).
636 let paced_case =
637 !warming && self.anim_pacing && pacing.is_some() && inputs.is_paced_only_frame();
638
639 if warming {
640 self.anchor_non_paced(pacing);
641 return FrameDecision::Run;
642 }
643
644 if paced_case {
645 let p = pacing.expect("paced_case implies pacing.is_some()");
646 // The interval in force for THIS tick: the theme cap folded with the
647 // previous paint's requested interval, tightened back to the bare cap
648 // when a focus/IME edge is riding along (see this method's docs — the
649 // edge's deferral bound must not widen with a slow per-request
650 // interval). The edge arm is literally `p.interval`: since
651 // `effective_interval() == max(cap, requested) >= p.interval`,
652 // tightening to the cap and `min`-ing with it are the same value —
653 // "ignore the request on an edge tick".
654 let interval = if inputs.focus_or_ime_changed {
655 p.interval
656 } else {
657 p.effective_interval()
658 };
659 return match self.last_paced_run {
660 // Inside the interval since the last produced frame: throttle.
661 // The shell leaves `last_needs_frame` set across a skip (no
662 // repaint re-latches it), so the gate keeps waking and never
663 // starves the loop.
664 Some(last) if p.now.saturating_sub(last) < interval => FrameDecision::Skip,
665 _ => {
666 self.anchor_paced(p.now, interval);
667 FrameDecision::Run
668 }
669 };
670 }
671
672 if inputs.any_set() {
673 // A non-paced Run (event/signal/transition/change-flag): reset the
674 // pace cadence to this real frame so a paced frame never fires
675 // immediately after one.
676 self.anchor_non_paced(pacing);
677 FrameDecision::Run
678 } else {
679 FrameDecision::Skip
680 }
681 }
682
683 /// Anchor the pace clock to this frame's exact clock — used for every
684 /// *non-paced* produced frame (warmup / event / transition), so the loop's
685 /// next interval is measured from the most recent real frame. Records the
686 /// interval that was in force too, so the next paced fire compares against a
687 /// current cadence rather than a stale one (see
688 /// [`last_paced_interval`](Self::last_paced_interval)).
689 fn anchor_non_paced(&mut self, pacing: Option<FramePacing>) {
690 if let Some(p) = pacing {
691 self.last_paced_run = Some(p.now);
692 self.last_paced_interval = Some(p.effective_interval());
693 }
694 }
695
696 /// Anchor the pace clock for a *paced* fire at `now`, for a loop running at
697 /// `interval`: advance by exactly one interval to hold a drift-free cadence
698 /// on the discrete vsync grid (anchoring to the raw `now`, which lands up to
699 /// a tick past the ideal fire time, would drift the effective rate below the
700 /// cap).
701 ///
702 /// Two cases re-anchor to `now` instead:
703 ///
704 /// - **A long stall** (≥ two intervals — a paused/resumed loop), so the loop
705 /// resumes at cadence rather than firing a catch-up burst.
706 /// - **A changed interval.** The drift-free arithmetic assumes a fixed
707 /// interval; with per-request intervals ([`FramePacing::requested_interval`])
708 /// the active one can change between paced frames, and advancing an anchor
709 /// laid down under the *old* cadence by the *new* interval is what produces
710 /// a double-fire on a shortening flip (the old anchor can already be
711 /// several new intervals in the past). Adopting `now` at the fire is both
712 /// the burst-free and the stall-free answer: the flip costs at most one
713 /// fresh interval of wait, never a missed cadence.
714 ///
715 /// **Overflow ceiling.** `interval` here always traces back to a widget's
716 /// [`frust_core::PaintCtx::request_frame_paced_at`], which clamps to
717 /// [`frust_core::PaintCtx::MAX_PACED_INTERVAL`] (10s) before it is ever
718 /// folded into `PaintOutcome::paced_interval` — so `interval * 2` and
719 /// `interval.as_nanos() as u64` below stay far below `Duration`/`u64`
720 /// overflow or truncation even at the widest legal input. This function
721 /// performs no clamp of its own; it relies entirely on that upstream
722 /// bound, the single entry point every paced interval flows through.
723 fn anchor_paced(&mut self, now: FrameTime, interval: std::time::Duration) {
724 let next = match (self.last_paced_run, self.last_paced_interval) {
725 (Some(last), Some(previous))
726 if previous == interval && now.saturating_sub(last) < interval * 2 =>
727 {
728 FrameTime::from_nanos(last.as_nanos().saturating_add(interval.as_nanos() as u64))
729 }
730 _ => now,
731 };
732 self.last_paced_run = Some(next);
733 self.last_paced_interval = Some(interval);
734 }
735}
736
737impl Default for FrameGate {
738 fn default() -> Self {
739 Self::new()
740 }
741}
742
743/// Disables the per-pod layout skip when set to any value
744/// (`RenderRoot::set_layout_skip(false)`): every pod lays out on every pass.
745pub const NO_LAYOUT_SKIP_VAR: &str = "FRUST_NO_LAYOUT_SKIP";
746
747/// Turns the layout audit on (`RenderRoot::set_layout_audit(true)`) when set to
748/// a value other than `"0"`. A debug tool: it runs a second, full layout pass
749/// per frame.
750pub const LAYOUT_AUDIT_VAR: &str = "FRUST_LAYOUT_AUDIT";
751
752/// Disables retained layers when set to any value
753/// (`RenderRoot::set_retained_layers(false)`): `PaintCtx::subtree_clean` and
754/// `PaintCtx::retained_available` always answer `false`, so a boundary widget
755/// repaints its body every frame.
756pub const NO_RETAINED_LAYERS_VAR: &str = "FRUST_NO_RETAINED_LAYERS";
757
758/// Where one knob's switch variable was found: the compile-time define
759/// (`option_env!`) and/or the process environment. `Default` is "set nowhere".
760#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
761pub struct KnobSource<'a> {
762 /// The compile-time define, if any.
763 pub compile: Option<&'a str>,
764 /// The runtime environment value, if any.
765 pub runtime: Option<&'a str>,
766}
767
768impl<'a> KnobSource<'a> {
769 /// Set only by the compile-time define.
770 pub fn compile(value: &'a str) -> Self {
771 Self {
772 compile: Some(value),
773 runtime: None,
774 }
775 }
776
777 /// Set only by the runtime environment.
778 pub fn runtime(value: &'a str) -> Self {
779 Self {
780 compile: None,
781 runtime: Some(value),
782 }
783 }
784}
785
786/// The layout-skip and retained-layer knobs a shell resolves once at startup and applies to
787/// its `RenderRoot` (directly, or through [`crate::AppTree`]).
788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789pub struct LayoutKnobs {
790 /// Value for `RenderRoot::set_layout_skip` (`false` when
791 /// [`NO_LAYOUT_SKIP_VAR`] is set).
792 pub skip: bool,
793 /// Value for `RenderRoot::set_layout_audit` (`true` when
794 /// [`LAYOUT_AUDIT_VAR`] is set non-`"0"`).
795 pub audit: bool,
796 /// Value for `RenderRoot::set_retained_layers` (`false` when
797 /// [`NO_RETAINED_LAYERS_VAR`] is set).
798 pub retained: bool,
799}
800
801impl LayoutKnobs {
802 /// Resolve the knobs from the compile-time defines and the process
803 /// environment (the same `option_env!` + runtime-env shape as the other
804 /// `FRUST_*` switches; Android/iOS have no useful runtime env, so the
805 /// compile-time define is their route).
806 pub fn from_env() -> Self {
807 let skip_var = std::env::var(NO_LAYOUT_SKIP_VAR).ok();
808 let audit_var = std::env::var(LAYOUT_AUDIT_VAR).ok();
809 let retained_var = std::env::var(NO_RETAINED_LAYERS_VAR).ok();
810 Self::resolve(
811 KnobSource {
812 compile: option_env!("FRUST_NO_LAYOUT_SKIP"),
813 runtime: skip_var.as_deref(),
814 },
815 KnobSource {
816 compile: option_env!("FRUST_LAYOUT_AUDIT"),
817 runtime: audit_var.as_deref(),
818 },
819 KnobSource {
820 compile: option_env!("FRUST_NO_RETAINED_LAYERS"),
821 runtime: retained_var.as_deref(),
822 },
823 )
824 }
825
826 /// The pure resolution [`Self::from_env`] wraps, unit-testable without
827 /// touching the process environment. Each argument names where one knob's
828 /// switch variable was found: `no_skip` ([`NO_LAYOUT_SKIP_VAR`]), `audit`
829 /// ([`LAYOUT_AUDIT_VAR`]) and `no_retained` ([`NO_RETAINED_LAYERS_VAR`]).
830 pub fn resolve(no_skip: KnobSource, audit: KnobSource, no_retained: KnobSource) -> Self {
831 Self {
832 skip: no_skip.compile.is_none() && no_skip.runtime.is_none(),
833 audit: kill_switch(audit.compile, audit.runtime),
834 retained: no_retained.compile.is_none() && no_retained.runtime.is_none(),
835 }
836 }
837
838 /// Apply to a type-erased app and log one info line.
839 pub fn apply_to_app(self, app: &mut dyn crate::AppTree) {
840 app.set_layout_skip(self.skip);
841 app.set_layout_audit(self.audit);
842 app.set_retained_layers(self.retained);
843 self.log();
844 }
845
846 /// Log the resolved knobs at info, once.
847 pub fn log(self) {
848 log::info!(
849 "layout skip={} audit={} retained={}",
850 self.skip,
851 self.audit,
852 self.retained
853 );
854 }
855}
856
857/// Reads the [`NO_FRAME_GATE_VAR`] kill switch from the compile-time define
858/// and the process environment, mirroring [`crate::perf::enabled`]'s
859/// `option_env!` + runtime-env pattern: either source set to a non-`"0"`
860/// value engages the switch.
861fn kill_switch_engaged() -> bool {
862 kill_switch(
863 option_env!("FRUST_NO_FRAME_GATE"),
864 std::env::var(NO_FRAME_GATE_VAR).ok().as_deref(),
865 )
866}
867
868/// Reads the [`NO_ANIM_PACING_VAR`] kill switch from the compile-time define and
869/// the process environment, the same `option_env!` + runtime-env shape as
870/// [`kill_switch_engaged`]. Public so a non-`FrameGate` consumer (the desktop
871/// shell, which paces via a delayed redraw rather than a skip gate) can honor
872/// the same switch. Either source set to a non-`"0"` value engages it.
873pub fn anim_pacing_kill_switch_engaged() -> bool {
874 kill_switch(
875 option_env!("FRUST_NO_ANIM_PACING"),
876 std::env::var(NO_ANIM_PACING_VAR).ok().as_deref(),
877 )
878}
879
880/// The pure decision [`kill_switch_engaged`] wraps: a non-empty, non-`"0"`
881/// value from either the compile-time or runtime source engages the switch.
882/// Split out so it is directly unit-testable without touching the process
883/// environment (see [`crate::perf`]'s `trace_switch`).
884fn kill_switch(compile_time: Option<&str>, runtime: Option<&str>) -> bool {
885 fn is_set_non_zero(value: Option<&str>) -> bool {
886 matches!(value, Some(v) if v != "0")
887 }
888 is_set_non_zero(compile_time) || is_set_non_zero(runtime)
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
896 fn layout_knobs_resolve() {
897 let unset = KnobSource::default();
898 let d = LayoutKnobs::resolve(unset, unset, unset);
899 assert!(d.skip && !d.audit && d.retained);
900 assert!(!LayoutKnobs::resolve(KnobSource::runtime("1"), unset, unset).skip);
901 assert!(!LayoutKnobs::resolve(KnobSource::compile("0"), unset, unset).skip);
902 assert!(LayoutKnobs::resolve(unset, KnobSource::runtime("1"), unset).audit);
903 assert!(!LayoutKnobs::resolve(unset, KnobSource::runtime("0"), unset).audit);
904 }
905
906 #[test]
907 fn retained_knob_resolves_from_either_source() {
908 let unset = KnobSource::default();
909 // Set to any value (like the layout-skip kill switch) turns it off.
910 assert!(!LayoutKnobs::resolve(unset, unset, KnobSource::runtime("1")).retained);
911 assert!(!LayoutKnobs::resolve(unset, unset, KnobSource::compile("1")).retained);
912 assert!(!LayoutKnobs::resolve(unset, unset, KnobSource::runtime("0")).retained);
913 // The other knobs are independent of it.
914 let k = LayoutKnobs::resolve(unset, unset, KnobSource::runtime("1"));
915 assert!(k.skip && !k.audit);
916 assert!(LayoutKnobs::resolve(KnobSource::runtime("1"), unset, unset).retained);
917 }
918
919 /// A `FrameInputs` with exactly one field set, by name — the driver for
920 /// the exhaustive per-input table test below.
921 fn only(field: &str) -> FrameInputs {
922 let mut i = FrameInputs::default();
923 match field {
924 "signals_dirty" => i.signals_dirty = true,
925 "events_since_last_frame" => i.events_since_last_frame = true,
926 "pointer_capture_active" => i.pointer_capture_active = true,
927 "focus_or_ime_changed" => i.focus_or_ime_changed = true,
928 "last_needs_frame" => i.last_needs_frame = true,
929 "last_needs_frame_paced_only" => i.last_needs_frame_paced_only = true,
930 "change_flags_pending" => i.change_flags_pending = true,
931 "deferred_callbacks_pending" => i.deferred_callbacks_pending = true,
932 "theme_or_appearance_changed" => i.theme_or_appearance_changed = true,
933 "surface_changed_or_resized" => i.surface_changed_or_resized = true,
934 "a11y_action_performed" => i.a11y_action_performed = true,
935 "resumed_recently" => i.resumed_recently = true,
936 other => panic!("unknown FrameInputs field {other:?}"),
937 }
938 i
939 }
940
941 /// Every input field, so the table test below is exhaustive by
942 /// construction — adding a field without listing it here fails the count
943 /// assertion.
944 const ALL_INPUTS: &[&str] = &[
945 "signals_dirty",
946 "events_since_last_frame",
947 "pointer_capture_active",
948 "focus_or_ime_changed",
949 "last_needs_frame",
950 "last_needs_frame_paced_only",
951 "change_flags_pending",
952 "deferred_callbacks_pending",
953 "theme_or_appearance_changed",
954 "surface_changed_or_resized",
955 "a11y_action_performed",
956 "resumed_recently",
957 ];
958
959 // -----------------------------------------------------------------
960 // kill_switch (pure — the env-reading wrapper's cache-free counterpart)
961 // -----------------------------------------------------------------
962
963 #[test]
964 fn kill_switch_off_when_neither_set() {
965 assert!(!kill_switch(None, None));
966 }
967
968 #[test]
969 fn kill_switch_on_when_compile_time_set_non_zero() {
970 assert!(kill_switch(Some("1"), None));
971 }
972
973 #[test]
974 fn kill_switch_on_when_runtime_set_non_zero() {
975 assert!(kill_switch(None, Some("1")));
976 }
977
978 #[test]
979 fn kill_switch_off_when_either_is_literal_zero_and_other_unset() {
980 assert!(!kill_switch(Some("0"), None));
981 assert!(!kill_switch(None, Some("0")));
982 }
983
984 #[test]
985 fn kill_switch_on_when_either_source_wins() {
986 assert!(kill_switch(Some("0"), Some("1")));
987 assert!(kill_switch(Some("1"), Some("0")));
988 }
989
990 // -----------------------------------------------------------------
991 // decide: the OR-list table
992 // -----------------------------------------------------------------
993
994 #[test]
995 fn each_single_input_forces_run() {
996 // Exhaustive: every field, set alone on a fresh (non-warming) gate,
997 // must force a Run — including the two that changed shape here: the
998 // focus/IME EDGE (`focus_or_ime_changed`, which still forces one frame
999 // per transition even though it no longer blocks pacing) and the
1000 // deferred-callback peek (`deferred_callbacks_pending`).
1001 assert_eq!(
1002 ALL_INPUTS.len(),
1003 12,
1004 "the OR-list must have all eleven wake inputs plus the \
1005 paced-only signal"
1006 );
1007 for field in ALL_INPUTS {
1008 let mut gate = FrameGate::with_enabled(true);
1009 let decision = gate.decide(only(field));
1010 assert_eq!(
1011 decision,
1012 FrameDecision::Run,
1013 "input {field:?} alone must force a Run"
1014 );
1015 }
1016 }
1017
1018 #[test]
1019 fn all_false_inputs_skip() {
1020 let mut gate = FrameGate::with_enabled(true);
1021 assert_eq!(
1022 gate.decide(FrameInputs::default()),
1023 FrameDecision::Skip,
1024 "no input set (and no warmup) must skip"
1025 );
1026 }
1027
1028 #[test]
1029 fn any_set_matches_decide_for_all_false() {
1030 let inputs = FrameInputs::default();
1031 assert!(!inputs.any_set());
1032 }
1033
1034 // -----------------------------------------------------------------
1035 // decide: resume warmup countdown
1036 // -----------------------------------------------------------------
1037
1038 #[test]
1039 fn warmup_forces_run_for_n_frames_then_skips() {
1040 let mut gate = FrameGate::with_enabled(true);
1041 gate.note_resumed();
1042 assert_eq!(gate.warmup_remaining(), WARMUP_FRAMES);
1043
1044 // All-false inputs: only the warmup keeps these frames running.
1045 for frame in 0..WARMUP_FRAMES {
1046 assert_eq!(
1047 gate.decide(FrameInputs::default()),
1048 FrameDecision::Run,
1049 "warmup frame {frame} must run despite no inputs"
1050 );
1051 }
1052 // Warmup exhausted: the next all-false frame skips.
1053 assert_eq!(gate.warmup_remaining(), 0);
1054 assert_eq!(
1055 gate.decide(FrameInputs::default()),
1056 FrameDecision::Skip,
1057 "after the warmup window, an all-false frame skips again"
1058 );
1059 }
1060
1061 #[test]
1062 fn note_resumed_reopens_the_warmup_window() {
1063 let mut gate = FrameGate::with_enabled(true);
1064 gate.note_resumed();
1065 for _ in 0..WARMUP_FRAMES {
1066 gate.decide(FrameInputs::default());
1067 }
1068 assert_eq!(gate.warmup_remaining(), 0);
1069 // A second resume (e.g. surface recreated after backgrounding) reopens
1070 // the window.
1071 gate.note_resumed();
1072 assert_eq!(
1073 gate.decide(FrameInputs::default()),
1074 FrameDecision::Run,
1075 "a fresh note_resumed reopens the warmup window"
1076 );
1077 }
1078
1079 // -----------------------------------------------------------------
1080 // Kill switch / disabled
1081 // -----------------------------------------------------------------
1082
1083 #[test]
1084 fn disabled_gate_always_runs() {
1085 let mut gate = FrameGate::disabled();
1086 assert!(!gate.is_enabled());
1087 // All-false inputs, no warmup: a disabled gate still runs.
1088 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Run);
1089 // And keeps running frame after frame.
1090 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Run);
1091 }
1092
1093 #[test]
1094 fn enabled_gate_can_skip() {
1095 let mut gate = FrameGate::with_enabled(true);
1096 assert!(gate.is_enabled());
1097 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Skip);
1098 }
1099
1100 #[test]
1101 fn frame_decision_predicates() {
1102 assert!(FrameDecision::Run.is_run());
1103 assert!(!FrameDecision::Run.is_skip());
1104 assert!(FrameDecision::Skip.is_skip());
1105 assert!(!FrameDecision::Skip.is_run());
1106 }
1107
1108 // -----------------------------------------------------------------
1109 // decide_paced: animation pacing
1110 // -----------------------------------------------------------------
1111
1112 use std::time::Duration;
1113
1114 /// One vsync step of a `hz`-Hz refresh, in nanoseconds.
1115 fn step_nanos(hz: f64) -> u64 {
1116 (1_000_000_000.0 / hz) as u64
1117 }
1118
1119 /// The paced-only input: a prior paced (CosmeticLoop) frame request with
1120 /// every other OR-list signal clear — the sole case pacing throttles.
1121 fn paced_only() -> FrameInputs {
1122 FrameInputs {
1123 last_needs_frame: true,
1124 last_needs_frame_paced_only: true,
1125 ..FrameInputs::default()
1126 }
1127 }
1128
1129 /// A 30Hz cosmetic-loop interval, the framework default.
1130 fn interval_30hz() -> Duration {
1131 Duration::from_secs_f64(1.0 / 30.0)
1132 }
1133
1134 /// A pacing context naming no per-request interval: the theme cap alone —
1135 /// what every `request_frame_paced` (interval-less) caller produces, and the
1136 /// shape every pre-`request_frame_paced_at` test in this module drives.
1137 fn pacing(now: FrameTime, interval: Duration) -> FramePacing {
1138 FramePacing {
1139 now,
1140 interval,
1141 requested_interval: None,
1142 }
1143 }
1144
1145 /// A pacing context carrying a per-request interval, as a shell latches it
1146 /// from the previous paint's `PaintOutcome::paced_interval`.
1147 fn pacing_at(now: FrameTime, interval: Duration, requested: Duration) -> FramePacing {
1148 FramePacing {
1149 now,
1150 interval,
1151 requested_interval: Some(requested),
1152 }
1153 }
1154
1155 /// A ~2Hz caret blink — the motivating per-request interval, deliberately
1156 /// far slower than any theme's cosmetic-loop cap.
1157 fn interval_500ms() -> Duration {
1158 Duration::from_millis(500)
1159 }
1160
1161 #[test]
1162 fn is_paced_only_frame_requires_last_needs_frame_and_no_other_input() {
1163 assert!(paced_only().is_paced_only_frame());
1164 // paced flag without last_needs_frame is not a paced-only frame.
1165 let only_flag = FrameInputs {
1166 last_needs_frame_paced_only: true,
1167 ..FrameInputs::default()
1168 };
1169 assert!(!only_flag.is_paced_only_frame());
1170 // any concurrent input disqualifies pacing.
1171 let mut with_event = paced_only();
1172 with_event.events_since_last_frame = true;
1173 assert!(!with_event.is_paced_only_frame());
1174 // ...including the deferred-callback peek, which owes a rebuild.
1175 let mut with_flush = paced_only();
1176 with_flush.deferred_callbacks_pending = true;
1177 assert!(!with_flush.is_paced_only_frame());
1178
1179 // THE POINT OF THE EDGE: a STEADY focus session (a caret blinking in a
1180 // focused field — the edge is false because nothing moved since the
1181 // last tick) alongside a paced-only request now PACES. As the old level
1182 // input (`focus_or_ime_active`, true for the whole session) this was
1183 // disqualified, so a focused screen re-rendered every vsync forever and
1184 // the pacing arm was unreachable — 62–120 fps measured on a Xiaomi 12.
1185 let steady_focus = FrameInputs {
1186 focus_or_ime_changed: false,
1187 ..paced_only()
1188 };
1189 assert!(
1190 steady_focus.is_paced_only_frame(),
1191 "a steady focus session must not block paced-loop throttling"
1192 );
1193 let mut gate = FrameGate::with_flags(true, true);
1194 let interval = interval_30hz();
1195 // First paced frame runs (nothing to pace against yet) and anchors...
1196 assert!(
1197 gate.decide_paced(steady_focus, pacing(FrameTime::from_nanos(0), interval),)
1198 .is_run()
1199 );
1200 // ...and the very next 120Hz tick, still inside the 30Hz interval, is
1201 // throttled — the behavior a focused screen could never reach before.
1202 assert_eq!(
1203 gate.decide_paced(
1204 steady_focus,
1205 pacing(FrameTime::from_nanos(step_nanos(120.0)), interval),
1206 ),
1207 FrameDecision::Skip,
1208 "a paced loop under a steady focus session must throttle to the cap"
1209 );
1210
1211 // The transition edge itself is deliberately NOT a disqualifier (see
1212 // `is_paced_only_frame`'s docs): it is one-shot, so the repaint it asks
1213 // for lands with the loop's next paced frame rather than immediately.
1214 let edge_mid_loop = FrameInputs {
1215 focus_or_ime_changed: true,
1216 ..paced_only()
1217 };
1218 assert!(edge_mid_loop.is_paced_only_frame());
1219 // With no paced loop in flight it forces a Run like every other input
1220 // (`each_single_input_forces_run` above covers the isolated case).
1221 assert!(
1222 FrameInputs {
1223 focus_or_ime_changed: true,
1224 ..FrameInputs::default()
1225 }
1226 .any_set()
1227 );
1228 }
1229
1230 // -----------------------------------------------------------------
1231 // The focus/IME EDGE, driven the way a shell drives it
1232 // -----------------------------------------------------------------
1233
1234 /// The shells' cache mechanics, verbatim, in the two halves both mobile
1235 /// shells actually run: [`peek`](FocusEdgeCache::peek) at gather time (a
1236 /// non-mutating `generation != last_seen`, the value that feeds
1237 /// [`FrameInputs::focus_or_ime_changed`]) and
1238 /// [`commit`](FocusEdgeCache::commit) as the first statement past the
1239 /// `decide_paced(..).is_skip()` early return, i.e. only on a frame that
1240 /// actually runs.
1241 ///
1242 /// The split is the contract, not an implementation detail: this is the one
1243 /// OR-list input that is both consumed-on-read and *not* an
1244 /// [`FrameInputs::is_paced_only_frame`] disqualifier, so committing it at
1245 /// gather time erases any edge the pacing defers.
1246 ///
1247 /// Both mobile shells are target-gated (`#[cfg(target_os = ...)]`) so their
1248 /// own copies never compile on the host — this stands in for them, with
1249 /// `frust-core`'s generation tests (`focus_ime_generation_*`) pinning the
1250 /// other half: that the generation moves exactly once per real focus/IME
1251 /// transition and never on a same-value write.
1252 struct FocusEdgeCache {
1253 last_seen: u64,
1254 }
1255 impl FocusEdgeCache {
1256 fn new(seed: u64) -> Self {
1257 Self { last_seen: seed }
1258 }
1259 /// Gather-time peek: "has the session moved since the last frame this
1260 /// shell produced?" — never mutates, so a skipped tick keeps reporting
1261 /// the same pending edge.
1262 fn peek(&self, generation: u64) -> bool {
1263 generation != self.last_seen
1264 }
1265 /// Run-time commit: the edge has now been consumed by a frame that is
1266 /// actually being produced.
1267 fn commit(&mut self, generation: u64) {
1268 self.last_seen = generation;
1269 }
1270 }
1271
1272 #[test]
1273 fn focus_edge_fires_once_per_transition_and_goes_quiet() {
1274 // A scripted focus/IME session as `RenderRoot::focus_ime_generation`
1275 // reports it: idle, focus gained, steady blinking, IME published,
1276 // steady typing pause, IME cleared, blur, idle again. Each transition
1277 // bumps the generation exactly once; the steady stretches repeat it.
1278 let script: &[(u64, bool, &str)] = &[
1279 (0, false, "idle before anything is focused"),
1280 (0, false, "still idle"),
1281 (1, true, "focus gained"),
1282 (1, false, "steady focus (caret blinking)"),
1283 (1, false, "still steady"),
1284 (2, true, "IME surface published"),
1285 (2, false, "steady IME session"),
1286 (3, true, "IME surface cleared"),
1287 (4, true, "focus lost (blur)"),
1288 (4, false, "idle again"),
1289 ];
1290 let mut cache = FocusEdgeCache::new(0);
1291 let mut gate = FrameGate::with_enabled(true);
1292 for (generation, expected_edge, what) in script {
1293 let inputs = FrameInputs {
1294 focus_or_ime_changed: cache.peek(*generation),
1295 ..FrameInputs::default()
1296 };
1297 assert_eq!(
1298 inputs.focus_or_ime_changed, *expected_edge,
1299 "edge for {what:?}"
1300 );
1301 // With every other input clear, the decision follows the edge
1302 // exactly: one Run per transition, Skip through each steady stretch
1303 // — where the old level input ran every single tick.
1304 let expect = if *expected_edge {
1305 FrameDecision::Run
1306 } else {
1307 FrameDecision::Skip
1308 };
1309 let decision = gate.decide(inputs);
1310 assert_eq!(decision, expect, "decision for {what:?}");
1311 // The shells' commit site: only a produced frame consumes the edge.
1312 // Here every edge tick Runs (nothing paces it), so the cache
1313 // advances on exactly the transitions — one repaint each.
1314 if decision.is_run() {
1315 cache.commit(*generation);
1316 }
1317 }
1318 }
1319
1320 #[test]
1321 fn a_long_steady_focus_session_produces_no_frames() {
1322 // The regression this task closes, in the shape the device showed it: a
1323 // static screen holding focus, nothing else dirty, over a long tick
1324 // stream. The generation never moves, so the gate must produce ZERO
1325 // frames (it produced one per vsync — 62–120 fps — as a level input).
1326 let mut cache = FocusEdgeCache::new(7);
1327 let mut gate = FrameGate::with_enabled(true);
1328 let mut runs = 0usize;
1329 for _ in 0..600 {
1330 let inputs = FrameInputs {
1331 focus_or_ime_changed: cache.peek(7),
1332 ..FrameInputs::default()
1333 };
1334 if gate.decide(inputs).is_run() {
1335 cache.commit(7);
1336 runs += 1;
1337 }
1338 }
1339 assert_eq!(runs, 0, "a steady focus session must produce no frames");
1340 }
1341
1342 #[test]
1343 fn a_focus_edge_persists_across_paced_skips_and_lands_within_one_cap_interval() {
1344 // The end-to-end contract, driven exactly the way a mobile shell's
1345 // `frame()` drives it: PEEK the generation into `FrameInputs`, decide,
1346 // and commit the cache only past the `is_skip()` early return.
1347 //
1348 // Shape: a 120Hz tick stream, a caret loop naming its own 500ms cadence
1349 // (`PaintCtx::request_frame_paced_at`), and a focus/IME transition
1350 // landing one vsync into that interval. The edge tightening resolves
1351 // that tick to Skip — the loop's anchor is one tick old, far inside the
1352 // 33ms cap — which is precisely why the edge must NOT be consumed there:
1353 // a shell draining its cache at gather time erases it, and the repaint
1354 // then falls through to the caret's full 500ms interval (the pre-fix
1355 // behaviour, contrasted at the end of this test).
1356 let tick = step_nanos(120.0);
1357 let cap = interval_30hz();
1358 let at = |i: u64| FrameTime::from_nanos(i * tick);
1359 let caret = |i: u64| FramePacing {
1360 now: at(i),
1361 interval: cap,
1362 requested_interval: Some(interval_500ms()),
1363 };
1364 let inputs = |edge: bool| FrameInputs {
1365 focus_or_ime_changed: edge,
1366 ..paced_only()
1367 };
1368
1369 let mut gate = FrameGate::with_flags(true, true);
1370 let mut cache = FocusEdgeCache::new(5);
1371
1372 // Tick 0: the caret loop's own first paced frame, no edge — it anchors
1373 // the pace clock at t=0.
1374 assert!(!cache.peek(5));
1375 assert!(gate.decide_paced(inputs(false), caret(0)).is_run());
1376 cache.commit(5);
1377
1378 // The transition happens: the generation moves 5 -> 6. Every tick from
1379 // here until the loop's next produced frame PEEKS the same still-pending
1380 // edge — the persistence a drain-on-gather shell (and the pre-fix
1381 // `gather` helper this suite used) could not express — and the frame the
1382 // edge asked for lands on the first tick that runs, which is where it is
1383 // finally consumed.
1384 let mut landed_tick = None;
1385 for i in 1..120u64 {
1386 assert!(
1387 cache.peek(6),
1388 "the edge must still be pending at tick {i} — a Skip consumes nothing"
1389 );
1390 if gate.decide_paced(inputs(true), caret(i)).is_run() {
1391 cache.commit(6);
1392 landed_tick = Some(i);
1393 break;
1394 }
1395 }
1396 let landed_tick =
1397 landed_tick.expect("the deferred edge must be consumed by a tick that runs");
1398 let landed_ns = landed_tick * tick;
1399 assert!(
1400 landed_ns <= cap.as_nanos() as u64 + tick,
1401 "the edge repaint must land within one cap interval of the loop's \
1402 last produced frame (plus one tick of 120Hz grid rounding); landed \
1403 at {landed_ns}ns"
1404 );
1405 assert!(
1406 landed_ns < interval_500ms().as_nanos() as u64,
1407 "…and nowhere near the caret's own 500ms request"
1408 );
1409
1410 // Consumed exactly once: the next tick reports no edge, and the loop
1411 // goes straight back to its own slow cadence (the tightening is scoped
1412 // to the edge tick alone).
1413 assert!(!cache.peek(6), "a produced frame clears the edge");
1414 assert_eq!(
1415 gate.decide_paced(inputs(false), caret(landed_tick + 1)),
1416 FrameDecision::Skip
1417 );
1418
1419 // The contrast that makes the shell-side split load-bearing: the same
1420 // stream with the pre-fix shape — the generation cache committed at
1421 // GATHER time, whatever the decision — loses the edge on the very first
1422 // skipped tick, so the repaint waits out the caret's 500ms request.
1423 let mut drained_gate = FrameGate::with_flags(true, true);
1424 let mut drained_cache = FocusEdgeCache::new(5);
1425 let mut first_run_after_bump: Option<u64> = None;
1426 for i in 0..120u64 {
1427 let generation = if i >= 1 { 6 } else { 5 };
1428 let edge = drained_cache.peek(generation);
1429 drained_cache.commit(generation); // the pre-fix drain: unconditional
1430 if drained_gate.decide_paced(inputs(edge), caret(i)).is_run() && i >= 1 {
1431 first_run_after_bump = Some(i * tick);
1432 break;
1433 }
1434 }
1435 assert!(
1436 first_run_after_bump.is_some_and(|ns| ns >= interval_500ms().as_nanos() as u64),
1437 "drain-on-gather erases the deferred edge, so the repaint falls \
1438 through to the 500ms per-request interval; observed \
1439 {first_run_after_bump:?}ns"
1440 );
1441 }
1442
1443 #[test]
1444 fn a_warmup_run_commits_the_focus_edge_too() {
1445 // The commit-on-Run rule keys off `is_skip()` and nothing else, so it
1446 // needs no special-casing for the warmup path — which returns `Run`
1447 // *before* `any_set()`/`is_paced_only_frame` are ever consulted. A
1448 // pending edge riding a warmup frame is therefore consumed by it, once.
1449 let cap = interval_30hz();
1450 let mut gate = FrameGate::with_flags(true, true);
1451 let mut cache = FocusEdgeCache::new(1);
1452 gate.note_resumed();
1453
1454 // A focus/IME transition landing on the first post-resume tick.
1455 assert!(cache.peek(2));
1456 let decision = gate.decide_paced(
1457 FrameInputs {
1458 focus_or_ime_changed: cache.peek(2),
1459 ..paced_only()
1460 },
1461 pacing(FrameTime::from_nanos(0), cap),
1462 );
1463 assert_eq!(
1464 decision,
1465 FrameDecision::Run,
1466 "the resume warmup forces this frame to run"
1467 );
1468 assert_eq!(gate.warmup_remaining(), WARMUP_FRAMES - 1);
1469 // A Run is a Run: the shell commits here exactly as on any other.
1470 assert!(decision.is_run());
1471 cache.commit(2);
1472
1473 // Consumed once — the rest of the warmup window still runs (that is the
1474 // warmup's job), but it no longer carries an edge.
1475 assert!(!cache.peek(2), "the warmup frame consumed the edge");
1476 for i in 1..=u64::from(WARMUP_FRAMES - 1) {
1477 assert!(!cache.peek(2));
1478 assert!(
1479 gate.decide_paced(
1480 FrameInputs {
1481 focus_or_ime_changed: cache.peek(2),
1482 ..paced_only()
1483 },
1484 pacing(FrameTime::from_nanos(i * step_nanos(120.0)), cap),
1485 )
1486 .is_run()
1487 );
1488 cache.commit(2);
1489 }
1490 assert_eq!(gate.warmup_remaining(), 0);
1491 }
1492
1493 // -----------------------------------------------------------------
1494 // The deferred-callback flush peek
1495 // -----------------------------------------------------------------
1496
1497 #[test]
1498 fn a_marked_flush_alone_forces_a_run() {
1499 // The stall this input closes: a deferred state-bearing callback is
1500 // owed a `Housekeeping` broadcast that only `RenderRoot::rebuild` can
1501 // dispatch, and NOTHING else is dirty. Without this input the gate
1502 // skips, the rebuild never runs, and the callback waits for whatever
1503 // touch happens to arrive next.
1504 let mut gate = FrameGate::with_enabled(true);
1505 let owed = FrameInputs {
1506 deferred_callbacks_pending: true,
1507 ..FrameInputs::default()
1508 };
1509 assert_eq!(gate.decide(owed), FrameDecision::Run);
1510 // And it keeps forcing frames until the drain clears the mark — the
1511 // shell re-peeks every tick, so the input simply goes false.
1512 assert_eq!(gate.decide(owed), FrameDecision::Run);
1513 assert_eq!(
1514 gate.decide(FrameInputs::default()),
1515 FrameDecision::Skip,
1516 "once the rebuild drained the mark the gate idles again"
1517 );
1518 }
1519
1520 #[test]
1521 fn paced_only_stream_runs_at_the_cap_not_every_vsync() {
1522 // A 120Hz tick stream feeding paced-only inputs, capped at 30Hz, must
1523 // produce ~30 runs per simulated second (one every ~4 ticks).
1524 let mut gate = FrameGate::with_flags(true, true);
1525 let tick = step_nanos(120.0);
1526 let interval = interval_30hz();
1527
1528 let mut runs = 0usize;
1529 // 120 ticks == 1 simulated second.
1530 for i in 0..120u64 {
1531 let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
1532 if gate.decide_paced(paced_only(), pacing).is_run() {
1533 runs += 1;
1534 }
1535 }
1536 // 30Hz cap over 1s ⇒ ~30 runs. Allow ±2 for boundary rounding of the
1537 // 120→30 tick ratio.
1538 assert!(
1539 (28..=32).contains(&runs),
1540 "paced-only 120Hz stream should run ~30x/s, ran {runs}"
1541 );
1542 }
1543
1544 #[test]
1545 fn transition_input_mid_interval_runs_immediately() {
1546 // Pace a loop, then a transition (paced_only == false) arrives inside
1547 // the interval — it must run immediately, never wait for the cap.
1548 let mut gate = FrameGate::with_flags(true, true);
1549 let interval = interval_30hz();
1550
1551 // First paced frame runs and anchors the pace clock at t=0.
1552 let run0 = gate.decide_paced(paced_only(), pacing(FrameTime::from_nanos(0), interval));
1553 assert!(run0.is_run());
1554 // A tick well inside the 33ms interval, but carrying a transition
1555 // request (paced-only flag cleared) — runs immediately.
1556 let mut transition = FrameInputs {
1557 last_needs_frame: true,
1558 last_needs_frame_paced_only: false,
1559 ..FrameInputs::default()
1560 };
1561 // sanity: this is NOT a paced-only frame
1562 assert!(!transition.is_paced_only_frame());
1563 let decision = gate.decide_paced(
1564 transition,
1565 pacing(FrameTime::from_nanos(step_nanos(120.0)), interval),
1566 );
1567 assert_eq!(
1568 decision,
1569 FrameDecision::Run,
1570 "a transition mid-interval must run immediately, not pace"
1571 );
1572 // A plain event mid-interval likewise runs immediately.
1573 transition = FrameInputs {
1574 events_since_last_frame: true,
1575 ..FrameInputs::default()
1576 };
1577 assert_eq!(
1578 gate.decide_paced(
1579 transition,
1580 pacing(FrameTime::from_nanos(2 * step_nanos(120.0)), interval),
1581 ),
1582 FrameDecision::Run,
1583 "an event mid-interval must run immediately"
1584 );
1585 }
1586
1587 #[test]
1588 fn paced_skip_leaves_the_request_alive_and_never_starves() {
1589 // A long paced-only stream must keep producing frames at the cap — it
1590 // never stops running entirely (starvation guard).
1591 let mut gate = FrameGate::with_flags(true, true);
1592 let tick = step_nanos(120.0);
1593 let interval = interval_30hz();
1594
1595 let mut last_run_tick: Option<u64> = None;
1596 let mut max_gap = 0u64;
1597 let mut total_runs = 0usize;
1598 for i in 0..600u64 {
1599 // 5 simulated seconds
1600 let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
1601 if gate.decide_paced(paced_only(), pacing).is_run() {
1602 total_runs += 1;
1603 if let Some(prev) = last_run_tick {
1604 max_gap = max_gap.max(i - prev);
1605 }
1606 last_run_tick = Some(i);
1607 }
1608 }
1609 assert!(
1610 total_runs > 0,
1611 "paced stream must keep running (no starvation)"
1612 );
1613 // The gap between runs stays bounded near the 4-tick cap ratio — never
1614 // an unbounded stall.
1615 assert!(
1616 max_gap <= 5,
1617 "paced runs must stay periodic; observed max gap of {max_gap} ticks"
1618 );
1619 }
1620
1621 #[test]
1622 fn anim_pacing_kill_switch_runs_every_tick() {
1623 // Gate enabled (skips still work) but pacing disabled: a paced-only
1624 // stream runs every vsync, pre-pacing behavior.
1625 let mut gate = FrameGate::with_flags(true, false);
1626 let tick = step_nanos(120.0);
1627 let interval = interval_30hz();
1628 for i in 0..120u64 {
1629 let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
1630 assert_eq!(
1631 gate.decide_paced(paced_only(), pacing),
1632 FrameDecision::Run,
1633 "with pacing disabled every paced tick must run"
1634 );
1635 }
1636 }
1637
1638 #[test]
1639 fn disabled_gate_ignores_pacing() {
1640 // FRUST_NO_FRAME_GATE (disabled gate) forces Run regardless of pacing.
1641 let mut gate = FrameGate::disabled();
1642 for i in 0..10u64 {
1643 let pacing = pacing(
1644 FrameTime::from_nanos(i * step_nanos(120.0)),
1645 interval_30hz(),
1646 );
1647 assert_eq!(gate.decide_paced(paced_only(), pacing), FrameDecision::Run);
1648 }
1649 }
1650
1651 #[test]
1652 fn non_paced_decide_runs_paced_only_every_tick() {
1653 // The non-pacing `decide` entry never throttles: a paced-only frame is
1654 // just another `any_set` Run (conservative pre-pacing behavior).
1655 let mut gate = FrameGate::with_flags(true, true);
1656 for _ in 0..10 {
1657 assert_eq!(gate.decide(paced_only()), FrameDecision::Run);
1658 }
1659 }
1660
1661 // -----------------------------------------------------------------
1662 // Per-request paced intervals (`PaintCtx::request_frame_paced_at`)
1663 // -----------------------------------------------------------------
1664
1665 /// Count the runs a constant paced-only stream produces over `ticks` ticks
1666 /// of a 120Hz tick stream, at `interval` (theme cap) and `requested`
1667 /// (per-request, `None` for a bare `request_frame_paced`).
1668 fn paced_runs(
1669 gate: &mut FrameGate,
1670 ticks: u64,
1671 interval: Duration,
1672 requested: Option<Duration>,
1673 ) -> usize {
1674 let tick = step_nanos(120.0);
1675 (0..ticks)
1676 .filter(|i| {
1677 let now = FrameTime::from_nanos(i * tick);
1678 let p = FramePacing {
1679 now,
1680 interval,
1681 requested_interval: requested,
1682 };
1683 gate.decide_paced(paced_only(), p).is_run()
1684 })
1685 .count()
1686 }
1687
1688 #[test]
1689 fn effective_interval_folds_the_request_against_the_theme_cap() {
1690 let cap = interval_30hz();
1691 // No request named: the theme's own cadence, verbatim (every caller
1692 // before `request_frame_paced_at` existed).
1693 assert_eq!(pacing(FrameTime::ZERO, cap).effective_interval(), cap);
1694 // `Duration::ZERO` is what a bare `request_frame_paced` folds in — "at
1695 // the theme's own rate" — so it resolves identically to `None`.
1696 assert_eq!(
1697 pacing_at(FrameTime::ZERO, cap, Duration::ZERO).effective_interval(),
1698 cap
1699 );
1700 // A slower request is honored as asked.
1701 assert_eq!(
1702 pacing_at(FrameTime::ZERO, cap, interval_500ms()).effective_interval(),
1703 interval_500ms()
1704 );
1705 // A request TIGHTER than the cap is clamped to it: `cosmetic_loop_rate`
1706 // is a ceiling on decorative motion, not a target (motion that must
1707 // land every vsync is `TickClass::Transition`, never paced at all).
1708 assert_eq!(
1709 pacing_at(FrameTime::ZERO, cap, Duration::from_millis(8)).effective_interval(),
1710 cap
1711 );
1712 }
1713
1714 #[test]
1715 fn a_frame_requesting_33ms_and_500ms_paces_at_33ms() {
1716 // The MIN-lattice, driven through the REAL core-side fold: two paced
1717 // requests in one paint pass (a 30Hz shimmer and a 2Hz caret) aggregate
1718 // to the tightest, so the gate paces the frame at 33ms — the caret is
1719 // simply repainted more often than it needs (no visual harm, by
1720 // design), and the shimmer is never starved down to 2Hz.
1721 let mut paint =
1722 frust_core::PaintCtx::new(kurbo::Point::ZERO, kurbo::Size::new(100.0, 100.0));
1723 paint.request_frame_paced_at(Duration::from_millis(33));
1724 paint.request_frame_paced_at(interval_500ms());
1725 let requested = paint.paced_interval();
1726 assert_eq!(requested, Some(Duration::from_millis(33)));
1727
1728 // 1 simulated second at 120Hz against the 30Hz framework-default cap.
1729 let mut gate = FrameGate::with_flags(true, true);
1730 let runs = paced_runs(&mut gate, 120, interval_30hz(), requested);
1731 assert!(
1732 (28..=32).contains(&runs),
1733 "a 33ms+500ms frame must pace at 33ms (~30 runs/s), ran {runs}"
1734 );
1735 // The contrast that makes the fold load-bearing: the same stream with
1736 // ONLY the 500ms request paces at 2Hz. Keeping the 33ms requester is
1737 // what holds the frame at 30Hz.
1738 let mut slow_gate = FrameGate::with_flags(true, true);
1739 let slow_runs = paced_runs(&mut slow_gate, 120, interval_30hz(), Some(interval_500ms()));
1740 assert!(
1741 (1..=3).contains(&slow_runs),
1742 "the 500ms request alone should run ~2x/s, ran {slow_runs}"
1743 );
1744 }
1745
1746 #[test]
1747 fn a_500ms_request_paces_far_slower_than_the_theme_cap() {
1748 // The motivating case: a caret blink asking for 2Hz against a 30Hz cap
1749 // must produce ~2 frames per simulated second, not ~30.
1750 let mut gate = FrameGate::with_flags(true, true);
1751 let runs = paced_runs(&mut gate, 120, interval_30hz(), Some(interval_500ms()));
1752 assert!(
1753 (1..=3).contains(&runs),
1754 "a 500ms paced request should run ~2x/s, ran {runs}"
1755 );
1756 // ...and the same stream with no request named still runs at the cap,
1757 // so the slowdown is the request's doing and nothing else's.
1758 let mut cap_gate = FrameGate::with_flags(true, true);
1759 let cap_runs = paced_runs(&mut cap_gate, 120, interval_30hz(), None);
1760 assert!(
1761 (28..=32).contains(&cap_runs),
1762 "an interval-less paced stream still runs at the cap, ran {cap_runs}"
1763 );
1764 }
1765
1766 #[test]
1767 fn a_changed_interval_neither_bursts_nor_stalls_the_cadence() {
1768 // Cadence stability under a VARIABLE interval — the caveat the
1769 // drift-free `last + interval` anchor arithmetic would otherwise trip
1770 // on. The active interval flips 33ms <-> 500ms every simulated second
1771 // across paced frames; at each transition the anchor adopts the interval
1772 // in force at that Run, so:
1773 // * no BURST — two paced runs never land closer than the tighter of
1774 // the two intervals (minus one tick of grid rounding), and
1775 // * no STALL — they never land further apart than the slower interval
1776 // plus one tick.
1777 let tick = step_nanos(120.0);
1778 // The realistic flip: a shimmer (the bare theme-cap request, 33ms at the
1779 // 30Hz default) appearing and disappearing while a 500ms caret blinks —
1780 // the MIN fold hands the gate 33ms while both run, 500ms once only the
1781 // caret is left.
1782 let fast = interval_30hz();
1783 let slow = interval_500ms();
1784
1785 // Swept across every flip PHASE, because the pathological case is
1786 // phase-dependent: it needs the flip to land a *part* of an interval
1787 // after the last produced frame (the residue an anchor advanced under
1788 // the old cadence turns into a double-fire), which only some offsets
1789 // produce. The flip period is deliberately 137 ticks — not a whole
1790 // number of either interval — so the sweep walks the whole phase space
1791 // instead of locking onto one alignment.
1792 const FLIP_PERIOD: u64 = 137;
1793 for offset in 0..FLIP_PERIOD {
1794 let mut gate = FrameGate::with_flags(true, true);
1795 let mut last_run: Option<u64> = None;
1796 let mut min_gap_ns = u64::MAX;
1797 let mut max_gap_ns = 0u64;
1798 // 6 simulated seconds at 120Hz, flipping the request periodically.
1799 for i in 0..720u64 {
1800 let now_ns = i * tick;
1801 let shimmer_onscreen = ((i + offset) / FLIP_PERIOD).is_multiple_of(2);
1802 let requested = if shimmer_onscreen {
1803 Duration::ZERO
1804 } else {
1805 slow
1806 };
1807 let p = FramePacing {
1808 now: FrameTime::from_nanos(now_ns),
1809 interval: fast,
1810 requested_interval: Some(requested),
1811 };
1812 if gate.decide_paced(paced_only(), p).is_run() {
1813 if let Some(prev) = last_run {
1814 let gap = now_ns - prev;
1815 min_gap_ns = min_gap_ns.min(gap);
1816 max_gap_ns = max_gap_ns.max(gap);
1817 }
1818 last_run = Some(now_ns);
1819 }
1820 }
1821
1822 assert!(
1823 min_gap_ns + tick >= fast.as_nanos() as u64,
1824 "no burst (flip offset {offset}): the tightest observed gap \
1825 ({min_gap_ns}ns) must not undercut the fast interval by more \
1826 than one tick"
1827 );
1828 assert!(
1829 max_gap_ns <= slow.as_nanos() as u64 + tick,
1830 "no stall (flip offset {offset}): the widest observed gap \
1831 ({max_gap_ns}ns) must not exceed the slow interval by more than \
1832 one tick"
1833 );
1834 }
1835 }
1836
1837 #[test]
1838 fn a_shortening_interval_flip_never_double_fires() {
1839 // The exact shape the "anchor adopts the interval in force at the last
1840 // Run" rule exists for, on a real 120Hz grid: a 500ms caret loop whose
1841 // shimmer comes back (500ms -> 33ms) at a moment that is *part way*
1842 // through the old cadence. Advancing the old anchor by the NEW interval
1843 // there leaves it up to a full interval in the past, so the frame after
1844 // the flip fires on the very next tick — a visible double-fire.
1845 let cap = interval_30hz();
1846 let slow = interval_500ms();
1847 let mut gate = FrameGate::with_flags(true, true);
1848 let paced_at = |now_ns: u64, requested: Duration| FramePacing {
1849 now: FrameTime::from_nanos(now_ns),
1850 interval: cap,
1851 requested_interval: Some(requested),
1852 };
1853
1854 // A shimmer+caret frame (the MIN fold hands the gate the cap) anchors
1855 // the loop at t=0.
1856 assert!(
1857 gate.decide_paced(paced_only(), paced_at(0, Duration::ZERO))
1858 .is_run()
1859 );
1860 // The shimmer ends: only the 500ms caret is left. Two slow frames.
1861 assert!(
1862 gate.decide_paced(paced_only(), paced_at(500_000_000, slow))
1863 .is_run()
1864 );
1865 assert!(
1866 gate.decide_paced(paced_only(), paced_at(900_000_000, slow))
1867 .is_skip()
1868 );
1869 assert!(
1870 gate.decide_paced(paced_only(), paced_at(1_000_000_000, slow))
1871 .is_run()
1872 );
1873
1874 // The shimmer returns 58ms into the caret's 500ms interval — past the
1875 // cap, so this tick fires.
1876 assert!(
1877 gate.decide_paced(paced_only(), paced_at(1_058_333_333, Duration::ZERO))
1878 .is_run()
1879 );
1880 // The next three 120Hz ticks must all skip: the cadence restarts from
1881 // the frame just produced, not from the stale 500ms anchor.
1882 for (n, now_ns) in [1_066_666_666u64, 1_075_000_000, 1_083_333_333]
1883 .into_iter()
1884 .enumerate()
1885 {
1886 assert_eq!(
1887 gate.decide_paced(paced_only(), paced_at(now_ns, Duration::ZERO)),
1888 FrameDecision::Skip,
1889 "tick {n} after a shortening flip must not double-fire"
1890 );
1891 }
1892 // ...and exactly one cap interval later the loop resumes cadence (no
1893 // stall either).
1894 assert_eq!(
1895 gate.decide_paced(paced_only(), paced_at(1_091_666_666, Duration::ZERO)),
1896 FrameDecision::Run,
1897 "the fast cadence resumes one interval after the flip frame"
1898 );
1899 }
1900
1901 #[test]
1902 fn the_anchor_adopts_the_interval_in_force_at_the_last_run() {
1903 // The rule the test above measures, asserted directly on the two flips.
1904 let cap = Duration::from_millis(10);
1905 let fast = Duration::from_millis(33);
1906 let slow = interval_500ms();
1907 let mut gate = FrameGate::with_flags(true, true);
1908
1909 let at = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
1910 let paced = |now: FrameTime, requested: Duration| FramePacing {
1911 now,
1912 interval: cap,
1913 requested_interval: Some(requested),
1914 };
1915
1916 // t=0: first paced frame anchors at 33ms cadence.
1917 assert!(gate.decide_paced(paced_only(), paced(at(0), fast)).is_run());
1918 // t=20ms: inside the 33ms interval — throttled.
1919 assert!(
1920 gate.decide_paced(paced_only(), paced(at(20), fast))
1921 .is_skip()
1922 );
1923 // t=40ms: one 33ms interval elapsed — runs, cadence intact.
1924 assert!(
1925 gate.decide_paced(paced_only(), paced(at(40), fast))
1926 .is_run()
1927 );
1928
1929 // The loop now flips to 500ms (the shimmer ended; only a caret is left).
1930 // t=100ms is 60ms past the last run: well inside the NEW interval, so it
1931 // must throttle rather than fire on the stale 33ms cadence.
1932 assert!(
1933 gate.decide_paced(paced_only(), paced(at(100), slow))
1934 .is_skip()
1935 );
1936 // t=545ms — 500ms past the t=40ms(+33) anchor — fires, and re-anchors to
1937 // `now` because the interval changed.
1938 assert!(
1939 gate.decide_paced(paced_only(), paced(at(545), slow))
1940 .is_run()
1941 );
1942 // Next 500ms tick lands one full interval later, not sooner (no burst).
1943 assert!(
1944 gate.decide_paced(paced_only(), paced(at(800), slow))
1945 .is_skip()
1946 );
1947 assert!(
1948 gate.decide_paced(paced_only(), paced(at(1045), slow))
1949 .is_run()
1950 );
1951
1952 // Flip back to 33ms: the very next tick past one fast interval runs (no
1953 // stall waiting out the old 500ms cadence), then holds the fast cadence.
1954 assert!(
1955 gate.decide_paced(paced_only(), paced(at(1060), fast))
1956 .is_skip()
1957 );
1958 assert!(
1959 gate.decide_paced(paced_only(), paced(at(1080), fast))
1960 .is_run()
1961 );
1962 assert!(
1963 gate.decide_paced(paced_only(), paced(at(1090), fast))
1964 .is_skip(),
1965 "no double-fire immediately after a shortening flip"
1966 );
1967 assert!(
1968 gate.decide_paced(paced_only(), paced(at(1115), fast))
1969 .is_run()
1970 );
1971 }
1972
1973 #[test]
1974 fn a_focus_ime_edge_is_bounded_by_the_theme_cap_not_a_long_request() {
1975 // The decided bound for the one wake input that rides INSIDE a paced
1976 // decision: a focus/IME edge landing on a paced-only tick waits at most
1977 // one `cosmetic_loop_rate` interval — never the (much longer) per-request
1978 // interval a caret named. Without the tightening this edge would sit
1979 // behind a 500ms caret blink, a user-visible focus lag.
1980 //
1981 // This pins the GATE half of that contract and holds unchanged: it feeds
1982 // the edge to every tick by hand, which is what a shell must actually do
1983 // for the bound to be real. The shell half — peeking the generation and
1984 // committing it only on a Run, so a deferred edge survives the Skips in
1985 // between — is pinned by
1986 // `a_focus_edge_persists_across_paced_skips_and_lands_within_one_cap_interval`
1987 // above.
1988 let cap = interval_30hz();
1989 let mut gate = FrameGate::with_flags(true, true);
1990 let at = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
1991 let caret = |now: FrameTime| FramePacing {
1992 now,
1993 interval: cap,
1994 requested_interval: Some(interval_500ms()),
1995 };
1996 let edge = FrameInputs {
1997 focus_or_ime_changed: true,
1998 ..paced_only()
1999 };
2000
2001 // A 500ms caret loop, anchored by its first paced frame at t=0.
2002 assert!(gate.decide_paced(paced_only(), caret(at(0))).is_run());
2003 // t=20ms: still inside the 33ms cap, so even an edge waits (the bound is
2004 // ONE cap interval, not zero — this is the documented deferral).
2005 assert_eq!(
2006 gate.decide_paced(edge, caret(at(20))),
2007 FrameDecision::Skip,
2008 "an edge inside the cap interval is still absorbed by the pacing"
2009 );
2010 // t=40ms: one cap interval past the anchor — the edge's frame lands here
2011 // rather than at t=500ms.
2012 assert_eq!(
2013 gate.decide_paced(edge, caret(at(40))),
2014 FrameDecision::Run,
2015 "a focus/IME edge must not wait out a long per-request interval"
2016 );
2017 // Worst case, stated as the bound: the edge never waits longer than one
2018 // theme-cap interval from the loop's last produced frame.
2019 assert!(cap <= Duration::from_millis(100));
2020
2021 // With no edge, the same loop keeps its own slow cadence — the
2022 // tightening is scoped to the edge tick alone.
2023 assert_eq!(
2024 gate.decide_paced(paced_only(), caret(at(80))),
2025 FrameDecision::Skip,
2026 "the edge tick must not permanently re-tighten the caret's cadence"
2027 );
2028 assert_eq!(
2029 gate.decide_paced(paced_only(), caret(at(560))),
2030 FrameDecision::Run
2031 );
2032 }
2033
2034 /// A transition-class continuation: the previous paint asked for this
2035 /// frame, not only for a paced cosmetic loop.
2036 fn continuation() -> FrameInputs {
2037 FrameInputs {
2038 last_needs_frame: true,
2039 ..FrameInputs::default()
2040 }
2041 }
2042
2043 #[test]
2044 fn continuous_after_the_threshold_of_continuation_runs() {
2045 let mut gate = FrameGate::with_enabled(true);
2046 assert!(!gate.is_continuous(), "a fresh gate reports no regime");
2047 for run in 1..CONTINUOUS_RUN_FRAMES {
2048 assert_eq!(gate.decide(continuation()), FrameDecision::Run);
2049 assert_eq!(gate.continuation_streak(), run);
2050 assert!(!gate.is_continuous(), "{run} runs are below the threshold");
2051 }
2052 assert_eq!(gate.decide(continuation()), FrameDecision::Run);
2053 assert!(gate.is_continuous());
2054 // It stays continuous for as long as the animation keeps asking.
2055 assert_eq!(gate.decide(continuation()), FrameDecision::Run);
2056 assert!(gate.is_continuous());
2057 }
2058
2059 #[test]
2060 fn a_skip_or_a_run_without_a_continuation_ends_the_regime() {
2061 let mut gate = FrameGate::with_enabled(true);
2062 for _ in 0..CONTINUOUS_RUN_FRAMES {
2063 gate.decide(continuation());
2064 }
2065 assert!(gate.is_continuous());
2066 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Skip);
2067 assert!(!gate.is_continuous(), "a skipped tick ends the regime");
2068 assert_eq!(gate.continuation_streak(), 0);
2069
2070 for _ in 0..CONTINUOUS_RUN_FRAMES {
2071 gate.decide(continuation());
2072 }
2073 assert!(gate.is_continuous());
2074 // A frame forced by an event alone is produced, but no animation asked
2075 // for it.
2076 assert_eq!(
2077 gate.decide(only("events_since_last_frame")),
2078 FrameDecision::Run
2079 );
2080 assert!(
2081 !gate.is_continuous(),
2082 "a run without a continuation ends it"
2083 );
2084 }
2085
2086 #[test]
2087 fn a_paced_only_loop_is_never_continuous() {
2088 // Pacing off, so every paced-only tick runs: still not continuous —
2089 // only a transition-class request counts.
2090 let mut gate = FrameGate::with_flags(true, false);
2091 let interval = interval_30hz();
2092 for tick in 0..(CONTINUOUS_RUN_FRAMES as u64 * 4) {
2093 let now = FrameTime::from_nanos(tick * step_nanos(120.0));
2094 assert_eq!(
2095 gate.decide_paced(paced_only(), pacing(now, interval)),
2096 FrameDecision::Run
2097 );
2098 assert!(!gate.is_continuous());
2099 }
2100 }
2101
2102 #[test]
2103 fn continuity_is_reported_through_decide_paced_and_never_changes_a_decision() {
2104 let interval = interval_30hz();
2105 let mut reporting = FrameGate::with_flags(true, true);
2106 let mut reference = FrameGate::with_flags(true, true);
2107 let script = [
2108 continuation(),
2109 continuation(),
2110 FrameInputs::default(),
2111 paced_only(),
2112 continuation(),
2113 continuation(),
2114 continuation(),
2115 continuation(),
2116 only("signals_dirty"),
2117 paced_only(),
2118 ];
2119 for (tick, inputs) in script.into_iter().enumerate() {
2120 let now = FrameTime::from_nanos(tick as u64 * step_nanos(60.0));
2121 let decision = reporting.decide_paced(inputs, pacing(now, interval));
2122 // The reference gate never has its streak read: same decisions.
2123 assert_eq!(
2124 decision,
2125 reference.decide_paced(inputs, pacing(now, interval))
2126 );
2127 }
2128 // The streak was ended by the last two (non-continuation) ticks.
2129 assert!(!reporting.is_continuous());
2130 }
2131
2132 #[test]
2133 fn a_disabled_gate_still_reports_the_regime() {
2134 // The kill switch makes every tick run; the regime still follows the
2135 // inputs, so a shell's dependent behaviour does not flip with it.
2136 let mut gate = FrameGate::disabled();
2137 for _ in 0..CONTINUOUS_RUN_FRAMES {
2138 assert_eq!(gate.decide(continuation()), FrameDecision::Run);
2139 }
2140 assert!(gate.is_continuous());
2141 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Run);
2142 assert!(!gate.is_continuous());
2143 }
2144}