Skip to content

Commit 55f7711

Browse files
fix(admin): stop main-thread freeze when opening admin after a run
Root cause: the /api/events SSE 'init' handler replayed the server log buffer by calling appendLog() once per line: for (const logMsg of msg.logs) appendLog(logMsg); appendLog sets `body.scrollTop = body.scrollHeight` every call — a forced synchronous reflow. The buffer holds up to LOG_BUFFER_MAX = 5000 lines (hydrated from disk on boot + during a run), so opening admin after a run meant ~5000 back-to-back reflows on the main thread and the tab locked up. It also built 5000 DOM nodes only to trim back to 500. live.html already solved this (seedLogBacklog guards the per-line scroll behind _logSeeding and scrolls once); admin.html never got the parity. Fix: - Extract _buildLogEntry(msg, cat): builds one entry div with no DOM insertion/scroll/reflow. Shared by live appendLog + batch replay so categorization / escHtml escaping / linkification stay byte-identical. - appendLogBatch(msgs): keep only the last LOG_DOM_MAX (=500) lines — the cap appendLog enforces anyway, so the visible result is identical — build them into ONE DocumentFragment, insert + scroll exactly once. One reflow instead of thousands; 500 nodes built instead of 5000. - init handler calls appendLogBatch(msg.logs). - Hoist the magic 500 into a named LOG_DOM_MAX const (both trim sites). Test: new test/admin-log-batch.test.js (11) runs the REAL extracted functions against a fake DOM and asserts O(cap) work + a single reflow for a 5000-line buffer, last-500 retention/order, reconnect bounding, guards, and live-path trim. Wired into npm test. Adversarial review: SHIP (7/7 parity risks verified).
1 parent 8609aff commit 55f7711

3 files changed

Lines changed: 192 additions & 7 deletions

File tree

admin.html

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,9 +1440,11 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
14401440
if (msg.type === 'init' || msg.type === 'state') {
14411441
state = Object.assign(state, msg.state);
14421442
if (msg.results) { resultsArr = msg.results; renderTable(); }
1443-
// Restore logs from server buffer on init
1443+
// Restore logs from server buffer on init — batch-render in a single
1444+
// DocumentFragment so a full 5000-line buffer doesn't trigger one
1445+
// forced reflow per line (that froze the admin tab on open).
14441446
if (msg.type === 'init' && msg.logs && msg.logs.length > 0) {
1445-
for (const logMsg of msg.logs) appendLog(logMsg);
1447+
appendLogBatch(msg.logs);
14461448
}
14471449
// Reset retest button when done
14481450
if (state.status === 'done' || state.status === 'idle') {
@@ -2096,10 +2098,18 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
20962098
return (row.dataset.cat || 'node') === f;
20972099
}
20982100

2101+
// Max log rows kept in the DOM. The server buffer (LOG_BUFFER_MAX) is far
2102+
// larger; replaying all of it would build thousands of rows we immediately
2103+
// trim away — see appendLogBatch for why that mattered.
2104+
const LOG_DOM_MAX = 500;
2105+
2106+
// Build ONE log-entry div — no DOM insertion, no scroll, no reflow. Shared
2107+
// by the live single-line appendLog and the batched init replay so
2108+
// categorization / escaping / linkification stay byte-identical between the
2109+
// two paths.
20992110
// `cat` is the server-sent category for live SSE lines; replayed init buffer
21002111
// lines arrive as plain strings (no cat), so re-derive via logCategory().
2101-
function appendLog(msg, cat) {
2102-
const body = document.getElementById('logBody');
2112+
function _buildLogEntry(msg, cat) {
21032113
const div = document.createElement('div');
21042114
let cls = '';
21052115
if (/|warn|pause|insufficient/i.test(msg)) cls = 'log-warn';
@@ -2116,9 +2126,34 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21162126
const linkified = escaped.replace(/(https?:\/\/[^\s<]+)/g, (u) => `<a href="${u}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">${u}</a>`);
21172127
div.innerHTML = `<span class="log-time">${ts}</span>` + linkified;
21182128
if (!_logEntryMatches(div, _logFilter)) div.style.display = 'none';
2119-
body.appendChild(div);
2129+
return div;
2130+
}
2131+
2132+
// Live single-line append: one row, one reflow — fine at live cadence.
2133+
function appendLog(msg, cat) {
2134+
const body = document.getElementById('logBody');
2135+
body.appendChild(_buildLogEntry(msg, cat));
2136+
body.scrollTop = body.scrollHeight;
2137+
if (body.children.length > LOG_DOM_MAX) body.removeChild(body.firstChild);
2138+
}
2139+
2140+
// Batch-replay the server's init log buffer (up to LOG_BUFFER_MAX=5000 lines)
2141+
// WITHOUT freezing the main thread. The old init path called appendLog() per
2142+
// line, and each call set `scrollTop = scrollHeight` — a forced synchronous
2143+
// reflow — so a full buffer meant thousands of reflows on page load and the
2144+
// tab locked up. Here we (a) keep only the last LOG_DOM_MAX lines (the cap
2145+
// appendLog enforces anyway, so the visible result is identical), (b) build
2146+
// them into a single DocumentFragment, and (c) insert + scroll exactly once.
2147+
// One reflow instead of thousands.
2148+
function appendLogBatch(msgs) {
2149+
if (!Array.isArray(msgs) || msgs.length === 0) return;
2150+
const body = document.getElementById('logBody');
2151+
const tail = msgs.length > LOG_DOM_MAX ? msgs.slice(-LOG_DOM_MAX) : msgs;
2152+
const frag = document.createDocumentFragment();
2153+
for (const m of tail) frag.appendChild(_buildLogEntry(m));
2154+
body.appendChild(frag);
21202155
body.scrollTop = body.scrollHeight;
2121-
if (body.children.length > 500) body.removeChild(body.firstChild);
2156+
while (body.children.length > LOG_DOM_MAX) body.removeChild(body.firstChild);
21222157
}
21232158

21242159
// ─── Test Run Management ────────────────────────────────────────────────

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",

test/admin-log-batch.test.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* Admin log-replay — main-thread regression test
3+
*
4+
* Bug: opening admin.html replayed the server log buffer (up to
5+
* LOG_BUFFER_MAX = 5000 lines) by calling appendLog() once per line. Each
6+
* appendLog set `body.scrollTop = body.scrollHeight`, a forced synchronous
7+
* reflow — so a full buffer meant ~5000 reflows on page load and the tab froze.
8+
* It also built 5000 DOM nodes only to trim back to 500.
9+
*
10+
* Fix: appendLogBatch() builds the (last 500) lines into one DocumentFragment
11+
* and inserts + scrolls exactly once. This test runs the REAL functions from
12+
* admin.html against a fake DOM and asserts the work is O(cap), single-reflow.
13+
*
14+
* Run: node test/admin-log-batch.test.js
15+
*/
16+
17+
import { readFileSync } from 'node:fs';
18+
import { fileURLToPath } from 'node:url';
19+
import { dirname, join } from 'node:path';
20+
import vm from 'node:vm';
21+
22+
const out = { pass: 0, fail: 0, errors: [] };
23+
function ok(cond, name) {
24+
if (cond) { out.pass++; console.log(` PASS ${name}`); }
25+
else { out.fail++; out.errors.push(name); console.log(` FAIL ${name}`); }
26+
}
27+
28+
const __dirname = dirname(fileURLToPath(import.meta.url));
29+
const html = readFileSync(join(__dirname, '..', 'admin.html'), 'utf8');
30+
31+
// Extract a `function NAME(...) { ... }` block by balanced-brace scan. The
32+
// admin.html log helpers only contain balanced braces (template `${}` pairs),
33+
// so a simple depth counter is sufficient here.
34+
function extractFn(src, name) {
35+
const m = new RegExp(`function\\s+${name}\\s*\\(`).exec(src);
36+
if (!m) throw new Error(`function ${name} not found in admin.html`);
37+
let depth = 0, started = false, j = m.index;
38+
for (; j < src.length; j++) {
39+
const c = src[j];
40+
if (c === '{') { depth++; started = true; }
41+
else if (c === '}') { depth--; if (started && depth === 0) { j++; break; } }
42+
}
43+
return src.slice(m.index, j);
44+
}
45+
46+
const FNS = ['escHtml', 'logCategory', '_logEntryMatches', '_buildLogEntry', 'appendLog', 'appendLogBatch'];
47+
const extracted = FNS.map(n => extractFn(html, n)).join('\n\n');
48+
49+
// ─── Fake DOM ──────────────────────────────────────────────────────────────
50+
let createElementCount = 0;
51+
let createFragCount = 0;
52+
53+
function makeNode(tag) {
54+
const node = {
55+
tagName: tag,
56+
dataset: {},
57+
style: {},
58+
_children: [],
59+
_scrollWrites: 0,
60+
className: '',
61+
innerHTML: '',
62+
scrollHeight: 1000,
63+
__isFrag: tag === '#fragment',
64+
get children() { return this._children; },
65+
get firstChild() { return this._children[0]; },
66+
appendChild(c) {
67+
if (c && c.__isFrag) { for (const cc of c._children) this._children.push(cc); c._children = []; }
68+
else this._children.push(c);
69+
return c;
70+
},
71+
insertBefore(c) { this._children.unshift(c); return c; },
72+
removeChild(c) { const i = this._children.indexOf(c); if (i >= 0) this._children.splice(i, 1); return c; },
73+
};
74+
// Count writes to scrollTop — each write is a forced reflow in the browser.
75+
let _scrollTop = 0;
76+
Object.defineProperty(node, 'scrollTop', {
77+
get() { return _scrollTop; },
78+
set(v) { _scrollTop = v; node._scrollWrites++; },
79+
});
80+
return node;
81+
}
82+
83+
const logBody = makeNode('div');
84+
const fakeDocument = {
85+
getElementById(id) { return id === 'logBody' ? logBody : null; },
86+
createElement(tag) { createElementCount++; return makeNode(tag); },
87+
createDocumentFragment() { createFragCount++; return makeNode('#fragment'); },
88+
};
89+
90+
const sandbox = {
91+
document: fakeDocument,
92+
_logFilter: 'all',
93+
LOG_DOM_MAX: 500,
94+
Date,
95+
console,
96+
};
97+
vm.createContext(sandbox);
98+
vm.runInContext(extracted, sandbox);
99+
100+
const LOG_DOM_MAX = 500;
101+
102+
console.log('\nAdmin log-replay — main-thread regression\n');
103+
104+
// ─── 1. Full 5000-line buffer: O(cap) work, single reflow ────────────────────
105+
console.log('[1] appendLogBatch(5000 lines) is bounded + single-reflow');
106+
const big = Array.from({ length: 5000 }, (_, i) => `line ${i} — node sentnode1${i}`);
107+
createElementCount = 0; createFragCount = 0; logBody._scrollWrites = 0;
108+
vm.runInContext('appendLogBatch(globalThis.__big)', Object.assign(sandbox, { __big: big }));
109+
110+
ok(logBody.children.length === LOG_DOM_MAX,
111+
`DOM capped at ${LOG_DOM_MAX} rows (got ${logBody.children.length})`);
112+
ok(logBody._scrollWrites === 1,
113+
`exactly ONE reflow for the whole buffer (got ${logBody._scrollWrites})`);
114+
ok(createElementCount === LOG_DOM_MAX,
115+
`builds only ${LOG_DOM_MAX} nodes, not 5000 (got ${createElementCount})`);
116+
ok(createFragCount === 1, `uses one DocumentFragment (got ${createFragCount})`);
117+
// Only the LAST 500 lines are kept (matches the cap the live path enforces).
118+
ok(logBody.children[logBody.children.length - 1].innerHTML.includes('line 4999'),
119+
'keeps the newest line (4999) at the tail');
120+
ok(logBody.children[0].innerHTML.includes('line 4500'),
121+
'oldest kept line is 4500 (last 500 of 5000)');
122+
123+
// ─── 2. Small buffer: appends all, still single reflow ───────────────────────
124+
console.log('[2] appendLogBatch(small buffer) appends all, single reflow');
125+
logBody._children = []; createElementCount = 0; logBody._scrollWrites = 0;
126+
const small = ['alpha', 'bravo', 'charlie'];
127+
vm.runInContext('appendLogBatch(globalThis.__small)', Object.assign(sandbox, { __small: small }));
128+
ok(logBody.children.length === 3, `appended all 3 (got ${logBody.children.length})`);
129+
ok(logBody._scrollWrites === 1, `single reflow (got ${logBody._scrollWrites})`);
130+
131+
// ─── 3. Guards ───────────────────────────────────────────────────────────────
132+
console.log('[3] guards: empty / non-array are no-ops');
133+
logBody._children = []; logBody._scrollWrites = 0;
134+
vm.runInContext('appendLogBatch([]); appendLogBatch(null); appendLogBatch(undefined)', sandbox);
135+
ok(logBody.children.length === 0, 'no rows added for empty/null/undefined');
136+
ok(logBody._scrollWrites === 0, 'no reflow for empty/null/undefined');
137+
138+
// ─── 4. Live single-line appendLog still trims to cap ────────────────────────
139+
console.log('[4] appendLog single-line path stays bounded');
140+
logBody._children = [];
141+
for (let i = 0; i < 600; i++) {
142+
vm.runInContext(`appendLog(globalThis.__m)`, Object.assign(sandbox, { __m: `live ${i}` }));
143+
}
144+
ok(logBody.children.length === LOG_DOM_MAX,
145+
`single-line appends trim to ${LOG_DOM_MAX} (got ${logBody.children.length})`);
146+
147+
console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`);
148+
if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`);
149+
console.log('='.repeat(60));
150+
process.exit(out.fail ? 1 : 0);

0 commit comments

Comments
 (0)