fix(control-pane): bound snapshot loads and automatic polling

Preserve ordered manual/query recovery while giving snapshot headers and bodies one ten-second deadline. Keep action mutation behavior unchanged.

Source-PR: https://github.com/affaan-m/ECC/pull/3235
Source-Parent: aff9c64560
Review-Manifest-SHA256: 59efa4136448129df673d047e633e88351c98cc1fcdc812f475575bc6f6ce201
This commit is contained in:
affaan-m
2026-09-28 02:47:12 -04:00
parent aff9c64560
commit 621abca350
2 changed files with 406 additions and 41 deletions
+29 -5
View File
@@ -443,6 +443,9 @@ function renderControlPaneHtml() {
// the newest data any load brought, and the error box the outcome of the
// newest load that has finished, so a load that finishes late can neither
// replace newer data nor overrule a newer outcome.
const SNAPSHOT_DEADLINE_MS = 10000;
const SNAPSHOT_POLL_INTERVAL_MS = 15000;
let snapshotsInFlight = 0;
let loadsStarted = 0;
let newestFinished = 0;
let shownLoad = 0;
@@ -656,11 +659,28 @@ function renderControlPaneHtml() {
async function load(live = false) {
const id = ++loadsStarted;
const query = state.query;
snapshotsInFlight++;
let snapshotTimer = null;
try {
const url = new URL('/api/snapshot', window.location.href);
if (query) url.searchParams.set('query', query);
const response = await fetch(url);
const snapshot = await readJsonResponse(response);
const controller = new AbortController();
let rejectDeadline;
const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; });
snapshotTimer = setTimeout(() => {
// Reject first so the fixed timeout wins over an abort rejection.
rejectDeadline(new Error('Snapshot request timed out after 10 seconds.'));
controller.abort();
}, SNAPSHOT_DEADLINE_MS);
// Only fetch/body decoding races the deadline. Late abort-ignoring
// responses have no rendering or state side effects after losing.
const snapshot = await Promise.race([
(async () => {
const response = await fetch(url, { signal: controller.signal });
return readJsonResponse(response);
})(),
deadline
]);
// A snapshot that cannot be shown fails its load like one that could
// not be fetched, and older data may still take the board.
if (id > shownLoad) {
@@ -673,6 +693,9 @@ function renderControlPaneHtml() {
failure = { error, live };
showFailure();
return;
} finally {
if (snapshotTimer !== null) clearTimeout(snapshotTimer);
snapshotsInFlight--;
}
if (id < newestFinished) {
// A newer load failed first. This data is still the newest on the
@@ -756,11 +779,12 @@ function renderControlPaneHtml() {
.catch(error => showError('#app', error));
};
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
// Only automatic polls coalesce. Manual/query loads retain ordered overlap.
// Browser suspension may delay timers; this is not a wall-clock server SLA.
setInterval(() => {
if (document.hidden) return;
if (document.hidden || snapshotsInFlight > 0) return;
load(true).catch(error => showError('#app', error));
}, 15000);
}, SNAPSHOT_POLL_INTERVAL_MS);
load().catch(error => showError('#app', error));
</script>
+377 -36
View File
@@ -1,33 +1,38 @@
/**
* Tests for the browser script the local ECC2 control pane serves.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const vm = require('vm');
const { buildControlPaneSnapshot } = require('../../scripts/lib/control-pane/state');
const { renderControlPaneHtml } = require('../../scripts/lib/control-pane/ui');
const pages = new Set();
async function test(name, fn) {
try {
await fn();
console.log(` PASS ${name}`);
return true;
} catch (error) {
let failed = false;
let failure;
try { await fn(); } catch (error) { failed = true; failure = error; }
finally {
for (const page of pages) {
try { await page.dispose(); } catch (error) {
if (!failed) { failed = true; failure = error; }
}
}
pages.clear();
}
if (failed) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
console.log(` Error: ${failure?.message ?? String(failure)}`);
return false;
}
console.log(` PASS ${name}`);
return true;
}
function inlineScript(html) {
const start = html.indexOf('<script>') + '<script>'.length;
return html.slice(start, html.lastIndexOf('</script>'));
}
// The page's clock. The page shows times with toLocaleString, which follows
// the locale's calendar (a Thai locale counts Buddhist years), so a test
// compares against the same call on this instant.
@@ -64,7 +69,45 @@ function openPage(snapshot, { hold = false } = {}) {
}
return elements.get(selector);
};
const page = { online: true, hold, pending: [], requests: [], refresh: null, element, now: NOW };
const page = { online: true, hold, pending: [], requests: [], refresh: null, element, now: NOW,
timers: new Map(), timerCalls: [], clearCalls: [], tick: 0, ignoreAbort: false };
let timerId = 0;
class FakeAbortController {
constructor() {
const listeners = new Set();
this.signal = { aborted: false, listeners,
addEventListener: (_type, callback) => listeners.add(callback),
removeEventListener: (_type, callback) => listeners.delete(callback) };
this.aborts = 0;
}
abort() {
this.aborts++;
if (this.signal.aborted) return;
this.signal.aborted = true;
for (const listener of this.signal.listeners) listener();
}
}
page.fireTimer = id => {
const timer = page.timers.get(id);
assert.ok(timer, 'Expected a pending deadline');
page.timers.delete(id);
timer.callback();
};
page.advance = ms => {
page.tick += ms;
for (const [id, timer] of [...page.timers]) if (timer.at <= page.tick) page.fireTimer(id);
};
page.dispose = async () => {
await settle();
for (const request of page.requests) request.reply.fail(new Error('Fixture disposed'));
await settle();
const remaining = page.timers.size;
page.timers.clear();
assert.strictEqual(remaining, 0, 'Every load must remove its deadline after fixture settlement');
assert.ok(page.requests.every(request => request.reply.settled), 'All fake requests must settle');
assert.ok(page.requests.every(request => !request.options.signal || request.options.signal.listeners.size === 0), 'Fake abort listeners must be removed');
};
pages.add(page);
class Clock extends PageDate {
constructor(...args) {
super(...(args.length > 0 ? args : [page.now.getTime()]));
@@ -77,22 +120,65 @@ function openPage(snapshot, { hold = false } = {}) {
Intl,
Date: Clock,
console,
AbortController: FakeAbortController,
setTimeout: (callback, ms) => {
const id = ++timerId;
page.timerCalls.push({ id, ms });
page.timers.set(id, { callback, at: page.tick + ms });
return id;
},
clearTimeout: id => { page.clearCalls.push(id); page.timers.delete(id); },
fetch: (url, options = {}) =>
new Promise((resolve, reject) => {
page.requests = [...page.requests, { url: String(url), options }];
const reply = {
succeed: (data = snapshot) => resolve({ ok: true, status: 200, statusText: 'OK', json: async () => data }),
fail: () => reject(new TypeError('Failed to fetch'))
let bodyResolve;
let bodyReject;
let headers = false;
let queuedBody;
const reply = { settled: false, ignoreAbort: page.ignoreAbort };
const finish = () => {
reply.settled = true;
options.signal?.removeEventListener('abort', onAbort);
};
if (page.hold) page.pending = [...page.pending, reply];
const onAbort = () => {
if (!reply.ignoreAbort) reply.fail(new Error('Synthetic AbortError'));
};
reply.respond = response => {
if (reply.settled || headers) return;
headers = true;
resolve({ ...response, json: async () => {
try { return await response.json(); } finally { finish(); }
} });
};
reply.headers = () => reply.respond({ ok: true, status: 200, json: () => new Promise((accept, refuse) => {
bodyResolve = accept; bodyReject = refuse;
if (queuedBody) (queuedBody.error ? refuse : accept)(queuedBody.error || queuedBody.data);
}) });
reply.succeed = (data = snapshot) => {
if (reply.settled) return;
if (!headers) reply.respond({ ok: true, status: 200, statusText: 'OK', json: async () => data });
else if (bodyResolve) bodyResolve(data);
else queuedBody = { data };
};
reply.fail = (error = new TypeError('Failed to fetch')) => {
if (reply.settled) return;
if (!headers) { finish(); reject(error); }
else if (bodyReject) bodyReject(error);
else queuedBody = { error };
};
page.requests.push({ url: String(url), options, reply });
options.signal?.addEventListener('abort', onAbort);
if (page.hold) page.pending.push(reply);
else if (page.online) reply.succeed();
else reply.fail();
}),
setInterval: callback => {
setInterval: (callback, ms) => {
page.intervalMs = ms;
page.refresh = callback;
}
};
vm.runInNewContext(inlineScript(renderControlPaneHtml()), page.script);
vm.createContext(page.script);
vm.runInContext(inlineScript(renderControlPaneHtml()), page.script);
page.state = () => JSON.parse(vm.runInContext('JSON.stringify({ loadedAt: loadedAt && loadedAt.getTime(), shownLoad, loadsStarted, newestFinished, query: state.query, shownQuery: state.shownQuery, allowActions: state.allowActions, active: typeof snapshotsInFlight === "undefined" ? null : snapshotsInFlight })', page.script));
return page;
}
@@ -142,6 +228,8 @@ async function runTests() {
let failed = 0;
const snapshot = await isolatedSnapshot();
// The original ordering cases below explicitly dispatch load(true) where
// overlap is intentional. Automatic interval coalescing is tested separately.
if (
await test('a failed live refresh is reported, and cleared by the next one that succeeds', async () => {
@@ -173,9 +261,9 @@ async function runTests() {
await settle();
page.hold = true;
page.refresh();
page.script.load(true);
page.hold = false;
page.refresh();
page.script.load(true);
await settle();
page.pending[0].fail();
await settle();
@@ -191,9 +279,9 @@ async function runTests() {
await settle();
page.hold = true;
page.refresh();
page.refresh();
page.refresh();
page.script.load(true);
page.script.load(true);
page.script.load(true);
page.pending[1].succeed();
await settle();
page.pending[0].fail();
@@ -210,8 +298,8 @@ async function runTests() {
await settle();
page.hold = true;
page.refresh();
page.refresh();
page.script.load(true);
page.script.load(true);
page.pending[0].succeed();
await settle();
page.pending[1].fail();
@@ -230,8 +318,8 @@ async function runTests() {
await settle();
page.hold = true;
page.refresh();
page.refresh();
page.script.load(true);
page.script.load(true);
page.pending[1].fail();
await settle();
page.pending[0].succeed();
@@ -251,8 +339,8 @@ async function runTests() {
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
page.hold = true;
page.refresh();
page.refresh();
page.script.load(true);
page.script.load(true);
page.pending[1].succeed(answer('newer'));
await settle();
page.pending[0].succeed(answer('older'));
@@ -268,7 +356,7 @@ async function runTests() {
const page = openPage(snapshot, { hold: true });
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
page.refresh();
page.script.load(true);
page.pending[1].fail();
await settle();
page.pending[0].succeed(answer('first'));
@@ -289,7 +377,7 @@ async function runTests() {
page.hold = true;
page.element('#refresh').listeners.click();
page.refresh();
page.script.load(true);
page.pending[1].succeed();
await settle();
page.pending[0].fail();
@@ -306,7 +394,7 @@ async function runTests() {
await settle();
page.hold = true;
page.refresh();
page.script.load(true);
page.element('#refresh').listeners.click();
page.pending[1].fail();
await settle();
@@ -332,8 +420,8 @@ async function runTests() {
};
page.hold = true;
page.refresh();
page.refresh();
page.script.load(true);
page.script.load(true);
page.pending[1].succeed(unshowable);
await settle();
const box = page.element('#app');
@@ -397,7 +485,7 @@ async function runTests() {
await settle();
const before = displayedBoard(page);
page.now = new Date(NOW.getTime() + 60_000);
page.refresh();
page.script.load(true);
const invalid = {
...original,
dbPath: 'new-database', database: { exists: true },
@@ -454,6 +542,259 @@ async function runTests() {
) passed++;
else failed++;
const answer = (query, allowActions = false) => ({
...snapshot, knowledge: { ...snapshot.knowledge, query }, execution: { allowActions },
});
const search = (page, query) => {
page.element('#query').value = query;
page.element('#query-form').listeners.submit({ preventDefault() {} });
};
const deadlineTests = [
['actual intervals coalesce a stalled request, then timeout and recover', async () => {
const page = openPage(snapshot);
await settle();
const before = displayedBoard(page);
const accepted = page.state().loadedAt;
page.hold = true;
page.refresh();
const request = page.requests[1];
for (let i = 0; i < 4; i++) page.refresh();
assert.strictEqual(page.requests.length, 2, 'Only one automatic load may be active');
assert.strictEqual(page.state().active, 1);
assert.strictEqual(page.intervalMs, 15000);
assert.strictEqual(page.timerCalls.at(-1).ms, 10000);
page.advance(9999);
await settle();
assert.strictEqual(page.element('#app').hidden, true);
assert.strictEqual(request.options.signal.aborted, false);
page.advance(1);
await settle();
assert.strictEqual(request.options.signal.aborted, true);
assert.strictEqual(page.state().active, 0);
assert.strictEqual(page.timers.size, 0);
assert.deepStrictEqual(displayedBoard(page), before);
assert.strictEqual(page.state().loadedAt, accepted);
assert.match(page.element('#app').textContent, /Live refresh failed[\s\S]*Snapshot request timed out after 10 seconds/);
assert.ok(page.element('#app').textContent.includes(NOW.toLocaleString()));
page.hold = false;
page.now = new Date(NOW.getTime() + 60_000);
page.refresh();
await settle();
assert.strictEqual(page.requests.length, 3);
assert.strictEqual(page.element('#app').hidden, true);
assert.strictEqual(page.state().loadedAt, page.now.getTime());
assert.strictEqual(page.timers.size, 0);
}],
['one total deadline includes headers and a stalled JSON body', async () => {
const page = openPage(snapshot);
await settle();
const before = displayedBoard(page);
page.hold = true;
page.refresh();
page.advance(4000);
page.pending[0].headers();
await settle();
page.advance(5999);
await settle();
assert.strictEqual(page.element('#app').hidden, true);
page.advance(1);
await settle();
assert.match(page.element('#app').textContent, /Snapshot request timed out after 10 seconds/);
assert.doesNotMatch(page.element('#app').textContent, /Synthetic AbortError/);
assert.deepStrictEqual(displayedBoard(page), before);
assert.strictEqual(page.requests[1].options.signal.aborted, true);
assert.strictEqual(page.state().active, 0);
}],
['expired header response cannot overwrite a newer accepted query or permission', async () => {
const page = openPage(snapshot);
await settle();
page.hold = true; page.ignoreAbort = true;
page.refresh();
const late = page.pending[0];
page.advance(10000);
await settle();
search(page, 'new query');
page.pending[1].succeed(answer('new query', true));
await settle();
const before = displayedBoard(page);
const state = page.state();
late.succeed(answer('expired query', false));
await settle();
assert.deepStrictEqual(displayedBoard(page), before);
assert.deepStrictEqual(page.state(), state);
assert.strictEqual(page.element('#app').hidden, true);
}],
['expired body response cannot overwrite a newer failure or release its active counter twice', async () => {
const page = openPage(snapshot);
await settle();
page.hold = true; page.ignoreAbort = true;
page.refresh();
const late = page.pending[0];
late.headers();
await settle();
page.advance(10000);
await settle();
page.refresh();
page.pending[1].fail(new Error('Newer failure'));
await settle();
const failure = page.element('#app').textContent;
const before = displayedBoard(page);
page.refresh(); // Another load remains active while the expired loser finishes.
late.succeed(answer('expired query', true));
await settle();
assert.deepStrictEqual(displayedBoard(page), before);
assert.strictEqual(page.element('#app').textContent, failure);
assert.strictEqual(page.state().active, 1);
page.refresh();
assert.strictEqual(page.requests.length, 4, 'Late completion must not reopen the automatic dispatch gate');
page.pending[2].succeed();
await settle();
assert.strictEqual(page.state().active, 0);
}],
['expired late fetch rejection stays handled and preserves the newer outcome', async () => {
const page = openPage(snapshot);
await settle();
page.hold = true; page.ignoreAbort = true;
page.refresh();
const late = page.pending[0];
page.advance(10000);
await settle();
page.refresh();
page.pending[1].succeed();
await settle();
const state = page.state();
late.fail(new Error('Late ignored abort rejection'));
await settle();
assert.deepStrictEqual(page.state(), state);
assert.strictEqual(page.element('#app').hidden, true);
}],
['initial timeout is visible without an invented last-good timestamp', async () => {
const page = openPage(snapshot, { hold: true });
page.refresh();
assert.strictEqual(page.requests.length, 1, 'Initial load also suppresses automatic dispatch');
page.advance(10000);
await settle();
assert.strictEqual(page.element('#app').hidden, false);
assert.match(page.element('#app').textContent, /Snapshot request timed out/);
assert.doesNotMatch(page.element('#app').textContent, /data below is from/);
assert.strictEqual(page.state().loadedAt, null);
page.hold = false;
page.refresh();
await settle();
assert.strictEqual(page.element('#app').hidden, true);
assert.strictEqual(page.state().loadedAt, NOW.getTime());
}],
['manual and query loads may overlap a poll while actual intervals remain suppressed', async () => {
const page = openPage(snapshot);
await settle();
page.hold = true;
page.refresh();
page.element('#refresh').listeners.click();
search(page, 'query');
assert.strictEqual(page.requests.length, 4);
assert.strictEqual(page.state().active, 3);
page.pending[1].succeed();
await settle();
assert.strictEqual(page.state().active, 2);
page.refresh();
assert.strictEqual(page.requests.length, 4);
page.pending[2].succeed(answer('query'));
await settle();
assert.strictEqual(page.state().active, 1);
page.advance(10000);
await settle();
assert.strictEqual(page.state().active, 0);
assert.strictEqual(page.element('#app').hidden, true, 'Older timeout does not overrule newer success');
page.refresh();
assert.strictEqual(page.requests.length, 5);
}],
['newer manual timeout remains visible when an older valid load supplies fallback data', async () => {
const page = openPage(snapshot);
await settle();
page.hold = true;
search(page, 'older');
search(page, 'newer');
// Adversarial completion order: fire only the newer timer, not wall time.
page.fireTimer(page.timerCalls.at(-1).id);
await settle();
page.pending[0].succeed(answer('older', true));
await settle();
assert.strictEqual(page.element('#query').value, 'older');
assert.strictEqual(page.state().shownQuery, 'older');
assert.strictEqual(page.state().allowActions, true);
assert.match(page.element('#app').textContent, /Snapshot request timed out/);
assert.doesNotMatch(page.element('#app').textContent, /Live refresh failed/);
assert.strictEqual(page.state().active, 0);
}],
['hidden interval leaves existing failure intact until a visible successful refresh', async () => {
const page = openPage(snapshot);
await settle();
page.online = false;
page.refresh();
await settle();
const failure = page.element('#app').textContent;
page.script.document.hidden = true;
page.refresh();
assert.strictEqual(page.requests.length, 2);
assert.strictEqual(page.element('#app').textContent, failure);
page.script.document.hidden = false; page.online = true;
page.refresh();
await settle();
assert.strictEqual(page.element('#app').hidden, true);
}],
['action POST remains unbounded by snapshot timers and its reload gets a deadline', async () => {
const page = openPage(snapshot);
await settle();
page.hold = true;
const action = page.script.runAction('recall-knowledge');
assert.strictEqual(page.requests[1].options.method, 'POST');
assert.strictEqual(page.requests[1].options.signal, undefined);
assert.strictEqual(page.timers.size, 0);
page.advance(10000);
assert.strictEqual(page.pending[0].settled, false);
page.pending[0].succeed({ ok: true });
await settle();
assert.match(page.requests[2].url, /\/api\/snapshot/);
assert.ok(page.requests[2].options.signal);
assert.strictEqual(page.timers.size, 1);
page.pending[1].succeed();
await action;
assert.strictEqual(page.timers.size, 0);
}],
];
for (const route of ['HTTP failure', 'invalid JSON', 'malformed late section', 'fetch setup', 'controller setup', 'URL setup', 'timer setup']) {
deadlineTests.push([`${route} releases its timer and active counter for the next interval`, async () => {
const page = openPage(snapshot);
await settle();
page.hold = true;
const key = { 'fetch setup': 'fetch', 'controller setup': 'AbortController', 'URL setup': 'URL', 'timer setup': 'setTimeout' }[route];
const original = page.script[key];
if (key) page.script[key] = function () { throw new Error(route); };
page.refresh();
if (route === 'HTTP failure') page.pending[0].respond({ ok: false, json: async () => ({ error: route }) });
if (route === 'invalid JSON') page.pending[0].respond({ ok: true, json: async () => { throw new Error(route); } });
if (route === 'malformed late section') page.pending[0].succeed({ ...snapshot, actions: {} });
await settle();
assert.strictEqual(page.element('#app').hidden, false);
assert.strictEqual(page.state().active, 0);
assert.strictEqual(page.timers.size, 0);
if (key) page.script[key] = original;
page.hold = false;
const count = page.requests.length;
page.refresh();
await settle();
assert.strictEqual(page.requests.length, count + 1);
assert.strictEqual(page.element('#app').hidden, true);
assert.strictEqual(page.state().active, 0);
}]);
}
for (const [name, check] of deadlineTests) {
if (await test(name, check)) passed++;
else failed++;
}
assert.strictEqual(pages.size, 0, 'Every fake page and its pending requests were disposed');
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}