Skip to content

Commit 8bebcdf

Browse files
committed
fix: harden sync prune recovery
1 parent a919320 commit 8bebcdf

6 files changed

Lines changed: 687 additions & 79 deletions

File tree

index.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3465,6 +3465,7 @@ while (attempted.size < Math.max(1, accountCount)) {
34653465
index: number;
34663466
account: AccountStorageV3["accounts"][number];
34673467
}> = [];
3468+
let rollbackStorage: AccountStorageV3 | null = null;
34683469
await withAccountStorageTransaction(async (loadedStorage, persist) => {
34693470
const currentStorage =
34703471
loadedStorage ??
@@ -3488,6 +3489,7 @@ while (attempted.size < Math.max(1, accountCount)) {
34883489
if (removedTargets.length === 0) {
34893490
return;
34903491
}
3492+
rollbackStorage = structuredClone(currentStorage);
34913493

34923494
const activeAccountIdentity = {
34933495
refreshToken:
@@ -3554,21 +3556,41 @@ while (attempted.size < Math.max(1, accountCount)) {
35543556
}),
35553557
),
35563558
);
3557-
await withFlaggedAccountsTransaction(async (currentFlaggedStorage, persist) => {
3558-
await persist({
3559-
version: 1,
3560-
accounts: currentFlaggedStorage.accounts.filter(
3561-
(flagged) =>
3562-
!removedFlaggedKeys.has(
3563-
getSyncRemovalTargetKey({
3564-
refreshToken: flagged.refreshToken,
3565-
organizationId: flagged.organizationId,
3566-
accountId: flagged.accountId,
3567-
}),
3568-
),
3569-
),
3559+
try {
3560+
await withFlaggedAccountsTransaction(async (currentFlaggedStorage, persist) => {
3561+
await persist({
3562+
version: 1,
3563+
accounts: currentFlaggedStorage.accounts.filter(
3564+
(flagged) =>
3565+
!removedFlaggedKeys.has(
3566+
getSyncRemovalTargetKey({
3567+
refreshToken: flagged.refreshToken,
3568+
organizationId: flagged.organizationId,
3569+
accountId: flagged.accountId,
3570+
}),
3571+
),
3572+
),
3573+
});
35703574
});
3571-
});
3575+
} catch (flaggedError) {
3576+
if (rollbackStorage) {
3577+
try {
3578+
await withAccountStorageTransaction(async (_current, persist) => {
3579+
await persist(rollbackStorage as AccountStorageV3);
3580+
});
3581+
} catch (restoreError) {
3582+
const flaggedMessage =
3583+
flaggedError instanceof Error ? flaggedError.message : String(flaggedError);
3584+
const restoreMessage =
3585+
restoreError instanceof Error ? restoreError.message : String(restoreError);
3586+
throw new Error(
3587+
`Failed to remove flagged sync entries after account removal: ${flaggedMessage}; ` +
3588+
`failed to restore removed accounts: ${restoreMessage}`,
3589+
);
3590+
}
3591+
}
3592+
throw flaggedError;
3593+
}
35723594
invalidateAccountManagerCache();
35733595
}
35743596
};
@@ -3754,7 +3776,12 @@ while (attempted.size < Math.max(1, accountCount)) {
37543776
console.log("");
37553777
} catch (error) {
37563778
const message = error instanceof Error ? error.message : String(error);
3757-
const backupHint = backupPath ? `\nBackup: ${backupPath}` : "";
3779+
const cleanupBackupPath =
3780+
error instanceof Error &&
3781+
typeof (error as Error & { backupPath?: unknown }).backupPath === "string"
3782+
? ((error as Error & { backupPath: string }).backupPath)
3783+
: undefined;
3784+
const backupHint = cleanupBackupPath ? `\nBackup: ${cleanupBackupPath}` : "";
37583785
console.log(`\nCleanup failed: ${message}${backupHint}\n`);
37593786
}
37603787
};

lib/codex-multi-auth-sync.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ interface PreparedCodexMultiAuthPreviewStorage {
121121

122122
const TEMP_CLEANUP_RETRY_DELAYS_MS = [100, 250, 500] as const;
123123
const STALE_TEMP_CLEANUP_RETRY_DELAY_MS = 150;
124+
const STALE_TEMP_SWEEP_RETRYABLE_CODES = new Set(["EBUSY", "EAGAIN", "EACCES", "EPERM", "ENOTEMPTY"]);
124125

125126
function sleepAsync(ms: number): Promise<void> {
126127
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -311,7 +312,7 @@ async function cleanupStaleNormalizedImportTempDirs(
311312
continue;
312313
}
313314
let message = error instanceof Error ? error.message : String(error);
314-
if (code === "EBUSY" || code === "EACCES" || code === "EPERM") {
315+
if (code && STALE_TEMP_SWEEP_RETRYABLE_CODES.has(code)) {
315316
await sleepAsync(STALE_TEMP_CLEANUP_RETRY_DELAY_MS);
316317
try {
317318
await fs.rm(candidateDir, { recursive: true, force: true });
@@ -1212,6 +1213,7 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(
12121213
activeIndex: 0,
12131214
activeIndexByFamily: {},
12141215
};
1216+
let writtenBackupPath: string | undefined;
12151217
if (backupPath) {
12161218
await fs.mkdir(dirname(backupPath), { recursive: true });
12171219
const tempBackupPath = `${backupPath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
@@ -1221,6 +1223,7 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(
12211223
mode: 0o600,
12221224
});
12231225
await fs.rename(tempBackupPath, backupPath);
1226+
writtenBackupPath = backupPath;
12241227
} catch (error) {
12251228
try {
12261229
await fs.unlink(tempBackupPath);
@@ -1230,10 +1233,17 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(
12301233
throw error;
12311234
}
12321235
}
1233-
const plan = buildCodexMultiAuthOverlapCleanupPlan(fallback);
1234-
if (plan.nextStorage) {
1235-
await persist(plan.nextStorage);
1236+
try {
1237+
const plan = buildCodexMultiAuthOverlapCleanupPlan(fallback);
1238+
if (plan.nextStorage) {
1239+
await persist(plan.nextStorage);
1240+
}
1241+
return plan.result;
1242+
} catch (error) {
1243+
if (writtenBackupPath && error instanceof Error) {
1244+
(error as Error & { backupPath?: string }).backupPath = writtenBackupPath;
1245+
}
1246+
throw error;
12361247
}
1237-
return plan.result;
12381248
});
12391249
}

lib/sync-prune-backup.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,22 @@ type FlaggedSnapshot<TAccount extends object> = {
55
accounts: TAccount[];
66
};
77

8-
type TokenRedacted<TAccount extends object> =
9-
Omit<TAccount, "accessToken" | "refreshToken" | "idToken"> & {
10-
accessToken?: undefined;
11-
refreshToken?: undefined;
12-
idToken?: undefined;
13-
};
14-
15-
function cloneWithoutTokens<TAccount extends object>(account: TAccount): TokenRedacted<TAccount> {
16-
const clone = structuredClone(account) as TokenRedacted<TAccount>;
17-
delete clone.accessToken;
18-
delete clone.refreshToken;
19-
delete clone.idToken;
20-
return clone;
21-
}
22-
238
export function createSyncPruneBackupPayload<TFlaggedAccount extends object>(
249
currentAccountsStorage: AccountStorageV3,
2510
currentFlaggedStorage: FlaggedSnapshot<TFlaggedAccount>,
2611
): {
2712
version: 1;
28-
accounts: Omit<AccountStorageV3, "accounts"> & {
29-
accounts: Array<TokenRedacted<AccountStorageV3["accounts"][number]>>;
30-
};
31-
flagged: FlaggedSnapshot<TokenRedacted<TFlaggedAccount>>;
13+
accounts: AccountStorageV3;
14+
flagged: FlaggedSnapshot<TFlaggedAccount>;
3215
} {
3316
return {
3417
version: 1,
35-
accounts: {
18+
accounts: structuredClone({
3619
...currentAccountsStorage,
37-
accounts: currentAccountsStorage.accounts.map((account) => cloneWithoutTokens(account)),
3820
activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) },
39-
},
40-
flagged: {
21+
}),
22+
flagged: structuredClone({
4123
...currentFlaggedStorage,
42-
accounts: currentFlaggedStorage.accounts.map((flagged) => cloneWithoutTokens(flagged)),
43-
},
24+
}),
4425
};
4526
}

test/codex-multi-auth-sync.test.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -808,7 +808,9 @@ describe("codex-multi-auth sync", () => {
808808
skipped: 0,
809809
total: 4,
810810
});
811-
expect(rmSpy).toHaveBeenCalledTimes(3);
811+
expect(
812+
rmSpy.mock.calls.filter(([path]) => String(path).includes("oc-chatgpt-multi-auth-sync-")),
813+
).toHaveLength(3);
812814
expect(vi.mocked(loggerModule.logWarn)).not.toHaveBeenCalledWith(
813815
expect.stringContaining("Failed to remove temporary codex sync directory"),
814816
);
@@ -961,7 +963,9 @@ describe("codex-multi-auth sync", () => {
961963
}
962964
});
963965

964-
it("retries stale temp sweep once on transient Windows lock errors", async () => {
966+
it.each(["EBUSY", "ENOTEMPTY", "EAGAIN"] as const)(
967+
"retries stale temp sweep once on transient Windows %s cleanup errors",
968+
async (code) => {
965969
const rootDir = join(process.cwd(), ".tmp-codex-multi-auth");
966970
const fakeHome = await fs.promises.mkdtemp(join(os.tmpdir(), "codex-sync-home-"));
967971
process.env.CODEX_MULTI_AUTH_DIR = rootDir;
@@ -987,7 +991,7 @@ describe("codex-multi-auth sync", () => {
987991
const rmSpy = vi.spyOn(fs.promises, "rm").mockImplementation(async (path, options) => {
988992
if (!staleSweepBlocked && String(path) === staleDir) {
989993
staleSweepBlocked = true;
990-
throw Object.assign(new Error("busy"), { code: "EBUSY" });
994+
throw Object.assign(new Error("busy"), { code });
991995
}
992996
return originalRm(path, options as never);
993997
});
@@ -1701,6 +1705,67 @@ describe("codex-multi-auth sync", () => {
17011705
},
17021706
);
17031707

1708+
it("annotates overlap cleanup failures with the written backup path", async () => {
1709+
const storageModule = await import("../lib/storage.js");
1710+
const persist = vi.fn(async () => {
1711+
throw new Error("persist failed");
1712+
});
1713+
vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) =>
1714+
handler(
1715+
{
1716+
version: 3,
1717+
activeIndex: 0,
1718+
activeIndexByFamily: {},
1719+
accounts: [
1720+
{
1721+
accountId: "org-local",
1722+
organizationId: "org-local",
1723+
accountIdSource: "org",
1724+
email: "shared@example.com",
1725+
refreshToken: "rt-local",
1726+
addedAt: 5,
1727+
lastUsed: 5,
1728+
},
1729+
{
1730+
accountTags: ["codex-multi-auth-sync"],
1731+
email: "shared@example.com",
1732+
refreshToken: "rt-sync",
1733+
addedAt: 4,
1734+
lastUsed: 4,
1735+
},
1736+
],
1737+
},
1738+
persist,
1739+
),
1740+
);
1741+
const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined);
1742+
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined);
1743+
const renameSpy = vi.spyOn(fs.promises, "rename").mockResolvedValue(undefined);
1744+
1745+
try {
1746+
const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js");
1747+
let thrown: unknown;
1748+
try {
1749+
await cleanupCodexMultiAuthSyncedOverlaps("/tmp/overlap-cleanup-backup.json");
1750+
} catch (error) {
1751+
thrown = error;
1752+
}
1753+
1754+
expect(mkdirSpy).toHaveBeenCalledWith("/tmp", { recursive: true });
1755+
expect(writeSpy).toHaveBeenCalled();
1756+
expect(renameSpy).toHaveBeenCalled();
1757+
expect(thrown).toBeInstanceOf(Error);
1758+
expect(thrown).toMatchObject({
1759+
message: "persist failed",
1760+
backupPath: "/tmp/overlap-cleanup-backup.json",
1761+
});
1762+
} finally {
1763+
mkdirSpy.mockRestore();
1764+
writeSpy.mockRestore();
1765+
renameSpy.mockRestore();
1766+
}
1767+
});
1768+
17041769

17051770
it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => {
17061771
const storageModule = await import("../lib/storage.js");

0 commit comments

Comments
 (0)