324 lines
12 KiB
Python
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
|