// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Runtime performance guardrails. //! //! The idle/redraw model is efficient only if the app cooperates — a stuck //! [`App::is_animating`](crate::App::is_animating), an aggressive //! [`poll_interval`](crate::App::poll_interval), or continuous animation on //! the software backend all quietly burn CPU/battery. The docs warn about //! these, but the runtime can also **detect** them (opt-in dev diagnostics, //! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz //! on the software renderer, opt-out via //! [`App::cap_software_animation`](crate::App::cap_software_animation)). use std::sync::OnceLock; use std::time::Duration; use crate::time::Instant; /// A settled animation should return `false` well before this; staying `true` /// this long is almost always a forgotten `is_animating = false`. const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 ); /// Continuous software-rendered animation past this is worth flagging on /// mobile, where it means sustained CPU with no GPU offload. const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 ); /// Minimum gap between software-backend animation re-rasters (≈ 30 Hz). const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 ); /// A `poll_interval` shorter than this defeats the event-driven idle model. const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 ); /// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached. fn perf_warn_enabled() -> bool { static ENABLED: OnceLock = OnceLock::new(); *ENABLED.get_or_init( || std::env::var( "LTK_PERF_WARN" ) .map( |v| !v.is_empty() && v != "0" ) .unwrap_or( false ) ) } /// Wall time of one GLES raster, split at submission and at GPU completion. /// Opt-in via `LTK_PERF_RASTER`; the `glFinish` it adds stalls the pipeline. pub( crate ) struct RasterTrace { start: Instant, } impl RasterTrace { pub( crate ) fn begin() -> Option { static ENABLED: OnceLock = OnceLock::new(); let enabled = *ENABLED.get_or_init( || std::env::var( "LTK_PERF_RASTER" ) .map( |v| !v.is_empty() && v != "0" ) .unwrap_or( false ) ); enabled.then( || Self { start: Instant::now() } ) } pub( crate ) fn end( self, gl: &glow::Context, label: &str, pw: u32, ph: u32 ) { use glow::HasContext; let submitted = self.start.elapsed(); unsafe { gl.finish(); } let done = self.start.elapsed(); eprintln!( "[ltk][raster] {label} {pw}x{ph} cpu={:.2}ms gpu_done={:.2}ms", submitted.as_secs_f64() * 1000.0, done.as_secs_f64() * 1000.0, ); } } /// Per-runtime performance-guard state. Lives on `AppData`. pub struct PerfState { /// When the current continuous animation began (`None` while idle). animating_since: Option, /// Last software-backend animation re-raster, for the 30 Hz cap. last_anim_draw: Instant, warned_stuck: bool, warned_software_anim: bool, warned_poll: bool, } impl PerfState { pub fn new() -> Self { Self { animating_since: None, // Start in the past so the first animated frame is never throttled. // `checked_sub` guards against underflow shortly after boot. last_anim_draw: Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ), warned_stuck: false, warned_software_anim: false, warned_poll: false, } } /// Reset when the app stops animating, so the next animation starts fresh /// (and can warn again if it, too, gets stuck). pub fn animation_stopped( &mut self ) { self.animating_since = None; self.warned_stuck = false; self.warned_software_anim = false; } /// Called on every animated main-surface frame callback. Runs the opt-in /// diagnostics and applies the software animation-rate cap. Returns `true` /// if this frame should actually re-raster, `false` to throttle-skip it /// (the caller keeps the vsync cadence with a bare frame callback). pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool { let since = *self.animating_since.get_or_insert_with( Instant::now ); if perf_warn_enabled() { if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD { eprintln!( "[ltk][perf] App::is_animating() has stayed true for {}s — a settled \ animation must return false, or the loop redraws at the display rate \ forever (battery drain).", STUCK_THRESHOLD.as_secs(), ); self.warned_stuck = true; } if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD { eprintln!( "[ltk][perf] continuous animation on the software renderer — sustained \ CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \ or a GLES-capable compositor.", ); self.warned_software_anim = true; } } if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL { return false; } self.last_anim_draw = Instant::now(); true } /// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short /// enough to defeat the idle model. pub fn warn_poll_interval( &mut self, dur: Duration ) { if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD { eprintln!( "[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \ wake the loop from your worker with set_channel_sender instead of polling.", dur.as_millis(), ); self.warned_poll = true; } } } #[ cfg( test ) ] mod tests { use super::*; #[ test ] fn gles_animation_is_never_capped() { let mut p = PerfState::new(); // Two back-to-back frames on the GPU backend both re-raster. assert!( p.animated_frame( false, true ) ); assert!( p.animated_frame( false, true ) ); } #[ test ] fn software_animation_is_capped_between_rerasters() { let mut p = PerfState::new(); // First software frame draws; an immediate second is throttled // (< 33 ms since the last re-raster). assert!( p.animated_frame( true, true ) ); assert!( !p.animated_frame( true, true ) ); } #[ test ] fn software_cap_opt_out_keeps_full_rate() { let mut p = PerfState::new(); assert!( p.animated_frame( true, false ) ); assert!( p.animated_frame( true, false ) ); } #[ test ] fn stopping_resets_stuck_warning_state() { let mut p = PerfState::new(); let _ = p.animated_frame( false, true ); assert!( p.animating_since.is_some() ); p.animation_stopped(); assert!( p.animating_since.is_none() ); } }