Files
rays eb86f889b3 fix(logging): unify Doctor startup output
Standardize backend log identities and severity highlighting while keeping persisted logs ANSI-free. Restore the encoding-aware startup banner and emit its art as one tightly packed block.
2026-08-09 05:38:01 +08:00

1150 lines
45 KiB
Python

"""
Smart Logger for ComfyUI Runtime Diagnostics.
Intercepts stdout/stderr to capture logs and provide real-time error analysis.
ARCHITECTURE (v1.7.5):
- SafeStreamWrapper: Wraps sys.stdout/stderr (after ComfyUI's LogInterceptor if present)
- DoctorLogProcessor: Background thread that processes queued messages
- Zero deadlock risk: write() holds no locks, only enqueues messages
Previous issues (v1.2.x):
- Used SmartLogger with AsyncFileWriter
- Attempted to use ComfyUI's on_flush callbacks (failed due to callback ordering)
- Had potential deadlock risks with nested stream wrapping
See: .planning/STAGE1_LOGGER_FIX_PLAN.md for full design details
"""
import sys
import datetime
import threading
import queue
import time
import os
import logging
import hashlib
from collections import deque
from typing import Optional, Dict, Any, List
try:
from .services.time_utils import UTC_MIN, parse_utc_timestamp, utc_filename_timestamp, utc_isoformat
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from services.time_utils import UTC_MIN, parse_utc_timestamp, utc_filename_timestamp, utc_isoformat
try:
from .terminal_output import DoctorLogFormatter, emit_doctor_log, stream_supports_color
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from terminal_output import DoctorLogFormatter, emit_doctor_log, stream_supports_color
# ==============================================================================
# R22: Asyncio transport GC exclusion patterns
# ==============================================================================
# Windows ProactorEventLoop transport GC raises ValueError during __del__.
# These are non-actionable Python-internal warnings that should never trigger
# Doctor error analysis. See: roadmap.md R22
_ASYNCIO_GC_EXCLUSION_PATTERNS = (
"_ProactorBasePipeTransport.__del__",
"I/O operation on closed pipe",
"unclosed transport",
)
def _is_asyncio_gc_noise(message: str) -> bool:
"""Check if a message is a known-benign asyncio transport GC warning.
These warnings originate from Python's asyncio internals on Windows when
a ProactorEventLoop transport is garbage-collected after its pipe has
already been closed. They are non-actionable and would cause recursive
log pollution if Doctor tries to analyse them.
"""
if not message:
return False
return any(pat in message for pat in _ASYNCIO_GC_EXCLUSION_PATTERNS)
# IMPORTANT: keep legacy emoji markers here for backward compatibility
# with old logs and pre-R26 console output. New Doctor-owned output must stay
# ASCII, but the parser still needs to recognize historical messages.
_LEGACY_DOCTOR_OUTPUT_MARKERS = (
"🎯 ERROR LOCATION:",
"💡 SUGGESTION:",
)
_ASCII_DOCTOR_OUTPUT_MARKERS = (
"ERROR LOCATION:",
"SUGGESTION:",
)
_LEGACY_TENSOR_ALERT_MARKERS = (
"❌ CRITICAL: Tensor contains",
"⚠️ Meta Tensor",
)
_ASCII_TENSOR_ALERT_MARKERS = (
"CRITICAL: Tensor contains",
"WARNING: Meta Tensor",
)
def _contains_tensor_alert(message: str) -> bool:
if not message:
return False
return any(marker in message for marker in (_LEGACY_TENSOR_ALERT_MARKERS + _ASCII_TENSOR_ALERT_MARKERS))
def _normalize_backend_suggestion(text: str) -> str:
if not isinstance(text, str):
return text
normalized = text
for prefix in ("💡 SUGGESTION: ", "💡 Suggestion: "):
if normalized.startswith(prefix):
return "SUGGESTION: " + normalized[len(prefix):]
return normalized.replace("💡 SUGGESTION:", "SUGGESTION:")
# ==============================================================================
# R22: Dedicated internal logger (bypasses SafeStreamWrapper)
# ==============================================================================
_doctor_internal_logger = logging.getLogger("doctor._internal")
_doctor_internal_logger.propagate = False # CRITICAL: do not propagate to root
# R22: Thread-local reentrance guard for SafeStreamWrapper
_stream_reentrance = threading.local()
try:
# CRITICAL: do not replace these with bare internal imports; ComfyUI loads
# Doctor as a package and the extension root is not guaranteed on sys.path.
from .analyzer import ErrorAnalyzer
from .config import CONFIG
from .history_store import HistoryStore, HistoryEntry
from .services.log_ring_buffer import get_ring_buffer
from .services.context_extractor import detect_fatal_pattern
from .services import doctor_paths
except ImportError as import_error:
# Fallback for direct execution (tests)
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from analyzer import ErrorAnalyzer
from config import CONFIG
from history_store import HistoryStore, HistoryEntry
try:
from services.log_ring_buffer import get_ring_buffer
except ImportError:
get_ring_buffer = None
try:
from services.context_extractor import detect_fatal_pattern
except ImportError:
detect_fatal_pattern = None
try:
from services import doctor_paths
except ImportError:
doctor_paths = None
# ==============================================================================
# Global State for API Access
# ==============================================================================
_last_analysis: Dict[str, Any] = {
"error": None,
"suggestion": None,
"timestamp": None,
"node_context": None, # NodeContext.to_dict() result
"analysis_metadata": None,
"matched_pattern_id": None,
"pattern_category": None,
"pattern_priority": None,
"resolution_status": None,
}
# P1: Error history buffer (ring buffer for last N errors)
# When history_size=0, use unbounded deque
_analysis_history: deque = deque() if CONFIG.history_size == 0 else deque(maxlen=CONFIG.history_size)
# F1: Persistent history store
# R18: Use canonical data directory via doctor_paths
_current_dir = os.path.dirname(os.path.abspath(__file__))
def _resolve_history_path() -> str:
"""
Choose a writable, stable location for history persistence.
Why:
- ComfyUI Desktop / Manager may treat writes inside the extension folder as "conflicts"
and may also place extensions under locations with stricter permissions.
- Using the canonical Doctor data dir is robust across install modes
(portable/git clone/desktop) and respects ComfyUI's user directory when available.
"""
if doctor_paths:
try:
data_dir = doctor_paths.get_doctor_data_dir()
return os.path.join(data_dir, "error_history.json")
except Exception:
pass
return os.path.join(_current_dir, "logs", "error_history.json")
_history_file = _resolve_history_path()
_history_store: Optional[HistoryStore] = None
def _migrate_legacy_data():
"""R18: One-time migration of legacy history to new location."""
if not doctor_paths:
return
legacy_path = os.path.join(_current_dir, "logs", "error_history.json")
target_path = _history_file
# If legacy exists and target doesn't, migrate.
# Best-effort: prefer moving the file out of the extension folder to avoid "conflict" warnings.
if os.path.exists(legacy_path) and not os.path.exists(target_path):
try:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
import shutil
# Copy into canonical location first, then rename legacy to a backup marker.
# This preserves a forensic trail while preventing repeated migrations.
shutil.copy2(legacy_path, target_path)
try:
ts = utc_filename_timestamp()
os.rename(legacy_path, f"{legacy_path}.migrated-{ts}")
except Exception:
# If rename fails (permissions/locks), leave legacy in place; migration is best-effort.
pass
except Exception as e:
emit_doctor_log(f"Migration failed: {e}", "ERROR", stream=sys.stderr)
def _get_history_store() -> HistoryStore:
"""Lazy initialization of history store."""
global _history_store
if _history_store is None:
_migrate_legacy_data()
aggregate_window_seconds = 60
try:
aggregate_window_seconds = int(
getattr(CONFIG.guardrails, "AGGREGATION_WINDOW_SECONDS", 60)
)
except Exception:
aggregate_window_seconds = 60
_history_store = HistoryStore(
_history_file,
maxlen=CONFIG.history_size,
max_bytes=getattr(CONFIG, 'history_size_bytes', 5*1024*1024),
aggregate_window_seconds=aggregate_window_seconds,
)
return _history_store
# R2: Thread safety locks for shared mutable state
_history_lock = threading.Lock()
_TRACEBACK_ACTIVE = threading.Event()
class DroppingQueue:
"""
Bounded queue with drop policy and counters.
- Non-priority messages are dropped when full.
- Priority messages evict the oldest non-priority item if possible.
- Drop counters are tracked for health/observability.
"""
def __init__(self, maxsize: int = 1000):
self._maxsize = maxsize
self._queue = deque()
self._cv = threading.Condition()
self._stats = {
"queue_dropped_total": 0,
"queue_dropped_priority": 0,
"queue_dropped_non_priority": 0,
"queue_dropped_oldest": 0,
}
def _record_drop(self, item):
if not item:
return
priority, _ = item
self._stats["queue_dropped_total"] += 1
if priority:
self._stats["queue_dropped_priority"] += 1
else:
self._stats["queue_dropped_non_priority"] += 1
def _remove_at(self, index: int):
self._queue.rotate(-index)
item = self._queue.popleft()
self._queue.rotate(index)
return item
def _drop_oldest(self, prefer_non_priority: bool):
if not self._queue:
return None
if prefer_non_priority:
for idx, item in enumerate(self._queue):
if not item[0]:
return self._remove_at(idx)
return self._queue.popleft()
def put_nowait(self, item, priority: bool = False) -> bool:
with self._cv:
if self._maxsize and len(self._queue) >= self._maxsize:
if priority:
dropped = self._drop_oldest(prefer_non_priority=True)
self._record_drop(dropped)
self._stats["queue_dropped_oldest"] += 1
else:
self._stats["queue_dropped_total"] += 1
self._stats["queue_dropped_non_priority"] += 1
return False
self._queue.append((priority, item))
self._cv.notify()
return True
def get(self, timeout: float = None):
with self._cv:
if not self._queue:
self._cv.wait(timeout)
if not self._queue:
raise queue.Empty
return self._queue.popleft()
def qsize(self) -> int:
with self._cv:
return len(self._queue)
def get_stats(self) -> Dict[str, int]:
with self._cv:
return dict(self._stats)
def clear(self) -> None:
with self._cv:
self._queue.clear()
def _is_priority_message(message: str) -> bool:
if _TRACEBACK_ACTIVE.is_set():
return True
if not message:
return False
if "Traceback (most recent call last):" in message:
return True
if "Failed to validate prompt for output" in message:
return True
return False
# ==============================================================================
# API Functions (preserved from original implementation)
# ==============================================================================
def get_last_analysis() -> Dict[str, Any]:
"""Get the last error analysis result for API access."""
with _history_lock:
return _last_analysis.copy()
def update_resolution_status(timestamp: str, status: str) -> bool:
"""Update resolution status in history store and in-memory analysis data."""
updated = False
try:
store = _get_history_store()
with store._lock:
store._load()
for entry in store._history:
if entry.timestamp == timestamp:
entry.resolution_status = status
updated = True
break
store._save()
except Exception:
pass
with _history_lock:
global _last_analysis
if _last_analysis.get("timestamp") == timestamp:
_last_analysis["resolution_status"] = status
updated = True
for entry in _analysis_history:
if entry.get("timestamp") == timestamp:
entry["resolution_status"] = status
updated = True
break
return updated
def get_analysis_history() -> List[Dict[str, Any]]:
"""Get the error analysis history (most recent first)."""
# Prefer persistent store, fallback to in-memory
try:
store = _get_history_store()
history = store.get_all()
if history:
return history
except Exception:
pass # Fallback to in-memory
with _history_lock:
return list(reversed(_analysis_history))
def clear_analysis_history() -> bool:
"""Clear all history (both persistent and in-memory)."""
success = True
try:
store = _get_history_store()
store.clear()
except Exception:
success = False
with _history_lock:
_analysis_history.clear()
global _last_analysis
_last_analysis = {
"error": None,
"suggestion": None,
"timestamp": None,
"node_context": None,
"analysis_metadata": None,
"matched_pattern_id": None,
"pattern_category": None,
"pattern_priority": None,
"resolution_status": None,
}
# Best-effort reset of in-flight logger state so old queued lines don't repopulate
# _last_analysis right after clearing (important for deterministic tests).
try:
if _message_queue:
_message_queue.clear()
except Exception:
success = False
try:
if _log_processor:
# IMPORTANT: keep traceback state reset inside DoctorLogProcessor; direct field writes race capture.
_log_processor.reset_traceback_state()
except Exception:
success = False
return success
# ==============================================================================
# New Architecture: SafeStreamWrapper + DoctorLogProcessor
# ==============================================================================
class SafeStreamWrapper:
"""
Safe stream wrapper that avoids deadlock with ComfyUI's LogInterceptor.
Design principles:
1. write() immediately pass-through (holds NO locks)
2. Message is enqueued for background processing
3. All other attributes are proxied to original stream
This avoids conflicts with ComfyUI's LogInterceptor because:
- We don't hold any locks during write()
- We call original_stream.write() FIRST, then enqueue
- Background thread is completely decoupled
"""
def __init__(self, original_stream, message_queue):
"""
Initialize wrapper.
Args:
original_stream: Original stream (may be LogInterceptor or raw stdout/stderr)
message_queue: Queue for background processing
"""
self._original_stream = original_stream
self._queue = message_queue
def write(self, data):
"""
Write data: immediate pass-through + enqueue for analysis.
CRITICAL: This method holds NO locks to avoid deadlock.
R22: Thread-local reentrance guard prevents recursive capture.
"""
# R22: Reentrance guard — if we are already inside a write() on this
# thread (e.g. because _record_analysis triggered a logging call that
# routes back here), skip the enqueue step to break the recursion.
if getattr(_stream_reentrance, "active", False):
try:
self._original_stream.write(data)
except (OSError, AttributeError, UnicodeError, ValueError):
pass
return
_stream_reentrance.active = True
try:
# 1. Immediately write to original stream (may be LogInterceptor)
try:
self._original_stream.write(data)
except (OSError, AttributeError, UnicodeError, ValueError):
pass # Stream may be closed during shutdown
# 2. Enqueue for background processing (non-blocking)
try:
priority = _is_priority_message(data)
self._queue.put_nowait(data, priority=priority)
except Exception:
pass
finally:
_stream_reentrance.active = False
# R14: Add to ring buffer for reliable log context capture
try:
if get_ring_buffer:
ring_buffer = get_ring_buffer()
ring_buffer.add_line(data)
except Exception:
pass # Never fail on ring buffer operations
def flush(self):
"""Flush original stream."""
try:
self._original_stream.flush()
except (OSError, AttributeError, UnicodeError, ValueError):
pass
def __getattr__(self, name):
"""Proxy all other attributes to original stream (encoding, isatty, fileno, etc)."""
return getattr(self._original_stream, name)
class FlushSafeProxy:
"""
Stream proxy that never raises from write()/flush().
Purpose:
- Some ComfyUI Desktop builds use a LogInterceptor whose flush() can raise
`OSError: [Errno 22] Invalid argument` under Windows packaging.
- Python's logging.StreamHandler calls flush() after every emit.
- If flush raises, logging prints an exception traceback, causing a log storm.
This proxy keeps the original stream behavior but makes flush safe.
It intentionally does NOT enqueue messages for Doctor analysis.
"""
def __init__(self, original_stream):
self._original_stream = original_stream
def write(self, data):
try:
return self._original_stream.write(data)
except (OSError, AttributeError, UnicodeError, ValueError):
return 0
def flush(self):
try:
return self._original_stream.flush()
except (OSError, AttributeError, UnicodeError, ValueError):
return None
def __getattr__(self, name):
return getattr(self._original_stream, name)
# IMPORTANT: keep the internal logger on a flush-safe stream. On some
# Windows/Anaconda console states, raw sys.__stderr__ writes can raise
# WinError 6 / invalid handle from background threads; if that escapes a
# StreamHandler emit, Python logging prints a secondary traceback that Doctor
# then captures as a false error.
if not _doctor_internal_logger.handlers:
_internal_handler = logging.StreamHandler(FlushSafeProxy(sys.__stderr__))
_internal_handler.setFormatter(
DoctorLogFormatter(
include_timestamp=False,
color_enabled=stream_supports_color(sys.__stderr__),
)
)
_doctor_internal_logger.addHandler(_internal_handler)
_doctor_internal_logger.setLevel(logging.DEBUG)
class DoctorLogProcessor(threading.Thread):
"""
Background thread for log analysis.
Functionality:
1. Read messages from queue
2. Assemble traceback buffer (same logic as old SmartLogger._analyze_stream)
3. Call ErrorAnalyzer.analyze()
4. Update history store
This runs completely independently from the main thread.
"""
def __init__(self, message_queue):
super().__init__(daemon=True, name="DoctorLogProcessor")
self._queue = message_queue
self._running = True
self._metrics = {
"buffer_dropped": 0,
"traceback_resets": 0,
"queue_timeouts": 0,
}
# Traceback buffer state (migrated from SmartLogger)
self._buffer_lock = threading.RLock()
self.buffer = []
self.in_traceback = False
self.last_buffer_time = 0
try:
self._aggregate_window_seconds = int(
getattr(CONFIG.guardrails, "AGGREGATION_WINDOW_SECONDS", 60)
)
except Exception:
self._aggregate_window_seconds = 60
if self._aggregate_window_seconds <= 0:
self._aggregate_window_seconds = 60
def _parse_ts(self, ts: str) -> datetime.datetime:
return parse_utc_timestamp(ts) or UTC_MIN
def run(self):
"""Main loop: process queued messages."""
while self._running:
try:
# Use timeout to allow periodic buffer timeout checks
priority, message = self._queue.get(timeout=0.5)
self._process_message(message)
except queue.Empty:
# Timeout: check if buffer needs flushing
self._metrics["queue_timeouts"] += 1
self._check_buffer_timeout()
except Exception as e:
# Log error but don't crash the thread
_doctor_internal_logger.error("LogProcessor error: %s", e, exc_info=True)
def _process_message(self, message):
with self._buffer_lock:
return self._process_message_locked(message)
def _process_message_locked(self, message):
"""
Process a single message (migrated from SmartLogger._analyze_stream).
"""
current_time = time.time()
# Always check buffer timeout, even when logs keep coming.
# In some environments (e.g., ComfyUI Desktop), periodic Doctor-API logs
# can prevent queue.Empty from firing, which previously blocked buffer flush
# for non-traceback errors like "Failed to validate prompt for output".
self._check_buffer_timeout()
# ═══════════════════════════════════════════════════════════════
# CRITICAL FIX (2026-01-06): Prevent recursive log capture
# ═══════════════════════════════════════════════════════════════
# BUG: Doctor's own log messages (e.g., "[Doctor] _record_analysis called")
# were being captured by SafeStreamWrapper, and since they contain
# the original error text, they triggered new analysis cycles.
# This caused duplicate suggestions and infinite recursion.
#
# SOLUTION: Skip any message that contains Doctor's internal log markers.
#
# DO NOT REMOVE THIS CHECK! It prevents infinite recursion.
# See: .planning/260106-BUGFIX_DUPLICATE_LOG_CAPTURE.md
# ═══════════════════════════════════════════════════════════════
doctor_markers = [
"[ComfyUI-Doctor ", # Canonical severity-aware output prefix
"[Doctor]", # Internal logging prefix
"[Doctor-API]", # API logging prefix
"----------------------------------------", # Divider line
]
doctor_markers.extend(_ASCII_DOCTOR_OUTPUT_MARKERS)
doctor_markers.extend(_LEGACY_DOCTOR_OUTPUT_MARKERS)
for marker in doctor_markers:
if marker in message:
return # Skip Doctor's own output to prevent recursion
# R22: Exclude asyncio transport GC warnings (Windows ProactorEventLoop)
# These are non-actionable Python-internal warnings that cause recursive
# log pollution when Doctor captures and re-analyses them.
if _is_asyncio_gc_noise(message):
return
# P3: Urgent single-line warnings (immediate analysis)
# R14: Enhanced with detect_fatal_pattern for non-traceback errors
is_urgent = _contains_tensor_alert(message)
# R14: Check for fatal patterns (CUDA OOM, CRITICAL, etc.)
if not is_urgent and detect_fatal_pattern:
fatal_marker = detect_fatal_pattern(message)
if fatal_marker:
is_urgent = True
_doctor_internal_logger.debug("R14 fatal pattern detected: %s", fatal_marker)
if is_urgent and not self.in_traceback:
result = ErrorAnalyzer.analyze(message)
suggestion, metadata = result if result else (None, None)
if suggestion:
self._record_analysis(message, suggestion, metadata)
return
# Detect traceback start
if "Traceback (most recent call last):" in message:
self._set_traceback_state(True)
self.buffer = [message]
self.last_buffer_time = current_time
return
# Handle validation errors
if "Failed to validate prompt for output" in message:
if not self.in_traceback:
self._set_traceback_state(True)
self.buffer = [message]
self.last_buffer_time = current_time
else:
self.buffer.append(message)
self.last_buffer_time = current_time
return
if self.in_traceback:
# Check timeout
if current_time - self.last_buffer_time > CONFIG.traceback_timeout_seconds:
full_traceback = "".join(self.buffer)
result = ErrorAnalyzer.analyze(full_traceback)
suggestion, metadata = result if result else (None, None)
if suggestion or "Failed to validate" in full_traceback:
self._record_analysis(full_traceback, suggestion, metadata)
self._set_traceback_state(False)
self.buffer = []
else:
# ═══════════════════════════════════════════════════════════════
# CRITICAL FIX (2026-01-06): Check completion marker BEFORE buffer.append()
# ═══════════════════════════════════════════════════════════════
# BUG: "Prompt executed in X seconds" was being added to buffer BEFORE
# is_complete_traceback() detected completion, causing it to be
# recorded as part of the error message.
#
# SOLUTION: Intercept "Prompt executed" BEFORE appending to buffer.
# Use current buffer (without this line) for recording.
#
# DO NOT MOVE THIS CHECK AFTER buffer.append()!
# ═══════════════════════════════════════════════════════════════
if "Prompt executed" in message:
full_traceback = "".join(self.buffer)
if full_traceback.strip(): # Only record if buffer has content
result = ErrorAnalyzer.analyze(full_traceback)
suggestion, metadata = result if result else (None, None)
self._record_analysis(full_traceback, suggestion, metadata)
self._set_traceback_state(False)
self.buffer = []
return
self.buffer.append(message)
self.last_buffer_time = current_time
full_traceback = "".join(self.buffer)
# Normal traceback completion
if ErrorAnalyzer.is_complete_traceback(full_traceback):
result = ErrorAnalyzer.analyze(full_traceback)
suggestion, metadata = result if result else (None, None)
self._record_analysis(full_traceback, suggestion, metadata)
self._set_traceback_state(False)
self.buffer = []
# Buffer limit safety
elif len(self.buffer) > CONFIG.buffer_limit:
self._metrics["buffer_dropped"] += 1
self._metrics["traceback_resets"] += 1
self._set_traceback_state(False)
self.buffer = []
def _check_buffer_timeout(self):
"""Check if buffer has timed out (called on queue.Empty)."""
with self._buffer_lock:
if self.in_traceback and self.buffer:
current_time = time.time()
if current_time - self.last_buffer_time > CONFIG.traceback_timeout_seconds:
full_traceback = "".join(self.buffer)
result = ErrorAnalyzer.analyze(full_traceback)
suggestion, metadata = result if result else (None, None)
if suggestion or "Failed to validate" in full_traceback:
self._record_analysis(full_traceback, suggestion, metadata)
self._metrics["traceback_resets"] += 1
self._set_traceback_state(False)
self.buffer = []
def reset_traceback_state(self) -> None:
"""Clear in-flight traceback assembly under the processor-owned lock."""
with self._buffer_lock:
self.buffer = []
self._set_traceback_state(False)
self.last_buffer_time = 0
def _set_traceback_state(self, active: bool) -> None:
self.in_traceback = active
if active:
_TRACEBACK_ACTIVE.set()
else:
_TRACEBACK_ACTIVE.clear()
def _record_analysis(self, full_traceback, suggestion, metadata=None):
"""
Record analysis result (migrated from SmartLogger._record_analysis).
Args:
full_traceback: The full traceback string
suggestion: Suggestion text (or None if no match)
metadata: Optional metadata dict with pattern info (from F4)
"""
# R22: Use dedicated internal logger that bypasses SafeStreamWrapper
# to prevent cross-contamination between Doctor's diagnostic output
# and the captured exception text.
_doctor_internal_logger.debug(
"_record_analysis called with traceback preview: %s...",
full_traceback[:100] if full_traceback else "None",
)
# ═══════════════════════════════════════════════════════════════
# CRITICAL FIX (2026-01-06): Prevent non-error messages from
# overwriting legitimate error records
# ═══════════════════════════════════════════════════════════════
# BUG: Normal messages like "Prompt executed in X seconds" were
# being recorded as errors, overwriting real error logs.
#
# SOLUTION: Validate that full_traceback contains actual error
# indicators before recording.
#
# DO NOT REMOVE THIS VALIDATION!
# ═══════════════════════════════════════════════════════════════
# Skip recording if no actual error content
# This prevents normal messages like "Prompt executed in X seconds" from
# overwriting legitimate error records
if not full_traceback:
return
# Desktop-specific: ignore ComfyUI logging flush exceptions.
# These can create high-frequency "error storms" even when inference isn't running,
# rapidly filling error_history.json with non-actionable noise.
if (
"logging\\__init__.py" in full_traceback
and "ComfyUI\\app\\logger.py" in full_traceback
and "OSError: [Errno 22] Invalid argument" in full_traceback
):
return
# R22: Exclude asyncio transport GC warnings
# These originate from Windows ProactorEventLoop transport __del__
# and are non-actionable noise that would fill history with duplicates.
if _is_asyncio_gc_noise(full_traceback):
return
# Explicit exclusion for normal execution messages
# These messages should NEVER be recorded as errors
normal_messages = [
"Prompt executed in",
"got prompt",
"Executing node",
"To see the GUI go to:",
"Starting server",
]
for normal_msg in normal_messages:
if normal_msg in full_traceback and "Error" not in full_traceback and "Exception" not in full_traceback:
_doctor_internal_logger.debug("Skipping normal message: %s", full_traceback[:100])
return
# Require at least one error indicator
error_indicators = [
"Traceback (most recent call last):",
"Error:",
"Error ", # Catch RuntimeError, ValueError etc without colon
"Exception:",
"Exception ",
"Failed to validate",
"❌ CRITICAL",
"⚠️ Meta Tensor",
"CRITICAL: Tensor contains",
"WARNING: Meta Tensor",
]
has_error_indicator = any(indicator in full_traceback for indicator in error_indicators)
# Also accept if we have a valid suggestion (pattern matched)
if not has_error_indicator and not suggestion:
_doctor_internal_logger.debug(
"Skipping non-error message (no indicators): %s",
full_traceback[:100],
)
return
analysis_metadata = metadata if isinstance(metadata, dict) else {}
matched_pattern_id = analysis_metadata.get("matched_pattern_id")
pattern_category = analysis_metadata.get("pattern_category") or analysis_metadata.get("category")
pattern_priority = analysis_metadata.get("pattern_priority") or analysis_metadata.get("priority")
node_context = ErrorAnalyzer.extract_node_context(full_traceback)
timestamp = utc_isoformat()
error_signature = hashlib.sha256(full_traceback.encode("utf-8", errors="ignore")).hexdigest()
new_analysis = {
"error": full_traceback,
"suggestion": suggestion,
"timestamp": timestamp,
"first_seen": timestamp,
"last_seen": timestamp,
"repeat_count": 1,
"error_signature": error_signature,
"node_context": node_context.to_dict() if node_context else None,
"analysis_metadata": analysis_metadata,
"matched_pattern_id": matched_pattern_id,
"pattern_category": pattern_category,
"pattern_priority": pattern_priority,
"resolution_status": "unresolved",
}
# R2: Thread-safe update of shared state
with _history_lock:
global _last_analysis
_last_analysis = new_analysis
# Aggregate repeated identical errors within 60 seconds to avoid unbounded history growth.
# NOTE: We still update _last_analysis every time so the UI reflects new occurrences.
aggregated = False
try:
now_ts = self._parse_ts(timestamp)
# Look back across recent history to find a matching signature in-window.
# Limit scan to keep cost bounded even when history is unbounded.
scan_limit = 50
hist_len = len(_analysis_history)
start_index = max(0, hist_len - scan_limit)
for i in range(hist_len - 1, start_index - 1, -1):
existing = _analysis_history[i]
if not isinstance(existing, dict):
continue
if existing.get("error_signature") != error_signature:
continue
last_seen_str = existing.get("last_seen") or existing.get("timestamp") or ""
last_seen_ts = self._parse_ts(last_seen_str)
if (now_ts - last_seen_ts).total_seconds() <= self._aggregate_window_seconds:
existing["repeat_count"] = int(existing.get("repeat_count", 1) or 1) + 1
existing["last_seen"] = timestamp
# Keep first_seen stable
if not existing.get("first_seen"):
existing["first_seen"] = existing.get("timestamp") or timestamp
aggregated = True
break
except Exception:
pass
if not aggregated:
_analysis_history.append(new_analysis.copy())
# F1: Persist to history store
try:
store = _get_history_store()
entry = HistoryEntry(
timestamp=timestamp,
error=full_traceback,
suggestion=suggestion if suggestion else {},
node_context=node_context.to_dict() if node_context else None,
matched_pattern_id=matched_pattern_id,
pattern_category=pattern_category,
pattern_priority=pattern_priority,
resolution_status="unresolved",
analysis_metadata=analysis_metadata,
repeat_count=1,
first_seen=timestamp,
last_seen=timestamp,
error_signature=error_signature,
)
store.append(entry)
except Exception:
pass # Persistence failure should not break error analysis
# Only print if we have something useful (context OR suggestion)
if not suggestion and (not node_context or not node_context.is_valid()):
return
# Build formatted output
output_parts = []
output_parts.append(f"\n{'-'*40}")
# Add node context if available
if node_context and node_context.is_valid():
node_info = []
if node_context.node_id:
node_info.append(f"Node ID: #{node_context.node_id}")
if node_context.node_name:
node_info.append(f"Name: {node_context.node_name}")
if node_context.node_class:
node_info.append(f"Class: {node_context.node_class}")
if node_context.custom_node_path:
node_info.append(f"Source: {node_context.custom_node_path}")
output_parts.append("ERROR LOCATION: " + " | ".join(node_info))
# Add suggestion (defensive: ensure suggestion is a string)
if suggestion:
if isinstance(suggestion, str):
output_parts.append(_normalize_backend_suggestion(suggestion))
elif isinstance(suggestion, (tuple, list)) and len(suggestion) > 0:
# Handle case where suggestion is accidentally a tuple (e.g., from analyzer)
normalized = str(suggestion[0]) if suggestion[0] else ""
output_parts.append(_normalize_backend_suggestion(normalized))
output_parts.append(f"{'-'*40}\n")
formatted_output = "\n".join(output_parts)
# Print to stdout (will be captured by our wrapper and ComfyUI's LogInterceptor)
try:
print(formatted_output, file=sys.__stdout__)
except (OSError, AttributeError, UnicodeError, ValueError):
pass # Silently fail if stdout is unavailable
def stop(self):
"""Gracefully stop the processor thread."""
self._running = False
# ==============================================================================
# Installation API (preserved interface, new implementation)
# ==============================================================================
# Global state for installation
_message_queue = None
_log_processor = None
_original_stdout = None
_original_stderr = None
def install(log_path: str):
"""
Install Doctor logger (new architecture).
Args:
log_path: Log file path (currently unused, kept for API compatibility)
Architecture:
- Creates message queue and background processor thread
- Wraps sys.stdout/stderr with SafeStreamWrapper
- Background thread handles all error analysis
Note: This completely avoids the on_flush callback issues from v1.2.x
"""
global _message_queue, _log_processor, _original_stdout, _original_stderr
# IMPORTANT: do a clean reinstall instead of skipping outright. Tests,
# hot-reload, or interrupted teardown can leave the global logger state
# installed across calls; a hard skip would make subsequent captures flaky.
if _message_queue is not None or isinstance(sys.stdout, SafeStreamWrapper) or isinstance(sys.stderr, SafeStreamWrapper):
try:
_doctor_internal_logger.debug("install() detected existing logger state; forcing clean reinstall")
except Exception:
pass
uninstall()
# Create queue and background processor
_message_queue = DroppingQueue(maxsize=CONFIG.log_queue_maxsize)
_log_processor = DoctorLogProcessor(_message_queue)
_log_processor.start()
# Save original streams (may already be ComfyUI's LogInterceptor)
_original_stdout = sys.stdout
_original_stderr = sys.stderr
# Wrap stdout/stderr
sys.stdout = SafeStreamWrapper(_original_stdout, _message_queue)
sys.stderr = SafeStreamWrapper(_original_stderr, _message_queue)
# ComfyUI Desktop (and some ComfyUI builds) install StreamHandler(s) before Doctor,
# capturing the original sys.stdout/sys.stderr object (often LogInterceptor).
# If that stream's flush() raises (e.g., OSError 22), Python logging will emit a traceback
# for EVERY log record, causing massive log spam and filling Doctor history with noise.
#
# Patch existing StreamHandler streams to wrap them in a flush-safe proxy.
try:
root_logger = logging.getLogger()
for handler in list(getattr(root_logger, "handlers", []) or []):
stream = getattr(handler, "stream", None)
if stream in (_original_stdout, _original_stderr) or getattr(type(stream), "__name__", "") == "LogInterceptor":
handler.stream = FlushSafeProxy(stream)
except Exception:
pass
emit_doctor_log("Logger installed (SafeStreamWrapper mode)", "INFO", stream=sys.stdout)
emit_doctor_log(f"Original stdout type: {type(_original_stdout).__name__}", "INFO", stream=sys.stdout)
emit_doctor_log(f"Original stderr type: {type(_original_stderr).__name__}", "INFO", stream=sys.stdout)
def uninstall():
"""Uninstall logger and restore original streams."""
global _log_processor, _original_stdout, _original_stderr, _message_queue
# Restore streams first so shutdown logging cannot recurse through wrappers.
if _original_stdout is not None:
sys.stdout = _original_stdout
if _original_stderr is not None:
sys.stderr = _original_stderr
# Stop background thread
if _log_processor:
_log_processor.stop()
_log_processor.join(timeout=2.0)
_log_processor = None
# Clear queue
if _message_queue:
_message_queue.clear()
_message_queue = None
_original_stdout = None
_original_stderr = None
emit_doctor_log("Logger uninstalled", "INFO", stream=sys.stdout)
def get_logger_metrics() -> Dict[str, Any]:
"""Return logger health metrics for diagnostics and tests."""
metrics = {
"queue_size": 0,
"queue_dropped_total": 0,
"queue_dropped_priority": 0,
"queue_dropped_non_priority": 0,
"queue_dropped_oldest": 0,
"buffer_dropped": 0,
"traceback_resets": 0,
"queue_timeouts": 0,
}
if _message_queue:
metrics.update(_message_queue.get_stats())
metrics["queue_size"] = _message_queue.qsize()
if _log_processor:
metrics.update(_log_processor._metrics)
return metrics
# ==============================================================================
# Backward Compatibility
# ==============================================================================
class SmartLogger:
"""
Backward compatibility class.
The old SmartLogger interface is preserved for existing code that may
reference it, but internally it now uses the new architecture.
"""
@classmethod
def install(cls, log_path: str):
"""Install logger (delegates to new install())."""
install(log_path)
@classmethod
def uninstall(cls):
"""Uninstall logger (delegates to new uninstall())."""
uninstall()