screentinker/server/test/log-selfprotect.test.js
ScreenTinker 4bda49cf60 fix(#146) E: log/write self-protection — coalesced logs, batched telemetry, bounded maps
Don't let telemetry/logging cook the loop under a storm.

- lib/log-coalescer.js: dedup+count high-frequency lines, flush ONE summarized line per
  key per window ("[loop-lag] band=critical (x47 in 30s)"). Bounded buffer (auto-flush
  at MAX_KEYS). Applied to the loop-lag "still loaded" line (band CHANGES stay immediate),
  the per-request OTA check line, and "Device reconnected".
- loop-lag: event_loop_lag rows are BUFFERED and batch-inserted on a flush interval
  (was a synchronous INSERT per sample); the buffer is bounded (drop-oldest). Its
  retention prune now rides the Item-A chunkedDelete so this table can never repeat the
  status_log bloat-then-freeze. /api/status still reads in-memory current (real-time
  band unaffected).
- Bounded the previously un-evicted per-device Maps: content-ack limiter gets an idle
  sweep (started in server.js); status-log-writer.lastWritten is capped (drop-oldest;
  it only suppresses a redundant consecutive row, so eviction is safe).

Tests: N identical lines -> one counted line; single line verbatim; coalescer buffer
bounded under a distinct-key flood; content-ack Map swept of idle buckets.
loop-lag-integration updated for the batched-insert cadence. Suite 266/266.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:34:01 -05:00

58 lines
2.3 KiB
JavaScript

'use strict';
// #146 hardening (Item E) — log/write self-protection. The coalescer collapses N
// identical high-frequency lines into ONE summarized line, and its buffer is bounded.
// The content-ack limiter's per-device Map is now swept (no unbounded growth).
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-logself-' + crypto.randomBytes(4).toString('hex'));
const { test } = require('node:test');
const assert = require('node:assert/strict');
const coalescer = require('../lib/log-coalescer');
const contentAck = require('../lib/content-ack-limiter');
function capture(fn) {
const lines = [];
const rl = console.log, rw = console.warn;
console.log = (...a) => lines.push(a.join(' '));
console.warn = (...a) => lines.push(a.join(' '));
try { fn(); } finally { console.log = rl; console.warn = rw; }
return lines;
}
test('N identical lines in a window emit ONE summarized line with the count', () => {
coalescer.reset();
for (let i = 0; i < 47; i++) coalescer.record('loop-lag:critical', '[loop-lag] band=critical p99=1502ms');
const out = capture(() => coalescer.flush());
assert.equal(out.length, 1, 'coalesced to a single line');
assert.match(out[0], /x47/, 'shows the count');
assert.match(out[0], /band=critical/, 'keeps the sample text');
});
test('a single occurrence logs verbatim (no count suffix)', () => {
coalescer.reset();
coalescer.record('k', 'one-off line');
const out = capture(() => coalescer.flush());
assert.deepEqual(out, ['one-off line']);
});
test('buffer stays BOUNDED under a flood of distinct keys (auto-flush at cap)', () => {
coalescer.reset();
capture(() => { for (let i = 0; i < 5000; i++) coalescer.record('key-' + i, 'line ' + i); });
assert.ok(coalescer._size() <= 500, `buffer bounded (<=500), was ${coalescer._size()}`);
});
test('content-ack limiter Map is swept of idle buckets (no unbounded growth)', () => {
contentAck.reset();
const now = 0;
contentAck.check('dev-a', 'c1', 'ready', 'normal', now);
contentAck.check('dev-b', 'c1', 'ready', 'normal', now);
assert.ok(contentAck._size() >= 2);
const swept = contentAck.sweep(10 * 300000); // far past the idle window
assert.ok(swept >= 2, 'idle buckets evicted');
assert.equal(contentAck._size(), 0, 'map drained');
});