mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 04:38:20 +00:00
perf: bound memory growth and reduce render CPU on packets page (#421)
## Problem On a long-running session the packets page consumed 8 GB of browser memory and 20%+ CPU on an 8-core machine. Root causes: 1. **Unbounded `packets` array growth via WebSocket** — `packets.unshift()` was called for every new unique hash, but nothing ever trimmed the array. After hours of live traffic the array grew well past the initial 50 k load limit. 2. **Unbounded `pauseBuffer`** — all WS messages queued while paused, no cap. 3. **Unbounded `_children` growth** — expanded groups received a `unshift(p)` on every matching WS message with no size limit. 4. **O(n) `observers.find()` inside the O(n) render loop** — with 50 k rows, each render triggered up to 50 k linear scans through the observers list. 5. **Full DOM rebuild on every WS message** — `renderTableRows()` was called synchronously on every WebSocket batch, reconstructing the entire table on each incoming packet. ## Changes - `packets[]` is now trimmed to `PACKET_LIMIT` after each WS batch; evicted entries are also removed from `hashIndex` to prevent stale references. - `pauseBuffer` capped at 2 000 entries (oldest dropped). - `_children` capped at 200 entries on WS prepend. - `renderTableRows()` on the WS path is debounced to 200 ms, batching rapid updates into a single redraw. - `observersById = new Map()` pre-built from the observers array; all `observers.find()` calls in the render loop and WS filter replaced with O(1) `Map.get()`. ## Test plan - [x] Load the packets page and leave it running for several minutes with live WebSocket traffic — memory in DevTools should remain stable rather than growing continuously - [x] Pause live updates, wait for several messages, then resume — buffer replays correctly and display updates - [x] Expand a packet group and leave it open during live traffic — children update but don't grow past 200 - [x] Region filter still works correctly (relies on the observer Map lookup) - [x] Observer name / IATA badge renders correctly in grouped and flat mode 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
+15
-2
@@ -35,6 +35,11 @@
|
||||
let hopNameCache = {};
|
||||
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
|
||||
let filtersBuilt = false;
|
||||
let _renderTimer = null;
|
||||
function scheduleRender() {
|
||||
clearTimeout(_renderTimer);
|
||||
_renderTimer = setTimeout(() => renderTableRows(), 200);
|
||||
}
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
|
||||
|
||||
@@ -327,6 +332,7 @@
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (packetsPaused) {
|
||||
pauseBuffer.push(...msgs);
|
||||
if (pauseBuffer.length > 2000) pauseBuffer = pauseBuffer.slice(-2000);
|
||||
const btn = document.getElementById('pktPauseBtn');
|
||||
if (btn) btn.textContent = '▶ ' + pauseBuffer.length;
|
||||
return;
|
||||
@@ -383,6 +389,7 @@
|
||||
// Update expanded children if this group is expanded
|
||||
if (expandedHashes.has(h) && existing._children) {
|
||||
existing._children.unshift(p);
|
||||
if (existing._children.length > 200) existing._children.length = 200;
|
||||
sortGroupChildren(existing);
|
||||
}
|
||||
} else {
|
||||
@@ -403,11 +410,16 @@
|
||||
if (h) hashIndex.set(h, newGroup);
|
||||
}
|
||||
}
|
||||
// Re-sort by latest DESC
|
||||
// Re-sort by latest DESC, then evict oldest beyond the limit
|
||||
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
|
||||
if (packets.length > PACKET_LIMIT) {
|
||||
const evicted = packets.splice(PACKET_LIMIT);
|
||||
for (const p of evicted) { if (p.hash) hashIndex.delete(p.hash); }
|
||||
}
|
||||
} else {
|
||||
// Flat mode: prepend
|
||||
// Flat mode: prepend, then evict oldest beyond the limit
|
||||
packets = filtered.concat(packets);
|
||||
if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT;
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
// Debounce WS-triggered renders to avoid rapid full rebuilds
|
||||
@@ -418,6 +430,7 @@
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearTimeout(_renderTimer);
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
detachVScrollListener();
|
||||
|
||||
@@ -3033,6 +3033,69 @@ console.log('\n=== channels.js: formatHashHex (issue #465) ===');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===== packets.js: memory bounds =====
|
||||
{
|
||||
console.log('\nPackets page — memory bounds:');
|
||||
const src = fs.readFileSync('public/packets.js', 'utf8');
|
||||
|
||||
test('pauseBuffer is capped at 2000 entries', () => {
|
||||
assert.ok(src.includes('pauseBuffer.length > 2000'),
|
||||
'pauseBuffer cap check must be present');
|
||||
assert.ok(src.includes('pauseBuffer = pauseBuffer.slice(-2000)'),
|
||||
'pauseBuffer must be trimmed to last 2000 entries');
|
||||
});
|
||||
|
||||
test('packets array is trimmed to PACKET_LIMIT after WS update in grouped mode', () => {
|
||||
assert.ok(src.includes('packets.length > PACKET_LIMIT'),
|
||||
'grouped mode must check packets length against PACKET_LIMIT');
|
||||
assert.ok(src.includes('packets.splice(PACKET_LIMIT)'),
|
||||
'grouped mode must splice packets to PACKET_LIMIT');
|
||||
});
|
||||
|
||||
test('evicted packets are removed from hashIndex', () => {
|
||||
assert.ok(/const evicted = packets\.splice\(PACKET_LIMIT\)[\s\S]{0,200}hashIndex\.delete\(p\.hash\)/.test(src),
|
||||
'after splice, evicted entries must be deleted from hashIndex');
|
||||
});
|
||||
|
||||
test('packets array is trimmed to PACKET_LIMIT after WS update in flat mode', () => {
|
||||
assert.ok(/packets = filtered\.concat\(packets\)[\s\S]{0,100}packets\.length = PACKET_LIMIT/.test(src),
|
||||
'flat mode must truncate packets to PACKET_LIMIT after prepend');
|
||||
});
|
||||
|
||||
test('_children is capped at 200 on WebSocket prepend', () => {
|
||||
assert.ok(src.includes('existing._children.length > 200'),
|
||||
'_children cap check must be present');
|
||||
assert.ok(src.includes('existing._children.length = 200'),
|
||||
'_children must be truncated to 200');
|
||||
});
|
||||
|
||||
test('observerMap is built from observers array in loadObservers', () => {
|
||||
assert.ok(src.includes('observerMap = new Map(observers.map(o => [o.id, o]))'),
|
||||
'observerMap must be built as id→observer Map in loadObservers');
|
||||
});
|
||||
|
||||
test('observerMap is reset in destroy', () => {
|
||||
assert.ok(src.includes('observerMap = new Map()'),
|
||||
'destroy must reset observerMap to empty Map');
|
||||
});
|
||||
|
||||
test('WS handler debounces render via _wsRenderTimer', () => {
|
||||
const wsBlock = src.slice(src.indexOf('wsHandler = debouncedOnWS'), src.indexOf('function destroy()'));
|
||||
assert.ok(wsBlock.includes('_wsRenderTimer'),
|
||||
'WS handler must debounce renders via _wsRenderTimer');
|
||||
assert.ok(wsBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'WS handler must clear pending timer before scheduling new render');
|
||||
assert.ok(/setTimeout\(function \(\) \{ renderTableRows\(\); \}/.test(wsBlock),
|
||||
'WS handler must schedule renderTableRows via setTimeout');
|
||||
});
|
||||
|
||||
test('destroy clears _wsRenderTimer', () => {
|
||||
const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 500);
|
||||
assert.ok(destroyBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'destroy must clear _wsRenderTimer to prevent stale renders after navigation');
|
||||
});
|
||||
}
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
Reference in New Issue
Block a user