-
-
Notifications
You must be signed in to change notification settings - Fork 724
fix: reduce excessive file writes from account state updates #459
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1087,7 +1087,7 @@ describe("AccountManager", () => { | |
|
|
||
| expect(saveSpy).not.toHaveBeenCalled(); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1500); | ||
| await vi.advanceTimersByTimeAsync(5500); | ||
|
|
||
| expect(saveSpy).toHaveBeenCalledTimes(1); | ||
|
|
||
|
|
@@ -1112,7 +1112,7 @@ describe("AccountManager", () => { | |
|
|
||
| const flushPromise = manager.flushSaveToDisk(); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1500); | ||
| await vi.advanceTimersByTimeAsync(5500); | ||
| await flushPromise; | ||
|
|
||
| expect(saveSpy).toHaveBeenCalledTimes(1); | ||
|
|
@@ -1135,16 +1135,43 @@ describe("AccountManager", () => { | |
| const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); | ||
|
|
||
| manager.requestSaveToDisk(); | ||
| await vi.advanceTimersByTimeAsync(1500); | ||
| await vi.advanceTimersByTimeAsync(5500); | ||
|
|
||
| expect(saveSpy).toHaveBeenCalledTimes(1); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(3000); | ||
| await vi.advanceTimersByTimeAsync(6000); | ||
|
|
||
| expect(saveSpy).toHaveBeenCalledTimes(1); | ||
|
|
||
| saveSpy.mockRestore(); | ||
| }); | ||
|
|
||
| it("skips write when account state has not changed", async () => { | ||
| vi.useFakeTimers(); | ||
|
|
||
| const stored: AccountStorageV4 = { | ||
| version: 4, | ||
| accounts: [ | ||
| { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, | ||
| ], | ||
| activeIndex: 0, | ||
| }; | ||
|
|
||
| const manager = new AccountManager(undefined, stored); | ||
| const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); | ||
|
|
||
| // First save — should write | ||
| manager.requestSaveToDisk(); | ||
| await vi.advanceTimersByTimeAsync(5500); | ||
| expect(saveSpy).toHaveBeenCalledTimes(1); | ||
|
|
||
| // Second save with no state change — should skip | ||
| manager.requestSaveToDisk(); | ||
| await vi.advanceTimersByTimeAsync(5500); | ||
| expect(saveSpy).toHaveBeenCalledTimes(1); | ||
|
|
||
| saveSpy.mockRestore(); | ||
| }); | ||
|
Comment on lines
+1149
to
+1174
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deduplication test mocks saveToDisk, bypassing the real snapshot-write path The new test mocks However, this doesn't test the case where the real Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! Prompt To Fix With AIThis is a comment left during a code review.
Path: src/plugin/accounts.test.ts
Line: 1149:1174
Comment:
**Deduplication test mocks saveToDisk, bypassing the real snapshot-write path**
The new test mocks `saveToDisk` via `vi.spyOn(...).mockResolvedValue()`. This means `executeSave` takes a snapshot, sees a diff from `null`, calls the mock (which does nothing), then stores the snapshot as `lastSavedSnapshot`. The second call correctly sees equality and skips.
However, this doesn't test the case where the *real* `saveToDisk` (which calls `buildStorageState()` again independently) could write a state that differs from the comparison snapshot. Consider adding a test that exercises state mutation *during* the debounce window to confirm the snapshot reflects what's actually on disk.
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise. |
||
| }); | ||
|
|
||
| describe("Rate Limit Reason Classification", () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
|
@@ -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, | ||
|
|
@@ -1005,9 +1006,11 @@ export class AccountManager { | |
| claude: claudeIndex, | ||
| gemini: geminiIndex, | ||
| }, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| await saveAccounts(storage); | ||
| async saveToDisk(): Promise<void> { | ||
| await saveAccounts(this.buildStorageState()); | ||
| } | ||
|
|
||
| requestSaveToDisk(): void { | ||
|
|
@@ -1017,7 +1020,7 @@ export class AccountManager { | |
| this.savePending = true; | ||
| this.saveTimeout = setTimeout(() => { | ||
| void this.executeSave(); | ||
| }, 1000); | ||
| }, 5000); | ||
| } | ||
|
|
||
| async flushSaveToDisk(): Promise<void> { | ||
|
|
@@ -1032,9 +1035,13 @@ export class AccountManager { | |
| private async executeSave(): Promise<void> { | ||
| this.savePending = false; | ||
| this.saveTimeout = null; | ||
|
|
||
| try { | ||
| await this.saveToDisk(); | ||
| const snapshot = JSON.stringify(this.buildStorageState()); | ||
| if (snapshot !== this.lastSavedSnapshot) { | ||
| await this.saveToDisk(); | ||
| this.lastSavedSnapshot = snapshot; | ||
|
Comment on lines
+1040
to
+1043
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale snapshot stored as last-saved state
A safer approach is to capture the snapshot once, pass it to This ensures the snapshot stored is exactly what was written, not a concurrent re-read. Prompt To Fix With AIThis is a comment left during a code review.
Path: src/plugin/accounts.ts
Line: 1040:1043
Comment:
**Stale snapshot stored as last-saved state**
`lastSavedSnapshot` is set to the snapshot taken *before* `saveToDisk()` is awaited, but `saveToDisk()` internally calls `buildStorageState()` again. If any state mutation happens during the `await saveAccounts(...)` call (e.g., a rate-limit update arriving while the lockfile/rename cycle is in progress), the snapshot stored in `lastSavedSnapshot` will diverge from what was actually written to disk. The next `executeSave` call will then compare against a snapshot that doesn't match the file on disk, potentially skipping a write that should have happened.
A safer approach is to capture the snapshot once, pass it to `saveToDisk`, and only update `lastSavedSnapshot` after a confirmed successful write of that exact snapshot:
```
const snapshot = JSON.stringify(this.buildStorageState());
if (snapshot !== this.lastSavedSnapshot) {
await saveAccounts(JSON.parse(snapshot) as AccountStorageV4);
this.lastSavedSnapshot = snapshot;
}
```
This ensures the snapshot stored is exactly what was written, not a concurrent re-read.
How can I resolve this? If you propose a fix, please make it concise. |
||
| } | ||
| } catch { | ||
| // best-effort persistence; avoid unhandled rejection from timer-driven saves | ||
| } finally { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing semicolon
The call to
requestSaveToDisk()is missing a trailing semicolon, inconsistent with the rest of the codebase style.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix With AI