diff --git a/README.md b/README.md index 15be821..ec05235 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,12 @@ 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. Installed-app +authenticated notification-read endpoint and keeps transient failures queued. Every foreground +same-origin dashboard API request also has a 15-second browser deadline, including fresh- +authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's +fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server +outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are +not changed by this session boundary. 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. diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 7ea13d5..82a07fc 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v60'; +const CACHE = 'stackchain-dashboard-shell-v61'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ diff --git a/frontend/session.js b/frontend/session.js index 25be1c3..33f587c 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -93,6 +93,7 @@ promptAuthorization = () => null, onExpired = () => {}, onClearError = () => {}, + requestTimeoutMs = 15000, }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); let expirationStarted = false; @@ -122,6 +123,56 @@ catch (_error) { return false; } } + function isDashboardApi(input) { + try { + const url = new URL(String(input?.url || input), origin); + return url.origin === origin && url.pathname.startsWith(base + 'api/v1/'); + } catch (_error) { return false; } + } + + async function fetchWithDeadline(input, options, method, phase = 'request') { + if (!isDashboardApi(input)) return fetchImpl(input, options); + const controller = new AbortController(); + const callerSignal = options.signal || input?.signal; + let timeout; + let rejectCancellation; + const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; }); + const cancelFromCaller = () => { + const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError'); + error.source = 'caller'; + controller.abort(error); + rejectCancellation(error); + }; + if (callerSignal?.aborted) cancelFromCaller(); + else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true }); + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = new Error( + SAFE_METHODS.has(method) + ? 'Request timed out. Try again.' + : 'Request timed out. Refresh to verify the outcome before retrying.' + ); + error.name = 'TimeoutError'; + error.method = method; + error.outcome = 'unknown'; + error.safeToRetry = SAFE_METHODS.has(method); + error.phase = phase; + controller.abort(error); + reject(error); + }, requestTimeoutMs); + }); + try { + return await Promise.race([ + Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })), + deadline, + cancellation, + ]); + } finally { + clearTimeout(timeout); + callerSignal?.removeEventListener?.('abort', cancelFromCaller); + } + } + async function sessionFetch(input, options = {}, allowStepUp = true) { const method = String(options.method || input?.method || 'GET').toUpperCase(); const requestOptions = { ...options }; @@ -131,7 +182,7 @@ if (csrf) headers.set('X-CSRF-Token', csrf); requestOptions.headers = headers; } - const response = await fetchImpl(input, requestOptions); + const response = await fetchWithDeadline(input, requestOptions, method); if (response.status === 428 && isSameOrigin(input) && allowStepUp) { const payload = await response.clone().json().catch(() => ({})); const detail = payload?.detail || {}; @@ -147,7 +198,7 @@ }); const csrf = csrfToken(); if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf); - const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', { + const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', { method: 'POST', headers: authorizationHeaders, body: JSON.stringify({ @@ -155,7 +206,7 @@ action: detail.action, target: detail.target, }), - }); + }, 'POST', 'fresh-authorization'); if (!authorization.ok) return authorization; const grant = await authorization.json().catch(() => ({})); if (!grant.grant) return response; diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 18b7973..b444b57 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -62,6 +62,160 @@ const boundary = createSessionBoundary({{ return json.loads(result.stdout) +def test_same_origin_api_fetch_times_out_even_when_fetch_ignores_abort(): + script = f""" +const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); +let aborted = false; +const boundary = createSessionBoundary({{ + cookie: () => '', + origin: 'https://forge.example', + base: '/dashboard/', + requestTimeoutMs: 10, + fetchImpl: (_url, options = {{}}) => new Promise(resolve => {{ + options.signal?.addEventListener('abort', () => {{ aborted = true; }}); + setTimeout(() => resolve(new Response('{{}}', {{status:200}})), 80); + }}), + location: {{ replace: () => {{}} }}, +}}); +(async () => {{ + const started = Date.now(); + try {{ await boundary.fetch('/dashboard/api/v1/context'); }} + catch (error) {{ + process.stdout.write(JSON.stringify({{ + name:error.name, message:error.message, method:error.method, + outcome:error.outcome, aborted, elapsed:Date.now()-started, + }})); + }} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True, timeout=2 + ) + output = json.loads(result.stdout) + + assert output["name"] == "TimeoutError" + assert output["message"] == "Request timed out. Try again." + assert output["method"] == "GET" + assert output["outcome"] == "unknown" + assert output["aborted"] is True + assert output["elapsed"] < 70 + + +def test_caller_abort_cancels_same_origin_api_fetch_without_waiting_for_deadline(): + script = f""" +const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); +const caller = new AbortController(); +let requestAborted = false; +const boundary = createSessionBoundary({{ + cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000, + fetchImpl: (_url, options = {{}}) => new Promise(() => {{ + options.signal.addEventListener('abort', () => {{ requestAborted = true; }}); + }}), + location: {{ replace: () => {{}} }}, +}}); +(async () => {{ + const started = Date.now(); + const pending = boundary.fetch('/dashboard/api/v1/context', {{signal:caller.signal}}); + setTimeout(() => caller.abort(new DOMException('Superseded', 'AbortError')), 10); + try {{ await pending; }} catch (error) {{ + process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source, requestAborted, elapsed:Date.now()-started}})); + }} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True, timeout=2 + ) + output = json.loads(result.stdout) + + assert output["name"] == "AbortError" + assert output["message"] == "Superseded" + assert output["source"] == "caller" + assert output["requestAborted"] is True + assert output["elapsed"] < 100 + + +def test_request_object_abort_signal_is_composed_with_the_api_deadline(): + script = f""" +const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); +const caller = new AbortController(); +const boundary = createSessionBoundary({{ + cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000, + fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}}, +}}); +(async () => {{ + const request = new Request('https://forge.example/dashboard/api/v1/context', {{signal:caller.signal}}); + const pending = boundary.fetch(request); + setTimeout(() => caller.abort(new DOMException('Request superseded', 'AbortError')), 5); + try {{ await pending; }} catch (error) {{ + process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source}})); + }} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True, timeout=2 + ) + + assert json.loads(result.stdout) == { + "name": "AbortError", + "message": "Request superseded", + "source": "caller", + } + + +def test_mutation_timeout_warns_that_the_server_outcome_may_be_ambiguous(): + script = f""" +const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); +const boundary = createSessionBoundary({{ + cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5, + fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}}, +}}); +(async () => {{ + try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/issues/9', {{method:'PATCH'}}); }} + catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, message:error.message, method:error.method, safeToRetry:error.safeToRetry}})); }} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True, timeout=2 + ) + output = json.loads(result.stdout) + + assert output == { + "name": "TimeoutError", + "message": "Request timed out. Refresh to verify the outcome before retrying.", + "method": "PATCH", + "safeToRetry": False, + } + + +def test_fresh_authorization_request_uses_the_same_deadline_boundary(): + script = f""" +const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); +let calls = 0; +const boundary = createSessionBoundary({{ + cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5, + fetchImpl: () => {{ + calls += 1; + if (calls === 1) return Promise.resolve(new Response(JSON.stringify({{detail:{{code:'step_up_required', action:'merge_pull', target:'stackchain/app#9'}}}}), {{status:428, headers:{{'Content-Type':'application/json'}}}})); + return new Promise(() => {{}}); + }}, + promptAuthorization: async () => 'operator-token', location: {{replace:() => {{}}}}, +}}); +(async () => {{ + try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/pulls/9/merge', {{method:'POST'}}); }} + catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, phase:error.phase, calls}})); }} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True, timeout=2 + ) + + assert json.loads(result.stdout) == { + "name": "TimeoutError", + "phase": "fresh-authorization", + "calls": 2, + } + + def test_mutating_same_origin_fetch_receives_csrf_proof(): result = run_session_scenario( """ diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 0c942d9..fd5e038 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 430b4b6..6ec4046 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-v60" in worker + assert "stackchain-dashboard-shell-v61" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 20d61c5..ca2ec9d 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-v60" in worker + assert "stackchain-dashboard-shell-v61" in worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 124bdbc..10a4de0 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/plan-today.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 200606a..bc33b81 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -117,7 +117,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -125,14 +125,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -141,21 +141,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" 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-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 5ffc3d4..9672f58 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v60" in source + assert "stackchain-dashboard-shell-v61" in source assert "BASE + 'static/today-sync.js'" in source