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..2a9e83b63 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,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 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..ce2b1b2da 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("STOMP client 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