fix: add timeouts to all WebSocket operations

- WS_TIMEOUT (8s) for all WebSocket commands via ThreadPoolExecutor
- SIGALRM hard timeout for list_apps (recv blocks indefinitely)
- Fallback to known app aliases when TV doesn't respond to app_list
- SamsungTVWS constructor now passes timeout parameter
- Prevents MCP tools from hanging forever on unresponsive TV

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andres Eduardo Garcia Marquez 2026-03-02 01:59:17 -05:00
parent f12556bb43
commit 4dd9132a9b
1 changed files with 51 additions and 9 deletions

60
tv.py
View File

@ -7,12 +7,15 @@ import logging
import socket import socket
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.error import URLError from urllib.error import URLError
WS_TIMEOUT = 8 # seconds — max wait for any WebSocket operation
from samsungtvws import SamsungTVWS from samsungtvws import SamsungTVWS
log = logging.getLogger("samsung-tv") log = logging.getLogger("samsung-tv")
@ -202,14 +205,16 @@ class SamsungTV:
ip = self._ensure_ip() ip = self._ensure_ip()
try: try:
self._ws = SamsungTVWS( self._ws = SamsungTVWS(
host=ip, port=8002, token_file=TOKEN_FILE, name="ClaudeCode" host=ip, port=8002, token_file=TOKEN_FILE,
timeout=WS_TIMEOUT, name="ClaudeCode",
) )
self._ws.open() self._ws.open()
log.info("Connected via WSS:8002") log.info("Connected via WSS:8002")
except Exception: except Exception:
log.warning("WSS:8002 failed, trying WS:8001") log.warning("WSS:8002 failed, trying WS:8001")
self._ws = SamsungTVWS( self._ws = SamsungTVWS(
host=ip, port=8001, token_file=TOKEN_FILE, name="ClaudeCode" host=ip, port=8001, token_file=TOKEN_FILE,
timeout=WS_TIMEOUT, name="ClaudeCode",
) )
self._ws.open() self._ws.open()
return self._ws return self._ws
@ -218,12 +223,21 @@ class SamsungTV:
self._ws = None self._ws = None
return self._ensure_ws() return self._ensure_ws()
def _send_ws(self, method: str, **kwargs: Any) -> Any: def _send_ws(self, method: str, timeout: float = WS_TIMEOUT, **kwargs: Any) -> Any:
"""Send WebSocket command with auto-reconnect.""" """Send WebSocket command with auto-reconnect and timeout."""
ws = self._ensure_ws() ws = self._ensure_ws()
for attempt in range(2): for attempt in range(2):
try: try:
return getattr(ws, method)(**kwargs) with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(getattr(ws, method), **kwargs)
return future.result(timeout=timeout)
except FuturesTimeout:
log.error("WS %s timed out after %ss", method, timeout)
self._ws = None
raise TimeoutError(
f"TV did not respond to '{method}' within {timeout}s. "
"TV may be unresponsive or in a state that doesn't support this command."
)
except Exception as e: except Exception as e:
if attempt == 0: if attempt == 0:
log.warning("WS error (%s), reconnecting...", e) log.warning("WS error (%s), reconnecting...", e)
@ -376,11 +390,39 @@ class SamsungTV:
return name_or_id return name_or_id
def list_apps(self) -> list[dict[str, Any]]: def list_apps(self) -> list[dict[str, Any]]:
import signal
def _timeout_handler(signum, frame):
raise TimeoutError("app_list timeout")
from samsungtvws.remote import ChannelEmitCommand
from samsungtvws.helper import process_api_response
from samsungtvws.event import ED_INSTALLED_APP_EVENT, parse_installed_app
ws = self._ensure_ws() ws = self._ensure_ws()
raw = ws.app_list() assert ws.connection
if not raw:
return [] old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
return [{"id": a.get("appId"), "name": a.get("name", "Unknown")} for a in raw] signal.alarm(WS_TIMEOUT)
try:
ws._ws_send(ChannelEmitCommand.get_installed_app())
response = process_api_response(ws.connection.recv())
signal.alarm(0)
if response.get("event") == ED_INSTALLED_APP_EVENT:
raw = parse_installed_app(response)
return [{"id": a.get("appId"), "name": a.get("name", "Unknown")} for a in (raw or [])]
return self._known_apps_fallback()
except (TimeoutError, Exception) as e:
signal.alarm(0)
self._ws = None
log.warning("list_apps from TV failed (%s), using known aliases", e)
return self._known_apps_fallback()
finally:
signal.signal(signal.SIGALRM, old_handler)
@staticmethod
def _known_apps_fallback() -> list[dict[str, Any]]:
return [{"id": ids[0], "name": name.title()} for name, ids in APP_ALIASES.items()]
def launch_app( def launch_app(
self, name_or_id: str, meta_tag: str | None = None self, name_or_id: str, meta_tag: str | None = None