@@ -466,6 +466,74 @@ public async Task TryAcquireAsync_WhenProviderAlwaysThrows_RethrowsAfterExhausti
466466 Times . Exactly ( 3 ) ) ;
467467 }
468468
469+ [ Fact ]
470+ public async Task AutoStart_HeartbeatTimerFires_KeepsSlotHeld ( )
471+ {
472+ // Arrange: short heartbeat interval so the heartbeat timer callback actually fires.
473+ var options = new SemaphoreOptions
474+ {
475+ SemaphoreName = "heartbeat-semaphore" ,
476+ MaxCount = 1 ,
477+ AutoStart = true ,
478+ HeartbeatInterval = TimeSpan . FromMilliseconds ( 50 ) ,
479+ HeartbeatTimeout = TimeSpan . FromMilliseconds ( 250 ) ,
480+ AcquisitionInterval = TimeSpan . FromMilliseconds ( 50 ) ,
481+ EnableDetailedLogging = true
482+ } ;
483+ service = new SemaphoreService ( provider , Options . Create ( options ) , loggerFactory . CreateLogger < SemaphoreService > ( ) ) ;
484+
485+ // Act: AutoStart acquires immediately, then the heartbeat timer renews the slot repeatedly.
486+ await service . StartAsync ( ) ;
487+ await WaitUntilAsync ( ( ) => service . IsHolding , TimeSpan . FromSeconds ( 5 ) ) ;
488+
489+ // Assert: after several heartbeat intervals the slot is still held (renewals succeeded).
490+ await Task . Delay ( 250 ) ;
491+ service . IsHolding . ShouldBeTrue ( ) ;
492+ }
493+
494+ [ Fact ]
495+ public async Task AutoStart_WhenInitiallyFull_AcquisitionTimerAcquiresOnceSlotFrees ( )
496+ {
497+ // Arrange: occupy the only slot, then start an auto-starting service that must wait.
498+ const string semaphore = "acquisition-semaphore" ;
499+ var meta = new Dictionary < string , string > ( ) ;
500+ await provider . TryAcquireAsync ( semaphore , "other-holder" , 1 , meta , TimeSpan . FromMinutes ( 5 ) ) ;
501+
502+ var options = new SemaphoreOptions
503+ {
504+ SemaphoreName = semaphore ,
505+ MaxCount = 1 ,
506+ AutoStart = true ,
507+ HeartbeatInterval = TimeSpan . FromMilliseconds ( 50 ) ,
508+ HeartbeatTimeout = TimeSpan . FromMilliseconds ( 250 ) ,
509+ AcquisitionInterval = TimeSpan . FromMilliseconds ( 50 )
510+ } ;
511+ service = new SemaphoreService ( provider , Options . Create ( options ) , loggerFactory . CreateLogger < SemaphoreService > ( ) ) ;
512+
513+ // Act: the acquisition timer keeps retrying while the semaphore is full.
514+ await service . StartAsync ( ) ;
515+ await Task . Delay ( 150 ) ;
516+ service . IsHolding . ShouldBeFalse ( ) ;
517+
518+ // Free the slot; the acquisition timer should pick it up on a subsequent tick.
519+ await provider . ReleaseAsync ( semaphore , "other-holder" ) ;
520+
521+ // Assert
522+ await WaitUntilAsync ( ( ) => service . IsHolding , TimeSpan . FromSeconds ( 5 ) ) ;
523+ service . IsHolding . ShouldBeTrue ( ) ;
524+ }
525+
526+ private static async Task WaitUntilAsync ( Func < bool > condition , TimeSpan timeout )
527+ {
528+ using var cts = new CancellationTokenSource ( timeout ) ;
529+ while ( ! condition ( ) )
530+ {
531+ if ( cts . IsCancellationRequested )
532+ throw new TimeoutException ( "Condition was not met within the allotted time." ) ;
533+ await Task . Delay ( 20 ) ;
534+ }
535+ }
536+
469537 private SemaphoreService CreateService ( string semaphoreName , int maxCount ) =>
470538 new ( provider , Options . Create ( new SemaphoreOptions
471539 {
0 commit comments