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.
This commit is contained in:
+73
-26
@@ -40,30 +40,68 @@ class DistractionEvent:
|
|||||||
self.timestamp = time.time()
|
self.timestamp = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
class ActivityWatchClient:
|
class ActivityWatchClient:
|
||||||
"""
|
"""
|
||||||
Client interface for ActivityWatch API with automatic native Win32 fallback.
|
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):
|
def __init__(self, config: Config):
|
||||||
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.api_url = f"{self.base_url}/api/0"
|
||||||
self.bucket_id: Optional[str] = None
|
self.bucket_id: Optional[str] = None
|
||||||
self.is_connected: bool = False
|
self.is_connected: bool = False
|
||||||
self.last_connect_attempt: float = 0.0
|
|
||||||
self.connect_retry_delay: float = 5.0 # Retry connecting every 5s
|
self.connect_retry_delay: float = 5.0 # Retry connecting every 5s
|
||||||
|
|
||||||
def check_connection(self) -> bool:
|
self._latest_event: Optional[Dict[str, Any]] = None
|
||||||
"""Pings the ActivityWatch /api/0/info endpoint."""
|
self._lock = threading.Lock()
|
||||||
now = time.time()
|
self._running = True
|
||||||
if now - self.last_connect_attempt < self.connect_retry_delay and not self.is_connected:
|
self._worker_thread = threading.Thread(
|
||||||
return False
|
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:
|
try:
|
||||||
req = urllib.request.Request(f"{self.api_url}/info")
|
if not self.is_connected or not self.bucket_id:
|
||||||
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
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", headers={"User-Agent": "Catser"})
|
||||||
|
with urllib.request.urlopen(req, timeout=0.8) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
logger.info(f"Connected to ActivityWatch server v{data.get('version', 'unknown')}")
|
logger.info(f"Connected to ActivityWatch server v{data.get('version', 'unknown')}")
|
||||||
@@ -72,17 +110,16 @@ class ActivityWatchClient:
|
|||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
if self.is_connected:
|
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
|
self.is_connected = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _discover_window_bucket(self) -> Optional[str]:
|
def _discover_window_bucket(self) -> Optional[str]:
|
||||||
"""Discovers the active 'aw-watcher-window_*' bucket."""
|
"""Discovers the active 'aw-watcher-window_*' bucket."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(f"{self.api_url}/buckets")
|
req = urllib.request.Request(f"{self.api_url}/buckets", headers={"User-Agent": "Catser"})
|
||||||
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
with urllib.request.urlopen(req, timeout=0.8) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
buckets = json.loads(resp.read().decode("utf-8"))
|
buckets = json.loads(resp.read().decode("utf-8"))
|
||||||
for b_id in buckets.keys():
|
for b_id in buckets.keys():
|
||||||
@@ -91,22 +128,17 @@ class ActivityWatchClient:
|
|||||||
logger.info(f"Using ActivityWatch window bucket: {self.bucket_id}")
|
logger.info(f"Using ActivityWatch window bucket: {self.bucket_id}")
|
||||||
return self.bucket_id
|
return self.bucket_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error querying ActivityWatch buckets: {e}")
|
logger.debug(f"Error querying ActivityWatch buckets: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_current_window_event(self) -> Optional[Dict[str, Any]]:
|
def _fetch_latest_event(self) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""Fetches the latest event from the active window bucket."""
|
||||||
Fetches the latest window event from ActivityWatch.
|
if not self.bucket_id:
|
||||||
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
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"{self.api_url}/buckets/{self.bucket_id}/events?limit=1"
|
url = f"{self.api_url}/buckets/{self.bucket_id}/events?limit=1"
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url, headers={"User-Agent": "Catser"})
|
||||||
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
with urllib.request.urlopen(req, timeout=0.8) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
events = json.loads(resp.read().decode("utf-8"))
|
events = json.loads(resp.read().decode("utf-8"))
|
||||||
if events and isinstance(events, list) and len(events) > 0:
|
if events and isinstance(events, list) and len(events) > 0:
|
||||||
@@ -119,9 +151,24 @@ class ActivityWatchClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"ActivityWatch query error: {e}")
|
logger.debug(f"ActivityWatch query error: {e}")
|
||||||
self.is_connected = False
|
self.is_connected = False
|
||||||
|
|
||||||
return None
|
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]:
|
def check_for_distraction(self) -> Optional[DistractionEvent]:
|
||||||
"""
|
"""
|
||||||
Checks current active window against distraction rules.
|
Checks current active window against distraction rules.
|
||||||
|
|||||||
Reference in New Issue
Block a user