From 123e841543fa0e5632e25ebcbd9bac594651a625 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:03:37 -0700 Subject: [PATCH] fix(pet): stop the hover watcher with the window it watches `spawn_pet_hover_watcher` polls the windowing layer on an 80 ms timer for as long as the pet window is open: `cursor_position` every tick, plus `outer_position` and `outer_size` every fifth. Each of those is a synchronous request/reply round trip to the main thread, answered inside tauri's event loop, and the loop only stops once a tick happens to find the pet missing from the window map. Nothing else in the process polls the windowing layer on a timer, and left running it is about 1.5M round trips a day at an idle machine. Two ways that outlives what it watches today. On the quit path, `RunEvent::ExitRequested` blocks the main thread on the web-server stop and on the ACP disconnects. Every watcher tick during that window posts a request only that blocked thread can answer, and blocks a tokio worker waiting for it, while the event loop is on its way down. Window close has the same shape in miniature: the watcher keeps round-tripping at a window between `CloseRequested` and the point where it leaves the window map. A second watcher can also exist. `open_pet_window` early-returns only while `get_webview_window` still answers, so a close followed by a re-open inside one tick builds a new window before the old loop sees the gap. The old loop then finds the new window, never exits, and two watchers poll in parallel while fighting over the single `PET_HOVER_WAS_INSIDE` flag. So the watcher now holds a `CancellationToken` in a process-global slot. Installing a watcher cancels whatever it replaced, which is the at-most-one invariant; `stop_pet_hover_watcher` installs `None`, which is the stop. The loop selects on the token `biased` ahead of the tick, so a stop lands before the next round trip rather than one tick after it. Both branches are cancel safe, so losing either drops no tick and no cancellation. `lib.rs` stops the watcher from the pet window's close/destroy branch and again at the top of `ExitRequested`, before anything there blocks the main thread; a watcher whose window is already gone gets no close event, so the second call is not redundant. Behavior is unchanged while the pet window is open: same interval, same bounds cache, same enter/leave events, on every platform. Not gated to macOS despite the doc comment's macOS-shaped rationale, because `PetWindow.tsx` carries no DOM hover handler of its own; these events are the only thing that drives the waving animation anywhere, so a gate would silently drop hover-waving on Windows and Linux. --- src-tauri/src/commands/windows.rs | 165 +++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 16 +++ 2 files changed, 178 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 904a8e34c3..2795ea4ee3 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -9,6 +9,7 @@ use tauri::{ window::{Effect, EffectState, EffectsBuilder}, AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl, WebviewWindowBuilder, }; +use tokio_util::sync::CancellationToken; use crate::app_error::AppCommandError; use crate::db::service::app_metadata_service; @@ -1241,6 +1242,67 @@ const PET_BASE_HEIGHT: f64 = 208.0; /// the user has to wiggle off-pet-and-back to re-trigger waving. static PET_HOVER_WAS_INSIDE: AtomicBool = AtomicBool::new(false); +/// Cancellation handle for the live hover watcher, so its poll loop stops on a +/// signal rather than on eventually noticing the window has gone. +/// +/// Every tick of that loop is a synchronous request/reply round trip to the +/// main thread: `outer_position`, `outer_size` and `cursor_position` are all +/// messages that only tauri's event loop can answer. A watcher that outlives +/// the window it watches therefore keeps posting work at a window being +/// destroyed and at an event loop that is shutting down, and it blocks a tokio +/// worker on each one while the main thread is busy with teardown. +/// +/// Holding the handle here also enforces at most one live watcher. +/// `open_pet_window` early-returns only while `get_webview_window` still +/// answers, so a close immediately followed by a re-open can build a second +/// window before the first watcher's next tick sees the gap. The old watcher +/// then finds the *new* window, never exits, and two loops poll in parallel +/// while fighting over the single [`PET_HOVER_WAS_INSIDE`] flag. +static PET_HOVER_WATCHER: Mutex> = Mutex::new(None); + +/// Install `next` as the live hover watcher, cancelling whatever it replaced. +/// +/// Takes the slot explicitly rather than reading the global, so the bookkeeping +/// is testable without a running app. +fn install_watcher_in(slot: &Mutex>, next: Option) { + let previous = { + // Poison-tolerant: the guarded value is a single `Option` that cannot + // be left half-written, and this runs from `on_window_event` and + // `RunEvent::ExitRequested`, i.e. on the main thread inside the + // platform event loop, where a panic cannot unwind across the + // `extern "system"` boundary and aborts the process instead. + let mut live = slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + std::mem::replace(&mut *live, next) + }; + // Cancelled outside the lock: `cancel` runs the token's registered wakers, + // and none of them should be able to re-enter this slot. + if let Some(previous) = previous { + previous.cancel(); + } +} + +/// Install `next` as the live hover watcher. See [`PET_HOVER_WATCHER`]. +fn install_pet_hover_watcher(next: Option) { + install_watcher_in(&PET_HOVER_WATCHER, next); +} + +/// Stop the pet hover watcher, if one is running. +/// +/// Called from `lib.rs` when the pet window closes or is destroyed, and once +/// more at the top of `ExitRequested` so the quit path is covered even if no +/// close event ever reaches a watcher whose window is already gone. +/// +/// Stopping first on quit matters: `ExitRequested` goes on to `block_on` the +/// web-server stop and the ACP disconnects *on the main thread*, and every +/// watcher tick during that window is a request only that blocked thread can +/// answer. +/// +/// A watcher that has already ended leaves its (now inert) token behind; +/// cancelling it is a no-op, and the next install replaces it. +pub fn stop_pet_hover_watcher() { + install_pet_hover_watcher(None); +} + /// Apply the pet-window-specific platform style. Deliberately separate from /// `apply_platform_window_style`: that helper sets a solid background color /// for the main / settings / git windows, which would defeat the @@ -1348,8 +1410,18 @@ pub async fn open_pet_window( /// the cursor crosses into the pet window's bounds. Native webviews on /// macOS don't reliably deliver mouse events to non-key windows, so we /// detect "cursor over the pet" in Rust and let the frontend trigger the -/// waving animation in response. The task ends when the pet window is -/// closed. +/// waving animation in response. +/// +/// Cross-platform on purpose, despite the macOS-shaped reason above: the pet +/// renderer has no DOM hover handler of its own, so these events are the only +/// thing that drives the waving animation anywhere. Gating this to macOS would +/// silently drop hover-waving on Windows and Linux. +/// +/// The task ends on the first of: [`stop_pet_hover_watcher`] (window close, +/// window destroy, or app quit), a newer watcher replacing this one, or the pet +/// window leaving the window map. The signal comes first so a stopped watcher +/// issues no further round trips, rather than firing one more tick's worth at a +/// window that is being destroyed. fn spawn_pet_hover_watcher(app: AppHandle) { use std::time::Duration; use tauri::Emitter; @@ -1360,6 +1432,11 @@ fn spawn_pet_hover_watcher(app: AppHandle) { // suppressed on the JS side via a pointer-down guard (see PetWindow). const BOUNDS_REFRESH_TICKS: u8 = 5; + let cancel = CancellationToken::new(); + // Retires any watcher left over from an earlier pet window before this one + // starts, so the two never poll (and never race on the hover flag) at once. + install_pet_hover_watcher(Some(cancel.clone())); + tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_millis(80)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -1370,7 +1447,15 @@ fn spawn_pet_hover_watcher(app: AppHandle) { let mut bounds: Option<(f64, f64, f64, f64)> = None; let mut ticks_since_refresh: u8 = BOUNDS_REFRESH_TICKS; loop { - interval.tick().await; + // `biased` so a cancellation that is already pending wins over a + // tick that is also ready: the point of the signal is that no + // round trip is issued after the stop. Both branches are cancel + // safe, so losing one drops no tick and no cancellation. + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => {} + } let Some(window) = app.get_webview_window(PET_WINDOW_LABEL) else { break; }; @@ -2269,6 +2354,80 @@ mod pet_panel_geometry_tests { } } +#[cfg(test)] +mod pet_hover_watcher_tests { + use super::install_watcher_in; + use std::sync::Mutex; + use tokio_util::sync::CancellationToken; + + /// A slot of its own per test, so none of these touch the process-global + /// `PET_HOVER_WATCHER` and they stay safe to run in parallel. + fn slot() -> Mutex> { + Mutex::new(None) + } + + /// The invariant that keeps a second watcher from ever existing. A pet + /// window can be closed and re-opened inside one 80 ms tick, in which case + /// the old loop's `get_webview_window` check never sees the gap: it finds + /// the *new* window, never exits, and two loops then poll the windowing + /// layer in parallel while fighting over one hover flag. Starting a watcher + /// has to retire the previous one. + #[test] + fn starting_a_watcher_retires_the_previous_one() { + let slot = slot(); + + let first = CancellationToken::new(); + install_watcher_in(&slot, Some(first.clone())); + assert!( + !first.is_cancelled(), + "a freshly installed watcher must not start out stopped" + ); + + let second = CancellationToken::new(); + install_watcher_in(&slot, Some(second.clone())); + assert!(first.is_cancelled(), "the older watcher must be retired"); + assert!( + !second.is_cancelled(), + "the watcher that replaced it keeps running" + ); + } + + /// The close and quit path. Without a signal the loop only stops once it + /// happens to notice the window has left the window map, which is up to a + /// tick later, and on the quit path that tick is spent round-tripping to a + /// main thread that is already tearing the event loop down. + #[test] + fn stopping_cancels_the_live_watcher() { + let slot = slot(); + let live = CancellationToken::new(); + install_watcher_in(&slot, Some(live.clone())); + + install_watcher_in(&slot, None); + assert!(live.is_cancelled(), "the live watcher must be stopped"); + } + + /// Stopping is called more than once per quit (the pet window's close + /// handler and `ExitRequested` both do it) and may be called with nothing + /// running at all. Neither may leave the slot in a state that stops the + /// next watcher before it has run, because an ordinary close is routinely + /// followed by a re-open. + #[test] + fn stopping_twice_leaves_a_later_watcher_running() { + let slot = slot(); + install_watcher_in(&slot, Some(CancellationToken::new())); + install_watcher_in(&slot, None); + install_watcher_in(&slot, None); + + let reopened = CancellationToken::new(); + install_watcher_in(&slot, Some(reopened.clone())); + assert!( + !reopened.is_cancelled(), + "the watcher for a re-opened pet window must survive the stops \ + that preceded it" + ); + } +} + #[cfg(test)] mod settings_route_tests { use super::resolve_settings_route; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1307db5cb5..e2e31fe13e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1090,6 +1090,13 @@ mod tauri_app { tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed ) { + // Stop the hover watcher with the window it watches. Its + // poll loop round-trips to *this* thread on every tick, and + // it would otherwise keep doing so until it next noticed + // the window had left the window map, which on the quit + // path is while the event loop is already tearing down. + windows::stop_pet_hover_watcher(); + // Persist `enabled = false` so the next launch doesn't // race-open the pet before the user asks for it. We // intentionally do NOT clear `active_pet_id` — the user @@ -1674,6 +1681,15 @@ mod tauri_app { .run(|app, event| match event { tauri::RunEvent::ExitRequested { .. } => { APP_QUITTING.store(true, Ordering::Relaxed); + // First, before anything below blocks this thread. The pet + // hover watcher polls the windowing layer every 80 ms, and + // each poll is a request only the main thread can answer, + // so leaving it running through the `block_on` calls below + // has a tokio worker waiting on a thread that is waiting on + // that worker's runtime. A watcher whose window is already + // gone gets no close event, so this is not covered by the + // `label == "pet"` branch above. + windows::stop_pet_hover_watcher(); // Drop the desktop pet alongside the workspace so it // never outlives a real quit. Tauri also tears down all // windows on shutdown, but doing it explicitly here lets