From 59109155731e190109f74193aabd07559563d72e Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 8 Aug 2026 23:58:02 +0900 Subject: [PATCH] fix: preserve Codex refresh lock ownership --- src/codex/account-store.ts | 20 +++++++++++-- tests/codex-account-store.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 09630ca312..cf19627c98 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { ConfigMutationLockError, @@ -327,7 +327,13 @@ function isRefreshLockStale(path: string): boolean { const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown }; return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS; } catch { - return true; + // A newly-created lock is briefly empty while its owner writes metadata. + // Do not let a waiter steal it during that acquisition window. + try { + return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS; + } catch { + return false; + } } } @@ -363,9 +369,17 @@ async function withCodexRefreshFileLock(lockKey: string, signal: AbortSignal, try { return await fn(); } finally { + const ownedIdentity = fd == null ? null : fstatSync(fd); if (fd != null) closeSync(fd); try { - unlinkSync(path); + const currentIdentity = statSync(path); + if ( + ownedIdentity + && currentIdentity.dev === ownedIdentity.dev + && currentIdentity.ino === ownedIdentity.ino + ) { + unlinkSync(path); + } } catch (err) { if (errCode(err) !== "ENOENT") throw err; } diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index eed7c09959..d84463c3ba 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -293,6 +293,56 @@ describe("codex-account-store CRUD", () => { } }); + test("refresh does not steal a newly-created empty file lock", async () => { + const { + getValidCodexToken, + readCodexAccountRecord, + saveCodexAccountCredential, + saveCodexAccountCredentialIfGeneration, + } = await import("../src/codex/account-store"); + saveCodexAccountCredential("refresh-empty-lock", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" }); + const generation = readCodexAccountRecord("refresh-empty-lock")!.generation; + const lockPath = refreshLockPathForToken("old-r"); + writeFileSync(lockPath, ""); + const refreshed = { accessToken: "other-process", refreshToken: "other-r", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acc" }; + const release = setTimeout(() => { + saveCodexAccountCredentialIfGeneration("refresh-empty-lock", generation, refreshed); + unlinkSync(lockPath); + }, 20); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("fetch should not be called while an empty lock is being initialized"); + }) as typeof fetch; + + try { + expect((await getValidCodexToken("refresh-empty-lock")).accessToken).toBe("other-process"); + } finally { + clearTimeout(release); + globalThis.fetch = originalFetch; + } + }); + + test("refresh owner does not remove a replacement file lock", async () => { + const { getValidCodexToken, saveCodexAccountCredential } = await import("../src/codex/account-store"); + saveCodexAccountCredential("refresh-replaced-lock", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" }); + const lockPath = refreshLockPathForToken("old-r"); + const replacement = JSON.stringify({ acquiredAt: Date.now(), pid: 54321 }) + "\n"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + unlinkSync(lockPath); + writeFileSync(lockPath, replacement); + return new Response(JSON.stringify({ access_token: "new", expires_in: 3600 }), { status: 200 }); + }) as typeof fetch; + + try { + expect((await getValidCodexToken("refresh-replaced-lock")).accessToken).toBe("new"); + expect(readFileSync(lockPath, "utf8")).toBe(replacement); + } finally { + if (existsSync(lockPath)) unlinkSync(lockPath); + globalThis.fetch = originalFetch; + } + }); + test("stale refresh lock is reclaimed", async () => { const { getValidCodexToken, saveCodexAccountCredential } = await import("../src/codex/account-store"); saveCodexAccountCredential("refresh-stale-lock", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" });