Skip to content

Commit 467e5f5

Browse files
committed
fix: persist recovered flagged backups
1 parent 53beeac commit 467e5f5

6 files changed

Lines changed: 137 additions & 1 deletion

File tree

lib/storage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,6 +1806,14 @@ export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> {
18061806
isRecord,
18071807
now: () => Date.now(),
18081808
}),
1809+
persistRecoveredBackup: async (storage, resetMarkerPath) =>
1810+
withStorageLock(async () => {
1811+
if (existsSync(resetMarkerPath)) {
1812+
return false;
1813+
}
1814+
await saveFlaggedAccountsUnlocked(storage);
1815+
return true;
1816+
}),
18091817
saveFlaggedAccounts,
18101818
loadFlaggedAccountsState,
18111819
logError: (message, details) => {

lib/storage/flagged-load-entry.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,20 @@ export async function loadFlaggedAccountsEntry(params: {
55
getLegacyFlaggedAccountsPath: () => string;
66
getIntentionalResetMarkerPath: (path: string) => string;
77
normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1;
8+
persistRecoveredBackup: (
9+
storage: FlaggedAccountStorageV1,
10+
resetMarkerPath: string,
11+
) => Promise<boolean>;
812
saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise<void>;
913
loadFlaggedAccountsState: (args: {
1014
path: string;
1115
legacyPath: string;
1216
resetMarkerPath: string;
1317
normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1;
18+
persistRecoveredBackup: (
19+
storage: FlaggedAccountStorageV1,
20+
resetMarkerPath: string,
21+
) => Promise<boolean>;
1422
saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise<void>;
1523
logError: (message: string, details: Record<string, unknown>) => void;
1624
logInfo: (message: string, details: Record<string, unknown>) => void;
@@ -24,6 +32,7 @@ export async function loadFlaggedAccountsEntry(params: {
2432
legacyPath: params.getLegacyFlaggedAccountsPath(),
2533
resetMarkerPath: params.getIntentionalResetMarkerPath(path),
2634
normalizeFlaggedStorage: params.normalizeFlaggedStorage,
35+
persistRecoveredBackup: params.persistRecoveredBackup,
2736
saveFlaggedAccounts: params.saveFlaggedAccounts,
2837
logError: params.logError,
2938
logInfo: params.logInfo,

lib/storage/flagged-storage-io.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export async function loadFlaggedAccountsState(params: {
5757
legacyPath: string;
5858
resetMarkerPath: string;
5959
normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1;
60+
persistRecoveredBackup: (
61+
storage: FlaggedAccountStorageV1,
62+
resetMarkerPath: string,
63+
) => Promise<boolean>;
6064
saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise<void>;
6165
logError: (message: string, details: Record<string, unknown>) => void;
6266
logInfo: (message: string, details: Record<string, unknown>) => void;
@@ -84,6 +88,23 @@ export async function loadFlaggedAccountsState(params: {
8488
if (existsSync(params.resetMarkerPath)) {
8589
return empty;
8690
}
91+
if (recovered.accounts.length > 0) {
92+
try {
93+
const persisted = await params.persistRecoveredBackup(
94+
recovered,
95+
params.resetMarkerPath,
96+
);
97+
if (!persisted) {
98+
return empty;
99+
}
100+
} catch (persistError) {
101+
params.logError("Failed to persist recovered flagged account storage", {
102+
from: backupPath,
103+
to: params.path,
104+
error: String(persistError),
105+
});
106+
}
107+
}
87108
params.logInfo("Recovered flagged account storage from backup", {
88109
from: backupPath,
89110
to: params.path,

test/rotation-integration.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { promises as fs } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
4-
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
4+
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
55
import { AccountManager } from "../lib/accounts.js";
66
import {
77
deduplicateAccounts,
@@ -303,6 +303,28 @@ describe("Multi-Account Rotation Integration", () => {
303303
TEST_ACCOUNTS.slice(0, 3).map((account) => account.email),
304304
);
305305
}, 15_000);
306+
307+
it("removeWithRetry retries transient windows fs.rm errors", async () => {
308+
const rmSpy = vi.spyOn(fs, "rm");
309+
const retryCodes = ["EBUSY", "EPERM", "ENOTEMPTY"];
310+
for (const code of retryCodes) {
311+
rmSpy.mockImplementationOnce(async () => {
312+
const error = new Error(code) as NodeJS.ErrnoException;
313+
error.code = code;
314+
throw error;
315+
});
316+
}
317+
318+
let callCount = 0;
319+
try {
320+
await removeWithRetry(testStoragePath, { force: true });
321+
callCount = rmSpy.mock.calls.length;
322+
} finally {
323+
rmSpy.mockRestore();
324+
}
325+
326+
expect(callCount).toBe(retryCodes.length + 1);
327+
});
306328
});
307329

308330
describe("Debounced save", () => {

test/storage-recovery-paths.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ describe("storage recovery paths", () => {
132132
const recovered = await loadFlaggedAccounts();
133133
expect(recovered.accounts).toHaveLength(1);
134134
expect(recovered.accounts[0]?.email).toBe("flagged@example.com");
135+
136+
const persisted = JSON.parse(await fs.readFile(flaggedPath, "utf-8")) as {
137+
accounts?: Array<{ email?: string }>;
138+
};
139+
expect(persisted.accounts?.[0]?.email).toBe("flagged@example.com");
135140
});
136141

137142
it("recovers flagged accounts from backup file when primary storage is unreadable", async () => {
@@ -157,6 +162,11 @@ describe("storage recovery paths", () => {
157162
const recovered = await loadFlaggedAccounts();
158163
expect(recovered.accounts).toHaveLength(1);
159164
expect(recovered.accounts[0]?.email).toBe("flagged2@example.com");
165+
166+
const persisted = JSON.parse(await fs.readFile(flaggedPath, "utf-8")) as {
167+
accounts?: Array<{ email?: string }>;
168+
};
169+
expect(persisted.accounts?.[0]?.email).toBe("flagged2@example.com");
160170
});
161171

162172
it("suppresses flagged backup recovery when the reset marker appears mid-read", async () => {

test/unified-settings.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,72 @@ describe("unified settings", () => {
193193
});
194194
});
195195

196+
it("keeps the last good backup when a concurrent writer updates the primary after a backup-derived read", async () => {
197+
const {
198+
getUnifiedSettingsPath,
199+
loadUnifiedPluginConfigSync,
200+
saveUnifiedPluginConfig,
201+
} = await import("../lib/unified-settings.js");
202+
203+
await saveUnifiedPluginConfig({ codexMode: true, fetchTimeoutMs: 45_000 });
204+
await saveUnifiedPluginConfig({ codexMode: false, fetchTimeoutMs: 90_000 });
205+
await fs.writeFile(getUnifiedSettingsPath(), "{ invalid json", "utf8");
206+
207+
expect(loadUnifiedPluginConfigSync()).toEqual({
208+
codexMode: true,
209+
fetchTimeoutMs: 45_000,
210+
});
211+
212+
const concurrentPrimary = {
213+
version: 1,
214+
pluginConfig: {
215+
codexMode: false,
216+
fetchTimeoutMs: 150_000,
217+
retries: 4,
218+
},
219+
};
220+
const copySpy = vi.spyOn(fs, "copyFile");
221+
const renameSpy = vi.spyOn(fs, "rename");
222+
let injectedConcurrentWrite = false;
223+
renameSpy.mockImplementationOnce(async () => {
224+
injectedConcurrentWrite = true;
225+
await fs.writeFile(
226+
getUnifiedSettingsPath(),
227+
`${JSON.stringify(concurrentPrimary, null, 2)}\n`,
228+
"utf8",
229+
);
230+
const error = new Error("denied") as NodeJS.ErrnoException;
231+
error.code = "EACCES";
232+
throw error;
233+
});
234+
235+
try {
236+
await expect(
237+
saveUnifiedPluginConfig({ codexMode: true, fetchTimeoutMs: 120_000 }),
238+
).rejects.toThrow();
239+
} finally {
240+
copySpy.mockRestore();
241+
renameSpy.mockRestore();
242+
}
243+
244+
expect(injectedConcurrentWrite).toBe(true);
245+
expect(copySpy).not.toHaveBeenCalledWith(
246+
getUnifiedSettingsPath(),
247+
`${getUnifiedSettingsPath()}.bak`,
248+
);
249+
const backupRecord = JSON.parse(
250+
await fs.readFile(`${getUnifiedSettingsPath()}.bak`, "utf8"),
251+
) as { pluginConfig?: Record<string, unknown> };
252+
expect(backupRecord.pluginConfig).toEqual({
253+
codexMode: true,
254+
fetchTimeoutMs: 45_000,
255+
});
256+
const primaryRecord = JSON.parse(
257+
await fs.readFile(getUnifiedSettingsPath(), "utf8"),
258+
) as { pluginConfig?: Record<string, unknown> };
259+
expect(primaryRecord.pluginConfig).toEqual(concurrentPrimary.pluginConfig);
260+
});
261+
196262
it("resumes snapshotting after a backup-derived write succeeds", async () => {
197263
const {
198264
getUnifiedSettingsPath,

0 commit comments

Comments
 (0)