From 5498bccf8545d45151be2180167a5b4409a8633f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 19:02:46 +0200 Subject: [PATCH] tray is somewhat capable of hangling single tray running --- client/ayon_core/cli_commands.py | 5 +- client/ayon_core/tools/tray/__init__.py | 5 + client/ayon_core/tools/tray/addons_manager.py | 4 + client/ayon_core/tools/tray/lib.py | 195 ++++++++++++++++-- client/ayon_core/tools/tray/ui/tray.py | 45 +++- .../tools/tray/webserver/__init__.py | 2 + .../tray/webserver/host_console_listener.py | 2 - .../ayon_core/tools/tray/webserver/server.py | 19 ++ .../tools/tray/webserver/webserver.py | 10 +- 9 files changed, 254 insertions(+), 33 deletions(-) diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 774ee3e847..9b19620e9a 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -12,10 +12,7 @@ class Commands: """ @staticmethod def launch_tray(): - from ayon_core.lib import Logger - from ayon_core.tools.tray.ui import main - - Logger.set_process_name("Tray") + from ayon_core.tools.tray import main main() diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index 534e7100f5..001b37e129 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,6 +1,11 @@ from .addons_manager import TrayAddonsManager +from .lib import ( + is_tray_running, + main, +) __all__ = ( "TrayAddonsManager", + "main", ) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index ad265298d0..5acf89c06d 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -146,6 +146,7 @@ class TrayAddonsManager(AddonsManager): def start_addons(self): self._tray_webserver.start() + report = {} time_start = time.time() prev_start_time = time_start @@ -185,3 +186,6 @@ class TrayAddonsManager(AddonsManager): ), exc_info=True ) + + def get_tray_webserver(self): + return self._tray_webserver diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 52e603daf0..ba16e5cbc5 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -1,8 +1,27 @@ -@@ -1,49 +0,0 @@ import os -from typing import Optional, Dict, Any +import json +import hashlib +import subprocess +import csv +import time +import signal +from typing import Optional, Dict, Tuple, Any import ayon_api +import requests + +from ayon_core.lib import Logger +from ayon_core.lib.local_settings import get_ayon_appdirs + + +class TrayState: + NOT_RUNNING = 0 + STARTING = 1 + RUNNING = 2 + + +class TrayIsRunningError(Exception): + pass def _get_default_server_url() -> str: @@ -13,38 +32,170 @@ def _get_default_variant() -> str: return ayon_api.get_default_settings_variant() -def get_tray_store_dir() -> str: - pass +def _get_server_and_variant( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Tuple[str, str]: + if not server_url: + server_url = _get_default_server_url() + if not variant: + variant = _get_default_variant() + return server_url, variant -def get_tray_information( - sever_url: str, variant: str +def _windows_pid_is_running(pid: int) -> bool: + args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"] + output = subprocess.check_output(args) + csv_content = csv.DictReader(output.decode("utf-8").splitlines()) + # if "PID" not in csv_content.fieldnames: + # return False + for _ in csv_content: + return True + return False + + +def _create_tray_hash(server_url: str, variant: str) -> str: + data = f"{server_url}|{variant}" + return hashlib.sha256(data.encode()).hexdigest() + + +def get_tray_storage_dir() -> str: + return get_ayon_appdirs("tray") + + +def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]: + # TODO implement server side information + response = requests.get(f"{tray_url}/tray") + try: + response.raise_for_status() + except requests.HTTPError: + return None + return response.json() + + +def _get_tray_info_filepath( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> str: + hash_dir = get_tray_storage_dir() + server_url, variant = _get_server_and_variant(server_url, variant) + filename = _create_tray_hash(server_url, variant) + return os.path.join(hash_dir, filename) + + +def get_tray_file_info( + server_url: Optional[str] = None, + variant: Optional[str] = None ) -> Optional[Dict[str, Any]]: - pass - - -def validate_tray_server(server_url: str) -> bool: - tray_info = get_tray_information(server_url) - if tray_info is None: - return False - return True + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return None + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except Exception: + return None + return data def get_tray_server_url( server_url: Optional[str] = None, variant: Optional[str] = None ) -> Optional[str]: - if not server_url: - server_url = _get_default_server_url() - if not variant: - variant = _get_default_variant() + data = get_tray_file_info(server_url, variant) + if data is None: + return None + return data.get("url") + + +def set_tray_server_url(tray_url: str, started: bool): + filepath = _get_tray_info_filepath() + if os.path.exists(filepath): + info = get_tray_file_info() + if info.get("pid") != os.getpid(): + raise TrayIsRunningError("Tray is already running.") + os.makedirs(os.path.dirname(filepath), exist_ok=True) + data = { + "url": tray_url, + "pid": os.getpid(), + "started": started + } + with open(filepath, "w") as stream: + json.dump(data, stream) + + +def remove_tray_server_url(): + filepath = _get_tray_info_filepath() + if not os.path.exists(filepath): + return + with open(filepath, "r") as stream: + data = json.load(stream) + if data.get("pid") != os.getpid(): + return + os.remove(filepath) + + +def get_tray_information( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Optional[Dict[str, Any]]: + tray_url = get_tray_server_url(server_url, variant) + return _get_tray_information(tray_url) + + +def get_tray_state( + server_url: Optional[str] = None, + variant: Optional[str] = None +): + file_info = get_tray_file_info(server_url, variant) + if file_info is None: + return TrayState.NOT_RUNNING + + if file_info.get("started") is False: + return TrayState.STARTING + + tray_url = file_info.get("url") + info = _get_tray_information(tray_url) + if not info: + # Remove the information as the tray is not running + remove_tray_server_url() + return TrayState.NOT_RUNNING + return TrayState.RUNNING def is_tray_running( server_url: Optional[str] = None, variant: Optional[str] = None ) -> bool: - server_url = get_tray_server_url(server_url, variant) - if server_url and validate_tray_server(server_url): - return True - return False + state = get_tray_state(server_url, variant) + return state != TrayState.NOT_RUNNING + + +def main(): + from ayon_core.tools.tray.ui import main + + Logger.set_process_name("Tray") + + state = get_tray_state() + if state == TrayState.RUNNING: + # TODO send some information to tray? + print("Tray is already running.") + return + + if state == TrayState.STARTING: + print("Tray is starting.") + return + # TODO try to handle stuck tray? + time.sleep(5) + state = get_tray_state() + if state == TrayState.RUNNING: + return + if state == TrayState.STARTING: + file_info = get_tray_file_info() or {} + pid = file_info.get("pid") + if pid is not None: + os.kill(pid, signal.SIGTERM) + remove_tray_server_url() + + main() + diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 2b038bcb5d..6900e80ed5 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -1,10 +1,12 @@ import os import sys +import time import collections import atexit - +import json import platform +from aiohttp.web_response import Response import ayon_api from qtpy import QtCore, QtGui, QtWidgets @@ -27,6 +29,11 @@ from ayon_core.tools.utils import ( get_ayon_qt_app, ) from ayon_core.tools.tray import TrayAddonsManager +from ayon_core.tools.tray.lib import ( + set_tray_server_url, + remove_tray_server_url, + TrayIsRunningError, +) from .info_widget import InfoWidget from .dialogs import ( @@ -68,6 +75,7 @@ class TrayManager: self._execution_in_progress = None self._closing = False self._services_submenu = None + self._start_time = time.time() @property def doubleclick_callback(self): @@ -105,6 +113,15 @@ class TrayManager: tray_menu = self.tray_widget.menu self._addons_manager.initialize(tray_menu) + webserver = self._addons_manager.get_tray_webserver() + try: + set_tray_server_url(webserver.webserver_url, False) + except TrayIsRunningError: + self.log.error("Tray is already running.") + self.exit() + return + + webserver.add_route("GET", "/tray", self._get_web_tray_info) admin_submenu = ITrayAction.admin_submenu(tray_menu) tray_menu.addMenu(admin_submenu) @@ -125,7 +142,15 @@ class TrayManager: tray_menu.addAction(exit_action) # Tell each addon which addons were imported - self._addons_manager.start_addons() + # TODO Capture only webserver issues (the only thing that can crash). + try: + self._addons_manager.start_addons() + except Exception: + self.log.error( + "Failed to start addons.", + exc_info=True + ) + return self.exit() # Print time report self._addons_manager.print_report() @@ -147,6 +172,8 @@ class TrayManager: self.execute_in_main_thread(self._startup_validations) + set_tray_server_url(webserver.webserver_url, True) + def get_services_submenu(self): return self._services_submenu @@ -213,6 +240,7 @@ class TrayManager: self.tray_widget.exit() def on_exit(self): + remove_tray_server_url() self._addons_manager.on_exit() def execute_in_main_thread(self, callback, *args, **kwargs): @@ -225,6 +253,19 @@ class TrayManager: return item + async def _get_web_tray_info(self, request): + return Response(text=json.dumps({ + "bundle": os.getenv("AYON_BUNDLE_NAME"), + "dev_mode": is_dev_mode_enabled(), + "staging_mode": is_staging_enabled(), + "addons": { + addon.name: addon.version + for addon in self._addons_manager.get_enabled_addons() + }, + "installer_version": os.getenv("AYON_VERSION"), + "running_time": time.time() - self._start_time, + })) + def _on_update_check_timer(self): try: bundles = ayon_api.get_bundles() diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py index 92b5c54e43..938e7205b4 100644 --- a/client/ayon_core/tools/tray/webserver/__init__.py +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -1,8 +1,10 @@ from .structures import HostMsgAction +from .base_routes import RestApiEndpoint from .webserver import TrayWebserver __all__ = ( "HostMsgAction", + "RestApiEndpoint", "TrayWebserver", ) diff --git a/client/ayon_core/tools/tray/webserver/host_console_listener.py b/client/ayon_core/tools/tray/webserver/host_console_listener.py index 3ec57d2598..2c1a7ae9b5 100644 --- a/client/ayon_core/tools/tray/webserver/host_console_listener.py +++ b/client/ayon_core/tools/tray/webserver/host_console_listener.py @@ -23,9 +23,7 @@ class IconType: class HostListener: def __init__(self, webserver, tray_manager): - self._window_per_id = {} self._tray_manager = tray_manager - self.webserver = webserver self._window_per_id = {} # dialogs per host name self._action_per_id = {} # QAction per host name diff --git a/client/ayon_core/tools/tray/webserver/server.py b/client/ayon_core/tools/tray/webserver/server.py index 2e0d1b258c..5b6e7e52d4 100644 --- a/client/ayon_core/tools/tray/webserver/server.py +++ b/client/ayon_core/tools/tray/webserver/server.py @@ -52,6 +52,25 @@ class WebServerManager: def add_static(self, prefix: str, path: str): self.app.router.add_static(prefix, path) + def add_addon_route( + self, + addon_name: str, + path: str, + request_method: str, + handler: Callable + ) -> str: + path = path.lstrip("/") + full_path = f"/addons/{addon_name}/{path}" + self.app.router.add_route(request_method, full_path, handler) + return full_path + + def add_addon_static( + self, addon_name: str, prefix: str, path: str + ) -> str: + full_path = f"/addons/{addon_name}/{prefix}" + self.app.router.add_static(full_path, path) + return full_path + def start_server(self): if self.webserver_thread and not self.webserver_thread.is_alive(): self.webserver_thread.start() diff --git a/client/ayon_core/tools/tray/webserver/webserver.py b/client/ayon_core/tools/tray/webserver/webserver.py index 0a19fd5b07..a013bdf19a 100644 --- a/client/ayon_core/tools/tray/webserver/webserver.py +++ b/client/ayon_core/tools/tray/webserver/webserver.py @@ -13,6 +13,7 @@ work as expected. It is because of few limitations connected to asyncio module. import os import socket +from typing import Callable from ayon_core import resources from ayon_core.lib import Logger @@ -39,7 +40,7 @@ class TrayWebserver: static_prefix = "/res" self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR) statisc_url = "{}{}".format( - self._webserver_url, static_prefix + webserver_url, static_prefix ) os.environ[self.webserver_url_env] = str(webserver_url) @@ -55,8 +56,11 @@ class TrayWebserver: self._log = Logger.get_logger("TrayWebserver") return self._log - def add_route(self, *args, **kwargs): - self._server_manager.add_route(*args, **kwargs) + def add_route(self, request_method: str, path: str, handler: Callable): + self._server_manager.add_route(request_method, path, handler) + + def add_static(self, prefix: str, path: str): + self._server_manager.add_static(prefix, path) @property def server_manager(self):