Spaces:
Running
Running
| # memory_usage_reporter.py | |
| """ | |
| Background memory usage reporter. | |
| - Logs process RSS, VMS, peak (if available), GC counts, and optional tracemalloc stats | |
| - Writes to logs/memory.log and also propagates to root logger (run.log) via a child logger | |
| - Designed to be lightweight and safe in GUI apps | |
| """ | |
| import os | |
| import sys | |
| import time | |
| import threading | |
| import logging | |
| import gc | |
| from logging.handlers import RotatingFileHandler | |
| try: | |
| import psutil | |
| except Exception: | |
| psutil = None | |
| # Global singletons | |
| _GLOBAL_THREAD = None | |
| _GLOBAL_STOP = threading.Event() | |
| def _ensure_logs_dir() -> str: | |
| # Prefer explicit override from main app | |
| try: | |
| env_dir = os.environ.get("GLOSSARION_LOG_DIR") | |
| if env_dir: | |
| dir_path = os.path.expanduser(env_dir) | |
| os.makedirs(dir_path, exist_ok=True) | |
| return dir_path | |
| except Exception: | |
| pass | |
| def _can_write(p: str) -> bool: | |
| try: | |
| os.makedirs(p, exist_ok=True) | |
| test_file = os.path.join(p, ".write_test") | |
| with open(test_file, "w", encoding="utf-8") as f: | |
| f.write("ok") | |
| os.remove(test_file) | |
| return True | |
| except Exception: | |
| return False | |
| # Frozen exe: try next to the executable first | |
| try: | |
| if getattr(sys, 'frozen', False) and hasattr(sys, 'executable'): | |
| exe_dir = os.path.dirname(sys.executable) | |
| candidate = os.path.join(exe_dir, "logs") | |
| if _can_write(candidate): | |
| return candidate | |
| except Exception: | |
| pass | |
| # User-local app data (persistent and writable) | |
| try: | |
| base = os.environ.get('LOCALAPPDATA') or os.environ.get('APPDATA') or os.path.expanduser('~') | |
| candidate = os.path.join(base, 'Glossarion', 'logs') | |
| if _can_write(candidate): | |
| return candidate | |
| except Exception: | |
| pass | |
| # Development fallback: next to this file | |
| try: | |
| base_dir = os.path.abspath(os.path.dirname(__file__)) | |
| candidate = os.path.join(base_dir, "logs") | |
| if _can_write(candidate): | |
| return candidate | |
| except Exception: | |
| pass | |
| # Final fallback: CWD | |
| fallback = os.path.join(os.getcwd(), "logs") | |
| os.makedirs(fallback, exist_ok=True) | |
| return fallback | |
| def _make_logger() -> logging.Logger: | |
| logger = logging.getLogger("memory") | |
| logger.setLevel(logging.INFO) | |
| # Avoid duplicate handlers if called more than once | |
| if not any(isinstance(h, RotatingFileHandler) for h in logger.handlers): | |
| logs_dir = _ensure_logs_dir() | |
| file_path = os.path.join(logs_dir, "memory.log") | |
| fh = RotatingFileHandler(file_path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8") | |
| fmt = logging.Formatter( | |
| fmt="%(asctime)s %(levelname)s [%(process)d:%(threadName)s] %(name)s: %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S", | |
| ) | |
| fh.setFormatter(fmt) | |
| logger.addHandler(fh) | |
| # Do NOT propagate to root; keep memory logs out of console and only in memory.log | |
| logger.propagate = False | |
| return logger | |
| def _get_process() -> "psutil.Process | None": | |
| if psutil is None: | |
| return None | |
| try: | |
| return psutil.Process() | |
| except Exception: | |
| return None | |
| def _format_bytes(num: int) -> str: | |
| try: | |
| for unit in ["B", "KB", "MB", "GB", "TB"]: | |
| if num < 1024.0: | |
| return f"{num:,.1f}{unit}" | |
| num /= 1024.0 | |
| return f"{num:,.1f}PB" | |
| except Exception: | |
| return str(num) | |
| def _collect_stats(proc) -> dict: | |
| stats = {} | |
| try: | |
| if proc is not None: | |
| mi = proc.memory_info() | |
| stats["rss"] = mi.rss | |
| stats["vms"] = getattr(mi, "vms", 0) | |
| # Peak RSS on Windows via psutil.Process.memory_info() may expose peak_wset in private API; skip for portability | |
| else: | |
| stats["rss"] = 0 | |
| stats["vms"] = 0 | |
| except Exception: | |
| stats["rss"] = stats.get("rss", 0) | |
| stats["vms"] = stats.get("vms", 0) | |
| # GC stats | |
| try: | |
| counts = gc.get_count() | |
| stats["gc"] = counts | |
| except Exception: | |
| stats["gc"] = (0, 0, 0) | |
| return stats | |
| def _worker(interval_sec: float, include_tracemalloc: bool): | |
| """Memory usage monitoring worker - runs in background thread.""" | |
| try: | |
| log = _make_logger() | |
| proc = _get_process() | |
| # Optional tracemalloc | |
| if include_tracemalloc: | |
| try: | |
| import tracemalloc | |
| if not tracemalloc.is_tracing(): | |
| tracemalloc.start() | |
| tm_enabled = True | |
| except Exception: | |
| tm_enabled = False | |
| else: | |
| tm_enabled = False | |
| except Exception: | |
| # If initialization fails, exit thread gracefully | |
| return | |
| # Main monitoring loop with additional safety | |
| while not _GLOBAL_STOP.is_set(): | |
| try: | |
| st = _collect_stats(proc) | |
| rss = st.get("rss", 0) | |
| vms = st.get("vms", 0) | |
| gc0, gc1, gc2 = st.get("gc", (0, 0, 0)) | |
| msg = ( | |
| f"RSS={_format_bytes(rss)} VMS={_format_bytes(vms)} " | |
| f"GC={gc0}/{gc1}/{gc2}" | |
| ) | |
| if tm_enabled: | |
| try: | |
| import tracemalloc | |
| cur, peak = tracemalloc.get_traced_memory() | |
| msg += f" TM_CUR={_format_bytes(cur)} TM_PEAK={_format_bytes(peak)}" | |
| except Exception: | |
| pass | |
| log.info(msg) | |
| except Exception as e: | |
| try: | |
| log.warning("memory reporter error: %s", e) | |
| except Exception: | |
| pass | |
| finally: | |
| # Use a single sleep with timeout instead of multiple small sleeps | |
| # This reduces thread switching overhead that can cause GIL issues | |
| try: | |
| _GLOBAL_STOP.wait(timeout=interval_sec) | |
| except Exception: | |
| # Fallback to regular sleep if wait fails | |
| time.sleep(interval_sec) | |
| def start_global_memory_logger(interval_sec: float = 3.0, include_tracemalloc: bool = False) -> None: | |
| """Start the background memory logger once per process. | |
| interval_sec: how often to log | |
| include_tracemalloc: if True, also log tracemalloc current/peak | |
| """ | |
| global _GLOBAL_THREAD | |
| # Thread-safe check | |
| with threading.Lock(): | |
| if _GLOBAL_THREAD and _GLOBAL_THREAD.is_alive(): | |
| return | |
| # Clear stop event before starting | |
| _GLOBAL_STOP.clear() | |
| try: | |
| t = threading.Thread( | |
| target=_worker, | |
| args=(interval_sec, include_tracemalloc), | |
| name="mem-logger", | |
| daemon=True | |
| ) | |
| t.start() | |
| _GLOBAL_THREAD = t | |
| except Exception: | |
| # Do not raise to avoid breaking GUI startup | |
| _GLOBAL_THREAD = None | |
| pass | |
| def stop_global_memory_logger() -> None: | |
| try: | |
| _GLOBAL_STOP.set() | |
| if _GLOBAL_THREAD and _GLOBAL_THREAD.is_alive(): | |
| # Give it a moment to exit | |
| _GLOBAL_THREAD.join(timeout=2.0) | |
| except Exception: | |
| pass | |