77 openSync ,
88 readdirSync ,
99 readFileSync ,
10+ renameSync ,
11+ rmdirSync ,
1012 unlinkSync ,
1113 writeFileSync ,
1214} from "node:fs"
@@ -21,10 +23,17 @@ const PARTICIPANT_SUFFIX = ".json"
2123const REMOVAL_CLAIM_PREFIX = "removed."
2224const REMOVAL_CLAIM_SUFFIX = ".claim"
2325const ACQUIRE_ATTEMPTS = 10
26+ const PROTOCOL_LOCK_DIRECTORY = "protocol.lock"
27+ const PROTOCOL_LOCK_OWNER_FILENAME = "owner.json"
28+ const RETIRED_LOCK_PREFIX = "retired."
29+ const RETIRED_LOCK_SUFFIX = ".lock"
30+ const PROTOCOL_LOCK_ATTEMPTS = 500
31+ const PROTOCOL_LOCK_RETRY_MS = 10
2432
2533export interface CrossHostLeaseDependencies {
2634 pidAlive ( pid : number ) : boolean
2735 processStartIdentity : ProcessStartIdentityLookup
36+ onParticipantPublished ?( ) : void
2837}
2938
3039const defaultDependencies : CrossHostLeaseDependencies = {
@@ -168,6 +177,90 @@ function ownerIsStale(owner: ProcessOwner, dependencies: CrossHostLeaseDependenc
168177 return identity ? identity !== owner . processStartIdentity : undefined
169178}
170179
180+ function waitForProtocolLock ( ) : void {
181+ Atomics . wait ( new Int32Array ( new SharedArrayBuffer ( 4 ) ) , 0 , 0 , PROTOCOL_LOCK_RETRY_MS )
182+ }
183+
184+ function protocolLockOwnerPath ( lockDirectory : string ) : string {
185+ return join ( lockDirectory , PROTOCOL_LOCK_OWNER_FILENAME )
186+ }
187+
188+ function retireProtocolLock ( electionDirectory : string , observed : string ) : boolean {
189+ const lockDirectory = join ( electionDirectory , PROTOCOL_LOCK_DIRECTORY )
190+ if ( readIfExists ( protocolLockOwnerPath ( lockDirectory ) ) !== observed ) return false
191+ const retired = join ( electionDirectory , `${ RETIRED_LOCK_PREFIX } ${ digest ( observed ) } ${ RETIRED_LOCK_SUFFIX } ` )
192+ try {
193+ // ponytail: immutable retirement directories accumulate; clean them only if directory growth becomes material.
194+ renameSync ( lockDirectory , retired )
195+ return true
196+ } catch ( error ) {
197+ if ( hasErrorCode ( error , "ENOENT" ) || hasErrorCode ( error , "EEXIST" ) || hasErrorCode ( error , "ENOTEMPTY" ) ) return false
198+ throw error
199+ }
200+ }
201+
202+ function acquireProtocolLock (
203+ electionDirectory : string ,
204+ owner : ProcessOwner ,
205+ dependencies : CrossHostLeaseDependencies ,
206+ ) : ProcessOwner | undefined {
207+ const lockDirectory = join ( electionDirectory , PROTOCOL_LOCK_DIRECTORY )
208+ const lockOwner = { ...owner , runToken : `${ owner . runToken } .${ randomUUID ( ) } ` }
209+ const serialized = serializeOwner ( lockOwner )
210+ for ( let attempt = 0 ; attempt < PROTOCOL_LOCK_ATTEMPTS ; attempt += 1 ) {
211+ try {
212+ mkdirSync ( lockDirectory , { mode : 0o700 } )
213+ try {
214+ publishProcessLockOwner ( protocolLockOwnerPath ( lockDirectory ) , serialized )
215+ return lockOwner
216+ } catch ( error ) {
217+ try { rmdirSync ( lockDirectory ) } catch { }
218+ throw error
219+ }
220+ } catch ( error ) {
221+ if ( ! hasErrorCode ( error , "EEXIST" ) ) throw error
222+ }
223+
224+ const observed = readIfExists ( protocolLockOwnerPath ( lockDirectory ) )
225+ const existing = observed === undefined ? undefined : parseOwner ( observed )
226+ if ( ! existing ) {
227+ if ( observed !== undefined && attempt >= ACQUIRE_ATTEMPTS - 1 ) return undefined
228+ waitForProtocolLock ( )
229+ continue
230+ }
231+ const stale = ownerIsStale ( existing , dependencies )
232+ if ( stale === undefined || ! stale ) {
233+ waitForProtocolLock ( )
234+ continue
235+ }
236+ retireProtocolLock ( electionDirectory , observed ! )
237+ }
238+ return undefined
239+ }
240+
241+ function publishProcessLockOwner ( path : string , value : string ) : void {
242+ let descriptor : number | undefined
243+ try {
244+ descriptor = openSync ( path , "wx" , 0o600 )
245+ writeFileSync ( descriptor , value , "utf8" )
246+ try { fsyncSync ( descriptor ) } catch ( error ) {
247+ if ( ! hasErrorCode ( error , "EINVAL" ) && ! hasErrorCode ( error , "ENOTSUP" ) && ! hasErrorCode ( error , "ENOSYS" ) ) throw error
248+ }
249+ closeSync ( descriptor )
250+ descriptor = undefined
251+ } catch ( error ) {
252+ if ( descriptor !== undefined ) try { closeSync ( descriptor ) } catch { }
253+ try { unlinkSync ( path ) } catch { }
254+ throw error
255+ }
256+ }
257+
258+ function releaseProtocolLock ( electionDirectory : string , owner : ProcessOwner ) : void {
259+ const observed = readIfExists ( protocolLockOwnerPath ( join ( electionDirectory , PROTOCOL_LOCK_DIRECTORY ) ) )
260+ const current = observed === undefined ? undefined : parseOwner ( observed )
261+ if ( observed !== undefined && current && sameOwner ( current , owner ) ) retireProtocolLock ( electionDirectory , observed )
262+ }
263+
171264function removeObservedOwner (
172265 electionDirectory : string ,
173266 path : string ,
@@ -245,59 +338,64 @@ export class CrossHostRegistration {
245338 ) : CrossHostRegistration | undefined {
246339 if ( ! owner . processStartIdentity ) return undefined
247340 mkdirSync ( electionDirectory , { recursive : true , mode : 0o700 } )
341+ const protocolLockOwner = acquireProtocolLock ( electionDirectory , owner , dependencies )
342+ if ( ! protocolLockOwner ) return undefined
248343 const path = join ( electionDirectory , CROSS_HOST_OWNER_FILENAME )
249344 const participant = participantPath ( electionDirectory , owner )
250345 let primary = false
251346
252- if ( primaryCandidate ) {
253- try {
254- publish ( path , serializeOwner ( owner ) )
255- primary = true
256- } catch ( error ) {
257- if ( ! hasErrorCode ( error , "EEXIST" ) ) throw error
258- }
259- }
260-
261- if ( primaryCandidate && ! primary ) {
262- for ( let attempt = 0 ; attempt < ACQUIRE_ATTEMPTS ; attempt += 1 ) {
347+ try {
348+ if ( primaryCandidate ) {
263349 try {
264350 publish ( path , serializeOwner ( owner ) )
265351 primary = true
266- break
267352 } catch ( error ) {
268353 if ( ! hasErrorCode ( error , "EEXIST" ) ) throw error
269354 }
355+ }
270356
271- const observed = readIfExists ( path )
272- if ( observed === undefined ) continue
273- const existing = parseOwner ( observed )
274- if ( ! existing ) break
275- if ( sameOwner ( existing , owner ) ) {
276- primary = true
277- break
357+ if ( primaryCandidate && ! primary ) {
358+ for ( let attempt = 0 ; attempt < ACQUIRE_ATTEMPTS ; attempt += 1 ) {
359+ try {
360+ publish ( path , serializeOwner ( owner ) )
361+ primary = true
362+ break
363+ } catch ( error ) {
364+ if ( ! hasErrorCode ( error , "EEXIST" ) ) throw error
365+ }
366+
367+ const observed = readIfExists ( path )
368+ if ( observed === undefined ) continue
369+ const existing = parseOwner ( observed )
370+ if ( ! existing ) break
371+ if ( sameOwner ( existing , owner ) ) {
372+ primary = true
373+ break
374+ }
375+ const stale = ownerIsStale ( existing , dependencies )
376+ if ( stale === undefined || ! stale || hasOtherLiveParticipants ( electionDirectory , owner , dependencies ) ) break
377+ if ( ! removeObservedOwner ( electionDirectory , path , observed , existing , owner ) ) continue
278378 }
279- const stale = existing . pid === owner . pid && existing . runToken !== owner . runToken
280- ? true
281- : ownerIsStale ( existing , dependencies )
282- if ( stale === undefined || ! stale || hasOtherLiveParticipants ( electionDirectory , owner , dependencies ) ) break
283- if ( ! removeObservedOwner ( electionDirectory , path , observed , existing , owner ) ) continue
284379 }
285- }
286380
287- try {
288- publishParticipant ( participant , owner )
289- } catch ( error ) {
290- if ( primary ) {
291- const observed = readIfExists ( path )
292- const current = observed === undefined ? undefined : parseOwner ( observed )
293- if ( observed !== undefined && current && sameOwner ( current , owner ) ) {
294- removeObservedOwner ( electionDirectory , path , observed , owner , owner )
381+ try {
382+ publishParticipant ( participant , owner )
383+ dependencies . onParticipantPublished ?.( )
384+ } catch ( error ) {
385+ if ( primary ) {
386+ const observed = readIfExists ( path )
387+ const current = observed === undefined ? undefined : parseOwner ( observed )
388+ if ( observed !== undefined && current && sameOwner ( current , owner ) ) {
389+ removeObservedOwner ( electionDirectory , path , observed , owner , owner )
390+ }
295391 }
392+ throw error
296393 }
297- throw error
298- }
299394
300- return new CrossHostRegistration ( path , owner , electionDirectory , participant , dependencies , primary )
395+ return new CrossHostRegistration ( path , owner , electionDirectory , participant , dependencies , primary )
396+ } finally {
397+ releaseProtocolLock ( electionDirectory , protocolLockOwner )
398+ }
301399 }
302400
303401 get isPrimary ( ) : boolean {
@@ -309,17 +407,23 @@ export class CrossHostRegistration {
309407
310408 release ( ) : boolean {
311409 if ( this . released ) return false
410+ const protocolLockOwner = acquireProtocolLock ( this . electionDirectory , this . owner , this . dependencies )
411+ if ( ! protocolLockOwner ) return false
312412 let removed = false
313- if ( this . isPrimary && ! hasOtherLiveParticipants ( this . electionDirectory , this . owner , this . dependencies ) ) {
314- const observed = readIfExists ( this . path )
315- const current = observed === undefined ? undefined : parseOwner ( observed )
316- if ( observed !== undefined && current && sameOwner ( current , this . owner ) ) {
317- removed = removeObservedOwner ( this . electionDirectory , this . path , observed , this . owner , this . owner )
413+ try {
414+ if ( this . isPrimary && ! hasOtherLiveParticipants ( this . electionDirectory , this . owner , this . dependencies ) ) {
415+ const observed = readIfExists ( this . path )
416+ const current = observed === undefined ? undefined : parseOwner ( observed )
417+ if ( observed !== undefined && current && sameOwner ( current , this . owner ) ) {
418+ removed = removeObservedOwner ( this . electionDirectory , this . path , observed , this . owner , this . owner )
419+ }
318420 }
421+ removeParticipantIfOwned ( this . participant , this . owner )
422+ this . primary = false
423+ this . released = true
424+ return removed
425+ } finally {
426+ releaseProtocolLock ( this . electionDirectory , protocolLockOwner )
319427 }
320- removeParticipantIfOwned ( this . participant , this . owner )
321- this . primary = false
322- this . released = true
323- return removed
324428 }
325429}
0 commit comments