Skip to content

DI: avoid duplicate disposal for shared singleton instances#128768

Open
Copilot wants to merge 4 commits into
mainfrom
copilot/add-dependency-injection-support
Open

DI: avoid duplicate disposal for shared singleton instances#128768
Copilot wants to merge 4 commits into
mainfrom
copilot/add-dependency-injection-support

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 29, 2026

When multiple service registrations resolve to the same singleton instance via factory indirection (sp => sp.GetRequiredService<TImpl>()), MEDI could capture that object multiple times for scope disposal. This can lead to repeated disposal and incorrect teardown ordering for dependents.

  • Scope capture/disposal behavior

    • Kept ServiceProviderEngineScope.CaptureDisposable allocation-light on hot paths by appending disposables without capture-time deduplication.
    • Added lazy reference-based deduplication in BeginDispose: scan 0 -> count, null duplicate entries, then dispose count -> 0 while skipping nulls.
    • Preserves disposal ordering semantics for dependents while ensuring shared singleton instances are disposed once per scope.
  • Behavioral coverage

    • Added targeted tests in ServiceProviderEngineScopeTests for:
      • same singleton resolved as multiple services is disposed once;
      • shared singleton is disposed after dependents that reference it during their own disposal.
  • Representative scenario

    services.AddSingleton<TImpl>();
    services.AddSingleton<IService1>(sp => sp.GetRequiredService<TImpl>());
    services.AddSingleton<IService2>(sp => sp.GetRequiredService<TImpl>());

    After this change, duplicate captures are lazily collapsed at dispose time so TImpl is disposed once, and dependent teardown order remains correct.

Copilot AI review requested due to automatic review settings May 29, 2026 13:59
Copilot AI review requested due to automatic review settings May 29, 2026 13:59
Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 29, 2026 14:14
Copilot AI changed the title [WIP] Add explicit way of binding multiple interfaces to singleton instance DI: avoid duplicate capture/disposal for shared singleton instances May 29, 2026
Copilot AI requested a review from rosebyte May 29, 2026 14:15
@rosebyte
Copy link
Copy Markdown
Member

rosebyte commented Jun 5, 2026

@copilot , the brute force deduplication isn't an option here since services might be resolved in hot-path, gating the list with a set to deduplicate would mean higher memory consumption (8 bytes vs 28 bytes per item) so the best compromise seems to be lazy deduplication when we dispose the scope, going 0->count with a set and nullify all already seen objects and then do count->0 disposing and skipping nulls.

Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot June 5, 2026 09:29
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Jun 5, 2026

@copilot , the brute force deduplication isn't an option here since services might be resolved in hot-path, gating the list with a set to deduplicate would mean higher memory consumption (8 bytes vs 2...

Updated in 2fe33aa. I removed capture-time duplicate checks and switched to lazy deduplication in BeginDispose: we now scan forward with a reference-based HashSet, null out repeated entries, then keep the existing reverse disposal pass while skipping nulls in sync/async disposal paths.

Copilot AI changed the title DI: avoid duplicate capture/disposal for shared singleton instances DI: avoid duplicate disposal for shared singleton instances Jun 5, 2026
@rosebyte rosebyte marked this pull request as ready for review June 8, 2026 05:41
Copilot AI review requested due to automatic review settings June 8, 2026 05:41
Copy link
Copy Markdown
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 changes DI scope disposal to avoid disposing the same instance multiple times when it’s captured repeatedly via factory indirection (e.g., multiple singleton registrations returning the same underlying singleton). It does this by collapsing duplicate entries in the captured-disposables list at dispose time, while keeping reverse-disposal ordering for dependents.

Changes:

  • Add reference-based deduplication of _disposables entries during BeginDispose, nulling duplicate list slots and skipping nulls during disposal.
  • Update sync and async disposal loops to tolerate null entries introduced by deduplication.
  • Add regression tests ensuring a shared singleton instance is disposed once and disposed after dependents.

Reviewed changes

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

File Description
src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ServiceProviderEngineScope.cs Adds dispose-time deduplication for captured disposables and updates dispose loops to skip null entries.
src/libraries/Microsoft.Extensions.DependencyInjection/tests/DI.Tests/ServiceProviderEngineScopeTests.cs Adds tests for singleton aliasing scenarios to validate single-dispose behavior and dependent teardown ordering.

Comment on lines +310 to +320
if (_disposables is { Count: > 0 } disposables)
{
var seen = new HashSet<object>(ReferenceEqualityComparer.Instance);
for (int i = 0; i < disposables.Count; i++)
{
if (!seen.Add(disposables[i]))
{
disposables[i] = null!;
}
}
}
Comment on lines +134 to +151
[Fact]
public void Dispose_DoesNotDisposeSameSingletonInstanceResolvedAsMultipleServices()
{
var services = new ServiceCollection();
services.AddSingleton<MultipleServiceImpl>();
services.AddSingleton<IMultipleService1>(sp => sp.GetRequiredService<MultipleServiceImpl>());
services.AddSingleton<IMultipleService2>(sp => sp.GetRequiredService<MultipleServiceImpl>());

var serviceProvider = services.BuildServiceProvider();
var service = serviceProvider.GetRequiredService<MultipleServiceImpl>();

Assert.Same(service, serviceProvider.GetRequiredService<IMultipleService1>());
Assert.Same(service, serviceProvider.GetRequiredService<IMultipleService2>());

serviceProvider.Dispose();

Assert.Equal(1, service.DisposeCount);
}
Comment on lines +153 to +171
[Fact]
public void Dispose_DisposesSharedSingletonAfterDependents()
{
var services = new ServiceCollection();
services.AddSingleton<MultipleServiceImpl>();
services.AddSingleton<IMultipleService1>(sp => sp.GetRequiredService<MultipleServiceImpl>());
services.AddSingleton<IMultipleService2>(sp => sp.GetRequiredService<MultipleServiceImpl>());
services.AddSingleton<DependsOnMultipleService>();

var serviceProvider = services.BuildServiceProvider();

_ = serviceProvider.GetRequiredService<DependsOnMultipleService>();
_ = serviceProvider.GetRequiredService<IMultipleService2>();
var service = serviceProvider.GetRequiredService<MultipleServiceImpl>();

serviceProvider.Dispose();

Assert.Equal(1, service.DisposeCount);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API Proposal]: Dependency Injection - Add explicit way of binding multiple interfaces to the same singleton instance

3 participants