From 8d538cff823dbd75ed50191b169d89796f174a51 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 13 Jul 2026 22:11:26 -0400 Subject: [PATCH] feat: add install error-report generation (schema v1) --- sd_installer/cli.py | 53 +++++++++++ sd_installer/installer.py | 90 ++++++++++++++++--- sd_installer/report.py | 184 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 sd_installer/report.py diff --git a/sd_installer/cli.py b/sd_installer/cli.py index 3f7110c..421bed6 100644 --- a/sd_installer/cli.py +++ b/sd_installer/cli.py @@ -9,6 +9,7 @@ python -m sd_installer install --cuda cu128 # Install with specific CUDA python -m sd_installer verify # Verify existing installation python -m sd_installer diagnose # Detailed diagnostics + python -m sd_installer report # Write a diagnostic report on demand python -m sd_installer repair # Auto-fix known issues python -m sd_installer generate-bat # Generate standalone batch file python -m sd_installer install-tensorrt # Install TensorRT packages @@ -202,6 +203,50 @@ def cmd_diagnose(args): return 0 +def cmd_report(args): + """Generate a diagnostic report on demand (no failure required).""" + from .report import write_error_report + + try: + base = Path(args.base_folder) if args.base_folder else find_base_folder() + except RuntimeError as e: + print(f"ERROR: {e}") + return 1 + + # Find Python executable in venv + venv_path = base / "venv" + if sys.platform == "win32": + python_exe = venv_path / "Scripts" / "python.exe" + else: + python_exe = venv_path / "bin" / "python" + + if not python_exe.exists(): + print(f"ERROR: Virtual environment not found at {venv_path}") + return 1 + + print("Generating StreamDiffusionTD Diagnostic Report") + print("=" * 40) + + out_dir = Path(args.output) if args.output else base / "error_reports" + report_path = write_error_report( + out_dir, + { + "stage": "installation", + "phase": "manual", + "python_exe": str(python_exe), + "base_folder": str(base), + "venv_path": str(venv_path), + }, + ) + + if report_path: + print(f"\nReport written to: {report_path}") + return 0 + + print("ERROR: Failed to write report.") + return 1 + + def cmd_repair(args): """Auto-fix known issues.""" from .verifier import Verifier @@ -384,6 +429,14 @@ def main(): diagnose_parser = subparsers.add_parser("diagnose", help="Run detailed diagnostics") diagnose_parser.set_defaults(func=cmd_diagnose) + # report command + report_parser = subparsers.add_parser("report", help="Generate a diagnostic report on demand") + report_parser.add_argument( + "--output", + help="Directory to write the report into (default: base folder/error_reports)", + ) + report_parser.set_defaults(func=cmd_report) + # repair command repair_parser = subparsers.add_parser("repair", help="Auto-fix known issues") repair_parser.add_argument( diff --git a/sd_installer/installer.py b/sd_installer/installer.py index dd7284e..a2d55a7 100644 --- a/sd_installer/installer.py +++ b/sd_installer/installer.py @@ -120,6 +120,10 @@ def __init__( self.pytorch_config = PYTORCH_CONFIGS[cuda_version] + # Populated during install() for the on-failure diagnostic report (see report.py). + self.current_phase: Optional[str] = None + self._last_pip_stderr: Optional[str] = None + @property def python_exe(self) -> Path: """Path to Python executable in venv.""" @@ -154,6 +158,7 @@ def _run_pip(self, args: list, check: bool = True, cwd: Optional[Path] = None) - ) if check and result.returncode != 0: print(f" STDERR: {result.stderr}") + self._last_pip_stderr = result.stderr raise RuntimeError(f"pip failed: {result.stderr}") return result @@ -299,7 +304,8 @@ def phase4b_cuda_link(self): print(" CUDA-IPC zero-copy export will fall back to the mirror-DAT transport") def phase4c_cuda_link_env(self): - """Phase 4c: Persist CUDALINK_LIB_PATH and CUDALINK_DOORBELL (Windows only). + """Phase 4c: Persist CUDALINK_LIB_PATH, CUDALINK_DOORBELL, and SDTD_BASE_FOLDER_PATH + (Windows only). CUDALINK_LIB_PATH -> this venv's site-packages: TouchDesigner's CUDALinkBootstrap.py reads CUDALINK_LIB_PATH at Text DAT import time to @@ -318,11 +324,19 @@ def phase4c_cuda_link_env(self): must be persisted here instead. CUDALINK_WAIT_BACKEND is deliberately left unset -- its default "auto" already selects the native path. + SDTD_BASE_FOLDER_PATH -> this install's base_folder (StreamDiffusion repo root): + Same cross-process problem as CUDALINK_DOORBELL -- TD's Python process needs a reliable + anchor to the repo root for the inference-side error-report dump + (streamdiffusion.utils.diagnostics.write_error_report), and a runtime os.environ.setdefault + in td_manager.py can't reach that separate process. Persisting it here means every + TD-launched Python inherits it without a manual env-var step. + setx writes to HKCU\\Environment (user scope) and only affects processes started *after* it runs, so TD must be (re)started after installation to pick it up. This intentionally overwrites any prior manual value (e.g. an older cuda_link_lib\\ target). Non-fatal: if setx fails or this isn't Windows, TD simply falls back to the mirror-DAT - classic mode (for CUDALINK_LIB_PATH) or the poll-sleep wait backend (for CUDALINK_DOORBELL). + classic mode (for CUDALINK_LIB_PATH), the poll-sleep wait backend (for CUDALINK_DOORBELL), + or the diagnostics module's own __file__-relative fallback (for SDTD_BASE_FOLDER_PATH). """ if sys.platform != "win32": return # setx is a Windows-only mechanism; non-Windows TD launches are unaffected @@ -356,6 +370,17 @@ def phase4c_cuda_link_env(self): else: print(" CUDALINK_DOORBELL=1 persisted (enables doorbell/native-wait IPC fast path).") + # SDTD_BASE_FOLDER_PATH -> repo root, so TD's Python process can locate error_reports/ + # without a manual env-var step. Independent of the blocks above, so it runs even if + # either warned. + base_result = subprocess.run( + ["setx", "SDTD_BASE_FOLDER_PATH", str(self.base_folder)], capture_output=True, text=True + ) + if base_result.returncode != 0: + print(f" WARNING: setx failed to persist SDTD_BASE_FOLDER_PATH: {base_result.stderr.strip()}") + else: + print(f" SDTD_BASE_FOLDER_PATH={self.base_folder} persisted (anchors error-report dumps).") + def phase5_missing_pins(self): """Phase 5: Install packages not pinned in setup.py and fix diffusers.""" self._report_progress("Installing packages not in setup.py (timm, python-osc, peft)...", 5, 8) @@ -411,6 +436,31 @@ def phase8_verify(self) -> bool: verifier = Verifier(str(self.python_exe)) return verifier.run_all() + def _write_install_error_report(self, exc: BaseException) -> None: + """Best-effort diagnostic dump on install failure. Never raises -- a bug here must + not mask the real installation error, which the caller re-raises regardless.""" + try: + from .report import write_error_report + + report_path = write_error_report( + self.base_folder / "error_reports", + { + "stage": "installation", + "exc": exc, + "phase": self.current_phase, + "python_exe": str(self.python_exe), + "base_folder": str(self.base_folder), + "cuda_version": self.cuda_version, + "pytorch_config": self.pytorch_config, + "venv_path": str(self.venv_path), + "pip_stderr": self._last_pip_stderr, + }, + ) + if report_path: + print(f"\n Error report written to: {report_path}") + except Exception as report_exc: + print(f" WARNING: Failed to generate error report: {report_exc}") + def install(self, python_exe: Optional[str] = None) -> bool: """ Run full installation. @@ -433,18 +483,30 @@ def install(self, python_exe: Optional[str] = None) -> bool: # Create venv if needed self.create_venv(python_exe) - # Run installation phases - self.phase1_foundation() - self.phase2_pytorch() - self.phase3_xformers() - self.phase3b_insightface() # Pre-install insightface from wheel (Windows) - self.phase4_streamdiffusion() - self.phase4b_cuda_link() # Pre-install cuda-link from wheel (CUDA-IPC transport) - self.phase4c_cuda_link_env() # Persist CUDALINK_LIB_PATH -> venv (TD library mode) - self.phase5_missing_pins() - self.phase6_conflict_prone() - self.phase7_numpy_lock() - success = self.phase8_verify() + # Run installation phases. current_phase is tracked so a failure report (see + # _write_install_error_report) can name the phase that was running when it broke. + phases = [ + ("phase1_foundation", self.phase1_foundation), + ("phase2_pytorch", self.phase2_pytorch), + ("phase3_xformers", self.phase3_xformers), + ("phase3b_insightface", self.phase3b_insightface), # insightface from wheel (Windows) + ("phase4_streamdiffusion", self.phase4_streamdiffusion), + ("phase4b_cuda_link", self.phase4b_cuda_link), # cuda-link from wheel (CUDA-IPC transport) + ("phase4c_cuda_link_env", self.phase4c_cuda_link_env), # CUDALINK_LIB_PATH -> venv (TD library mode) + ("phase5_missing_pins", self.phase5_missing_pins), + ("phase6_conflict_prone", self.phase6_conflict_prone), + ("phase7_numpy_lock", self.phase7_numpy_lock), + ] + + try: + for name, phase_fn in phases: + self.current_phase = name + phase_fn() + self.current_phase = "phase8_verify" + success = self.phase8_verify() + except Exception as exc: + self._write_install_error_report(exc) + raise print() print("=" * 50) diff --git a/sd_installer/report.py b/sd_installer/report.py new file mode 100644 index 0000000..97a27c1 --- /dev/null +++ b/sd_installer/report.py @@ -0,0 +1,184 @@ +""" +StreamDiffusionTD Installer Error Report + +Builds a self-contained, human-readable diagnostic report (schema v1) when an +installation phase fails, and backs the manual `report` CLI subcommand. + +Best-effort by design: report generation must never raise past write_error_report's +own try/except, so a bug in the reporter never masks the real installation error. +""" + +import datetime +import os +import platform +import subprocess +import sys +import traceback +from pathlib import Path +from typing import Optional + +from .verifier import Verifier, match_known_error + +SCHEMA_VERSION = "v1" +# Only these env-var prefixes are dumped -- never the full os.environ (avoids leaking secrets). +ENV_ALLOWLIST_PREFIXES = ("CUDALINK_", "HF_", "SD_", "SDTD_") +# Even an allowlisted-prefix var is dropped if its name contains one of these substrings -- +# a prefix match alone isn't enough (e.g. HF_TOKEN matches "HF_" but must never be dumped). +ENV_DENYLIST_SUBSTRINGS = ("TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD", "CRED") + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(datetime.timezone.utc) + + +def _nvidia_smi_driver() -> str: + """Query the NVIDIA driver version via nvidia-smi. Best-effort.""" + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().splitlines()[0] + except Exception: + pass + return "unknown" + + +def _collect_env_allowlist() -> dict: + """Collect only allow-listed env vars -- never dump full os.environ (secrets).""" + return { + k: v + for k, v in sorted(os.environ.items()) + if k.startswith(ENV_ALLOWLIST_PREFIXES) and not any(bad in k.upper() for bad in ENV_DENYLIST_SUBSTRINGS) + } + + +def _format_section(title: str, lines: list) -> str: + body = "\n".join(lines) if lines else "(none)" + return f"== {title} ==\n{body}\n" + + +def build_report_text(context: dict) -> str: + """ + Build the schema-v1 diagnostic report text. + + Expected context keys (all optional unless noted): + stage (str, required): "installation" + exc (BaseException): the caught exception -- feeds SUMMARY + TRACEBACK + exc_text (str): pre-formatted traceback text, used when `exc` is absent + phase (str): name of the failing (or current, for manual reports) phase + python_exe (str | Path): venv python -- used to run Verifier.diagnose() + base_folder, cuda_version, pytorch_config, venv_path: installer config + pip_stderr (str): captured stderr tail from the failing pip invocation + + Returns: + Full report text, ready to write to disk. + """ + stage = context.get("stage", "installation") + lines = [ + "StreamDiffusionTD Error Report (schema v1)", + f"Generated: {_utc_now().isoformat()}", + f"Stage: {stage}", + "-" * 50, + "", + ] + + # == SUMMARY == + exc = context.get("exc") + if exc is not None: + error_line = f"{type(exc).__name__}: {exc}" + elif context.get("exc_text"): + error_line = context["exc_text"].strip().splitlines()[-1] + else: + error_line = "unknown" + summary_lines = [f"Error: {error_line}", f"Context: {context.get('phase', 'unknown')}"] + + pip_stderr = context.get("pip_stderr") + match_text = pip_stderr or (str(exc) if exc is not None else "") + known = None + if match_text: + try: + known = match_known_error(match_text) + except Exception: + known = None + if known: + summary_lines.append(f"Known-cause match: {known['cause']} -- fix: {known['fix']}") + lines.append(_format_section("SUMMARY", summary_lines)) + + # == TRACEBACK == + if exc is not None: + tb_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + else: + tb_text = context.get("exc_text") or "(no traceback available)" + lines.append(_format_section("TRACEBACK", [tb_text.rstrip("\n")])) + + # == SYSTEM == / == VERSIONS == + system_lines = [ + f"OS: {platform.platform()}", + f"Python (host): {sys.version.replace(chr(10), ' ')}", + f"NVIDIA driver: {_nvidia_smi_driver()}", + ] + versions_lines = [] + python_exe = context.get("python_exe") + if python_exe: + try: + info = Verifier(str(python_exe)).diagnose() + gpu = info.get("gpu", {}) + if gpu: + system_lines.append(f"GPU: {gpu.get('name', 'unknown')}") + if "vram_mb" in gpu: + system_lines.append(f"VRAM total: {gpu['vram_mb']} MB") + if "compute_capability" in gpu: + system_lines.append(f"Compute capability: {gpu['compute_capability']}") + for pkg, version in info.get("versions", {}).items(): + versions_lines.append(f"{pkg}: {version}") + except Exception as diag_exc: + versions_lines.append(f"(Verifier.diagnose() failed: {diag_exc})") + else: + versions_lines.append("(no python_exe provided -- venv package versions unavailable)") + lines.append(_format_section("SYSTEM", system_lines)) + lines.append(_format_section("VERSIONS", versions_lines)) + + # == CONFIG == + config_keys = ("base_folder", "cuda_version", "pytorch_config", "venv_path") + config_lines = [f"{key}: {context[key]}" for key in config_keys if key in context] + lines.append(_format_section("CONFIG", config_lines)) + + # == ENV == + env_lines = [f"{k}={v}" for k, v in _collect_env_allowlist().items()] + lines.append(_format_section("ENV", env_lines)) + + # == LOG TAIL == + log_lines = pip_stderr.strip().splitlines()[-50:] if pip_stderr else [] + lines.append(_format_section("LOG TAIL", log_lines)) + + return "\n".join(lines) + + +def write_error_report(out_dir, context: dict) -> Optional[Path]: + """ + Build and write a diagnostic report to disk. + + Best-effort -- any failure here is caught, printed, and swallowed rather than + raised, so a reporting bug never masks the original installation error. + + Args: + out_dir: Directory to write the report into (created if missing). + context: See build_report_text() for expected keys. + + Returns: + Path to the written report, or None if writing failed. + """ + try: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + timestamp = _utc_now().strftime("%Y%m%d_%H%M%S") + report_path = out_dir / f"install_error_report_{timestamp}.txt" + report_path.write_text(build_report_text(context), encoding="utf-8") + return report_path + except Exception as write_exc: + print(f" WARNING: Failed to write error report: {write_exc}") + return None