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