Skip to content

Commit 1285a03

Browse files
committed
fix: close storage and config review gaps
1 parent c7fa1f2 commit 1285a03

File tree

4 files changed

+141
-12
lines changed

4 files changed

+141
-12
lines changed

lib/config.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,11 @@ function withConfigMutationLock<T>(fn: () => Promise<T>): Promise<T> {
133133

134134
async function withConfigProcessLock<T>(fn: () => Promise<T>): Promise<T> {
135135
let lastError: NodeJS.ErrnoException | null = null;
136+
let attempt = 0;
136137

137138
await fs.mkdir(dirname(CONFIG_PATH), { recursive: true });
138139

139-
for (let attempt = 0; attempt < CONFIG_LOCK_RETRY_ATTEMPTS; attempt += 1) {
140+
while (attempt < CONFIG_LOCK_RETRY_ATTEMPTS) {
140141
try {
141142
const handle = await fs.open(CONFIG_LOCK_PATH, "wx", 0o600);
142143
try {
@@ -167,6 +168,7 @@ async function withConfigProcessLock<T>(fn: () => Promise<T>): Promise<T> {
167168
Math.min(CONFIG_LOCK_RETRY_BASE_DELAY_MS * 2 ** attempt, CONFIG_LOCK_RETRY_MAX_DELAY_MS),
168169
),
169170
);
171+
attempt += 1;
170172
continue;
171173
}
172174
throw error;
@@ -178,15 +180,20 @@ async function withConfigProcessLock<T>(fn: () => Promise<T>): Promise<T> {
178180

179181
async function renameConfigWithWindowsRetry(sourcePath: string, destinationPath: string): Promise<void> {
180182
let lastError: NodeJS.ErrnoException | null = null;
181-
for (let attempt = 0; attempt < 5; attempt += 1) {
183+
for (let attempt = 0; attempt < CONFIG_LOCK_RETRY_ATTEMPTS; attempt += 1) {
182184
try {
183185
await fs.rename(sourcePath, destinationPath);
184186
return;
185187
} catch (error) {
186188
const code = (error as NodeJS.ErrnoException).code;
187189
if (code === "EPERM" || code === "EBUSY") {
188190
lastError = error as NodeJS.ErrnoException;
189-
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
191+
await new Promise((resolve) =>
192+
setTimeout(
193+
resolve,
194+
Math.min(CONFIG_LOCK_RETRY_BASE_DELAY_MS * 2 ** attempt, CONFIG_LOCK_RETRY_MAX_DELAY_MS),
195+
),
196+
);
190197
continue;
191198
}
192199
throw error;

lib/storage.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { promises as fs, existsSync } from "node:fs";
1+
import { constants as fsConstants, promises as fs, existsSync } from "node:fs";
22
import { randomBytes } from "node:crypto";
33
import { dirname, join } from "node:path";
44
import { ACCOUNT_LIMITS } from "./constants.js";
@@ -159,12 +159,16 @@ async function renameWithWindowsRetry(sourcePath: string, destinationPath: strin
159159
}
160160
}
161161

162-
async function copyFileWithWindowsRetry(sourcePath: string, destinationPath: string): Promise<void> {
162+
async function copyFileWithWindowsRetry(
163+
sourcePath: string,
164+
destinationPath: string,
165+
mode = 0,
166+
): Promise<void> {
163167
let lastError: NodeJS.ErrnoException | null = null;
164168

165169
for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
166170
try {
167-
await fs.copyFile(sourcePath, destinationPath);
171+
await fs.copyFile(sourcePath, destinationPath, mode);
168172
return;
169173
} catch (error) {
170174
if (isWindowsLockError(error)) {
@@ -1136,7 +1140,10 @@ async function loadFlaggedAccountsUnlocked(
11361140
}
11371141

11381142
export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> {
1139-
return withStorageLock(async () => loadFlaggedAccountsUnlocked());
1143+
return withStorageLock(async () => {
1144+
const accountsSnapshot = await loadAccountsInternal(saveAccountsUnlocked);
1145+
return loadFlaggedAccountsUnlocked(accountsSnapshot);
1146+
});
11401147
}
11411148

11421149
/**
@@ -1291,10 +1298,7 @@ function previewImportAccountsAgainstExistingNormalized(
12911298
export async function backupRawAccountsFile(filePath: string, force = true): Promise<void> {
12921299
await withStorageLock(async () => {
12931300
const resolvedPath = resolvePath(filePath);
1294-
1295-
if (!force && existsSync(resolvedPath)) {
1296-
throw new Error(`File already exists: ${resolvedPath}`);
1297-
}
1301+
const copyMode = force ? 0 : fsConstants.COPYFILE_EXCL;
12981302

12991303
await migrateLegacyProjectStorageIfNeeded(saveAccountsUnlocked);
13001304
const storagePath = getStoragePath();
@@ -1303,7 +1307,14 @@ export async function backupRawAccountsFile(filePath: string, force = true): Pro
13031307
}
13041308

13051309
await fs.mkdir(dirname(resolvedPath), { recursive: true });
1306-
await copyFileWithWindowsRetry(storagePath, resolvedPath);
1310+
try {
1311+
await copyFileWithWindowsRetry(storagePath, resolvedPath, copyMode);
1312+
} catch (error) {
1313+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
1314+
throw new Error(`File already exists: ${resolvedPath}`);
1315+
}
1316+
throw error;
1317+
}
13071318
await fs.chmod(resolvedPath, 0o600).catch((chmodErr) => {
13081319
log.warn("Failed to restrict backup file permissions", {
13091320
path: resolvedPath,

test/plugin-config.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,46 @@ describe('Plugin Configuration', () => {
861861
}
862862
});
863863

864+
it('does not consume retry budget when stale lock cleanup succeeds repeatedly', async () => {
865+
mockExistsSync.mockReturnValue(true);
866+
const readSpy = vi.spyOn(fs.promises, 'readFile').mockResolvedValue(
867+
JSON.stringify({ experimental: { syncFromCodexMultiAuth: { enabled: false } } }) as never
868+
);
869+
const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined as never);
870+
const writeSpy = vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
871+
const renameSpy = vi.spyOn(fs.promises, 'rename').mockResolvedValue(undefined);
872+
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
873+
const closeSpy = vi.fn(async () => undefined);
874+
const staleError = Object.assign(new Error('lock exists'), { code: 'EEXIST' });
875+
const openSpy = vi.spyOn(fs.promises, 'open');
876+
for (let attempt = 0; attempt < 5; attempt += 1) {
877+
openSpy.mockRejectedValueOnce(staleError as never);
878+
}
879+
openSpy.mockResolvedValue({
880+
close: closeSpy,
881+
} as never);
882+
const statSpy = vi.spyOn(fs.promises, 'stat').mockResolvedValue({
883+
mtimeMs: Date.now() - 31_000,
884+
} as never);
885+
886+
try {
887+
await setSyncFromCodexMultiAuthEnabled(true);
888+
expect(statSpy).toHaveBeenCalledTimes(5);
889+
expect(openSpy).toHaveBeenCalledTimes(6);
890+
expect(unlinkSpy).toHaveBeenCalledTimes(6);
891+
expect(writeSpy).toHaveBeenCalled();
892+
expect(renameSpy).toHaveBeenCalled();
893+
} finally {
894+
readSpy.mockRestore();
895+
mkdirSpy.mockRestore();
896+
writeSpy.mockRestore();
897+
renameSpy.mockRestore();
898+
unlinkSpy.mockRestore();
899+
openSpy.mockRestore();
900+
statSpy.mockRestore();
901+
}
902+
});
903+
864904
it('reads the persisted sync toggle flag', () => {
865905
expect(getSyncFromCodexMultiAuthEnabled({ experimental: { syncFromCodexMultiAuth: { enabled: true } } })).toBe(true);
866906
expect(getSyncFromCodexMultiAuthEnabled({})).toBe(false);

test/storage.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,6 +1784,57 @@ describe("storage", () => {
17841784
}
17851785
});
17861786

1787+
it("reuses the loaded accounts snapshot inside loadFlaggedAccounts", async () => {
1788+
await saveAccounts({
1789+
version: 3,
1790+
activeIndex: 0,
1791+
activeIndexByFamily: {},
1792+
accounts: [
1793+
{
1794+
refreshToken: "account-refresh",
1795+
accountId: "account-id",
1796+
addedAt: 1,
1797+
lastUsed: 1,
1798+
},
1799+
],
1800+
});
1801+
await saveFlaggedAccounts({
1802+
version: 1,
1803+
accounts: [
1804+
{
1805+
refreshToken: "account-refresh",
1806+
flaggedAt: 1,
1807+
addedAt: 1,
1808+
lastUsed: 1,
1809+
},
1810+
{
1811+
refreshToken: "orphan-refresh",
1812+
flaggedAt: 2,
1813+
addedAt: 2,
1814+
lastUsed: 2,
1815+
},
1816+
],
1817+
});
1818+
1819+
const originalReadFile = fs.readFile.bind(fs);
1820+
const readSpy = vi.spyOn(fs, "readFile");
1821+
let accountReadCount = 0;
1822+
readSpy.mockImplementation(async (path, options) => {
1823+
if (String(path) === testStoragePath) {
1824+
accountReadCount++;
1825+
}
1826+
return originalReadFile(path as Parameters<typeof fs.readFile>[0], options as never);
1827+
});
1828+
1829+
try {
1830+
const flagged = await loadFlaggedAccounts();
1831+
expect(flagged.accounts.map((account) => account.refreshToken)).toEqual(["account-refresh"]);
1832+
expect(accountReadCount).toBe(1);
1833+
} finally {
1834+
readSpy.mockRestore();
1835+
}
1836+
});
1837+
17871838
it("reuses the loaded accounts snapshot inside withFlaggedAccountsTransaction", async () => {
17881839
await saveAccounts({
17891840
version: 3,
@@ -1856,6 +1907,26 @@ describe("storage", () => {
18561907
const raw = JSON.parse(await fs.readFile(backupPath, "utf-8")) as { accounts: Array<{ refreshToken: string }> };
18571908
expect(raw.accounts[0]?.refreshToken).toBe("backup-refresh");
18581909
});
1910+
1911+
it("surfaces a copy-time EEXIST when backing up raw accounts without force", async () => {
1912+
await saveAccounts({
1913+
version: 3,
1914+
activeIndex: 0,
1915+
activeIndexByFamily: {},
1916+
accounts: [{ accountId: "existing", refreshToken: "ref-existing", addedAt: 1, lastUsed: 1 }],
1917+
});
1918+
1919+
const backupPath = join(testWorkDir, "raw-backup-race.json");
1920+
const copySpy = vi.spyOn(fs, "copyFile").mockRejectedValue(
1921+
Object.assign(new Error("exists"), { code: "EEXIST" }) as never,
1922+
);
1923+
1924+
try {
1925+
await expect(backupRawAccountsFile(backupPath, false)).rejects.toThrow(/File already exists/);
1926+
} finally {
1927+
copySpy.mockRestore();
1928+
}
1929+
});
18591930
});
18601931

18611932
describe("setStoragePath", () => {

0 commit comments

Comments
 (0)