Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion crates/openmouse-hid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Instant> = 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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
})
}
Expand Down Expand Up @@ -1324,6 +1390,7 @@ fn with_open_group<T>(
let group = map
.get(&(vendor_id, product_id))
.ok_or_else(|| "This HID interface is not open.".to_string())?;
wake_readers(group);
operation(group)
}

Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions gpui/src/activity.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<oneshot::Sender<()>>>,
}

/// 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<oneshot::Receiver<()>> {
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);
}
}
9 changes: 8 additions & 1 deletion gpui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions gpui/src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
24 changes: 20 additions & 4 deletions gpui/src/game_watcher.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
});
Expand All @@ -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;
}
});
Expand Down
3 changes: 3 additions & 0 deletions gpui/src/hid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions gpui/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod activity;
pub mod app;
pub mod audio;
pub mod changelog;
Expand Down
26 changes: 22 additions & 4 deletions gpui/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -285,10 +286,27 @@ pub fn spawn_wallpaper_watcher(
let window = *window;
cx.spawn(async move |cx| {
let mut last: Option<String> = 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
Expand Down
Loading