From 65e0cb69895bf5ebaecfdbbe595775e337c430bd Mon Sep 17 00:00:00 2001 From: rlahmidi Date: Fri, 15 May 2026 17:11:10 +0200 Subject: [PATCH 1/2] FIX rpc default timeout value --- README.md | 2 +- docs/usage/usage.md | 19 ++++++++++--------- pyproject.toml | 8 +++++++- pytvpaint/george/client/__init__.py | 18 +++++++++++++++--- pytvpaint/george/client/rpc.py | 29 +++++++++++++++++------------ 5 files changed, 50 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b170bca..53fa467 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![](https://img.shields.io/pypi/v/pytvpaint)](https://pypi.org/project/pytvpaint/) [![Downloads](https://static.pepy.tech/badge/pytvpaint/month)](https://pepy.tech/project/pytvpaint) [![](https://img.shields.io/pypi/pyversions/pytvpaint)](https://pypi.org/project/pytvpaint/) -[![](https://custom-icon-badges.demolab.com/badge/custom-11.5+-blue.svg?logo=butterfly_1f98b&label=TVPaint)](https://www.tvpaint.com/doc/tvp11/) +[![](https://custom-icon-badges.demolab.com/badge/custom-11.5+-blue.svg?logo=butterfly_1f98b&label=TVPaint)](https://doc.tvpaint.com/docs/)

diff --git a/docs/usage/usage.md b/docs/usage/usage.md index b6c9fd5..8166905 100644 --- a/docs/usage/usage.md +++ b/docs/usage/usage.md @@ -12,15 +12,16 @@ their George counterparts. These can be imported from `pytvpaint.george.*`. PyTVPaint can be configured using these variables: -| Name | Default value | Description | -|:-------------------------------|:-----------------|:-------------------------------------------------------------------------------------------------------------------------------| -| `PYTVPAINT_LOG_LEVEL` | `INFO` | Changes the log level of PyTVPaint. Use the `DEBUG` value to see the RPC requests and responses for debugging George commands. | -| `PYTVPAINT_LOG_PATH` | `` | Log file output path, default is `` | -| `PYTVPAINT_WS_HOST` | `ws://localhost` | The hostname of the RPC over WebSocket server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | -| `PYTVPAINT_WS_PORT` | `3000` | The port of the RPC over WebSocket server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | -| `PYTVPAINT_WS_STARTUP_CONNECT` | `1` | Whether or not PyTVPaint should automatically connect to the WebSocket server at startup (module import). Accepts 0 or 1. | -| `PYTVPAINT_WS_TIMEOUT` | `60` seconds | The timeout after which we stop reconnecting at startup or if the connection was lost. | -| `TVP_RPC_LOG_PATH` | `` | C++ plugin log file output path, default is `` +| Name | Default value | Description | +|:----------------------------------|:-----------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `PYTVPAINT_LOG_LEVEL` | `INFO` | Changes the log level of PyTVPaint. Use the `DEBUG` value to see the RPC requests and responses for debugging George commands. | +| `PYTVPAINT_LOG_PATH` | | Log file output path, if none provided no file log will be written. | +| `PYTVPAINT_WS_HOST` | `ws://localhost` | The hostname of the RPC server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | +| `PYTVPAINT_WS_PORT` | `3000` | The port of the RPC server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | +| `PYTVPAINT_WS_TIMEOUT` | `60` seconds | The timeout in seconds before the client considers the connection lost. | +| `PYTVPAINT_WS_MAX_RETRIES` | `5` | The maximum number of failed probes before the client consideres the connection lost. | +| `PYTVPAINT_WS_STARTUP_CONNECT` | `1` | Whether or not PyTVPaint should automatically connect to the WebSocket server at startup (module import). Accepts 0 or 1. | +| `TVP_RPC_LOG_PATH` | | C++ plugin log file output path, if none provided no file log will be written. ## Automatic client connection diff --git a/pyproject.toml b/pyproject.toml index 2dcac97..30b5e07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,18 @@ dynamic = ["version"] description = "Python scripting for TVPaint" readme = "README.md" license = { text = "MIT" } -requires-python = ">=3.9" authors = [ { name = "Brunch Studio Developers", email = "dev@brunchstudio.tv" }, { name = "Radouane Lahmidi", email = "rlahmidi@brunchstudio.tv" }, { name = "Joseph Henry", email = "jhenry@brunchstudio.tv" }, ] +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11" +] keywords = ["tvpaint", "brunch", "tvp", "george"] dependencies = [ "websocket-client>=1.7.0", diff --git a/pytvpaint/george/client/__init__.py b/pytvpaint/george/client/__init__.py index b722738..f292909 100644 --- a/pytvpaint/george/client/__init__.py +++ b/pytvpaint/george/client/__init__.py @@ -21,13 +21,18 @@ DEFAULT_HOST = "ws://localhost" DEFAULT_PORT = 3000 DEFAULT_TIMEOUT = 60 +DEFAULT_MAX_RETRIES = 5 def _connect_client( - host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, timeout: int = DEFAULT_TIMEOUT, startup_connect: bool = True + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + timeout: int | None = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + startup_connect: bool = True, ) -> JSONRPCClient: - _rpc_client = JSONRPCClient(f"{host}:{port}", timeout) + _rpc_client = JSONRPCClient(url=f"{host}:{port}", timeout=timeout, max_retries=max_retries) if not startup_connect: log.debug("Auto Connect Disabled, RPC client is not connected") return _rpc_client @@ -63,8 +68,15 @@ def _connect_client( _rpc_host = os.getenv("PYTVPAINT_WS_HOST", DEFAULT_HOST) _rpc_port = int(os.getenv("PYTVPAINT_WS_PORT", DEFAULT_PORT)) _rpc_timeout = int(os.getenv("PYTVPAINT_WS_TIMEOUT", DEFAULT_TIMEOUT)) +_rpc_max_retries = int(os.getenv("PYTVPAINT_WS_MAX_RETRIES", DEFAULT_MAX_RETRIES)) _rpc_startup_connect = bool(int(os.getenv("PYTVPAINT_WS_STARTUP_CONNECT", 1))) -rpc_client = _connect_client(_rpc_host, _rpc_port, _rpc_timeout, _rpc_startup_connect) +rpc_client = _connect_client( + host=_rpc_host, + port=_rpc_port, + timeout=_rpc_timeout, + max_retries=_rpc_max_retries, + startup_connect=_rpc_startup_connect, +) T = TypeVar("T", bound=Callable[..., Any]) diff --git a/pytvpaint/george/client/rpc.py b/pytvpaint/george/client/rpc.py index 0c6693c..c914663 100644 --- a/pytvpaint/george/client/rpc.py +++ b/pytvpaint/george/client/rpc.py @@ -62,7 +62,7 @@ class JSONRPCClient: See: https://www.jsonrpc.org/specification#notification """ - def __init__(self, url: str, timeout: int = 60, max_retries: int = 5, version: str = "2.0") -> None: + def __init__(self, url: str, timeout: int | None = 60, max_retries: int = 5, version: str = "2.0") -> None: """Initialize a new JSON-RPC client with a WebSocket url endpoint. Args: @@ -74,9 +74,9 @@ def __init__(self, url: str, timeout: int = 60, max_retries: int = 5, version: s self.ws_handle = WebSocket() self.url = url self.rpc_id = 0 - self.timeout = timeout self.max_retries = max_retries self.jsonrpc_version = version + self.timeout = timeout def __del__(self) -> None: """Called when the client goes out of scope.""" @@ -104,22 +104,27 @@ def is_connected(self) -> bool: return True - def connect(self, timeout: int = 0) -> None: + def connect(self) -> None: """Connects to the WebSocket endpoint and configures TCP keepalive.""" - self.ws_handle.connect(self.url, timeout=timeout) + if self.timeout is not None and self.timeout <= 0: + raise ValueError("Timeout cannot be 0 or lower. Use None for infinite blocking or > 0 for a timed block.") + self.ws_handle.connect(self.url, timeout=self.timeout) if self.ws_handle.sock: sock = self.ws_handle.sock - sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.settimeout(self.timeout) - # Apply OS-specific keepalive configurations if available - if hasattr(socket, "TCP_KEEPIDLE"): - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, self.timeout) - if hasattr(socket, "TCP_KEEPINTVL"): - probe_interval = max(1, max(self.timeout, 1) // 6) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, probe_interval) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + if self.timeout is not None: + if hasattr(socket, "TCP_KEEPIDLE"): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, self.timeout) + # Interval between probes + if hasattr(socket, "TCP_KEEPINTVL"): + probe_interval = max(1, self.timeout // 6) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, probe_interval) + # Number of failed probes if hasattr(socket, "TCP_KEEPCNT"): - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, self.max_retries) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, max(1, self.max_retries)) def disconnect(self) -> None: """Disconnects from the server.""" From 1f03d6fa15119ced32686da543e148b6e4f27263 Mon Sep 17 00:00:00 2001 From: rlahmidi Date: Fri, 15 May 2026 20:24:00 +0200 Subject: [PATCH 2/2] REVERT socket setup --- docs/usage/usage.md | 1 - pytvpaint/george/client/__init__.py | 6 +- pytvpaint/george/client/rpc.py | 93 ++++++++++++++++++----------- 3 files changed, 60 insertions(+), 40 deletions(-) diff --git a/docs/usage/usage.md b/docs/usage/usage.md index 8166905..551a424 100644 --- a/docs/usage/usage.md +++ b/docs/usage/usage.md @@ -19,7 +19,6 @@ PyTVPaint can be configured using these variables: | `PYTVPAINT_WS_HOST` | `ws://localhost` | The hostname of the RPC server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | | `PYTVPAINT_WS_PORT` | `3000` | The port of the RPC server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | | `PYTVPAINT_WS_TIMEOUT` | `60` seconds | The timeout in seconds before the client considers the connection lost. | -| `PYTVPAINT_WS_MAX_RETRIES` | `5` | The maximum number of failed probes before the client consideres the connection lost. | | `PYTVPAINT_WS_STARTUP_CONNECT` | `1` | Whether or not PyTVPaint should automatically connect to the WebSocket server at startup (module import). Accepts 0 or 1. | | `TVP_RPC_LOG_PATH` | | C++ plugin log file output path, if none provided no file log will be written. diff --git a/pytvpaint/george/client/__init__.py b/pytvpaint/george/client/__init__.py index f292909..a76d074 100644 --- a/pytvpaint/george/client/__init__.py +++ b/pytvpaint/george/client/__init__.py @@ -21,18 +21,16 @@ DEFAULT_HOST = "ws://localhost" DEFAULT_PORT = 3000 DEFAULT_TIMEOUT = 60 -DEFAULT_MAX_RETRIES = 5 def _connect_client( host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, timeout: int | None = DEFAULT_TIMEOUT, - max_retries: int = DEFAULT_MAX_RETRIES, startup_connect: bool = True, ) -> JSONRPCClient: - _rpc_client = JSONRPCClient(url=f"{host}:{port}", timeout=timeout, max_retries=max_retries) + _rpc_client = JSONRPCClient(url=f"{host}:{port}", timeout=timeout) if not startup_connect: log.debug("Auto Connect Disabled, RPC client is not connected") return _rpc_client @@ -68,13 +66,11 @@ def _connect_client( _rpc_host = os.getenv("PYTVPAINT_WS_HOST", DEFAULT_HOST) _rpc_port = int(os.getenv("PYTVPAINT_WS_PORT", DEFAULT_PORT)) _rpc_timeout = int(os.getenv("PYTVPAINT_WS_TIMEOUT", DEFAULT_TIMEOUT)) -_rpc_max_retries = int(os.getenv("PYTVPAINT_WS_MAX_RETRIES", DEFAULT_MAX_RETRIES)) _rpc_startup_connect = bool(int(os.getenv("PYTVPAINT_WS_STARTUP_CONNECT", 1))) rpc_client = _connect_client( host=_rpc_host, port=_rpc_port, timeout=_rpc_timeout, - max_retries=_rpc_max_retries, startup_connect=_rpc_startup_connect, ) diff --git a/pytvpaint/george/client/rpc.py b/pytvpaint/george/client/rpc.py index c914663..1aba36d 100644 --- a/pytvpaint/george/client/rpc.py +++ b/pytvpaint/george/client/rpc.py @@ -2,14 +2,19 @@ from __future__ import annotations +import contextlib import json import select import socket import sys +import threading +from time import time from typing import Any, Union, cast from typing_extensions import NotRequired, TypedDict -from websocket import WebSocket +from websocket import WebSocket, WebSocketException + +from pytvpaint import log JSONValueType = Union[str, int, float, bool, None] @@ -57,26 +62,52 @@ def __init__(self, error: JSONRPCError) -> None: class JSONRPCClient: - """Simple JSON-RPC 2.0 client over websockets. + """Simple JSON-RPC 2.0 client over websockets with thread-safe automatic reconnection. See: https://www.jsonrpc.org/specification#notification """ - def __init__(self, url: str, timeout: int | None = 60, max_retries: int = 5, version: str = "2.0") -> None: + def __init__(self, url: str, timeout: int | None = 60, version: str = "2.0") -> None: """Initialize a new JSON-RPC client with a WebSocket url endpoint. Args: url: the WebSocket url endpoint - timeout: the socket operation timeout - max_retries: the maximum socket connection retries + timeout: the continuous reconnection timeout threshold version: The JSON-RPC version. Defaults to "2.0". """ self.ws_handle = WebSocket() self.url = url self.rpc_id = 0 - self.max_retries = max_retries - self.jsonrpc_version = version self.timeout = timeout + self.jsonrpc_version = version + + self.stop_ping = threading.Event() + self.run_forever = False + self.ping_thread: threading.Thread | None = None + + def _auto_reconnect(self) -> None: + """Automatic WebSocket reconnection in a background thread.""" + disconnect_time: float = 0 + + while self.run_forever and not self.stop_ping.wait(1): + if self.is_connected: + disconnect_time = 0 + with contextlib.suppress(WebSocketException, OSError): + self.ws_handle.ping() + continue + + if disconnect_time == 0: + disconnect_time = time() + + if self.timeout and (time() - disconnect_time) > self.timeout: + log.error("WebSocket automatic reconnection timeout exceeded.") + self.run_forever = False + break + + with contextlib.suppress(Exception): + self.ws_handle.close() + self.ws_handle.connect(self.url, timeout=self.timeout) + log.info(f"Reconnected automatically to endpoint: {self.url}") def __del__(self) -> None: """Called when the client goes out of scope.""" @@ -84,50 +115,44 @@ def __del__(self) -> None: @property def is_connected(self) -> bool: - """Returns True if the client is connected and the socket is active.""" + """Returns True if the client is connected and the socket is physically active.""" if not self.ws_handle.connected or self.ws_handle.sock is None: return False try: - # check if the socket is readable. + # Non-blocking check to determine if the socket has received a FIN packet readable_sockets, _, _ = select.select([self.ws_handle.sock], [], [], 0.0) if readable_sockets: - # MSG_PEEK reads data without consuming it from the buffer. - # If recv returns 0 bytes on a readable socket, the peer has disconnected. data = self.ws_handle.sock.recv(1, socket.MSG_PEEK) if not data: return False except (BlockingIOError, InterruptedError): - pass # Normal non-blocking behavior + pass except Exception: - return False # Socket error indicates disconnection + return False return True def connect(self) -> None: - """Connects to the WebSocket endpoint and configures TCP keepalive.""" - if self.timeout is not None and self.timeout <= 0: - raise ValueError("Timeout cannot be 0 or lower. Use None for infinite blocking or > 0 for a timed block.") - + """Connects to the WebSocket endpoint and initializes the monitoring thread.""" + if self.timeout == 0: + raise ValueError("Timeout cannot be 0. Use None for infinite blocking or > 0 for a timed block.") self.ws_handle.connect(self.url, timeout=self.timeout) - if self.ws_handle.sock: - sock = self.ws_handle.sock - sock.settimeout(self.timeout) - - sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - if self.timeout is not None: - if hasattr(socket, "TCP_KEEPIDLE"): - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, self.timeout) - # Interval between probes - if hasattr(socket, "TCP_KEEPINTVL"): - probe_interval = max(1, self.timeout // 6) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, probe_interval) - # Number of failed probes - if hasattr(socket, "TCP_KEEPCNT"): - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, max(1, self.max_retries)) + + if not self.ping_thread or not self.ping_thread.is_alive(): + self.stop_ping.clear() + self.run_forever = True + self.ping_thread = threading.Thread(target=self._auto_reconnect, daemon=True) + self.ping_thread.start() def disconnect(self) -> None: - """Disconnects from the server.""" + """Disconnects from the server and halts the monitoring thread.""" + self.run_forever = False + self.stop_ping.set() + + if self.ping_thread and self.ping_thread.is_alive(): + self.ping_thread.join(timeout=2) + self.ws_handle.close() def increment_rpc_id(self) -> None: @@ -139,7 +164,7 @@ def execute_remote( method: str, params: list[JSONValueType] | None = None, ) -> JSONRPCResponse: - """Executes a remote procedure call. + """Executes a remote procedure call synchronously. Args: method: the name of the method to be invoked