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