Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 77 additions & 5 deletions src/ModularPipelines/Engine/ModuleContextProvider.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
using ModularPipelines.Context;
using ModularPipelines.Interfaces;

namespace ModularPipelines.Engine;

internal class ModuleContextProvider : IPipelineContextProvider, IScopeDisposer
internal class ModuleContextProvider : IPipelineContextProvider, IScopeDisposer, IAsyncDisposable
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

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:

  1. The synchronous Dispose from IScopeDisposer will call scope.Dispose() on each scope
  2. The asynchronous DisposeAsync will call scope.DisposeAsync() on each scope
  3. 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.

Copilot uses AI. Check for mistakes.
{
private readonly IServiceProvider _serviceProvider;
private readonly List<IServiceScope> _scopes = new();
private readonly ConcurrentBag<IServiceScope> _scopes = new();
private readonly object _disposeLock = new();
private bool _disposed;

public ModuleContextProvider(IServiceProvider serviceProvider)
{
Expand All @@ -16,15 +19,84 @@ public ModuleContextProvider(IServiceProvider serviceProvider)

public IPipelineContext GetModuleContext()
{
var serviceScope = _serviceProvider.CreateAsyncScope();
AsyncServiceScope serviceScope;

_scopes.Add(serviceScope);
lock (_disposeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);

// Create and add scope inside lock to ensure atomicity with disposal.
// This prevents a race where disposal could take a snapshot between
// the _disposed check and the Add operation, leaving the scope undisposed.
serviceScope = _serviceProvider.CreateAsyncScope();
_scopes.Add(serviceScope);
}

return serviceScope.ServiceProvider.GetRequiredService<IPipelineContext>();
}

public IEnumerable<IServiceScope> GetScopes()
{
// Return empty if disposed to prevent IScopeDisposer.Dispose from double-disposing
if (_disposed)
{
return [];
}

return _scopes;
}
}

public async ValueTask DisposeAsync()
{
IServiceScope[] scopesToDispose;

lock (_disposeLock)
{
if (_disposed)
{
return;
}

_disposed = true;

// Take snapshot inside lock to ensure atomicity.
// Any thread that passed the _disposed check in GetModuleContext
// before we set it will have already added to _scopes.
scopesToDispose = _scopes.ToArray();
}

foreach (var scope in scopesToDispose)
{
// AsyncServiceScope always implements IAsyncDisposable
await ((IAsyncDisposable)scope).DisposeAsync().ConfigureAwait(false);
}

GC.SuppressFinalize(this);
}

// Explicit implementation to coordinate with DisposeAsync and prevent double disposal
void IDisposable.Dispose()
{
IServiceScope[] scopesToDispose;

lock (_disposeLock)
{
if (_disposed)
{
return;
}

_disposed = true;

// Take snapshot inside lock to ensure atomicity
scopesToDispose = _scopes.ToArray();
}

foreach (var scope in scopesToDispose)
{
scope.Dispose();
}

GC.SuppressFinalize(this);
}
}
Loading