diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js index 9da128507..2961851ed 100644 --- a/scripts/lib/control-pane/control-plane-view-ui.js +++ b/scripts/lib/control-pane/control-plane-view-ui.js @@ -307,10 +307,12 @@ function renderControlPlaneViewHtml() { // reported as an outage rather than swallowed. timer = setTimeout(function () { controller.abort(); }, TIMEOUT_MS); // A poll that has already answered, or timed out, owns the view. Anything - // that settles later is superseded and must be dropped. + // that settles later is superseded and must be dropped. The watermark only + // ever moves forward: a poll that settles late is itself stale, and letting + // it lower the mark would re-admit an even older poll behind it. function settle() { clearTimeout(timer); - settledPoll = token; + if (token > settledPoll) settledPoll = token; } fetch('/api/control-plane', { signal: controller.signal }).then(function (r) { if (!r.ok) throw new Error('Control-plane request failed'); diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js index d046e68bd..22b9940cf 100644 --- a/tests/lib/control-plane-view-ui.test.js +++ b/tests/lib/control-plane-view-ui.test.js @@ -395,6 +395,56 @@ let passed = 0; `a timed out poll must announce the outage, got ${hung.elements.get('announce').textContent}`); passed += 1; + // Three polls settling out of order as #3, #1, #2. The ignored #1 must not + // pull the staleness mark back to 1, or the even older #2 would then be let + // through on top of #3's counts. + async function settleOutOfOrder(second, third) { + let releaseFirst, releaseSecond, releaseThird; + const holdOne = new Promise(resolve => { releaseFirst = resolve; }); + const holdTwo = new Promise(resolve => { releaseSecond = resolve; }); + const holdThree = new Promise(resolve => { releaseThird = resolve; }); + const view = await render([ + { ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 1, resolutions: 0 } }), hold: holdOne }, + second(holdTwo), + third(holdThree) + ]); + // Start all three, then settle them newest first. + await view.pollAgain(); + await view.pollAgain(); + await view.pollAgain(); + releaseThird(); + await settle(); + releaseFirst(); + await settle(); + releaseSecond(); + await settle(); + return view; + } + + const newest = { tasks: 3, lanes: 1, agents: 2, advisories: 9, resolutions: 0 }; + // The ignored #1 is a success, and the older #2 is also a success. + const outOfOrderSuccess = await settleOutOfOrder( + hold => ({ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 2, resolutions: 1 } }), hold }), + hold => ({ ok: true, data: populatedView({ counts: newest }), hold }) + ); + assert.ok(outOfOrderSuccess.labelOf('c').includes('9 advisories') + && !outOfOrderSuccess.labelOf('c').includes('2 advisories'), + `an older success must not lower the staleness mark, got ${outOfOrderSuccess.labelOf('c')}`); + passed += 1; + + // The ignored #1 is a success, and the older #2 is a failure, which would + // otherwise mark a healthy view offline. + const outOfOrderFailure = await settleOutOfOrder( + hold => ({ ok: false, data: { ok: false, error: 'snapshot unavailable' }, hold }), + hold => ({ ok: true, data: populatedView({ counts: newest }), hold }) + ); + assert.notStrictEqual(outOfOrderFailure.elements.get('status').textContent, 'offline', + 'a superseded failure must not mark a healthy view offline'); + assert.ok(outOfOrderFailure.labelOf('c').includes('9 advisories') + && !/unavailable/i.test(outOfOrderFailure.labelOf('c')), + `the newest counts must survive an ignored poll, got ${outOfOrderFailure.labelOf('c')}`); + passed += 1; + console.log(`Results: Passed: ${passed}, Failed: 0`); })().catch(error => { console.error(error.message);