From a7fe2c83538450956465695ec5dd3788163c5dd2 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 31 Jul 2026 10:10:51 -0400 Subject: [PATCH] fix: navigation dying after an app-pool restart Two independent ways a restart leaves the SPA unable to navigate, both of which look identical to a user - a click that does nothing. 1. The router awaits loadEnabledPlugins() to gate plugin routes. An app-pool restart leaves that request hanging (IIS queues it while the worker starts) and axios sets no timeout, so the navigation never resolves. Worse, the promise is cached, so every later navigation awaited the same dead request and stayed frozen long after the backend recovered. Bound the wait and fail open on expiry, and drop the cached promise when an attempt times out or fails so the next navigation retries. The setup-state probe in the guard gets the same bound (it already fails open, defaulting to "complete"). 2. A deploy replaces the content-hashed chunk files, so a tab open across it asks for chunks that no longer exist and the dynamic import rejects with nothing handling it. Reload once on a chunk-load error, via router.onError and Vite's preloadError, guarded by a sessionStorage flag against a reload loop and cleared on the next successful navigation. Also stop index.html being cached: it names the hashed chunks, so a stale copy points at files the deploy already deleted. It now revalidates while the hashed assets under assets/ cache for a year. --- frontend/src/composables/enabledPlugins.js | 22 +++++- .../src/composables/enabledPlugins.spec.js | 74 +++++++++++++++++++ frontend/src/router/index.js | 43 ++++++++++- shopdb/__init__.py | 16 +++- 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 frontend/src/composables/enabledPlugins.spec.js diff --git a/frontend/src/composables/enabledPlugins.js b/frontend/src/composables/enabledPlugins.js index b95e2a3..40026fc 100644 --- a/frontend/src/composables/enabledPlugins.js +++ b/frontend/src/composables/enabledPlugins.js @@ -7,11 +7,18 @@ import { pluginsApi } from '../api' let enabledPromise = null let enabledSet = null // resolved Set, or null while pending / on failure +// The router AWAITS this, so it must never outlast a navigation. An app-pool +// restart leaves requests hanging (IIS queues them while the worker starts) and +// axios has no timeout, so an unbounded wait here froze navigation outright - +// and because the promise was cached, every later navigation awaited the same +// hung request and stayed frozen after the backend recovered. +const GATE_TIMEOUT_MS = 4000 + // Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this // works on unauthenticated kiosk routes (e.g. /tv) too. export function loadEnabledPlugins() { if (!enabledPromise) { - enabledPromise = pluginsApi.enabled() + const fetched = pluginsApi.enabled() .then(response => { const names = response?.data?.data // Only trust a real array; anything else -> fail open (null). @@ -23,6 +30,19 @@ export function loadEnabledPlugins() { enabledSet = null return null }) + + // Bounded wait, fail-open on expiry. A timed-out or failed attempt drops the + // cached promise so the NEXT navigation retries instead of inheriting a dead + // one; a request that lands late still populates enabledSet. + let timer + const bounded = new Promise(resolve => { + timer = setTimeout(() => resolve(null), GATE_TIMEOUT_MS) + }) + enabledPromise = Promise.race([fetched, bounded]).then(result => { + clearTimeout(timer) + if (result === null) enabledPromise = null + return result + }) } return enabledPromise } diff --git a/frontend/src/composables/enabledPlugins.spec.js b/frontend/src/composables/enabledPlugins.spec.js new file mode 100644 index 0000000..074be66 --- /dev/null +++ b/frontend/src/composables/enabledPlugins.spec.js @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const enabled = vi.fn() +vi.mock('../api', () => ({ pluginsApi: { enabled: (...args) => enabled(...args) } })) + +const { loadEnabledPlugins, isPluginEnabled, resetEnabledPlugins } = + await import('./enabledPlugins') + +beforeEach(() => { + resetEnabledPlugins() + enabled.mockReset() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('loadEnabledPlugins', () => { + it('resolves the enabled set and gates on it', async () => { + enabled.mockResolvedValue({ data: { data: ['machines', 'printers'] } }) + + await loadEnabledPlugins() + + expect(isPluginEnabled('machines')).toBe(true) + expect(isPluginEnabled('usb')).toBe(false) + }) + + it('fails open on error', async () => { + enabled.mockRejectedValue(new Error('boom')) + + await loadEnabledPlugins() + + expect(isPluginEnabled('usb')).toBe(true) + }) + + it('does not hang the router when the request never settles', async () => { + // What an app-pool restart looks like: IIS queues the request while the + // worker starts, and axios has no timeout. + enabled.mockReturnValue(new Promise(() => {})) + + const pending = loadEnabledPlugins() + await vi.advanceTimersByTimeAsync(5000) + + await expect(pending).resolves.toBeNull() + expect(isPluginEnabled('usb')).toBe(true) + }) + + it('retries on the next navigation after a hung request', async () => { + enabled.mockReturnValueOnce(new Promise(() => {})) + + const first = loadEnabledPlugins() + await vi.advanceTimersByTimeAsync(5000) + await first + + // The dead promise must not be cached, or every later navigation inherits + // it and stays frozen even once the backend is back. + enabled.mockResolvedValueOnce({ data: { data: ['machines'] } }) + await loadEnabledPlugins() + + expect(enabled).toHaveBeenCalledTimes(2) + expect(isPluginEnabled('machines')).toBe(true) + expect(isPluginEnabled('usb')).toBe(false) + }) + + it('fetches once while the request is healthy', async () => { + enabled.mockResolvedValue({ data: { data: ['machines'] } }) + + await loadEnabledPlugins() + await loadEnabledPlugins() + + expect(enabled).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2040ca3..03c7cbb 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -156,6 +156,42 @@ router.afterEach(async (to) => { document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle }) +// A deploy replaces the hashed chunk files, so a tab that was open across it +// still asks for chunks that no longer exist. The dynamic import rejects, the +// navigation aborts, and to the user the site simply stops navigating. Reload +// once to pick up the new index.html; the sessionStorage flag stops a reload +// loop if the chunk is missing for any other reason. +const CHUNK_RELOAD_KEY = 'shopdb-chunk-reloaded' + +function isChunkLoadError(error) { + const text = `${error?.message || ''} ${error?.name || ''}` + return /dynamically imported module|Importing a module script failed|ChunkLoadError|Loading chunk/i.test(text) +} + +function reloadOnceForStaleChunk(error) { + if (!isChunkLoadError(error)) return false + if (sessionStorage.getItem(CHUNK_RELOAD_KEY)) return false + sessionStorage.setItem(CHUNK_RELOAD_KEY, '1') + window.location.reload() + return true +} + +router.onError(error => { + reloadOnceForStaleChunk(error) +}) + +// Vite raises this for a failed module preload, which does not always surface +// as a router error. +window.addEventListener('vite:preloadError', event => { + if (reloadOnceForStaleChunk(event?.payload || event)) event.preventDefault?.() +}) + +// A navigation that completes proves the current chunks are good, so clear the +// flag - otherwise the one allowed reload is spent for the rest of the session. +router.afterEach(() => { + sessionStorage.removeItem(CHUNK_RELOAD_KEY) +}) + // Navigation guard router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() @@ -197,7 +233,12 @@ router.beforeEach(async (to, from, next) => { // First-run: steer a fresh admin into the setup wizard (once, until finished). if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') { if (!isSetupLoaded()) { - await refreshSetupState() + // Bounded like the plugin gate: a hung request during an app-pool restart + // must not hold the navigation open. Its own catch already fails open. + await Promise.race([ + refreshSetupState(), + new Promise(resolve => setTimeout(resolve, 4000)), + ]) } if (!setupComplete.value && !setupSkipped.value) { return next('/setup') diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 760a250..08e3a1b 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -244,11 +244,23 @@ def register_frontend_routes(app: Flask): # (the probe was a path-traversal risk surface). if path: try: - return send_from_directory(frontend_dist, path) + response = send_from_directory(frontend_dist, path) + # Asset filenames carry a content hash, so a given URL never + # changes - cache them hard. Everything else stays revalidated. + if path.startswith('assets/'): + response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + else: + response.headers['Cache-Control'] = 'no-cache' + return response except Exception: pass - return send_from_directory(frontend_dist, 'index.html') + # index.html names the hashed chunks, so a stale copy points at files a + # deploy has already deleted and the SPA stops navigating. Always + # revalidate it. + response = send_from_directory(frontend_dist, 'index.html') + response.headers['Cache-Control'] = 'no-cache' + return response def configure_logging(app: Flask):