@@ -893,6 +893,116 @@ describe('acquireFencedFileLock — renewal TOCTOU', () => {
893893 await rm ( lockPath , { force : true } )
894894 } )
895895
896+ it ( 'marks the lock lost when a replacement owner arrives between the renewal read and rename' , async ( ) => {
897+ const target = join ( root , 'state.json' )
898+ const lockPath = `${ target } .accounts.lock`
899+ const clearIntervalSpy = spyOn ( globalThis , 'clearInterval' )
900+
901+ let pausedResolve ! : ( ) => void
902+ const paused = new Promise < void > ( ( resolve ) => {
903+ pausedResolve = resolve
904+ } )
905+ let gateResolve ! : ( ) => void
906+ const gate = new Promise < void > ( ( resolve ) => {
907+ gateResolve = resolve
908+ } )
909+ let hookFired = false
910+
911+ try {
912+ const lock = await acquireFencedFileLock ( {
913+ path : target ,
914+ name : 'accounts' ,
915+ ttlMs : 60_000 ,
916+ renewIntervalMs : 10 ,
917+ onStep : async ( step ) => {
918+ if ( step === 'renew-read' && ! hookFired ) {
919+ hookFired = true
920+ pausedResolve ( )
921+ await gate
922+ }
923+ } ,
924+ } )
925+ expect ( lock ) . not . toBeNull ( )
926+
927+ // Wait for owner A's renewal tick to reach the read seam, then
928+ // owner B takes over between A's read and A's rename.
929+ await paused
930+ await writeLock ( lockPath , 'owner-B' , Date . now ( ) + 60_000 )
931+ gateResolve ( )
932+
933+ await lock ! . whenLost ( )
934+ expect ( lock ! . hasLost ( ) ) . toBe ( true )
935+ // markLost() cleared the renewal interval exactly once.
936+ expect ( clearIntervalSpy ) . toHaveBeenCalledTimes ( 1 )
937+
938+ // A's pre-rename re-read must catch the takeover before the
939+ // rename — B's lock is intact, not clobbered by A's renewal.
940+ const contents = await readLock ( lockPath )
941+ expect ( contents ?. ownerId ) . toBe ( 'owner-B' )
942+
943+ await lock ! . release ( )
944+ await rm ( lockPath , { force : true } )
945+ } finally {
946+ clearIntervalSpy . mockRestore ( )
947+ }
948+ } )
949+
950+ it ( 'marks the lock lost when a takeover lands right after the renewal rename (verify-after-commit)' , async ( ) => {
951+ const target = join ( root , 'state.json' )
952+ const lockPath = `${ target } .accounts.lock`
953+ const clearIntervalSpy = spyOn ( globalThis , 'clearInterval' )
954+
955+ let pausedResolve ! : ( ) => void
956+ const paused = new Promise < void > ( ( resolve ) => {
957+ pausedResolve = resolve
958+ } )
959+ let gateResolve ! : ( ) => void
960+ const gate = new Promise < void > ( ( resolve ) => {
961+ gateResolve = resolve
962+ } )
963+ let hookFired = false
964+
965+ try {
966+ const lock = await acquireFencedFileLock ( {
967+ path : target ,
968+ name : 'accounts' ,
969+ ttlMs : 60_000 ,
970+ renewIntervalMs : 10 ,
971+ onStep : async ( step ) => {
972+ if ( step === 'renew-committed' && ! hookFired ) {
973+ hookFired = true
974+ pausedResolve ( )
975+ await gate
976+ }
977+ } ,
978+ } )
979+ expect ( lock ) . not . toBeNull ( )
980+
981+ // A's renewal has just renamed its refreshed payload onto the
982+ // lock file; owner B's acquisition lands in the post-commit
983+ // window, before A's verify re-read.
984+ await paused
985+ await writeLock ( lockPath , 'owner-B' , Date . now ( ) + 60_000 )
986+ gateResolve ( )
987+
988+ await lock ! . whenLost ( )
989+ expect ( lock ! . hasLost ( ) ) . toBe ( true )
990+ expect ( clearIntervalSpy ) . toHaveBeenCalledTimes ( 1 )
991+
992+ // A backed off after the verify re-read saw B; B's write is the
993+ // final content and A's release refuses to delete it.
994+ const contents = await readLock ( lockPath )
995+ expect ( contents ?. ownerId ) . toBe ( 'owner-B' )
996+
997+ await lock ! . release ( )
998+ const afterRelease = await readLock ( lockPath )
999+ expect ( afterRelease ?. ownerId ) . toBe ( 'owner-B' )
1000+ await rm ( lockPath , { force : true } )
1001+ } finally {
1002+ clearIntervalSpy . mockRestore ( )
1003+ }
1004+ } )
1005+
8961006 it ( 'whenLost() resolves promptly when the lock is taken over mid-renewal' , async ( ) => {
8971007 const target = join ( root , 'state.json' )
8981008 const lockPath = `${ target } .accounts.lock`
0 commit comments