From 068ffd81ef0edfb684a257712c7b035bbf242ae0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 16:36:53 +0000 Subject: [PATCH 1/2] fix: move IN-loop clipboard ops off watchdog thread pool (Windows) On Windows, watchdog's Observer uses WindowsApiObserver which dispatches events through a thread pool. The old code ran file I/O and pyperclip clipboard access synchronously inside the watchdog event handler, blocking pool workers under load and causing pool-exhaustion errors and UI slowness that didn't reproduce on Linux. - Add _in_queue (SimpleQueue) and a dedicated clipsync-in thread that drains the queue and calls _on_file_changed. The watchdog handler now just puts a path on the queue and returns immediately, keeping the thread pool free. - Cache resolved clipboard file paths in _ClipboardFileHandler so _matches() doesn't call Path.resolve() three times per event. - Add fast name-based pre-filter before the (more expensive) resolve() call, since Syncthing generates many temp-file events for other files. https://claude.ai/code/session_0128MyVVEu1Dt3jTeaZ1edyL --- clipsync/clipboard.py | 56 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/clipsync/clipboard.py b/clipsync/clipboard.py index b60f30b..401d99d 100644 --- a/clipsync/clipboard.py +++ b/clipsync/clipboard.py @@ -22,6 +22,7 @@ import io import logging +import queue import subprocess import sys import threading @@ -137,6 +138,8 @@ def __init__(self, settings: config.Settings) -> None: self._settings = settings self._stop = threading.Event() self._poll_thread: threading.Thread | None = None + self._in_thread: threading.Thread | None = None + self._in_queue: queue.SimpleQueue[str] = queue.SimpleQueue() self._observer: Observer | None = None # type: ignore[valid-type] self._last_synced: str | bytes | None = None self._lock = threading.Lock() @@ -160,6 +163,8 @@ def start(self) -> None: self._seed_from_file() self._poll_thread = threading.Thread(target=self._out_loop, name="clipsync-out", daemon=True) self._poll_thread.start() + self._in_thread = threading.Thread(target=self._in_loop, name="clipsync-in", daemon=True) + self._in_thread.start() self._start_watcher() log.info("Clipboard sync started (host=%s)", _HOSTNAME) @@ -172,6 +177,10 @@ def stop(self) -> None: except Exception: log.exception("Error stopping file observer") self._observer = None + # Unblock _in_loop which may be waiting on the queue + self._in_queue.put("") + if self._in_thread and self._in_thread.is_alive(): + self._in_thread.join(timeout=3) if self._poll_thread and self._poll_thread.is_alive(): self._poll_thread.join(timeout=3) log.info("Clipboard sync stopped") @@ -412,6 +421,37 @@ def _out_tick(self) -> None: except OSError: log.exception("OUT [%s]: Failed to write clipboard file", _HOSTNAME) + def _in_loop(self) -> None: + """Drain _in_queue and apply remote file changes to the local clipboard. + + Watchdog dispatches events on its own internal thread (backed by a + thread pool on Windows). Doing clipboard I/O there blocks the pool and + causes pool-exhaustion errors. This loop runs on a thread we own so + watchdog events are always handled off the pool in bounded time. + """ + _last_processed: dict[str, float] = {} + while True: + try: + path = self._in_queue.get(timeout=0.5) + except queue.Empty: + if self._stop.is_set(): + break + continue + if not path: # sentinel posted by stop() + break + if self._stop.is_set(): + break + if self._is_paused(): + continue + now = time.monotonic() + if now - _last_processed.get(path, 0.0) < 0.1: + continue + _last_processed[path] = now + try: + self._on_file_changed(path) + except Exception: + log.exception("Error in IN loop") + def _start_watcher(self) -> None: handler = _ClipboardFileHandler(self) observer = Observer() @@ -480,13 +520,19 @@ def __init__(self, sync: ClipboardSync) -> None: super().__init__() self._sync = sync self._debounce_until = 0.0 + # Fast name-based pre-filter to avoid Path.resolve() on every event. + # Syncthing generates many temp-file events; most are irrelevant. + self._target_names = {config.CLIPBOARD_FILENAME, config.CLIPBOARD_IMAGE_FILENAME} + # Cache resolved targets once so _matches doesn't re-resolve per event. + self._resolved_text = sync.clipboard_file.resolve() + self._resolved_image = sync.clipboard_image_file.resolve() def _matches(self, path: str) -> bool: + if Path(path).name not in self._target_names: + return False try: resolved = Path(path).resolve() - return ( - resolved == self._sync.clipboard_file.resolve() or resolved == self._sync.clipboard_image_file.resolve() - ) + return resolved == self._resolved_text or resolved == self._resolved_image except OSError: return False @@ -497,7 +543,9 @@ def _dispatch(self, path: str) -> None: if now < self._debounce_until: return self._debounce_until = now + 0.1 - self._sync._on_file_changed(path) + # Non-blocking: hand off to _in_loop so the watchdog thread pool + # is never held by clipboard I/O (avoids pool exhaustion on Windows). + self._sync._in_queue.put(path) def on_modified(self, event: FileSystemEvent) -> None: if event.is_directory: From 3c2e912d704736aa14b4891fb5131a26bb175eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 16:52:22 +0000 Subject: [PATCH 2/2] fix: move Syncthing API calls off Tkinter main thread in Devices/Incoming views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _DevicesContent._refresh() and IncomingWindow._refresh() both called connected_devices() / get_pending_devices() directly on the main thread. Each call has a 10-second timeout and connected_devices() makes 3 sequential requests, so a slow or unresponsive Syncthing froze the entire UI for up to 30 seconds on every auto-refresh cycle (every 3s). - Split each _refresh() into _do_refresh() (background thread) + _apply_refresh() (main thread via after(0, ...)), matching the pattern already used by _nearby_worker. - Catch requests.RequestException separately and show "Syncthing is not responding" instead of the raw HTTPConnectionPool traceback string. - Show a "Loading…" placeholder while the fetch is in flight. https://claude.ai/code/session_0128MyVVEu1Dt3jTeaZ1edyL --- clipsync/ui.py | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/clipsync/ui.py b/clipsync/ui.py index 7acf9b3..e31998a 100644 --- a/clipsync/ui.py +++ b/clipsync/ui.py @@ -18,6 +18,7 @@ from pathlib import Path import customtkinter as ctk +import requests from PIL import Image from . import __version__, config, pairing, update @@ -588,10 +589,27 @@ def _auto_refresh(self) -> None: def _refresh(self) -> None: for child in self._list_frame.winfo_children(): child.destroy() + ctk.CTkLabel(self._list_frame, text="Loading…", text_color=("gray50", "gray60")).pack(pady=20) + threading.Thread(target=self._do_refresh, daemon=True).start() + + def _do_refresh(self) -> None: try: - devices = self._app.client.connected_devices() + devices: list[dict] = self._app.client.connected_devices() + error: str | None = None + except requests.RequestException: + devices = [] + error = "Syncthing is not responding" except Exception as exc: - ctk.CTkLabel(self._list_frame, text=f"Error: {exc}", text_color="red").pack(pady=10) + devices = [] + error = str(exc) + if self._exists(): + self._win.after(0, self._apply_refresh, devices, error) + + def _apply_refresh(self, devices: list[dict], error: str | None) -> None: + for child in self._list_frame.winfo_children(): + child.destroy() + if error: + ctk.CTkLabel(self._list_frame, text=error, text_color="red").pack(pady=10) return if not devices: empty = ctk.CTkFrame(self._list_frame, fg_color="transparent") @@ -1214,10 +1232,27 @@ def _auto_refresh(self) -> None: def _refresh(self) -> None: for child in self._list_frame.winfo_children(): child.destroy() + ctk.CTkLabel(self._list_frame, text="Loading…", text_color=("gray50", "gray60")).pack(pady=20) + threading.Thread(target=self._do_refresh, daemon=True).start() + + def _do_refresh(self) -> None: try: - pending = self._app.client.get_pending_devices() or {} + pending: dict = self._app.client.get_pending_devices() or {} + error: str | None = None + except requests.RequestException: + pending = {} + error = "Syncthing is not responding" except Exception as exc: - ctk.CTkLabel(self._list_frame, text=f"Error: {exc}", text_color="red").pack(pady=10) + pending = {} + error = str(exc) + if self.exists(): + self.window.after(0, self._apply_refresh, pending, error) + + def _apply_refresh(self, pending: dict, error: str | None) -> None: + for child in self._list_frame.winfo_children(): + child.destroy() + if error: + ctk.CTkLabel(self._list_frame, text=error, text_color="red").pack(pady=10) return rejected = set(self._app.settings.get("rejected_device_ids") or []) visible = [