diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 1e8885ae11..ab32e137b5 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, @@ -655,10 +655,43 @@ 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; + // The owner creates the file and writes its metadata in two steps, so a live lock is + // briefly unreadable. Age the file itself instead of calling that window stale, which + // let a waiter delete a lock whose owner was still inside its critical section. + try { + return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS; + } catch { + return false; + } } } +function releaseCodexRefreshFileLock(path: string, fd: number): void { + let owned: { dev: bigint; ino: bigint } | null = null; + try { + const info = fstatSync(fd, { bigint: true }); + if (info.dev >= 0n && info.ino > 0n) owned = { dev: info.dev, ino: info.ino }; + } catch { /* Unknown descriptor identity never authorizes unlink. */ } + try { + withConfigMutationLockSync(() => { + let current: { dev: bigint; ino: bigint } | null = null; + try { + const info = statSync(path, { bigint: true }); + if (info.dev >= 0n && info.ino > 0n) current = { dev: info.dev, ino: info.ino }; + } catch { /* Keep the lock and the callback outcome when the path probe fails. */ } + if (owned && current && current.dev === owned.dev && current.ino === owned.ino) { + try { unlinkSync(path); } catch (err) { + if (errCode(err) !== "ENOENT") throw err; + } + } + }); + } catch (err) { + // Keep the descriptor alive through comparison/unlink so its inode cannot be recycled. + // Unavailable coordination leaves the path without masking the completed refresh. + if (!(err instanceof ConfigMutationLockError)) throw err; + } finally { closeSync(fd); } +} + export async function withCodexRefreshFileLock(lockKey: string, signal: AbortSignal, fn: () => Promise): Promise { hardenConfigDir(); const dir = getConfigDir(); @@ -670,33 +703,45 @@ export async function withCodexRefreshFileLock(lockKey: string, signal: Abort while (fd == null) { if (signal.aborted) throw signal.reason; try { - fd = openSync(path, "wx", 0o600); - writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n"); - break; - } catch (err) { - if (errCode(err) !== "EEXIST") throw err; - if (isRefreshLockStale(path)) { + // Serialize only metadata operations, never the async refresh callback. Cooperating + // contenders cannot reclaim a successor between stale observation and path mutation. + withConfigMutationLockSync(() => { try { - unlinkSync(path); - } catch (unlinkErr) { - if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr; + fd = openSync(path, "wx", 0o600); + writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n"); + } catch (err) { + if (fd != null) { + const failedFd = fd; + fd = null; + try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve write failure. */ } + throw err; + } + if (errCode(err) !== "EEXIST") throw err; + if (isRefreshLockStale(path)) { + try { unlinkSync(path); } catch (unlinkErr) { + if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr; + } + } } - continue; + }); + } catch (err) { + // A failed SQLite commit can follow successful file creation; it still owns an fd. + if (fd != null) { + const failedFd = fd; + fd = null; + try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve admission failure. */ } } - if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError(); - await sleep(REFRESH_LOCK_POLL_MS, signal); + if (!(err instanceof ConfigMutationLockError)) throw err; } + if (fd != null) break; + if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError(); + await sleep(REFRESH_LOCK_POLL_MS, signal); } try { return await fn(); } finally { - if (fd != null) closeSync(fd); - try { - unlinkSync(path); - } catch (err) { - if (errCode(err) !== "ENOENT") throw err; - } + releaseCodexRefreshFileLock(path, fd); } } diff --git a/structure/catalog.md b/structure/catalog.md index 2fd03722df..7c90696809 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -244,6 +244,9 @@ Pool mode routes across main plus added Codex credentials. Key rules: generation it started from still holds; a lost race raises a generation-conflict error rather than overwriting the newer credential (`src/codex/account-store.ts`). Callers handle that error; they do not assume a silent retry. + The lock itself is identity-scoped: a not-yet-readable lock counts as held until it ages out, + and release requires a usable matching descriptor identity. Unknown identity leaves the path + for stale recovery without replacing the callback outcome when the path probe fails; confirmed-owner unlink errors other than `ENOENT` still propagate. Acquisition, stale reclamation and identity-checked release run inside the existing synchronous SQLite config-mutation transaction; the async refresh callback runs outside it. Release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Busy release coordination preserves the callback outcome and leaves the path for stale recovery. This serializes cooperating writers; stat/unlink is not atomic against non-cooperating filesystem writers. - **Authentication identity, quota domain, and cache domain are tracked separately** (`src/routing/identity-domains.ts`). `classifyCredential` returns all three with provenance: `pool.credentialGroups` supplies operator-declared quota domains, a small built-in table diff --git a/structure/codex-home.md b/structure/codex-home.md index 339358ea9b..5a95c396b5 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -1,5 +1,7 @@ # Codex Home +A lock in the Codex credential store is governed by [descriptor identity and age](catalog.md#accounts-namespaces-and-pool-rotation), so the mere presence of its filename is neither acquisition nor release authority. Failed path-identity probes leave the lock for stale recovery and preserve the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + ## Codex home `src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from diff --git a/structure/config.md b/structure/config.md index d00b36b06b..e9b5f1badd 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,5 +1,7 @@ # Config Surface +Configuration consumers retain the [refresh-lock ownership boundary](catalog.md#accounts-namespaces-and-pool-rotation); failing to establish a usable matching lock identity does not authorize deleting its path or replacing the refresh callback outcome with a path-probe error. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 4628ca5e50..83f04169c2 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -8,7 +8,7 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior ## Dashboard serving -The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts +Account refresh actions follow the [credential refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a held unreadable lock is distinct from one this process may release, and path-probe errors preserve the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts the proxy when needed and opens `http://localhost:`, or `http://127.0.0.1:` when `hub.managementIngress.enabled` is true — see [the hub management dashboard address](runtime.md#hub-management-dashboard-address). All ordinary HTTP responses (excluding successful WebSocket upgrades) include `X-Frame-Options: DENY` and diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 0c0ffe38d0..8f907ae677 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,5 +1,7 @@ # Docs And Release +Refresh-lock validation covers fresh unreadable locks, descriptor-matched release, path-probe failures preserving callback outcomes, and confirmed-owner unlink error handling in `tests/codex-integration/codex-account-store.test.ts`; the [catalog contract](../catalog.md#accounts-namespaces-and-pool-rotation) explicitly does not promise atomic compare-and-delete. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index ab7511c3b0..ddc03cfcb0 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -428,6 +428,12 @@ Pool mode needs stable public names and a store that survives concurrent refresh - The credential store is generation-guarded and refresh-locked (`src/codex/account-store.ts`): a refresh persists only if the generation it started from still holds, and a lost race raises a generation-conflict error instead of overwriting the newer credential. + The lock is held and released by file identity rather than by path. A lock that exists but is + not yet readable counts as held until it ages past the stale window, because its owner creates + the file and writes its metadata as two steps, and a holder deletes the lock only while the + path still resolves to the file it created. If descriptor identity is unavailable or unusable, + release leaves the path for stale-lock recovery. Path-probe errors preserve the callback outcome; confirmed-owner unlink errors other than `ENOENT` still propagate. The stat/unlink pair is not an atomic + compare-and-delete against non-cooperating writers. Cooperating acquisition, stale reclamation and release serialize inside the synchronous config-mutation transaction, released before the async callback. Release keeps its descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. ## Sidecars, management, and UI @@ -560,8 +566,8 @@ consulted, and they run in `resolveCodexAccountForThreadDetailed` ahead of it. A with no recorded refusal is deliberately not a release path on its own — stickiness until the account actually refuses is intended — but it does surrender the binding as soon as a sibling with headroom exists. Unbound assignment is untouched and still takes the coolest eligible account, -because a fresh request has no warm prefix to lose. `pool.cacheAffinity` remains the stronger -opt-in, raising the bar from the threshold to genuine exhaustion. +because a fresh request has no warm prefix to lose. `pool.cacheAffinity` is enabled by default, +raising the bar from the threshold to genuine exhaustion. Two call sites need the rule — the live path in `reevaluateAffinityQuota` and the side-effect-free `previewReusableAffinityAccount` that subagent fallback reads — and they share one helper rather diff --git a/structure/runtime.md b/structure/runtime.md index bd9ebbd561..cdce19229c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -3,6 +3,8 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index c0c92891f7..cee403db70 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -3,6 +3,8 @@ Encrypted-task and fallback request handling follow the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +Concurrent refreshes triggered by independent agent work share the [credential refresh-lock contract](catalog.md#accounts-namespaces-and-pool-rotation); unknown lock identity remains available for stale recovery rather than immediate removal, and a failed path probe cannot mask the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + ## Plaintext V2 agent messages `src/responses/plaintext-v2-agent-messages.ts` owns the experimental, configuration-only diff --git a/tests/codex-integration/codex-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index e44ce355e0..a71db9b875 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -624,6 +626,223 @@ describe("codex-account-store CRUD", () => { } }); + test("a refresh lock that is still being initialized is not reclaimed as stale", async () => { + const { getValidCodexToken, saveCodexAccountCredential } = await import("../../src/codex/account-store"); + saveCodexAccountCredential("refresh-empty-lock", { accessToken: "old", refreshToken: "empty-r", expiresAt: 0, chatgptAccountId: "acc" }); + // The owner creates the lock file and writes its metadata as two steps, so a live lock is + // briefly unreadable. Treating that window as stale let a waiter delete a lock whose owner + // was still inside its critical section, and both then ran the refresh. + const lockPath = refreshLockPathForToken("empty-r"); + writeFileSync(lockPath, ""); + let fetchCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(JSON.stringify({ access_token: "new", expires_in: 3600 }), { status: 200 }); + }) as typeof fetch; + + try { + const pending = getValidCodexToken("refresh-empty-lock"); + await new Promise(resolve => setTimeout(resolve, 200)); + expect(existsSync(lockPath)).toBe(true); + expect(fetchCalls).toBe(0); + unlinkSync(lockPath); + const result = await pending; + expect(result.accessToken).toBe("new"); + expect(fetchCalls).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("releasing a refresh lock leaves a lock another owner recreated in place", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = "recreated-owner"; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + await withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => { + // A waiter reclaimed this path and a second owner took it over while we held it. + renameSync(lockPath, `${lockPath}.reclaimed`); + writeFileSync(lockPath, JSON.stringify({ acquiredAt: Date.now(), pid: 999_001 }) + "\n"); + }); + expect(existsSync(lockPath)).toBe(true); + expect((JSON.parse(readFileSync(lockPath, "utf-8")) as { pid: number }).pid).toBe(999_001); + unlinkSync(lockPath); + unlinkSync(`${lockPath}.reclaimed`); + }); + + test.each([false, true])("refresh release prevents inode reuse before comparison (callback failure=%s)", async (callbackFails) => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = `release-inode-reuse-${callbackFails}`; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + const originalFstat = fs.fstatSync; + const originalStat = fs.statSync; + let fd: number | undefined; + let owned: ReturnType | undefined; + let openDuringComparison = false; + const descriptor = spyOn(fs, "fstatSync").mockImplementation((...args: Parameters) => { + fd = args[0]; + owned = originalFstat(...args); + return owned; + }); + const probe = spyOn(fs, "statSync").mockImplementation((...args: Parameters) => { + if (args[0] === path && fd !== undefined && owned) { + try { originalFstat(fd); openDuringComparison = true; } catch { /* Descriptor closed early. */ } + // Model an allocator reusing the unlinked owner's inode only after its last fd closes. + // Holding that fd alive must prevent this ABA regardless of the host filesystem. + if (!openDuringComparison) return owned; + } + return originalStat(...args); + }); + const failure = new Error("original refresh failure"); + try { + const pending = withCodexRefreshFileLock(key, new AbortController().signal, async () => { + unlinkSync(path); + writeFileSync(path, "successor"); + if (callbackFails) throw failure; + return "refreshed"; + }); + if (callbackFails) await expect(pending).rejects.toBe(failure); + else expect(await pending).toBe("refreshed"); + expect(openDuringComparison).toBe(true); + expect(readFileSync(path, "utf8")).toBe("successor"); + expect(fd).toBeDefined(); + expect(() => originalFstat(fd!)).toThrow(); + } finally { descriptor.mockRestore(); probe.mockRestore(); } + }); + + test("refresh release preserves the path when descriptor identity cannot be read", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = "unknown-owner"; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + const original = fs.fstatSync; + let released = false; + const probe = spyOn(fs, "fstatSync").mockImplementation((...args: Parameters) => { + if (released) throw new Error("identity probe unavailable"); + return original(...args); + }); + try { + await withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => { + renameSync(lockPath, `${lockPath}.reclaimed`); + writeFileSync(lockPath, "replacement-owner"); + released = true; + }); + expect(readFileSync(lockPath, "utf8")).toBe("replacement-owner"); + } finally { + probe.mockRestore(); + } + }); + + for (const code of ["EACCES", "EIO"]) { + for (const callbackFails of [false, true]) { + test(`refresh release preserves the callback outcome after ${code} path probe failure (${callbackFails})`, async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = `path-probe-${code}-${callbackFails}`; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + const original = fs.statSync; + const callbackError = new Error("refresh failed"); + let released = false; + const probe = spyOn(fs, "statSync").mockImplementation((...args: Parameters) => { + if (released && args[0] === lockPath) throw Object.assign(new Error("path probe unavailable"), { code }); + return original(...args); + }); + try { + const pending = withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => { + released = true; + if (callbackFails) throw callbackError; + return "refreshed"; + }); + if (callbackFails) await expect(pending).rejects.toBe(callbackError); + else expect(await pending).toBe("refreshed"); + expect(existsSync(lockPath)).toBe(true); + } finally { probe.mockRestore(); } + }); + } + } + + test.each(["ENOENT", "EACCES"])("refresh release preserves confirmed-owner unlink handling for %s", async (code) => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = `unlink-${code}`; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + const original = fs.unlinkSync; + const unlinkError = Object.assign(new Error("unlink failed"), { code }); + let attempts = 0; + const probe = spyOn(fs, "unlinkSync").mockImplementation((path) => { + if (path === lockPath) { attempts++; throw unlinkError; } + return original(path); + }); + try { + const pending = withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => "refreshed"); + if (code === "ENOENT") expect(await pending).toBe("refreshed"); + else await expect(pending).rejects.toBe(unlinkError); + expect(attempts).toBe(1); + } finally { probe.mockRestore(); } + }); + + test("refresh stale reclamation excludes a second SQLite writer until acquisition finishes", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = "serialized-stale"; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + writeFileSync(path, JSON.stringify({ acquiredAt: 0 })); + const db = new Database(join(TEST_DIR, "config-mutation.sqlite"), { create: true }); + const original = fs.unlinkSync; + let blocked = false; + const probe = spyOn(fs, "unlinkSync").mockImplementation((candidate) => { + if (candidate === path && !blocked) { + try { db.exec("BEGIN IMMEDIATE"); db.exec("ROLLBACK"); } + catch (error) { blocked = (error as { code?: string }).code === "SQLITE_BUSY"; } + } + return original(candidate); + }); + try { + await withCodexRefreshFileLock(key, new AbortController().signal, async () => { + expect(blocked).toBe(true); + // The callback must not hold the metadata transaction across network/async work. + db.exec("BEGIN IMMEDIATE"); db.exec("ROLLBACK"); + }); + expect(existsSync(path)).toBe(false); + } finally { probe.mockRestore(); db.close(); } + }); + + test.each([false, true])("refresh metadata failure closes its descriptor and preserves replacement=%s", async (replacement) => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = `metadata-write-${replacement}`; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + const original = fs.writeFileSync; + const failure = Object.assign(new Error("metadata write failed"), { code: "EIO" }); + let descriptor: number | undefined; + let called = false; + const probe = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (typeof args[0] === "number") { + descriptor = args[0]; + if (replacement) { renameSync(path, `${path}.reclaimed`); original(path, "successor"); } + throw failure; + } + return original(...args); + }); + try { + await expect(withCodexRefreshFileLock(key, new AbortController().signal, async () => { called = true; })).rejects.toBe(failure); + expect(called).toBe(false); + expect(descriptor).toBeDefined(); + expect(() => fs.fstatSync(descriptor!)).toThrow(); + expect(existsSync(path)).toBe(replacement); + if (replacement) expect(readFileSync(path, "utf8")).toBe("successor"); + } finally { probe.mockRestore(); } + }); + + test("refresh release keeps its result and lock when metadata coordination is busy", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = "release-coordination-busy"; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + const db = new Database(join(TEST_DIR, "config-mutation.sqlite"), { create: true }); + try { + expect(await withCodexRefreshFileLock(key, new AbortController().signal, async () => { + db.exec("BEGIN IMMEDIATE"); + return "refreshed"; + })).toBe("refreshed"); + expect(existsSync(path)).toBe(true); + } finally { db.exec("ROLLBACK"); db.close(); } + }); + test("same refresh grant joins a live flight", async () => { const { getCodexAccountCredential,