11import {
22 chmodSync ,
3+ closeSync ,
34 existsSync ,
45 mkdirSync ,
6+ openSync ,
57 readFileSync ,
68 renameSync ,
79 statSync ,
10+ unlinkSync ,
811 writeFileSync ,
912} from 'node:fs' ;
1013import { homedir } from 'node:os' ;
@@ -109,22 +112,26 @@ export function writeProfile(
109112 options : CredentialsOptions = { } ,
110113) : void {
111114 const path = resolvePath ( options ) ;
112- const file = readCredentialsFile ( options ) ;
113- file [ profile ] = { ...file [ profile ] , ...entry } ;
114- writeCredentialsAtomic ( path , file ) ;
115+ withCredentialsLock ( path , ( ) => {
116+ const file = readCredentialsFile ( options ) ;
117+ file [ profile ] = { ...file [ profile ] , ...entry } ;
118+ writeCredentialsAtomic ( path , file ) ;
119+ } ) ;
115120}
116121
117122export function deleteProfile ( profile : string , options : CredentialsOptions = { } ) : boolean {
118123 const path = resolvePath ( options ) ;
119- const file = readCredentialsFile ( options ) ;
120- if ( ! ( profile in file ) ) return false ;
121- delete file [ profile ] ;
122- if ( Object . keys ( file ) . length === 0 ) {
123- writeCredentialsAtomic ( path , { } ) ;
124- } else {
125- writeCredentialsAtomic ( path , file ) ;
126- }
127- return true ;
124+ return withCredentialsLock ( path , ( ) => {
125+ const file = readCredentialsFile ( options ) ;
126+ if ( ! ( profile in file ) ) return false ;
127+ delete file [ profile ] ;
128+ if ( Object . keys ( file ) . length === 0 ) {
129+ writeCredentialsAtomic ( path , { } ) ;
130+ } else {
131+ writeCredentialsAtomic ( path , file ) ;
132+ }
133+ return true ;
134+ } ) ;
128135}
129136
130137export function ensureRestrictiveMode ( path : string ) : void {
@@ -144,3 +151,91 @@ function writeCredentialsAtomic(path: string, file: CredentialsFile): void {
144151 renameSync ( tmp , path ) ;
145152 ensureRestrictiveMode ( path ) ;
146153}
154+
155+ /** Max wall-clock wait when another process holds the credentials lock. */
156+ const CREDENTIALS_LOCK_MAX_WAIT_MS = 10_000 ;
157+ /** Back-off between lock attempts. */
158+ const CREDENTIALS_LOCK_RETRY_MS = 25 ;
159+ /** Reclaim a lock file when the holder pid is gone or the file is older than this. */
160+ const CREDENTIALS_LOCK_STALE_MS = 30_000 ;
161+
162+ function credentialsLockPath ( credentialsPath : string ) : string {
163+ return `${ credentialsPath } .lock` ;
164+ }
165+
166+ /**
167+ * Serialize read-modify-write on the credentials file across processes.
168+ * `writeCredentialsAtomic` only makes the final rename atomic; without this
169+ * lock, concurrent `writeProfile` / `deleteProfile` calls can each read the
170+ * same snapshot and the last rename wins — silently dropping the other update.
171+ */
172+ function withCredentialsLock < T > ( credentialsPath : string , fn : ( ) => T ) : T {
173+ acquireCredentialsLock ( credentialsPath ) ;
174+ try {
175+ return fn ( ) ;
176+ } finally {
177+ releaseCredentialsLock ( credentialsPath ) ;
178+ }
179+ }
180+
181+ function acquireCredentialsLock ( credentialsPath : string ) : void {
182+ const lockPath = credentialsLockPath ( credentialsPath ) ;
183+ const deadline = Date . now ( ) + CREDENTIALS_LOCK_MAX_WAIT_MS ;
184+ while ( Date . now ( ) < deadline ) {
185+ try {
186+ const fd = openSync ( lockPath , 'wx' ) ;
187+ try {
188+ writeFileSync ( fd , `${ process . pid } \n${ Date . now ( ) } \n` , 'utf8' ) ;
189+ } finally {
190+ closeSync ( fd ) ;
191+ }
192+ return ;
193+ } catch ( err ) {
194+ const code = ( err as NodeJS . ErrnoException ) . code ;
195+ if ( code !== 'EEXIST' ) throw err ;
196+ if ( isStaleCredentialsLock ( lockPath ) ) {
197+ try {
198+ unlinkSync ( lockPath ) ;
199+ } catch {
200+ // Another waiter may have claimed or released the lock.
201+ }
202+ continue ;
203+ }
204+ syncSleep ( CREDENTIALS_LOCK_RETRY_MS ) ;
205+ }
206+ }
207+ throw new Error ( `Timed out acquiring credentials lock: ${ lockPath } ` ) ;
208+ }
209+
210+ function releaseCredentialsLock ( credentialsPath : string ) : void {
211+ try {
212+ unlinkSync ( credentialsLockPath ( credentialsPath ) ) ;
213+ } catch {
214+ // Lock already released or never acquired — teardown must not mask errors.
215+ }
216+ }
217+
218+ function isStaleCredentialsLock ( lockPath : string ) : boolean {
219+ try {
220+ const stat = statSync ( lockPath ) ;
221+ if ( Date . now ( ) - stat . mtimeMs > CREDENTIALS_LOCK_STALE_MS ) return true ;
222+ const firstLine = readFileSync ( lockPath , 'utf8' ) . split ( '\n' ) [ 0 ] ?? '' ;
223+ const pid = Number . parseInt ( firstLine , 10 ) ;
224+ if ( ! Number . isFinite ( pid ) || pid <= 0 ) return true ;
225+ try {
226+ process . kill ( pid , 0 ) ;
227+ return false ;
228+ } catch {
229+ return true ;
230+ }
231+ } catch {
232+ return false ;
233+ }
234+ }
235+
236+ function syncSleep ( ms : number ) : void {
237+ const until = Date . now ( ) + ms ;
238+ while ( Date . now ( ) < until ) {
239+ // Busy-wait: credentials I/O is sync-only; sub-ms precision is unnecessary.
240+ }
241+ }
0 commit comments