Skip to content

Commit 704a81f

Browse files
Merge pull request #1139 from corbitsdev/cl-8628-serialize-oauth-token-refresh-for-shared-codex-credentials
fix(codex): serialize shared-credential OAuth token refreshes
2 parents 2c38c6f + 0dcc526 commit 704a81f

8 files changed

Lines changed: 878 additions & 6 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import {
2+
mkdtemp,
3+
readFile,
4+
rm,
5+
stat,
6+
utimes,
7+
writeFile,
8+
} from "node:fs/promises";
9+
import { tmpdir } from "node:os";
10+
import { join } from "node:path";
11+
import { describe, expect, test } from "bun:test";
12+
import {
13+
CodexRefreshLockTimeoutError,
14+
withCodexRefreshLock,
15+
} from "./refresh-lock.js";
16+
17+
async function tempDir(): Promise<string> {
18+
return mkdtemp(join(tmpdir(), "cl8628-lock-"));
19+
}
20+
21+
describe("codex refresh lock", () => {
22+
test("concurrent holders serialize and the lock file is removed", async () => {
23+
const dir = await tempDir();
24+
try {
25+
const lock = join(dir, "refresh.lock");
26+
let active = 0;
27+
let maxActive = 0;
28+
const results = await Promise.all(
29+
[0, 1, 2, 3, 4].map((i) =>
30+
withCodexRefreshLock(lock, async () => {
31+
active += 1;
32+
maxActive = Math.max(maxActive, active);
33+
// The file exists while held so a second process contends on it.
34+
await stat(lock);
35+
await new Promise((resolve) => setTimeout(resolve, 20));
36+
active -= 1;
37+
return i;
38+
}),
39+
),
40+
);
41+
expect(results).toEqual([0, 1, 2, 3, 4]);
42+
expect(maxActive).toBe(1);
43+
await expect(stat(lock)).rejects.toThrow();
44+
} finally {
45+
await rm(dir, { recursive: true, force: true });
46+
}
47+
});
48+
49+
test("a foreign-held lock times out with a recovery hint", async () => {
50+
const dir = await tempDir();
51+
try {
52+
const lock = join(dir, "refresh.lock");
53+
// Simulate a lock held by another process: withCodexRefreshLock never
54+
// created it, so only the file path (not the in-memory chain) applies.
55+
await writeFile(lock, "");
56+
let failure: unknown;
57+
try {
58+
await withCodexRefreshLock(lock, async () => "never", {
59+
timeoutMs: 100,
60+
retryMs: 10,
61+
});
62+
} catch (err) {
63+
failure = err;
64+
}
65+
expect(failure).toBeInstanceOf(CodexRefreshLockTimeoutError);
66+
expect((failure as CodexRefreshLockTimeoutError).lockPath).toBe(lock);
67+
expect((failure as CodexRefreshLockTimeoutError).message).toContain(
68+
"remove this lock file manually and retry",
69+
);
70+
} finally {
71+
await rm(dir, { recursive: true, force: true });
72+
}
73+
});
74+
75+
test("a stale lock from a crashed holder is taken over", async () => {
76+
const dir = await tempDir();
77+
try {
78+
const lock = join(dir, "refresh.lock");
79+
await writeFile(lock, "");
80+
const ancient = new Date(Date.now() - 60_000);
81+
await utimes(lock, ancient, ancient);
82+
const result = await withCodexRefreshLock(
83+
lock,
84+
async () => "taken-over",
85+
{
86+
timeoutMs: 5_000,
87+
staleMs: 1_000,
88+
},
89+
);
90+
expect(result).toBe("taken-over");
91+
await expect(stat(lock)).rejects.toThrow();
92+
} finally {
93+
await rm(dir, { recursive: true, force: true });
94+
}
95+
});
96+
97+
test("a crashed holder's lock is taken over under default options", async () => {
98+
const dir = await tempDir();
99+
try {
100+
const lock = join(dir, "refresh.lock");
101+
// Simulate a crashed holder in the real tag format but with a PID that
102+
// is already dead: takeover must fire via liveness, not the stale
103+
// horizon (which defaults far above the default timeout).
104+
const exited = Bun.spawn(["bun", "--version"], {
105+
stdout: "ignore",
106+
stderr: "ignore",
107+
});
108+
await exited.exited;
109+
await writeFile(lock, `${String(exited.pid)}:crashed-holder`);
110+
const result = await withCodexRefreshLock(lock, async () => "recovered");
111+
expect(result).toBe("recovered");
112+
await expect(stat(lock)).rejects.toThrow();
113+
} finally {
114+
await rm(dir, { recursive: true, force: true });
115+
}
116+
});
117+
118+
test("a belated release never deletes a takeover holder's lock", async () => {
119+
const dir = await tempDir();
120+
try {
121+
const lock = join(dir, "refresh.lock");
122+
await withCodexRefreshLock(lock, async () => {
123+
// Simulate a stale-takeover steal landing mid-hold: the victim's
124+
// release must leave the new holder's file alone.
125+
await writeFile(lock, "42424242:takeover-holder");
126+
});
127+
expect(await readFile(lock, "utf8")).toBe("42424242:takeover-holder");
128+
} finally {
129+
await rm(dir, { recursive: true, force: true });
130+
}
131+
});
132+
133+
test("same-process queue wait is bounded by the timeout", async () => {
134+
const dir = await tempDir();
135+
try {
136+
const lock = join(dir, "refresh.lock");
137+
let release!: () => void;
138+
const gate = new Promise<void>((resolve) => {
139+
release = resolve;
140+
});
141+
const first = withCodexRefreshLock(lock, async () => {
142+
await gate;
143+
return "first";
144+
});
145+
// The second waiter queues behind the first in memory, outside the
146+
// file timer: it must still give up within its own timeout.
147+
await expect(
148+
withCodexRefreshLock(lock, async () => "second", {
149+
timeoutMs: 100,
150+
retryMs: 10,
151+
}),
152+
).rejects.toBeInstanceOf(CodexRefreshLockTimeoutError);
153+
release();
154+
expect(await first).toBe("first");
155+
} finally {
156+
await rm(dir, { recursive: true, force: true });
157+
}
158+
});
159+
160+
test("a lock held by another process blocks acquisition until released", async () => {
161+
const dir = await tempDir();
162+
const holderPath = new URL(
163+
"../../../tests/fixtures/codex-refresh-lock/hold-lock.ts",
164+
import.meta.url,
165+
).pathname;
166+
const lock = join(dir, "refresh.lock");
167+
const proc = Bun.spawn(["bun", "run", holderPath, lock, "1500"], {
168+
stdout: "pipe",
169+
stderr: "pipe",
170+
});
171+
try {
172+
// Wait until the holder process actually holds the file lock.
173+
if (proc.stdout === null) throw new Error("holder has no stdout pipe");
174+
const reader = proc.stdout.getReader();
175+
const decoder = new TextDecoder();
176+
let output = "";
177+
const deadline = Date.now() + 10_000;
178+
try {
179+
while (!output.includes("held")) {
180+
if (Date.now() > deadline)
181+
throw new Error("lock holder never acquired the lock");
182+
const { value, done } = await reader.read();
183+
if (done) break;
184+
output += decoder.decode(value, { stream: true });
185+
}
186+
} finally {
187+
reader.releaseLock();
188+
}
189+
expect(output).toContain("held");
190+
191+
// While the other process holds it, acquisition times out instead of
192+
// overlapping the grant.
193+
let failure: unknown;
194+
try {
195+
await withCodexRefreshLock(lock, async () => "never", {
196+
timeoutMs: 300,
197+
retryMs: 10,
198+
});
199+
} catch (err) {
200+
failure = err;
201+
}
202+
expect(failure).toBeInstanceOf(CodexRefreshLockTimeoutError);
203+
204+
// Once the holder exits and releases, the lock is acquirable again.
205+
await proc.exited;
206+
expect(await withCodexRefreshLock(lock, async () => "acquired")).toBe(
207+
"acquired",
208+
);
209+
} finally {
210+
proc.kill();
211+
await rm(dir, { recursive: true, force: true });
212+
}
213+
});
214+
});

0 commit comments

Comments
 (0)