Conversation
- Change List<IServiceScope> to ConcurrentBag<IServiceScope> for thread safety - Implement IAsyncDisposable to dispose all scopes when the provider is disposed - Prevents resource leaks and memory growth from unbounded scope accumulation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR improves thread safety and async disposal in ModuleContextProvider by switching to ConcurrentBag and implementing IAsyncDisposable. Critical IssuesThe PR adds public interface IScopeDisposer : IDisposable
{
IEnumerable<IServiceScope> GetScopes();
void IDisposable.Dispose()
{
foreach (var serviceScope in GetScopes())
{
serviceScope.Dispose();
}
}
}The interface already provides a default Problems:
Solutions (choose one): Option A: Update IScopeDisposer interface (Recommended) public interface IScopeDisposer : IDisposable, IAsyncDisposable
{
IEnumerable<IServiceScope> GetScopes();
void IDisposable.Dispose()
{
foreach (var serviceScope in GetScopes())
serviceScope.Dispose();
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
foreach (var scope in GetScopes())
{
if (scope is IAsyncDisposable asyncDisposable)
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
else
scope.Dispose();
}
}
}Then remove the Option B: Override the synchronous Dispose() method void IDisposable.Dispose()
{
// Explicitly override to use async disposal
DisposeAsync().AsTask().GetAwaiter().GetResult();
}While Consider adding a volatile flag or lock to prevent disposal while scopes are still being created, or ensure the disposal only happens after all scope creation is complete. SuggestionsNone. Verdict |
There was a problem hiding this comment.
Pull request overview
This PR refactors the ModuleContextProvider to improve thread safety and async disposal patterns by changing from List<IServiceScope> to ConcurrentBag<IServiceScope> and implementing IAsyncDisposable.
Key Changes
- Replaces
List<IServiceScope>withConcurrentBag<IServiceScope>for thread-safe scope collection - Adds
IAsyncDisposableinterface implementation withDisposeAsync()method - Implements async disposal logic that handles both async and sync disposable scopes
| public async ValueTask DisposeAsync() | ||
| { | ||
| foreach (var scope in _scopes) | ||
| { | ||
| if (scope is IAsyncDisposable asyncDisposable) | ||
| { | ||
| await asyncDisposable.DisposeAsync().ConfigureAwait(false); | ||
| } | ||
| else | ||
| { | ||
| scope.Dispose(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The DisposeAsync implementation has a critical concurrency issue. While DisposeAsync iterates over the ConcurrentBag, GetModuleContext can still be adding new scopes to it. This creates a race condition where:
- Scopes added during disposal may not be disposed
- The iteration over ConcurrentBag is not thread-safe when concurrent modifications occur
Additionally, this implementation is missing several important elements of proper async disposal:
- No mechanism to prevent concurrent disposal calls
- Missing GC.SuppressFinalize(this) to suppress finalization
- No coordination to prevent new scopes from being added during disposal
Consider implementing a proper disposal pattern with a disposed flag and synchronization, or ensure that disposal only occurs when no concurrent calls to GetModuleContext are possible.
| if (scope is IAsyncDisposable asyncDisposable) | ||
| { | ||
| await asyncDisposable.DisposeAsync().ConfigureAwait(false); | ||
| } | ||
| else | ||
| { | ||
| scope.Dispose(); | ||
| } |
There was a problem hiding this comment.
The type check for IAsyncDisposable is unnecessary. CreateAsyncScope returns AsyncServiceScope, which always implements IAsyncDisposable. This check adds complexity without providing any benefit and will always take the true branch.
Consider simplifying this to directly call DisposeAsync on each scope without the conditional check.
| if (scope is IAsyncDisposable asyncDisposable) | |
| { | |
| await asyncDisposable.DisposeAsync().ConfigureAwait(false); | |
| } | |
| else | |
| { | |
| scope.Dispose(); | |
| } | |
| await ((IAsyncDisposable)scope).DisposeAsync().ConfigureAwait(false); |
| namespace ModularPipelines.Engine; | ||
|
|
||
| internal class ModuleContextProvider : IPipelineContextProvider, IScopeDisposer | ||
| internal class ModuleContextProvider : IPipelineContextProvider, IScopeDisposer, IAsyncDisposable |
There was a problem hiding this comment.
The IScopeDisposer interface provides a default implementation of IDisposable.Dispose() that synchronously disposes scopes. However, with this class now also implementing IAsyncDisposable, there's a potential conflict:
- The synchronous Dispose from IScopeDisposer will call scope.Dispose() on each scope
- The asynchronous DisposeAsync will call scope.DisposeAsync() on each scope
- If both are called, scopes could be disposed twice
Additionally, the IScopeDisposer.Dispose implementation doesn't coordinate with DisposeAsync, creating potential race conditions. Consider whether both disposal patterns are needed, or implement proper coordination between them with a disposed flag.
- Add disposed flag with lock to prevent concurrent disposal calls - Throw ObjectDisposedException if GetModuleContext called after disposal - Return empty from GetScopes when disposed to prevent double-disposal - Add explicit IDisposable.Dispose implementation coordinated with DisposeAsync - Add GC.SuppressFinalize calls - Remove unnecessary IAsyncDisposable type check (AsyncServiceScope always implements it) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR improves thread safety and resource cleanup in ModuleContextProvider by using ConcurrentBag and implementing proper async disposal. Critical Issues1. Race condition between GetModuleContext and disposalLocation: ModuleContextProvider.cs:19-24, 33-60 There's a race condition window between checking // Thread A (DisposeAsync)
lock (_disposeLock) {
_disposed = true; // Sets flag
}
// Lock released - Thread B can now enter GetModuleContext
// Thread B (GetModuleContext)
ObjectDisposedException.ThrowIf(_disposed, this); // Throws
// Thread A continues
var scopesToDispose = _scopes.ToArray(); // Takes snapshotHowever, there's a subtle issue: if Thread B passes the check just before Thread A sets Fix: Move the snapshot inside the lock to ensure atomicity: public async ValueTask DisposeAsync()
{
IServiceScope[] scopesToDispose;
lock (_disposeLock)
{
if (_disposed)
{
return;
}
_disposed = true;
scopesToDispose = _scopes.ToArray(); // Snapshot inside lock
}
foreach (var scope in scopesToDispose)
{
await ((IAsyncDisposable)scope).DisposeAsync().ConfigureAwait(false);
}
GC.SuppressFinalize(this);
}Apply the same pattern to the synchronous 2. Potential ObjectDisposedException leak in GetScopes()Location: ModuleContextProvider.cs:30-38 The Impact: If someone calls the interface's default Recommendation: Either:
Given the explicit SuggestionsNone - the critical issues above must be addressed first. Verdict |
Take the _scopes.ToArray() snapshot inside the lock to ensure atomicity between setting _disposed = true and capturing all scopes. This prevents a race where a thread could add a scope after we set _disposed but before we took the snapshot. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
SummaryAdds thread-safe scope management with ConcurrentBag and implements IAsyncDisposable for proper async cleanup of service scopes. Critical Issues1. Race condition between GetModuleContext() and disposal (src/ModularPipelines/Engine/ModuleContextProvider.cs:21)There's a race condition where a thread could pass the _disposed check in GetModuleContext() (line 22), then get suspended before adding to _scopes (line 26). Meanwhile, another thread enters disposal, takes the snapshot, and completes. The first thread then resumes and adds a scope that will never be disposed. Suggested fix: Move the _disposed check inside the lock to ensure atomicity. 2. Double disposal via IScopeDisposer.Dispose() (src/ModularPipelines/Interfaces/IScopeDisposer.cs:19-25)The IScopeDisposer interface has a default implementation of Dispose() that calls GetScopes() and disposes each scope. Your explicit IDisposable.Dispose() implementation also disposes all scopes. While you check _disposed in GetScopes() and return empty array (line 33-36), this coordination is fragile. Suggested fix: Consider documenting this coordination or restructuring to make it clearer. 3. Missing ConfigureAwait(false) on AsyncServiceScope disposal (line 59)The cast to IAsyncDisposable should be followed by ConfigureAwait(false) for consistency with the rest of the codebase's async patterns. Verdict |
The previous fix moved the snapshot inside the lock, but there was still a race condition where a thread could pass the _disposed check, then get suspended while another thread disposes. The first thread would then add a scope that never gets disposed. Now the entire scope creation and addition is inside the lock, ensuring atomicity between the _disposed check and adding to the collection. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryAdds thread-safe disposal with ConcurrentBag and IAsyncDisposable to fix scope cleanup in ModuleContextProvider. Critical IssuesNone found ✅ SuggestionsConsider ConfigureAwait consistencyThe DisposeAsync() method uses .ConfigureAwait(false) for async disposal calls (line 53), which is good. However, consider whether the explicit IDisposable.Dispose() implementation (lines 60-82) should be calling synchronous Dispose() on scopes that may have been created via CreateAsyncScope(). Context: AsyncServiceScope always implements both IDisposable and IAsyncDisposable. When disposing synchronously (line 77), calling the sync path on a scope that was created for async disposal works but may not properly dispose async resources. Recommendation: Document this design decision with a comment, or consider making the class only implement IAsyncDisposable and letting consumers handle sync disposal (though this may break existing code). The current approach is pragmatic and functional. Lock-free alternative considerationWhile the current lock-based approach (lines 22-32, 34-47, 62-74) is correct and safe, ConcurrentBag is already thread-safe for concurrent adds. The primary concern is the race between checking _disposed and adding to the bag. The current implementation is sound - this is just noting that an alternative approach using Interlocked.CompareExchange on the _disposed flag could reduce lock contention, but the added complexity may not be worth it given that disposal is a one-time operation. Verdict✅ APPROVE - No critical issues The implementation correctly addresses issue #1477 with proper thread safety, disposal patterns, and race condition handling. The explicit IDisposable.Dispose() correctly overrides the default interface implementation to coordinate with DisposeAsync() and prevent double disposal via the _disposed flag check in GetScopes(). |
Summary
List<IServiceScope>toConcurrentBag<IServiceScope>for thread safetyIAsyncDisposablewithDisposeAsync()for proper async cleanupFixes #1477
Test Plan
🤖 Generated with Claude Code