From 791082bae202f2c545a5763ecfaf5b79c4ac2e89 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 20:44:26 +0000 Subject: [PATCH] fix: resume background delivery after purge (#425) --- frontend/background-issue-sync.js | 20 ++++++++-- frontend/service-worker.js | 7 +++- tests/test_background_issue_sync.py | 45 +++++++++++++++++++++++ tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 40 ++++++++++++++------ tests/test_today_sync.py | 2 +- 9 files changed, 99 insertions(+), 23 deletions(-) diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 4f59695..c43ed48 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -205,6 +205,7 @@ function createBackgroundIssueSync({ batch = work => work(), requestTimeoutMs = 15000, }) { let purgeRequested = false; + let activePurge = null; let activeFlush = null; const activeRequests = new Set(); const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000); @@ -422,15 +423,26 @@ function createBackgroundIssueSync({ return activeFlush; } - async function purge() { + function purge() { + if (activePurge) return activePurge; purgeRequested = true; activeRequests.forEach(controller => controller.abort()); - if (activeFlush) await activeFlush.catch(() => {}); - await store.close?.(); + activePurge = (async () => { + if (activeFlush) await activeFlush.catch(() => {}); + await store.close?.(); + })(); + return activePurge; + } + + async function resume() { + const pendingPurge = activePurge; + if (pendingPurge) await pendingPurge; + if (activePurge === pendingPurge) activePurge = null; + purgeRequested = false; } return { - flush, send, purge, + flush, send, purge, resume, 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 440c269..b1308ac 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-v69'; +const CACHE = 'stackchain-dashboard-shell-v70'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -217,7 +217,10 @@ self.addEventListener('sync', event => { }); self.addEventListener('message', event => { - if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify()); + if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => { + await issueSync.resume(); + await flushAndNotify(); + })()); if (event.data?.type === 'stackchain-session-lease') { event.waitUntil(storeOfflineLease(event.data.expiresAt)); } diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 7a961cd..5e2cd7c 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -811,6 +811,51 @@ const transaction=work=>{{const run=tail.then(()=>work({{ assert "error" not in output +def test_resume_after_purge_reopens_background_delivery_with_fresh_identity(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const state={{closed:0,identityLookups:0,mutations:0,keys:[]}}; +let queued={{ + id:'capture-after-login',ownerLogin:'timmy',status:'queued',repository:'stackchain/dashboard', + title:'Recovered capture',body:'Evidence',labelIds:[],operationId:'op-after-login', +}}; +const store={{ + close:async()=>{{state.closed+=1;}}, + claimBatch:async login=>{{ + if(queued?.ownerLogin!==login)return []; + const claimed=queued;queued=null;return [claimed]; + }}, + complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0, +}}; +const fetchJson=async(url,options={{}})=>{{ + if(url==='api/v1/background-identity'){{state.identityLookups+=1;return {{login:'timmy'}};}} + state.mutations+=1;state.keys.push(options.headers['Idempotency-Key']); + return {{repository:'stackchain/dashboard',number:425,title:'Recovered capture'}}; +}}; +(async()=>{{ + const sync=createBackgroundIssueSync({{store,fetchJson}}); + await sync.purge(); + const blocked=await sync.flush(); + await sync.resume(); + const delivered=await sync.flush(); + process.stdout.write(JSON.stringify({{state,blocked,delivered}})); +}})(); +""" + output = run_node(script) + + assert output["state"] == { + "closed": 1, + "identityLookups": 1, + "mutations": 1, + "keys": ["op-after-login"], + } + assert output["blocked"]["login"] == "" + assert output["delivered"]["login"] == "timmy" + assert output["delivered"]["confirmed"] == [ + {"repository": "stackchain/dashboard", "number": 425, "title": "Recovered capture"} + ] + + @pytest.mark.anyio async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch): calls = 0 diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 26a549c..71cb273 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-v69" in source + assert "stackchain-dashboard-shell-v70" 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 d496471..4b7cf39 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-v69" in worker + assert "stackchain-dashboard-shell-v70" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 5af9149..76e788c 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-v69" in worker + assert "stackchain-dashboard-shell-v70" in worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 2025db4..b4b6ee4 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 052c1b5..0ca9c9e 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, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -47,8 +47,9 @@ 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; }}, + flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }}, + purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }}, + resume: async () => {{ state.backgroundResumes += 1; state.outboxLifecycle.push('resume'); }}, getReceiptPreference: async login => state.receiptLogin === login, }}, }}, @@ -121,7 +122,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -130,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -138,7 +139,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -146,14 +147,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-v69" in source + assert "stackchain-dashboard-shell-v70" 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-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -162,21 +163,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-v69" in source + assert "stackchain-dashboard-shell-v70" 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-v69" in source + assert "stackchain-dashboard-shell-v70" 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-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/update-ownership.js'" in source @@ -240,6 +241,21 @@ def test_authenticated_page_message_resumes_queued_background_delivery(): assert result["backgroundFlushes"] == 1 +def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing(): + result = run_worker_scenario( + """ + const replies = []; + await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:value=>replies.push(value)}]); + await dispatchMessage({type:'stackchain-resume-outbox'}); + process.stdout.write(JSON.stringify({state,replies})); +""" + ) + + assert result["replies"] == [{"ok": True}] + assert result["state"]["outboxLifecycle"] == ["purge", "resume", "flush"] + assert result["state"]["backgroundResumes"] == 1 + + def test_background_mutation_abort_also_cancels_stalled_csrf_lookup(): result = run_worker_scenario( """ @@ -342,7 +358,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 37ae5a9..0fd8f3f 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-v69" in source + assert "stackchain-dashboard-shell-v70" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0