diff --git a/src/renderer/src/store/plugin-panel-retry-timer-cleanup.test.ts b/src/renderer/src/store/plugin-panel-retry-timer-cleanup.test.ts new file mode 100644 index 00000000000..e2f12dce2c7 --- /dev/null +++ b/src/renderer/src/store/plugin-panel-retry-timer-cleanup.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { PluginHostListEntry } from '../../../preload/api-types' +import type { PluginChangeEvent } from '../../../shared/plugins/plugin-change-event' +import type { usePluginPanelsStore } from './plugin-panels' + +type List = () => Promise +type PanelStore = typeof usePluginPanelsStore + +function plugin(): PluginHostListEntry { + return { + pluginKey: 'orca-samples.current', + consentFingerprint: 'test', + name: 'Current', + version: '1', + publisher: 'test', + status: 'idle', + needsReconsent: false, + isDev: false, + official: false, + bundled: false, + capabilities: [], + panels: [], + commands: [], + hasWorker: false, + restarts: 0 + } +} + +function view(store: PanelStore) { + const { plugins, panelErrors, fetchStatus } = store.getState() + return { plugins, panelErrors, fetchStatus } +} + +async function harness(list: List) { + let changed: ((event: PluginChangeEvent) => void) | undefined + const onChanged = vi.fn((callback: (event: PluginChangeEvent) => void) => { + changed = callback + return vi.fn() + }) + vi.stubGlobal('window', { api: { plugins: { list, onChanged } } }) + const { usePluginPanelsStore: store, ensurePluginPanelsLoaded: ensure } = + await import('./plugin-panels') + return { store, ensure, onChanged, change: () => changed?.({ contentPacksChanged: false }) } +} + +const empty = (fetchStatus: 'loading' | 'error' | 'ready') => ({ + plugins: [], + panelErrors: {}, + fetchStatus +}) + +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(0) +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +it('retires superseded change-event retries and preserves every refresh/state update', async () => { + const failure = new Error('transport unavailable') + const list = vi.fn().mockRejectedValue(failure) + const { store, ensure, change, onChanged } = await harness(list) + const history: ReturnType[] = [] + const unsubscribe = store.subscribe(() => history.push(view(store))) + ensure() + await Promise.resolve() + for (let index = 1; index < 64; index++) { + change() + await Promise.resolve() + } + const pendingBeforeReplacement = vi.getTimerCount() + store.getState().setPlugins([]) + const pendingAfterReplacement = vi.getTimerCount() + await vi.advanceTimersByTimeAsync(500) + expect(list).toHaveBeenCalledTimes(64) + await expect(list.mock.results[0]?.value).rejects.toBe(failure) + expect(onChanged).toHaveBeenCalledOnce() + expect(history).toEqual([ + ...Array.from({ length: 64 }, () => [empty('loading'), empty('error')]).flat(), + empty('ready') + ]) + expect(view(store)).toEqual(empty('ready')) + expect(vi.getTimerCount()).toBe(0) + unsubscribe() + expect([pendingBeforeReplacement, pendingAfterReplacement]).toEqual([1, 0]) +}) + +it('retires a failed-owner timer while a replacement IPC stays pending without cancelling it', async () => { + const gate = Promise.withResolvers() + const list = vi + .fn() + .mockRejectedValueOnce(new Error('starting')) + .mockReturnValueOnce(gate.promise) + const { store } = await harness(list) + await store.getState().fetchPlugins() + const pending = store.getState().fetchPlugins() + let settled = false + void pending.then(() => { + settled = true + }) + const timers = vi.getTimerCount() + await vi.advanceTimersByTimeAsync(500) + expect(settled).toBe(false) + expect(list).toHaveBeenCalledTimes(2) + expect(view(store)).toEqual(empty('loading')) + const rows = [plugin()] + gate.resolve(rows) + await expect(pending).resolves.toBeUndefined() + expect(store.getState().plugins).toBe(rows) + expect(view(store)).toEqual({ plugins: rows, panelErrors: {}, fetchStatus: 'ready' }) + expect(timers).toBe(0) +}) + +it('keeps successful replacement rows and their references after retiring the old error retry', async () => { + const rows = [plugin()] + const list = vi + .fn() + .mockRejectedValueOnce(new Error('starting')) + .mockResolvedValueOnce(rows) + const { store } = await harness(list) + await store.getState().fetchPlugins() + await expect(store.getState().fetchPlugins()).resolves.toBeUndefined() + const timers = vi.getTimerCount() + await vi.advanceTimersByTimeAsync(500) + expect(list).toHaveBeenCalledTimes(2) + expect(store.getState().plugins).toBe(rows) + expect(view(store)).toEqual({ plugins: rows, panelErrors: {}, fetchStatus: 'ready' }) + expect(timers).toBe(0) +}) + +it.each([undefined, null, false])( + 'keeps missing/unsupported bridge fallback for %s while releasing an invalid retry', + async (bridge) => { + const list = vi.fn().mockRejectedValue(new Error('starting')) + const { store } = await harness(list) + await store.getState().fetchPlugins() + vi.stubGlobal('window', { api: { plugins: bridge } }) + await expect(store.getState().fetchPlugins()).resolves.toBeUndefined() + const timers = vi.getTimerCount() + await vi.advanceTimersByTimeAsync(500) + expect(list).toHaveBeenCalledOnce() + expect(view(store)).toEqual(empty('ready')) + expect(timers).toBe(0) + } +) + +it.each(['reject', 'throw'] as const)( + 'keeps the current 500ms retry and original %s error after a second failed request', + async (kind) => { + const failure = new Error('same transport error') + const times: number[] = [] + const list = vi.fn(() => { + times.push(Date.now()) + if (kind === 'throw') { + throw failure + } + return Promise.reject(failure) + }) + const { store } = await harness(list) + await expect(store.getState().fetchPlugins()).resolves.toBeUndefined() + await expect(store.getState().fetchPlugins()).resolves.toBeUndefined() + const timers = vi.getTimerCount() + await vi.advanceTimersByTimeAsync(499) + expect(times).toEqual([0, 0]) + await vi.advanceTimersByTimeAsync(1) + expect(times).toEqual([0, 0, 500]) + expect(view(store)).toEqual(empty('error')) + expect(vi.getTimerCount()).toBe(0) + if (kind === 'throw') { + expect(list.mock.results[0]).toMatchObject({ type: 'throw', value: failure }) + } else { + await expect(list.mock.results[0]?.value).rejects.toBe(failure) + } + expect(timers).toBe(1) + } +) + +it('preserves the complete live 250ms/500ms retry schedule and cap', async () => { + const times: number[] = [] + const failure = new Error('unavailable') + const list = vi.fn(() => { + times.push(Date.now()) + return Promise.reject(failure) + }) + const { store } = await harness(list) + await store.getState().fetchPlugins() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(249) + expect(times).toEqual([0]) + await vi.advanceTimersByTimeAsync(1) + expect(times).toEqual([0, 250]) + await vi.advanceTimersByTimeAsync(499) + expect(times).toEqual([0, 250]) + await vi.advanceTimersByTimeAsync(1) + expect(times).toEqual([0, 250, 750]) + expect(view(store)).toEqual(empty('error')) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(5000) + expect(list).toHaveBeenCalledTimes(3) +}) + +it('keeps an authoritative replacement made reentrantly during loading above the older failure', async () => { + const gate = Promise.withResolvers() + const rows = [plugin()] + const list = vi + .fn() + .mockRejectedValueOnce(new Error('starting')) + .mockReturnValueOnce(gate.promise) + const { store } = await harness(list) + await store.getState().fetchPlugins() + const unsubscribe = store.subscribe((state) => { + if (state.fetchStatus === 'loading') { + store.getState().setPlugins(rows) + } + }) + const pending = store.getState().fetchPlugins() + const failure = new Error('old request failed') + gate.reject(failure) + await expect(pending).resolves.toBeUndefined() + await expect(list.mock.results[1]?.value).rejects.toBe(failure) + await vi.advanceTimersByTimeAsync(500) + expect(list).toHaveBeenCalledTimes(2) + expect(store.getState().plugins).toBe(rows) + expect(view(store)).toEqual({ plugins: rows, panelErrors: {}, fetchStatus: 'ready' }) + expect(vi.getTimerCount()).toBe(0) + unsubscribe() +}) + +it('preserves existing reentrant error replacement and its already-fenced post-set retry', async () => { + const list = vi.fn().mockRejectedValue(new Error('starting')) + const { store } = await harness(list) + const rows = [plugin()] + const unsubscribe = store.subscribe((state) => { + if (state.fetchStatus === 'error') { + store.getState().setPlugins(rows) + } + }) + await store.getState().fetchPlugins() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(250) + expect(list).toHaveBeenCalledOnce() + expect(store.getState().plugins).toBe(rows) + expect(view(store)).toEqual({ plugins: rows, panelErrors: {}, fetchStatus: 'ready' }) + expect(vi.getTimerCount()).toBe(0) + unsubscribe() +}) + +it('retains the latest reentrant fetch and ignores the superseded request result', async () => { + const gate = Promise.withResolvers() + const rows = [plugin()] + const list = vi + .fn() + .mockRejectedValueOnce(new Error('starting')) + .mockResolvedValueOnce(rows) + .mockReturnValueOnce(gate.promise) + const { store } = await harness(list) + await store.getState().fetchPlugins() + let entered = false + let latest: Promise | undefined + const unsubscribe = store.subscribe((state) => { + if (!entered && state.fetchStatus === 'loading') { + entered = true + latest = store.getState().fetchPlugins() + } + }) + const old = store.getState().fetchPlugins() + await latest + const timers = vi.getTimerCount() + gate.reject(new Error('superseded')) + await expect(old).resolves.toBeUndefined() + await vi.advanceTimersByTimeAsync(500) + expect(list).toHaveBeenCalledTimes(3) + expect(store.getState().plugins).toBe(rows) + expect(view(store)).toEqual({ plugins: rows, panelErrors: {}, fetchStatus: 'ready' }) + unsubscribe() + expect(timers).toBe(0) +}) diff --git a/src/renderer/src/store/plugin-panels.ts b/src/renderer/src/store/plugin-panels.ts index 56c168ce46f..5556bc89f52 100644 --- a/src/renderer/src/store/plugin-panels.ts +++ b/src/renderer/src/store/plugin-panels.ts @@ -30,6 +30,13 @@ let pluginListRetryAttempt = 0 let pluginListRetryTimer: ReturnType | null = null const PLUGIN_LIST_MAX_RETRIES = 2 +function clearPluginListRetryTimer(): void { + if (pluginListRetryTimer) { + clearTimeout(pluginListRetryTimer) + pluginListRetryTimer = null + } +} + function schedulePluginListRetry(generation: number): void { if (pluginListRetryAttempt >= PLUGIN_LIST_MAX_RETRIES) { pluginListRetryAttempt = 0 @@ -52,6 +59,7 @@ export const usePluginPanelsStore = create()((set) => ({ fetchStatus: 'idle', fetchPlugins: async () => { const generation = ++pluginListGeneration + clearPluginListRetryTimer() // Why: preload may predate the plugins namespace (web client pairing an // older desktop build); treat a missing bridge as "no plugins" fail-soft. const pluginsApi = window.api?.plugins @@ -86,10 +94,7 @@ export const usePluginPanelsStore = create()((set) => ({ setPlugins: (plugins) => { pluginListGeneration += 1 pluginListRetryAttempt = 0 - if (pluginListRetryTimer) { - clearTimeout(pluginListRetryTimer) - pluginListRetryTimer = null - } + clearPluginListRetryTimer() set((state) => ({ plugins, fetchStatus: 'ready',