Files

324 lines
12 KiB
Python

"""
Error Analyzer for ComfyUI Runtime Diagnostics.
Analyzes Python tracebacks and provides human-readable suggestions for common errors.
Refactored to use Plugin-based Analysis Pipeline (A6).
"""
import re
import functools
import logging
import json
from typing import Optional, Tuple, List, Dict, Any
# CRITICAL: keep relative-first fallback; ComfyUI loads Doctor as a package,
# while pytest imports these modules as top-level files.
try:
from .pipeline import AnalysisPipeline, AnalysisContext, NodeContext
from .pipeline.stages import SanitizerStage, PatternMatcherStage, ContextEnhancerStage, LLMContextBuilderStage
from .i18n import ERROR_KEYS
from .services.workflow_pruner import WorkflowPruner
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from pipeline import AnalysisPipeline, AnalysisContext, NodeContext
from pipeline.stages import SanitizerStage, PatternMatcherStage, ContextEnhancerStage, LLMContextBuilderStage
from i18n import ERROR_KEYS
from services.workflow_pruner import WorkflowPruner
# Keep PATTERNS for Legacy Fallback (Stage 2)
# Pattern definitions: (regex_pattern, error_key, has_groups)
# Patterns are checked in order, first match wins
PATTERNS: List[Tuple[str, str, bool]] = [
# SafeTensors Error
(r"safetensors_rust.SafetensorError: Error while deserializing header",
ERROR_KEYS["SAFETENSORS_ERROR"], False),
# CUDNN Error
(r"RuntimeError: cuDNN error: CUDNN_STATUS_EXECUTION_FAILED",
ERROR_KEYS["CUDNN_ERROR"], False),
# Missing InsightFace
(r"ModuleNotFoundError: No module named 'insightface'",
ERROR_KEYS["MISSING_INSIGHTFACE"], False),
# Model/VAE Mismatch (autograd generic but specific text)
(r"RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn",
ERROR_KEYS["MODEL_VAE_MISMATCH"], False),
# MPS OOM
(r"MPS backend out of memory",
ERROR_KEYS["MPS_OOM"], False),
# Invalid Prompt (JSON)
(r"json.decoder.JSONDecodeError",
ERROR_KEYS["INVALID_PROMPT"], False),
# Type mismatch
(r"RuntimeError: expected scalar type (\w+) but found (\w+)",
ERROR_KEYS["TYPE_MISMATCH"], True),
# Dimension mismatch
(r"RuntimeError: The size of tensor ([a-z]) \((\d+)\) must match the size of tensor ([a-z]) \((\d+)\) at non-singleton dimension (\d+)",
ERROR_KEYS["DIMENSION_MISMATCH"], True),
# CUDA OOM (classic)
(r"CUDA out of memory",
ERROR_KEYS["OOM"], False),
# PyTorch OOM (newer format)
(r"torch\.OutOfMemoryError",
ERROR_KEYS["TORCH_OOM"], False),
# Matrix multiplication
(r"mat1 and mat2 shapes cannot be multiplied",
ERROR_KEYS["MATRIX_MULT"], False),
# Device/Type mismatch
(r"Input type \((\w+)\) and weight type \((\w+)\) should be the same",
ERROR_KEYS["DEVICE_TYPE"], True),
# Missing module (improved: supports submodules like 'pkg.submodule')
(r"(?:ModuleNotFoundError|ImportError): No module named ['\"]?([\w.]+)['\"]?",
ERROR_KEYS["MISSING_MODULE"], True),
# Assertion error
(r"AssertionError: (.+)",
ERROR_KEYS["ASSERTION"], True),
# Key error
(r"KeyError: ['\"](.+)['\"]",
ERROR_KEYS["KEY_ERROR"], True),
# Attribute error
(r"AttributeError: '(.+)' object has no attribute '(.+)'",
ERROR_KEYS["ATTRIBUTE_ERROR"], True),
# Shape mismatch (generic)
(r"ValueError: (.+) shape (.+) doesn't match (.+)",
ERROR_KEYS["SHAPE_MISMATCH"], True),
# File not found
(r"FileNotFoundError: \[Errno 2\] No such file or directory: '(.+)'",
ERROR_KEYS["FILE_NOT_FOUND"], True),
# ComfyUI Validation Error (Dynamic inputs mismatch)
(r"Failed to validate prompt for output \d+:[\s\S]*?\*\s+([^\n]+)\s+\d+:\s*\n\s*-\s+([^\n]+)",
ERROR_KEYS["VALIDATION_ERROR"], True),
# IMPORTANT: keep legacy emoji-compatible patterns here so historical
# console output still matches after R26 switches new backend output to ASCII.
# Debug Node: Tensor NaN/Inf
(r"(?:❌\s*)?CRITICAL: Tensor contains (NaN|Inf)",
ERROR_KEYS["TENSOR_NAN_INF"], True),
# Debug Node: Meta Tensor
(r"(?:⚠️\s*)?(?:WARNING:\s*)?Meta Tensor",
ERROR_KEYS["META_TENSOR"], False),
]
_pipeline_instance = None
def get_pipeline():
"""Singleton accessor for AnalysisPipeline."""
global _pipeline_instance
if _pipeline_instance is None:
stages = [
SanitizerStage(),
PatternMatcherStage(legacy_patterns=PATTERNS),
ContextEnhancerStage(),
LLMContextBuilderStage(WorkflowPruner()),
]
_pipeline_instance = AnalysisPipeline(stages)
return _pipeline_instance
# Helper for pre-compiling patterns (used by is_complete_traceback or legacy parts)
@functools.lru_cache(maxsize=64)
def _compile_pattern(pattern: str):
"""Compile and cache a regex pattern."""
return re.compile(pattern, re.IGNORECASE)
class ErrorAnalyzer:
"""
Analyzes Python tracebacks and provides human-readable suggestions
for common ComfyUI/PyTorch errors.
A6: Now acts as a wrapper around AnalysisPipeline.
"""
@staticmethod
def _infer_category_from_key(error_key: str) -> str:
"""
Infer error category from error_key.
Kept for backward compatibility and internal use by PatternMatcherStage.
"""
key_lower = error_key.lower()
if any(keyword in key_lower for keyword in ['oom', 'memory', 'allocation']):
return 'memory'
if any(keyword in key_lower for keyword in ['safetensors', 'checkpoint', 'model', 'lora', 'vae']):
return 'model_loading'
if any(keyword in key_lower for keyword in ['validation', 'missing_input', 'type_mismatch', 'dimension', 'shape']):
return 'workflow'
if any(keyword in key_lower for keyword in ['cuda', 'cudnn', 'torch', 'mps', 'insightface', 'module']):
return 'framework'
return 'generic'
@staticmethod
def _coerce_node_context(node_context: Any) -> Optional[NodeContext]:
"""Convert route/client node context dictionaries into pipeline NodeContext."""
if isinstance(node_context, NodeContext):
return node_context
if not isinstance(node_context, dict) or not node_context:
return None
def _value(*keys: str) -> Optional[str]:
for key in keys:
value = node_context.get(key)
if value is not None and value != "":
return str(value)
return None
return NodeContext(
node_id=_value("node_id", "id"),
node_name=_value("node_name", "title", "name"),
node_class=_value("node_class", "class_type", "type"),
custom_node_path=_value("custom_node_path"),
display_node=_value("display_node"),
parent_node=_value("parent_node"),
real_node_id=_value("real_node_id"),
)
@staticmethod
def _coerce_workflow_json(workflow_json: Any) -> Optional[Dict[str, Any]]:
"""Normalize route workflow payloads before pipeline pruning."""
if isinstance(workflow_json, dict):
return workflow_json
if isinstance(workflow_json, str) and workflow_json.strip():
try:
parsed = json.loads(workflow_json)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
return None
@staticmethod
def build_llm_context(
traceback_text: str,
*,
workflow_json: Optional[Any] = None,
node_context: Optional[Any] = None,
execution_logs: Optional[List[str]] = None,
system_info: Optional[Dict[str, Any]] = None,
settings: Optional[Dict[str, Any]] = None,
) -> AnalysisContext:
"""
Build structured LLM context through the active analysis pipeline.
Route handlers pass already-sanitized inputs here; the pipeline owns
summary, node, workflow, log, system-info, and manifest assembly.
"""
ctx = AnalysisContext(
traceback=traceback_text or "",
workflow_json=ErrorAnalyzer._coerce_workflow_json(workflow_json),
system_info=system_info or {},
settings=settings or {},
)
coerced_node = ErrorAnalyzer._coerce_node_context(node_context)
if coerced_node:
ctx.node_context = coerced_node
if execution_logs:
ctx.execution_logs = [str(line) for line in execution_logs]
pipeline = get_pipeline()
pipeline.run(ctx)
return ctx
@staticmethod
def extract_node_context(traceback_text: str) -> NodeContext:
"""
Extract ComfyUI node context from a traceback.
A6: Delegates to ContextEnhancerStage directly.
"""
if not traceback_text:
return NodeContext()
# Create a temp context and run just the Enhancer Stage
# This prevents running full pipeline overhead for just extraction
# But maintains logic in one place (ContextEnhancerStage)
ctx = AnalysisContext(traceback=traceback_text)
enhancer = ContextEnhancerStage()
enhancer.process(ctx)
return ctx.node_context or NodeContext()
@staticmethod
def analyze(traceback_text: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
"""
Scans the traceback for known error patterns and returns a suggestion with metadata.
A6: Delegates to AnalysisPipeline.
"""
if not traceback_text:
return (None, None)
try:
ctx = ErrorAnalyzer.build_llm_context(traceback_text)
return (ctx.suggestion, ctx.metadata)
except Exception as e:
logging.error(f"[ErrorAnalyzer] Pipeline failed: {e}", exc_info=True)
return (None, None)
@staticmethod
def is_complete_traceback(text: str) -> bool:
"""
Check if the text contains a complete Python traceback.
Kept as utility.
"""
# For standard Python tracebacks
if "Traceback (most recent call last):" in text:
error_pattern = r'\n([A-Z][a-zA-Z0-9]*(?:Error|Exception|Warning|Interrupt)):.*'
has_python_error = bool(re.search(error_pattern, text))
if has_python_error:
return True
# For ComfyUI Validation Errors
if "Failed to validate prompt for output" in text:
validation_count = text.count("Failed to validate prompt for output")
executing_marker = "Executing prompt:" in text
if executing_marker:
return True
has_details = bool(re.search(r'\n[*\-] ', text))
if has_details and validation_count >= 1:
lines = text.strip().split('\n')
if lines:
last_line = lines[-1].strip()
if "Output will be ignored" in last_line or "Prompt executed" in last_line:
return True
return False
@staticmethod
def reload_patterns() -> bool:
"""
Reload patterns from JSON files.
Wraps PatternLoader through Pipeline?
The Pipeline's PatternMatcherStage holds the plugin list and loader reference.
The loader is a singleton, so reloading it via get_pattern_loader() works globaly.
"""
try:
try:
from .pattern_loader import get_pattern_loader
except ImportError as import_error:
from import_compat import ensure_absolute_import_fallback_allowed
ensure_absolute_import_fallback_allowed(import_error)
from pattern_loader import get_pattern_loader
loader = get_pattern_loader()
return loader.reload_if_changed()
except Exception as e:
logging.warning(f"[ErrorAnalyzer] Failed to reload patterns: {e}")
return False