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
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()
+31 -8
View File
@@ -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()
+8 -1
View File
@@ -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__":
+21 -3
View File
@@ -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"]