mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 01:54:42 +00:00
0b8b1e91a6
## Summary Replace all `setInterval`-based animations in `live.js` with `requestAnimationFrame` loops and add a concurrency cap to prevent unbounded animation accumulation under high packet throughput. Fixes #384 ## Problem Under high throughput (≥5 packets/sec), the live map accumulated unbounded `setInterval` timers: - `pulseNode()`: 26ms interval per pulse ring - `drawAnimatedLine()`: 33ms interval per hop line + 52ms nested interval for fade-out - Ghost hop pulse: 600ms interval per ghost marker At 5 pkts/sec × 3 hops = **15+ concurrent intervals**, climbing without limit. This caused UI jank, rising CPU usage, and potential memory leaks from leaked Leaflet markers. ## Changes ### `public/live.js` | Function | Before | After | |----------|--------|-------| | `pulseNode()` | `setInterval` (26ms) + `setTimeout` safety | `requestAnimationFrame` loop, self-terminates at 2s or opacity ≤ 0 | | `drawAnimatedLine()` | `setInterval` (33ms) for line + nested `setInterval` (52ms) for fade | Two `requestAnimationFrame` loops (line advance + fade-out) | | Ghost hop pulse | `setInterval` (600ms) + `setTimeout` (3s) | `requestAnimationFrame` loop with 3s expiry | | `animatePath()` | No concurrency limit | Returns early when `activeAnims >= MAX_CONCURRENT_ANIMS` (20) | ### `public/index.html` - Cache buster version bump ### `test-live-anims.js` (new) - 7 tests verifying: - No `setInterval` in `pulseNode`, `drawAnimatedLine`, or `animatePath` - `MAX_CONCURRENT_ANIMS` defined and set to 20 - Concurrency check present in `animatePath` - No stale `setInterval` in animation hot paths ## Complexity & Scale - **Time complexity**: O(1) per animation frame (no change in per-frame work) - **Concurrency**: Hard-capped at 20 simultaneous animations (previously unbounded) - **At 5 pkts/sec, 3 hops**: Excess animations silently dropped instead of accumulating timers - **rAF benefit**: Browser coalesces all animations into single paint cycle; paused tabs stop animating automatically ## Test Results ``` === Animation interval elimination === ✅ pulseNode does not use setInterval ✅ drawAnimatedLine does not use setInterval ✅ ghost hop pulse does not use setInterval === Concurrency cap === ✅ MAX_CONCURRENT_ANIMS is defined ✅ MAX_CONCURRENT_ANIMS is set to 20 ✅ animatePath checks MAX_CONCURRENT_ANIMS before proceeding === Safety: no stale setInterval in animation functions === ✅ no setInterval remains in animation hot path 7 passed, 0 failed ``` All existing tests pass (packet-filter: 62, aging: 29, frontend-helpers: 241). ## Performance Proof (Rule 0 compliance) Benchmark: `node test-anim-perf.js` — simulates timer/animation accumulation under realistic throughput. ### Timer count: old (setInterval) vs new (rAF + cap) | Scenario | Old model (peak concurrent timers) | New model (peak concurrent animations) | |----------|-----------------------------------:|---------------------------------------:| | 5 pkt/s × 3 hops, 30s sustained | **123** | **20** | | 5 pkt/s × 3 hops, 5min sustained | **123** | **20** | | 20 pkt/s × 3 hops, 10s burst | **246** | **20** | **Before:** Each hop spawns 3 `setInterval` timers (pulse 26ms, line 33ms, fade 52ms) that live 0.6–2s each. At 5 pkt/s × 3 hops = 15 timers/sec, peak concurrent timers reach **123** (limited only by timer lifetime, not by any cap). Under burst traffic (20 pkt/s), this climbs to **246+**. **After:** `MAX_CONCURRENT_ANIMS = 20` hard-caps active animations. Excess packets are silently dropped. rAF loops replace all `setInterval` calls, coalescing into single paint cycles. Peak concurrent animations: **always ≤ 20**, regardless of throughput or duration. --------- Co-authored-by: you <you@example.com> Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com>
79 lines
3.5 KiB
JavaScript
79 lines
3.5 KiB
JavaScript
/* Unit tests for live.js animation system — verifies rAF migration and concurrency cap */
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
const src = fs.readFileSync('public/live.js', 'utf8');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
|
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
|
}
|
|
|
|
console.log('\n=== Animation interval elimination ===');
|
|
|
|
test('pulseNode does not use setInterval', () => {
|
|
// Extract pulseNode function body
|
|
const pulseStart = src.indexOf('function pulseNode(');
|
|
const nextFn = src.indexOf('\n function ', pulseStart + 1);
|
|
const body = src.substring(pulseStart, nextFn);
|
|
assert.ok(!body.includes('setInterval'), 'pulseNode still uses setInterval');
|
|
assert.ok(body.includes('requestAnimationFrame'), 'pulseNode should use requestAnimationFrame');
|
|
});
|
|
|
|
test('drawAnimatedLine does not use setInterval', () => {
|
|
const drawStart = src.indexOf('function drawAnimatedLine(');
|
|
const nextFn = src.indexOf('\n function ', drawStart + 1);
|
|
const body = src.substring(drawStart, nextFn);
|
|
assert.ok(!body.includes('setInterval'), 'drawAnimatedLine still uses setInterval');
|
|
assert.ok(body.includes('requestAnimationFrame'), 'drawAnimatedLine should use requestAnimationFrame');
|
|
});
|
|
|
|
test('ghost hop pulse does not use setInterval', () => {
|
|
// Ghost pulse is inside animatePath
|
|
const animStart = src.indexOf('function animatePath(');
|
|
const animEnd = src.indexOf('\n function ', animStart + 1);
|
|
const body = src.substring(animStart, animEnd);
|
|
assert.ok(!body.includes('setInterval'), 'animatePath still uses setInterval');
|
|
});
|
|
|
|
console.log('\n=== Concurrency cap ===');
|
|
|
|
test('MAX_CONCURRENT_ANIMS is defined', () => {
|
|
assert.ok(src.includes('MAX_CONCURRENT_ANIMS'), 'MAX_CONCURRENT_ANIMS constant not found');
|
|
});
|
|
|
|
test('MAX_CONCURRENT_ANIMS is set to 20', () => {
|
|
const match = src.match(/MAX_CONCURRENT_ANIMS\s*=\s*(\d+)/);
|
|
assert.ok(match, 'Could not parse MAX_CONCURRENT_ANIMS value');
|
|
assert.strictEqual(parseInt(match[1]), 20);
|
|
});
|
|
|
|
test('animatePath checks MAX_CONCURRENT_ANIMS before proceeding', () => {
|
|
const animStart = src.indexOf('function animatePath(');
|
|
// Check that within the first 200 chars of the function, we check the cap
|
|
const snippet = src.substring(animStart, animStart + 300);
|
|
assert.ok(snippet.includes('activeAnims >= MAX_CONCURRENT_ANIMS'), 'animatePath should check activeAnims against cap');
|
|
});
|
|
|
|
console.log('\n=== Safety: no stale setInterval in animation functions ===');
|
|
|
|
test('no setInterval remains in animation hot path', () => {
|
|
// The only acceptable setIntervals are the UI ones (timeline, clock, prune, rate counter)
|
|
// Count total setInterval occurrences
|
|
const matches = src.match(/setInterval\(/g) || [];
|
|
// Count known OK ones: _timelineRefreshInterval, _lcdClockInterval, _pruneInterval, _rateCounterInterval
|
|
const okPatterns = ['_timelineRefreshInterval', '_lcdClockInterval', '_pruneInterval', '_rateCounterInterval'];
|
|
let okCount = 0;
|
|
for (const p of okPatterns) {
|
|
if (src.includes(p + ' = setInterval') || src.includes(p + '= setInterval')) okCount++;
|
|
}
|
|
// Allow some non-animation setIntervals (the 4 UI ones above)
|
|
assert.ok(matches.length <= okCount + 1,
|
|
`Found ${matches.length} setInterval calls, expected at most ${okCount + 1} (non-animation). Some animation setIntervals may remain.`);
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed\n`);
|
|
process.exit(failed > 0 ? 1 : 0);
|