mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
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:
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user