FIx slow closing
This commit is contained in:
parent
8a307bc0cf
commit
4e2479c56d
4 changed files with 78 additions and 81 deletions
|
|
@ -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:
|
||||
|
|
|
|||
80
gui/main.py
80
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:
|
||||
|
|
|
|||
|
|
@ -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
38
tests/test_log_viewer.py
Normal 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()
|
||||
Loading…
Reference in a new issue