diff --git a/src/plugin.ts b/src/plugin.ts index ee8b624..7ea82f0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1699,8 +1699,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( accountManager.markToastShown(account.index); } - accountManager.requestSaveToDisk(); - let authRecord = accountManager.toAuthDetails(account); if (accessTokenExpired(authRecord)) { @@ -2289,7 +2287,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( account.consecutiveFailures = 0; getHealthTracker().recordSuccess(account.index); accountManager.markAccountUsed(account.index); - + accountManager.requestSaveToDisk(); + void triggerAsyncQuotaRefreshForAccount( accountManager, account.index, diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index ad0b62b..ceda7db 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from "./accounts"; -import type { AccountStorageV4 } from "./storage"; +import { saveAccounts, type AccountStorageV4 } from "./storage"; import type { OAuthAuthDetails } from "./types"; // Mock storage to prevent test data from leaking to real config files @@ -1069,6 +1069,8 @@ describe("AccountManager", () => { describe("Issue #174: saveToDisk throttling", () => { it("requestSaveToDisk coalesces multiple calls into one write", async () => { vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); const stored: AccountStorageV4 = { version: 4, @@ -1079,23 +1081,22 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); manager.requestSaveToDisk(); manager.requestSaveToDisk(); - expect(saveSpy).not.toHaveBeenCalled(); + expect(saveMock).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(5500); - expect(saveSpy).toHaveBeenCalledTimes(1); - - saveSpy.mockRestore(); + expect(saveMock).toHaveBeenCalledTimes(1); }); it("flushSaveToDisk waits for pending save to complete", async () => { vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); const stored: AccountStorageV4 = { version: 4, @@ -1106,22 +1107,21 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); const flushPromise = manager.flushSaveToDisk(); - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(5500); await flushPromise; - expect(saveSpy).toHaveBeenCalledTimes(1); - - saveSpy.mockRestore(); + expect(saveMock).toHaveBeenCalledTimes(1); }); it("does not save again if no new requestSaveToDisk after flush", async () => { vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); const stored: AccountStorageV4 = { version: 4, @@ -1132,18 +1132,68 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(5500); + + expect(saveMock).toHaveBeenCalledTimes(1); - expect(saveSpy).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(6000); + + expect(saveMock).toHaveBeenCalledTimes(1); + }); + + it("writes when account state changes between debounced saves", async () => { + vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); + + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; - await vi.advanceTimersByTimeAsync(3000); + const manager = new AccountManager(undefined, stored); - expect(saveSpy).toHaveBeenCalledTimes(1); + // First save + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(1); - saveSpy.mockRestore(); + // Mutate state, then request save — should write again + manager.markAccountUsed(0); + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(2); + }); + + it("skips write when account state has not changed", async () => { + vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); + + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + + // First save — should write + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(1); + + // Second save with no state change — should skip + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index 0a91326..681b3d7 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -312,6 +312,7 @@ export class AccountManager { private savePending = false; private saveTimeout: ReturnType | null = null; private savePromiseResolvers: Array<() => void> = []; + private lastSavedSnapshot: string | null = null; static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise { const stored = await loadAccounts(); @@ -988,11 +989,11 @@ export class AccountManager { return [...this.accounts]; } - async saveToDisk(): Promise { + private buildStorageState(): AccountStorageV4 { const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude); const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini); - - const storage: AccountStorageV4 = { + + return { version: 4, accounts: this.accounts.map((a) => ({ email: a.email, @@ -1003,7 +1004,7 @@ export class AccountManager { lastUsed: a.lastUsed, enabled: a.enabled, lastSwitchReason: a.lastSwitchReason, - rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined, + rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? { ...a.rateLimitResetTimes } : undefined, coolingDownUntil: a.coolingDownUntil, cooldownReason: a.cooldownReason, fingerprint: a.fingerprint, @@ -1020,9 +1021,13 @@ export class AccountManager { claude: claudeIndex, gemini: geminiIndex, }, - }; + } + } - await saveAccounts(storage); + async saveToDisk(): Promise { + const state = this.buildStorageState(); + await saveAccounts(state); + this.lastSavedSnapshot = JSON.stringify(state); } requestSaveToDisk(): void { @@ -1032,7 +1037,7 @@ export class AccountManager { this.savePending = true; this.saveTimeout = setTimeout(() => { void this.executeSave(); - }, 1000); + }, 5000); } async flushSaveToDisk(): Promise { @@ -1047,9 +1052,14 @@ export class AccountManager { private async executeSave(): Promise { this.savePending = false; this.saveTimeout = null; - + try { - await this.saveToDisk(); + const state = this.buildStorageState(); + const snapshot = JSON.stringify(state); + if (snapshot !== this.lastSavedSnapshot) { + await saveAccounts(state); + this.lastSavedSnapshot = snapshot; + } } catch { // best-effort persistence; avoid unhandled rejection from timer-driven saves } finally {