Skip to content
This repository was archived by the owner on Mar 30, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,8 +1699,6 @@ export const createAntigravityPlugin = (providerId: string) => async (
accountManager.markToastShown(account.index);
}

accountManager.requestSaveToDisk();

let authRecord = accountManager.toAuthDetails(account);

if (accessTokenExpired(authRecord)) {
Expand Down Expand Up @@ -2330,7 +2328,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,
Expand Down
86 changes: 68 additions & 18 deletions src/plugin/accounts.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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);
});
});

Expand Down
28 changes: 19 additions & 9 deletions src/plugin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ export class AccountManager {
private savePending = false;
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
private savePromiseResolvers: Array<() => void> = [];
private lastSavedSnapshot: string | null = null;

static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise<AccountManager> {
const stored = await loadAccounts();
Expand Down Expand Up @@ -973,11 +974,11 @@ export class AccountManager {
return [...this.accounts];
}

async saveToDisk(): Promise<void> {
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,
Expand All @@ -988,7 +989,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,
Expand All @@ -1005,9 +1006,13 @@ export class AccountManager {
claude: claudeIndex,
gemini: geminiIndex,
},
};
}
}

await saveAccounts(storage);
async saveToDisk(): Promise<void> {
const state = this.buildStorageState();
await saveAccounts(state);
this.lastSavedSnapshot = JSON.stringify(state);
}

requestSaveToDisk(): void {
Expand All @@ -1017,7 +1022,7 @@ export class AccountManager {
this.savePending = true;
this.saveTimeout = setTimeout(() => {
void this.executeSave();
}, 1000);
}, 5000);
}

async flushSaveToDisk(): Promise<void> {
Expand All @@ -1032,9 +1037,14 @@ export class AccountManager {
private async executeSave(): Promise<void> {
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 {
Expand Down