feat: add memory_debug endpoint for memory leak diagnostics and improve SSL context handling for GitHub requests

This commit is contained in:
Lloyd
2026-04-20 14:51:48 +01:00
parent 799a85ddf9
commit 5eb1fc47ca
3 changed files with 90 additions and 9 deletions
+12 -8
View File
@@ -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
+64
View File
@@ -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):
+14 -1
View File
@@ -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: