From add9bde426ab3914b59de9ef45b100f3468ca229 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:21:07 +0000 Subject: [PATCH] Back off idle background work in the GPUI app The native app polled at a fixed rate whether or not anyone was looking at it. Three changes cut that without delaying anything a user can see: - HID readers (openmouse-hid): an opt-in idle backoff. After two quiet seconds a reader adds a 100 ms wait, outside the device lock, so five open interfaces drop from about 650 wakeups a second to about 130. Every request marks activity and unparks the readers first, so replies are still read at the normal cadence. Off unless a host calls set_idle_backoff; only the GPUI app does, so the Tauri app behaves exactly as before. - Wallpaper watcher: only runs its checks while the Dynamic theme is selected (each check spawns osascript on macOS). Switching to Dynamic already resamples on its own. - Window activity: a shared flag set from the main window's activation events. While the window is not active the game process scan goes from 2 s to 10 s, the install scan from 10 s to 60 s and the wallpaper check from 2 s to 30 s. They slow rather than stop, so a game launching behind the window still applies its profile, and regaining focus wakes them immediately. --- crates/openmouse-hid/src/lib.rs | 82 ++++++++++++++++++++++- gpui/src/activity.rs | 114 ++++++++++++++++++++++++++++++++ gpui/src/app.rs | 9 ++- gpui/src/bin/main.rs | 3 + gpui/src/game_watcher.rs | 24 +++++-- gpui/src/hid.rs | 3 + gpui/src/lib.rs | 1 + gpui/src/theme.rs | 26 ++++++-- 8 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 gpui/src/activity.rs diff --git a/crates/openmouse-hid/src/lib.rs b/crates/openmouse-hid/src/lib.rs index e4c574b..cb508e7 100644 --- a/crates/openmouse-hid/src/lib.rs +++ b/crates/openmouse-hid/src/lib.rs @@ -61,7 +61,7 @@ //! exclusive mode is off) has no bearing on the freeze either way. use std::collections::{HashMap, HashSet}; use std::ffi::CString; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::LazyLock; use std::sync::Arc; use std::thread::{self, JoinHandle}; @@ -313,6 +313,66 @@ const READ_POLL_TIMEOUT_MS: i32 = 10; /// emitted, so it no longer delays report delivery by 5 ms. const READ_IDLE_GAP_MS: u64 = 5; +/// Opt-in idle backoff for the reader threads, off unless a host calls +/// [`set_idle_backoff`] (only the GPUI app does; the Tauri app is unchanged). +/// +/// A held-open interface otherwise wakes about 66 times a second per reader +/// for as long as the process runs, even with a mouse sitting untouched. With +/// the backoff on, a reader that has seen no report and no request for +/// [`IDLE_AFTER`] adds an [`IDLE_PARK`] wait after its usual gap, taken +/// outside the device lock like the gap, so a writer is never held up by it. +/// +/// Requests stay prompt: every request goes through `with_open_group`, which +/// marks activity and unparks the readers before it writes, so the reply is +/// read at the normal cadence rather than after a parked wait. Only an +/// unsolicited report arriving while idle can be delayed, by at most +/// [`IDLE_PARK`]; the OS queues it meanwhile and the reader drains it at the +/// normal cadence from then on. +static IDLE_BACKOFF: AtomicBool = AtomicBool::new(false); +static LAST_ACTIVITY_MS: AtomicU64 = AtomicU64::new(0); +static ACTIVITY_CLOCK: LazyLock = LazyLock::new(Instant::now); +const IDLE_AFTER: Duration = Duration::from_secs(2); +const IDLE_PARK: Duration = Duration::from_millis(100); + +/// Turns the idle backoff on or off for every reader in the process. +pub fn set_idle_backoff(enabled: bool) { + IDLE_BACKOFF.store(enabled, Ordering::Relaxed); + note_activity(); +} + +fn now_ms() -> u64 { + ACTIVITY_CLOCK.elapsed().as_millis() as u64 +} + +fn note_activity() { + LAST_ACTIVITY_MS.store(now_ms(), Ordering::Relaxed); +} + +fn quiet_for_too_long(enabled: bool, now_ms: u64, last_activity_ms: u64) -> bool { + enabled && now_ms.saturating_sub(last_activity_ms) >= IDLE_AFTER.as_millis() as u64 +} + +fn readers_are_idle() -> bool { + quiet_for_too_long( + IDLE_BACKOFF.load(Ordering::Relaxed), + now_ms(), + LAST_ACTIVITY_MS.load(Ordering::Relaxed), + ) +} + +/// Marks a request as activity and cuts short any reader parked in its idle +/// wait. A token left over from an unpark that found no sleeper only skips +/// one idle wait later, which is harmless. +fn wake_readers(group: &OpenGroup) { + if !IDLE_BACKOFF.load(Ordering::Relaxed) { + return; + } + note_activity(); + for reader in &group.readers { + reader.thread().unpark(); + } +} + #[derive(Clone, Serialize)] pub struct HidInterface { pub key: String, @@ -1032,6 +1092,7 @@ fn spawn_reader( match read { Ok(0) => {} Ok(length) => { + note_activity(); // Some backends prefix numbered reports with the report // id, some don't — assume they do (report id in // buffer[0]), matching the equivalent ambiguity the @@ -1142,6 +1203,11 @@ fn spawn_reader( // Deliberately after the report above has been emitted and with // no lock held — see READ_IDLE_GAP_MS. thread::sleep(Duration::from_millis(READ_IDLE_GAP_MS)); + // Quiet for a while: wait longer, still with no lock held. A request + // unparks this (see `wake_readers`); see `IDLE_BACKOFF`. + if readers_are_idle() { + thread::park_timeout(IDLE_PARK); + } } }) } @@ -1324,6 +1390,7 @@ fn with_open_group( let group = map .get(&(vendor_id, product_id)) .ok_or_else(|| "This HID interface is not open.".to_string())?; + wake_readers(group); operation(group) } @@ -1530,6 +1597,19 @@ mod tests { ); } + /// The idle backoff is opt-in and only kicks in after a full quiet spell, + /// so the Tauri app (which never enables it) and any active exchange keep + /// the original reader cadence. + #[test] + fn readers_only_go_idle_when_enabled_and_quiet_long_enough() { + let after = IDLE_AFTER.as_millis() as u64; + assert!(!quiet_for_too_long(false, 10 * after, 0), "off unless enabled"); + assert!(!quiet_for_too_long(true, after - 1, 0), "not quiet long enough yet"); + assert!(quiet_for_too_long(true, after, 0)); + assert!(!quiet_for_too_long(true, 5_000, 5_000), "fresh activity ends the idle spell"); + assert!(!quiet_for_too_long(true, 100, 5_000), "a clock that reads earlier never counts as idle"); + } + /// The reply that made a working Razer look asleep was the request echoed /// straight back with status 0x00. Pin the rule that tells one from an /// answer: treating an echo as an answer is what pinned the route cache to diff --git a/gpui/src/activity.rs b/gpui/src/activity.rs new file mode 100644 index 0000000..77f7cbc --- /dev/null +++ b/gpui/src/activity.rs @@ -0,0 +1,114 @@ +//! Whether the main window is in front, shared with the background loops. +//! +//! The loops that poll the system (game processes, launcher installs, the +//! wallpaper) have no reason to run at full rate while nobody is looking at +//! the window, but they cannot simply stop: a game launching behind the +//! window must still apply its profile. So they keep running at a slower +//! cadence while the window is not active, and [`Activity::sleep`] cuts the +//! slow wait short the moment the window comes back so what is on screen is +//! fresh when it appears. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use futures::channel::oneshot; +use futures::future::select; +use gpui_kit::BackgroundExecutor; + +pub struct Activity { + active: AtomicBool, + waiters: Mutex>>, +} + +/// The main window's state. Starts active so nothing is slowed before the +/// window has reported anything. +pub static ACTIVITY: Activity = Activity::new(); + +impl Activity { + const fn new() -> Self { + Self { + active: AtomicBool::new(true), + waiters: Mutex::new(Vec::new()), + } + } + + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Relaxed) + } + + /// Records the window's state; becoming active wakes every slow sleeper. + pub fn set_active(&self, active: bool) { + self.active.store(active, Ordering::Relaxed); + if active { + let waiters = std::mem::take(&mut *self.waiters.lock().unwrap()); + for waiter in waiters { + let _ = waiter.send(()); + } + } + } + + /// A receiver that resolves when the window becomes active, or `None` if + /// it already is. The check and the registration happen under one lock so + /// a wake-up between them cannot be missed. + fn until_active(&self) -> Option> { + let mut waiters = self.waiters.lock().unwrap(); + if self.is_active() { + return None; + } + waiters.retain(|waiter| !waiter.is_canceled()); + let (sender, receiver) = oneshot::channel(); + waiters.push(sender); + Some(receiver) + } + + /// Waits `active_for` while the window is active, `inactive_for` while it + /// is not, ending early if it becomes active during the longer wait. + pub async fn sleep( + &self, + executor: &BackgroundExecutor, + active_for: Duration, + inactive_for: Duration, + ) { + match self.until_active() { + None => executor.timer(active_for).await, + Some(woken) => { + let timer = executor.timer(inactive_for); + futures::pin_mut!(timer); + let _ = select(timer, woken).await; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn becoming_active_wakes_a_registered_sleeper() { + let activity = Activity::new(); + activity.set_active(false); + let mut woken = activity.until_active().expect("inactive window registers a waiter"); + assert_eq!(woken.try_recv(), Ok(None), "not woken while still inactive"); + activity.set_active(true); + assert_eq!(woken.try_recv(), Ok(Some(()))); + } + + #[test] + fn an_active_window_never_registers_a_waiter() { + let activity = Activity::new(); + assert!(activity.until_active().is_none()); + assert!(activity.waiters.lock().unwrap().is_empty()); + } + + #[test] + fn abandoned_waiters_are_dropped_on_the_next_registration() { + let activity = Activity::new(); + activity.set_active(false); + drop(activity.until_active()); + drop(activity.until_active()); + let _kept = activity.until_active(); + assert_eq!(activity.waiters.lock().unwrap().len(), 1); + } +} diff --git a/gpui/src/app.rs b/gpui/src/app.rs index dc35977..8a3f800 100644 --- a/gpui/src/app.rs +++ b/gpui/src/app.rs @@ -450,6 +450,13 @@ impl AppView { }, ); + // The background scans slow down while this window is not in front + // (see `activity.rs`). Only the main window reports: the game overlay + // never takes focus and must not count as the app being used. + let sub_activity = cx.observe_window_activation(window, |_this: &mut Self, window, _cx| { + crate::activity::ACTIVITY.set_active(window.is_window_active()); + }); + let games = games::load_catalog(); let game_watcher = crate::game_watcher::GameWatcher::start(cx); let mut view = Self { @@ -478,7 +485,7 @@ impl AppView { control_sliders: Default::default(), slider_live: Default::default(), slider_seq: 0, - _subscriptions: vec![sub_opacity, sub_vibrancy], + _subscriptions: vec![sub_opacity, sub_vibrancy, sub_activity], _game_watcher: Some(game_watcher), }; // The OS entry is the truth for launch-on-startup, not the saved flag. diff --git a/gpui/src/bin/main.rs b/gpui/src/bin/main.rs index 78e91d2..8bd822d 100644 --- a/gpui/src/bin/main.rs +++ b/gpui/src/bin/main.rs @@ -50,6 +50,9 @@ fn main() { // There is no tray yet, so minimized is the quiet option. if openmouse_gpui::autostart::launched_by_os() { let _ = cx.update_window(handle, |_, window, _| window.minimize_window()); + // A window that never became active reports no deactivation, so + // say so here or the background scans would stay at full rate. + openmouse_gpui::activity::ACTIVITY.set_active(false); } else { cx.activate(true); } diff --git a/gpui/src/game_watcher.rs b/gpui/src/game_watcher.rs index a44d52b..8f5c8a0 100644 --- a/gpui/src/game_watcher.rs +++ b/gpui/src/game_watcher.rs @@ -1,6 +1,7 @@ //! Lifetime-bound background detection. Process and install scans have separate //! owners so launcher I/O cannot delay the two-second running-game poll. +use crate::activity::ACTIVITY; use crate::app::AppView; use crate::games::{self, Game}; use gpui_kit::{Context, Task}; @@ -10,6 +11,11 @@ use sysinfo::{ProcessRefreshKind, ProcessStatus, ProcessesToUpdate, System, Upda const PROCESS_INTERVAL: Duration = Duration::from_secs(2); const INSTALL_INTERVAL: Duration = Duration::from_secs(10); +// While the window is not active the scans slow down rather than stop: a game +// can still launch behind the window and its profile should still apply, just +// a few seconds later. Coming back to the window rescans straight away. +const PROCESS_INTERVAL_INACTIVE: Duration = Duration::from_secs(10); +const INSTALL_INTERVAL_INACTIVE: Duration = Duration::from_secs(60); /// Dropping the view drops these tasks, including any awaited background task. /// The foreground callbacks capture only the weak entity supplied by GPUI. @@ -43,8 +49,13 @@ impl GameWatcher { { break; } - cx.background_executor() - .timer(PROCESS_INTERVAL.saturating_sub(started.elapsed())) + let elapsed = started.elapsed(); + ACTIVITY + .sleep( + cx.background_executor(), + PROCESS_INTERVAL.saturating_sub(elapsed), + PROCESS_INTERVAL_INACTIVE.saturating_sub(elapsed), + ) .await; } }); @@ -69,8 +80,13 @@ impl GameWatcher { { break; } - cx.background_executor() - .timer(INSTALL_INTERVAL.saturating_sub(started.elapsed())) + let elapsed = started.elapsed(); + ACTIVITY + .sleep( + cx.background_executor(), + INSTALL_INTERVAL.saturating_sub(elapsed), + INSTALL_INTERVAL_INACTIVE.saturating_sub(elapsed), + ) .await; } }); diff --git a/gpui/src/hid.rs b/gpui/src/hid.rs index c57328b..e3dfc9a 100644 --- a/gpui/src/hid.rs +++ b/gpui/src/hid.rs @@ -328,6 +328,9 @@ impl HidService { /// Starts the runtime thread. It loads the bundle before serving the first /// request, so construction itself is cheap. pub fn start() -> Self { + // Held-open interfaces otherwise wake their reader threads dozens of + // times a second forever; this app lets them back off while quiet. + core::set_idle_backoff(true); let (tx, rx) = mpsc::unbounded(); let sink_tx = tx.clone(); std::thread::Builder::new() diff --git a/gpui/src/lib.rs b/gpui/src/lib.rs index e56292c..7ed6876 100644 --- a/gpui/src/lib.rs +++ b/gpui/src/lib.rs @@ -1,3 +1,4 @@ +pub mod activity; pub mod app; pub mod audio; pub mod changelog; diff --git a/gpui/src/theme.rs b/gpui/src/theme.rs index 4d16838..3680b70 100644 --- a/gpui/src/theme.rs +++ b/gpui/src/theme.rs @@ -271,7 +271,8 @@ pub fn cached_dynamic_accent() -> Option<(u8, u8, u8)> { } /// Poll loop keeping Dynamic in sync while the app runs (mirrors -/// `startDynamicAccentWatcher`): 2s signature ticks, resampling + repainting +/// `startDynamicAccentWatcher`): 2s signature ticks while Dynamic is the +/// theme and the window is active, slower otherwise, resampling + repainting /// only when the signature moves. The blocking osascript spawn + image decode /// run on the background executor (mirrors `spawn_blocking`); the UI thread /// only pays for the repaint. Detached task — it ends when its view is dropped. @@ -285,10 +286,27 @@ pub fn spawn_wallpaper_watcher( let window = *window; cx.spawn(async move |cx| { let mut last: Option = None; + // Only Dynamic follows the wallpaper, and every check costs an + // `osascript` spawn on macOS, so any other theme just waits it out. + // Switching to Dynamic resamples on its own (`AppView::set_theme`), + // so the long wait never delays that. An inactive window also waits + // longer, and coming back to it checks straight away. + let mut dynamic = true; loop { - cx.background_executor().timer(Duration::from_secs(2)).await; - if view.upgrade().is_none() { - break; + let (active_for, inactive_for) = if dynamic { + (Duration::from_secs(2), Duration::from_secs(30)) + } else { + (Duration::from_secs(30), Duration::from_secs(30)) + }; + crate::activity::ACTIVITY + .sleep(cx.background_executor(), active_for, inactive_for) + .await; + match view.update(cx, |view, _| view.theme() == ThemeChoice::Dynamic) { + Ok(now_dynamic) => dynamic = now_dynamic, + Err(_) => break, + } + if !dynamic { + continue; } // Cheap check first (path|mtime, no decode)... let signature = cx