11import { createHash } from "node:crypto";
2- import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2+ import { mkdir, open, readFile, rename, stat , unlink, writeFile } from "node:fs/promises";
33import { readFileSync } from "node:fs";
44import { homedir } from "node:os";
55import { dirname, join } from "node:path";
6+ import { setTimeout as delay } from "node:timers/promises";
67import type {
78 OAuthClientInformationFull,
89 OAuthTokens,
@@ -126,6 +127,10 @@ export function tryLoadAuthStateSync(
126127 return parseAuthState(raw);
127128}
128129
130+ function isEexist(err: unknown): boolean {
131+ return typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST";
132+ }
133+
129134// pid alone is not unique per call — concurrent saves in one process must not
130135// share a temp path or the second rename hits ENOENT after the first moves it.
131136let tmpWriteCounter = 0;
@@ -135,6 +140,70 @@ let tmpWriteCounter = 0;
135140// session's saveCodeVerifier wiping another's just-written tokens).
136141const updateChains = new Map<string, Promise<unknown>>();
137142
143+ const LOCK_STALE_MS = 5_000;
144+ const LOCK_RETRY_MS = 25;
145+
146+ async function acquireAuthFileLock(lockPath: string) {
147+ while (true) {
148+ try {
149+ return await open(lockPath, "wx", 0o600);
150+ } catch (err) {
151+ if (!isEexist(err)) throw err;
152+ try {
153+ const info = await stat(lockPath);
154+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
155+ try {
156+ await unlink(lockPath);
157+ } catch (unlinkErr) {
158+ if (!isEnoent(unlinkErr)) throw unlinkErr;
159+ }
160+ continue;
161+ }
162+ } catch (statErr) {
163+ if (isEnoent(statErr)) continue;
164+ throw statErr;
165+ }
166+ await delay(LOCK_RETRY_MS);
167+ }
168+ }
169+ }
170+
171+ async function withAuthFileLock<T>(path: string, op: () => Promise<T>): Promise<T> {
172+ const lockPath = `${path}.lock`;
173+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
174+ const lock = await acquireAuthFileLock(lockPath);
175+ try {
176+ return await op();
177+ } finally {
178+ try {
179+ await lock.close();
180+ } catch {
181+ // Close can fail if the handle was already torn down.
182+ }
183+ try {
184+ await unlink(lockPath);
185+ } catch {
186+ // Missing lock is fine; a leftover file is recovered as stale.
187+ }
188+ }
189+ }
190+
191+ function enqueueAuthFileOp<T>(path: string, op: () => Promise<T>): Promise<T> {
192+ const previous = updateChains.get(path) ?? Promise.resolve();
193+ const run = previous.then(
194+ () => withAuthFileLock(path, op),
195+ () => withAuthFileLock(path, op),
196+ );
197+ updateChains.set(
198+ path,
199+ run.then(
200+ () => undefined,
201+ () => undefined,
202+ ),
203+ );
204+ return run;
205+ }
206+
138207async function writeAuthFile(path: string, state: MCPAuthState): Promise<void> {
139208 await mkdir(dirname(path), { recursive: true, mode: 0o700 });
140209 const tmp = `${path}.${process.pid}.${(tmpWriteCounter += 1)}.tmp`;
@@ -151,19 +220,7 @@ export async function saveAuthState(
151220 home: string = homedir(),
152221): Promise<void> {
153222 const path = authFilePath(identity, home);
154- const previous = updateChains.get(path) ?? Promise.resolve();
155- const write = previous.then(
156- () => writeAuthFile(path, state),
157- () => writeAuthFile(path, state),
158- );
159- updateChains.set(
160- path,
161- write.then(
162- () => undefined,
163- () => undefined,
164- ),
165- );
166- await write;
223+ await enqueueAuthFileOp(path, () => writeAuthFile(path, state));
167224}
168225
169226// Load → mutate → save under the per-file chain. Mutator receives a mutable
@@ -174,49 +231,20 @@ export async function updateAuthState(
174231 home: string = homedir(),
175232): Promise<MCPAuthState> {
176233 const path = authFilePath(identity, home);
177- const previous = updateChains.get(path) ?? Promise.resolve();
178- const run = previous.then(
179- async () => {
180- const state = await loadAuthState(identity, home);
181- mutator(state);
182- await writeAuthFile(path, state);
183- return state;
184- },
185- async () => {
186- const state = await loadAuthState(identity, home);
187- mutator(state);
188- await writeAuthFile(path, state);
189- return state;
190- },
191- );
192- updateChains.set(
193- path,
194- run.then(
195- () => undefined,
196- () => undefined,
197- ),
198- );
199- return run;
234+ return enqueueAuthFileOp(path, async () => {
235+ const state = await loadAuthState(identity, home);
236+ mutator(state);
237+ await writeAuthFile(path, state);
238+ return state;
239+ });
200240}
201241
202242export async function deleteAuthState(
203243 identity: MCPAuthIdentity,
204244 home: string = homedir(),
205245): Promise<void> {
206246 const path = authFilePath(identity, home);
207- const previous = updateChains.get(path) ?? Promise.resolve();
208- const run = previous.then(
209- () => unlinkAuthFile(path),
210- () => unlinkAuthFile(path),
211- );
212- updateChains.set(
213- path,
214- run.then(
215- () => undefined,
216- () => undefined,
217- ),
218- );
219- await run;
247+ await enqueueAuthFileOp(path, () => unlinkAuthFile(path));
220248}
221249
222250async function unlinkAuthFile(path: string): Promise<void> {
0 commit comments