|
1 | | -import { mkdir, open, readFile, stat, unlink } from "node:fs/promises"; |
2 | | -import { randomUUID } from "node:crypto"; |
3 | | -import { dirname } from "node:path"; |
| 1 | +import { withFileLock } from "../file-lock.js"; |
4 | 2 |
|
5 | 3 | // Serializes Codex OAuth refresh-and-persist sections across processes that |
6 | 4 | // share one credential store. Same-process callers chain on an in-memory |
7 | | -// tail; cross-process callers contend on an exclusive lock file with |
8 | | -// crashed-holder takeover via PID liveness; the inner TokenSession dedups |
9 | | -// overlapping refreshes. At most one refresh grant is in flight, so a second |
10 | | -// refresher sees the persisted result instead of racing refresh-token |
11 | | -// rotation and revoking its sibling. |
| 5 | +// tail; cross-process callers contend on an exclusive lock file with a |
| 6 | +// crashed-holder takeover via PID liveness. At most one refresh grant is in |
| 7 | +// flight, so a second refresher sees the persisted result instead of racing |
| 8 | +// refresh-token rotation and revoking its sibling. |
12 | 9 | // |
13 | 10 | // Accepted limits: PID liveness only means something on one host in one PID |
14 | 11 | // namespace (ESRCH would steal a live holder's lock, a recycled PID stalls |
15 | 12 | // recovery), and one lock path per store (two spellings contend on two |
16 | 13 | // files). Same-host headless runs satisfy both. |
17 | | -const tails = new Map<string, Promise<void>>(); |
18 | 14 |
|
19 | 15 | // Stale horizon for legacy locks that carry no holder PID (foreign writers, |
20 | 16 | // pre-tagging locks). Kept far above any legitimate hold and contender |
@@ -54,160 +50,17 @@ export class CodexRefreshLockTimeoutError extends Error { |
54 | 50 | } |
55 | 51 | } |
56 | 52 |
|
57 | | -// Holder tag: the creating PID (for crashed-holder liveness) plus a unique |
58 | | -// token, so only the creator removes the file — a belated release after a |
59 | | -// stale-takeover steal would otherwise hand two holders overlapping grants. |
60 | | -function holderTag(): string { |
61 | | - return `${String(process.pid)}:${randomUUID()}`; |
62 | | -} |
63 | | - |
64 | | -function holderPid(content: string): number | null { |
65 | | - const pid = Number(content.split(":")[0]?.trim()); |
66 | | - return Number.isInteger(pid) && pid > 0 ? pid : null; |
67 | | -} |
68 | | - |
69 | | -// kill(pid, 0) liveness: EPERM is alive (other user), ESRCH/EINVAL dead, |
70 | | -// anything else treated as alive so a confused signal check never steals a |
71 | | -// live holder's lock. Only meaningful within one PID namespace. |
72 | | -function isPidAlive(pid: number): boolean { |
73 | | - try { |
74 | | - process.kill(pid, 0); |
75 | | - return true; |
76 | | - } catch (err) { |
77 | | - const code = (err as NodeJS.ErrnoException)?.code; |
78 | | - return code !== "ESRCH" && code !== "EINVAL"; |
79 | | - } |
80 | | -} |
81 | | - |
82 | | -async function readLockContent(lockPath: string): Promise<string | null> { |
83 | | - try { |
84 | | - return await readFile(lockPath, "utf8"); |
85 | | - } catch { |
86 | | - return null; |
87 | | - } |
88 | | -} |
89 | | - |
90 | | -// Safe to take over: a tagged holder with a dead PID, or an untagged lock |
91 | | -// older than the stale horizon. |
92 | | -async function isLockStale( |
93 | | - lockPath: string, |
94 | | - staleMs: number, |
95 | | - content: string | null, |
96 | | -): Promise<boolean> { |
97 | | - if (content === null) return false; |
98 | | - const pid = holderPid(content); |
99 | | - if (pid !== null) return !isPidAlive(pid); |
100 | | - try { |
101 | | - const info = await stat(lockPath); |
102 | | - return Date.now() - info.mtimeMs > staleMs; |
103 | | - } catch { |
104 | | - // The lock vanished between read and stat: not ours to take over. |
105 | | - return false; |
106 | | - } |
107 | | -} |
108 | | - |
109 | | -async function acquireLockFile( |
110 | | - lockPath: string, |
111 | | - timeoutMs: number, |
112 | | - retryMs: number, |
113 | | - staleMs: number, |
114 | | - deadline: number, |
115 | | -): Promise<string> { |
116 | | - await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 }); |
117 | | - for (;;) { |
118 | | - const tag = holderTag(); |
119 | | - try { |
120 | | - const handle = await open(lockPath, "wx", 0o600); |
121 | | - await handle.writeFile(tag, "utf8"); |
122 | | - await handle.close(); |
123 | | - return tag; |
124 | | - } catch (err) { |
125 | | - if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err; |
126 | | - } |
127 | | - // A crashed holder never releases: unlink and take over. Re-check the |
128 | | - // content first so a concurrent winner's fresh lock is not unlinked. |
129 | | - const content = await readLockContent(lockPath); |
130 | | - if (content !== null && (await isLockStale(lockPath, staleMs, content))) { |
131 | | - if ((await readLockContent(lockPath)) === content) { |
132 | | - await unlink(lockPath).catch(() => undefined); |
133 | | - } |
134 | | - continue; |
135 | | - } |
136 | | - if (Date.now() >= deadline) |
137 | | - throw new CodexRefreshLockTimeoutError(lockPath, timeoutMs); |
138 | | - await new Promise((resolve) => setTimeout(resolve, retryMs)); |
139 | | - } |
140 | | -} |
141 | | - |
142 | | -// Bound the in-memory queue wait by the same deadline as file contention, |
143 | | -// or a backed-up queue stalls far longer than timeoutMs promises. |
144 | | -async function waitForTail( |
145 | | - previous: Promise<void>, |
146 | | - lockPath: string, |
147 | | - timeoutMs: number, |
148 | | - deadline: number, |
149 | | -): Promise<void> { |
150 | | - let timer: ReturnType<typeof setTimeout> | undefined; |
151 | | - try { |
152 | | - await Promise.race([ |
153 | | - previous.catch(() => undefined), |
154 | | - new Promise<never>((_, reject) => { |
155 | | - const remaining = deadline - Date.now(); |
156 | | - if (remaining <= 0) { |
157 | | - reject(new CodexRefreshLockTimeoutError(lockPath, timeoutMs)); |
158 | | - return; |
159 | | - } |
160 | | - timer = setTimeout(() => { |
161 | | - reject(new CodexRefreshLockTimeoutError(lockPath, timeoutMs)); |
162 | | - }, remaining); |
163 | | - }), |
164 | | - ]); |
165 | | - } finally { |
166 | | - clearTimeout(timer); |
167 | | - } |
168 | | -} |
169 | | - |
170 | | -/** |
171 | | - * Runs `work` while holding the refresh lock at `lockPath`. Same-process |
172 | | - * callers queue in memory, other processes on the lock file; one deadline |
173 | | - * covers both, so the call holds or throws within ~timeoutMs. The creating |
174 | | - * holder removes the file. Callers must pass one canonical path per store. |
175 | | - */ |
176 | 53 | export async function withCodexRefreshLock<T>( |
177 | 54 | lockPath: string, |
178 | 55 | work: () => Promise<T>, |
179 | 56 | options: CodexRefreshLockOptions = {}, |
180 | 57 | ): Promise<T> { |
181 | 58 | const timeoutMs = options.timeoutMs ?? 30_000; |
182 | | - const retryMs = options.retryMs ?? 25; |
183 | | - const staleMs = options.staleMs ?? DEFAULT_STALE_MS; |
184 | | - const deadline = Date.now() + timeoutMs; |
185 | | - const previous = tails.get(lockPath) ?? Promise.resolve(); |
186 | | - let releaseTail!: () => void; |
187 | | - const current = new Promise<void>((resolve) => { |
188 | | - releaseTail = resolve; |
| 59 | + return withFileLock(lockPath, work, { |
| 60 | + timeoutMs, |
| 61 | + retryMs: options.retryMs ?? 25, |
| 62 | + staleMs: options.staleMs ?? DEFAULT_STALE_MS, |
| 63 | + timeoutError: (lockPath: string, timeoutMs: number) => |
| 64 | + new CodexRefreshLockTimeoutError(lockPath, timeoutMs), |
189 | 65 | }); |
190 | | - tails.set(lockPath, current); |
191 | | - try { |
192 | | - await waitForTail(previous, lockPath, timeoutMs, deadline); |
193 | | - const tag = await acquireLockFile( |
194 | | - lockPath, |
195 | | - timeoutMs, |
196 | | - retryMs, |
197 | | - staleMs, |
198 | | - deadline, |
199 | | - ); |
200 | | - try { |
201 | | - return await work(); |
202 | | - } finally { |
203 | | - // Fenced release: remove the file only while it still carries our |
204 | | - // tag, or a stale-takeover steal would hand two holders grants. |
205 | | - if ((await readLockContent(lockPath)) === tag) { |
206 | | - await unlink(lockPath).catch(() => undefined); |
207 | | - } |
208 | | - } |
209 | | - } finally { |
210 | | - if (tails.get(lockPath) === current) tails.delete(lockPath); |
211 | | - releaseTail(); |
212 | | - } |
213 | 66 | } |
0 commit comments