ltk/event_loop/perf.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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, 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<bool> = OnceLock::new();
*ENABLED.get_or_init( ||
std::env::var( "LTK_PERF_WARN" )
.map( |v| !v.is_empty() && v != "0" )
.unwrap_or( false )
)
}
/// Per-runtime performance-guard state. Lives on `AppData`.
pub struct PerfState
{
/// When the current continuous animation began (`None` while idle).
animating_since: Option<Instant>,
/// 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() );
}
}