feat(perf): wire frame-rate limiter + adaptive chunking low-motion mode

- Wire 120 FPS FrameRateLimiter into run_event_loop via
  time_until_next_draw + mark_emitted
- Add low_motion support: 30 FPS cap via LOW_MOTION_MIN_FRAME_INTERVAL
- Add AdaptiveChunkingPolicy::set_low_motion() to force Smooth mode
- Add StreamingState::set_low_motion() to propagate to all block policies
- Tool spinner already freezes on first frame when low_motion is set

TODO_BACKEND.md §3, TODO_FIXES.md #4
This commit is contained in:
Hunter Bown
2026-04-27 19:33:52 -05:00
parent d4b9ccfdb3
commit a345a956aa
4 changed files with 145 additions and 1 deletions
+60 -1
View File
@@ -35,12 +35,17 @@ use std::time::Instant;
/// 120 FPS minimum frame interval (≈8.33ms).
pub const MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(8_333_334);
/// 30 FPS minimum frame interval (≈33.33ms) used in low-motion mode.
pub const LOW_MOTION_MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(33_333_333);
/// Remembers the most recent emitted draw, allowing deadlines to be clamped
/// forward so the next draw never lands sooner than `MIN_FRAME_INTERVAL`
/// after the last one.
#[derive(Debug, Default)]
pub struct FrameRateLimiter {
last_emitted_at: Option<Instant>,
/// When true, use the 30 FPS cap instead of 120 FPS.
low_motion: bool,
}
impl FrameRateLimiter {
@@ -52,7 +57,7 @@ impl FrameRateLimiter {
return requested;
};
let min_allowed = last_emitted_at
.checked_add(MIN_FRAME_INTERVAL)
.checked_add(self.interval())
.unwrap_or(last_emitted_at);
requested.max(min_allowed)
}
@@ -74,6 +79,19 @@ impl FrameRateLimiter {
Some(clamped - now)
}
}
/// Set low-motion mode: caps frame rate at 30 FPS instead of 120 FPS.
pub fn set_low_motion(&mut self, low_motion: bool) {
self.low_motion = low_motion;
}
fn interval(&self) -> Duration {
if self.low_motion {
LOW_MOTION_MIN_FRAME_INTERVAL
} else {
MIN_FRAME_INTERVAL
}
}
}
#[cfg(test)]
@@ -124,4 +142,45 @@ mod tests {
let well_past = t0 + Duration::from_millis(50);
assert!(limiter.time_until_next_draw(well_past).is_none());
}
#[test]
fn low_motion_clamps_to_30fps_interval() {
let t0 = Instant::now();
let mut limiter = FrameRateLimiter::default();
limiter.set_low_motion(true);
limiter.mark_emitted(t0);
let too_soon = t0 + Duration::from_millis(5);
// Under 30 FPS (~33.33 ms), a draw 5 ms after last emit is clamped.
assert_eq!(
limiter.clamp_deadline(too_soon),
t0 + LOW_MOTION_MIN_FRAME_INTERVAL
);
// After 34 ms, draw is allowed.
let after_34 = t0 + Duration::from_millis(34);
assert!(limiter.time_until_next_draw(after_34).is_none());
}
#[test]
fn low_motion_switching_respects_current_mode() {
let t0 = Instant::now();
let mut limiter = FrameRateLimiter::default();
// Default (120 FPS): mark at t0, 10 ms later is clamped to ~8.33ms
limiter.mark_emitted(t0);
let t10 = t0 + Duration::from_millis(10);
assert!(limiter.time_until_next_draw(t10).is_none()); // 10ms > 8.33ms
// Switch to low_motion; mark again
limiter.set_low_motion(true);
limiter.mark_emitted(t10);
let t20 = t10 + Duration::from_millis(10);
let remaining = limiter.time_until_next_draw(t20).unwrap();
// 30 FPS = 33.33 ms interval; 10ms elapsed → ~23.33 remaining
assert!(
remaining > Duration::from_millis(20) && remaining < Duration::from_millis(25),
"expected ~23.33ms remaining, got {remaining:?}"
);
}
}
+69
View File
@@ -90,6 +90,10 @@ pub struct AdaptiveChunkingPolicy {
mode: ChunkingMode,
below_exit_threshold_since: Option<Instant>,
last_catch_up_exit_at: Option<Instant>,
/// When true, the policy never enters `CatchUp` — it stays in `Smooth`
/// regardless of queue pressure, keeping the display calm for users who
/// prefer reduced visual churn.
low_motion: bool,
}
impl AdaptiveChunkingPolicy {
@@ -109,8 +113,31 @@ impl AdaptiveChunkingPolicy {
self.last_catch_up_exit_at = None;
}
/// When true, the policy never enters `CatchUp` — it stays in `Smooth`
/// regardless of queue pressure.
pub fn set_low_motion(&mut self, low_motion: bool) {
self.low_motion = low_motion;
if low_motion {
self.mode = ChunkingMode::Smooth;
self.below_exit_threshold_since = None;
self.last_catch_up_exit_at = None;
}
}
/// Computes a drain decision from the current queue snapshot.
pub fn decide(&mut self, snapshot: QueueSnapshot, now: Instant) -> ChunkingDecision {
// In low-motion mode, always use Smooth pacing regardless of queue
// pressure — the user asked for a calm, steady display.
if self.low_motion {
self.mode = ChunkingMode::Smooth;
self.below_exit_threshold_since = None;
return ChunkingDecision {
mode: self.mode,
entered_catch_up: false,
drain_plan: DrainPlan::Single,
};
}
if snapshot.queued_lines == 0 {
self.note_catch_up_exit(now);
self.mode = ChunkingMode::Smooth;
@@ -351,4 +378,46 @@ mod tests {
assert_eq!(severe.mode, ChunkingMode::CatchUp);
assert_eq!(severe.drain_plan, DrainPlan::Batch(64));
}
#[test]
fn low_motion_always_smooth_regardless_of_pressure() {
let mut policy = AdaptiveChunkingPolicy::new();
policy.set_low_motion(true);
let t0 = Instant::now();
// Queue depth far above ENTER threshold.
let d1 = policy.decide(snap(20, 10), t0);
assert_eq!(d1.mode, ChunkingMode::Smooth);
assert!(!d1.entered_catch_up);
assert_eq!(d1.drain_plan, DrainPlan::Single);
// Oldest age far above ENTER threshold.
let d2 = policy.decide(snap(5, 500), t0 + Duration::from_millis(100));
assert_eq!(d2.mode, ChunkingMode::Smooth);
assert!(!d2.entered_catch_up);
assert_eq!(d2.drain_plan, DrainPlan::Single);
// Severe backlog — still Smooth.
let d3 = policy.decide(snap(80, 500), t0 + Duration::from_millis(200));
assert_eq!(d3.mode, ChunkingMode::Smooth);
assert_eq!(d3.drain_plan, DrainPlan::Single);
}
#[test]
fn low_motion_reset_resumes_normal_operation() {
let mut policy = AdaptiveChunkingPolicy::new();
policy.set_low_motion(true);
let t0 = Instant::now();
// Low motion blocks catch-up.
let d1 = policy.decide(snap(20, 10), t0);
assert_eq!(d1.mode, ChunkingMode::Smooth);
// Turn off low motion — next burst should enter CatchUp.
policy.set_low_motion(false);
let d2 = policy.decide(snap(20, 10), t0 + Duration::from_millis(10));
assert_eq!(d2.mode, ChunkingMode::CatchUp);
assert!(d2.entered_catch_up);
assert_eq!(d2.drain_plan, DrainPlan::Batch(20));
}
}
+9
View File
@@ -491,6 +491,15 @@ impl StreamingState {
result
}
/// Propagate the low-motion flag to every block's chunking policy.
/// When true, all policies stay in `Smooth` regardless of queue pressure,
/// preventing CatchUp burst drains that would create sudden visual jumps.
pub fn set_low_motion(&mut self, low_motion: bool) {
for block in self.blocks.iter_mut().flatten() {
block.policy.set_low_motion(low_motion);
}
}
/// Check if any stream is still active
fn check_active(&mut self) {
self.is_active = self.blocks.iter().any(|b| {
+7
View File
@@ -954,6 +954,13 @@ async fn run_event_loop(
// redraw is needed but the limiter says we're inside the cooldown
// window, leave `needs_redraw = true` and shorten the poll timeout
// so the loop wakes up exactly when drawing is allowed.
// Sync low-motion flag into the frame-rate limiter and streaming
// chunking policy. Low-motion mode drops the frame cap to 30 FPS
// and forces Smooth-only chunking so the display stays calm.
frame_rate_limiter.set_low_motion(app.low_motion);
app.streaming_state.set_low_motion(app.low_motion);
let draw_wait = if app.needs_redraw {
frame_rate_limiter.time_until_next_draw(now)
} else {