nav resilience after pool restart, thinner marker rings, drop dead marker popup
This commit is contained in:
@@ -7,11 +7,18 @@ import { pluginsApi } from '../api'
|
||||
let enabledPromise = null
|
||||
let enabledSet = null // resolved Set<string>, 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
|
||||
}
|
||||
|
||||
74
frontend/src/composables/enabledPlugins.spec.js
Normal file
74
frontend/src/composables/enabledPlugins.spec.js
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user