11/** 
22 * @fileoverview  Process locking utilities with stale detection and exit cleanup. 
33 * Provides cross-platform inter-process synchronization using file-system based locks. 
4+  * Aligned with npm's npx locking strategy (5-second stale timeout, periodic touching). 
45 */ 
56
6- import  {  existsSync ,  mkdirSync ,  statSync  }  from  'node:fs' 
7+ import  {  existsSync ,  mkdirSync ,  statSync ,   utimesSync  }  from  'node:fs' 
78
89import  {  safeDeleteSync  }  from  './fs' 
910import  {  logger  }  from  './logger' 
@@ -35,10 +36,17 @@ export interface ProcessLockOptions {
3536  /** 
3637   * Stale lock timeout in milliseconds. 
3738   * Locks older than this are considered abandoned and can be reclaimed. 
38-    * Aligned with npm's npx locking strategy (5-10  seconds). 
39-    * @default  10000 (10  seconds) 
39+    * Aligned with npm's npx locking strategy (5 seconds). 
40+    * @default  5000 (5  seconds) 
4041   */ 
4142  staleMs ?: number  |  undefined 
43+ 
44+   /** 
45+    * Interval for touching lock file to keep it fresh in milliseconds. 
46+    * Set to 0 to disable periodic touching. 
47+    * @default  2000 (2 seconds) 
48+    */ 
49+   touchIntervalMs ?: number  |  undefined 
4250} 
4351
4452/** 
@@ -48,6 +56,7 @@ export interface ProcessLockOptions {
4856 */ 
4957class  ProcessLockManager  { 
5058  private  activeLocks  =  new  Set < string > ( ) 
59+   private  touchTimers  =  new  Map < string ,  NodeJS . Timeout > ( ) 
5160  private  exitHandlerRegistered  =  false 
5261
5362  /** 
@@ -60,24 +69,85 @@ class ProcessLockManager {
6069    } 
6170
6271    onExit ( ( )  =>  { 
72+       // Clear all touch timers. 
73+       for  ( const  timer  of  this . touchTimers . values ( ) )  { 
74+         clearInterval ( timer ) 
75+       } 
76+       this . touchTimers . clear ( ) 
77+ 
78+       // Clean up all active locks. 
6379      for  ( const  lockPath  of  this . activeLocks )  { 
6480        try  { 
6581          if  ( existsSync ( lockPath ) )  { 
6682            safeDeleteSync ( lockPath ,  {  recursive : true  } ) 
6783          } 
6884        }  catch  { 
69-           // Ignore cleanup errors during exit 
85+           // Ignore cleanup errors during exit.  
7086        } 
7187      } 
7288    } ) 
7389
7490    this . exitHandlerRegistered  =  true 
7591  } 
7692
93+   /** 
94+    * Touch a lock file to update its mtime. 
95+    * This prevents the lock from being detected as stale during long operations. 
96+    * 
97+    * @param  lockPath - Path to the lock directory 
98+    */ 
99+   private  touchLock ( lockPath : string ) : void { 
100+     try  { 
101+       if  ( existsSync ( lockPath ) )  { 
102+         const  now  =  new  Date ( ) 
103+         utimesSync ( lockPath ,  now ,  now ) 
104+       } 
105+     }  catch  ( error )  { 
106+       logger . warn ( 
107+         `Failed to touch lock ${ lockPath } ${ error  instanceof  Error  ? error . message  : String ( error ) }  , 
108+       ) 
109+     } 
110+   } 
111+ 
112+   /** 
113+    * Start periodic touching of a lock file. 
114+    * Aligned with npm npx strategy to prevent false stale detection. 
115+    * 
116+    * @param  lockPath - Path to the lock directory 
117+    * @param  intervalMs - Touch interval in milliseconds 
118+    */ 
119+   private  startTouchTimer ( lockPath : string ,  intervalMs : number ) : void { 
120+     if  ( intervalMs  <=  0  ||  this . touchTimers . has ( lockPath ) )  { 
121+       return 
122+     } 
123+ 
124+     const  timer  =  setInterval ( ( )  =>  { 
125+       this . touchLock ( lockPath ) 
126+     } ,  intervalMs ) 
127+ 
128+     // Prevent timer from keeping process alive. 
129+     timer . unref ( ) 
130+ 
131+     this . touchTimers . set ( lockPath ,  timer ) 
132+   } 
133+ 
134+   /** 
135+    * Stop periodic touching of a lock file. 
136+    * 
137+    * @param  lockPath - Path to the lock directory 
138+    */ 
139+   private  stopTouchTimer ( lockPath : string ) : void { 
140+     const  timer  =  this . touchTimers . get ( lockPath ) 
141+     if  ( timer )  { 
142+       clearInterval ( timer ) 
143+       this . touchTimers . delete ( lockPath ) 
144+     } 
145+   } 
146+ 
77147  /** 
78148   * Check if a lock is stale based on mtime. 
79-    * A lock is considered stale if it's older than the specified timeout,  
80-    * indicating the holding process likely died abnormally . 
149+    * Uses second-level granularity to avoid APFS floating-point precision issues.  
150+    * Aligned with npm's npx locking strategy . 
81151   * 
82152   * @param  lockPath - Path to the lock directory 
83153   * @param  staleMs - Stale timeout in milliseconds 
@@ -90,8 +160,10 @@ class ProcessLockManager {
90160      } 
91161
92162      const  stats  =  statSync ( lockPath ) 
93-       const  age  =  Date . now ( )  -  stats . mtime . getTime ( ) 
94-       return  age  >  staleMs 
163+       // Use second-level granularity to avoid APFS issues. 
164+       const  ageSeconds  =  Math . floor ( ( Date . now ( )  -  stats . mtime . getTime ( ) )  /  1000 ) 
165+       const  staleSeconds  =  Math . floor ( staleMs  /  1000 ) 
166+       return  ageSeconds  >  staleSeconds 
95167    }  catch  { 
96168      return  false 
97169    } 
@@ -128,35 +200,39 @@ class ProcessLockManager {
128200      baseDelayMs =  100 , 
129201      maxDelayMs =  1000 , 
130202      retries =  3 , 
131-       staleMs =  10_000 , 
203+       staleMs =  5000 , 
204+       touchIntervalMs =  2000 , 
132205    }  =  options 
133206
134-     // Ensure exit handler is registered before any lock acquisition 
207+     // Ensure exit handler is registered before any lock acquisition.  
135208    this . ensureExitHandler ( ) 
136209
137210    return  await  pRetry ( 
138211      async  ( )  =>  { 
139212        try  { 
140-           // Check for stale lock and remove if necessary 
213+           // Check for stale lock and remove if necessary.  
141214          if  ( existsSync ( lockPath )  &&  this . isStale ( lockPath ,  staleMs ) )  { 
142215            logger . log ( `Removing stale lock: ${ lockPath }  ) 
143216            try  { 
144217              safeDeleteSync ( lockPath ,  {  recursive : true  } ) 
145218            }  catch  { 
146-               // Ignore errors removing stale lock - will retry 
219+               // Ignore errors removing stale lock - will retry.  
147220            } 
148221          } 
149222
150-           // Atomic lock acquisition via mkdir 
223+           // Atomic lock acquisition via mkdir.  
151224          mkdirSync ( lockPath ,  {  recursive : false  } ) 
152225
153-           // Track lock for cleanup 
226+           // Track lock for cleanup.  
154227          this . activeLocks . add ( lockPath ) 
155228
156-           // Return release function 
229+           // Start periodic touching to prevent stale detection. 
230+           this . startTouchTimer ( lockPath ,  touchIntervalMs ) 
231+ 
232+           // Return release function. 
157233          return  ( )  =>  this . release ( lockPath ) 
158234        }  catch  ( error )  { 
159-           // Handle lock contention 
235+           // Handle lock contention.  
160236          if  ( error  instanceof  Error  &&  ( error  as  any ) . code  ===  'EEXIST' )  { 
161237            if  ( this . isStale ( lockPath ,  staleMs ) )  { 
162238              throw  new  Error ( `Stale lock detected: ${ lockPath }  ) 
@@ -177,7 +253,7 @@ class ProcessLockManager {
177253
178254  /** 
179255   * Release a lock and remove from tracking. 
180-    * Removes the lock directory  and stops tracking it for exit cleanup . 
256+    * Stops periodic touching  and removes the lock directory . 
181257   * 
182258   * @param  lockPath - Path to the lock directory 
183259   * 
@@ -187,6 +263,9 @@ class ProcessLockManager {
187263   * ``` 
188264   */ 
189265  release ( lockPath : string ) : void { 
266+     // Stop periodic touching. 
267+     this . stopTouchTimer ( lockPath ) 
268+ 
190269    try  { 
191270      if  ( existsSync ( lockPath ) )  { 
192271        safeDeleteSync ( lockPath ,  {  recursive : true  } ) 
0 commit comments