fix(control-pane): keep the canvas label and live region consistent

Two ways the outage state could mislead an operator reading either
channel.

The failure branch cleared the live region but left the canvas
aria-label holding the counts from the last successful poll, so landing
on the canvas during an outage reported stale counts. It now sets the
same unavailable wording on the canvas. apply() already restores the
count label on the next valid poll, and there is now a test for that.

Polls run every 5000ms and are not sequenced, so a slow older request
can settle after a newer one has succeeded. Its catch block then
announced an outage while the canvas showed the newer counts, and the
two channels contradicted each other. Each poll now takes a monotonic
token and only the newest one may touch the view, so a superseded
failure is dropped.

The runtime test can park a response, which lets it settle an older
failure after a newer success. New assertions cover the canvas label
after an outage, the restored label after recovery, and the
superseded-failure case. Removing the canvas reset, the token guard, or
the restore in apply() each fail an assertion, and the unmutated view
passes all of them.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
pasmud
2026-09-27 02:44:18 +00:00
co-authored by CommandCodeBot
parent 9e0996c6d6
commit e97e333040
2 changed files with 59 additions and 3 deletions
@@ -240,6 +240,14 @@ function renderControlPlaneViewHtml() {
});
}
// Wording shared by the canvas label and the live region so an outage reads
// the same way however the operator reaches it.
var UNAVAILABLE = 'Control-plane data is unavailable. Advisories and steering are unknown.';
// Monotonic poll id. Polls are not sequenced, so a slow older request can
// settle after a newer one. Only the newest poll may touch the view, which
// stops a late failure from overwriting a newer success.
var latestPoll = 0;
// Polling runs every few seconds, so only speak when the advisory and
// steering counts actually move. Repeating an unchanged summary would talk
// over the operator without telling them anything new.
@@ -274,14 +282,20 @@ function renderControlPlaneViewHtml() {
}
function poll() {
var token = ++latestPoll;
fetch('/api/control-plane').then(function (r) {
if (!r.ok) throw new Error('Control-plane request failed');
return r.json();
}).then(apply).catch(function () {
// A newer poll has already answered, so this failure is stale and must
// not overwrite the newer counts.
if (token !== latestPoll) return;
document.getElementById('status').textContent = 'offline';
// The last guidance is now stale, so replace it rather than leaving the
// live region claiming the airspace is clear.
announce('Control-plane data is unavailable. Advisories and steering are unknown.');
// live region claiming the airspace is clear. The canvas label goes with
// it, or it would still report the last successful counts.
canvas.setAttribute('aria-label', UNAVAILABLE);
announce(UNAVAILABLE);
});
}
+43 -1
View File
@@ -84,7 +84,8 @@ function element(tag, context) {
}
// Drives the view's inline script against a queue of poll responses, so one run
// can cover several polls and the state each one leaves behind.
// can cover several polls and the state each one leaves behind. A response may
// carry a `hold` function, which lets a test settle two polls out of order.
async function render(responses) {
const context = createContext();
const elements = new Map();
@@ -105,6 +106,9 @@ async function render(responses) {
setInterval(fn) { timers.push(fn); },
fetch: async () => {
const next = queue.length > 1 ? queue.shift() : queue[0];
// `hold` parks this response until the test releases it, which is how a
// slow older poll is made to settle after a newer one.
if (next.hold) await next.hold;
return { ok: next.ok, json: async () => next.data };
}
});
@@ -113,6 +117,7 @@ async function render(responses) {
elements,
context,
writesTo(id) { return elements.get(id).writes; },
labelOf(id) { return elements.get(id).attributes['aria-label']; },
async pollAgain() {
for (const fn of timers) fn();
await new Promise(resolve => setImmediate(resolve));
@@ -222,6 +227,12 @@ let passed = 0;
`an outage must say the counts are unknown, got ${outage.elements.get('announce').textContent}`);
passed += 1;
// The canvas label is the on-demand description, so an outage has to clear
// the last counts there too, not just in the live region.
assert.ok(/unavailable/i.test(outage.labelOf('c')) && /unknown/i.test(outage.labelOf('c')),
`the canvas label must not keep the last counts during an outage, got ${outage.labelOf('c')}`);
passed += 1;
// A repeated failure stays silent, but recovering online must speak again.
const writesAfterOutage = outage.writesTo('announce');
await outage.pollAgain();
@@ -243,6 +254,37 @@ let passed = 0;
`a recovered poll must announce the restored counts, got ${recovered.elements.get('announce').textContent}`);
passed += 1;
// apply() must put the counts back on the canvas once data flows again.
assert.ok(recovered.labelOf('c').includes('1 advisories') && recovered.labelOf('c').includes('1 steering'),
`a recovered poll must restore the count label on the canvas, got ${recovered.labelOf('c')}`);
passed += 1;
// Polls are not sequenced. An older poll that settles after a newer one must
// not overwrite it, or the live region and the canvas disagree. The initial
// poll takes the first response, the second response is the slow older poll,
// and the third is the newer success that lands while the older is parked.
let releaseOlder;
const olderSettles = new Promise(resolve => { releaseOlder = resolve; });
const overlapping = await render([
{ ok: true, data: populatedView() },
{ ok: false, data: { ok: false, error: 'snapshot unavailable' }, hold: olderSettles },
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 4, resolutions: 0 } }) }
]);
await overlapping.pollAgain();
await overlapping.pollAgain();
assert.ok(overlapping.labelOf('c').includes('4 advisories'),
`the newer success should land first, got ${overlapping.labelOf('c')}`);
releaseOlder();
await new Promise(resolve => setImmediate(resolve));
assert.ok(overlapping.labelOf('c').includes('4 advisories')
&& !/unavailable/i.test(overlapping.labelOf('c')),
`a superseded failure must not clear the newer canvas counts, got ${overlapping.labelOf('c')}`);
assert.ok(!/unavailable/i.test(overlapping.elements.get('announce').textContent),
`a superseded failure must not announce an outage, got ${overlapping.elements.get('announce').textContent}`);
assert.notStrictEqual(overlapping.elements.get('status').textContent, 'offline',
'a superseded failure must not mark the view offline');
passed += 1;
console.log(`Results: Passed: ${passed}, Failed: 0`);
})().catch(error => {
console.error(error.message);