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