fix(pyqt): make app close fast and crash-free

Clicking the window close button froze the app: closeEvent ran the full
process cleanup (engine disposal, CUDA flush, subprocess termination) and
unbounded thread joins synchronously on the GUI thread before the window
could start closing, and a 3.5s delay came from flush_cuda importing
torch even when it was never loaded.

- closeEvent no longer runs cleanup synchronously; the aboutToQuit hook,
  which was never connected (registered before QApplication existed), now
  runs it after the window is gone
- bound thread waits in cleanup_conversion_thread/cleanup_preview_threads
  with a terminate() fallback so closing never hangs
- flush_cuda skips torch work when torch was never imported
- restore the default Qt message handler before sys.exit; the custom
  Python handler was invoked during interpreter teardown and caused a
  SIGSEGV after shutdown cleanups finished
- log and time the close sequence (closeEvent steps + each shutdown hook)
This commit is contained in:
Deniz Şafak
2026-08-20 22:00:08 +03:00
parent aaa6ac112b
commit 823f5be029
4 changed files with 66 additions and 13 deletions
+6 -1
View File
@@ -11,6 +11,7 @@ Called by shutdown.py at process exit and by run_conversion() per-conversion.
from __future__ import annotations from __future__ import annotations
import gc import gc
import sys
from typing import Callable from typing import Callable
_UI_CLEANUPS: list[Callable[[], None]] = [] _UI_CLEANUPS: list[Callable[[], None]] = []
@@ -19,8 +20,12 @@ _UI_CLEANUPS: list[Callable[[], None]] = []
def flush_cuda() -> None: def flush_cuda() -> None:
"""Run GC and release CUDA cache. Safe to call multiple times.""" """Run GC and release CUDA cache. Safe to call multiple times."""
gc.collect() 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: try:
import torch torch = sys.modules["torch"]
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
torch.cuda.ipc_collect() torch.cuda.ipc_collect()
+31 -8
View File
@@ -5,9 +5,12 @@ import tempfile
import platform import platform
import base64 import base64
import re import re
import logging
from abogen.pyqt.queue_manager_gui import QueueManager from abogen.pyqt.queue_manager_gui import QueueManager
from abogen.pyqt.queued_item import QueuedItem from abogen.pyqt.queued_item import QueuedItem
_log = logging.getLogger("abogen.gui")
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation import hashlib # Added for cache path generation
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -3169,14 +3172,25 @@ class abogen(QWidget):
save_config(self.config) save_config(self.config)
def cleanup_conversion_thread(self): def cleanup_conversion_thread(self):
# Stop conversion thread # Stop conversion thread (bounded wait so closing never hangs)
if ( if (
hasattr(self, "conversion_thread") hasattr(self, "conversion_thread")
and self.conversion_thread is not None and self.conversion_thread is not None
and self.conversion_thread.isRunning() and self.conversion_thread.isRunning()
): ):
_log.info("Close: stopping conversion thread")
start = time.perf_counter()
self.conversion_thread.cancel() 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): def cleanup_preview_threads(self):
# Stop preview generation thread # Stop preview generation thread
@@ -3185,8 +3199,13 @@ class abogen(QWidget):
and self.preview_thread is not None and self.preview_thread is not None
and self.preview_thread.isRunning() and self.preview_thread.isRunning()
): ):
_log.info("Close: terminating preview thread")
start = time.perf_counter()
self.preview_thread.terminate() 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 # Stop audio playback thread
if ( if (
@@ -3194,8 +3213,13 @@ class abogen(QWidget):
and self.play_audio_thread is not None and self.play_audio_thread is not None
and self.play_audio_thread.isRunning() 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.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 # Cleanup pygame mixer if initialized
try: try:
@@ -3206,6 +3230,7 @@ class abogen(QWidget):
pass pass
def closeEvent(self, event): def closeEvent(self, event):
_log.info("Close: window close requested (converting=%s)", self.is_converting)
if self.is_converting: if self.is_converting:
box = QMessageBox(self) box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning) box.setIcon(QMessageBox.Icon.Warning)
@@ -3218,16 +3243,14 @@ class abogen(QWidget):
) )
box.setDefaultButton(QMessageBox.StandardButton.No) box.setDefaultButton(QMessageBox.StandardButton.No)
if box.exec() == QMessageBox.StandardButton.Yes: if box.exec() == QMessageBox.StandardButton.Yes:
from abogen import shutdown _log.info("Close: user confirmed exit during conversion")
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
else: else:
_log.info("Close: user cancelled exit")
event.ignore() event.ignore()
else: else:
from abogen import shutdown
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
+8 -1
View File
@@ -164,6 +164,9 @@ def main():
with timed_log("QApplication creation", logger=_log): with timed_log("QApplication creation", logger=_log):
app = QApplication(sys.argv) 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 # Set application icon using get_resource_path from utils
icon_path = get_resource_path("abogen.assets", "icon.ico") icon_path = get_resource_path("abogen.assets", "icon.ico")
if icon_path: if icon_path:
@@ -181,7 +184,11 @@ def main():
with timed_log("window show", logger=_log): with timed_log("window show", logger=_log):
ex.show() ex.show()
_log.info("App startup complete. Showing window.") _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__": if __name__ == "__main__":
+21 -3
View File
@@ -14,10 +14,14 @@ Per-conversion cleanup lives in run_conversion() finally block.
from __future__ import annotations from __future__ import annotations
import atexit import atexit
import logging
import signal import signal
import sys import sys
import time
from typing import Callable from typing import Callable
_log = logging.getLogger("abogen.shutdown")
_CLEANUP_FUNCS: list[Callable[[], None]] = [] _CLEANUP_FUNCS: list[Callable[[], None]] = []
_EXECUTED = False _EXECUTED = False
@@ -32,11 +36,17 @@ def _run_cleanups() -> None:
if _EXECUTED: if _EXECUTED:
return return
_EXECUTED = True _EXECUTED = True
_log.info("Shutdown: starting %d cleanup hook(s)", len(_CLEANUP_FUNCS))
for fn in _CLEANUP_FUNCS: for fn in _CLEANUP_FUNCS:
start = time.perf_counter()
try: try:
fn() fn()
except Exception: except Exception:
pass pass
_log.info(
"Shutdown: %s done in %.2fs", fn.__name__, time.perf_counter() - start
)
_log.info("Shutdown: all cleanups finished")
# ---- Process-level cleanup functions ---- # ---- Process-level cleanup functions ----
@@ -117,13 +127,19 @@ def register_shutdown() -> None:
except Exception: except Exception:
pass 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: try:
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
app = QApplication.instance() 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.aboutToQuit.connect(_run_cleanups)
app._abogen_cleanup_connected = True
_log.info("Shutdown: Qt aboutToQuit hook connected")
except Exception: except Exception:
pass pass
@@ -132,13 +148,15 @@ register_shutdown._registered = False
def _on_signal(signum: int, _frame) -> None: def _on_signal(signum: int, _frame) -> None:
_log.info("Shutdown: signal %s received", signum)
_run_cleanups() _run_cleanups()
sys.exit(0) sys.exit(0)
def request_shutdown() -> None: def request_shutdown() -> None:
"""Programmatically trigger cleanup (e.g., from GUI closeEvent).""" """Programmatically trigger cleanup (e.g., from GUI closeEvent)."""
_log.info("Shutdown: cleanup requested")
_run_cleanups() _run_cleanups()
__all__ = ["register_shutdown", "request_shutdown", "register_cleanup"] __all__ = ["register_shutdown", "install_qt_hook", "request_shutdown", "register_cleanup"]