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_f = None
self._tail_buffer = "" # Buffer for incomplete lines
self._tailing = False
# Default: prefer the most recent file in log/history, else legacy path
try:
latest = self._latest_history_file()
@ -187,6 +188,8 @@ class LogViewer(QWidget):
if interval_ms:
self._tail_interval = interval_ms
self._tailing = True
# Ensure directory exists but don't create the file
try:
self._tail_path.parent.mkdir(parents=True, exist_ok=True)
@ -215,26 +218,30 @@ class LogViewer(QWidget):
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.
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:
self._tail_timer.stop()
except Exception:
pass
# Final drain: read any remaining data before closing the handle
try:
self._poll_tail()
except Exception:
pass
# Flush any leftover partial line sitting in the buffer
if self._tail_buffer and self._tail_buffer.strip():
if drain and self._tail_f is not None:
try:
self.append_log_message(self._tail_buffer)
self._poll_tail()
except Exception:
pass
if self._tail_buffer and self._tail_buffer.strip():
try:
self.append_log_message(self._tail_buffer)
except Exception:
pass
self._tail_buffer = ""
else:
self._tail_buffer = ""
if self._tail_f:
try:
@ -245,6 +252,8 @@ class LogViewer(QWidget):
def _poll_tail(self):
"""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 not self._tail_f and self._tail_path.exists():
try:

View file

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

View file

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