diff --git a/frontend/context-poller.js b/frontend/context-poller.js index 0fbbf87..68fa821 100644 --- a/frontend/context-poller.js +++ b/frontend/context-poller.js @@ -5,9 +5,12 @@ function createContextPoller({ isHidden = () => false, setTimer = setTimeout, clearTimer = clearTimeout, + setDeadlineTimer = setTimeout, + clearDeadlineTimer = clearTimeout, intervalMs = 8000, + timeoutMs = 12000, }) { - let inFlight = null; + let activeRequest = null; let timer = null; let stopped = false; let revisions = {}; @@ -27,18 +30,55 @@ function createContextPoller({ }, intervalMs); } - function refresh() { + function abortError() { + const error = new Error('Live update superseded.'); + error.name = 'AbortError'; + return error; + } + + function supersede(request) { + if (!request || request.settled) return; + request.superseded = true; + request.controller.abort(); + request.rejectDeadline(abortError()); + } + + function refresh(options = {}) { if (stopped || isHidden()) return Promise.resolve(null); - if (inFlight) return inFlight; + if (activeRequest && !options.force) return activeRequest.promise; + if (activeRequest) supersede(activeRequest); + + const controller = new AbortController(); + let rejectDeadline; + const deadlinePromise = new Promise((resolve, reject) => { + rejectDeadline = reject; + }); + const requestState = { + controller, + deadline: null, + promise: null, + rejectDeadline, + settled: false, + superseded: false, + }; + activeRequest = requestState; + requestState.deadline = setDeadlineTimer(() => { + if (requestState.settled || requestState.superseded) return; + controller.abort(); + const error = new Error(`Live update timed out after ${timeoutMs}ms.`); + error.name = 'TimeoutError'; + rejectDeadline(error); + }, timeoutMs); let request; try { - request = fetchContext({ ...revisions }); + request = fetchContext({ ...revisions }, { signal: controller.signal }); } catch (error) { request = Promise.reject(error); } - inFlight = Promise.resolve(request) + requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise]) .then((snapshot) => { + if (activeRequest !== requestState) return retainedSnapshot; const changedSections = ['context', 'events', 'notifications'].filter( (section) => Object.prototype.hasOwnProperty.call(snapshot, section) ); @@ -48,20 +88,23 @@ function createContextPoller({ return retainedSnapshot; }) .catch((error) => { - onError(error); + if (activeRequest === requestState && !stopped) onError(error); return null; }) .finally(() => { - inFlight = null; + requestState.settled = true; + clearDeadlineTimer(requestState.deadline); + if (activeRequest !== requestState) return; + activeRequest = null; schedule(); }); - return inFlight; + return requestState.promise; } function setVisible(visible) { cancelTimer(); if (!visible) return Promise.resolve(null); - return refresh(); + return refresh({ force: true }); } return { @@ -71,6 +114,11 @@ function createContextPoller({ stop() { stopped = true; cancelTimer(); + if (activeRequest) { + const request = activeRequest; + activeRequest = null; + supersede(request); + } }, }; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 98bd378..e393dc2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -229,7 +229,7 @@ function setClock() { qs('#clock').textContent = fmt(new Date()); } setClock(); setInterval(setClock, 1000); - async function fetchLiveSnapshot(revisions = {}) { + async function fetchLiveSnapshot(revisions = {}, { signal } = {}) { const params = new URLSearchParams(); Object.entries(revisions).forEach(([section, revision]) => { if (Number.isInteger(revision) && revision >= 0) params.set(section + '_revision', revision); @@ -237,6 +237,7 @@ const query = params.toString(); const res = await fetch('api/v1/live' + (query ? '?' + query : ''), { headers: { Accept: 'application/json' }, + signal, }); if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); @@ -608,7 +609,9 @@ liveMode = false; activeFlushLogin = ''; if (!hasContextSnapshot && hydrateOfflineWork('outage')) return; - setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable'); + const timeoutStatus = e.name === 'TimeoutError' ? + 'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot'; + setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable'); if (!hasContextSnapshot) { qs('#context').innerHTML = '
Context unavailable.
'; qs('#view-hint').textContent = 'Active view unavailable.'; @@ -2811,7 +2814,7 @@ isHidden: () => document.hidden, intervalMs: 8000, }); - function load() { return contextPoller.refresh(); } + function load() { return contextPoller.refresh({ force: true }); } const offlineStatus = qs('#offline-status'); const keepWorkOffline = qs('#keep-work-offline'); @@ -2884,7 +2887,7 @@ offlineStatus.hidden = true; setOfflineWorkMode(false); setStatus('Reconnecting…'); - contextPoller.refresh(); + contextPoller.refresh({ force: true }); } keepWorkOffline.addEventListener('change', () => { offlineWorkStore.setEnabled(keepWorkOffline.checked); diff --git a/tests/test_context_polling.py b/tests/test_context_polling.py index 4fe4afb..96b0b04 100644 --- a/tests/test_context_polling.py +++ b/tests/test_context_polling.py @@ -114,3 +114,113 @@ const poller = createContextPoller({{ {"context": "timmy", "event": 2, "age": 2, "changed": []}, ], } + + +def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const timers = []; +const deadlineTimers = []; +const errors = []; +const snapshots = []; +const signals = []; +let calls = 0; +const poller = createContextPoller({{ + fetchContext: (revisions, options) => {{ + calls += 1; + signals.push(options.signal); + if (calls === 1) return new Promise(() => {{}}); + return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }}); + }}, + onSnapshot: snapshot => snapshots.push(snapshot.context.user.login), + onError: error => errors.push({{ name: error.name, message: error.message }}), + setTimer: (callback, delay) => {{ + const timer = {{ callback, delay, cancelled: false }}; + timers.push(timer); + return timer; + }}, + clearTimer: timer => {{ timer.cancelled = true; }}, + setDeadlineTimer: (callback, delay) => {{ + const timer = {{ callback, delay, cancelled: false }}; + deadlineTimers.push(timer); + return timer; + }}, + clearDeadlineTimer: timer => {{ timer.cancelled = true; }}, + intervalMs: 8000, + timeoutMs: 25, +}}); + +(async () => {{ + const first = poller.start(); + const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled); + deadline.callback(); + await first; + const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled); + retry.callback(); + await new Promise(resolve => setImmediate(resolve)); + process.stdout.write(JSON.stringify({{ + calls, + firstAborted: signals[0].aborted, + errors, + snapshots, + }})); +}})(); +""" + + assert run_node(script) == { + "calls": 2, + "firstAborted": True, + "errors": [ + {"name": "TimeoutError", "message": "Live update timed out after 25ms."} + ], + "snapshots": ["timmy"], + } + + +def test_forced_refresh_retires_stale_generation_without_late_overwrite(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +let resolveFirst; +let firstSignal; +const rendered = []; +const errors = []; +let calls = 0; +const poller = createContextPoller({{ + fetchContext: (revisions, options) => {{ + calls += 1; + if (calls === 1) {{ + firstSignal = options.signal; + return new Promise(resolve => {{ resolveFirst = resolve; }}); + }} + return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }}); + }}, + onSnapshot: snapshot => rendered.push(snapshot.context.user.login), + onError: error => errors.push(error.name), + setTimer: () => 1, + clearTimer: () => {{}}, + setDeadlineTimer: () => 1, + clearDeadlineTimer: () => {{}}, +}}); + +(async () => {{ + const stale = poller.start(); + const fresh = poller.refresh({{ force: true }}); + await fresh; + resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }}); + await stale; + await new Promise(resolve => setImmediate(resolve)); + process.stdout.write(JSON.stringify({{ + calls, + firstAborted: firstSignal.aborted, + rendered, + errors, + }})); +}})(); +""" + + assert run_node(script) == { + "calls": 2, + "firstAborted": True, + "rendered": ["fresh"], + "errors": [], + } diff --git a/tests/test_shared_context_snapshot.py b/tests/test_shared_context_snapshot.py index ba46ead..8fc4999 100644 --- a/tests/test_shared_context_snapshot.py +++ b/tests/test_shared_context_snapshot.py @@ -57,3 +57,21 @@ async def test_dashboard_reports_each_live_section_from_its_own_freshness(): assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html assert "Unread updates unavailable · showing last known updates" in html assert "Activity refresh failed · showing last activity" in html + + +@pytest.mark.anyio +async def test_operator_refresh_and_reconnect_supersede_a_stalled_live_request(): + html = await dashboard() + + assert "async function fetchLiveSnapshot(revisions = {}, { signal } = {})" in html + assert "headers: { Accept: 'application/json' },\n signal," in html + assert "function load() { return contextPoller.refresh({ force: true }); }" in html + assert "contextPoller.refresh({ force: true });" in html + + +@pytest.mark.anyio +async def test_stalled_refresh_keeps_the_last_snapshot_with_specific_guidance(): + html = await dashboard() + + assert "e.name === 'TimeoutError'" in html + assert "Update delayed · showing last snapshot" in html diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py index 0eba812..40b759a 100644 --- a/tests/test_work_pages.py +++ b/tests/test_work_pages.py @@ -103,7 +103,7 @@ async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect(): bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text() assert "window.addEventListener('offline'" in bootstrap assert "window.addEventListener('online'" in bootstrap - assert "contextPoller.refresh()" in bootstrap + assert "contextPoller.refresh({ force: true })" in bootstrap @pytest.mark.anyio