From de9b018e5d723e93ec2e447e626118082aa07932 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:38:53 +0700 Subject: [PATCH] Fix reset refresh timer for resets over 24.8 days away useProviders armed one setTimeout for the soonest provider reset and then called the forced refreshProviders(). WebView2 stores a timer delay as a signed 32-bit integer, so a reset 24.9 to 49.7 days away wrapped to a negative delay and refreshed at once, and every snapshot merge re-armed it. A monthly plan early in its cycle is enough to hit this. The hook now waits at most 2,147,483,647 ms per timer and re-arms with the remaining time until the reset is close enough. Nearer resets keep the old timing: one second after the reset, at least five seconds out. Tests cover the normal reset refresh and a 30-day reset, which refreshed at once before this change. --- .../src/hooks/useProviders.test.tsx | 76 +++++++++++++++++++ apps/desktop-tauri/src/hooks/useProviders.ts | 26 +++++-- 2 files changed, 97 insertions(+), 5 deletions(-) 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) {