Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
862df69
add try/catch blocks to disposing loops
Jan 19, 2026
99cdcf3
remove unused using
Jan 19, 2026
54c55a4
Merge branch 'main' into features/86426-di-aggregated-exceptions
rosebyte Jan 19, 2026
244edbe
Bring throw helpers to PUSH_COOP_PINVOKE_FRAME plan (#123015)
am11 Jan 16, 2026
94e3e25
Fix assertions generated by optCreateJumpTableImpliedAssertions (#123…
EgorBo Jan 16, 2026
d6a6535
[main] Source code updates from dotnet/dotnet (#123247)
dotnet-maestro[bot] Jan 16, 2026
0c3e4c2
Update NativeAOT Docker samples to use .NET 10 images (#123241)
Copilot Jan 16, 2026
15af27b
Add multiple environment variable sets to aspnet2 SPMI collection (#1…
Copilot Jan 16, 2026
01b4433
Fix interpreter threadabort in finally (#123231)
janvorli Jan 16, 2026
7797f55
[RyuJit/WASM] Some fixes related to local addresses and stores (#123261)
SingleAccretion Jan 16, 2026
95f82dd
[Startup] Bump PerfMap ahead of DiagnosticServer (#123226)
mdh1418 Jan 16, 2026
0bb8d77
Fix missing static libraries in NativeAOT packs for Apple mobile plat…
Copilot Jan 16, 2026
ac1c4e1
[LoongArch64] Fix the failed for Fp32x2StructFunc in the profiler tes…
LuckyXu-HF Jan 17, 2026
ec0c992
Cleanup __builtin_available where platform is now guaranteed on Apple…
vcsjones Jan 17, 2026
b142b07
Adding C#, F#, VB to StringSyntaxAttribute (#123211)
HakamFostok Jan 17, 2026
08553fc
[RyuJit/WASM] Establish SP & FP and home register arguments (#123270)
SingleAccretion Jan 17, 2026
169cea3
Remove nonExpansive parameter from Dictionary::PopulateEntry (#122758)
Copilot Jan 17, 2026
e56355d
Add early return in TryGetLast for empty results. (#123306)
prozolic Jan 18, 2026
5ea3028
[NativeAOT] Source to native mapping fix for out-of-order code (#123333)
rcj1 Jan 19, 2026
b1ab9b3
Simplify branching in ILImporter.Scanner.cs with Debug.Assert (#123235)
Copilot Jan 19, 2026
a2e63e7
SunOS process and thread support (#105403)
gwr Jan 19, 2026
1d202d4
fix analyzer warnings
Jan 19, 2026
c64cd8f
fix analyzer warnings
Jan 19, 2026
e658b0e
Merge branch 'main' into features/86426-di-aggregated-exceptions
rosebyte Jan 19, 2026
4308779
implement PR comments
Jan 23, 2026
02767e1
Merge branch 'features/86426-di-aggregated-exceptions' of https://git…
Jan 23, 2026
dfea777
Merge branch 'main' into features/86426-di-aggregated-exceptions
rosebyte Jan 23, 2026
c92eac0
implement PR comments
Jan 23, 2026
7a0c529
Merge branch 'features/86426-di-aggregated-exceptions' of https://git…
Jan 23, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -120,40 +120,60 @@ public object GetRequiredKeyedService(Type serviceType, object? serviceKey)
public void Dispose()
{
List<object>? toDispose = BeginDispose();
List<Exception>? exceptions = null;
var index = (toDispose?.Count ?? 0) - 1;

if (toDispose != null)
while (index >= 0)
{
for (int i = toDispose.Count - 1; i >= 0; i--)
try
{
if (toDispose[i] is IDisposable disposable)
for (; index >= 0; index--)
{
disposable.Dispose();
}
else
{
throw new InvalidOperationException(SR.Format(SR.AsyncDisposableServiceDispose, TypeNameHelper.GetTypeDisplayName(toDispose[i])));
if (toDispose![index] is IDisposable disposable)
{
disposable.Dispose();
}
else
{
throw new InvalidOperationException(SR.Format(SR.AsyncDisposableServiceDispose, TypeNameHelper.GetTypeDisplayName(toDispose[index])));
Copy link
Member

@CarnaViire CarnaViire Jan 23, 2026

Choose a reason for hiding this comment

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

Do I understand correctly that before, if any of the services happened to be IAsyncDisposable-only, it literally was breaking all the further disposals? 😅

Should we add a test for this, and also for the mix of IDisposable-only and IAsyncDisposable, to verify it works?


Also, I found that we actually state in the docs regarding IAsyncDisposables in DI:

Specifically, implementations of IDisposable and IAsyncDisposable are properly disposed at the end of their specified lifetime.

The way I'd interpret it, we promise here to clean up IAsyncDisposables regardless, but the implementation doesn't follow on this promise? 🤔

There is no warnings about this exception in the AsyncServiceScope.Dispose() API docs either... Is it documented somewhere?..

It might be smth out of scope of this PR, but it feels like something we might potentially want to fix? Especially given we already handle this case above in the CaptureDisposable method.


Re: "catch outside of for" optimization -- I'd vote for keeping it simple, i.e. ordinary for/foreach, and catch inside it, unless are we actually sure this will give any kind of perf gain (i.e. a microbenchmark). Am I missing smth? I don't think I've seen this pattern in the libraries, e.g. here is a straightforward foreach:

foreach (KeyValuePair<TKey, Lazy<RateLimiter>> limiter in _limiters)
{
try
{
limiter.Value.Value.Dispose();
}
catch (Exception ex)
{
exceptions ??= new List<Exception>();
exceptions.Add(ex);
}
}

Copy link
Member Author

Choose a reason for hiding this comment

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

That`s a good point, how about making an issue specifically about this so we collect more information and fix it in another PR?

Copy link
Member Author

Choose a reason for hiding this comment

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

I`ve created an issue to further discuss the behaviour, #123620.

}
}
}
catch (Exception ex)
{
exceptions ??= new List<Exception>();
exceptions.Add(ex);
index--;
}
}

if (exceptions is null)
{
return;
}

throw exceptions.Count == 1 ? exceptions[0] : new AggregateException(exceptions);
}

public ValueTask DisposeAsync()
{
List<object>? toDispose = BeginDispose();
List<Exception>? exceptions = null;
var index = (toDispose?.Count ?? 0) - 1;

if (toDispose != null)
while (index >= 0)
{
try
{
for (int i = toDispose.Count - 1; i >= 0; i--)
for (; index >= 0; index--)
{
object disposable = toDispose[i];
object disposable = toDispose![index];
if (disposable is IAsyncDisposable asyncDisposable)
{
ValueTask vt = asyncDisposable.DisposeAsync();
if (!vt.IsCompletedSuccessfully)
{
return Await(i, vt, toDispose);
return Await(index, vt, toDispose);
}

// If its a IValueTaskSource backed ValueTask,
Expand All @@ -168,11 +188,19 @@ public ValueTask DisposeAsync()
}
catch (Exception ex)
{
return new ValueTask(Task.FromException(ex));
exceptions ??= new List<Exception>();
exceptions.Add(ex);
index--;
}
}

return default;
if (exceptions is null)
{
return default;
}

var exception = exceptions.Count == 1 ? exceptions[0] : new AggregateException(exceptions);
return new ValueTask(Task.FromException(exception));

static async ValueTask Await(int i, ValueTask vt, List<object> toDispose)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection.Specification.Fakes;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.Extensions.DependencyInjection.ServiceLookup
{
Expand Down Expand Up @@ -41,5 +40,121 @@ public void ServiceProviderEngineScope_ImplementsAllServiceProviderInterfaces()
Assert.Contains(serviceProviderInterface, engineScopeInterfaces);
}
}

[Fact]
public void Dispose_ServiceThrows_DisposesAllAndThrows()
{
var services = new ServiceCollection();
services.AddKeyedTransient("throws", (_, _) => new TestDisposable(true));
services.AddKeyedTransient("doesnotthrow", (_, _) => new TestDisposable(false));

var scope = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>().CreateScope().ServiceProvider;

var disposables = new TestDisposable[]
{
scope.GetRequiredKeyedService<TestDisposable>("throws"),
scope.GetRequiredKeyedService<TestDisposable>("doesnotthrow")
};

var exception = Assert.Throws<InvalidOperationException>(() => ((IDisposable)scope).Dispose());
Assert.Equal(TestDisposable.ErrorMessage, exception.Message);
Assert.All(disposables, disposable => Assert.True(disposable.IsDisposed));
}

[Fact]
public void Dispose_TwoServicesThrows_DisposesAllAndThrowsAggregateException()
{
var services = new ServiceCollection();
services.AddKeyedTransient("throws", (_, _) => new TestDisposable(true));
services.AddKeyedTransient("doesnotthrow", (_, _) => new TestDisposable(false));

var scope = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>().CreateScope().ServiceProvider;

var disposables = new TestDisposable[]
{
scope.GetRequiredKeyedService<TestDisposable>("throws"),
scope.GetRequiredKeyedService<TestDisposable>("doesnotthrow"),
scope.GetRequiredKeyedService<TestDisposable>("throws"),
scope.GetRequiredKeyedService<TestDisposable>("doesnotthrow"),
};

var exception = Assert.Throws<AggregateException>(() => ((IDisposable)scope).Dispose());
Assert.Equal(2, exception.InnerExceptions.Count);
Assert.All(exception.InnerExceptions, ex => Assert.IsType<InvalidOperationException>(ex));
Assert.All(disposables, disposable => Assert.True(disposable.IsDisposed));
}

[Fact]
public async Task DisposeAsync_ServiceThrows_DisposesAllAndThrows()
{
var services = new ServiceCollection();
services.AddKeyedTransient("throws", (_, _) => new TestDisposable(true));
services.AddKeyedTransient("doesnotthrow", (_, _) => new TestDisposable(false));

var scope = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>().CreateScope().ServiceProvider;

var disposables = new TestDisposable[]
{
scope.GetRequiredKeyedService<TestDisposable>("throws"),
scope.GetRequiredKeyedService<TestDisposable>("doesnotthrow")
};

var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await ((IAsyncDisposable)scope).DisposeAsync());
Assert.Equal(TestDisposable.ErrorMessage, exception.Message);
Assert.All(disposables, disposable => Assert.True(disposable.IsDisposed));
}

[Fact]
public async Task DisposeAsync_TwoServicesThrows_DisposesAllAndThrowsAggregateException()
{
var services = new ServiceCollection();
services.AddKeyedTransient("throws", (_, _) => new TestDisposable(true));
services.AddKeyedTransient("doesnotthrow", (_, _) => new TestDisposable(false));

var scope = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>().CreateScope().ServiceProvider;

var disposables = new TestDisposable[]
{
scope.GetRequiredKeyedService<TestDisposable>("throws"),
scope.GetRequiredKeyedService<TestDisposable>("doesnotthrow"),
scope.GetRequiredKeyedService<TestDisposable>("throws"),
scope.GetRequiredKeyedService<TestDisposable>("doesnotthrow"),
};

var exception = await Assert.ThrowsAsync<AggregateException>(async () => await ((IAsyncDisposable)scope).DisposeAsync());
Assert.Equal(2, exception.InnerExceptions.Count);
Assert.All(exception.InnerExceptions, ex => Assert.IsType<InvalidOperationException>(ex));
Assert.All(disposables, disposable => Assert.True(disposable.IsDisposed));
}

private class TestDisposable : IDisposable, IAsyncDisposable
{
public const string ErrorMessage = "Dispose failed.";

private readonly bool _throwsOnDispose;

public bool IsDisposed { get; private set; }

public TestDisposable(bool throwsOnDispose)
{
_throwsOnDispose = throwsOnDispose;
}

public void Dispose()
{
IsDisposed = true;

if (_throwsOnDispose)
{
throw new InvalidOperationException(ErrorMessage);
}
}

public ValueTask DisposeAsync()
{
Dispose();
return default;
}
}
}
}
Loading