From 5eb1fc47cadb550b5807a6a430bb8fffd6743d7c Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 20 Apr 2026 14:51:48 +0100 Subject: [PATCH] feat: add memory_debug endpoint for memory leak diagnostics and improve SSL context handling for GitHub requests --- repeater/data_acquisition/sqlite_handler.py | 20 ++++--- repeater/web/api_endpoints.py | 64 +++++++++++++++++++++ repeater/web/update_endpoints.py | 15 ++++- 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 4edfd09..3b07a25 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -728,6 +728,8 @@ class SQLiteHandler: """Return CRC error records within the given time window (chronological).""" try: cutoff = time.time() - (hours * 3600) + if limit is None: + limit = 1000 with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row query = """ @@ -735,10 +737,9 @@ class SQLiteHandler: FROM crc_errors WHERE timestamp > ? ORDER BY timestamp DESC + LIMIT ? """ - if limit: - query += f" LIMIT {int(limit)}" - rows = conn.execute(query, (cutoff,)).fetchall() + rows = conn.execute(query, (cutoff, int(limit))).fetchall() return [{"timestamp": r["timestamp"], "count": r["count"]} for r in reversed(rows)] except Exception as e: logger.error(f"Failed to get CRC error history: {e}") @@ -1116,21 +1117,21 @@ class SQLiteHandler: try: cutoff = time.time() - (hours * 3600) + if limit is None: + limit = 1000 + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - # Build query with optional limit query = """ SELECT timestamp, noise_floor_dbm FROM noise_floor WHERE timestamp > ? ORDER BY timestamp DESC + LIMIT ? """ - if limit: - query += f" LIMIT {int(limit)}" - - measurements = conn.execute(query, (cutoff,)).fetchall() + measurements = conn.execute(query, (cutoff, int(limit))).fetchall() # Reverse to get chronological order (oldest to newest) result = [ @@ -1348,6 +1349,9 @@ class SQLiteHandler: ) -> List[dict]: try: + if limit is None: + limit = 500 + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index f2eda0a..4f6a7a5 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -1197,6 +1197,70 @@ class APIEndpoints: logger.error(f"Error getting hardware stats: {e}") return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + def memory_debug(self): + """Diagnostic endpoint: show what is actually holding memory. + + First call starts tracemalloc and takes a baseline snapshot. + Subsequent calls compare against the baseline and return the top + allocations that have *grown* since startup — i.e. the real leaks. + """ + import tracemalloc + + if not tracemalloc.is_tracing(): + tracemalloc.start(10) + # Store baseline snapshot for future diffs + self._tracemalloc_baseline = tracemalloc.take_snapshot() + return self._success({ + "status": "started", + "message": "tracemalloc started — call again after some time to see growth", + }) + + current = tracemalloc.take_snapshot() + baseline = getattr(self, "_tracemalloc_baseline", None) + + # Top 20 allocations right now (grouped by file + line) + top_current = current.statistics("lineno")[:20] + current_stats = [] + for stat in top_current: + current_stats.append({ + "file": str(stat.traceback), + "size_kb": round(stat.size / 1024, 1), + "count": stat.count, + }) + + result = {"current_top_20": current_stats} + + # If we have a baseline, show what GREW (the actual leaks) + if baseline: + diff = current.compare_to(baseline, "lineno") + growth = [d for d in diff if d.size_diff > 0] + growth.sort(key=lambda d: d.size_diff, reverse=True) + growth_stats = [] + for stat in growth[:20]: + growth_stats.append({ + "file": str(stat.traceback), + "size_diff_kb": round(stat.size_diff / 1024, 1), + "count_diff": stat.count_diff, + "current_size_kb": round(stat.size / 1024, 1), + }) + result["growth_since_baseline"] = growth_stats + + # Also include process-level memory for context + try: + import resource + rusage = resource.getrusage(resource.RUSAGE_SELF) + result["rss_mb"] = round(rusage.ru_maxrss / 1024, 1) # macOS=bytes, Linux=KB + except Exception: + pass + + traced_current, traced_peak = tracemalloc.get_traced_memory() + result["traced_current_mb"] = round(traced_current / (1024 * 1024), 2) + result["traced_peak_mb"] = round(traced_peak / (1024 * 1024), 2) + + return self._success(result) + @cherrypy.expose @cherrypy.tools.json_out() def hardware_processes(self): diff --git a/repeater/web/update_endpoints.py b/repeater/web/update_endpoints.py index b95a055..941fa78 100644 --- a/repeater/web/update_endpoints.py +++ b/repeater/web/update_endpoints.py @@ -19,6 +19,7 @@ import json import logging import os import re +import ssl import subprocess import threading import time @@ -43,6 +44,15 @@ PACKAGE_NAME = "pymc_repeater" # How long (seconds) before a cached check result expires CHECK_CACHE_TTL = 600 # 10 minutes +_github_ssl_ctx: Optional[ssl.SSLContext] = None + + +def _get_github_ssl_context() -> ssl.SSLContext: + global _github_ssl_ctx + if _github_ssl_ctx is None: + _github_ssl_ctx = ssl.create_default_context() + return _github_ssl_ctx + class _RateLimitError(Exception): """Raised when GitHub returns HTTP 403 due to rate limiting.""" @@ -371,6 +381,8 @@ class _UpdateState: def append_line(self, line: str) -> None: with self._lock: self.progress_lines.append(line) + if len(self.progress_lines) > 500: + self.progress_lines = self.progress_lines[-500:] _state = _UpdateState() @@ -394,7 +406,8 @@ def _fetch_url(url: str, timeout: int = 10) -> str: headers["Authorization"] = f"Bearer {token}" req = urllib.request.Request(url, headers=headers) try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + ctx = _get_github_ssl_context() if url.startswith("https") else None + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: return resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as exc: if exc.code == 403: