@@ -11,12 +11,15 @@ vi.mock('./host-process.ts', async (importOriginal) => {
1111 return { ...actual , isProcessZombie : ( pid : number ) => zombiePids . has ( pid ) } ;
1212} ) ;
1313
14- import { acquireProcessLock , type ProcessLockOwner } from './process-lock.ts' ;
14+ import {
15+ acquireProcessLock ,
16+ withProcessLock ,
17+ type ProcessLockOwner ,
18+ type ProcessLockRelease ,
19+ } from './process-lock.ts' ;
1520import { readProcessStartTime } from './host-process.ts' ;
1621import { mkdtempForTestSync } from './tmp-dir.fixtures.ts' ;
1722
18- const RECLAIMED_MARK = '.reclaimed-' ;
19-
2023let tmpDir : string ;
2124
2225beforeEach ( ( ) => {
@@ -293,13 +296,14 @@ test('one abandoned lock offered to two contenders is held by exactly one of the
293296 assert . ok ( reason instanceof AppError ) ;
294297 assert . equal ( reason . details ?. ownerLiveness , 'live' ) ;
295298 await ( acquired [ 0 ] as PromiseFulfilledResult < ( ) => Promise < void > > ) . value ( ) ;
296- assert . deepEqual ( listReclaimedSiblings ( tmpDir ) , [ ] ) ;
299+ assert . deepEqual ( listReclaimSiblings ( tmpDir ) , [ ] ) ;
297300} ) ;
298301
299- function listReclaimedSiblings ( directory : string ) : string [ ] {
302+ /** A reclaim that finishes leaves neither a parked directory nor a mutex behind. */
303+ function listReclaimSiblings ( directory : string ) : string [ ] {
300304 return fs
301305 . readdirSync ( directory )
302- . filter ( ( entry ) => entry . includes ( RECLAIMED_MARK ) )
306+ . filter ( ( entry ) => entry . includes ( '.reclaim' ) )
303307 . sort ( ) ;
304308}
305309
@@ -374,28 +378,39 @@ test('acquireProcessLock reclaims a stray path in place of the lock directory',
374378 assert . equal ( fs . existsSync ( lockDirPath ) , false ) ;
375379} ) ;
376380
377- test ( 'a forced reclaim leaves a directory that a live owner republished' , async ( ) => {
378- const lockDirPath = path . join ( tmpDir , 'republished.lock' ) ;
381+ test ( 'a contender that claims the path during a reclaim keeps its lock' , async ( ) => {
382+ const lockDirPath = path . join ( tmpDir , 'claimed-during-reclaim.lock' ) ;
383+ const mutexPath = path . join ( tmpDir , 'claimed-during-reclaim.reclaim.lock' ) ;
379384 const ownerFilePath = path . join ( lockDirPath , 'owner.json' ) ;
380385 fs . mkdirSync ( lockDirPath ) ;
381386 fs . writeFileSync (
382387 ownerFilePath ,
383388 JSON . stringify ( { pid : 999_999_999 , startTime : null , acquiredAtMs : Date . now ( ) } ) ,
384389 ) ;
390+ stampDirectoryAbandoned ( lockDirPath ) ;
385391
386- // A win32 handle refuses the rename, and by the time the forced removal would run a
387- // live holder has claimed the path: the forced removal must not reach its directory.
388- const realRename = fs . renameSync ;
389- const renameSpy = vi . spyOn ( fs , 'renameSync' ) . mockImplementation ( ( (
390- from : fs . PathLike ,
391- to : fs . PathLike ,
392+ // The moment a contender is admitted to judging this lock, another process clears the dead
393+ // claim and publishes itself. The judge must find a different directory, not a directory to
394+ // remove: nothing here stands between the two states, because nothing is moved out of the
395+ // way first.
396+ let claimed = false ;
397+ const realMkdir = fs . mkdirSync ;
398+ const mkdirSpy = vi . spyOn ( fs , 'mkdirSync' ) . mockImplementation ( ( (
399+ target : fs . PathLike ,
400+ options ?: fs . MakeDirectoryOptions & { recursive : true } ,
392401 ) => {
393- if ( ! String ( to ) . includes ( RECLAIMED_MARK ) ) return realRename ( from , to ) ;
394- fs . rmSync ( String ( from ) , { recursive : true , force : true } ) ;
395- fs . mkdirSync ( String ( from ) ) ;
396- fs . writeFileSync ( path . join ( String ( from ) , 'owner.json' ) , JSON . stringify ( currentProcessOwner ( ) ) ) ;
397- throw Object . assign ( new Error ( 'operation not permitted' ) , { code : 'EPERM' } ) ;
398- } ) as typeof fs . renameSync ) ;
402+ if ( String ( target ) !== mutexPath || claimed ) {
403+ return realMkdir ( target as string , options as fs . MakeDirectoryOptions ) ;
404+ }
405+ claimed = true ;
406+ fs . rmSync ( lockDirPath , { recursive : true , force : true } ) ;
407+ fs . mkdirSync ( lockDirPath ) ;
408+ fs . writeFileSync (
409+ ownerFilePath ,
410+ JSON . stringify ( { ...currentProcessOwner ( ) , claimToken : 'contender-claim' } ) ,
411+ ) ;
412+ return realMkdir ( target as string , options as fs . MakeDirectoryOptions ) ;
413+ } ) as typeof fs . mkdirSync ) ;
399414
400415 try {
401416 await assert . rejects (
@@ -409,48 +424,76 @@ test('a forced reclaim leaves a directory that a live owner republished', async
409424 ( error : unknown ) => {
410425 assert . ok ( error instanceof AppError ) ;
411426 assert . equal ( error . details ?. ownerLiveness , 'live' ) ;
427+ assert . equal ( error . details ?. ownerPid , process . pid ) ;
412428 return true ;
413429 } ,
414430 ) ;
415- assert . equal ( fs . existsSync ( ownerFilePath ) , true ) ;
431+ assert . equal ( claimed , true ) ;
432+ const record = JSON . parse ( fs . readFileSync ( ownerFilePath , 'utf8' ) ) as {
433+ pid : number ;
434+ claimToken : string ;
435+ } ;
436+ assert . equal ( record . pid , process . pid ) ;
437+ assert . equal ( record . claimToken , 'contender-claim' ) ;
416438 } finally {
417- renameSpy . mockRestore ( ) ;
439+ mkdirSpy . mockRestore ( ) ;
418440 }
419441} ) ;
420442
421- test ( 'a reclaim that cannot remove the directory it moved aside still holds the lock' , async ( ) => {
422- const lockDirPath = path . join ( tmpDir , 'immovable.lock' ) ;
443+ test ( 'a reclaim mutex another contender holds leaves the abandoned lock standing' , async ( ) => {
444+ const lockDirPath = path . join ( tmpDir , 'judged-by-another.lock' ) ;
445+ const ownerFilePath = path . join ( lockDirPath , 'owner.json' ) ;
446+ fs . mkdirSync ( lockDirPath ) ;
447+ const staleClaim = { pid : 999_999_999 , startTime : null , acquiredAtMs : Date . now ( ) } ;
448+ fs . writeFileSync ( ownerFilePath , JSON . stringify ( staleClaim ) ) ;
449+ stampDirectoryAbandoned ( lockDirPath ) ;
450+ fs . mkdirSync ( path . join ( tmpDir , 'judged-by-another.reclaim.lock' ) ) ;
451+
452+ // Nobody may judge this lock twice, and a contender that cannot say so keeps polling
453+ // rather than clearing what it has not finished reading.
454+ await assert . rejects (
455+ ( ) =>
456+ acquireProcessLock ( {
457+ lockDirPath,
458+ owner : { pid : 999_999_998 , startTime : null , acquiredAtMs : Date . now ( ) } ,
459+ timeoutMs : 50 ,
460+ pollMs : 1 ,
461+ } ) ,
462+ ( error : unknown ) => {
463+ assert . ok ( error instanceof AppError ) ;
464+ assert . equal ( error . details ?. ownerPid , 999_999_999 ) ;
465+ return true ;
466+ } ,
467+ ) ;
468+ assert . equal ( fs . existsSync ( ownerFilePath ) , true ) ;
469+ } ) ;
470+
471+ test ( 'a reclaim mutex left behind by a dead process is cleared by age' , async ( ) => {
472+ const lockDirPath = path . join ( tmpDir , 'dead-janitor.lock' ) ;
473+ const mutexPath = path . join ( tmpDir , 'dead-janitor.reclaim.lock' ) ;
423474 fs . mkdirSync ( lockDirPath ) ;
424475 fs . writeFileSync (
425476 path . join ( lockDirPath , 'owner.json' ) ,
426477 JSON . stringify ( { pid : 999_999_999 , startTime : null , acquiredAtMs : Date . now ( ) } ) ,
427478 ) ;
479+ fs . mkdirSync ( mutexPath ) ;
428480 stampDirectoryAbandoned ( lockDirPath ) ;
481+ stampDirectoryAbandoned ( mutexPath ) ;
429482
430- const realRemove = fs . rmSync ;
431- const removeSpy = vi . spyOn ( fs , 'rmSync' ) . mockImplementation ( ( ( target : fs . PathLike , options ) => {
432- if ( String ( target ) . includes ( RECLAIMED_MARK ) ) {
433- throw Object . assign ( new Error ( 'directory is busy' ) , { code : 'EBUSY' } ) ;
434- }
435- return realRemove ( target , options ) ;
436- } ) as typeof fs . rmSync ) ;
483+ const release = await acquireProcessLock ( {
484+ lockDirPath,
485+ owner : currentProcessOwner ( ) ,
486+ timeoutMs : 500 ,
487+ pollMs : 1 ,
488+ } ) ;
437489
438- try {
439- const release = await acquireProcessLock ( {
440- lockDirPath,
441- owner : currentProcessOwner ( ) ,
442- timeoutMs : 500 ,
443- pollMs : 1 ,
444- } ) ;
445- assert . equal ( fs . existsSync ( path . join ( lockDirPath , 'owner.json' ) ) , true ) ;
446- await release ( ) ;
447- } finally {
448- removeSpy . mockRestore ( ) ;
449- }
490+ assert . equal ( fs . existsSync ( path . join ( lockDirPath , 'owner.json' ) ) , true ) ;
491+ assert . equal ( fs . existsSync ( mutexPath ) , false ) ;
492+ await release ( ) ;
450493} ) ;
451494
452- test ( 'a live owner published between the stale read and the rename keeps its lock ' , async ( ) => {
453- const lockDirPath = path . join ( tmpDir , 'stolen-race .lock' ) ;
495+ test ( 'a reclaim that cannot clear the lock directory leaves the record it judged ' , async ( ) => {
496+ const lockDirPath = path . join ( tmpDir , 'immovable .lock' ) ;
454497 const ownerFilePath = path . join ( lockDirPath , 'owner.json' ) ;
455498 fs . mkdirSync ( lockDirPath ) ;
456499 fs . writeFileSync (
@@ -459,93 +502,123 @@ test('a live owner published between the stale read and the rename keeps its loc
459502 ) ;
460503 stampDirectoryAbandoned ( lockDirPath ) ;
461504
462- // The dead record is read, and before the rename lands another contender reclaims the
463- // path, publishes itself, and goes live. The rename then moves that live directory, and
464- // the only thing that can tell it apart from the one judged abandoned is the directory
465- // itself.
466- let republished = false ;
467- const realRename = fs . renameSync ;
468- const renameSpy = vi . spyOn ( fs , 'renameSync' ) . mockImplementation ( ( (
469- from : fs . PathLike ,
470- to : fs . PathLike ,
471- ) => {
472- if ( ! String ( to ) . includes ( RECLAIMED_MARK ) || republished ) return realRename ( from , to ) ;
473- republished = true ;
474- fs . rmSync ( String ( from ) , { recursive : true , force : true } ) ;
475- fs . mkdirSync ( String ( from ) ) ;
476- fs . writeFileSync ( path . join ( String ( from ) , 'owner.json' ) , JSON . stringify ( currentProcessOwner ( ) ) ) ;
477- return realRename ( from , to ) ;
478- } ) as typeof fs . renameSync ) ;
505+ const realRemove = fs . rmSync ;
506+ const removeSpy = vi . spyOn ( fs , 'rmSync' ) . mockImplementation ( ( ( target : fs . PathLike ) => {
507+ if ( String ( target ) !== lockDirPath ) {
508+ return realRemove ( target as string ) ;
509+ }
510+ throw Object . assign ( new Error ( 'directory is busy' ) , { code : 'EBUSY' } ) ;
511+ } ) as typeof fs . rmSync ) ;
479512
480513 try {
481514 await assert . rejects (
482515 ( ) =>
483516 acquireProcessLock ( {
484517 lockDirPath,
485- owner : { pid : 999_999_998 , startTime : null , acquiredAtMs : Date . now ( ) } ,
518+ owner : currentProcessOwner ( ) ,
486519 timeoutMs : 50 ,
487520 pollMs : 1 ,
488521 } ) ,
489522 ( error : unknown ) => {
490523 assert . ok ( error instanceof AppError ) ;
491- assert . equal ( error . details ?. ownerLiveness , 'live' ) ;
492- assert . equal ( error . details ?. ownerPid , process . pid ) ;
524+ assert . equal ( error . details ?. ownerPid , 999_999_999 ) ;
493525 return true ;
494526 } ,
495527 ) ;
496- assert . equal ( republished , true ) ;
497- assert . equal (
498- ( JSON . parse ( fs . readFileSync ( ownerFilePath , 'utf8' ) ) as { pid : number } ) . pid ,
499- process . pid ,
500- ) ;
501- assert . deepEqual (
502- fs
503- . readdirSync ( tmpDir )
504- . filter ( ( name ) => name . includes ( RECLAIMED_MARK ) )
505- . sort ( ) ,
506- [ ] ,
507- ) ;
528+ assert . equal ( fs . existsSync ( ownerFilePath ) , true ) ;
508529 } finally {
509- renameSpy . mockRestore ( ) ;
530+ removeSpy . mockRestore ( ) ;
510531 }
511532} ) ;
512533
513- test ( 'a reclaim whose directory another contender moved aside retries and acquires' , async ( ) => {
514- const lockDirPath = path . join ( tmpDir , 'lost-race.lock' ) ;
534+ test ( 'an abandoned lock with no record and something else inside is left alone' , async ( ) => {
535+ const lockDirPath = path . join ( tmpDir , 'occupied.lock' ) ;
536+ const strangerPath = path . join ( lockDirPath , 'not-a-record.json' ) ;
515537 fs . mkdirSync ( lockDirPath ) ;
516- fs . writeFileSync (
517- path . join ( lockDirPath , 'owner.json' ) ,
518- JSON . stringify ( { pid : 999_999_999 , startTime : null , acquiredAtMs : Date . now ( ) } ) ,
519- ) ;
538+ fs . writeFileSync ( strangerPath , 'nobody claims this' ) ;
520539 stampDirectoryAbandoned ( lockDirPath ) ;
540+ // Stamping the directory's own clocks back makes the stranger look older than the grace, too.
541+ fs . utimesSync ( strangerPath , new Date ( Date . now ( ) - 60_000 ) , new Date ( Date . now ( ) - 60_000 ) ) ;
521542
522- // The contender that loses the rename finds the path already gone and cannot have
523- // cleared anything; it goes back to `mkdir`, which is what decides the lock.
524- let attempted = 0 ;
525- const realRename = fs . renameSync ;
526- const renameSpy = vi . spyOn ( fs , 'renameSync' ) . mockImplementation ( ( (
527- from : fs . PathLike ,
528- to : fs . PathLike ,
529- ) => {
530- if ( ! String ( to ) . includes ( RECLAIMED_MARK ) ) return realRename ( from , to ) ;
531- attempted += 1 ;
532- fs . rmSync ( lockDirPath , { recursive : true , force : true } ) ;
533- throw Object . assign ( new Error ( 'no such directory' ) , { code : 'ENOENT' } ) ;
534- } ) as typeof fs . renameSync ) ;
543+ // No record means no claim to attribute the directory to, and the age of the path is
544+ // evidence about the path alone. An empty directory is removed; this one is not.
545+ await assert . rejects (
546+ ( ) =>
547+ acquireProcessLock ( {
548+ lockDirPath,
549+ owner : currentProcessOwner ( ) ,
550+ timeoutMs : 50 ,
551+ pollMs : 1 ,
552+ } ) ,
553+ ( error : unknown ) => {
554+ assert . ok ( error instanceof AppError ) ;
555+ return true ;
556+ } ,
557+ ) ;
558+ assert . equal ( fs . existsSync ( strangerPath ) , true ) ;
559+ } ) ;
535560
536- try {
537- const release = await acquireProcessLock ( {
538- lockDirPath,
539- owner : currentProcessOwner ( ) ,
540- timeoutMs : 500 ,
541- pollMs : 1 ,
542- } ) ;
543- assert . equal ( attempted , 1 ) ;
544- assert . equal ( fs . existsSync ( path . join ( lockDirPath , 'owner.json' ) ) , true ) ;
545- await release ( ) ;
546- } finally {
547- renameSpy . mockRestore ( ) ;
548- }
561+ test ( 'withProcessLock gives the lock back on every path out of the task' , async ( ) => {
562+ const releases : string [ ] = [ ] ;
563+ const release : ProcessLockRelease = async ( ) => {
564+ releases . push ( 'released' ) ;
565+ } ;
566+
567+ await withProcessLock ( {
568+ acquire : async ( ) => release ,
569+ task : async ( ) => 'done' ,
570+ } ) ;
571+ await assert . rejects (
572+ ( ) =>
573+ withProcessLock ( {
574+ acquire : async ( ) => release ,
575+ task : async ( ) => {
576+ throw new Error ( 'task failed' ) ;
577+ } ,
578+ } ) ,
579+ / t a s k f a i l e d / ,
580+ ) ;
581+
582+ assert . deepEqual ( releases , [ 'released' , 'released' ] ) ;
583+ } ) ;
584+
585+ test ( 'a task that failed is reported over a release that could not verify ownership' , async ( ) => {
586+ await assert . rejects (
587+ ( ) =>
588+ withProcessLock ( {
589+ acquire : async ( ) => async ( ) => {
590+ throw new AppError ( 'COMMAND_FAILED' , 'Cannot verify ownership of device claim' , {
591+ ownerReleaseUnverified : true ,
592+ } ) ;
593+ } ,
594+ task : async ( ) => {
595+ throw new Error ( 'the write was rejected' ) ;
596+ } ,
597+ } ) ,
598+ ( error : unknown ) => {
599+ assert . equal ( ( error as Error ) . message , 'the write was rejected' ) ;
600+ return true ;
601+ } ,
602+ ) ;
603+ } ) ;
604+
605+ test ( 'a completed task still reports a lock it could not give back' , async ( ) => {
606+ await assert . rejects (
607+ ( ) =>
608+ withProcessLock ( {
609+ acquire : async ( ) => async ( ) => {
610+ throw new AppError ( 'COMMAND_FAILED' , 'Cannot verify ownership of device claim' , {
611+ ownerReleaseUnverified : true ,
612+ } ) ;
613+ } ,
614+ task : async ( ) => 'done' ,
615+ } ) ,
616+ ( error : unknown ) => {
617+ assert . ok ( error instanceof AppError ) ;
618+ assert . equal ( error . details ?. ownerReleaseUnverified , true ) ;
619+ return true ;
620+ } ,
621+ ) ;
549622} ) ;
550623
551624function stampDirectoryAbandoned ( directory : string ) : void {
0 commit comments