FIx slow closing

This commit is contained in:
DazedAnon 2026-07-03 19:05:06 -05:00
parent 8a307bc0cf
commit 4e2479c56d
4 changed files with 78 additions and 81 deletions

View file

@ -114,6 +114,7 @@ class LogViewer(QWidget):
self._tail_interval = 300 # ms self._tail_interval = 300 # ms
self._tail_f = None self._tail_f = None
self._tail_buffer = "" # Buffer for incomplete lines self._tail_buffer = "" # Buffer for incomplete lines
self._tailing = False
# Default: prefer the most recent file in log/history, else legacy path # Default: prefer the most recent file in log/history, else legacy path
try: try:
latest = self._latest_history_file() latest = self._latest_history_file()
@ -187,6 +188,8 @@ class LogViewer(QWidget):
if interval_ms: if interval_ms:
self._tail_interval = interval_ms self._tail_interval = interval_ms
self._tailing = True
# Ensure directory exists but don't create the file # Ensure directory exists but don't create the file
try: try:
self._tail_path.parent.mkdir(parents=True, exist_ok=True) self._tail_path.parent.mkdir(parents=True, exist_ok=True)
@ -215,27 +218,31 @@ class LogViewer(QWidget):
self._tail_timer.start(self._tail_interval) self._tail_timer.start(self._tail_interval)
def stop_tail(self): def stop_tail(self, *, drain: bool = True):
"""Stop tailing the log file and close internal file handle. """Stop tailing the log file and close internal file handle.
Performs a final drain so no data written before this call is lost. When *drain* is True and tailing was active, performs one final read so
lines written just before shutdown are not lost. On application exit,
pass ``drain=False`` so a large history file is not loaded into the UI.
""" """
self._tailing = False
try: try:
self._tail_timer.stop() self._tail_timer.stop()
except Exception: except Exception:
pass pass
# Final drain: read any remaining data before closing the handle if drain and self._tail_f is not None:
try: try:
self._poll_tail() self._poll_tail()
except Exception: except Exception:
pass pass
# Flush any leftover partial line sitting in the buffer
if self._tail_buffer and self._tail_buffer.strip(): if self._tail_buffer and self._tail_buffer.strip():
try: try:
self.append_log_message(self._tail_buffer) self.append_log_message(self._tail_buffer)
except Exception: except Exception:
pass pass
self._tail_buffer = "" self._tail_buffer = ""
else:
self._tail_buffer = ""
if self._tail_f: if self._tail_f:
try: try:
self._tail_f.close() self._tail_f.close()
@ -245,6 +252,8 @@ class LogViewer(QWidget):
def _poll_tail(self): def _poll_tail(self):
"""Timer callback: read any new data from the tailed file and append it.""" """Timer callback: read any new data from the tailed file and append it."""
if not self._tailing:
return
# If file handle doesn't exist yet, try to open it if the file was created # If file handle doesn't exist yet, try to open it if the file was created
if not self._tail_f and self._tail_path.exists(): if not self._tail_f and self._tail_path.exists():
try: try:

View file

@ -401,6 +401,7 @@ class DazedMTLGUI(QMainWindow):
self.settings = QSettings("DazedTranslations", "DazedMTLTool") self.settings = QSettings("DazedTranslations", "DazedMTLTool")
self._pending_updates: dict = {} self._pending_updates: dict = {}
self._update_check_thread = None self._update_check_thread = None
self._shutdown_started = False
self._update_icon = "🔄" self._update_icon = "🔄"
self.btn_update = None self.btn_update = None
self.init_ui() self.init_ui()
@ -437,43 +438,33 @@ class DazedMTLGUI(QMainWindow):
"""Gracefully stop every background ``QThread`` before the process exits. """Gracefully stop every background ``QThread`` before the process exits.
A ``QThread`` that is destroyed while still running aborts the whole A ``QThread`` that is destroyed while still running aborts the whole
process with SIGABRT ("QThread: Destroyed while thread is still process with SIGABRT. Idempotent so ``closeEvent`` and ``aboutToQuit``
running"). Background workers here (startup update check, model-list can both call this safely.
fetch, workflow/wolf tasks, translation worker) do blocking network or
subprocess I/O and can outlive the window, so we sweep every live
``QThread`` and quit/wait it, terminating only as a last resort.
""" """
if getattr(self, "_shutdown_started", False):
return
self._shutdown_started = True
import gc import gc
current = QThread.currentThread() current = QThread.currentThread()
threads = []
for obj in gc.get_objects(): for obj in gc.get_objects():
try: try:
if isinstance(obj, QThread) and obj is not current: if not isinstance(obj, QThread) or obj is current:
threads.append(obj)
except Exception:
continue continue
if not obj.isRunning():
for t in threads:
try:
if not t.isRunning():
continue continue
# Nudge cooperative workers to stop before we block on them.
for stopper in ("stop", "requestInterruption"): for stopper in ("stop", "requestInterruption"):
fn = getattr(t, stopper, None) fn = getattr(obj, stopper, None)
if callable(fn): if callable(fn):
try: try:
fn() fn()
except Exception: except Exception:
pass pass
if not obj.wait(400):
try: try:
t.quit() obj.terminate()
except Exception: obj.wait(400)
pass
if not t.wait(3000):
try:
t.terminate()
t.wait(1000)
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -481,34 +472,27 @@ class DazedMTLGUI(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
"""Handle application close event.""" """Handle application close event."""
# Save window geometry/state
try: try:
self.save_window_state() self.save_window_state()
except Exception: except Exception:
pass pass
# Attempt to stop any running translation worker to ensure
# ThreadPoolExecutors and subprocesses are shut down so the
# Python process can exit cleanly.
try: try:
if hasattr(self, 'translation_tab') and self.translation_tab: if hasattr(self, 'translation_tab') and self.translation_tab:
tt = self.translation_tab tt = self.translation_tab
# Stop log tailing first (if active)
try: try:
if hasattr(tt, 'translation_log_viewer') and tt.translation_log_viewer: if hasattr(tt, 'translation_log_viewer') and tt.translation_log_viewer:
tt.translation_log_viewer.stop_tail() tt.translation_log_viewer.stop_tail(drain=False)
except Exception: except Exception:
pass pass
# If a worker exists and is running, request it to stop and wait
try: try:
if hasattr(tt, 'translation_worker') and tt.translation_worker and tt.translation_worker.isRunning(): if hasattr(tt, 'translation_worker') and tt.translation_worker and tt.translation_worker.isRunning():
tt.translation_worker.stop() tt.translation_worker.stop()
# Wait up to 5s for graceful stop, otherwise terminate if not tt.translation_worker.wait(2000):
if not tt.translation_worker.wait(5000):
try: try:
tt.translation_worker.terminate() tt.translation_worker.terminate()
tt.translation_worker.wait(2000) tt.translation_worker.wait(500)
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -516,8 +500,6 @@ class DazedMTLGUI(QMainWindow):
except Exception: except Exception:
pass pass
# Stop every remaining background thread (model fetch, update check,
# workflow/wolf workers) so the process can exit without aborting.
try: try:
self.stop_all_background_threads() self.stop_all_background_threads()
except Exception: except Exception:
@ -1341,32 +1323,6 @@ def main():
# ThreadPoolExecutor threads and subprocesses are shut down so the # ThreadPoolExecutor threads and subprocesses are shut down so the
# Python interpreter can exit cleanly. # Python interpreter can exit cleanly.
def _on_about_to_quit(): def _on_about_to_quit():
try:
if hasattr(window, 'translation_tab') and window.translation_tab:
tt = window.translation_tab
try:
if hasattr(tt, 'translation_log_viewer') and tt.translation_log_viewer:
tt.translation_log_viewer.stop_tail()
except Exception:
pass
try:
if hasattr(tt, 'translation_worker') and tt.translation_worker and tt.translation_worker.isRunning():
tt.translation_worker.stop()
# Wait briefly for graceful shutdown
if not tt.translation_worker.wait(3000):
try:
tt.translation_worker.terminate()
tt.translation_worker.wait(2000)
except Exception:
pass
except Exception:
pass
except Exception:
pass
# Final safety net: stop any remaining background threads so a
# QThread is never destroyed while still running (SIGABRT).
try: try:
window.stop_all_background_threads() window.stop_all_background_threads()
except Exception: except Exception:

View file

@ -1533,10 +1533,6 @@ class TranslationTab(QWidget):
pass pass
self._file_list_filter_installed = False self._file_list_filter_installed = False
def closeEvent(self, event):
self._remove_file_list_event_filter()
super().closeEvent(event)
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
"""Intercept mouse presses on the file list. """Intercept mouse presses on the file list.
@ -2966,11 +2962,9 @@ class TranslationTab(QWidget):
def closeEvent(self, event): def closeEvent(self, event):
"""Handle widget close event.""" """Handle widget close event."""
self._remove_file_list_event_filter()
if hasattr(self, 'log_timer'): if hasattr(self, 'log_timer'):
self.log_timer.stop() self.log_timer.stop()
if hasattr(self, 'translation_worker') and self.translation_worker.isRunning(): if hasattr(self, 'translation_log_viewer') and self.translation_log_viewer:
self.translation_worker.stop() self.translation_log_viewer.stop_tail(drain=False)
if not self.translation_worker.wait(3000):
self.translation_worker.terminate()
self.translation_worker.wait(1000)
event.accept() event.accept()

38
tests/test_log_viewer.py Normal file
View file

@ -0,0 +1,38 @@
"""Tests for LogViewer tail shutdown behavior."""
import sys
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from PyQt5.QtWidgets import QApplication
from gui.log_viewer import LogViewer
APP = QApplication.instance() or QApplication(sys.argv)
class LogViewerStopTailTests(unittest.TestCase):
def test_stop_tail_without_active_tail_is_instant(self):
viewer = LogViewer()
history = Path("log/history/translationHistory_20260703_175433.txt")
if history.exists():
viewer._tail_path = history
t0 = time.perf_counter()
viewer.stop_tail(drain=False)
elapsed = time.perf_counter() - t0
self.assertLess(elapsed, 0.2)
self.assertIsNone(viewer._tail_f)
def test_stop_tail_drain_skips_ui_when_not_tailing(self):
viewer = LogViewer()
with patch.object(viewer, "append_log_message") as append_mock:
viewer.stop_tail(drain=True)
append_mock.assert_not_called()
if __name__ == "__main__":
unittest.main()