diff --git a/abogen/application/cleanup.py b/abogen/application/cleanup.py index fa22cc2..87b2323 100644 --- a/abogen/application/cleanup.py +++ b/abogen/application/cleanup.py @@ -11,6 +11,7 @@ Called by shutdown.py at process exit and by run_conversion() per-conversion. from __future__ import annotations import gc +import sys from typing import Callable _UI_CLEANUPS: list[Callable[[], None]] = [] @@ -19,8 +20,12 @@ _UI_CLEANUPS: list[Callable[[], None]] = [] def flush_cuda() -> None: """Run GC and release CUDA cache. Safe to call multiple times.""" gc.collect() + # Skip entirely if torch was never imported — importing it here just to + # check would add several seconds to shutdown with nothing to flush. + if "torch" not in sys.modules: + return try: - import torch + torch = sys.modules["torch"] if torch.cuda.is_available(): torch.cuda.empty_cache() torch.cuda.ipc_collect() diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index 9ed5bc7..3e7ecee 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -5,9 +5,12 @@ import tempfile import platform import base64 import re +import logging from abogen.pyqt.queue_manager_gui import QueueManager from abogen.pyqt.queued_item import QueuedItem +_log = logging.getLogger("abogen.gui") + import abogen.hf_tracker as hf_tracker import hashlib # Added for cache path generation from PyQt6.QtWidgets import ( @@ -3169,14 +3172,25 @@ class abogen(QWidget): save_config(self.config) def cleanup_conversion_thread(self): - # Stop conversion thread + # Stop conversion thread (bounded wait so closing never hangs) if ( hasattr(self, "conversion_thread") and self.conversion_thread is not None and self.conversion_thread.isRunning() ): + _log.info("Close: stopping conversion thread") + start = time.perf_counter() self.conversion_thread.cancel() - self.conversion_thread.wait() + if not self.conversion_thread.wait(2000): + _log.warning("Close: conversion thread did not stop in 2s, terminating") + self.conversion_thread.terminate() + self.conversion_thread.wait(1000) + _log.info( + "Close: conversion thread stopped in %.2fs", + time.perf_counter() - start, + ) + else: + _log.info("Close: no running conversion thread") def cleanup_preview_threads(self): # Stop preview generation thread @@ -3185,8 +3199,13 @@ class abogen(QWidget): and self.preview_thread is not None and self.preview_thread.isRunning() ): + _log.info("Close: terminating preview thread") + start = time.perf_counter() self.preview_thread.terminate() - self.preview_thread.wait() + self.preview_thread.wait(1000) + _log.info( + "Close: preview thread stopped in %.2fs", time.perf_counter() - start + ) # Stop audio playback thread if ( @@ -3194,8 +3213,13 @@ class abogen(QWidget): and self.play_audio_thread is not None and self.play_audio_thread.isRunning() ): + _log.info("Close: stopping audio playback thread") + start = time.perf_counter() self.play_audio_thread.stop() - self.play_audio_thread.wait() + self.play_audio_thread.wait(1000) + _log.info( + "Close: audio thread stopped in %.2fs", time.perf_counter() - start + ) # Cleanup pygame mixer if initialized try: @@ -3206,6 +3230,7 @@ class abogen(QWidget): pass def closeEvent(self, event): + _log.info("Close: window close requested (converting=%s)", self.is_converting) if self.is_converting: box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Warning) @@ -3218,16 +3243,14 @@ class abogen(QWidget): ) box.setDefaultButton(QMessageBox.StandardButton.No) if box.exec() == QMessageBox.StandardButton.Yes: - from abogen import shutdown - shutdown.request_shutdown() + _log.info("Close: user confirmed exit during conversion") self.cleanup_conversion_thread() self.cleanup_preview_threads() event.accept() else: + _log.info("Close: user cancelled exit") event.ignore() else: - from abogen import shutdown - shutdown.request_shutdown() self.cleanup_conversion_thread() self.cleanup_preview_threads() event.accept() diff --git a/abogen/pyqt/main.py b/abogen/pyqt/main.py index e7d135a..833676a 100644 --- a/abogen/pyqt/main.py +++ b/abogen/pyqt/main.py @@ -164,6 +164,9 @@ def main(): with timed_log("QApplication creation", logger=_log): app = QApplication(sys.argv) + # Qt shutdown hook must be connected AFTER QApplication exists + shutdown.install_qt_hook() + # Set application icon using get_resource_path from utils icon_path = get_resource_path("abogen.assets", "icon.ico") if icon_path: @@ -181,7 +184,11 @@ def main(): with timed_log("window show", logger=_log): ex.show() _log.info("App startup complete. Showing window.") - sys.exit(app.exec()) + rc = app.exec() + # Restore the default Qt message handler BEFORE interpreter shutdown. + # A Python message handler invoked during Qt teardown segfaults (SIGSEGV). + qInstallMessageHandler(None) + sys.exit(rc) if __name__ == "__main__": diff --git a/abogen/shutdown.py b/abogen/shutdown.py index 60e0ca5..8c8f44a 100644 --- a/abogen/shutdown.py +++ b/abogen/shutdown.py @@ -14,10 +14,14 @@ Per-conversion cleanup lives in run_conversion() finally block. from __future__ import annotations import atexit +import logging import signal import sys +import time from typing import Callable +_log = logging.getLogger("abogen.shutdown") + _CLEANUP_FUNCS: list[Callable[[], None]] = [] _EXECUTED = False @@ -32,11 +36,17 @@ def _run_cleanups() -> None: if _EXECUTED: return _EXECUTED = True + _log.info("Shutdown: starting %d cleanup hook(s)", len(_CLEANUP_FUNCS)) for fn in _CLEANUP_FUNCS: + start = time.perf_counter() try: fn() except Exception: pass + _log.info( + "Shutdown: %s done in %.2fs", fn.__name__, time.perf_counter() - start + ) + _log.info("Shutdown: all cleanups finished") # ---- Process-level cleanup functions ---- @@ -117,13 +127,19 @@ def register_shutdown() -> None: except Exception: pass - # Qt hook — connect AFTER QApplication is created + install_qt_hook() + + +def install_qt_hook() -> None: + """Connect Qt aboutToQuit to cleanup. Must run AFTER QApplication is created.""" try: from PyQt6.QtWidgets import QApplication app = QApplication.instance() - if app is not None: + if app is not None and not getattr(app, "_abogen_cleanup_connected", False): app.aboutToQuit.connect(_run_cleanups) + app._abogen_cleanup_connected = True + _log.info("Shutdown: Qt aboutToQuit hook connected") except Exception: pass @@ -132,13 +148,15 @@ register_shutdown._registered = False def _on_signal(signum: int, _frame) -> None: + _log.info("Shutdown: signal %s received", signum) _run_cleanups() sys.exit(0) def request_shutdown() -> None: """Programmatically trigger cleanup (e.g., from GUI closeEvent).""" + _log.info("Shutdown: cleanup requested") _run_cleanups() -__all__ = ["register_shutdown", "request_shutdown", "register_cleanup"] +__all__ = ["register_shutdown", "install_qt_hook", "request_shutdown", "register_cleanup"]