Skip to content

Commit 5fc1d0a

Browse files
committed
fix(tests): eliminate race condition in async enumerable test
Add internal GetLeadershipChangesAsyncCore method that accepts a TaskCompletionSource parameter to signal when the subscriber is actually registered in the channel. This allows tests to wait for proper registration before starting the service, eliminating the race condition that caused flaky test failures in CI. - Add GetLeadershipChangesAsyncCore with subscriberRegistered parameter - Signal TaskCompletionSource after subscriber is added to channel - Add InternalsVisibleTo attribute to expose internal method to tests - Update test to use proper synchronization instead of arbitrary delay
1 parent 2145c70 commit 5fc1d0a

3 files changed

Lines changed: 24 additions & 8 deletions

File tree

src/MultiLock/LeaderElectionService.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -680,8 +680,7 @@ private async Task BroadcastEventsAsync()
680680
{
681681
// Read until the channel is completed (not until cancelled)
682682
// This ensures all pending events are broadcast during graceful shutdown
683-
// Note: ReadAllAsync() completes gracefully when the channel writer is completed,
684-
// so no try-catch is needed for normal operation
683+
// Note: ReadAllAsync() completes gracefully when the channel writer is completed
685684
await foreach (LeadershipChangedEventArgs eventArgs in broadcastChannel.Reader.ReadAllAsync().ConfigureAwait(false))
686685
{
687686
// Broadcast to all subscriber channels
@@ -705,8 +704,17 @@ private async Task BroadcastEventsAsync()
705704
/// An async enumerable that yields leadership change events as they occur.
706705
/// The enumeration will continue until the service is disposed or the cancellation token is triggered.
707706
/// </returns>
708-
public async IAsyncEnumerable<LeadershipChangedEventArgs> GetLeadershipChangesAsync(
709-
[EnumeratorCancellation] CancellationToken cancellationToken = default)
707+
public IAsyncEnumerable<LeadershipChangedEventArgs> GetLeadershipChangesAsync(
708+
CancellationToken cancellationToken = default)
709+
=> GetLeadershipChangesAsyncCore(cancellationToken, subscriberRegistered: null);
710+
711+
/// <summary>
712+
/// Internal overload that signals when the subscriber is registered.
713+
/// Used by tests to avoid race conditions.
714+
/// </summary>
715+
internal async IAsyncEnumerable<LeadershipChangedEventArgs> GetLeadershipChangesAsyncCore(
716+
[EnumeratorCancellation] CancellationToken cancellationToken,
717+
TaskCompletionSource? subscriberRegistered)
710718
{
711719
ThrowIfDisposed();
712720

@@ -723,6 +731,9 @@ public async IAsyncEnumerable<LeadershipChangedEventArgs> GetLeadershipChangesAs
723731
subscriberChannels.Add(subscriberChannel);
724732
}
725733

734+
// Signal that the subscriber is now registered
735+
subscriberRegistered?.TrySetResult();
736+
726737
try
727738
{
728739
// Read from the subscriber channel

src/MultiLock/MultiLock.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,8 @@
1313
<PackageReference Include="Microsoft.Extensions.Options" />
1414
</ItemGroup>
1515

16+
<ItemGroup>
17+
<InternalsVisibleTo Include="MultiLock.Tests" />
18+
</ItemGroup>
19+
1620
</Project>

tests/MultiLock.Tests/AsyncEnumerableApiTests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,16 +324,17 @@ public async Task GetLeadershipChangesAsync_CancellationToken_ShouldStopEnumerat
324324
{
325325
// Arrange
326326
ServiceProvider services = TestHelpers.CreateLeaderElectionService("test-participant");
327-
ILeaderElectionService service = services.GetRequiredService<ILeaderElectionService>();
327+
var service = (LeaderElectionService)services.GetRequiredService<ILeaderElectionService>();
328328
var events = new List<LeadershipChangedEventArgs>();
329329
object eventsLock = new();
330330
var cts = new CancellationTokenSource();
331+
var subscriberRegistered = new TaskCompletionSource();
331332

332333
// Act - Start listening BEFORE starting service
333334
CancellationToken cancellationToken = cts.Token;
334335
var eventTask = Task.Run(async () =>
335336
{
336-
await foreach (LeadershipChangedEventArgs e in service.GetLeadershipChangesAsync(cancellationToken))
337+
await foreach (LeadershipChangedEventArgs e in service.GetLeadershipChangesAsyncCore(cancellationToken, subscriberRegistered))
337338
{
338339
lock (eventsLock)
339340
{
@@ -342,8 +343,8 @@ public async Task GetLeadershipChangesAsync_CancellationToken_ShouldStopEnumerat
342343
}
343344
}, cancellationToken);
344345

345-
// Give the event listener task a chance to start
346-
await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token);
346+
// Wait for the subscriber to be registered (no race condition)
347+
await subscriberRegistered.Task;
347348

348349
await service.StartAsync(cts.Token);
349350
await service.WaitForLeadershipAsync(cts.Token);

0 commit comments

Comments
 (0)