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

325 lines
13 KiB
Python

"""
ComfyUI Doctor - Main Entry Point
This module initializes the smart debugging system on ComfyUI startup.
Features:
- Automatic log capture from startup
- System environment snapshot
- Error analysis with suggestions
- API endpoint for frontend integration
===========================================================================
CRITICAL: Relative Import Warning for Test Maintainers
===========================================================================
This module uses RELATIVE IMPORTS (from .logger import ...) which is REQUIRED
for ComfyUI custom node extensions.
DO NOT change to absolute imports (from logger import ...) as this will:
- Break ComfyUI's module loading system
- Cause "ModuleNotFoundError" when ComfyUI tries to load this extension
IMPORTANT FOR TESTING:
- pytest collection is isolated to tests and must not move or rename this file
- See tests/conftest.py for root-entrypoint collection exclusion
- See pytest.ini for importlib-mode configuration
If pytest fails with "attempted relative import with no known parent package":
1. Check that pytest.ini has "addopts = --import-mode=importlib"
2. Check that tests/conftest.py excludes the root entrypoint from collection
3. Do NOT modify these imports - the issue is in test configuration
Last Modified: 2026-08-08 (Removed entrypoint-mutation workaround)
===========================================================================
"""
import sys
import os
import glob
import datetime
import platform
import json
import re
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
# ============================================================================
# CRITICAL: DO NOT change these to absolute imports
# These MUST be relative imports for ComfyUI compatibility
# ============================================================================
from .logger import SmartLogger, get_last_analysis, get_analysis_history, clear_analysis_history, get_logger_metrics
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from .i18n import set_language, get_language, get_ui_text, SUPPORTED_LANGUAGES, UI_TEXT
from .config import CONFIG
from .analyzer import ErrorAnalyzer
from .session_manager import SessionManager
from .system_info import get_system_environment, format_env_for_llm
from .sanitizer import PIISanitizer, SanitizationLevel
from .security import is_local_llm_url, validate_ssrf_url, get_ssrf_metrics
from .outbound import get_outbound_sanitizer, sanitize_outbound_payload
from .llm_client import llm_request_with_retry, RetryConfig, RetryResult
from .services.token_budget import TokenBudgetService, BudgetConfig
from .services.prompt_composer import get_prompt_composer, PromptComposerConfig
from .services.doctor_paths import get_doctor_data_dir, get_path_diagnostics
from .services.llm_keys import resolve_api_key, get_provider_status
from .services.secret_store import get_secret_store
from .services.admin_guard import get_admin_guard_startup_warning, validate_admin_request
from .services.api_response import admin_denied_response, error_response
from .services.llm_provider_adapters import get_llm_provider_adapter
from .services.audit import ActionAudit
from .services.community_feedback import build_feedback_preview, submit_feedback, GitHubFeedbackConfig, FeedbackValidationError
from .terminal_output import (
DoctorLogFormatter,
emit_doctor_banner,
emit_doctor_log,
stream_supports_color,
)
# Global R12 Service
TOKEN_BUDGET_SERVICE = TokenBudgetService()
# R7: Apply configurable rate/concurrency limits from CONFIG
SessionManager.configure_limits(
core_rate_limit=getattr(CONFIG, "llm_core_rate_limit", None),
light_rate_limit=getattr(CONFIG, "llm_light_rate_limit", None),
max_concurrent=getattr(CONFIG, "llm_max_concurrent", None),
rate_window_seconds=getattr(getattr(CONFIG, "guardrails", None), "RATE_LIMIT_WINDOW_SECONDS", None),
)
SessionManager.configure_proxy_policy(
config_policy=getattr(CONFIG, "llm_proxy_policy", None),
env_policy=os.getenv(SessionManager.ENV_PROXY_POLICY_KEY),
)
def _close_retry_response(result: RetryResult) -> None:
resp = getattr(result, "response", None)
if resp is None:
return
try:
resp.close()
except Exception:
pass
def _admin_denied_response(code: str, message: str):
"""Standardized admin denial response payload/status for write-sensitive APIs."""
return admin_denied_response(web, code, message)
def _error_response(message: str, status: int, code: str | None = None, extra: dict | None = None):
"""Standardized Doctor API error response payload/status."""
return error_response(web, message, status, code=code, extra=extra)
def _startup_print(message: str = "", level: str = "INFO") -> None:
emit_doctor_log(message, level, stream=sys.stdout)
# --- LLM Environment Variable Fallbacks ---
# These can be set in system environment to provide default values
DOCTOR_LLM_API_KEY = os.getenv("DOCTOR_LLM_API_KEY")
DOCTOR_LLM_BASE_URL = os.getenv("DOCTOR_LLM_BASE_URL", "https://api.openai.com/v1")
DOCTOR_LLM_MODEL = os.getenv("DOCTOR_LLM_MODEL", "gpt-4o")
# --- Local LLM Service URLs (Environment Variable Support) ---
# Allows cross-platform compatibility (Windows vs WSL2, Docker, etc.)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
LMSTUDIO_BASE_URL = os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1")
# --- 1. Setup Log Directory (Local to Node) ---
current_dir = os.path.dirname(os.path.abspath(__file__))
# R18: Prefer canonical Doctor data dir for persisted logs (Desktop-safe)
log_dir = os.path.join(get_doctor_data_dir(), "logs")
if not os.path.exists(log_dir):
try:
os.makedirs(log_dir, exist_ok=True)
except OSError as e:
_startup_print(f"Could not create log directory: {e}", "WARNING")
# --- 2. Log File Cleanup ---
def cleanup_old_logs(log_directory: str, max_files: int = 10) -> None:
"""
Keep only the most recent N log files, delete older ones.
Args:
log_directory: Path to the logs directory.
max_files: Maximum number of log files to keep.
"""
try:
log_files = sorted(glob.glob(os.path.join(log_directory, "comfyui_debug_*.log")))
if len(log_files) > max_files:
for old_file in log_files[:-max_files]:
try:
os.remove(old_file)
except OSError:
pass # File may be locked, continue with others
except OSError:
pass # Directory access may fail, silently continue
cleanup_old_logs(log_dir, CONFIG.max_log_files)
# --- 3. Check if Prestartup Logger is already installed ---
prestartup_log_path = os.environ.get("COMFYUI_DOCTOR_LOG_PATH")
if prestartup_log_path and os.path.exists(prestartup_log_path):
# Prestartup logger was installed - use the same log file
log_path = prestartup_log_path
_startup_print("Upgrading from prestartup logger...")
_startup_print(f"Using existing log: {log_path}")
else:
# No prestartup logger - generate new log filename
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_filename = f"comfyui_debug_{timestamp}.log"
log_path = os.path.join(log_dir, log_filename)
_startup_print("Initializing smart debugger...")
_startup_print(f"Log file: {log_path}")
# --- 4. Install/Upgrade to Full Smart Logger ---
# This will replace the minimal PrestartupLogger with the full-featured SmartLogger
def _handoff_prestartup_logger():
"""
Best-effort cleanup of the prestartup logger to avoid leaked file handles.
This does NOT import prestartup_script.py to avoid re-running its side effects.
"""
for module in list(sys.modules.values()):
module_file = getattr(module, "__file__", "")
if not module_file:
continue
if module_file.endswith("prestartup_script.py"):
pre_logger = getattr(module, "PrestartupLogger", None)
if pre_logger and hasattr(pre_logger, "uninstall"):
try:
pre_logger.uninstall()
_startup_print("Prestartup logger handoff complete")
except Exception as handoff_error:
_startup_print(f"Prestartup logger handoff failed: {handoff_error}", "WARNING")
break
_handoff_prestartup_logger()
SmartLogger.install(log_path)
# --- 5. Setup API Logger for Doctor Operations ---
def setup_api_logger():
"""
Create a dedicated logger for API operations.
Logs to logs/api_operations.log (separate from SmartLogger's error logs).
"""
api_logger = logging.getLogger('ComfyUI-Doctor-API')
# Prevent duplicate handlers if called multiple times
if api_logger.handlers:
return api_logger
api_logger.setLevel(logging.INFO)
# File handler with rotation (max 5MB, keep 3 backups)
api_log_path = os.path.join(log_dir, 'api_operations.log')
file_handler = RotatingFileHandler(
api_log_path,
maxBytes=5 * 1024 * 1024, # 5MB
backupCount=3,
encoding='utf-8'
)
# Doctor-owned files stay canonical and ANSI-free.
formatter = DoctorLogFormatter(include_timestamp=True, color_enabled=False)
file_handler.setFormatter(formatter)
api_logger.addHandler(file_handler)
# Console handler for terminal output (user requested visibility)
console_handler = logging.StreamHandler(sys.stdout)
console_formatter = DoctorLogFormatter(
include_timestamp=False,
color_enabled=stream_supports_color(sys.stdout),
)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(logging.INFO)
api_logger.addHandler(console_handler)
# Prevent propagation to root logger (avoid duplicate console output)
api_logger.propagate = False
return api_logger
# Initialize logger
logger = setup_api_logger()
_startup_print(f"API logger initialized: {os.path.join(log_dir, 'api_operations.log')}")
admin_guard_warning = get_admin_guard_startup_warning()
if admin_guard_warning:
logger.warning(admin_guard_warning)
# --- 5. Log System Information (Hardware Snapshot) ---
def log_system_info() -> None:
"""Log system and hardware information at startup."""
emit_doctor_banner(stream=sys.stdout)
_startup_print(f"{'='*20} SYSTEM SNAPSHOT {'='*20}")
_startup_print(f"OS: {platform.system()} {platform.release()} ({platform.version()})")
_startup_print(f"Python: {sys.version.split()[0]}")
try:
import torch
_startup_print(f"PyTorch: {torch.__version__}")
_startup_print(f"CUDA Available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
_startup_print(f"CUDA Version: {torch.version.cuda}")
device_count = torch.cuda.device_count()
_startup_print(f"GPU Count: {device_count}")
for i in range(device_count):
props = torch.cuda.get_device_properties(i)
_startup_print(f" GPU {i}: {props.name} (VRAM: {props.total_memory / 1024**3:.2f} GB)")
except ImportError:
_startup_print("PyTorch: Not Installed (or not found in this env)")
# Log ComfyUI Arguments if available
_startup_print(f"Args: {sys.argv}")
_startup_print(f"{'='*57}\n")
log_system_info()
# --- 6. API Registration ---
try:
import server
import aiohttp
from aiohttp import web
from .api_routes import register_api_routes
register_api_routes({**globals(), "server": server, "aiohttp": aiohttp, "web": web})
except ImportError:
_startup_print("Server module not found (running in standalone mode?)", "WARNING")
except Exception as e:
_startup_print(f"Failed to register API: {e}", "WARNING")
# Web directory for frontend assets (required by ComfyUI)
WEB_DIRECTORY = "./web"
def _read_pyproject_value(pattern: str, fallback: str = "") -> str:
try:
pyproject_path = Path(__file__).resolve().with_name("pyproject.toml")
text = pyproject_path.read_text(encoding="utf-8", errors="ignore")
m = re.search(pattern, text)
if m:
value = (m.group(1) or "").strip()
return value or fallback
except Exception:
pass
return fallback
# Metadata for "About" / tooling integrations (best-effort, no hard dependency).
# Many ComfyUI/Manager UIs will display version + repo link when available.
__version__ = _read_pyproject_value(r'(?m)^version\s*=\s*["\\\']([^"\\\']+)["\\\']', fallback="unknown")
__repository__ = _read_pyproject_value(r'(?m)^Repository\s*=\s*["\\\']([^"\\\']+)["\\\']', fallback="https://github.com/rookiestar28/ComfyUI-Doctor")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY", "__version__", "__repository__"]