From 1f57b75d5dbb82e8fb20d807e05469ccee34c539 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 11:15:13 +0000 Subject: [PATCH] fix: complete private outbox purge before sign-out (#293) --- frontend/background-issue-sync.js | 39 +++++++++++++++++--- frontend/service-worker.js | 10 +++++- frontend/session.js | 45 ++++++++++++++++++++++-- tests/test_background_issue_sync.py | 33 +++++++++++++++++ tests/test_dashboard_session_frontend.py | 37 ++++++++++++++++--- tests/test_service_worker.py | 22 +++++++++--- 6 files changed, 170 insertions(+), 16 deletions(-) diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 180c2c8..b22879d 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o request.result.createObjectStore('issues', { keyPath: 'id' }); } }; - request.onsuccess = () => resolve(request.result); + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + db.close(); + databasePromise = undefined; + }; + resolve(db); + }; request.onerror = () => reject(request.error); }); return databasePromise; @@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); - return async work => { + const transact = async work => { const db = await database(); return new Promise((resolve, reject) => { const transaction = db.transaction('issues', 'readwrite'); @@ -38,6 +45,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o }); }); }; + transact.close = async () => { + if (!databasePromise) return; + const db = await databasePromise; + db.close(); + databasePromise = undefined; + }; + return transact; } function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) { @@ -149,10 +163,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n item.status !== 'sent' && item.ownerLogin !== ownerLogin).length), setReceiptPreference, getReceiptPreference, + close: () => transact.close?.(), }; } function createBackgroundIssueSync({ store, fetchJson, base = '' }) { + let purgeRequested = false; + let activeFlush = null; function receiptFor(item, status, delivered = {}) { if (status === 'attention') { return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' }; @@ -238,6 +255,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) { } async function send(item, currentLogin) { + if (purgeRequested) return { blocked: true }; if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; await store.upsert(item); const claimed = await store.claim(item.id, currentLogin); @@ -245,7 +263,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) { return deliver(claimed); } - async function flush() { + async function runFlush() { const identity = await fetchJson(base + 'api/v1/background-identity', { headers: { Accept: 'application/json' }, cache: 'no-store', }); @@ -267,8 +285,21 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) { return { confirmed, blocked, attention, login, receipts }; } + function flush() { + if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] }); + if (activeFlush) return activeFlush; + activeFlush = runFlush().finally(() => { activeFlush = null; }); + return activeFlush; + } + + async function purge() { + purgeRequested = true; + if (activeFlush) await activeFlush.catch(() => {}); + await store.close?.(); + } + return { - flush, send, + flush, send, purge, reconcile: (items, outboxLane) => store.reconcile(items, outboxLane), snapshot: () => store.snapshot(), setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled), diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 7cea91b..4c5405a 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-v24'; +const CACHE = 'stackchain-dashboard-shell-v25'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -100,6 +100,14 @@ self.addEventListener('sync', event => { self.addEventListener('message', event => { if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify()); + if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => { + try { + await issueSync.purge(); + event.ports?.[0]?.postMessage({ ok: true }); + } catch (error) { + event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') }); + } + })()); }); self.addEventListener('notificationclick', event => { diff --git a/frontend/session.js b/frontend/session.js index 9b208cf..85211cc 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -13,9 +13,14 @@ indexedDB: root.indexedDB, caches: root.caches, serviceWorker: root.navigator?.serviceWorker, + MessageChannel: root.MessageChannel, location: root.location, confirmAction: message => root.confirm(message), onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')), + onClearError: error => { + root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message })); + root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.'); + }, }); root.fetch = boundary.fetch; const attach = () => { @@ -30,8 +35,9 @@ root.stackchainSession = boundary; } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ - cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, confirmAction, + cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, onExpired = () => {}, + onClearError = () => {}, }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -73,11 +79,44 @@ } catch (_error) { /* Cookie invalidation still protects server data. */ } } + async function stopWorkerOutbox() { + const registration = await serviceWorker?.ready; + if (!registration?.active || !MessageChannel) return; + await new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000); + channel.port1.onmessage = event => { + clearTimeout(timeout); + if (event.data?.ok) resolve(); + else reject(new Error(event.data?.error || 'Background outbox purge failed.')); + }; + registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]); + }); + } + + function deletePrivateOutbox() { + if (!indexedDB) return Promise.resolve(); + return new Promise((resolve, reject) => { + let request; + try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); } + catch (error) { reject(error); return; } + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); + request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.')); + }); + } + async function clearPrivateDeviceData() { removeDashboardStorage(localStorage); if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage); - try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); } - catch (_error) { /* Continue clearing other dashboard state. */ } + try { + await stopWorkerOutbox(); + await deletePrivateOutbox(); + } catch (_error) { + const error = new Error('Could not clear private queued work from this device.'); + onClearError(error); + throw error; + } try { const keys = await caches?.keys?.() || []; await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))); diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 7ba6fea..6da784f 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -194,6 +194,39 @@ const transaction=work=>{{const run=tail.then(()=>work({{ ] +def test_indexeddb_store_closes_on_version_change_and_reopens_afterward(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const state={{opens:0,closes:0}}; +const indexedDB={{lastDb:null,open:()=>{{ + state.opens += 1; + const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}}; + const db={{ + objectStoreNames:{{contains:()=>true}}, + close:()=>{{state.closes += 1;}}, + transaction:()=>{{ + const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{ + getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}}, + }})}}; + setTimeout(()=>tx.oncomplete?.(),0); + return tx; + }}, + }}; + indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request; +}}}}; +(async()=>{{ + const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}}); + await store.snapshot(); + indexedDB.lastDb.onversionchange?.(); + await store.snapshot(); + process.stdout.write(JSON.stringify(state)); +}})(); +""" + output = run_node(script) + + assert output == {"opens": 2, "closes": 1} + + def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 8321a3a..9c27dc7 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js" def run_session_scenario(scenario: str) -> dict: harness = f""" const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); -const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [], confirmations: [] }}; +const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [] }}; const storage = {{ values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), get length() {{ return this.values.size; }}, @@ -31,10 +31,24 @@ const boundary = createSessionBoundary({{ }}, localStorage: storage, sessionStorage: storage, - indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }}, + indexedDB: {{ deleteDatabase: name => {{ + state.deletedDatabases.push(name); + const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }}; + setTimeout(() => {{ + if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }} + else {{ state.deletionCompleted = true; request.onsuccess?.(); }} + }}, 20); + return request; + }} }}, caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }}, - serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }}, - location: {{ assign: value => {{ state.assigned = value; }} }}, + serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }}, + MessageChannel: class {{ constructor() {{ + const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }}; + const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }}; + this.port1 = first; this.port2 = second; + }} }}, + location: {{ assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }} }}, + onClearError: error => state.clearErrors.push(error.message), confirmAction: message => {{ state.confirmations.push(message); return true; }}, }}); (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); @@ -72,6 +86,21 @@ process.stdout.write(JSON.stringify(state)); assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["assigned"] == "/dashboard/login" + assert result["assignedAfterDeletion"] is True + + +def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion(): + result = run_session_scenario( + """ +state.failDeletion = true; +try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; } +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["assigned"] == "" + assert result["clearErrors"] == ["Could not clear private queued work from this device."] + assert result["signOutError"] == "Could not clear private queued work from this device." def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint(): diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 940abad..3f5bb53 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, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }}; +const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }}; const cache = {{ addAll: async urls => {{ state.added = urls; }}, match: async request => state.cachedBody === null ? null : new Response(state.cachedBody), @@ -34,6 +34,7 @@ const context = {{ registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}}, __issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }}, + purge: async () => {{ state.outboxPurges += 1; }}, getReceiptPreference: async login => state.receiptLogin === login, }}, }}, @@ -68,9 +69,9 @@ async function dispatchSync(tag) {{ listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }}); if (pending) await pending; }} -async function dispatchMessage(data) {{ +async function dispatchMessage(data, ports = []) {{ let pending; - listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }}); + listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }}); if (pending) await pending; }} async function dispatchNotificationClick(route) {{ @@ -94,7 +95,7 @@ async function dispatchNotificationClick(route) {{ def test_mobile_install_flow_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v24" in source + assert "stackchain-dashboard-shell-v25" in source assert "BASE + 'static/install-app.js'" in source @@ -121,6 +122,19 @@ def test_authenticated_page_message_resumes_queued_background_delivery(): assert result["backgroundFlushes"] == 1 +def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion(): + result = run_worker_scenario( + """ + const replies = []; + await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]); + process.stdout.write(JSON.stringify({state,replies})); +""" + ) + + assert result["state"]["outboxPurges"] == 1 + assert result["replies"] == [{"ok": True}] + + def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route(): result = run_worker_scenario( """ -- 2.43.0