From d724e1338a5a732b3822b1d58fbc03f3111710ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 21:18:09 +0000 Subject: [PATCH] Halt background work when a newer build is detected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now, the only thing that happened on `onNeedRefresh` was that `updateState.available` flipped to true and the UpdateBanner appeared. Every background worker on the tab kept running — lease heartbeats, embedding generation, journaling, samskara, wiki, the librarian, plus the hourly usage poll — against code that was about to be replaced. On a long-lived tab the gap between "banner visible" and "user clicks Reload" can be tens of minutes, which means a non-trivial slice of background processing happens on a build the rest of the deploy has moved past. state.svelte.ts gains a single `haltBackgroundWork()` entry point and a module-scoped `workersHalted` latch. The halt extracts the manager.stop() + stopUsagePolling() sequence that `lock()` already ran inline into a shared `stopBackgroundWorkers()` helper, so the two teardown sites can't drift, and gates `startBackgroundWorkers()` on the latch so a settings fetch resolving after the SW update can't fire workers back up. update.svelte.ts calls `haltBackgroundWork()` from `onNeedRefresh` right after setting `updateState.available = true`. The update poller itself stays in update.svelte.ts and is not in the worker list — it's what announced the new build, so it has to keep running. The chat loop is user-initiated and continues; users get to finish their turn before the banner asks them to reload. The latch is one-way. The only thing that clears it is the page reload `applyUpdate()` performs, which is the correct semantic: once we know we're stale, sign-out + sign-in inside the same page session shouldn't quietly resume processing on the old code. --- src/lib/state.svelte.ts | 62 ++++++++++++++++++++++++++++++++-------- src/lib/update.svelte.ts | 9 ++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index 58aeb3f..2f90fc8 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -700,8 +700,21 @@ export function applyServerSettings(s: UserSettings): void { setSystemPrompts(s.systemPrompts ?? []); } +// One-way latch flipped by `haltBackgroundWork()` when a newer build is +// detected. Module-scoped (not on the reactive `app` object) because it +// must survive a lock/unlock cycle - if the user signs out and back in +// after the update banner appears, we still don't want to fire fresh +// workers against soon-to-be-stale code. The page reload through +// `applyUpdate()` is the only thing that clears it. +let workersHalted = false; + function startBackgroundWorkers(config: AppConfig): void { if (!app.supabase) return; + // A newer build was announced (see `haltBackgroundWork()` below) - + // don't fire workers that we'd just tear down. This covers the race + // where `onNeedRefresh` lands between `activate()` and the settings + // fetch that gates worker startup. + if (workersHalted) return; // Each manager acquires a cross-tab lock before spawning its // worker, so another tab holding the lock will make these calls // hang internally - they're fire-and-forget by design, never @@ -818,15 +831,19 @@ async function loadSettingsThenStartWorkers(config: AppConfig): Promise { startBackgroundWorkers(config); } -export function lock(): void { - // Tear all workers down before clearing services — each manager - // releases its Web Lock here so a queued tab can take over as soon - // as we're gone. Order doesn't matter; the locks are independent. - // Each handle's stop() is a no-op when start() never fired - // (module never loaded) and dispatches through the captured - // import Promise otherwise - so a sign-out that races a still- - // loading manager chunk still runs the teardown when the chunk - // lands. +/** + * Tear down every background worker plus the usage poller. Each + * manager handle's stop() is a no-op when start() never fired (module + * never loaded) and dispatches through the captured import Promise + * otherwise - so a teardown that races a still-loading manager chunk + * still runs when the chunk lands. Order doesn't matter; the locks are + * independent. + * + * Used by `lock()` (sign-out releases each Web Lock so a queued tab + * can take over) and by `haltBackgroundWork()` (newer build detected, + * stop processing on the old code until the user reloads). + */ +function stopBackgroundWorkers(): void { embeddings.stop(); reflection.stop(); summary.stop(); @@ -835,10 +852,31 @@ export function lock(): void { journal.stop(); wiki.stop(); wikiLibrarian.stop(); - // Tear down the usage poller and wipe the cache so rows billed - // against the previous API key don't leak into a subsequent - // unlock-with-different-config. stopUsagePolling(); +} + +/** + * Halt all background work because a newer build is waiting. Called + * from `update.svelte.ts::onNeedRefresh` once the SW reports a waiting + * version. Latches the `workersHalted` flag so that a subsequent + * sign-out + sign-in inside the same page session cannot accidentally + * fire workers back up on stale code - the only way out of the halted + * state is the page reload that `applyUpdate()` performs. + * + * The update poller itself lives in update.svelte.ts and is not in the + * worker list - it's what's announcing the new build, so it has to + * keep running. Same for the in-flight chat loop: it's user-initiated, + * not background, and the user gets to finish their turn before the + * update banner asks them to reload. + */ +export function haltBackgroundWork(): void { + if (workersHalted) return; + workersHalted = true; + stopBackgroundWorkers(); +} + +export function lock(): void { + stopBackgroundWorkers(); app.config = null; app.supabase = null; app.venice = null; diff --git a/src/lib/update.svelte.ts b/src/lib/update.svelte.ts index 4570799..cb94cc3 100644 --- a/src/lib/update.svelte.ts +++ b/src/lib/update.svelte.ts @@ -34,6 +34,7 @@ import { registerSW } from 'virtual:pwa-register'; import { createLogger } from './logger.svelte'; +import { haltBackgroundWork } from './state.svelte'; const log = createLogger('update'); @@ -123,6 +124,14 @@ export function initUpdateWatcher(): void { controller: !!navigator.serviceWorker?.controller, }); updateState.available = true; + // A newer build is waiting. Halt every background worker on + // this tab - they'd otherwise keep processing leases against + // soon-to-be-stale code until the user clicks Reload. The + // halt is one-way; the page reload through `applyUpdate()` is + // what clears it. The update poller itself keeps running - + // it's what flipped `available`, and it stays in + // update.svelte.ts so the halt doesn't touch it. + haltBackgroundWork(); }, onRegisteredSW(swUrl, registration) { // Routine SW-registered confirmation - fires on every load; the