diff --git a/gui/log_viewer.py b/gui/log_viewer.py index 78d5c23..1db9e8e 100644 --- a/gui/log_viewer.py +++ b/gui/log_viewer.py @@ -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: diff --git a/gui/main.py b/gui/main.py index 3eaaf67..7569bcf 100644 --- a/gui/main.py +++ b/gui/main.py @@ -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: diff --git a/gui/translation_tab.py b/gui/translation_tab.py index 1972e09..fa7ddd0 100644 --- a/gui/translation_tab.py +++ b/gui/translation_tab.py @@ -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() diff --git a/tests/test_log_viewer.py b/tests/test_log_viewer.py new file mode 100644 index 0000000..d1be380 --- /dev/null +++ b/tests/test_log_viewer.py @@ -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()