From 4d1d7cc6550cb78cef8c0ef8d318de083be279f7 Mon Sep 17 00:00:00 2001 From: max Date: Tue, 8 Sep 2026 23:26:21 +0200 Subject: [PATCH] perf(aw): run ActivityWatch polling in background thread to eliminate UI freezes Synchronous urllib calls in the 60 FPS main loop caused a 3-second network connect timeout every 5 seconds whenever ActivityWatch was offline (due to Windows IPv6/IPv4 localhost resolution). This completely froze the main thread, stalled the Windows message pump, and triggered Windows Hung-App detection (hourglass / wait cursor). ActivityWatchClient now polls in a dedicated daemon worker thread: - All socket connections and HTTP timeouts occur in the background thread. - get_current_window_event() reads cached state in 0ms without blocking. - Fallback to native Win32 window APIs is immediate with zero latency. --- catser/activitywatch.py | 101 +++++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/catser/activitywatch.py b/catser/activitywatch.py index c7165c9..ccda552 100644 --- a/catser/activitywatch.py +++ b/catser/activitywatch.py @@ -40,30 +40,68 @@ class DistractionEvent: self.timestamp = time.time() +import threading + class ActivityWatchClient: """ Client interface for ActivityWatch API with automatic native Win32 fallback. + Runs all HTTP requests in a background thread so that network latency or + timeouts when ActivityWatch is offline NEVER block the 60 FPS animation loop. """ def __init__(self, config: Config): self.config = config - self.base_url = config.aw_url.rstrip("/") + # Normalise localhost to 127.0.0.1 to prevent Windows IPv6 resolution timeouts + url = config.aw_url.rstrip("/") + if "localhost" in url: + url = url.replace("localhost", "127.0.0.1") + self.base_url = url self.api_url = f"{self.base_url}/api/0" self.bucket_id: Optional[str] = None self.is_connected: bool = False - self.last_connect_attempt: float = 0.0 self.connect_retry_delay: float = 5.0 # Retry connecting every 5s - def check_connection(self) -> bool: - """Pings the ActivityWatch /api/0/info endpoint.""" - now = time.time() - if now - self.last_connect_attempt < self.connect_retry_delay and not self.is_connected: - return False + self._latest_event: Optional[Dict[str, Any]] = None + self._lock = threading.Lock() + self._running = True + self._worker_thread = threading.Thread( + target=self._background_worker, + name="ActivityWatchWorker", + daemon=True, + ) + self._worker_thread.start() - self.last_connect_attempt = now + def _background_worker(self) -> None: + """ + Runs in the background, continuously attempting connection and polling + ActivityWatch without ever touching or blocking the main GUI thread. + """ + while self._running: + try: + if not self.is_connected or not self.bucket_id: + self._attempt_connect() + + if self.is_connected and self.bucket_id: + ev = self._fetch_latest_event() + with self._lock: + self._latest_event = ev + else: + with self._lock: + self._latest_event = None + except Exception as e: + logger.debug(f"AW worker error: {e}") + self.is_connected = False + with self._lock: + self._latest_event = None + + sleep_time = self.config.poll_interval_sec if self.is_connected else self.connect_retry_delay + time.sleep(sleep_time) + + def _attempt_connect(self) -> bool: + """Pings the ActivityWatch /api/0/info endpoint with a short timeout.""" try: - req = urllib.request.Request(f"{self.api_url}/info") - with urllib.request.urlopen(req, timeout=1.5) as resp: + req = urllib.request.Request(f"{self.api_url}/info", headers={"User-Agent": "Catser"}) + with urllib.request.urlopen(req, timeout=0.8) as resp: if resp.status == 200: data = json.loads(resp.read().decode("utf-8")) logger.info(f"Connected to ActivityWatch server v{data.get('version', 'unknown')}") @@ -72,17 +110,16 @@ class ActivityWatchClient: return True except Exception: if self.is_connected: - logger.warning("ActivityWatch server disconnected. Falling back to native tracking.") + logger.warning("ActivityWatch disconnected. Falling back to native tracking.") self.is_connected = False return False - return False def _discover_window_bucket(self) -> Optional[str]: """Discovers the active 'aw-watcher-window_*' bucket.""" try: - req = urllib.request.Request(f"{self.api_url}/buckets") - with urllib.request.urlopen(req, timeout=2.0) as resp: + req = urllib.request.Request(f"{self.api_url}/buckets", headers={"User-Agent": "Catser"}) + with urllib.request.urlopen(req, timeout=0.8) as resp: if resp.status == 200: buckets = json.loads(resp.read().decode("utf-8")) for b_id in buckets.keys(): @@ -91,22 +128,17 @@ class ActivityWatchClient: logger.info(f"Using ActivityWatch window bucket: {self.bucket_id}") return self.bucket_id except Exception as e: - logger.warning(f"Error querying ActivityWatch buckets: {e}") + logger.debug(f"Error querying ActivityWatch buckets: {e}") return None - def get_current_window_event(self) -> Optional[Dict[str, Any]]: - """ - Fetches the latest window event from ActivityWatch. - Returns dict with 'app' and 'title', or None. - """ - if not self.is_connected or not self.bucket_id: - if not self.check_connection() or not self.bucket_id: - return None - + def _fetch_latest_event(self) -> Optional[Dict[str, Any]]: + """Fetches the latest event from the active window bucket.""" + if not self.bucket_id: + return None try: url = f"{self.api_url}/buckets/{self.bucket_id}/events?limit=1" - req = urllib.request.Request(url) - with urllib.request.urlopen(req, timeout=1.5) as resp: + req = urllib.request.Request(url, headers={"User-Agent": "Catser"}) + with urllib.request.urlopen(req, timeout=0.8) as resp: if resp.status == 200: events = json.loads(resp.read().decode("utf-8")) if events and isinstance(events, list) and len(events) > 0: @@ -119,9 +151,24 @@ class ActivityWatchClient: except Exception as e: logger.debug(f"ActivityWatch query error: {e}") self.is_connected = False - return None + def check_connection(self) -> bool: + """Returns connection status immediately without blocking.""" + return self.is_connected + + def get_current_window_event(self) -> Optional[Dict[str, Any]]: + """ + Returns the latest window event from the background cache. + Completely non-blocking (0ms latency). + """ + with self._lock: + return self._latest_event + + def stop(self) -> None: + """Signals the background worker to exit.""" + self._running = False + def check_for_distraction(self) -> Optional[DistractionEvent]: """ Checks current active window against distraction rules.