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_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,26 +218,30 @@ 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:
|
|
||||||
self._poll_tail()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Flush any leftover partial line sitting in the buffer
|
|
||||||
if self._tail_buffer and self._tail_buffer.strip():
|
|
||||||
try:
|
try:
|
||||||
self.append_log_message(self._tail_buffer)
|
self._poll_tail()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 = ""
|
self._tail_buffer = ""
|
||||||
if self._tail_f:
|
if self._tail_f:
|
||||||
try:
|
try:
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
80
gui/main.py
80
gui/main.py
|
|
@ -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)
|
continue
|
||||||
except Exception:
|
if not obj.isRunning():
|
||||||
continue
|
|
||||||
|
|
||||||
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
|
||||||
try:
|
if not obj.wait(400):
|
||||||
t.quit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not t.wait(3000):
|
|
||||||
try:
|
try:
|
||||||
t.terminate()
|
obj.terminate()
|
||||||
t.wait(1000)
|
obj.wait(400)
|
||||||
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,15 +500,13 @@ 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:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
def setup_font_scaling(self):
|
def setup_font_scaling(self):
|
||||||
"""Set up font scaling based on configuration."""
|
"""Set up font scaling based on configuration."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
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