some fixes

This commit is contained in:
Ayzen
2026-08-26 15:02:21 +03:00
parent 74723bb635
commit 6ada811c2f
10 changed files with 607 additions and 115 deletions
+106 -42
View File
@@ -15,9 +15,10 @@ import logging
import os
from pathlib import Path
import sys
import threading
import traceback
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from PyQt6.QtCore import QTimer
from PyQt6.QtGui import QTextCursor
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
@@ -58,33 +59,60 @@ def _panel_extra(details: str | None, once_key: str | None) -> dict[str, object]
return {"panel_details": details, "panel_once_key": once_key}
class _PanelLogBridge(QObject):
"""Marshals log records from any thread onto the GUI thread for panel rendering.
class _PanelLogBuffer:
"""Thread-safe bounded buffer between logging handlers and the GUI flush timer.
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster),
but the log widget may only be touched on the GUI thread; emitting this queued
signal hands the record across safely (the GPIO-button pattern).
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster)
at a very high rate — e.g. the USB RX threads while the free-running sweep
streams. Posting one queued Qt event per record used to flood the GUI event
queue and keep the interface frozen long after a blocking operation finished
while the backlog rendered. Instead, records land in this bounded buffer and
a periodic GUI-side timer drains them in one batch; overflow drops the oldest
records and reports how many were lost.
"""
record = pyqtSignal(str, str, object, object) # display level, message, details, once_key
_CAPACITY = 2000
def __init__(self) -> None:
self._lock = threading.Lock()
self._entries: deque[tuple[str, str, str | None, str | None]] = deque(maxlen=self._CAPACITY)
self._dropped_count = 0
def append(self, level: str, text: str, details: str | None, once_key: str | None) -> None:
"""Store one record, evicting the oldest when full (any thread)."""
with self._lock:
if len(self._entries) == self._CAPACITY:
self._dropped_count += 1
self._entries.append((level, text, details, once_key))
def drain(self) -> tuple[list[tuple[str, str, str | None, str | None]], int]:
"""Return and clear all buffered records plus the overflow-drop count."""
with self._lock:
entries = list(self._entries)
self._entries.clear()
dropped_count = self._dropped_count
self._dropped_count = 0
return entries, dropped_count
class _QtLogPanelHandler(logging.Handler):
"""Logging handler that forwards application log records to the GUI log panel."""
def __init__(self, bridge: _PanelLogBridge) -> None:
def __init__(self, buffer: _PanelLogBuffer) -> None:
super().__init__()
self._bridge = bridge
self._buffer = buffer
def emit(self, record: logging.LogRecord) -> None:
"""Forward one record to the panel bridge, mapping WARNING to the short 'WARN'."""
"""Buffer one record for the panel, mapping WARNING to the short 'WARN'."""
try:
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
self._bridge.record.emit(
details = getattr(record, "panel_details", None)
once_key = getattr(record, "panel_once_key", None)
self._buffer.append(
display_level,
record.getMessage(),
getattr(record, "panel_details", None),
getattr(record, "panel_once_key", None),
details if isinstance(details, str) else None,
once_key if isinstance(once_key, str) else None,
)
except Exception: # noqa: BLE001 - logging must never raise into the caller
self.handleError(record)
@@ -145,23 +173,53 @@ class AppWindow(
log_dir = self._project_root / "python_app/runtime/logs"
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
self._gui_logger = get_logger("gui")
self._log_panel_bridge = _PanelLogBridge()
self._log_panel_bridge.record.connect(self._on_log_record)
self._log_panel_buffer = _PanelLogBuffer()
def _attach_log_panel(self) -> None:
"""Route application log records into the on-screen panel (widget now exists)."""
add_handler(_QtLogPanelHandler(self._log_panel_bridge))
add_handler(_QtLogPanelHandler(self._log_panel_buffer))
# One bounded flush per tick instead of one queued event per record: the
# panel can never flood the GUI event queue, no matter how chatty a
# DEBUG-level driver gets.
self._log_flush_timer = QTimer(self)
self._log_flush_timer.setInterval(100)
self._log_flush_timer.timeout.connect(self._flush_log_panel_buffer)
self._log_flush_timer.start()
def _on_log_record(self, level: str, text: str, details: object, once_key: object) -> None:
"""Render one forwarded log record in the panel (always on the GUI thread)."""
if not hasattr(self, "_log_box"):
def _flush_log_panel_buffer(self) -> None:
"""Render every buffered log record into the panel as one batched insert."""
entries, dropped_count = self._log_panel_buffer.drain()
if (not entries and not dropped_count) or not hasattr(self, "_log_box"):
return
self._append_log_entry(
level,
text,
details=details if isinstance(details, str) else None,
once_key=once_key if isinstance(once_key, str) else None,
)
entry_htmls: list[str] = []
if dropped_count:
entry_htmls.append(
self._render_log_entry_html(
"WARN",
f"Log panel overflow: {dropped_count} record(s) dropped "
"(they are still in the log file).",
)
)
error_seen = False
for level, text, details, once_key in entries:
if once_key is not None:
if once_key in self._logged_once_keys:
continue
self._logged_once_keys.add(once_key)
entry_htmls.append(self._render_log_entry_html(level, text, details))
error_seen = error_seen or level.upper() == "ERROR"
if not entry_htmls:
return
cursor = self._log_box.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log_box.setTextCursor(cursor)
self._log_box.insertHtml("".join(entry_htmls))
self._log_box.insertPlainText("\n")
self._log_box.ensureCursorVisible()
if error_seen and hasattr(self, "_status_label"):
self._status_label.setText("Status: error")
def _init_runtime_services(self) -> None:
"""Initialize long-lived service objects used by mixins."""
@@ -266,6 +324,10 @@ class AppWindow(
def _init_capture_state(self) -> None:
"""Initialize one-shot capture and sequence-control flags."""
self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
# Guards the blocking per-combo capture against duplicate requests, and keeps
# the dialog's action buttons disabled until the post-capture input backlog
# is dropped (see AppWindowPreprocessMixin._begin/_end_preprocess_capture).
self._preprocess_capture_busy = False
self._resume_pipeline_after_capture = False
self._single_capture_active = False
self._single_capture_start_ns: int | None = None
@@ -551,20 +613,8 @@ class AppWindow(
"""Return full chained traceback for error dialogs and log details."""
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
def _append_log_entry(
self,
level: str,
text: str,
*,
details: str | None = None,
once_key: str | None = None,
) -> None:
"""Append formatted log entry with timestamp and optional details."""
if once_key is not None:
if once_key in self._logged_once_keys:
return
self._logged_once_keys.add(once_key)
def _render_log_entry_html(self, level: str, text: str, details: str | None = None) -> str:
"""Render one log entry as the panel's HTML block."""
level_upper = level.upper()
palette = {
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
@@ -586,16 +636,30 @@ class AppWindow(
"<pre style='margin:3px 0 0 16px; color:"
f"{detail_color};'>{html.escape(details)}</pre>"
)
return "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
def _append_log_entry(
self,
level: str,
text: str,
*,
details: str | None = None,
once_key: str | None = None,
) -> None:
"""Append formatted log entry with timestamp and optional details."""
if once_key is not None:
if once_key in self._logged_once_keys:
return
self._logged_once_keys.add(once_key)
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
cursor = self._log_box.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log_box.setTextCursor(cursor)
self._log_box.insertHtml(entry_html)
self._log_box.insertHtml(self._render_log_entry_html(level, text, details))
self._log_box.insertPlainText("\n")
self._log_box.ensureCursorVisible()
if level_upper == "ERROR" and hasattr(self, "_status_label"):
if level.upper() == "ERROR" and hasattr(self, "_status_label"):
self._status_label.setText("Status: error")
def _on_log_level_selected(self, level_text: str) -> None:
@@ -2,6 +2,8 @@
from __future__ import annotations
from PyQt6.QtCore import QTimer
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.gui.trace_png_export import export_trace_png
from python_app.orchestration.preprocess_assets import (
@@ -519,13 +521,23 @@ class AppWindowPreprocessMixin:
if session is None:
self._show_error("No active capture sequence")
return
# The capture blocks the event loop, so clicks made during it are delivered
# only after it finishes. `_begin_preprocess_capture` disables the action
# buttons for that whole window (re-enabled via a posted event), so a queued
# click lands on a disabled button instead of silently starting — and
# advancing the combo cursor of — another capture.
if not self._begin_preprocess_capture():
return
try:
capture_result = session.capture_current_combo()
except Exception as exc: # noqa: BLE001
self._on_capture_combo_failed(session, exc)
return
self._record_preprocess_capture(session, capture_result)
try:
capture_result = session.capture_current_combo()
except Exception as exc: # noqa: BLE001
self._on_capture_combo_failed(session, exc)
return
self._record_preprocess_capture(session, capture_result)
finally:
self._end_preprocess_capture()
def _capture_all_remaining(self) -> None:
"""Capture all remaining combos for the active preprocess session."""
@@ -539,6 +551,8 @@ class AppWindowPreprocessMixin:
details=self._capture_state_details(),
)
return
if not self._begin_preprocess_capture():
return
display_name = preprocess_asset_display_name(session.kind)
dialog = self._ensure_preprocess_dialog()
@@ -547,13 +561,40 @@ class AppWindowPreprocessMixin:
f"{display_name} batch capture started: remaining="
f"{session.state().total_count - session.state().captured_count}"
)
while not session.is_complete():
try:
capture_result = session.capture_current_combo()
except Exception as exc: # noqa: BLE001
self._on_capture_combo_failed(session, exc)
return
self._record_preprocess_capture(session, capture_result)
try:
while not session.is_complete():
try:
capture_result = session.capture_current_combo()
except Exception as exc: # noqa: BLE001
self._on_capture_combo_failed(session, exc)
return
self._record_preprocess_capture(session, capture_result)
finally:
self._end_preprocess_capture()
def _begin_preprocess_capture(self) -> bool:
"""Mark a blocking combo capture as running; refuse when one already is.
Returns False for a duplicate request (e.g. a click delivered while an
error dialog inside a capture pumps the event loop).
"""
if self._preprocess_capture_busy:
self._log("Preprocess combo capture already in progress; ignoring duplicate request.")
return False
self._preprocess_capture_busy = True
# Disable the sequence action buttons for the whole blocked window.
self._update_capture_dialog_state()
return True
def _end_preprocess_capture(self) -> None:
"""Re-enable capture actions after the pending input backlog is discarded.
The zero-delay timer fires only after Qt has dispatched the window-system
events queued while the capture blocked the loop; those clicks hit the
still-disabled buttons and are dropped, then the buttons come back.
"""
self._preprocess_capture_busy = False
QTimer.singleShot(0, self._update_capture_dialog_state)
def _on_capture_combo_failed(
self,
@@ -817,6 +858,10 @@ class AppWindowPreprocessMixin:
and state.current_combo is not None
),
variant_count=state.variant_count,
# While a blocking capture is executing, every action stays disabled no
# matter what the session state allows: clicks queued during the freeze
# must land on disabled buttons (see `_end_preprocess_capture`).
actions_enabled=not self._preprocess_capture_busy,
)
def _cleanup_capture_session(self) -> None:
+13 -6
View File
@@ -354,8 +354,14 @@ class PreprocessDialog(QDialog):
can_finalize: bool,
can_capture_all: bool,
variant_count: int = 1,
actions_enabled: bool = True,
) -> None:
"""Update sequence progress/status widgets."""
"""Update sequence progress/status widgets.
With ``actions_enabled=False`` the progress labels still update but every
sequence action button is kept disabled — used while a blocking capture
runs, so input queued during the freeze cannot trigger another action.
"""
if kind is None:
self._active_kind_label.setText("<none>")
self._progress_label.setText("0 / 0")
@@ -368,13 +374,14 @@ class PreprocessDialog(QDialog):
self._capture_all_button.setText("Capture All Remaining")
return
actions_enabled = bool(actions_enabled)
active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind
self._active_kind_label.setText(active_label)
self._progress_label.setText(f"{captured_count} / {total_count}")
self._undo_last_button.setEnabled(bool(can_undo))
self._save_sequence_button.setEnabled(bool(can_finalize))
self._capture_all_button.setEnabled(bool(can_capture_all))
self._abort_button.setEnabled(True)
self._undo_last_button.setEnabled(bool(can_undo) and actions_enabled)
self._save_sequence_button.setEnabled(bool(can_finalize) and actions_enabled)
self._capture_all_button.setEnabled(bool(can_capture_all) and actions_enabled)
self._abort_button.setEnabled(actions_enabled)
self._capture_all_button.setText("Capture All Remaining")
if next_input is None or next_output is None:
self._combo_label.setText("<complete>")
@@ -384,7 +391,7 @@ class PreprocessDialog(QDialog):
if int(variant_count) > 1:
combo_text += f" | radar configs={int(variant_count)}"
self._combo_label.setText(combo_text)
self._capture_next_button.setEnabled(True)
self._capture_next_button.setEnabled(actions_enabled)
def set_available_sets(self, available_sets: dict[str, list[str]]) -> None:
"""Replace combo-box choices for all preprocess assets."""