@@ -18,6 +18,8 @@ export interface AcquiredFileLock {
1818
1919export const FILE_LOCK_DIR_SUFFIX = '.lock' ;
2020export const FILE_LOCK_OWNER_MARKER_PREFIX = 'owner-' ;
21+ export const FILE_LOCK_RETAINED_MARKER_PREFIX = 'retained-' ;
22+ /** Legacy retained marker. It remains recognizable but cannot be safely reclaimed. */
2123export const FILE_LOCK_RETAINED_MARKER = 'retained' ;
2224
2325export type ProcessLiveness = 'live' | 'dead' | 'unavailable' ;
@@ -40,7 +42,7 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
4042 lockPath ,
4143 `${ FILE_LOCK_OWNER_MARKER_PREFIX } ${ process . pid } -${ crypto . randomBytes ( 16 ) . toString ( 'hex' ) } ` ,
4244 ) ;
43- const retainedMarker = path . join ( lockPath , FILE_LOCK_RETAINED_MARKER ) ;
45+ const retainedMarker = path . join ( lockPath , getRetainedMarkerName ( path . basename ( ownerMarker ) ) ) ;
4446 const deadline = Date . now ( ) + options . timeoutMs ;
4547
4648 while ( true ) {
@@ -69,22 +71,9 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
6971 }
7072 state = 'retained' ;
7173 try {
72- await fsapi . writeFile ( retainedMarker , '' , { flag : 'wx' } ) ;
73- } catch ( error ) {
74- if ( hasErrorCode ( error , 'EEXIST' ) ) {
75- return ;
76- }
77- try {
78- await fsapi . rename ( ownerMarker , retainedMarker ) ;
79- } catch ( renameError ) {
80- if ( ! hasErrorCode ( renameError , 'EEXIST' ) ) {
81- throw createLockError (
82- 'Failed to mark the lock as retained' ,
83- 'ERETAINFAILED' ,
84- lockPath ,
85- ) ;
86- }
87- }
74+ await fsapi . rename ( ownerMarker , retainedMarker ) ;
75+ } catch ( _error ) {
76+ throw createLockError ( 'Failed to mark the lock as retained' , 'ERETAINFAILED' , lockPath ) ;
8877 }
8978 } ,
9079 release : async ( ) => {
@@ -119,72 +108,117 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
119108}
120109
121110export async function inspectFileLock ( filePath : string , options ?: InspectFileLockOptions ) : Promise < FileLockState > {
111+ return ( await inspectFileLockSnapshot ( filePath , options ) ) . state ;
112+ }
113+
114+ interface FileLockSnapshot {
115+ readonly state : FileLockState ;
116+ readonly marker ?: string ;
117+ readonly markerKind ?: 'owner' | 'retained' ;
118+ }
119+
120+ async function inspectFileLockSnapshot (
121+ filePath : string ,
122+ options ?: InspectFileLockOptions ,
123+ ) : Promise < FileLockSnapshot > {
122124 const lockPath = getFileLockPath ( filePath ) ;
123125
124126 let stat ;
125127 try {
126128 stat = await fsapi . lstat ( lockPath ) ;
127129 } catch ( error ) {
128130 if ( hasErrorCode ( error , 'ENOENT' ) ) {
129- return 'missing' ;
131+ return { state : 'missing' } ;
130132 }
131133 throw error ;
132134 }
133135
134136 if ( ! stat . isDirectory ( ) || stat . isSymbolicLink ( ) ) {
135- return 'malformed' ;
137+ return { state : 'malformed' } ;
136138 }
137139
138140 const entries = await fsapi . readdir ( lockPath ) ;
139141 const ownerEntries = entries . filter ( ( entry ) => entry . startsWith ( FILE_LOCK_OWNER_MARKER_PREFIX ) ) ;
142+ const generationRetainedEntries = entries . filter ( ( entry ) => entry . startsWith ( FILE_LOCK_RETAINED_MARKER_PREFIX ) ) ;
140143 const retainedEntries = entries . filter ( ( entry ) => entry === FILE_LOCK_RETAINED_MARKER ) ;
141144 const unknownEntries = entries . filter (
142- ( entry ) => ! entry . startsWith ( FILE_LOCK_OWNER_MARKER_PREFIX ) && entry !== FILE_LOCK_RETAINED_MARKER ,
145+ ( entry ) =>
146+ ! entry . startsWith ( FILE_LOCK_OWNER_MARKER_PREFIX ) &&
147+ ! entry . startsWith ( FILE_LOCK_RETAINED_MARKER_PREFIX ) &&
148+ entry !== FILE_LOCK_RETAINED_MARKER ,
143149 ) ;
144150
145- if ( unknownEntries . length > 0 || ownerEntries . length > 1 || retainedEntries . length > 1 ) {
146- return 'malformed' ;
151+ if (
152+ unknownEntries . length > 0 ||
153+ ownerEntries . length > 1 ||
154+ generationRetainedEntries . length > 1 ||
155+ retainedEntries . length > 1 ||
156+ generationRetainedEntries . length + retainedEntries . length > 1 ||
157+ generationRetainedEntries . length + ownerEntries . length > 1
158+ ) {
159+ return { state : 'malformed' } ;
147160 }
148161 if ( retainedEntries . length === 1 ) {
149- return 'retained' ;
162+ return { state : 'retained' } ;
163+ }
164+ if ( generationRetainedEntries . length === 1 ) {
165+ const retainedPid = parseMarkerPid ( generationRetainedEntries [ 0 ] , FILE_LOCK_RETAINED_MARKER_PREFIX ) ;
166+ if ( retainedPid === undefined ) {
167+ return { state : 'malformed' } ;
168+ }
169+ return { state : 'retained' , marker : generationRetainedEntries [ 0 ] , markerKind : 'retained' } ;
150170 }
151171 if ( ownerEntries . length === 1 ) {
152- const ownerPid = parseOwnerPid ( ownerEntries [ 0 ] ) ;
172+ const ownerPid = parseMarkerPid ( ownerEntries [ 0 ] , FILE_LOCK_OWNER_MARKER_PREFIX ) ;
153173 if ( ownerPid === undefined ) {
154- return 'malformed' ;
174+ return { state : 'malformed' } ;
155175 }
156176 const liveness = await ( options ?. checkProcessLiveness ?? getProcessLiveness ) ( ownerPid ) ;
157177 if ( liveness === 'dead' ) {
158- return 'stale' ;
178+ return { state : 'stale' , marker : ownerEntries [ 0 ] , markerKind : 'owner' } ;
159179 }
160- return liveness === 'live' ? 'held' : 'unavailable' ;
180+ return { state : liveness === 'live' ? 'held' : 'unavailable' , marker : ownerEntries [ 0 ] , markerKind : 'owner' } ;
161181 }
162- return 'orphaned' ;
182+ return { state : 'orphaned' } ;
163183}
164184
165185/**
166- * Move a stale or retained lock out of the lock name before a replacement owner is acquired.
167- * The rename prevents a newly-created lock from being removed based on an earlier inspection.
186+ * Claim and remove the exact observed stale or retained generation without releasing the lock directory.
168187 */
169- export async function reclaimFileLock ( filePath : string ) : Promise < boolean > {
188+ export async function reclaimFileLock ( filePath : string , options ?: InspectFileLockOptions ) : Promise < boolean > {
170189 const lockPath = getFileLockPath ( filePath ) ;
171- const state = await inspectFileLock ( filePath ) ;
172- if ( state !== 'stale' && state !== 'retained' ) {
190+ const snapshot = await inspectFileLockSnapshot ( filePath , options ) ;
191+ if (
192+ ( snapshot . state !== 'stale' && snapshot . state !== 'retained' ) ||
193+ ! snapshot . marker ||
194+ ! snapshot . markerKind
195+ ) {
173196 return false ;
174197 }
175198
176- const quarantinedLockPath = `${ lockPath } .reclaimed-${ process . pid } -${ crypto . randomBytes ( 16 ) . toString ( 'hex' ) } ` ;
199+ const claimedMarker = path . join (
200+ lockPath ,
201+ `.reclaim-${ process . pid } -${ crypto . randomBytes ( 16 ) . toString ( 'hex' ) } -${ snapshot . marker } ` ,
202+ ) ;
177203 try {
178- await fsapi . rename ( lockPath , quarantinedLockPath ) ;
204+ await fsapi . rename ( path . join ( lockPath , snapshot . marker ) , claimedMarker ) ;
179205 } catch ( error ) {
180- if ( hasErrorCode ( error , 'ENOENT' ) ) {
206+ if ( hasErrorCode ( error , 'ENOENT' ) || hasErrorCode ( error , 'EEXIST' ) ) {
181207 return false ;
182208 }
183209 throw error ;
184210 }
185211
186- await fsapi . remove ( quarantinedLockPath ) ;
187- return true ;
212+ try {
213+ await fsapi . unlink ( claimedMarker ) ;
214+ await fsapi . rmdir ( lockPath ) ;
215+ return true ;
216+ } catch ( error ) {
217+ if ( hasErrorCode ( error , 'ENOENT' ) || hasErrorCode ( error , 'ENOTEMPTY' ) ) {
218+ return false ;
219+ }
220+ throw error ;
221+ }
188222}
189223
190224export async function getProcessLiveness ( pid : number ) : Promise < ProcessLiveness > {
@@ -204,8 +238,10 @@ export async function getProcessLiveness(pid: number): Promise<ProcessLiveness>
204238
205239async function isRetainedLock ( lockPath : string ) : Promise < boolean > {
206240 try {
207- await fsapi . lstat ( path . join ( lockPath , FILE_LOCK_RETAINED_MARKER ) ) ;
208- return true ;
241+ const entries = await fsapi . readdir ( lockPath ) ;
242+ return entries . some (
243+ ( entry ) => entry === FILE_LOCK_RETAINED_MARKER || entry . startsWith ( FILE_LOCK_RETAINED_MARKER_PREFIX ) ,
244+ ) ;
209245 } catch ( error ) {
210246 if ( hasErrorCode ( error , 'ENOENT' ) ) {
211247 return false ;
@@ -220,8 +256,12 @@ function hasErrorCode(error: unknown, code: string): boolean {
220256 ) ;
221257}
222258
223- function parseOwnerPid ( entry : string ) : number | undefined {
224- const match = entry . match ( new RegExp ( `^${ escapeRegExp ( FILE_LOCK_OWNER_MARKER_PREFIX ) } (\\d+)-` ) ) ;
259+ function getRetainedMarkerName ( ownerMarker : string ) : string {
260+ return `${ FILE_LOCK_RETAINED_MARKER_PREFIX } ${ ownerMarker . slice ( FILE_LOCK_OWNER_MARKER_PREFIX . length ) } ` ;
261+ }
262+
263+ function parseMarkerPid ( entry : string , prefix : string ) : number | undefined {
264+ const match = entry . match ( new RegExp ( `^${ escapeRegExp ( prefix ) } (\\d+)-.+$` ) ) ;
225265 if ( ! match ) {
226266 return undefined ;
227267 }
0 commit comments