Skip to content

Commit 6a886b7

Browse files
Daniel MarbachSimonCropp
Daniel Marbach
andauthored
Use async scope to make sure scoped IAsyncDisposable instances get disposed (#6650) (#6658)
* AcceptanceTest to verify scoped async disposable support * use CreateAsyncScope * Add Assert * Extend test for singletons * Owned builder should be disposed using async disposable * Async dispose token registration * Dispose stopping token source * Fix leaking token source * Enable nullable in RunningEndpointInstance to indicate case when builder is null * Make the externally managed mode more visible in the code * Coverage of externally and internally managed mode * Rename tests Co-authored-by: Simon <[email protected]> Co-authored-by: Simon <[email protected]>
1 parent ae2556b commit 6a886b7

9 files changed

+246
-25
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
namespace NServiceBus.AcceptanceTests.Core.DependencyInjection
2+
{
3+
using System;
4+
using System.Threading.Tasks;
5+
using AcceptanceTesting;
6+
using EndpointTemplates;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using NUnit.Framework;
9+
10+
[TestFixture]
11+
public class When_registering_async_disposables_externally_managed : NServiceBusAcceptanceTest
12+
{
13+
[Test]
14+
public async Task Should_dispose()
15+
{
16+
ServiceProvider serviceProvider = null;
17+
var serviceCollection = new ServiceCollection();
18+
serviceCollection.AddSingleton<SingletonAsyncDisposable>();
19+
serviceCollection.AddScoped<ScopedAsyncDisposable>();
20+
21+
var context = await Scenario.Define<Context>()
22+
.WithEndpoint<EndpointWithAsyncDisposable>(b =>
23+
{
24+
IStartableEndpointWithExternallyManagedContainer configuredEndpoint = null;
25+
26+
b.ToCreateInstance(
27+
config =>
28+
{
29+
configuredEndpoint =
30+
EndpointWithExternallyManagedContainer.Create(config, serviceCollection);
31+
return Task.FromResult(configuredEndpoint);
32+
},
33+
configured =>
34+
{
35+
serviceProvider = serviceCollection.BuildServiceProvider();
36+
return configured.Start(serviceProvider);
37+
});
38+
b.When(e => e.SendLocal(new SomeMessage()));
39+
})
40+
.Done(c => c.ScopedAsyncDisposableDisposed)
41+
.Run(TimeSpan.FromSeconds(10));
42+
43+
await serviceProvider.DisposeAsync();
44+
45+
Assert.That(context.ScopedAsyncDisposableDisposed, Is.True, "Scoped AsyncDisposable wasn't disposed as it should have been.");
46+
Assert.That(context.SingletonAsyncDisposableDisposed, Is.True, "Singleton AsyncDisposable wasn't disposed as it should have been.");
47+
}
48+
49+
class Context : ScenarioContext
50+
{
51+
public bool ScopedAsyncDisposableDisposed { get; set; }
52+
public bool SingletonAsyncDisposableDisposed { get; set; }
53+
}
54+
55+
public class EndpointWithAsyncDisposable : EndpointConfigurationBuilder
56+
{
57+
public EndpointWithAsyncDisposable() =>
58+
EndpointSetup<DefaultServer>();
59+
60+
class HandlerWithAsyncDisposable : IHandleMessages<SomeMessage>
61+
{
62+
public HandlerWithAsyncDisposable(Context context, ScopedAsyncDisposable scopedAsyncDisposable, SingletonAsyncDisposable singletonAsyncDisposable)
63+
{
64+
testContext = context;
65+
this.scopedAsyncDisposable = scopedAsyncDisposable;
66+
this.singletonAsyncDisposable = singletonAsyncDisposable;
67+
}
68+
69+
public Task Handle(SomeMessage message, IMessageHandlerContext context)
70+
{
71+
scopedAsyncDisposable.Initialize(testContext);
72+
singletonAsyncDisposable.Initialize(testContext);
73+
return Task.CompletedTask;
74+
}
75+
76+
readonly Context testContext;
77+
readonly ScopedAsyncDisposable scopedAsyncDisposable;
78+
readonly SingletonAsyncDisposable singletonAsyncDisposable;
79+
}
80+
}
81+
82+
public class SomeMessage : IMessage
83+
{
84+
}
85+
86+
class SingletonAsyncDisposable : IAsyncDisposable
87+
{
88+
// This method is here to make the code being used in the handler to not trigger compiler warnings
89+
public void Initialize(Context scenarioContext) => context = scenarioContext;
90+
91+
public ValueTask DisposeAsync()
92+
{
93+
context.SingletonAsyncDisposableDisposed = true;
94+
return new ValueTask();
95+
}
96+
97+
Context context;
98+
}
99+
100+
class ScopedAsyncDisposable : IAsyncDisposable
101+
{
102+
// This method is here to make the code being used in the handler to not trigger compiler warnings
103+
public void Initialize(Context scenarioContext) => context = scenarioContext;
104+
105+
public ValueTask DisposeAsync()
106+
{
107+
context.ScopedAsyncDisposableDisposed = true;
108+
return new ValueTask();
109+
}
110+
111+
Context context;
112+
}
113+
}
114+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
namespace NServiceBus.AcceptanceTests.Core.DependencyInjection
2+
{
3+
using System;
4+
using System.Threading.Tasks;
5+
using AcceptanceTesting;
6+
using EndpointTemplates;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using NUnit.Framework;
9+
10+
[TestFixture]
11+
public class When_registering_async_disposables_internally_managed : NServiceBusAcceptanceTest
12+
{
13+
[Test]
14+
public async Task Should_dispose()
15+
{
16+
var context = await Scenario.Define<Context>()
17+
.WithEndpoint<EndpointWithAsyncDisposable>(b =>
18+
{
19+
b.When(e => e.SendLocal(new SomeMessage()));
20+
})
21+
.Done(c => c.ScopedAsyncDisposableDisposed)
22+
.Run(TimeSpan.FromSeconds(10));
23+
24+
Assert.That(context.ScopedAsyncDisposableDisposed, Is.True, "Scoped AsyncDisposable wasn't disposed as it should have been.");
25+
Assert.That(context.SingletonAsyncDisposableDisposed, Is.True, "Singleton AsyncDisposable wasn't disposed as it should have been.");
26+
}
27+
28+
class Context : ScenarioContext
29+
{
30+
public bool ScopedAsyncDisposableDisposed { get; set; }
31+
public bool SingletonAsyncDisposableDisposed { get; set; }
32+
}
33+
34+
public class EndpointWithAsyncDisposable : EndpointConfigurationBuilder
35+
{
36+
public EndpointWithAsyncDisposable() =>
37+
EndpointSetup<DefaultServer>(c =>
38+
{
39+
c.RegisterComponents(s =>
40+
{
41+
s.AddScoped<ScopedAsyncDisposable>();
42+
s.AddSingleton<SingletonAsyncDisposable>();
43+
});
44+
});
45+
46+
class HandlerWithAsyncDisposable : IHandleMessages<SomeMessage>
47+
{
48+
public HandlerWithAsyncDisposable(Context context, ScopedAsyncDisposable scopedAsyncDisposable, SingletonAsyncDisposable singletonAsyncDisposable)
49+
{
50+
testContext = context;
51+
this.scopedAsyncDisposable = scopedAsyncDisposable;
52+
this.singletonAsyncDisposable = singletonAsyncDisposable;
53+
}
54+
55+
public Task Handle(SomeMessage message, IMessageHandlerContext context)
56+
{
57+
scopedAsyncDisposable.Initialize(testContext);
58+
singletonAsyncDisposable.Initialize(testContext);
59+
return Task.CompletedTask;
60+
}
61+
62+
readonly Context testContext;
63+
readonly ScopedAsyncDisposable scopedAsyncDisposable;
64+
readonly SingletonAsyncDisposable singletonAsyncDisposable;
65+
}
66+
}
67+
68+
public class SomeMessage : IMessage
69+
{
70+
}
71+
72+
class SingletonAsyncDisposable : IAsyncDisposable
73+
{
74+
// This method is here to make the code being used in the handler to not trigger compiler warnings
75+
public void Initialize(Context scenarioContext) => context = scenarioContext;
76+
77+
public ValueTask DisposeAsync()
78+
{
79+
context.SingletonAsyncDisposableDisposed = true;
80+
return new ValueTask();
81+
}
82+
83+
Context context;
84+
}
85+
86+
class ScopedAsyncDisposable : IAsyncDisposable
87+
{
88+
// This method is here to make the code being used in the handler to not trigger compiler warnings
89+
public void Initialize(Context scenarioContext) => context = scenarioContext;
90+
91+
public ValueTask DisposeAsync()
92+
{
93+
context.ScopedAsyncDisposableDisposed = true;
94+
return new ValueTask();
95+
}
96+
97+
Context context;
98+
}
99+
}
100+
}

src/NServiceBus.Core/EndpointInstanceExtensions.cs

+5-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ public static class EndpointInstanceExtensions
1717
/// <param name="endpoint">The endpoint to stop.</param>
1818
/// <param name="gracefulStopTimeout">The length of time granted to gracefully complete processing.</param>
1919
[SuppressMessage("Code", "PS0018:A task-returning method should have a CancellationToken parameter unless it has a parameter implementing ICancellableContext", Justification = "Convenience method wrapping the CancellationToken overload.")]
20-
public static Task Stop(this IEndpointInstance endpoint, TimeSpan gracefulStopTimeout) =>
21-
endpoint.Stop(new CancellationTokenSource(gracefulStopTimeout).Token);
20+
public static async Task Stop(this IEndpointInstance endpoint, TimeSpan gracefulStopTimeout)
21+
{
22+
using var cancellationTokenSource = new CancellationTokenSource(gracefulStopTimeout);
23+
await endpoint.Stop(cancellationTokenSource.Token).ConfigureAwait(false);
24+
}
2225
}
2326
}

src/NServiceBus.Core/Hosting/ExternallyManagedContainerHost.cs

+4-4
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ public ExternallyManagedContainerHost(EndpointCreator endpointCreator, HostingCo
3434

3535
internal Lazy<IServiceProvider> Builder { get; private set; }
3636

37-
public async Task<IEndpointInstance> Start(IServiceProvider externalBuilder, CancellationToken cancellationToken = default)
37+
public async Task<IEndpointInstance> Start(IServiceProvider serviceProvider, CancellationToken cancellationToken = default)
3838
{
39-
objectBuilder = externalBuilder;
39+
objectBuilder = serviceProvider;
4040

41-
var startableEndpoint = endpointCreator.CreateStartableEndpoint(externalBuilder, hostingComponent);
41+
var startableEndpoint = endpointCreator.CreateStartableEndpoint(serviceProvider, hostingComponent);
4242

43-
hostingComponent.RegisterBuilder(externalBuilder);
43+
hostingComponent.RegisterServiceProvider(serviceProvider);
4444

4545
await hostingComponent.RunInstallers(cancellationToken).ConfigureAwait(false);
4646

src/NServiceBus.Core/Hosting/HostCreator.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public static async Task<IStartableEndpoint> CreateWithInternallyManagedContaine
5959

6060
var serviceProvider = serviceCollection.BuildServiceProvider();
6161
var startableEndpoint = endpointCreator.CreateStartableEndpoint(serviceProvider, hostingComponent);
62-
hostingComponent.RegisterBuilder(serviceProvider);
62+
hostingComponent.RegisterServiceProvider(serviceProvider);
6363

6464
await hostingComponent.RunInstallers(cancellationToken).ConfigureAwait(false);
6565

@@ -76,4 +76,4 @@ static void CheckIfSettingsWhereUsedToCreateAnotherEndpoint(SettingsHolder setti
7676
settings.Set("UsedToCreateEndpoint", true);
7777
}
7878
}
79-
}
79+
}

src/NServiceBus.Core/Hosting/HostingComponent.cs

+11-14
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212

1313
partial class HostingComponent
1414
{
15-
public HostingComponent(Configuration configuration, bool shouldDisposeBuilder)
15+
public HostingComponent(Configuration configuration, bool shouldDisposeServiceProvider)
1616
{
1717
this.configuration = configuration;
18-
this.shouldDisposeBuilder = shouldDisposeBuilder;
18+
this.shouldDisposeServiceProvider = shouldDisposeServiceProvider;
1919
}
2020

21-
public static HostingComponent Initialize(Configuration configuration, IServiceCollection serviceCollection, bool shouldDisposeBuilder)
21+
public static HostingComponent Initialize(Configuration configuration, IServiceCollection serviceCollection, bool shouldDisposeServiceProvider)
2222
{
2323
serviceCollection.ConfigureComponent(() => configuration.HostInformation, DependencyLifecycle.SingleInstance);
2424
serviceCollection.ConfigureComponent(() => configuration.CriticalError, DependencyLifecycle.SingleInstance);
@@ -53,13 +53,10 @@ public static HostingComponent Initialize(Configuration configuration, IServiceC
5353
PathToExe = PathUtilities.SanitizedPath(Environment.CommandLine)
5454
});
5555

56-
return new HostingComponent(configuration, shouldDisposeBuilder);
56+
return new HostingComponent(configuration, shouldDisposeServiceProvider);
5757
}
5858

59-
public void RegisterBuilder(IServiceProvider objectBuilder)
60-
{
61-
builder = objectBuilder;
62-
}
59+
public void RegisterServiceProvider(IServiceProvider serviceProvider) => this.serviceProvider = serviceProvider;
6360

6461
// This can't happen at start due to an old "feature" that allowed users to
6562
// run installers by "just creating the endpoint". See https://docs.particular.net/nservicebus/operations/installers#running-installers for more details.
@@ -72,7 +69,7 @@ public async Task RunInstallers(CancellationToken cancellationToken = default)
7269

7370
var installationUserName = GetInstallationUserName();
7471

75-
foreach (var installer in builder.GetServices<INeedToInstallSomething>())
72+
foreach (var installer in serviceProvider.GetServices<INeedToInstallSomething>())
7673
{
7774
await installer.Install(installationUserName, cancellationToken).ConfigureAwait(false);
7875
}
@@ -91,11 +88,11 @@ public async Task<IEndpointInstance> Start(IStartableEndpoint startableEndpoint,
9188
return endpointInstance;
9289
}
9390

94-
public void Stop()
91+
public async Task Stop(CancellationToken cancellationToken = default)
9592
{
96-
if (shouldDisposeBuilder)
93+
if (shouldDisposeServiceProvider && serviceProvider is IAsyncDisposable asyncDisposableBuilder)
9794
{
98-
(builder as IDisposable)?.Dispose();
95+
await asyncDisposableBuilder.DisposeAsync().ConfigureAwait(false);
9996
}
10097
}
10198

@@ -115,7 +112,7 @@ string GetInstallationUserName()
115112
}
116113

117114
readonly Configuration configuration;
118-
bool shouldDisposeBuilder;
119-
IServiceProvider builder;
115+
bool shouldDisposeServiceProvider;
116+
IServiceProvider serviceProvider;
120117
}
121118
}

src/NServiceBus.Core/Pipeline/MainPipelineExecutor.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ public async Task Invoke(MessageContext messageContext, CancellationToken cancel
2525

2626
using var activity = activityFactory.StartIncomingActivity(messageContext);
2727

28-
using (var childScope = rootBuilder.CreateScope())
28+
var childScope = rootBuilder.CreateAsyncScope();
29+
await using (childScope.ConfigureAwait(false))
2930
{
3031
var message = new IncomingMessage(messageContext.NativeMessageId, messageContext.Headers, messageContext.Body);
3132

src/NServiceBus.Core/Recoverability/RecoverabilityPipelineExecutor.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ public RecoverabilityPipelineExecutor(
3131

3232
public async Task<ErrorHandleResult> Invoke(ErrorContext errorContext, CancellationToken cancellationToken = default)
3333
{
34-
using (var childScope = serviceProvider.CreateScope())
34+
var childScope = serviceProvider.CreateAsyncScope();
35+
await using (childScope.ConfigureAwait(false))
3536
{
3637
var rootContext = new RootContext(childScope.ServiceProvider, messageOperations, pipelineCache, cancellationToken);
3738
rootContext.Extensions.Merge(errorContext.Extensions);

src/NServiceBus.Core/Unicast/RunningEndpointInstance.cs

+6-1
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,20 @@ public async Task Stop(CancellationToken cancellationToken = default)
6363
finally
6464
{
6565
settings.Clear();
66-
hostingComponent.Stop();
66+
await hostingComponent.Stop(cancellationToken).ConfigureAwait(false);
6767
status = Status.Stopped;
6868
Log.Info("Shutdown complete.");
6969
}
7070
}
7171
finally
7272
{
7373
stopSemaphore.Release();
74+
#if NET
75+
await tokenRegistration.DisposeAsync().ConfigureAwait(false);
76+
#else
7477
tokenRegistration.Dispose();
78+
#endif
79+
stoppingTokenSource.Dispose();
7580
}
7681
}
7782

0 commit comments

Comments
 (0)