From dd9e84a0dbefad6066193b6bd468689a755a02e3 Mon Sep 17 00:00:00 2001 From: Draculabo Date: Wed, 28 Jan 2026 12:58:35 +0800 Subject: [PATCH] feat: implement smart foreground quota refresh with debounce - Trigger immediate quota poll when main window gains focus. - Add 10s debounce to prevent API spam on frequent window switching. - Add concurrency guard to prevent overlapping poll requests. - Reset background polling interval after manual refresh. - Add unit tests for polling, debounce, and concurrency logic. --- src/main.ts | 4 + src/services/CloudMonitorService.ts | 136 ++++++++++++++----- src/tests/unit/cloud-monitor.test.ts | 194 +++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 35 deletions(-) create mode 100644 src/tests/unit/cloud-monitor.test.ts diff --git a/src/main.ts b/src/main.ts index 18b9622..9cfb66b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -144,6 +144,10 @@ function createWindow() { const { level, message, lineNumber, sourceId } = details; logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`); }); + + mainWindow.on('focus', () => { + CloudMonitorService.handleAppFocus(); + }); } app.on('child-process-gone', (event, details) => { diff --git a/src/services/CloudMonitorService.ts b/src/services/CloudMonitorService.ts index a916042..4aa2303 100644 --- a/src/services/CloudMonitorService.ts +++ b/src/services/CloudMonitorService.ts @@ -6,17 +6,28 @@ import { logger } from '../utils/logger'; export class CloudMonitorService { private static intervalId: NodeJS.Timeout | null = null; private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes + private static DEBOUNCE_TIME = 10000; // 10 seconds + private static lastFocusTime: number = 0; + private static isPolling: boolean = false; + + // Helper for testing + static resetStateForTesting() { + this.lastFocusTime = 0; + this.isPolling = false; + this.stop(); + } static start() { if (this.intervalId) return; logger.info('Starting CloudMonitorService...'); + // Set lastFocusTime to now to prevent "double-dip" on startup (focus event immediately after start) + this.lastFocusTime = Date.now(); + // Initial Poll this.poll().catch((e) => logger.error('Initial poll failed', e)); - this.intervalId = setInterval(() => { - this.poll().catch((e) => logger.error('Scheduled poll failed', e)); - }, this.POLL_INTERVAL); + this.startInterval(); } static stop() { @@ -27,40 +38,95 @@ export class CloudMonitorService { } } - static async poll() { - logger.info('CloudMonitor: Polling quotas...'); - const accounts = await CloudAccountRepo.getAccounts(); - const now = Math.floor(Date.now() / 1000); + /** + * Called when the application window gains focus. + * Triggers an immediate poll if not rate-limited by debounce. + */ + static async handleAppFocus() { + const now = Date.now(); - for (const account of accounts) { - try { - // 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety) - let accessToken = account.token.access_token; - if (account.token.expiry_timestamp < now + 600) { - logger.info(`Monitor: Refreshing token for ${account.email}`); - const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token); - account.token.access_token = newToken.access_token; - account.token.expires_in = newToken.expires_in; - account.token.expiry_timestamp = now + newToken.expires_in; - await CloudAccountRepo.updateToken(account.id, account.token); - accessToken = newToken.access_token; - } - - // 2. Fetch Quota - // We delay slightly between requests to act human/avoid spike - await new Promise((r) => setTimeout(r, 1000)); - const quota = await GoogleAPIService.fetchQuota(accessToken); - - // 3. Update DB - await CloudAccountRepo.updateQuota(account.id, quota); - await CloudAccountRepo.updateLastUsed(account.id); - } catch (error) { - logger.error(`Monitor: Failed to update ${account.email}`, error); - // Could mark status as 'error' or 'rate_limited' if 429 - } + // 1. Concurrency Guard: If we are already polling, don't pile up requests + if (this.isPolling) { + logger.info('Monitor: App focused, but polling is already in progress. Skipping.'); + return; } - // 4. Check for Auto-Switch - await AutoSwitchService.checkAndSwitchIfNeeded(); + // 2. Debounce: If we focused recently, don't poll again + if (now - this.lastFocusTime < this.DEBOUNCE_TIME) { + logger.info('Monitor: App focused, skipping poll (debounce active).'); + return; + } + + logger.info('Monitor: App focused, triggering immediate poll...'); + this.lastFocusTime = now; + + // 3. Trigger Poll + await this.poll().catch((e) => { + logger.error('Monitor: Focus poll failed', e); + }); + // 4. Reset the background interval so we don't double-poll shortly after + this.resetInterval(); + } + + private static startInterval() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + this.intervalId = setInterval(() => { + this.poll().catch((e) => logger.error('Scheduled poll failed', e)); + }, this.POLL_INTERVAL); + } + + private static resetInterval() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.startInterval(); // Restart the 5-minute timer + } + } + + static async poll() { + if (this.isPolling) { + return; // Extra safety + } + this.isPolling = true; + + try { + logger.info('CloudMonitor: Polling quotas...'); + const accounts = await CloudAccountRepo.getAccounts(); + const now = Math.floor(Date.now() / 1000); + + for (const account of accounts) { + try { + // 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety) + let accessToken = account.token.access_token; + if (account.token.expiry_timestamp < now + 600) { + logger.info(`Monitor: Refreshing token for ${account.email}`); + const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token); + account.token.access_token = newToken.access_token; + account.token.expires_in = newToken.expires_in; + account.token.expiry_timestamp = now + newToken.expires_in; + await CloudAccountRepo.updateToken(account.id, account.token); + accessToken = newToken.access_token; + } + + // 2. Fetch Quota + // We delay slightly between requests to act human/avoid spike + await new Promise((r) => setTimeout(r, 1000)); + const quota = await GoogleAPIService.fetchQuota(accessToken); + + // 3. Update DB + await CloudAccountRepo.updateQuota(account.id, quota); + await CloudAccountRepo.updateLastUsed(account.id); + } catch (error) { + logger.error(`Monitor: Failed to update ${account.email}`, error); + // Could mark status as 'error' or 'rate_limited' if 429 + } + } + + // 4. Check for Auto-Switch + await AutoSwitchService.checkAndSwitchIfNeeded(); + } finally { + this.isPolling = false; + } } } diff --git a/src/tests/unit/cloud-monitor.test.ts b/src/tests/unit/cloud-monitor.test.ts new file mode 100644 index 0000000..3b7d1cf --- /dev/null +++ b/src/tests/unit/cloud-monitor.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CloudMonitorService } from '../../services/CloudMonitorService'; +import { CloudAccountRepo } from '../../ipc/database/cloudHandler'; +import { GoogleAPIService } from '../../services/GoogleAPIService'; +import { AutoSwitchService } from '../../services/AutoSwitchService'; +import { logger } from '../../utils/logger'; + +// Mock dependencies +vi.mock('../../ipc/database/cloudHandler'); +vi.mock('../../services/GoogleAPIService'); +vi.mock('../../services/AutoSwitchService'); +vi.mock('../../utils/logger'); + +describe('CloudMonitorService', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + CloudMonitorService.resetStateForTesting(); + }); + + afterEach(() => { + CloudMonitorService.stop(); + vi.useRealTimers(); + }); + + it('should start polling on start() and set up interval', async () => { + const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined); + + CloudMonitorService.start(); + + // Should call initial poll + expect(pollSpy).toHaveBeenCalledTimes(1); + + // Fast forward 5 minutes + await vi.advanceTimersByTimeAsync(1000 * 60 * 5); + expect(pollSpy).toHaveBeenCalledTimes(2); + + pollSpy.mockRestore(); + }); + + it('should poll accounts correctly', async () => { + const mockAccounts = [ + { + id: 'acc1', + email: 'test@example.com', + token: { access_token: 'valid_token', expiry_timestamp: Date.now() / 1000 + 3600 }, + }, + ]; + + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never); + vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never); + + // Start poll but don't await immediately, as it pauses + const pollPromise = CloudMonitorService.poll(); + + // Advance time to pass the 1s sleep + await vi.advanceTimersByTimeAsync(1000); + + // Now await + await pollPromise; + + expect(CloudAccountRepo.getAccounts).toHaveBeenCalled(); + expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token'); + expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything()); + expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1'); + expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled(); + }); + + it('should refresh token if expired during poll', async () => { + const mockAccounts = [ + { + id: 'acc1', + email: 'expired@example.com', + token: { + access_token: 'old_token', + refresh_token: 'ref_token', + expiry_timestamp: Math.floor(Date.now() / 1000) - 100, // Expired + }, + }, + ]; + + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never); + vi.mocked(GoogleAPIService.refreshAccessToken).mockResolvedValue({ + access_token: 'new_token', + expires_in: 3600, + token_type: 'Bearer', + }); + vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never); + + // Same async pattern + const pollPromise = CloudMonitorService.poll(); + await vi.advanceTimersByTimeAsync(1000); + await pollPromise; + + expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token'); + expect(CloudAccountRepo.updateToken).toHaveBeenCalled(); + expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token'); + }); + + describe('handleAppFocus (Smart Refresh)', () => { + it('should trigger poll when focused after debounce time', async () => { + const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined); + + CloudMonitorService.start(); + vi.setSystemTime(Date.now() + 20000); + + await CloudMonitorService.handleAppFocus(); + + // Called once by start, once by focus + expect(pollSpy).toHaveBeenCalledTimes(2); + }); + + it('should NOT trigger poll if debounced (focused too soon)', async () => { + const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined); + + CloudMonitorService.start(); + + vi.setSystemTime(Date.now() + 1000); + + await CloudMonitorService.handleAppFocus(); + + // Called once by start, 0 by focus + expect(pollSpy).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('debounce active')); + }); + + it('should NOT trigger poll if already polling (concurrency guard)', async () => { + // Let's spy on poll to silence start's poll + const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined); + CloudMonitorService.start(); + pollSpy.mockRestore(); // Restore so we can test the real guard logic + + vi.setSystemTime(Date.now() + 20000); + + // 2. Mock getAccounts to delay + let resolveGetAccounts: (value: unknown) => void; + const getAccountsPromise = new Promise((resolve) => { + resolveGetAccounts = resolve; + }); + vi.mocked(CloudAccountRepo.getAccounts).mockImplementation(() => getAccountsPromise as never); + + // 3. Trigger first focus -> starts poll, hangs at getAccounts or sleep + // Actually poll() calls getAccounts first thing. + const p1 = CloudMonitorService.handleAppFocus(); + + // Allow p1 to start execution and enter poll() + // We invoke one run loop + // But since everything is sync until first await, it should enter poll, set isPolling=true, call getAccounts, and await. + + // 4. Trigger second focus immediately + const p2 = CloudMonitorService.handleAppFocus(); + + // 5. Release the first poll + resolveGetAccounts!([ + { id: '1', token: { access_token: 'tok', expiry_timestamp: 9999999999 } }, + ]); + + // Advance timer for the 1s sleep inside poll + await vi.advanceTimersByTimeAsync(1000); + + await Promise.all([p1, p2]); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('polling is already in progress'), + ); + // getAccounts should be called once (by the first unblocked poll) + // The second one blocked by guard before calling poll + expect(CloudAccountRepo.getAccounts).toHaveBeenCalledTimes(1); + }); + + it('should reset interval after successful focus poll', async () => { + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + const setIntervalSpy = vi.spyOn(global, 'setInterval'); + + // Spy on poll to consume the start() call + const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined); + CloudMonitorService.start(); + pollSpy.mockRestore(); // Restore real poll + + vi.setSystemTime(Date.now() + 20000); + + // Needs to handle async poll + vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]); + + const focusPromise = CloudMonitorService.handleAppFocus(); + await vi.advanceTimersByTimeAsync(1000); // For poll sleep + await focusPromise; + + expect(clearIntervalSpy).toHaveBeenCalled(); + // 1 for start (initial), 1 for reset = 2 + expect(setIntervalSpy).toHaveBeenCalledTimes(2); + }); + }); +});