"""S-CANVAS 크래시/예외 로깅 — sys.excepthook + faulthandler + 회전 파일 핸들러. 사용: from harness.crash_logger import install_crash_handlers install_crash_handlers() # main() 진입점에서 한 번 호출 동작: - 미처리 예외: traceback을 logs/scanvas.log + logs/crash_.txt에 저장 - C-level 크래시(segfault 등): faulthandler가 stderr + logs/faulthandler.log로 trace - 메인 thread 외 thread 예외도 캡처 (threading.excepthook) - 기존 stdout/stderr 출력은 유지 (사용자 인지 가능) """ from __future__ import annotations import datetime as _dt import faulthandler import logging import logging.handlers import sys import threading import traceback from pathlib import Path # 상수 _LOG_DIR = Path(__file__).resolve().parent.parent / "logs" _MAIN_LOG = _LOG_DIR / "scanvas.log" _FAULT_LOG = _LOG_DIR / "faulthandler.log" _MAX_BYTES = 5 * 1024 * 1024 # 5MB per file _BACKUP_COUNT = 5 # rotate up to scanvas.log.5 _installed = False _crash_logger: logging.Logger | None = None def _ensure_log_dir() -> None: _LOG_DIR.mkdir(parents=True, exist_ok=True) def _build_logger() -> logging.Logger: logger = logging.getLogger("scanvas.crash") logger.setLevel(logging.DEBUG) # 회전 파일 핸들러 (중복 install 방지: 핸들러 이미 있으면 재사용) if not any(isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers): handler = logging.handlers.RotatingFileHandler( _MAIN_LOG, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT, encoding="utf-8", ) fmt = logging.Formatter( "%(asctime)s | %(levelname)s | %(threadName)s | %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) handler.setFormatter(fmt) logger.addHandler(handler) return logger def _dump_crash_artifact(prefix: str, body: str) -> Path: """크래시별 별도 파일에도 전체 traceback 보관 (회전 로그와 별개).""" ts = _dt.datetime.now().strftime("%Y%m%dT%H%M%S") path = _LOG_DIR / f"{prefix}_{ts}.txt" path.write_text(body, encoding="utf-8") return path def _excepthook(exc_type, exc_value, exc_tb): """sys.excepthook — 메인 thread 미처리 예외.""" if exc_type is KeyboardInterrupt: # Ctrl+C는 정상 흐름으로 처리 (기본 동작 유지) sys.__excepthook__(exc_type, exc_value, exc_tb) return body = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) if _crash_logger: _crash_logger.critical("UNCAUGHT EXCEPTION (main thread):\n%s", body) artifact = _dump_crash_artifact("crash", body) # 기존 동작 유지 — stderr에도 출력 sys.__excepthook__(exc_type, exc_value, exc_tb) print(f"\n[crash_logger] 크래시 덤프 → {artifact}", file=sys.stderr) def _thread_excepthook(args: threading.ExceptHookArgs) -> None: """threading.excepthook — 워커 thread 미처리 예외 (Py3.8+).""" if args.exc_type is SystemExit: return body = "".join(traceback.format_exception( args.exc_type, args.exc_value, args.exc_traceback)) thread_name = args.thread.name if args.thread else "" if _crash_logger: _crash_logger.error( "UNCAUGHT EXCEPTION (thread %s):\n%s", thread_name, body) _dump_crash_artifact(f"crash_thread_{thread_name}", body) def install_crash_handlers(also_install_faulthandler: bool = True) -> Path: """크래시 핸들러를 한 번만 install. 재호출은 no-op. Returns: 로그 디렉토리 경로 (사용자에게 보여줄 수 있음). """ global _installed, _crash_logger # noqa: PLW0603 (singleton install pattern) if _installed: return _LOG_DIR _ensure_log_dir() _crash_logger = _build_logger() # Python 예외 훅 sys.excepthook = _excepthook threading.excepthook = _thread_excepthook # C-level 크래시 (segfault 등) — 별도 파일에 기록 if also_install_faulthandler: # 기존 stderr 출력은 그대로 남기고, 추가로 파일에도. # NOTE: faulthandler는 long-lived file handle이 필요 (크래시 시점에 쓰기 위해) # — context manager로 닫으면 안 됨. try: fh = open(_FAULT_LOG, "a", encoding="utf-8") # noqa: SIM115 (faulthandler needs long-lived fh) faulthandler.enable(file=fh, all_threads=True) except OSError as e: # 파일 핸들 못 열면 stderr만으로 폴백 faulthandler.enable(all_threads=True) _crash_logger.warning("faulthandler 파일 모드 실패 (%s) — stderr만 사용", e) _installed = True _crash_logger.info("crash_logger 설치 완료 (logs=%s)", _LOG_DIR) return _LOG_DIR def get_logger() -> logging.Logger: """app.log() 같은 일반 로깅도 같은 회전 파일에 쓰고 싶을 때 사용.""" if _crash_logger is None: # auto-install (설치 안 한 경우 안전한 폴백) install_crash_handlers() return _crash_logger # type: ignore[return-value]