Skip to content

fix: Correct disposal race condition in LeaderElectionService#5

Merged
steingran merged 1 commit intomainfrom
1-disposal
Nov 4, 2025
Merged

fix: Correct disposal race condition in LeaderElectionService#5
steingran merged 1 commit intomainfrom
1-disposal

Conversation

@steingran
Copy link
Owner

Implemented IAsyncDisposable to prevent ObjectDisposedException when timer callbacks execute during disposal. Added active callback tracking with atomic operations and wait mechanism to ensure safe shutdown.

Includes 8 new tests covering concurrent disposal scenarios.

Copilot AI review requested due to automatic review settings November 4, 2025 22:14
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements disposal race condition fixes for the LeaderElectionService to prevent ObjectDisposedException errors during shutdown. The changes add proper synchronization mechanisms to wait for active timer callbacks before disposing resources, along with comprehensive test coverage for various disposal scenarios.

  • Introduced IAsyncDisposable implementation with callback tracking to safely wait for active operations
  • Added disposal state checks in timer callbacks to prevent operations on disposed objects
  • Created comprehensive test suite validating disposal behavior under concurrent scenarios

Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 13 comments.

File Description
src/MultiLock/LeaderElectionService.cs Implemented IAsyncDisposable, added callback tracking with activeCallbacks counter, enhanced disposal logic to wait for active callbacks, and added disposal state checks throughout timer callbacks
tests/MultiLock.Tests/DisposalRaceConditionTests.cs Added comprehensive test suite covering disposal race conditions, concurrent disposal, rapid start/stop cycles, and multi-service disposal scenarios

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Implemented IAsyncDisposable to prevent ObjectDisposedException when timer callbacks execute during disposal. Added active callback tracking with atomic operations and wait mechanism to ensure safe shutdown.

Includes 8 new tests covering concurrent disposal scenarios.
@steingran steingran requested a review from Copilot November 4, 2025 23:11
@steingran steingran merged commit 9b53aba into main Nov 4, 2025
8 checks passed
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 18 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +335 to +339
if (isDisposed)
return;

// Set disposal flag first to prevent new operations
isDisposed = true;
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check-then-set pattern for isDisposed (lines 335-339) is not thread-safe. Multiple concurrent calls to DisposeAsync() could both pass the check at line 335 before either sets the flag at line 339, leading to duplicate disposal logic execution. Use Interlocked.CompareExchange to atomically check and set the flag, or convert isDisposed from bool to int and use Interlocked.CompareExchange(ref isDisposed, 1, 0) to ensure only one thread proceeds with disposal.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +46
ServiceProvider serviceProvider = services.BuildServiceProvider();
var host = serviceProvider.GetRequiredService<IHostedService>() as LeaderElectionService;
host.ShouldNotBeNull();

// Act - Start the service and let it acquire leadership
await host.StartAsync(CancellationToken.None);
await Task.Delay(200); // Wait for leadership acquisition and heartbeat to start

// Dispose while heartbeat callbacks are likely running
var disposeTask = Task.Run(async () => await host.DisposeAsync());

// Should complete without throwing
await disposeTask.WaitAsync(TimeSpan.FromSeconds(15));

// Assert - No exception should be thrown
disposeTask.IsCompletedSuccessfully.ShouldBeTrue();
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serviceProvider created at line 31 is never disposed, causing a resource leak. Add await serviceProvider.DisposeAsync(); after line 46 to properly clean up the service provider and its managed services.

Copilot uses AI. Check for mistakes.
await disposeTask.WaitAsync(TimeSpan.FromSeconds(15));

// Assert - No exception should be thrown
disposeTask.IsCompletedSuccessfully.ShouldBeTrue();
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serviceProvider created at line 64 is never disposed, causing a resource leak. Add await serviceProvider.DisposeAsync(); after line 79 to properly clean up the service provider and its managed services.

Suggested change
disposeTask.IsCompletedSuccessfully.ShouldBeTrue();
disposeTask.IsCompletedSuccessfully.ShouldBeTrue();
await serviceProvider.DisposeAsync();

Copilot uses AI. Check for mistakes.
await Task.WhenAll(disposeTasks).WaitAsync(TimeSpan.FromSeconds(15));

// Assert - All dispose tasks should complete successfully
disposeTasks.All(t => t.IsCompletedSuccessfully).ShouldBeTrue();
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serviceProvider created at line 96 is never disposed, causing a resource leak. Add await serviceProvider.DisposeAsync(); after line 112 to properly clean up the service provider and its managed services.

Suggested change
disposeTasks.All(t => t.IsCompletedSuccessfully).ShouldBeTrue();
disposeTasks.All(t => t.IsCompletedSuccessfully).ShouldBeTrue();
await serviceProvider.DisposeAsync();

Copilot uses AI. Check for mistakes.
await host.StartAsync(CancellationToken.None);
await Task.Delay(50); // Very short delay
await host.StopAsync(CancellationToken.None);
await host.DisposeAsync();
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serviceProvider created at line 135 is never disposed, causing a resource leak. Add await serviceProvider.DisposeAsync(); after line 142 to properly clean up the service provider and its managed services.

Suggested change
await host.DisposeAsync();
await host.DisposeAsync();
await serviceProvider.DisposeAsync();

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +170
var host = serviceProvider.GetRequiredService<IHostedService>() as LeaderElectionService;
host.ShouldNotBeNull();
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable host may be null at this access because of this assignment.

Suggested change
var host = serviceProvider.GetRequiredService<IHostedService>() as LeaderElectionService;
host.ShouldNotBeNull();
if (serviceProvider.GetRequiredService<IHostedService>() is not LeaderElectionService host)
throw new InvalidOperationException("IHostedService is not a LeaderElectionService");

Copilot uses AI. Check for mistakes.
Comment on lines +200 to +202
var host = serviceProvider.GetRequiredService<IHostedService>() as LeaderElectionService;
host.ShouldNotBeNull();

Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable host may be null at this access because of this assignment.

Suggested change
var host = serviceProvider.GetRequiredService<IHostedService>() as LeaderElectionService;
host.ShouldNotBeNull();
var host = (LeaderElectionService)serviceProvider.GetRequiredService<IHostedService>();

Copilot uses AI. Check for mistakes.
host.ShouldNotBeNull();

// Act
await host.StartAsync(CancellationToken.None);
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable host may be null at this access because of this assignment.

Copilot uses AI. Check for mistakes.
var host = serviceProvider.GetRequiredService<IHostedService>() as LeaderElectionService;
host.ShouldNotBeNull();

await host.StartAsync(CancellationToken.None);
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable host may be null at this access because of this assignment.

Copilot uses AI. Check for mistakes.
provider.Dispose();
}
// Dispose should never throw, but we catch defensively to ensure cleanup completes
catch (Exception ex)
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Suggested change
catch (Exception ex)
catch (Exception ex) when (
ex is not OutOfMemoryException &&
ex is not StackOverflowException &&
ex is not ThreadAbortException)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants