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
|
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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user