From 0ff6b5c8fde4219d41ead673b520badc9badea2e Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 23:54:41 +0000 Subject: [PATCH] fix: bound stalled dashboard navigations (#353) --- README.md | 5 +- frontend/service-worker.js | 30 ++++++- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_service_worker.py | 98 +++++++++++++++++++++-- 5 files changed, 124 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2739222..e5eb79a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ triageable offline: **Queue read & next** writes an account-bound, notification- deduplicated acknowledgement to the durable background delivery system, removes the update from the local queue immediately, and opens the next saved conversation. A cold offline reload suppresses acknowledgements still waiting to sync; reconnect uses the -authenticated notification-read endpoint and keeps transient failures queued. +authenticated notification-read endpoint and keeps transient failures queued. Installed-app +navigations are also deadline-bounded: after four seconds without a network response, +Stackchain aborts the request and opens the cached dashboard shell. If the shell has not +been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging. Closed-app delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries diff --git a/frontend/service-worker.js b/frontend/service-worker.js index a1e96a4..4f61297 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,8 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v42'; +const CACHE = 'stackchain-dashboard-shell-v43'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); +const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ BASE, BASE + 'manifest.webmanifest', @@ -44,6 +45,25 @@ const SHELL = [ BASE + 'static/background-issue-sync.js', ]; +async function fetchNavigation(request) { + const controller = new AbortController(); + let timeout; + const deadline = new Promise((resolve, reject) => { + timeout = setTimeout(() => { + controller.abort(); + reject(new Error('Dashboard navigation timed out.')); + }, NAVIGATION_TIMEOUT_MS); + }); + try { + return await Promise.race([ + fetch(request, { signal: controller.signal }), + deadline, + ]); + } finally { + clearTimeout(timeout); + } +} + async function sessionCsrf(signal) { const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), { headers: { Accept: 'application/json' }, @@ -182,7 +202,7 @@ self.addEventListener('fetch', event => { if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return; if (request.mode === 'navigate') { event.respondWith( - fetch(request).then(async response => { + fetchNavigation(request).then(async response => { const cache = await caches.open(CACHE); const responseUrl = new URL(response.url || request.url); const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE; @@ -195,7 +215,11 @@ self.addEventListener('fetch', event => { return response; }).catch(async () => { const cache = await caches.open(CACHE); - return cache.match(BASE); + const cached = await cache.match(BASE); + return cached || new Response( + 'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.', + { status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, + ); }) ); return; diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index fe8b9c3..50689e6 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v42" in worker + assert "stackchain-dashboard-shell-v43" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index c6ec534..199abbe 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v42" in worker + assert "stackchain-dashboard-shell-v43" in worker diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 16576a3..4d2451a 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const cache = {{ addAll: async urls => {{ state.added = urls; }}, match: async request => state.cachedBody === null ? null : new Response(state.cachedBody), @@ -21,9 +21,10 @@ const cache = {{ }}; const context = {{ URL, Request, Response, Headers, AbortController, - console, + setTimeout, clearTimeout, console, self: {{ location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }}, + __STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15, addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, clients: {{ @@ -45,9 +46,19 @@ const context = {{ delete: async key => {{ state.deleted.push(key); return true; }}, match: async request => cache.match(request), }}, - fetch: async request => {{ + fetch: async (request, options = {{}}) => {{ state.fetches.push(String(request.url || request)); if (state.failFetch) throw new Error('offline'); + if (state.stallFetch) return new Promise((resolve, reject) => {{ + options.signal?.addEventListener('abort', () => {{ + state.fetchAborted = true; + reject(new Error('aborted')); + }}, {{ once: true }}); + }}); + if (state.lateFetch) return new Promise(resolve => {{ + options.signal?.addEventListener('abort', () => {{ state.fetchAborted = true; }}, {{ once: true }}); + setTimeout(() => resolve(new Response('late network')), 50); + }}); const response = new Response('network', {{ status: state.fetchStatus }}); Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }}); return response; @@ -94,10 +105,10 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): +def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v42" in source + assert "stackchain-dashboard-shell-v43" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -106,14 +117,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v42" in source + assert "stackchain-dashboard-shell-v43" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v42" in source + assert "stackchain-dashboard-shell-v43" in source assert "BASE + 'static/update-ownership.js'" in source @@ -360,6 +371,79 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url(): ] +def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline(): + result = run_worker_scenario( + """ + state.stallFetch = true; + state.cachedBody = 'cached dashboard'; + const response = await Promise.race([ + dispatch('fetch', { + method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/', + }), + new Promise(resolve => setTimeout(() => resolve(null), 80)), + ]); + process.stdout.write(JSON.stringify({ + timedOut: response === null, + body: response ? await response.text() : null, + state, + })); +""" + ) + + assert result["timedOut"] is False + assert result["body"] == "cached dashboard" + assert result["state"]["fetchAborted"] is True + assert result["state"]["puts"] == [] + + +def test_stalled_navigation_without_cached_shell_returns_deterministic_504(): + result = run_worker_scenario( + """ + state.stallFetch = true; + const response = await Promise.race([ + dispatch('fetch', { + method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/', + }), + new Promise(resolve => setTimeout(() => resolve(null), 80)), + ]); + process.stdout.write(JSON.stringify({ + status: response?.status || null, + body: response ? await response.text() : null, + contentType: response?.headers.get('Content-Type') || null, + state, + })); +""" + ) + + assert result["status"] == 504 + assert result["body"] == "Stackchain is offline and the dashboard is not cached yet. Reconnect and try again." + assert result["contentType"] == "text/plain; charset=utf-8" + assert result["state"]["fetchAborted"] is True + + +def test_navigation_deadline_wins_when_fetch_ignores_abort_and_prevents_late_cache_write(): + result = run_worker_scenario( + """ + state.lateFetch = true; + state.cachedBody = 'cached dashboard'; + const response = await Promise.race([ + dispatch('fetch', { + method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/', + }), + new Promise(resolve => setTimeout(() => resolve(null), 35)), + ]); + const body = response ? await response.text() : null; + await new Promise(resolve => setTimeout(resolve, 60)); + process.stdout.write(JSON.stringify({timedOut: response === null, body, state})); +""" + ) + + assert result["timedOut"] is False + assert result["body"] == "cached dashboard" + assert result["state"]["fetchAborted"] is True + assert result["state"]["puts"] == [] + + def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell(): result = run_worker_scenario( """ -- 2.43.0