11import { createHash , randomUUID } from "node:crypto" ;
2- import { mkdir , readFile , rmdir , unlink , writeFile } from "node:fs/promises" ;
2+ import { lstat , mkdir , readFile , rmdir , unlink , writeFile } from "node:fs/promises" ;
33import { setTimeout as sleep } from "node:timers/promises" ;
44import { dirname , join } from "node:path" ;
55import { getConfigFile } from "../../../lib/config.ts" ;
@@ -12,6 +12,15 @@ const IDEMPOTENCY_KEY_PATTERN =
1212 / ^ c l e r k - i n i t - i o s - r e g i s t r a t i o n - [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 1 - 8 ] [ 0 - 9 a - f ] { 3 } - [ 8 9 a b ] [ 0 - 9 a - f ] { 3 } - [ 0 - 9 a - f ] { 12 } $ / i;
1313const CONCURRENT_WRITE_ATTEMPTS = 20 ;
1414const CONCURRENT_WRITE_RETRY_MS = 5 ;
15+ const LOCK_RETRY_MS = 10 ;
16+ const LOCK_TIMEOUT_MS = 5_000 ;
17+ const LOCK_STALE_MS = 30_000 ;
18+
19+ interface IOSNativeRegistrationRetryStoreOptions {
20+ lockRetryMs ?: number ;
21+ lockTimeoutMs ?: number ;
22+ lockStaleMs ?: number ;
23+ }
1524
1625export interface IOSNativeRegistrationRetryIdentity {
1726 applicationId : string ;
@@ -22,7 +31,8 @@ export interface IOSNativeRegistrationRetryIdentity {
2231
2332export interface IOSNativeRegistrationRetryStore {
2433 getOrCreate ( identity : IOSNativeRegistrationRetryIdentity ) : Promise < string > ;
25- clear ( identity : IOSNativeRegistrationRetryIdentity ) : Promise < void > ;
34+ peek ( identity : IOSNativeRegistrationRetryIdentity ) : Promise < string | undefined > ;
35+ clear ( identity : IOSNativeRegistrationRetryIdentity , expectedKey : string ) : Promise < boolean > ;
2636}
2737
2838interface IOSNativeRegistrationRetryRecord {
@@ -69,6 +79,74 @@ function isExistingFile(error: unknown): boolean {
6979 return ( error as NodeJS . ErrnoException ) . code === "EEXIST" ;
7080}
7181
82+ function lockPath ( baseDirectory : string , identity : IOSNativeRegistrationRetryIdentity ) : string {
83+ return `${ retryPath ( baseDirectory , identity ) } .lock` ;
84+ }
85+
86+ async function acquireLock (
87+ baseDirectory : string ,
88+ identity : IOSNativeRegistrationRetryIdentity ,
89+ options : Required < IOSNativeRegistrationRetryStoreOptions > ,
90+ ) : Promise < string > {
91+ await mkdir ( retryDirectory ( baseDirectory ) , { recursive : true , mode : 0o700 } ) ;
92+ const path = lockPath ( baseDirectory , identity ) ;
93+ const deadline = Date . now ( ) + options . lockTimeoutMs ;
94+ while ( true ) {
95+ try {
96+ await mkdir ( path , { mode : 0o700 } ) ;
97+ return path ;
98+ } catch ( error ) {
99+ if ( ! isExistingFile ( error ) ) throw error ;
100+ if ( Date . now ( ) >= deadline ) {
101+ let stale = false ;
102+ try {
103+ stale = Date . now ( ) - ( await lstat ( path ) ) . mtimeMs >= options . lockStaleMs ;
104+ } catch ( statError ) {
105+ if ( isMissingFile ( statError ) ) continue ;
106+ throw statError ;
107+ }
108+ throw new Error (
109+ stale
110+ ? `The Clerk iOS registration retry-state lock is stale and was left in place for safety: ${ path } `
111+ : "Timed out waiting for the Clerk iOS registration retry-state lock." ,
112+ ) ;
113+ }
114+ await sleep ( options . lockRetryMs ) ;
115+ }
116+ }
117+ }
118+
119+ async function withIdentityLock < T > (
120+ baseDirectory : string ,
121+ identity : IOSNativeRegistrationRetryIdentity ,
122+ options : Required < IOSNativeRegistrationRetryStoreOptions > ,
123+ operation : ( ) => Promise < T > ,
124+ ) : Promise < T > {
125+ const path = await acquireLock ( baseDirectory , identity , options ) ;
126+ const release = async ( ) => {
127+ try {
128+ await rmdir ( path ) ;
129+ } catch ( error ) {
130+ if ( ! isMissingFile ( error ) ) throw error ;
131+ }
132+ } ;
133+ try {
134+ const result = await operation ( ) ;
135+ await release ( ) ;
136+ return result ;
137+ } catch ( operationError ) {
138+ try {
139+ await release ( ) ;
140+ } catch ( releaseError ) {
141+ throw new AggregateError (
142+ [ operationError , releaseError ] ,
143+ "The Clerk iOS registration retry operation and lock release both failed." ,
144+ ) ;
145+ }
146+ throw operationError ;
147+ }
148+ }
149+
72150function isRetryRecord (
73151 value : unknown ,
74152 identity : IOSNativeRegistrationRetryIdentity ,
@@ -173,40 +251,64 @@ async function getOrCreateRetryKey(
173251async function clearRetryKey (
174252 baseDirectory : string ,
175253 identity : IOSNativeRegistrationRetryIdentity ,
176- ) : Promise < void > {
254+ expectedKey : string ,
255+ ) : Promise < boolean > {
256+ const existing = await readRetryRecord ( baseDirectory , identity ) ;
257+ if ( ! existing ) return true ;
258+ if ( existing . idempotencyKey !== expectedKey ) return false ;
177259 try {
178260 await unlink ( retryPath ( baseDirectory , identity ) ) ;
179261 } catch ( error ) {
180262 if ( ! isMissingFile ( error ) ) throw error ;
181- return ;
182- }
183-
184- // Remove only the empty operational directory. Never remove other CLI state.
185- try {
186- await rmdir ( retryDirectory ( baseDirectory ) ) ;
187- } catch {
188- // Another retry record is present, or another process started a retry.
263+ return true ;
189264 }
265+ return true ;
190266}
191267
192268export function createIOSNativeRegistrationRetryStore (
193269 resolveBaseDirectory : ( ) => string = ( ) => dirname ( getConfigFile ( ) ) ,
270+ options : IOSNativeRegistrationRetryStoreOptions = { } ,
194271) : IOSNativeRegistrationRetryStore {
272+ const lockOptions : Required < IOSNativeRegistrationRetryStoreOptions > = {
273+ lockRetryMs : options . lockRetryMs ?? LOCK_RETRY_MS ,
274+ lockTimeoutMs : options . lockTimeoutMs ?? LOCK_TIMEOUT_MS ,
275+ lockStaleMs : options . lockStaleMs ?? LOCK_STALE_MS ,
276+ } ;
195277 return {
196278 async getOrCreate ( identity ) {
197279 const baseDirectory = resolveBaseDirectory ( ) ;
198280 const path = retryPath ( baseDirectory , identity ) ;
199281 return withHomeFsAccess (
200282 { operation : "write" , target : path , label : "CLI idempotency state directory" } ,
201- async ( ) => getOrCreateRetryKey ( baseDirectory , identity ) ,
283+ async ( ) =>
284+ withIdentityLock ( baseDirectory , identity , lockOptions , async ( ) =>
285+ getOrCreateRetryKey ( baseDirectory , identity ) ,
286+ ) ,
202287 ) ;
203288 } ,
204- async clear ( identity ) {
289+ async peek ( identity ) {
205290 const baseDirectory = resolveBaseDirectory ( ) ;
206291 const path = retryPath ( baseDirectory , identity ) ;
207- await withHomeFsAccess (
292+ return withHomeFsAccess (
293+ { operation : "read" , target : path , label : "CLI idempotency state directory" } ,
294+ async ( ) =>
295+ withIdentityLock (
296+ baseDirectory ,
297+ identity ,
298+ lockOptions ,
299+ async ( ) => ( await readRetryRecord ( baseDirectory , identity ) ) ?. idempotencyKey ,
300+ ) ,
301+ ) ;
302+ } ,
303+ async clear ( identity , expectedKey ) {
304+ const baseDirectory = resolveBaseDirectory ( ) ;
305+ const path = retryPath ( baseDirectory , identity ) ;
306+ return withHomeFsAccess (
208307 { operation : "delete" , target : path , label : "CLI idempotency state directory" } ,
209- async ( ) => clearRetryKey ( baseDirectory , identity ) ,
308+ async ( ) =>
309+ withIdentityLock ( baseDirectory , identity , lockOptions , async ( ) =>
310+ clearRetryKey ( baseDirectory , identity , expectedKey ) ,
311+ ) ,
210312 ) ;
211313 } ,
212314 } ;
0 commit comments