diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 0f07ad3878..93601110d9 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -62,6 +62,13 @@ function provider(id: string, usedPercent = 20): ProviderUsageSnapshot { }; } +function providerResettingAt(id: string, resetsAt: string): ProviderUsageSnapshot { + const snapshot = provider(id); + return { ...snapshot, primary: { ...snapshot.primary, resetsAt } }; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; + function emitProviderEvent(event: string, payload: unknown) { for (const listener of eventMocks.listeners.get(event) ?? []) { listener({ payload }); @@ -403,6 +410,75 @@ describe("useProviders", () => { } }); + it("refreshes once the soonest reset has passed", async () => { + vi.useFakeTimers({ now: new Date("2026-10-01T00:00:00Z") }); + try { + tauriMocks.getCachedProviders.mockResolvedValue([ + providerResettingAt("codex", "2026-10-01T00:10:00Z"), + ]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await act(async () => {}); + expect(result.current.providers).toHaveLength(1); + + // The timer waits one second past the reset. + await act(async () => { + vi.advanceTimersByTime(10 * 60_000); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1_000); + }); + expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("waits for a reset beyond the 32-bit timer limit instead of refreshing at once", async () => { + vi.useFakeTimers({ now: new Date("2026-10-01T00:00:00Z") }); + try { + // 30 days is past the 2^31 - 1 ms (about 24.8 days) that setTimeout can + // hold. A longer delay runs at once, which forced a refresh on every + // provider update. + tauriMocks.getCachedProviders.mockResolvedValue([ + providerResettingAt("cursor", "2026-10-31T00:00:00Z"), + ]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await act(async () => {}); + expect(result.current.providers).toHaveLength(1); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + // A fresh snapshot with the same reset re-arms the timer; it must still wait. + act(() => { + emitProviderEvent("provider-updated", providerResettingAt("cursor", "2026-10-31T00:00:00Z")); + }); + await act(async () => { + vi.advanceTimersByTime(80); + }); + await act(async () => { + vi.advanceTimersByTime(25 * DAY_MS); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(5 * DAY_MS - 60_080); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1_000); + }); + expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it("clears each provider from refresh state as it completes", async () => { const { result } = renderHook(() => useProviders({ refreshOnMount: false }), diff --git a/apps/desktop-tauri/src/hooks/useProviders.ts b/apps/desktop-tauri/src/hooks/useProviders.ts index 00cf92c8f5..7eff36af3c 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.ts +++ b/apps/desktop-tauri/src/hooks/useProviders.ts @@ -11,6 +11,13 @@ import { refreshProvidersIfStale, } from "../lib/tauri"; +/** + * Longest delay `setTimeout` can hold: browsers, WebView2 included, store it + * as a signed 32-bit integer. A longer delay wraps around and fires early: + * a reset 24.9 to 49.7 days away would refresh at once. + */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; + export interface UseProvidersOptions { /** * Delay the automatic stale-aware refresh on mount. Tray/menu surfaces use @@ -276,11 +283,20 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes if (nextReset === undefined) return; - const delay = Math.max(5_000, nextReset - now + 1_000); - resetRefreshTimerRef.current = window.setTimeout(() => { - resetRefreshTimerRef.current = undefined; - refresh(); - }, delay); + const resetAt = nextReset; + const armResetRefresh = () => { + const remaining = resetAt - Date.now() + 1_000; + if (remaining > MAX_TIMER_DELAY_MS) { + // Too far away for one timer: wait the longest delay, then re-check. + resetRefreshTimerRef.current = window.setTimeout(armResetRefresh, MAX_TIMER_DELAY_MS); + return; + } + resetRefreshTimerRef.current = window.setTimeout(() => { + resetRefreshTimerRef.current = undefined; + refresh(); + }, Math.max(5_000, remaining)); + }; + armResetRefresh(); return () => { if (resetRefreshTimerRef.current !== undefined) {