fix: bound stalled dashboard navigations (#353)
All checks were successful
CI / lint (pull_request) Successful in 35s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-08 23:54:41 +00:00
parent 391e8964c4
commit 0ff6b5c8fd
5 changed files with 124 additions and 13 deletions

View File

@ -42,7 +42,10 @@ triageable offline: **Queue read & next** writes an account-bound, notification-
deduplicated acknowledgement to the durable background delivery system, removes the deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold 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 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 Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is 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 aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries

View File

@ -1,7 +1,8 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); 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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [ const SHELL = [
BASE, BASE,
BASE + 'manifest.webmanifest', BASE + 'manifest.webmanifest',
@ -44,6 +45,25 @@ const SHELL = [
BASE + 'static/background-issue-sync.js', 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) { async function sessionCsrf(signal) {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), { const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
@ -182,7 +202,7 @@ self.addEventListener('fetch', event => {
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return; if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
event.respondWith( event.respondWith(
fetch(request).then(async response => { fetchNavigation(request).then(async response => {
const cache = await caches.open(CACHE); const cache = await caches.open(CACHE);
const responseUrl = new URL(response.url || request.url); const responseUrl = new URL(response.url || request.url);
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE; const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
@ -195,7 +215,11 @@ self.addEventListener('fetch', event => {
return response; return response;
}).catch(async () => { }).catch(async () => {
const cache = await caches.open(CACHE); 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; return;

View File

@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

View File

@ -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])) 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 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

View File

@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs'); const fs = require('fs');
const vm = require('vm'); const vm = require('vm');
const listeners = {{}}; 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 = {{ const cache = {{
addAll: async urls => {{ state.added = urls; }}, addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody), match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
@ -21,9 +21,10 @@ const cache = {{
}}; }};
const context = {{ const context = {{
URL, Request, Response, Headers, AbortController, URL, Request, Response, Headers, AbortController,
console, setTimeout, clearTimeout, console,
self: {{ self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }}, location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
addEventListener: (name, handler) => {{ listeners[name] = handler; }}, addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }}, skipWaiting: async () => {{ state.skipped = true; }},
clients: {{ clients: {{
@ -45,9 +46,19 @@ const context = {{
delete: async key => {{ state.deleted.push(key); return true; }}, delete: async key => {{ state.deleted.push(key); return true; }},
match: async request => cache.match(request), match: async request => cache.match(request),
}}, }},
fetch: async request => {{ fetch: async (request, options = {{}}) => {{
state.fetches.push(String(request.url || request)); state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline'); 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 }}); const response = new Response('network', {{ status: state.fetchStatus }});
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }}); Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
return response; return response;
@ -94,10 +105,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout) 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() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
result = run_worker_scenario( result = run_worker_scenario(
""" """