@@ -18,8 +18,17 @@ export type ProcessLockOwner = {
1818 acquiredAtMs : number ;
1919} ;
2020
21+ /**
22+ * One acquisition of a lock. The token says which: two records can name the same process
23+ * and still be different claims on the same path, which is what a release and a reclaim
24+ * have to tell apart.
25+ */
26+ export type ProcessLockOwnerRecord = ProcessLockOwner & {
27+ claimToken : string | null ;
28+ } ;
29+
2130type ProcessLockOwnerReading =
22- | { kind : 'owner' ; owner : ProcessLockOwner }
31+ | { kind : 'owner' ; owner : ProcessLockOwnerRecord }
2332 | { kind : 'unwritten' }
2433 | { kind : 'unreadable' } ;
2534
@@ -39,15 +48,16 @@ export async function acquireProcessLock(params: {
3948 const description = params . description ?? 'process lock' ;
4049
4150 fs . mkdirSync ( path . dirname ( lockDirPath ) , { recursive : true } ) ;
51+ const claim : ProcessLockOwnerRecord = { ...owner , claimToken : crypto . randomUUID ( ) } ;
4252
4353 while ( Date . now ( ) < deadline ) {
4454 try {
4555 fs . mkdirSync ( lockDirPath ) ;
46- writeProcessLockOwner ( ownerFilePath , owner ) ;
56+ writeProcessLockOwner ( ownerFilePath , claim ) ;
4757 let released = false ;
4858 return async ( ) => {
4959 if ( released ) return ;
50- const outcome = releaseProcessLock ( lockDirPath , ownerFilePath , owner ) ;
60+ const outcome = releaseProcessLock ( lockDirPath , ownerFilePath , claim ) ;
5161 if ( outcome !== 'unverified' ) {
5262 released = true ;
5363 return ;
@@ -84,7 +94,7 @@ function staleLockHint(lockDirPath: string): string {
8494 return `Remove ${ lockDirPath } once you have confirmed no live process holds it, then retry.` ;
8595}
8696
87- function writeProcessLockOwner ( ownerFilePath : string , owner : ProcessLockOwner ) : void {
97+ function writeProcessLockOwner ( ownerFilePath : string , owner : ProcessLockOwnerRecord ) : void {
8898 publishFileSync ( {
8999 destination : ownerFilePath ,
90100 contents : JSON . stringify ( owner ) ,
@@ -99,12 +109,15 @@ function writeProcessLockOwner(ownerFilePath: string, owner: ProcessLockOwner):
99109function releaseProcessLock (
100110 lockDirPath : string ,
101111 ownerFilePath : string ,
102- owner : ProcessLockOwner ,
112+ claim : ProcessLockOwnerRecord ,
103113) : 'removed' | 'not-owner' | 'unverified' {
104114 const reading = readProcessLockOwner ( ownerFilePath ) ;
105115 if ( reading . kind === 'unreadable' ) return 'unverified' ;
106- if ( reading . kind === 'unwritten' || ! ownerIdentityMatches ( reading . owner , owner ) )
116+ if ( reading . kind === 'unwritten' || ! ownerIdentityMatches ( reading . owner , claim ) )
107117 return 'not-owner' ;
118+ // The same process can hold this path twice in sequence, and a reclaim that moved our
119+ // directory aside leaves a record behind that names us as though nothing had happened.
120+ if ( reading . owner . claimToken !== claim . claimToken ) return 'not-owner' ;
108121 fs . rmSync ( lockDirPath , { recursive : true , force : true } ) ;
109122 return 'removed' ;
110123}
@@ -125,7 +138,10 @@ function clearStaleProcessLock(
125138 // owner record, so its age is the only evidence available about it.
126139 if ( ! lockStats . isDirectory ( ) ) {
127140 return reclaimWhenAbandoned ( lockStats , ownerGraceMs )
128- ? reclaimProcessLockDirectory ( lockDirPath , ownerFilePath , lockStats )
141+ ? reclaimProcessLockDirectory ( lockDirPath , ownerFilePath , {
142+ stats : lockStats ,
143+ claimToken : null ,
144+ } )
129145 : false ;
130146 }
131147
@@ -134,7 +150,10 @@ function clearStaleProcessLock(
134150 if ( isLiveProcessLockOwner ( reading . owner ) ) {
135151 return false ;
136152 }
137- return reclaimProcessLockDirectory ( lockDirPath , ownerFilePath , lockStats ) ;
153+ return reclaimProcessLockDirectory ( lockDirPath , ownerFilePath , {
154+ stats : lockStats ,
155+ claimToken : reading . owner . claimToken ,
156+ } ) ;
138157 }
139158 // A record we cannot read leaves an owner whose identity is unknown, which is not
140159 // evidence of death. Only a record that is genuinely absent lets the directory's
@@ -143,7 +162,10 @@ function clearStaleProcessLock(
143162 return false ;
144163 }
145164 return reclaimWhenAbandoned ( lockStats , ownerGraceMs )
146- ? reclaimProcessLockDirectory ( lockDirPath , ownerFilePath , lockStats )
165+ ? reclaimProcessLockDirectory ( lockDirPath , ownerFilePath , {
166+ stats : lockStats ,
167+ claimToken : null ,
168+ } )
147169 : false ;
148170}
149171
@@ -161,7 +183,7 @@ function reclaimWhenAbandoned(lockStats: fs.Stats, ownerGraceMs: number): boolea
161183function reclaimProcessLockDirectory (
162184 lockDirPath : string ,
163185 ownerFilePath : string ,
164- judged : fs . Stats ,
186+ judged : JudgedLock ,
165187) : boolean {
166188 const asidePath = reclaimedLockPath ( lockDirPath ) ;
167189 try {
@@ -199,22 +221,28 @@ function reclaimWithoutTheRename(
199221
200222/**
201223 * A rename addresses whatever stands at the path now, not the directory whose record was
202- * read. A contender that reclaimed first and published a live owner in the meantime has
203- * put a different directory there, so what arrived is compared against what was judged:
204- * the same inode , and no live owner inside .
224+ * read. A contender that reclaimed first and claimed the path again has put a different
225+ * directory there, so what arrived is compared against what was judged: the same inode,
226+ * no live owner inside , and the claim token that was read before the rename .
205227 */
206- function reclaimedLockIsTheOneJudged ( asidePath : string , judged : fs . Stats ) : boolean {
228+ function reclaimedLockIsTheOneJudged ( asidePath : string , judged : JudgedLock ) : boolean {
207229 let moved : fs . Stats ;
208230 try {
209231 moved = fs . statSync ( asidePath ) ;
210232 } catch {
211233 return false ;
212234 }
213- if ( moved . ino !== judged . ino || moved . dev !== judged . dev ) return false ;
235+ if ( moved . ino !== judged . stats . ino || moved . dev !== judged . stats . dev ) return false ;
214236 const reading = readProcessLockOwner ( path . join ( asidePath , OWNER_FILE_NAME ) ) ;
215- return ! ( reading . kind === 'owner' && isLiveProcessLockOwner ( reading . owner ) ) ;
237+ if ( reading . kind === 'owner' && isLiveProcessLockOwner ( reading . owner ) ) return false ;
238+ return readClaimToken ( reading ) === judged . claimToken ;
216239}
217240
241+ type JudgedLock = {
242+ stats : fs . Stats ;
243+ claimToken : string | null ;
244+ } ;
245+
218246/**
219247 * Returns a directory that turned out to belong to someone else. Failure is not a
220248 * licence to delete it: its holder's own release reports an unreadable owner rather than
@@ -259,7 +287,11 @@ function readProcessLockOwner(ownerFilePath: string): ProcessLockOwnerReading {
259287 return owner ? { kind : 'owner' , owner } : { kind : 'unreadable' } ;
260288}
261289
262- function parseProcessLockOwner ( contents : string ) : ProcessLockOwner | null {
290+ function readClaimToken ( reading : ProcessLockOwnerReading ) : string | null {
291+ return reading . kind === 'owner' ? reading . owner . claimToken : null ;
292+ }
293+
294+ function parseProcessLockOwner ( contents : string ) : ProcessLockOwnerRecord | null {
263295 let parsed : unknown ;
264296 try {
265297 parsed = JSON . parse ( contents ) ;
@@ -275,6 +307,9 @@ function parseProcessLockOwner(contents: string): ProcessLockOwner | null {
275307 pid : record . pid as number ,
276308 startTime : typeof record . startTime === 'string' ? record . startTime : null ,
277309 acquiredAtMs : record . acquiredAtMs as number ,
310+ // A record written before claims were tokenized names a process without saying which
311+ // acquisition it was, which no release can match and no reclaim can be blamed for.
312+ claimToken : typeof record . claimToken === 'string' ? record . claimToken : null ,
278313 } ;
279314}
280315
0 commit comments