feat(memory-pressure): implement memory management features including periodic cleanup and SQLite pragmas for low memory conditions

This commit is contained in:
Ivan 2026-07-09 11:05:26 -05:00
parent 08f98b9877
commit 8b30976980
No known key found for this signature in database
19 changed files with 1050 additions and 17 deletions

View file

@ -9,7 +9,7 @@ const {
loadCrashReport,
persistCrashReport,
} = require("./backendCrashReport");
const { killOrphanBackendProcesses } = require("./backendProcessWin");
const { killOrphanBackendProcesses } = require("./backendProcessOrphans");
const LOG_LINE_CAP = 200;

View file

@ -0,0 +1,128 @@
"use strict";
const { execFileSync } = require("node:child_process");
const { killOrphanBackendProcesses: killOrphanBackendProcessesWin } = require("./backendProcessWin");
const BACKEND_IMAGE_UNIX = "ReticulumMeshChatX";
/**
* @param {string} args
* @returns {boolean}
*/
function isHeadlessBackendArgs(args) {
if (typeof args !== "string" || !args) {
return false;
}
if (!args.includes(BACKEND_IMAGE_UNIX)) {
return false;
}
return /\s--headless(?:\s|$)/.test(args) || args.endsWith(" --headless");
}
/**
* @param {number|null|undefined} ownPid
* @returns {number[]}
*/
function listUnixBackendPids(ownPid = null) {
if (process.platform === "win32") {
return [];
}
try {
const out = execFileSync("ps", ["-eo", "pid=,args="], {
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
});
const pids = [];
for (const line of out.split(/\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const match = trimmed.match(/^(\d+)\s+(.*)$/);
if (!match) {
continue;
}
const pid = Number(match[1]);
if (!Number.isFinite(pid) || pid <= 0) {
continue;
}
if (ownPid != null && pid === ownPid) {
continue;
}
if (pid === process.pid) {
continue;
}
if (!isHeadlessBackendArgs(match[2])) {
continue;
}
pids.push(pid);
}
return pids;
} catch {
return [];
}
}
/**
* @param {number[]} pids
*/
function killUnixPids(pids) {
if (!Array.isArray(pids)) {
return;
}
for (const pid of pids) {
try {
process.kill(pid, "SIGTERM");
} catch {
/* process may already be gone */
}
}
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
let remaining = 0;
for (const pid of pids) {
try {
process.kill(pid, 0);
remaining += 1;
} catch {
/* gone */
}
}
if (remaining === 0) {
return;
}
const spinUntil = Date.now() + 50;
while (Date.now() < spinUntil) {
/* brief wait for SIGTERM to take effect */
}
}
for (const pid of pids) {
try {
process.kill(pid, "SIGKILL");
} catch {
/* process may already be gone */
}
}
}
/**
* @param {number|null|undefined} ownPid
* @returns {number}
*/
function killOrphanBackendProcesses(ownPid = null) {
if (process.platform === "win32") {
return killOrphanBackendProcessesWin(ownPid);
}
const pids = listUnixBackendPids(ownPid);
killUnixPids(pids);
return pids.length;
}
module.exports = {
BACKEND_IMAGE_UNIX,
isHeadlessBackendArgs,
killOrphanBackendProcesses,
killUnixPids,
listUnixBackendPids,
};

View file

@ -139,11 +139,11 @@ from meshchatx.src.backend.meshchat_utils import (
parse_lxmf_stamp_cost,
parse_nomadnetwork_node_display_name,
)
from meshchatx.src.backend.memory_pressure import MemoryPressureManager, cache_stats
from meshchatx.src.backend.nomadnet_downloader import (
NomadnetFileDownloader,
NomadnetPageDownloader,
get_cached_active_link,
sweep_stale_links,
)
from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_field_data_to_map,
@ -514,6 +514,7 @@ class ReticulumMeshChat:
reticulum_getter=lambda: getattr(self, "reticulum", None),
broadcast_event=self._on_rns_link_broadcast,
)
self.memory_pressure = MemoryPressureManager(app=self)
# Track long-running rns.link.* handler tasks per WS client so they can
# be cancelled when the client disconnects.
self._rns_link_tasks: dict[web.WebSocketResponse, set[asyncio.Task]] = {}
@ -3048,7 +3049,10 @@ class ReticulumMeshChat:
gc_counter += 1
if gc_counter >= 300:
gc_counter = 0
sweep_stale_links()
try:
await asyncio.to_thread(self.memory_pressure.run_periodic_cleanup)
except Exception as exc:
print(f"[memory_pressure] periodic cleanup error: {exc}")
# Python 3.14+ incremental GC: with threshold[2]==0 full gen2
# collections are never scheduled automatically, so force one.
if sys.version_info >= (3, 14) and gc.get_threshold()[2] == 0:
@ -7217,6 +7221,18 @@ class ReticulumMeshChat:
None,
),
"busy_timeout": _safe_sqlite_pragma("busy_timeout", None),
"temp_store": _safe_sqlite_pragma("temp_store", None),
"cache_size": _safe_sqlite_pragma("cache_size", None),
"mmap_size": _safe_sqlite_pragma("mmap_size", None),
"memory_relaxed": bool(
getattr(
self.database,
"_sqlite_memory_relaxed",
False,
)
if self.database is not None
else False
),
},
"reticulum_config_path": self._api_reticulum_config_path(),
"host_platform": sys.platform,
@ -7243,6 +7259,12 @@ class ReticulumMeshChat:
"announces_per_second": announces_per_second,
"announces_per_minute": announces_per_minute,
"announces_per_hour": announces_per_hour,
**cache_stats(),
"memory_cleanup": getattr(
getattr(self, "memory_pressure", None),
"last_stats",
{},
),
},
"is_reticulum_running": hasattr(self, "reticulum")
and self.reticulum is not None,

View file

@ -87,6 +87,7 @@ class Database:
self.debug_logs = DebugLogsDAO(self.provider)
self.access_attempts = AccessAttemptsDAO(self.provider)
self.crash_history = CrashHistoryDAO(self.provider)
self._sqlite_memory_relaxed = False
def initialize(self):
self._tune_sqlite_pragmas()
@ -104,9 +105,28 @@ class Database:
self.execute_sql("PRAGMA cache_size=-8000") # 8 MB
self.execute_sql("PRAGMA mmap_size=67108864") # 64 MB
self.execute_sql("PRAGMA busy_timeout=5000") # 5 s wait on lock contention
self._sqlite_memory_relaxed = False
except Exception as exc:
print(f"SQLite pragma setup failed: {exc}")
def apply_memory_pressure_pragmas(self, relax: bool) -> bool:
"""Move SQLite temp/cache work toward disk when host RAM is low."""
try:
if relax:
self.execute_sql("PRAGMA temp_store=FILE")
self.execute_sql("PRAGMA cache_size=-2000") # 2 MB
self.execute_sql("PRAGMA mmap_size=0")
self._sqlite_memory_relaxed = True
else:
self.execute_sql("PRAGMA temp_store=MEMORY")
self.execute_sql("PRAGMA cache_size=-8000")
self.execute_sql("PRAGMA mmap_size=67108864")
self._sqlite_memory_relaxed = False
return True
except Exception as exc:
print(f"SQLite memory-pressure pragma update failed: {exc}")
return False
def _get_pragma_value(self, pragma: str, default=None):
safe = _sanitize_pragma_read_name(pragma)
if safe is None:

View file

@ -0,0 +1,164 @@
# SPDX-License-Identifier: 0BSD
"""Periodic and pressure-triggered memory cleanup for MeshChatX."""
from __future__ import annotations
import logging
import time
from typing import Any
from meshchatx.src.backend import nomadnet_downloader, reticulum_pathfinding, rns_link_manager
_log = logging.getLogger("meshchatx.memory_pressure")
HELD_ANNOUNCES_DROP_THRESHOLD = 512
ANNOUNCE_CACHE_CLEAN_INTERVAL_S = 30 * 60
class MemoryPressureManager:
"""Coordinates link sweeps, path pruning, and SQLite disk offload."""
def __init__(self, app: Any = None):
self.app = app
self._last_announce_cache_clean = 0.0
self._sqlite_relaxed = False
self.last_stats: dict[str, Any] = {
"nomad_links_swept": 0,
"rns_links_swept": 0,
"paths_pruned_expired": 0,
"paths_pruned_cap": 0,
"announce_cache_cleaned": False,
"held_queues_dropped": False,
"sqlite_relaxed": False,
}
def run_periodic_cleanup(self) -> dict[str, Any]:
"""Called from announce_loop about every 5 minutes."""
reticulum = getattr(self.app, "reticulum", None) if self.app else None
before_nomad = nomadnet_downloader.cached_link_count()
nomadnet_downloader.sweep_stale_links()
after_nomad = nomadnet_downloader.cached_link_count()
before_rns = rns_link_manager.cached_link_count()
rns_link_manager.sweep_stale_links()
after_rns = rns_link_manager.cached_link_count()
expired_dropped = reticulum_pathfinding.prune_expired_path_table_entries(
reticulum,
)
cap_dropped = reticulum_pathfinding.prune_path_table_to_soft_cap(reticulum)
announce_cleaned = False
now = time.time()
if now - self._last_announce_cache_clean >= ANNOUNCE_CACHE_CLEAN_INTERVAL_S:
announce_cleaned = reticulum_pathfinding.clean_rns_announce_cache()
if announce_cleaned:
self._last_announce_cache_clean = now
held_dropped = self._maybe_drop_held_announce_queues()
self.last_stats = {
"nomad_links_swept": max(0, before_nomad - after_nomad),
"rns_links_swept": max(0, before_rns - after_rns),
"nomad_cached_links": after_nomad,
"rns_cached_links": after_rns,
"paths_pruned_expired": expired_dropped,
"paths_pruned_cap": cap_dropped,
"path_table_size": reticulum_pathfinding.path_table_size(),
"announce_cache_cleaned": announce_cleaned,
"held_queues_dropped": held_dropped,
"sqlite_relaxed": self._sqlite_relaxed,
}
if (
expired_dropped
or cap_dropped
or announce_cleaned
or held_dropped
or before_nomad != after_nomad
or before_rns != after_rns
):
_log.info("Memory cleanup: %s", self.last_stats)
return self.last_stats
def on_memory_low(self, available_mb: float) -> dict[str, Any]:
"""Reactive cleanup when HealthMonitor reports low available RAM."""
stats = self.run_periodic_cleanup()
db = getattr(self.app, "database", None) if self.app else None
if db is not None and hasattr(db, "apply_memory_pressure_pragmas"):
try:
db.apply_memory_pressure_pragmas(True)
self._sqlite_relaxed = True
stats["sqlite_relaxed"] = True
except Exception as exc:
_log.debug("SQLite pressure pragmas failed: %s", exc)
_log.warning(
"Memory pressure cleanup (available=%.0f MB): %s",
available_mb,
stats,
)
return stats
def on_memory_recovered(self) -> None:
if not self._sqlite_relaxed:
return
db = getattr(self.app, "database", None) if self.app else None
if db is not None and hasattr(db, "apply_memory_pressure_pragmas"):
try:
db.apply_memory_pressure_pragmas(False)
self._sqlite_relaxed = False
self.last_stats["sqlite_relaxed"] = False
except Exception as exc:
_log.debug("SQLite restore pragmas failed: %s", exc)
def _maybe_drop_held_announce_queues(self) -> bool:
if self.app is None:
return False
handler = getattr(self.app, "rnpath_handler", None)
if handler is None or not hasattr(handler, "drop_announce_queues"):
return False
held = self._count_held_announces()
if held < HELD_ANNOUNCES_DROP_THRESHOLD:
return False
try:
handler.drop_announce_queues()
_log.info("Dropped announce queues (held_announces=%s)", held)
return True
except Exception as exc:
_log.debug("drop_announce_queues failed: %s", exc)
return False
def _count_held_announces(self) -> int:
total = 0
try:
held = getattr(
__import__("RNS").Transport,
"held_announces",
None,
)
if isinstance(held, dict):
total += len(held)
except Exception:
pass
try:
import RNS
interfaces = getattr(RNS.Transport, "interfaces", None) or []
for iface in interfaces:
iface_held = getattr(iface, "held_announces", None)
if isinstance(iface_held, dict):
total += len(iface_held)
elif isinstance(iface_held, (list, set, tuple)):
total += len(iface_held)
except Exception:
pass
return total
def cache_stats() -> dict[str, int]:
return {
"nomad_cached_links": nomadnet_downloader.cached_link_count(),
"rns_cached_links": rns_link_manager.cached_link_count(),
"path_table_size": reticulum_pathfinding.path_table_size(),
}

View file

@ -15,12 +15,22 @@ from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
# Global cache for Nomad Network links (reuse instead of reconnecting per request).
# Protected by _nomadnet_links_lock for callers that may touch Reticulum from multiple threads.
nomadnet_cached_links: dict[bytes, object] = {}
_nomadnet_link_last_used: dict[bytes, float] = {}
_nomadnet_links_lock = threading.Lock()
# Cap active Nomad links retained in process memory.
MAX_CACHED_LINKS = 32
LINK_IDLE_TTL_S = 30 * 60
# Wait granularity while polling for path / link (seconds). Smaller = faster reaction, slightly more wakeups.
_POLL_INTERVAL_S = 0.02
def cached_link_count() -> int:
with _nomadnet_links_lock:
return len(nomadnet_cached_links)
def get_cached_active_link(destination_hash: bytes):
"""Return a cached link if present and ACTIVE; drop stale entries."""
with _nomadnet_links_lock:
@ -28,16 +38,30 @@ def get_cached_active_link(destination_hash: bytes):
if link is None:
return None
if link.status is RNS.Link.ACTIVE:
_nomadnet_link_last_used[destination_hash] = time.time()
return link
try:
del nomadnet_cached_links[destination_hash]
except KeyError:
pass
_nomadnet_link_last_used.pop(destination_hash, None)
return None
def _teardown_links(links) -> None:
for link in links:
if link is None:
continue
try:
link.teardown()
except Exception:
pass
def sweep_stale_links():
"""Evict all non-ACTIVE links from the global cache."""
"""Evict non-ACTIVE, idle, and over-cap links from the global cache."""
now = time.time()
to_teardown = []
with _nomadnet_links_lock:
stale = [
k
@ -45,14 +69,52 @@ def sweep_stale_links():
if v.status is not RNS.Link.ACTIVE
]
for k in stale:
del nomadnet_cached_links[k]
to_teardown.append(nomadnet_cached_links.pop(k, None))
_nomadnet_link_last_used.pop(k, None)
idle = [
k
for k, last in _nomadnet_link_last_used.items()
if k in nomadnet_cached_links and now - last > LINK_IDLE_TTL_S
]
for k in idle:
to_teardown.append(nomadnet_cached_links.pop(k, None))
_nomadnet_link_last_used.pop(k, None)
while len(nomadnet_cached_links) > MAX_CACHED_LINKS:
oldest_key = min(
nomadnet_cached_links.keys(),
key=lambda k: _nomadnet_link_last_used.get(k, 0.0),
)
to_teardown.append(nomadnet_cached_links.pop(oldest_key, None))
_nomadnet_link_last_used.pop(oldest_key, None)
orphans = [k for k in _nomadnet_link_last_used if k not in nomadnet_cached_links]
for k in orphans:
del _nomadnet_link_last_used[k]
_teardown_links(to_teardown)
def _cache_link_if_active(destination_hash: bytes, link) -> None:
if link is None or link.status is not RNS.Link.ACTIVE:
return
to_teardown = []
with _nomadnet_links_lock:
nomadnet_cached_links[destination_hash] = link
_nomadnet_link_last_used[destination_hash] = time.time()
while len(nomadnet_cached_links) > MAX_CACHED_LINKS:
candidates = [
k for k in nomadnet_cached_links if k != destination_hash
]
if not candidates:
break
oldest_key = min(
candidates,
key=lambda k: _nomadnet_link_last_used.get(k, 0.0),
)
to_teardown.append(nomadnet_cached_links.pop(oldest_key, None))
_nomadnet_link_last_used.pop(oldest_key, None)
_teardown_links(to_teardown)
def _uncache_link_if_matches(destination_hash: bytes, link) -> None:
@ -64,6 +126,7 @@ def _uncache_link_if_matches(destination_hash: bytes, link) -> None:
del nomadnet_cached_links[destination_hash]
except KeyError:
pass
_nomadnet_link_last_used.pop(destination_hash, None)
class NomadnetDownloader:

View file

@ -29,6 +29,7 @@ class HealthMonitor:
ENTROPY_WARN_THRESHOLD = 1.5 # out of max ~2.32 for 5 log levels
ERROR_RATE_WARN = 0.3
MEMORY_WARN_MB = 100 # warn when available < 100 MB
MEMORY_RECOVER_MB = 400 # restore SQLite RAM pragmas above this
CONSECUTIVE_NEEDED = 2 # consecutive bad readings before alert
def __init__(self, log_handler, app=None):
@ -41,6 +42,7 @@ class HealthMonitor:
self._entropy_history = collections.deque(maxlen=self.ENTROPY_WINDOW)
self._error_rate_history = collections.deque(maxlen=self.ENTROPY_WINDOW)
self._mem_available_history = collections.deque(maxlen=self.ENTROPY_WINDOW)
self._memory_pressure_active = False
def start(self):
if self._running:
@ -123,6 +125,15 @@ class HealthMonitor:
"value": round(available_mb, 1),
},
)
self._trigger_memory_pressure(available_mb)
elif (
self._memory_pressure_active
and self._consecutive_above(
self._mem_available_history,
self.MEMORY_RECOVER_MB,
)
):
self._recover_memory_pressure()
for w in warnings:
_log.warning("Health warning: %s", w["message"])
@ -153,6 +164,26 @@ class HealthMonitor:
vals = list(deq)
return vals[-1] < threshold and vals[-2] < threshold
def _trigger_memory_pressure(self, available_mb: float) -> None:
self._memory_pressure_active = True
manager = getattr(self.app, "memory_pressure", None) if self.app else None
if manager is None:
return
try:
manager.on_memory_low(available_mb)
except Exception as exc:
_log.debug("Memory pressure cleanup failed: %s", exc)
def _recover_memory_pressure(self) -> None:
self._memory_pressure_active = False
manager = getattr(self.app, "memory_pressure", None) if self.app else None
if manager is None:
return
try:
manager.on_memory_recovered()
except Exception as exc:
_log.debug("Memory pressure recovery failed: %s", exc)
def _broadcast(self, warning_data):
if not self.app:
return

View file

@ -29,6 +29,11 @@ def format_outbound_path_finding_measure(outcome: OutboundPathOutcome) -> str:
IDX_PT_TIMESTAMP = 0
IDX_PT_RVCD_IF = 5
# Soft cap for in-memory RNS path table entries (~270 MB at RNS estimate).
DEFAULT_PATH_TABLE_SOFT_CAP = 15_000
DEFAULT_PATH_PRUNE_BATCH = 500
DEFAULT_PATH_PRUNE_TRIGGER = 10_000
def _path_table_entry_is_expired_by_reticulum_rules(entry) -> bool:
ts = entry[IDX_PT_TIMESTAMP]
@ -54,6 +59,126 @@ def transport_path_table_entry_is_expired(destination_hash: bytes) -> bool:
return _path_table_entry_is_expired_by_reticulum_rules(entry)
def path_table_size() -> int:
try:
with RNS.Transport.path_table_lock:
return len(RNS.Transport.path_table)
except Exception:
return 0
def prune_expired_path_table_entries(
reticulum: Optional["ReticulumLike"] = None,
*,
max_to_drop: int = DEFAULT_PATH_PRUNE_BATCH,
trigger_size: int = DEFAULT_PATH_PRUNE_TRIGGER,
) -> int:
"""Drop expired path-table entries when the table is large.
Returns the number of paths dropped.
"""
if max_to_drop <= 0:
return 0
size = path_table_size()
if size < trigger_size:
return 0
expired: list[tuple[float, bytes]] = []
try:
with RNS.Transport.path_table_lock:
for dest_hash, entry in RNS.Transport.path_table.items():
if _path_table_entry_is_expired_by_reticulum_rules(entry):
ts = entry[IDX_PT_TIMESTAMP] if entry else 0.0
expired.append((ts, dest_hash))
except Exception:
return 0
if not expired:
return 0
expired.sort(key=lambda item: item[0])
dropped = 0
for _ts, dest_hash in expired[:max_to_drop]:
try:
if reticulum is not None:
if reticulum.drop_path(dest_hash):
dropped += 1
else:
RNS.Transport.expire_path(dest_hash)
dropped += 1
except Exception:
continue
return dropped
def prune_path_table_to_soft_cap(
reticulum: Optional["ReticulumLike"] = None,
*,
soft_cap: int = DEFAULT_PATH_TABLE_SOFT_CAP,
max_to_drop: int = DEFAULT_PATH_PRUNE_BATCH,
) -> int:
"""If still over soft_cap after expiry prune, drop oldest unresponsive paths."""
if soft_cap <= 0 or max_to_drop <= 0:
return 0
size = path_table_size()
if size <= soft_cap:
return 0
candidates: list[tuple[float, bytes]] = []
try:
with RNS.Transport.path_table_lock:
for dest_hash, entry in RNS.Transport.path_table.items():
ts = entry[IDX_PT_TIMESTAMP] if entry else 0.0
unresponsive = False
try:
unresponsive = RNS.Transport.path_is_unresponsive(dest_hash)
except Exception:
unresponsive = False
# Prefer unresponsive, then oldest.
rank = (0.0 if unresponsive else 1.0e12) + float(ts or 0.0)
candidates.append((rank, dest_hash))
except Exception:
return 0
if not candidates:
return 0
over = size - soft_cap
budget = min(max_to_drop, over)
candidates.sort(key=lambda item: item[0])
dropped = 0
for _rank, dest_hash in candidates[:budget]:
try:
if reticulum is not None:
if reticulum.drop_path(dest_hash):
dropped += 1
else:
RNS.Transport.expire_path(dest_hash)
dropped += 1
except Exception:
continue
return dropped
def clean_rns_announce_cache() -> bool:
"""Ask RNS to trim on-disk announce cache files not referenced by paths."""
try:
owner = getattr(RNS.Transport, "owner", None)
if owner is not None and getattr(owner, "is_connected_to_shared_instance", False):
return False
clean = getattr(RNS.Transport, "clean_cache", None)
if callable(clean):
clean()
return True
clean_ann = getattr(RNS.Transport, "clean_announce_cache", None)
if callable(clean_ann):
clean_ann()
return True
except Exception:
return False
return False
def should_rediscover_path(destination_hash: bytes) -> bool:
if not RNS.Transport.has_path(destination_hash):
return True

View file

@ -16,6 +16,7 @@ from meshchatx.src.backend import reticulum_pathfinding
# Kept separate from nomadnet_downloader.nomadnet_cached_links — the two caches
# may merge in the future if NomadNet is ported onto this generic Links API.
rns_cached_links: dict[tuple[str, bytes], "RNS.Link"] = {}
_rns_link_last_used: dict[tuple[str, bytes], float] = {}
_rns_links_lock = threading.Lock()
# Per-cache-key count of consecutive RNS.Link request failures. Reset on
@ -29,10 +30,19 @@ _link_failure_counts: dict[tuple[str, bytes], int] = {}
# then go through the full open_link path and re-establish.
_LINK_RECYCLE_FAILURE_THRESHOLD = 2
# Cap active RNS links retained in process memory.
MAX_CACHED_LINKS = 64
LINK_IDLE_TTL_S = 30 * 60
# Wait granularity while polling for path / link (seconds).
_POLL_INTERVAL_S = 0.02
def cached_link_count() -> int:
with _rns_links_lock:
return len(rns_cached_links)
def get_cached_active_link(aspect: str, destination_hash: bytes):
"""Return a cached link if present and ACTIVE; drop stale entries."""
key = (aspect, destination_hash)
@ -41,37 +51,94 @@ def get_cached_active_link(aspect: str, destination_hash: bytes):
if link is None:
return None
if link.status is RNS.Link.ACTIVE:
_rns_link_last_used[key] = time.time()
return link
try:
del rns_cached_links[key]
except KeyError:
pass
_rns_link_last_used.pop(key, None)
_link_failure_counts.pop(key, None)
return None
def _teardown_links(links) -> None:
for link in links:
if link is None:
continue
try:
link.teardown()
except Exception:
pass
def _evict_over_cap_locked(preserve_key=None):
"""Evict oldest links until at or under MAX_CACHED_LINKS. Caller holds lock."""
to_teardown = []
while len(rns_cached_links) > MAX_CACHED_LINKS:
candidates = [
k for k in rns_cached_links if preserve_key is None or k != preserve_key
]
if not candidates:
break
oldest_key = min(
candidates,
key=lambda k: _rns_link_last_used.get(k, 0.0),
)
to_teardown.append(rns_cached_links.pop(oldest_key, None))
_rns_link_last_used.pop(oldest_key, None)
_link_failure_counts.pop(oldest_key, None)
return to_teardown
def sweep_stale_links():
now = time.time()
to_teardown = []
with _rns_links_lock:
stale = [
k for k, v in rns_cached_links.items() if v.status is not RNS.Link.ACTIVE
]
for k in stale:
del rns_cached_links[k]
to_teardown.append(rns_cached_links.pop(k, None))
_rns_link_last_used.pop(k, None)
_link_failure_counts.pop(k, None)
idle = [
k
for k, last in _rns_link_last_used.items()
if k in rns_cached_links and now - last > LINK_IDLE_TTL_S
]
for k in idle:
to_teardown.append(rns_cached_links.pop(k, None))
_rns_link_last_used.pop(k, None)
_link_failure_counts.pop(k, None)
to_teardown.extend(_evict_over_cap_locked())
# Drop counter entries whose link is no longer cached so the dict
# cannot grow unbounded across link churn.
orphans = [k for k in _link_failure_counts if k not in rns_cached_links]
for k in orphans:
del _link_failure_counts[k]
used_orphans = [k for k in _rns_link_last_used if k not in rns_cached_links]
for k in used_orphans:
del _rns_link_last_used[k]
_teardown_links(to_teardown)
def _cache_link_if_active(aspect: str, destination_hash: bytes, link) -> None:
if link is None or link.status is not RNS.Link.ACTIVE:
return
key = (aspect, destination_hash)
to_teardown = []
with _rns_links_lock:
rns_cached_links[key] = link
_rns_link_last_used[key] = time.time()
# A freshly cached link starts with a clean failure count, even if
# an older link at the same key died with a non-zero count.
_link_failure_counts.pop(key, None)
to_teardown.extend(_evict_over_cap_locked(preserve_key=key))
_teardown_links(to_teardown)
def _uncache_link_if_matches(aspect: str, destination_hash: bytes, link) -> None:
@ -84,6 +151,7 @@ def _uncache_link_if_matches(aspect: str, destination_hash: bytes, link) -> None
del rns_cached_links[key]
except KeyError:
pass
_rns_link_last_used.pop(key, None)
_link_failure_counts.pop(key, None)

View file

@ -1911,6 +1911,7 @@ export default {
audioAttachmentRecordingTimer: null,
androidNativeOpusAttachment: false,
lxmfMessageAudioAttachmentCache: {},
lxmfMessageAudioAttachmentOrder: [],
isDownloadingAudio: {},
expandedMessageInfo: null,
imageModalUrl: null,
@ -2418,6 +2419,7 @@ export default {
this.messageBubbleTranslation = {};
if (oldPeer) {
this.saveDraft(oldPeer.destination_hash);
this.clearAudioAttachmentCache();
}
this.teardownPeerHeaderResizeObserver();
this.disconnectOpenConversationScrollObserver();
@ -2547,8 +2549,56 @@ export default {
clearInterval(this.propagationStatusInterval);
}
this.disconnectOpenConversationScrollObserver();
this.clearAudioAttachmentCache();
},
methods: {
clearAudioAttachmentCache() {
const cache = this.lxmfMessageAudioAttachmentCache || {};
for (const url of Object.values(cache)) {
if (typeof url === "string" && url.startsWith("blob:")) {
try {
URL.revokeObjectURL(url);
} catch {
/* ignore */
}
}
}
this.lxmfMessageAudioAttachmentCache = {};
this.lxmfMessageAudioAttachmentOrder = [];
},
rememberAudioAttachment(hash, objectUrl) {
if (!hash || !objectUrl) {
return;
}
const maxEntries = 50;
const cache = this.lxmfMessageAudioAttachmentCache;
const order = this.lxmfMessageAudioAttachmentOrder;
if (cache[hash] && cache[hash] !== objectUrl) {
try {
URL.revokeObjectURL(cache[hash]);
} catch {
/* ignore */
}
}
cache[hash] = objectUrl;
const existing = order.indexOf(hash);
if (existing >= 0) {
order.splice(existing, 1);
}
order.push(hash);
while (order.length > maxEntries) {
const evictHash = order.shift();
const evictUrl = cache[evictHash];
delete cache[evictHash];
if (typeof evictUrl === "string" && evictUrl.startsWith("blob:")) {
try {
URL.revokeObjectURL(evictUrl);
} catch {
/* ignore */
}
}
}
},
isMeshChatXAndroid() {
return (
window.MeshChatXAndroid &&
@ -4281,7 +4331,7 @@ export default {
const objectUrl = await this.decodeLxmfAudioFieldToBlobUrl(audioField);
if (objectUrl) {
this.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash] = objectUrl;
this.rememberAudioAttachment(chatItem.lxmf_message.hash, objectUrl);
}
} catch (e) {
console.error("Failed to download or decode audio:", e);
@ -5142,8 +5192,7 @@ export default {
continue;
}
// update audio cache
this.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash] = objectUrl;
this.rememberAudioAttachment(chatItem.lxmf_message.hash, objectUrl);
}
},
async decodeLxmfAudioFieldToBlobUrl(audioField) {

View file

@ -53,6 +53,8 @@ import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
import {
ANNOUNCE_HASH_CHUNK_SIZE,
VIZ_ANNOUNCE_ASPECTS,
VIZ_ANNOUNCE_SOFT_CAP,
VIZ_PATH_TABLE_SOFT_CAP,
dedupeIconQueueEntries,
pathHashesWithinHopFilter,
pickAdaptiveFetchConcurrency,
@ -259,6 +261,15 @@ export default {
delete this.iconCache[key];
}
this.iconCache = {};
this.pathTable = [];
this.announces = {};
this.conversations = {};
try {
this.nodes.clear();
this.edges.clear();
} catch {
/* DataSet may already be destroyed with the network */
}
},
mounted() {
const isMobile = window.innerWidth < 640;
@ -324,10 +335,15 @@ export default {
});
this.pathTable.push(...firstResp.data.path_table);
const totalCount = firstResp.data.total_count;
const softCap = VIZ_PATH_TABLE_SOFT_CAP;
if (totalCount > this.pageSize) {
const concurrency = this.pathFetchConcurrency;
for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize * concurrency) {
if (this.abortController.signal.aborted) return;
if (this.pathTable.length >= softCap) {
this.loadingStatus = `Loading paths (capped at ${softCap} / ${totalCount})`;
break;
}
const chunk = [];
for (let i = 0; i < concurrency && offset + i * this.pageSize < totalCount; i++) {
chunk.push(offset + i * this.pageSize);
@ -340,7 +356,10 @@ export default {
);
const responses = await Promise.all(promises);
for (const r of responses) {
this.pathTable.push(...r.data.path_table);
const rows = r.data.path_table || [];
const room = softCap - this.pathTable.length;
if (room <= 0) break;
this.pathTable.push(...rows.slice(0, room));
}
this.loadingStatus = `Loading paths (${this.pathTable.length} / ${totalCount})`;
}
@ -402,6 +421,15 @@ export default {
}
}
}
const announceKeys = Object.keys(this.announces);
if (announceKeys.length > VIZ_ANNOUNCE_SOFT_CAP) {
const neededSet = new Set(needed);
const extras = announceKeys.filter((hash) => !neededSet.has(hash));
const overflow = announceKeys.length - VIZ_ANNOUNCE_SOFT_CAP;
for (const hash of extras.slice(0, overflow)) {
delete this.announces[hash];
}
}
},
async getConfig() {
try {

View file

@ -1,7 +1,12 @@
const DB_NAME = "meshchat_map_cache";
const DB_VERSION = 2;
const DB_VERSION = 3;
const STORE_NAME = "tiles";
const STATE_STORE = "map_state";
const META_STORE = "tile_meta";
const MAX_TILES = 5000;
const MAX_BYTES = 256 * 1024 * 1024;
const TILE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
class TileCache {
constructor() {
@ -36,6 +41,9 @@ class TileCache {
if (!db.objectStoreNames.contains(STATE_STORE)) {
db.createObjectStore(STATE_STORE);
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE);
}
};
request.onsuccess = (event) => {
@ -45,24 +53,59 @@ class TileCache {
});
}
_blobSize(data) {
if (!data) return 0;
if (typeof data.size === "number") return data.size;
if (data.byteLength != null) return data.byteLength;
if (typeof data === "string") return data.length;
return 0;
}
async getTile(key) {
await this.initPromise;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], "readonly");
const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const metaStore = transaction.objectStore(META_STORE);
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const value = request.result;
if (value != null) {
const metaReq = metaStore.get(key);
metaReq.onsuccess = () => {
const prev = metaReq.result || {};
metaStore.put(
{
...prev,
lastAccess: Date.now(),
size: prev.size ?? this._blobSize(value),
},
key
);
};
}
resolve(value);
};
request.onerror = () => reject(request.error);
});
}
async setTile(key, data) {
await this.initPromise;
await this._evictIfNeeded(this._blobSize(data));
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], "readwrite");
const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const metaStore = transaction.objectStore(META_STORE);
store.put(data, key);
metaStore.put(
{
lastAccess: Date.now(),
size: this._blobSize(data),
},
key
);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
@ -70,6 +113,76 @@ class TileCache {
});
}
async _readAllMeta() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([META_STORE], "readonly");
const store = transaction.objectStore(META_STORE);
const request = store.openCursor();
const rows = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(rows);
return;
}
rows.push({ key: cursor.key, ...(cursor.value || {}) });
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
async _evictIfNeeded(incomingBytes = 0) {
let meta;
try {
meta = await this._readAllMeta();
} catch {
return;
}
const now = Date.now();
const expired = meta.filter((m) => m.lastAccess && now - m.lastAccess > TILE_TTL_MS);
if (expired.length > 0) {
await this._deleteKeys(expired.map((m) => m.key));
meta = meta.filter((m) => !expired.some((e) => e.key === m.key));
}
let totalBytes = meta.reduce((sum, m) => sum + (m.size || 0), 0) + incomingBytes;
let count = meta.length + (incomingBytes > 0 ? 1 : 0);
if (count <= MAX_TILES && totalBytes <= MAX_BYTES) {
return;
}
const ordered = meta.slice().sort((a, b) => (a.lastAccess || 0) - (b.lastAccess || 0));
const toDelete = [];
for (const row of ordered) {
if (count <= MAX_TILES && totalBytes <= MAX_BYTES) {
break;
}
toDelete.push(row.key);
totalBytes -= row.size || 0;
count -= 1;
}
if (toDelete.length > 0) {
await this._deleteKeys(toDelete);
}
}
async _deleteKeys(keys) {
if (!keys.length) return;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const metaStore = transaction.objectStore(META_STORE);
for (const key of keys) {
store.delete(key);
metaStore.delete(key);
}
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
});
}
async getMapState(key) {
await this.initPromise;
return new Promise((resolve, reject) => {
@ -98,9 +211,14 @@ class TileCache {
async clear() {
await this.initPromise;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME, STATE_STORE], "readwrite");
transaction.objectStore(STORE_NAME).clear();
transaction.objectStore(STATE_STORE).clear();
const stores = [STORE_NAME, STATE_STORE];
if (this.db.objectStoreNames.contains(META_STORE)) {
stores.push(META_STORE);
}
const transaction = this.db.transaction(stores, "readwrite");
for (const name of stores) {
transaction.objectStore(name).clear();
}
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);

View file

@ -4,6 +4,12 @@ export const VIZ_ANNOUNCE_ASPECTS = ["lxmf.delivery", "nomadnetwork.node"];
export const ANNOUNCE_HASH_CHUNK_SIZE = 500;
/** Soft cap for client-side path table rows kept in the visualiser. */
export const VIZ_PATH_TABLE_SOFT_CAP = 20_000;
/** Soft cap for announce map entries keyed by destination hash. */
export const VIZ_ANNOUNCE_SOFT_CAP = 10_000;
/**
* @param {unknown[]} pathTable
* @param {number|null|undefined} hopMax

View file

@ -0,0 +1,69 @@
# SPDX-License-Identifier: 0BSD
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.memory_pressure import MemoryPressureManager
def test_run_periodic_cleanup_sweeps_and_reports_stats():
app = MagicMock()
app.reticulum = MagicMock()
app.rnpath_handler = MagicMock()
app.database = MagicMock()
manager = MemoryPressureManager(app=app)
with (
patch(
"meshchatx.src.backend.memory_pressure.nomadnet_downloader.cached_link_count",
side_effect=[2, 1],
),
patch(
"meshchatx.src.backend.memory_pressure.nomadnet_downloader.sweep_stale_links",
) as nomad_sweep,
patch(
"meshchatx.src.backend.memory_pressure.rns_link_manager.cached_link_count",
side_effect=[3, 2],
),
patch(
"meshchatx.src.backend.memory_pressure.rns_link_manager.sweep_stale_links",
) as rns_sweep,
patch(
"meshchatx.src.backend.memory_pressure.reticulum_pathfinding.prune_expired_path_table_entries",
return_value=4,
),
patch(
"meshchatx.src.backend.memory_pressure.reticulum_pathfinding.prune_path_table_to_soft_cap",
return_value=1,
),
patch(
"meshchatx.src.backend.memory_pressure.reticulum_pathfinding.clean_rns_announce_cache",
return_value=True,
),
patch(
"meshchatx.src.backend.memory_pressure.reticulum_pathfinding.path_table_size",
return_value=42,
),
patch.object(manager, "_count_held_announces", return_value=0),
):
stats = manager.run_periodic_cleanup()
nomad_sweep.assert_called_once()
rns_sweep.assert_called_once()
assert stats["nomad_links_swept"] == 1
assert stats["rns_links_swept"] == 1
assert stats["paths_pruned_expired"] == 4
assert stats["paths_pruned_cap"] == 1
assert stats["announce_cache_cleaned"] is True
assert stats["path_table_size"] == 42
def test_on_memory_low_relaxes_sqlite():
app = MagicMock()
app.database = MagicMock()
manager = MemoryPressureManager(app=app)
with patch.object(manager, "run_periodic_cleanup", return_value={"ok": True}):
stats = manager.on_memory_low(50.0)
app.database.apply_memory_pressure_pragmas.assert_called_once_with(True)
assert stats["sqlite_relaxed"] is True
manager.on_memory_recovered()
app.database.apply_memory_pressure_pragmas.assert_called_with(False)

View file

@ -19,9 +19,15 @@ from meshchatx.src.backend.nomadnet_downloader import (
def clear_nomadnet_link_cache():
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
from meshchatx.src.backend.nomadnet_downloader import _nomadnet_link_last_used
_nomadnet_link_last_used.clear()
yield
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
from meshchatx.src.backend.nomadnet_downloader import _nomadnet_link_last_used
_nomadnet_link_last_used.clear()
@pytest.fixture
@ -159,3 +165,25 @@ def test_file_downloader_passes_query_data_to_parent():
data="foo=bar",
)
assert fd.data == "foo=bar"
def test_nomad_link_cache_evicts_over_cap():
from meshchatx.src.backend import nomadnet_downloader as nd
original_max = nd.MAX_CACHED_LINKS
nd.MAX_CACHED_LINKS = 2
try:
links = []
for i in range(3):
link = MagicMock()
link.status = RNS.Link.ACTIVE
dest = bytes([i]) * 16
nd._cache_link_if_active(dest, link)
links.append((dest, link))
assert nd.cached_link_count() == 2
assert get_cached_active_link(links[0][0]) is None
links[0][1].teardown.assert_called()
assert get_cached_active_link(links[1][0]) is links[1][1]
assert get_cached_active_link(links[2][0]) is links[2][1]
finally:
nd.MAX_CACHED_LINKS = original_max

View file

@ -356,3 +356,55 @@ async def test_wait_for_path_times_out():
):
ok = await rp.wait_for_path(MagicMock(), DEST, 0.01, 0.01)
assert ok is False
def test_prune_expired_path_table_entries_respects_trigger():
cleanup = []
try:
old = time.time() - (RNS.Transport.DESTINATION_TIMEOUT + 100)
entry = [old, None, None, None, None, None]
_put_path_entry(DEST, entry, cleanup)
assert (
rp.prune_expired_path_table_entries(
None,
max_to_drop=10,
trigger_size=10_000,
)
== 0
)
reticulum = MagicMock()
reticulum.drop_path.return_value = True
dropped = rp.prune_expired_path_table_entries(
reticulum,
max_to_drop=10,
trigger_size=1,
)
assert dropped == 1
reticulum.drop_path.assert_called_once_with(DEST)
finally:
with RNS.Transport.path_table_lock:
for dest in cleanup:
RNS.Transport.path_table.pop(dest, None)
def test_prune_path_table_to_soft_cap_drops_oldest():
cleanup = []
try:
reticulum = MagicMock()
reticulum.drop_path.return_value = True
for i in range(3):
dest = bytes([i + 10]) * 16
entry = [float(i), None, None, None, None, None]
_put_path_entry(dest, entry, cleanup)
with patch.object(RNS.Transport, "path_is_unresponsive", return_value=False):
dropped = rp.prune_path_table_to_soft_cap(
reticulum,
soft_cap=1,
max_to_drop=5,
)
assert dropped >= 1
assert reticulum.drop_path.call_count >= 1
finally:
with RNS.Transport.path_table_lock:
for dest in cleanup:
RNS.Transport.path_table.pop(dest, None)

View file

@ -14,10 +14,12 @@ from meshchatx.src.backend import rns_link_manager as rlm
def clear_link_cache():
with rlm._rns_links_lock:
rlm.rns_cached_links.clear()
rlm._rns_link_last_used.clear()
rlm._link_failure_counts.clear()
yield
with rlm._rns_links_lock:
rlm.rns_cached_links.clear()
rlm._rns_link_last_used.clear()
rlm._link_failure_counts.clear()
@ -451,3 +453,23 @@ def test_parse_dest_aspect_helpers():
{"destination_hash": "zz", "aspect": "a"}
)
assert err == "invalid_destination_hash"
def test_rns_link_cache_evicts_over_cap():
original_max = rlm.MAX_CACHED_LINKS
rlm.MAX_CACHED_LINKS = 2
try:
kept = []
for i in range(3):
link = MagicMock()
link.status = rlm.RNS.Link.ACTIVE
dest = bytes([i + 1]) * 16
rlm._cache_link_if_active("app.aspect", dest, link)
kept.append((dest, link))
assert rlm.cached_link_count() == 2
assert rlm.get_cached_active_link("app.aspect", kept[0][0]) is None
kept[0][1].teardown.assert_called()
assert rlm.get_cached_active_link("app.aspect", kept[1][0]) is kept[1][1]
assert rlm.get_cached_active_link("app.aspect", kept[2][0]) is kept[2][1]
finally:
rlm.MAX_CACHED_LINKS = original_max

View file

@ -0,0 +1,14 @@
# SPDX-License-Identifier: 0BSD
from meshchatx.src.backend.database import Database
def test_apply_memory_pressure_pragmas_roundtrip(tmp_path):
db = Database(str(tmp_path / "pressure.db"))
db.initialize()
assert db.apply_memory_pressure_pragmas(True) is True
assert db._sqlite_memory_relaxed is True
assert db._get_pragma_value("temp_store") == 1 # FILE
assert db.apply_memory_pressure_pragmas(False) is True
assert db._sqlite_memory_relaxed is False
assert db._get_pragma_value("temp_store") == 2 # MEMORY

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
isHeadlessBackendArgs,
} from "../../electron/backendProcessOrphans.js";
describe("electron/backendProcessOrphans", () => {
it("detects headless backend command lines", () => {
expect(
isHeadlessBackendArgs(
"/tmp/.mount_x/resources/backend/ReticulumMeshChatX --headless --port 9337"
)
).toBe(true);
expect(
isHeadlessBackendArgs(
"/home/user/ReticulumMeshChatX --headless"
)
).toBe(true);
});
it("ignores non-backend and non-headless processes", () => {
expect(isHeadlessBackendArgs("reticulum-meshchatx --type=renderer")).toBe(false);
expect(isHeadlessBackendArgs("/tmp/ReticulumMeshChatX")).toBe(false);
expect(isHeadlessBackendArgs("")).toBe(false);
expect(isHeadlessBackendArgs(null)).toBe(false);
});
});