From cfb3d902d14daa889be2107de268319e2d45a379 Mon Sep 17 00:00:00 2001 From: Henri Cook Date: Tue, 15 Sep 2026 14:49:15 +0100 Subject: [PATCH 1/3] fix(desktop): websocket keepalive, reconnect backoff and persistent logs The client never sends anything proactively (STOMP client heartbeats are 0), so a half-open socket after sleep or a network blip was invisible to it: sends failed without triggering a reconnect and the single reconnect attempt had a 3s timeout and no retry. - run_forever(ping_interval=30, ping_timeout=10) so dead sockets are detected within ~40s - replace the one-shot sleep/connect in _on_close with a backoff loop (10s to 60s) guarded by a lock; disconnect() wakes it via an Event - a send() failing with WebSocketException/OSError closes the socket so the on_close path reconnects; connect timeout closes its leaked thread - RotatingFileHandler in append mode and INFO-level lifecycle logging so the next failure is diagnosable --- ClipCascade_Desktop/src/core/application.py | 9 ++- ClipCascade_Desktop/src/core/constants.py | 3 + ClipCascade_Desktop/src/stomp_ws/client.py | 13 +++-- .../src/stomp_ws/stomp_manager.py | 58 +++++++++++++++---- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/ClipCascade_Desktop/src/core/application.py b/ClipCascade_Desktop/src/core/application.py index 0424ca0d6..9c11df547 100644 --- a/ClipCascade_Desktop/src/core/application.py +++ b/ClipCascade_Desktop/src/core/application.py @@ -1,4 +1,5 @@ import logging +from logging.handlers import RotatingFileHandler import sys @@ -68,12 +69,10 @@ def __init__( def setup_logging(self): LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s" - logging.basicConfig( - level=LOG_LEVEL, - format=LOG_FORMAT, - filename=self.log_file_path, - filemode="w", + handler = RotatingFileHandler( + self.log_file_path, maxBytes=1_000_000, backupCount=3 ) + logging.basicConfig(level=LOG_LEVEL, format=LOG_FORMAT, handlers=[handler]) def ensure_single_instance(self): if PLATFORM == WINDOWS: diff --git a/ClipCascade_Desktop/src/core/constants.py b/ClipCascade_Desktop/src/core/constants.py index 78c357a33..e80424a22 100644 --- a/ClipCascade_Desktop/src/core/constants.py +++ b/ClipCascade_Desktop/src/core/constants.py @@ -177,7 +177,10 @@ def _strip_linux_cli_overrides(argv): # core constants RECONNECT_WS_TIMER = 10 # seconds +RECONNECT_WS_MAX_TIMER = 60 # seconds; reconnect backoff cap WEBSOCKET_TIMEOUT = 3000 # milliseconds +WEBSOCKET_PING_INTERVAL = 30 # seconds; WS-level keepalive to detect dead sockets +WEBSOCKET_PING_TIMEOUT = 10 # seconds; must be < WEBSOCKET_PING_INTERVAL # P2P signaling WebSocket keepalive (RFC 6455 ping/pong). P2P_WS_PING_INTERVAL_SEC = 25 diff --git a/ClipCascade_Desktop/src/stomp_ws/client.py b/ClipCascade_Desktop/src/stomp_ws/client.py index e09f32fec..582b63500 100644 --- a/ClipCascade_Desktop/src/stomp_ws/client.py +++ b/ClipCascade_Desktop/src/stomp_ws/client.py @@ -34,12 +34,13 @@ def __init__(self, url, headers={}, on_close_callback=None, sslopt=None): self.errorCallback = None def _connect(self, timeout=0): + run_kwargs = { + "ping_interval": WEBSOCKET_PING_INTERVAL, + "ping_timeout": WEBSOCKET_PING_TIMEOUT, + } if self._ws_sslopt: - thread = Thread( - target=lambda: self.ws.run_forever(sslopt=self._ws_sslopt) - ) - else: - thread = Thread(target=self.ws.run_forever) + run_kwargs["sslopt"] = self._ws_sslopt + thread = Thread(target=lambda: self.ws.run_forever(**run_kwargs)) thread.daemon = True thread.start() @@ -48,6 +49,8 @@ def _connect(self, timeout=0): time.sleep(0.25) total_ms += 250 if 0 < timeout < total_ms: + self.ws.on_close = None + self.ws.close() raise TimeoutError(f"Connection to {self.url} timed out") def _on_open(self, ws_app, *args): diff --git a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py index 48a78c4ac..0420d10cb 100644 --- a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py +++ b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py @@ -1,6 +1,8 @@ import json import logging +import threading import time +import websocket from interfaces.ws_interface import WSInterface @@ -32,6 +34,8 @@ def __init__(self, config: Config, is_login_phase=True): self.is_connected = False self.disconnected = False self.is_auto_reconnecting = False + self._reconnect_lock = threading.Lock() + self._reconnect_wakeup = threading.Event() def set_tray_ref(self, sys_tray: TaskbarPanel): """ @@ -50,6 +54,8 @@ def get_stats(self): def connect(self) -> tuple[bool, str]: try: + if self.disconnected: + return False, "Websocket disconnected" if self.is_connected: return True, "" self.client = Client( @@ -73,7 +79,7 @@ def connect(self) -> tuple[bool, str]: self.disconnect() return False, "Websocket disconnected" - # logging.info("Websocket connected") + logging.info("Websocket connected") self.is_connected = True self.is_auto_reconnecting = False if not self.first_conn_lost: @@ -93,17 +99,42 @@ def connect(self) -> tuple[bool, str]: def _on_close(self): self.is_connected = False - # Auto Reconnect - if not self.is_login_phase and not self.disconnected: - self.is_auto_reconnecting = True - if self.first_conn_lost: - self.notification_manager.notify( - title=f"{APP_NAME}: WebSocket Connection Lost ⛓️‍💥", - message="Check your internet connection. Retrying...", - ) - self.first_conn_lost = False - time.sleep(RECONNECT_WS_TIMER) # seconds - self.connect() + if self.is_login_phase or self.disconnected: + return + if not self._reconnect_lock.acquire(blocking=False): + return # a reconnect loop is already running on another socket's thread + try: + self._reconnect_loop() + finally: + self._reconnect_lock.release() + + def _reconnect_loop(self): + self.is_auto_reconnecting = True + if self.first_conn_lost: + self.notification_manager.notify( + title=f"{APP_NAME}: WebSocket Connection Lost ⛓️‍💥", + message="Check your internet connection. Retrying...", + ) + self.first_conn_lost = False + backoff = RECONNECT_WS_TIMER + self._reconnect_wakeup.clear() + while not self.disconnected and not self.is_connected: + logging.info(f"Websocket connection lost, reconnecting in {backoff}s") + if self._reconnect_wakeup.wait(backoff): + break # disconnect() requested + connected, msg = self.connect() + if connected: + return + logging.info(f"Reconnect attempt failed: {msg}") + backoff = min(backoff * 2, RECONNECT_WS_MAX_TIMER) + self.is_auto_reconnecting = False + + def _drop_connection(self): + """Close the socket so the on_close path drives a reconnect.""" + try: + self.client.ws.close() + except Exception: + pass def send(self, payload: str, payload_type: str = "text"): try: @@ -117,6 +148,8 @@ def send(self, payload: str, payload_type: str = "text"): self.client.send(destination=SEND_DESTINATION, body=body) except Exception as e: logging.error(f"Failed to send data: {e}") + if isinstance(e, (websocket.WebSocketException, OSError)): + self._drop_connection() def _receive(self, frame: any) -> str: try: @@ -150,6 +183,7 @@ def disconnect(self): self.clipboard_manager.previous_clipboard_hash = 0 self.disconnected = True self.first_conn_lost = True + self._reconnect_wakeup.set() try: self.client.disconnect() self.is_connected = False From c3adfc1ee794d5837132f07f6c1ffff55f2ee90e Mon Sep 17 00:00:00 2001 From: Henri Cook Date: Tue, 15 Sep 2026 17:36:27 +0100 Subject: [PATCH 2/3] fix(desktop): distinguish app connect log from websocket-client's websocket-client already logs "Websocket connected" at INFO on its own thread when the socket opens; the app's line 250ms later used the identical phrase, which reads as a double connect in the log. It isn't: one connection, one subscription. --- ClipCascade_Desktop/src/stomp_ws/stomp_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py index 0420d10cb..ce2b1b2da 100644 --- a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py +++ b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py @@ -79,7 +79,7 @@ def connect(self) -> tuple[bool, str]: self.disconnect() return False, "Websocket disconnected" - logging.info("Websocket connected") + logging.info("STOMP client connected") self.is_connected = True self.is_auto_reconnecting = False if not self.first_conn_lost: From 2e5c86c27dedcd85e39b3157d60baaace312dd66 Mon Sep 17 00:00:00 2001 From: Henri Cook Date: Tue, 15 Sep 2026 19:04:23 +0100 Subject: [PATCH 3/3] fix(desktop): don't null the socket under a timed-out run_forever thread The connect-timeout path called ws.close(), which sets self.sock=None while that attempt's run_forever thread is still live; the thread then dereferences self.sock.sock and logs a spurious "'NoneType' object has no attribute 'sock'". Detaching on_close is enough to stop the timed-out attempt from driving a reconnect. --- ClipCascade_Desktop/src/stomp_ws/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ClipCascade_Desktop/src/stomp_ws/client.py b/ClipCascade_Desktop/src/stomp_ws/client.py index 582b63500..2a9e83b63 100644 --- a/ClipCascade_Desktop/src/stomp_ws/client.py +++ b/ClipCascade_Desktop/src/stomp_ws/client.py @@ -49,8 +49,9 @@ def _connect(self, timeout=0): time.sleep(0.25) total_ms += 250 if 0 < timeout < total_ms: + # detach only; ws.close() here nulls self.sock under the live run_forever + # thread -> spurious "'NoneType' object has no attribute 'sock'" self.ws.on_close = None - self.ws.close() raise TimeoutError(f"Connection to {self.url} timed out") def _on_open(self, ws_app, *args):