From 834559bf9e1725a42db15cd8e07186038400d4eb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 07:53:28 -0700 Subject: [PATCH] perf: sanitize rate limit polling intervals (#4197) --- src/main/rate-limits/service.test.ts | 25 +++++++++++++++++++++++++ src/main/rate-limits/service.ts | 11 ++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 5aa1f408eaa..c083514b3cd 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -170,6 +170,31 @@ describe('RateLimitService', () => { expect(secondWindow.listenerCount('closed')).toBe(0) }) + it('sanitizes renderer-provided polling intervals before scheduling timers', () => { + vi.useFakeTimers() + const intervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + const service = new RateLimitService() + + service.setPollingInterval(Number.NaN) + service.start() + expect(intervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 15 * 60 * 1000) + + service.setPollingInterval(Number.MAX_SAFE_INTEGER) + expect(intervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 2_147_483_647) + + service.setPollingInterval(10) + expect(intervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 30_000) + + service.stop() + } finally { + intervalSpy.mockRestore() + vi.useRealTimers() + } + }) + it('keeps recent stale data across repeated failures', async () => { const service = new RateLimitService() const internal = serviceInternals(service) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 51c267f3da5..7bbbde31435 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -38,6 +38,8 @@ type ClaudeAuthPreparationResolver = ( // state is informational, so prefer keeping a recent snapshot over polling it // into 429s during long focused Orca sessions. const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes +const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. +const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open @@ -51,6 +53,13 @@ type InternalRateLimitState = { opencodeGo: ProviderRateLimits | null } +function normalizePollingInterval(ms: number): number { + if (!Number.isFinite(ms)) { + return DEFAULT_POLL_MS + } + return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -498,7 +507,7 @@ export class RateLimitService { } setPollingInterval(ms: number): void { - this.pollInterval = Math.max(30_000, ms) + this.pollInterval = normalizePollingInterval(ms) if (this.timer) { this.stopTimer() this.startTimer()