From e33532853070191c8ad6998332fdf82c882c4502 Mon Sep 17 00:00:00 2001 From: Brett Date: Tue, 30 Dec 2025 17:46:53 -0600 Subject: [PATCH] Improve server shutdown (#126) * Cleaned up server shutdown process * Fix exception on shutdown in Windows --- client.py | 22 ++++++++-- server.py | 117 +++++++++++++++++++++++++----------------------------- 2 files changed, 71 insertions(+), 68 deletions(-) diff --git a/client.py b/client.py index b43ddd8..4c0c364 100755 --- a/client.py +++ b/client.py @@ -3,7 +3,7 @@ import logging import threading from collections import deque -from server import start_server +from server import ZordonServer logger = logging.getLogger() @@ -13,6 +13,7 @@ def __setup_buffer_handler(): class BufferingHandler(logging.Handler, QObject): new_record = pyqtSignal(str) + flushOnClose = True def __init__(self, capacity=100): logging.Handler.__init__(self) @@ -52,12 +53,25 @@ def __show_gui(buffer_handler): window.buffer_handler = buffer_handler window.show() - return app.exec() + exit_code = app.exec() + + # cleanup: remove and close the GUI logging handler before interpreter shutdown + root_logger = logging.getLogger() + if buffer_handler in root_logger.handlers: + root_logger.removeHandler(buffer_handler) + try: + buffer_handler.close() + except Exception: + # never let logging cleanup throw during shutdown + pass + + return exit_code if __name__ == '__main__': import sys - local_server_thread = threading.Thread(target=start_server, args=[True], daemon=True) - local_server_thread.start() + server = ZordonServer() + server.start_server() __show_gui(__setup_buffer_handler()) + server.stop_server() sys.exit() diff --git a/server.py b/server.py index b21148e..97f4908 100755 --- a/server.py +++ b/server.py @@ -24,53 +24,15 @@ from src.version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER logger = logging.getLogger() -def start_server(skip_updates=False) -> int: - """Initializes the application and runs it. +class ZordonServer: - Args: - server_only: Run in server-only CLI mode. Default is False (runs in GUI mode). + def __init__(self): + # setup logging + logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S', + level=Config.server_log_level.upper()) + logging.getLogger("requests").setLevel(logging.WARNING) # suppress noisy requests/urllib3 logging + logging.getLogger("urllib3").setLevel(logging.WARNING) - Returns: - int: The exit status code. - """ - - def existing_process(process_name): - import psutil - current_pid = os.getpid() - current_process = psutil.Process(current_pid) - for proc in psutil.process_iter(['pid', 'name', 'ppid']): - proc_name = proc.info['name'].lower().rstrip('.exe') - if proc_name == process_name.lower() and proc.info['pid'] != current_pid: - if proc.info['pid'] == current_process.ppid(): - continue # parent process - elif proc.info['ppid'] == current_pid: - continue # child process - else: - return proc # unrelated process - return None - - # setup logging - logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S', - level=Config.server_log_level.upper()) - logging.getLogger("requests").setLevel(logging.WARNING) # suppress noisy requests/urllib3 logging - logging.getLogger("urllib3").setLevel(logging.WARNING) - - # check for existing instance - existing_proc = existing_process(APP_NAME) - if existing_proc: - logger.fatal(f"Another instance of {APP_NAME} is already running (pid: {existing_proc.pid})") - sys.exit(1) - - # check for updates - if not skip_updates: - update_thread = threading.Thread(target=check_for_updates, args=(APP_REPO_NAME, APP_REPO_OWNER, APP_NAME, - APP_VERSION)) - update_thread.start() - - # main start - logger.info(f"Starting {APP_NAME} Render Server") - return_code = 0 - try: # Load Config YAML Config.setup_config_dir() Config.load_config(system_safe_path(os.path.join(Config.config_dir(), 'config.yaml'))) @@ -88,6 +50,34 @@ def start_server(skip_updates=False) -> int: logger.debug(f"Thumbs directory: {PreviewManager.storage_path}") logger.debug(f"Engines directory: {EngineManager.engines_path}") + self.api_server = None + + def start_server(self): + + def existing_process(process_name): + import psutil + current_pid = os.getpid() + current_process = psutil.Process(current_pid) + for proc in psutil.process_iter(['pid', 'name', 'ppid']): + proc_name = proc.info['name'].lower().rstrip('.exe') + if proc_name == process_name.lower() and proc.info['pid'] != current_pid: + if proc.info['pid'] == current_process.ppid(): + continue # parent process + elif proc.info['ppid'] == current_pid: + continue # child process + else: + return proc # unrelated process + return None + + # check for existing instance + existing_proc = existing_process(APP_NAME) + if existing_proc: + err_msg = f"Another instance of {APP_NAME} is already running (pid: {existing_proc.pid})" + logger.fatal(err_msg) + raise ProcessLookupError(err_msg) + + # main start + logger.info(f"Starting {APP_NAME} Render Server") # Set up the RenderQueue object RenderQueue.load_state(database_directory=system_safe_path(os.path.expanduser(Config.upload_folder))) ServerProxyManager.subscribe_to_listener() @@ -97,9 +87,9 @@ def start_server(skip_updates=False) -> int: local_hostname = socket.gethostname() # configure and start API server - api_server = threading.Thread(target=start_api_server, args=(local_hostname,)) - api_server.daemon = True - api_server.start() + self.api_server = threading.Thread(target=start_api_server, args=(local_hostname,)) + self.api_server.daemon = True + self.api_server.start() # start zeroconf server ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", local_hostname, Config.port_number) @@ -115,27 +105,26 @@ def start_server(skip_updates=False) -> int: logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}") RenderQueue.start() # Start evaluating the render queue - # check for updates for render engines if configured or on first launch - # if Config.update_engines_on_launch or not EngineManager.get_engines(): - # EngineManager.update_all_engines() + def is_running(self): + return self.api_server and self.api_server.is_alive() - api_server.join() - - except KeyboardInterrupt: - pass - except Exception as e: - logging.error(f"Unhandled exception: {e}") - return_code = 1 - finally: - # shut down gracefully - logger.info(f"{APP_NAME} Render Server is preparing to shut down") + def stop_server(self): + logger.info(f"{APP_NAME} Render Server is preparing to stop") try: + ZeroconfServer.stop() RenderQueue.prepare_for_shutdown() except Exception as e: logger.exception(f"Exception during prepare for shutdown: {e}") - ZeroconfServer.stop() logger.info(f"{APP_NAME} Render Server has shut down") - return sys.exit(return_code) if __name__ == '__main__': - start_server() \ No newline at end of file + server = ZordonServer() + try: + server.start_server() + server.api_server.join() + except KeyboardInterrupt: + pass + except Exception as e: + logger.error(f"Unhandled exception: {e}") + finally: + server.stop_server()