From c34744247a576701f95130f679aeecbedd30af9b Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 3 Apr 2026 16:54:53 -0700 Subject: [PATCH] fix: clean up nodeActivity in pruneStaleNodes to prevent memory leak (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `nodeActivity` (an object tracking per-node packet counts for heatmap intensity) grows without bound — entries are added on every packet flash but never removed, even when stale nodes are pruned. ## Changes - **Delete `nodeActivity[key]`** alongside `nodeMarkers[key]` and `nodeData[key]` when removing stale WS-only nodes in `pruneStaleNodes()` - **Prune orphaned entries** — after the main prune loop, sweep `nodeActivity` and delete any key that has no corresponding `nodeData` entry (catches edge cases where nodes were removed by other code paths) - Both run every 60s via the existing `pruneStaleNodes` interval timer ## Testing - Added 2 regression tests in `test-frontend-helpers.js` verifying stale node cleanup and orphan removal - All 435 frontend helper tests pass, plus packet-filter (62) and aging (29) Fixes #390 --------- Co-authored-by: you --- public/live.js | 6 +++++ test-frontend-helpers.js | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/public/live.js b/public/live.js index 7a716f0e..378c387f 100644 --- a/public/live.js +++ b/public/live.js @@ -1650,6 +1650,7 @@ } delete nodeMarkers[key]; delete nodeData[key]; + delete nodeActivity[key]; pruned = true; } } else if (marker && marker._staleDimmed) { @@ -1665,12 +1666,17 @@ if (_el2) _el2.textContent = Object.keys(nodeMarkers).length; if (window.HopResolver) HopResolver.init(Object.values(nodeData)); } + // Prune orphaned nodeActivity entries (nodes removed above or never tracked) + for (var aKey in nodeActivity) { + if (!(aKey in nodeData)) delete nodeActivity[aKey]; + } } // Expose for testing window._livePruneStaleNodes = pruneStaleNodes; window._liveNodeMarkers = function() { return nodeMarkers; }; window._liveNodeData = function() { return nodeData; }; + window._liveNodeActivity = function() { return nodeActivity; }; window._vcrFormatTime = vcrFormatTime; window._liveDbPacketToLive = dbPacketToLive; window._liveExpandToBufferEntries = expandToBufferEntries; diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index b36de08d..0edbc4b0 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -998,6 +998,56 @@ console.log('\n=== live.js: pruneStaleNodes ==='); assert.ok(markers['apiNode'], 'API stale node should NOT be removed'); assert.ok(data['apiNode'], 'API stale node data should NOT be removed'); }); + + test('pruneStaleNodes cleans up nodeActivity for removed nodes', () => { + const { ctx } = makeLiveSandbox(); + const prune = ctx.window._livePruneStaleNodes; + const markers = ctx.window._liveNodeMarkers(); + const data = ctx.window._liveNodeData(); + const activity = ctx.window._liveNodeActivity(); + + // WS-only stale node + markers['staleNode'] = { _glowMarker: null }; + data['staleNode'] = { public_key: 'staleNode', role: 'companion', _liveSeen: Date.now() - 48 * 3600000 }; + activity['staleNode'] = 5; + + // Active node + markers['activeNode'] = { setStyle: function() {}, _glowMarker: null }; + data['activeNode'] = { public_key: 'activeNode', role: 'companion', _liveSeen: Date.now() }; + activity['activeNode'] = 3; + + prune(); + + assert.ok(!markers['staleNode'], 'stale node marker removed'); + assert.ok(!data['staleNode'], 'stale node data removed'); + assert.ok(!activity['staleNode'], 'stale node activity removed'); + assert.ok(markers['activeNode'], 'active node marker preserved'); + assert.ok(data['activeNode'], 'active node data preserved'); + assert.strictEqual(activity['activeNode'], 3, 'active node activity preserved'); + }); + + test('pruneStaleNodes removes orphaned nodeActivity entries', () => { + const { ctx } = makeLiveSandbox(); + const prune = ctx.window._livePruneStaleNodes; + const markers = ctx.window._liveNodeMarkers(); + const data = ctx.window._liveNodeData(); + const activity = ctx.window._liveNodeActivity(); + + // Add an active node + markers['existingNode'] = { setStyle: function() {}, _glowMarker: null }; + data['existingNode'] = { public_key: 'existingNode', role: 'companion', _liveSeen: Date.now() }; + activity['existingNode'] = 2; + + // Add orphaned activity (no corresponding nodeData) + activity['ghostNode'] = 10; + + prune(); + + assert.ok(markers['existingNode'], 'existing node preserved'); + assert.ok(data['existingNode'], 'existing node data preserved'); + assert.strictEqual(activity['existingNode'], 2, 'existing node activity preserved'); + assert.ok(!activity['ghostNode'], 'orphaned activity entry removed'); + }); } // ===== live.js: vcrFormatTime respects UTC/local setting =====