fix: bound stalled dashboard navigations (#353)
This commit is contained in:
parent
391e8964c4
commit
0ff6b5c8fd
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user