@@ -13,18 +13,31 @@ import { tmpdir } from 'node:os'
1313import { join } from 'node:path'
1414
1515import {
16- acquireFencedFileLock ,
16+ acquireFencedFileLock as acquireFencedFileLockUntracked ,
17+ type FencedFileLock ,
18+ type FencedFileLockOptions ,
1719 FileLockOwnershipError ,
1820 type FileLockStep ,
1921} from './file-lock.ts'
2022
2123let root : string
24+ const activeLocks = new Set < FencedFileLock > ( )
25+
26+ async function acquireFencedFileLock (
27+ options : FencedFileLockOptions ,
28+ ) : Promise < FencedFileLock | null > {
29+ const lock = await acquireFencedFileLockUntracked ( options )
30+ if ( lock ) activeLocks . add ( lock )
31+ return lock
32+ }
2233
2334beforeEach ( async ( ) => {
2435 root = await mkdtemp ( join ( tmpdir ( ) , 'file-lock-' ) )
2536} )
2637
2738afterEach ( async ( ) => {
39+ await Promise . all ( Array . from ( activeLocks , ( lock ) => lock . release ( ) ) )
40+ activeLocks . clear ( )
2841 await rm ( root , { recursive : true , force : true } )
2942} )
3043
@@ -351,7 +364,7 @@ describe('acquireFencedFileLock — release deletes only its own lock', () => {
351364 // stuck mid-writeFile) when we trigger release().
352365 await new Promise ( ( resolve ) => setTimeout ( resolve , 30 ) )
353366
354- await lock . release ( )
367+ await lock ! . release ( )
355368
356369 // With the fix: release awaits the in-flight renewal, then unlinks.
357370 // Without the fix: release unlinks first, the still-pending renewal
@@ -688,3 +701,230 @@ describe('acquireFencedFileLock — onStep interleavings', () => {
688701 expect ( observed ) . toEqual ( [ ] )
689702 } )
690703} )
704+
705+ describe ( 'acquireFencedFileLock — eviction marker TTL/PID reclamation' , ( ) => {
706+ it ( 'reclaims a stale eviction marker whose PID is dead' , async ( ) => {
707+ const target = join ( root , 'state.json' )
708+ const lockPath = `${ target } .accounts.lock`
709+ const evictingDir = `${ lockPath } .evicting`
710+ const markerPath = join ( evictingDir , 'owner.json' )
711+
712+ // Drop a stale marker with a dead PID (init(1) is always present on
713+ // POSIX but its recycled quit claim is rare; use a clearly bogus PID
714+ // to guarantee `process.kill(pid, 0)` throws ESRCH).
715+ await mkdir ( evictingDir , { recursive : true , mode : 0o700 } )
716+ await writeFile (
717+ markerPath ,
718+ JSON . stringify ( {
719+ ownerId : 'dead-evicter' ,
720+ pid : 2_000_000_000 ,
721+ createdAt : Date . now ( ) - 60_000 ,
722+ } ) ,
723+ 'utf8' ,
724+ )
725+
726+ // The lock itself is genuinely stale, so the contender will hit
727+ // the eviction path. With the stale-marker reclamation logic, the
728+ // first waiter removes the dead-PID marker and proceeds.
729+ await writeLock ( lockPath , 'zombie' , Date . now ( ) - 60_000 )
730+
731+ const lock = await acquireFencedFileLock ( {
732+ path : target ,
733+ name : 'accounts' ,
734+ ttlMs : 60_000 ,
735+ } )
736+ expect ( lock ) . not . toBeNull ( )
737+ // The lock is now ours — proves the reclamation worked.
738+ const contents = await readLock ( lockPath )
739+ expect ( contents ?. ownerId ) . toBe ( lock ! . ownerId )
740+ await lock ! . release ( )
741+ } )
742+
743+ it ( 'reclaims a marker older than 30 seconds regardless of PID liveness' , async ( ) => {
744+ const target = join ( root , 'state.json' )
745+ const lockPath = `${ target } .accounts.lock`
746+ const evictingDir = `${ lockPath } .evicting`
747+ const markerPath = join ( evictingDir , 'owner.json' )
748+
749+ // Marker is fresh-but-just-tipping-over the TTL. Use our own pid
750+ // so the liveness check is positive — only the age triggers the
751+ // reclaim.
752+ await mkdir ( evictingDir , { recursive : true , mode : 0o700 } )
753+ await writeFile (
754+ markerPath ,
755+ JSON . stringify ( {
756+ ownerId : 'old-but-alive' ,
757+ pid : process . pid ,
758+ createdAt : Date . now ( ) - 31_000 ,
759+ } ) ,
760+ 'utf8' ,
761+ )
762+
763+ await writeLock ( lockPath , 'zombie' , Date . now ( ) - 60_000 )
764+
765+ const lock = await acquireFencedFileLock ( {
766+ path : target ,
767+ name : 'accounts' ,
768+ ttlMs : 60_000 ,
769+ } )
770+ expect ( lock ) . not . toBeNull ( )
771+ const contents = await readLock ( lockPath )
772+ expect ( contents ?. ownerId ) . toBe ( lock ! . ownerId )
773+ await lock ! . release ( )
774+ } )
775+
776+ it ( 'respects a fresh, live marker (does not reclaim an in-progress eviction)' , async ( ) => {
777+ const target = join ( root , 'state.json' )
778+ const lockPath = `${ target } .accounts.lock`
779+ const evictingDir = `${ lockPath } .evicting`
780+ const markerPath = join ( evictingDir , 'owner.json' )
781+
782+ // Live marker — fresh AND alive. The contender must respect it.
783+ await mkdir ( evictingDir , { recursive : true , mode : 0o700 } )
784+ await writeFile (
785+ markerPath ,
786+ JSON . stringify ( {
787+ ownerId : 'live-evicter' ,
788+ pid : process . pid ,
789+ createdAt : Date . now ( ) ,
790+ } ) ,
791+ 'utf8' ,
792+ )
793+
794+ await writeLock ( lockPath , 'zombie' , Date . now ( ) - 60_000 )
795+
796+ const lock = await acquireFencedFileLock ( {
797+ path : target ,
798+ name : 'accounts' ,
799+ ttlMs : 60_000 ,
800+ } )
801+ // Bounded retries exhausted — the contender backs off.
802+ expect ( lock ) . toBeNull ( )
803+ // The live marker is still in place.
804+ const stillThere = await readFile ( markerPath , 'utf8' )
805+ expect ( JSON . parse ( stillThere ) . ownerId ) . toBe ( 'live-evicter' )
806+
807+ // Clean up.
808+ await rm ( evictingDir , { recursive : true , force : true } )
809+ await rm ( lockPath , { force : true } )
810+ } )
811+ } )
812+
813+ describe ( 'acquireFencedFileLock — renewal TOCTOU' , ( ) => {
814+ it ( 'makes release terminal and resolves existing whenLost waiters' , async ( ) => {
815+ const target = join ( root , 'state.json' )
816+ const lockPath = `${ target } .accounts.lock`
817+ const lock = await acquireFencedFileLock ( {
818+ path : target ,
819+ name : 'accounts' ,
820+ ttlMs : 60_000 ,
821+ renewIntervalMs : 10 ,
822+ } )
823+ expect ( lock ) . not . toBeNull ( )
824+ const lost = lock ! . whenLost ( )
825+
826+ await lock ! . release ( )
827+
828+ await expect ( lost ) . resolves . toBeUndefined ( )
829+ await expect ( lock ! . whenLost ( ) ) . resolves . toBeUndefined ( )
830+ expect ( lock ! . hasLost ( ) ) . toBe ( true )
831+ expect ( await readLock ( lockPath ) ) . toBeNull ( )
832+ await lock ! . release ( )
833+ } )
834+
835+ it ( 'stops the renewal interval as soon as ownership is lost' , async ( ) => {
836+ const target = join ( root , 'state.json' )
837+ const lockPath = `${ target } .accounts.lock`
838+ const clearIntervalSpy = spyOn ( globalThis , 'clearInterval' )
839+
840+ try {
841+ const lock = await acquireFencedFileLock ( {
842+ path : target ,
843+ name : 'accounts' ,
844+ ttlMs : 60_000 ,
845+ renewIntervalMs : 10 ,
846+ } )
847+ expect ( lock ) . not . toBeNull ( )
848+
849+ await writeLock ( lockPath , 'thief' , Date . now ( ) + 60_000 )
850+ await lock ! . whenLost ( )
851+
852+ expect ( clearIntervalSpy ) . toHaveBeenCalledTimes ( 1 )
853+ await lock ! . release ( )
854+ } finally {
855+ clearIntervalSpy . mockRestore ( )
856+ await rm ( lockPath , { force : true } )
857+ }
858+ } )
859+
860+ it ( 'detects ownership change between async read and write — does not clobber the new owner' , async ( ) => {
861+ const target = join ( root , 'state.json' )
862+ const lockPath = `${ target } .accounts.lock`
863+
864+ const lock = await acquireFencedFileLock ( {
865+ path : target ,
866+ name : 'accounts' ,
867+ ttlMs : 60_000 ,
868+ renewIntervalMs : 10 ,
869+ } )
870+ expect ( lock ) . not . toBeNull ( )
871+
872+ // Owner B evicts A and takes over the lock with a live expiry.
873+ // This is the "A pauses between read and write" moment: the moment
874+ // A's renewal is mid-cycle, B rips the lock file out and rewrites
875+ // it with B's ownerId. A's renewal must NOT clobber B's lock.
876+ await writeLock ( lockPath , 'owner-B' , Date . now ( ) + 60_000 )
877+
878+ // Wait for A's renewal tick to fire and notice the mismatch.
879+ await lock ! . whenLost ( )
880+
881+ // B's lock must be intact — A's renewal must NOT have overwritten it.
882+ const contents = await readLock ( lockPath )
883+ expect ( contents ?. ownerId ) . toBe ( 'owner-B' )
884+
885+ // A must report itself as lost.
886+ expect ( lock ! . hasLost ( ) ) . toBe ( true )
887+
888+ // Release should refuse to delete (the lock is B's).
889+ await lock ! . release ( )
890+ const afterRelease = await readLock ( lockPath )
891+ expect ( afterRelease ?. ownerId ) . toBe ( 'owner-B' )
892+
893+ await rm ( lockPath , { force : true } )
894+ } )
895+
896+ it ( 'whenLost() resolves promptly when the lock is taken over mid-renewal' , async ( ) => {
897+ const target = join ( root , 'state.json' )
898+ const lockPath = `${ target } .accounts.lock`
899+
900+ const lock = await acquireFencedFileLock ( {
901+ path : target ,
902+ name : 'accounts' ,
903+ ttlMs : 60_000 ,
904+ renewIntervalMs : 10 ,
905+ } )
906+ expect ( lock ) . not . toBeNull ( )
907+ expect ( lock ! . hasLost ( ) ) . toBe ( false )
908+
909+ // Write a different owner into the lock. Note: this happens AFTER
910+ // the initial acquire, so ownership is "fresh-stolen" — the renewal
911+ // must detect and bail.
912+ await writeLock ( lockPath , 'thief' , Date . now ( ) + 60_000 )
913+
914+ // Bounded wait — if whenLost() never resolves, the pipeline is
915+ // broken. 500ms is generous for the 10ms tick + sync re-read.
916+ const lostPromise = lock ! . whenLost ( )
917+ let timeoutHandle : ReturnType < typeof setTimeout > | null = null
918+ const timeout = new Promise < 'timeout' > ( ( resolve ) => {
919+ timeoutHandle = setTimeout ( ( ) => resolve ( 'timeout' ) , 1000 )
920+ } )
921+ const result = await Promise . race ( [ lostPromise , timeout ] ) . finally ( ( ) => {
922+ if ( timeoutHandle !== null ) clearTimeout ( timeoutHandle )
923+ } )
924+ expect ( result ) . toBeUndefined ( )
925+ expect ( lock ! . hasLost ( ) ) . toBe ( true )
926+
927+ await lock ! . release ( )
928+ await rm ( lockPath , { force : true } )
929+ } )
930+ } )
0 commit comments