11import { afterEach , describe , expect , test } from 'bun:test'
2- import { chmod , mkdtemp , mkdir , open , readFile , rm } from 'node:fs/promises'
2+ import { chmod , mkdtemp , mkdir , open , readFile , rm , stat } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
4- import { join } from 'node:path'
4+ import { join , resolve } from 'node:path'
5+ import { pathToFileURL } from 'node:url'
56import { fetchLogsWithAdaptiveRanges , initialCursor , logRangeLimitError , LogScanError , scanRanges } from '../src/monitoring/block-sync.ts'
67import { quorumValue , settledQuorumValue } from '../src/monitoring/read-quorum.ts'
78import { boundedDashboardJson } from '../src/dashboard/security.ts'
@@ -888,8 +889,7 @@ describe('shared bot primitives', () => {
888889 }
889890 } )
890891
891- test ( 'unlinks a failed lock initialization even when closing the handle fails' , async ( ) => {
892- let removed = false
892+ test ( 'reports cleanup failure when failed lock initialization cannot close its handle' , async ( ) => {
893893 await expect (
894894 acquireExclusiveProcessLock (
895895 'operator.lock' ,
@@ -902,41 +902,111 @@ describe('shared bot primitives', () => {
902902 close : async ( ) => {
903903 throw new Error ( 'close failed' )
904904 } ,
905+ fd : 1 ,
905906 sync : async ( ) => undefined ,
907+ truncate : async ( ) => undefined ,
906908 writeFile : async ( ) => {
907909 throw new Error ( 'write failed' )
908910 } ,
909911 } ) ,
910912 readFile : async ( ) => '' ,
911- rm : async ( ) => {
912- removed = true
913- } ,
913+ tryLock : ( ) => true ,
914914 } ,
915915 ) ,
916916 ) . rejects . toThrow ( 'Failed to initialize and clean up process lock' )
917- expect ( removed ) . toBe ( true )
918917 } )
919918
920- test ( 'retries process-lock cleanup after a transient unlink failure' , async ( ) => {
919+ test ( 'closes the file handle when native lock acquisition throws' , async ( ) => {
920+ let closed = false
921+ await expect (
922+ acquireExclusiveProcessLock (
923+ 'operator.lock' ,
924+ 'Test lock' ,
925+ { } ,
926+ {
927+ mkdir : async ( ) => undefined ,
928+ open : async ( ) => ( {
929+ chmod : async ( ) => undefined ,
930+ close : async ( ) => {
931+ closed = true
932+ } ,
933+ fd : 1 ,
934+ sync : async ( ) => undefined ,
935+ truncate : async ( ) => undefined ,
936+ writeFile : async ( ) => undefined ,
937+ } ) ,
938+ readFile : async ( ) => '' ,
939+ tryLock : ( ) => {
940+ throw new Error ( 'native lock unavailable' )
941+ } ,
942+ } ,
943+ ) ,
944+ ) . rejects . toThrow ( 'native lock unavailable' )
945+ expect ( closed ) . toBe ( true )
946+ } )
947+
948+ test ( 'retries process-lock cleanup after a transient close failure without replacing the lock inode' , async ( ) => {
921949 const directory = await mkdtemp ( join ( tmpdir ( ) , 'zoltar-process-lock-' ) )
922950 temporaryDirectories . push ( directory )
923951 const lockPath = join ( directory , 'operator.lock' )
924- let removals = 0
952+ let closes = 0
925953 const filesystem = {
926954 mkdir,
927955 open,
928956 readFile,
929- rm : async ( path : string , options : { force : true } ) => {
930- removals += 1
931- if ( removals === 1 ) throw new Error ( 'transient unlink failure' )
932- await rm ( path , options )
933- } ,
957+ tryLock : ( ) => true ,
934958 }
935- const lock = await acquireExclusiveProcessLock ( lockPath , 'Test lock' , { } , filesystem )
936- await expect ( lock . release ( ) ) . rejects . toThrow ( 'transient unlink failure' )
959+ const lock = await acquireExclusiveProcessLock (
960+ lockPath ,
961+ 'Test lock' ,
962+ { } ,
963+ {
964+ ...filesystem ,
965+ open : async ( ...arguments_ ) => {
966+ const handle = await open ( ...arguments_ )
967+ return {
968+ chmod : async mode => await handle . chmod ( mode ) ,
969+ close : async ( ) => {
970+ closes += 1
971+ if ( closes === 1 ) throw new Error ( 'transient close failure' )
972+ await handle . close ( )
973+ } ,
974+ fd : handle . fd ,
975+ sync : async ( ) => await handle . sync ( ) ,
976+ truncate : async length => await handle . truncate ( length ) ,
977+ writeFile : async ( data , options ) => await handle . writeFile ( data , options ) ,
978+ }
979+ } ,
980+ } ,
981+ )
982+ const inode = ( await stat ( lockPath ) ) . ino
983+ await expect ( lock . release ( ) ) . rejects . toThrow ( 'transient close failure' )
937984 await lock . release ( )
938985 const replacement = await acquireExclusiveProcessLock ( lockPath , 'Test lock' , { } , filesystem )
939986 await replacement . release ( )
987+ expect ( ( await stat ( lockPath ) ) . isFile ( ) ) . toBe ( true )
988+ expect ( ( await stat ( lockPath ) ) . ino ) . toBe ( inode )
989+ } )
990+
991+ test ( 'reclaims a process lock after its owner is killed' , async ( ) => {
992+ const directory = await mkdtemp ( join ( tmpdir ( ) , 'zoltar-killed-process-lock-' ) )
993+ temporaryDirectories . push ( directory )
994+ const lockPath = join ( directory , 'operator.lock' )
995+ const moduleUrl = pathToFileURL ( resolve ( import . meta. dir , '../src/execution/process-lock.ts' ) ) . href
996+ const child = Bun . spawn ( [ process . execPath , '--eval' , `import { acquireExclusiveProcessLock } from ${ JSON . stringify ( moduleUrl ) } ; await acquireExclusiveProcessLock(${ JSON . stringify ( lockPath ) } , 'Test lock', {}); console.log('ready'); await Bun.sleep(60_000)` ] , { stderr : 'pipe' , stdout : 'pipe' } )
997+ try {
998+ const reader = child . stdout . getReader ( )
999+ const next = await reader . read ( )
1000+ if ( next . done || ! new TextDecoder ( ) . decode ( next . value ) . includes ( 'ready' ) ) throw new Error ( `Lock subprocess stopped before acquisition: ${ await new Response ( child . stderr ) . text ( ) } ` )
1001+ reader . releaseLock ( )
1002+ child . kill ( 'SIGKILL' )
1003+ expect ( await child . exited ) . not . toBe ( 0 )
1004+ } finally {
1005+ if ( child . exitCode === null ) child . kill ( 'SIGKILL' )
1006+ await child . exited
1007+ }
1008+ const replacement = await acquireExclusiveProcessLock ( lockPath , 'Test lock' , { } )
1009+ await replacement . release ( )
9401010 } )
9411011
9421012 test ( 'rejects a process-lock directory writable by other users' , async ( ) => {
0 commit comments