-
Notifications
You must be signed in to change notification settings - Fork 53
Fix GrpcChannel handle leak in AzureManaged backend #625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
882ea20
387c960
9ef84a9
19f521f
9b67032
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,7 +1,10 @@ | ||||||
| // Copyright (c) Microsoft Corporation. | ||||||
| // Licensed under the MIT License. | ||||||
|
|
||||||
| using System.Collections.Concurrent; | ||||||
| using System.Linq; | ||||||
| using Azure.Core; | ||||||
| using Grpc.Net.Client; | ||||||
| using Microsoft.DurableTask.Client.Grpc; | ||||||
| using Microsoft.Extensions.DependencyInjection; | ||||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||||
|
|
@@ -99,11 +102,23 @@ static void ConfigureSchedulerOptions( | |||||
| /// <summary> | ||||||
| /// Configuration class that sets up gRPC channels for client options | ||||||
| /// using the provided Durable Task Scheduler options. | ||||||
| /// Channels are cached per configuration key and disposed when the service provider is disposed. | ||||||
| /// </summary> | ||||||
| /// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param> | ||||||
| class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerClientOptions> schedulerOptions) : | ||||||
| IConfigureNamedOptions<GrpcDurableTaskClientOptions> | ||||||
| sealed class ConfigureGrpcChannel : IConfigureNamedOptions<GrpcDurableTaskClientOptions>, IAsyncDisposable | ||||||
| { | ||||||
| readonly IOptionsMonitor<DurableTaskSchedulerClientOptions> schedulerOptions; | ||||||
| readonly ConcurrentDictionary<string, Lazy<GrpcChannel>> channels = new(); | ||||||
| int disposed; | ||||||
|
|
||||||
| /// <summary> | ||||||
| /// Initializes a new instance of the <see cref="ConfigureGrpcChannel"/> class. | ||||||
| /// </summary> | ||||||
| /// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param> | ||||||
| public ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerClientOptions> schedulerOptions) | ||||||
| { | ||||||
| this.schedulerOptions = schedulerOptions; | ||||||
| } | ||||||
|
|
||||||
| /// <summary> | ||||||
| /// Configures the default named options instance. | ||||||
| /// </summary> | ||||||
|
|
@@ -117,8 +132,72 @@ class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerClientOptions> sc | |||||
| /// <param name="options">The options instance to configure.</param> | ||||||
| public void Configure(string? name, GrpcDurableTaskClientOptions options) | ||||||
| { | ||||||
| DurableTaskSchedulerClientOptions source = schedulerOptions.Get(name ?? Options.DefaultName); | ||||||
| options.Channel = source.CreateChannel(); | ||||||
| #if NET7_0_OR_GREATER | ||||||
| ObjectDisposedException.ThrowIf(this.disposed == 1, this); | ||||||
| #else | ||||||
| if (this.disposed == 1) | ||||||
| { | ||||||
| throw new ObjectDisposedException(nameof(ConfigureGrpcChannel)); | ||||||
| } | ||||||
| #endif | ||||||
|
|
||||||
| string optionsName = name ?? Options.DefaultName; | ||||||
| DurableTaskSchedulerClientOptions source = this.schedulerOptions.Get(optionsName); | ||||||
|
|
||||||
| // Create a cache key based on the options name, endpoint, and task hub. | ||||||
| // This ensures channels are reused for the same configuration | ||||||
|
Comment on lines
+144
to
+148
|
||||||
| // but separate channels are created for different configurations. | ||||||
| string cacheKey = $"{optionsName}:{source.EndpointAddress}:{source.TaskHubName}"; | ||||||
|
||||||
| string cacheKey = $"{optionsName}:{source.EndpointAddress}:{source.TaskHubName}"; | |
| string cacheKey = string.Concat(optionsName, "\u001F", source.EndpointAddress, "\u001F", source.TaskHubName); |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DisposeAsync throws an AggregateException when any channel shutdown/dispose fails. Throwing from ServiceProvider disposal can surface as app shutdown failures and is difficult for callers to handle. Consider making this best-effort (swallow/log disposal errors) instead of throwing.
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,10 @@ | ||||||||
| // Copyright (c) Microsoft Corporation. | ||||||||
| // Licensed under the MIT License. | ||||||||
|
|
||||||||
| using System.Collections.Concurrent; | ||||||||
| using System.Linq; | ||||||||
| using Azure.Core; | ||||||||
| using Grpc.Net.Client; | ||||||||
| using Microsoft.DurableTask.Worker.Grpc; | ||||||||
| using Microsoft.DurableTask.Worker.Grpc.Internal; | ||||||||
| using Microsoft.Extensions.DependencyInjection; | ||||||||
|
|
@@ -101,11 +104,23 @@ static void ConfigureSchedulerOptions( | |||||||
| /// <summary> | ||||||||
| /// Configuration class that sets up gRPC channels for worker options | ||||||||
| /// using the provided Durable Task Scheduler options. | ||||||||
| /// Channels are cached per configuration key and disposed when the service provider is disposed. | ||||||||
| /// </summary> | ||||||||
| /// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param> | ||||||||
| class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerWorkerOptions> schedulerOptions) : | ||||||||
| IConfigureNamedOptions<GrpcDurableTaskWorkerOptions> | ||||||||
| sealed class ConfigureGrpcChannel : IConfigureNamedOptions<GrpcDurableTaskWorkerOptions>, IAsyncDisposable | ||||||||
| { | ||||||||
| readonly IOptionsMonitor<DurableTaskSchedulerWorkerOptions> schedulerOptions; | ||||||||
| readonly ConcurrentDictionary<string, Lazy<GrpcChannel>> channels = new(); | ||||||||
| int disposed; | ||||||||
|
Comment on lines
+109
to
+113
|
||||||||
|
|
||||||||
| /// <summary> | ||||||||
| /// Initializes a new instance of the <see cref="ConfigureGrpcChannel"/> class. | ||||||||
| /// </summary> | ||||||||
| /// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param> | ||||||||
| public ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerWorkerOptions> schedulerOptions) | ||||||||
| { | ||||||||
| this.schedulerOptions = schedulerOptions; | ||||||||
| } | ||||||||
|
|
||||||||
| /// <summary> | ||||||||
| /// Configures the default named options instance. | ||||||||
| /// </summary> | ||||||||
|
|
@@ -119,9 +134,73 @@ class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerWorkerOptions> sc | |||||||
| /// <param name="options">The options instance to configure.</param> | ||||||||
| public void Configure(string? name, GrpcDurableTaskWorkerOptions options) | ||||||||
| { | ||||||||
| DurableTaskSchedulerWorkerOptions source = schedulerOptions.Get(name ?? Options.DefaultName); | ||||||||
| options.Channel = source.CreateChannel(); | ||||||||
| #if NET7_0_OR_GREATER | ||||||||
| ObjectDisposedException.ThrowIf(this.disposed == 1, this); | ||||||||
| #else | ||||||||
| if (this.disposed == 1) | ||||||||
| { | ||||||||
| throw new ObjectDisposedException(nameof(ConfigureGrpcChannel)); | ||||||||
| } | ||||||||
| #endif | ||||||||
|
|
||||||||
| string optionsName = name ?? Options.DefaultName; | ||||||||
| DurableTaskSchedulerWorkerOptions source = this.schedulerOptions.Get(optionsName); | ||||||||
|
|
||||||||
| // Create a cache key based on the options name, endpoint, and task hub. | ||||||||
| // This ensures channels are reused for the same configuration | ||||||||
|
Comment on lines
+146
to
+150
|
||||||||
| // but separate channels are created for different configurations. | ||||||||
| string cacheKey = $"{optionsName}:{source.EndpointAddress}:{source.TaskHubName}"; | ||||||||
|
||||||||
| string cacheKey = $"{optionsName}:{source.EndpointAddress}:{source.TaskHubName}"; | |
| // Use a delimiter character (\u001F) that will not appear in typical endpoint URIs. | |
| string cacheKey = $"{optionsName}\u001F{source.EndpointAddress}\u001F{source.TaskHubName}"; |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DisposeAsync throws an AggregateException when any channel shutdown/dispose fails. Throwing from ServiceProvider disposal can surface as app shutdown failures and is difficult for callers to handle. Consider making this best-effort (swallow/log disposal errors) instead of throwing.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |||||||||||||||||||||||||
| using Azure.Identity; | ||||||||||||||||||||||||||
| using FluentAssertions; | ||||||||||||||||||||||||||
| using Grpc.Core; | ||||||||||||||||||||||||||
| using Grpc.Net.Client; | ||||||||||||||||||||||||||
| using Microsoft.DurableTask.Client.Grpc; | ||||||||||||||||||||||||||
| using Microsoft.Extensions.DependencyInjection; | ||||||||||||||||||||||||||
| using Microsoft.Extensions.Options; | ||||||||||||||||||||||||||
|
|
@@ -280,4 +281,120 @@ public void UseDurableTaskScheduler_WithConnectionStringAndRetryOptions_ShouldCo | |||||||||||||||||||||||||
| clientOptions.RetryOptions.RetryableStatusCodes.Should().Contain(StatusCode.Unknown); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [Fact] | ||||||||||||||||||||||||||
| public void UseDurableTaskScheduler_SameConfiguration_ReusesSameChannel() | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| // Arrange | ||||||||||||||||||||||||||
| ServiceCollection services = new ServiceCollection(); | ||||||||||||||||||||||||||
| Mock<IDurableTaskClientBuilder> mockBuilder = new Mock<IDurableTaskClientBuilder>(); | ||||||||||||||||||||||||||
| mockBuilder.Setup(b => b.Services).Returns(services); | ||||||||||||||||||||||||||
| DefaultAzureCredential credential = new DefaultAzureCredential(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Act | ||||||||||||||||||||||||||
| mockBuilder.Object.UseDurableTaskScheduler(ValidEndpoint, ValidTaskHub, credential); | ||||||||||||||||||||||||||
| ServiceProvider provider = services.BuildServiceProvider(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Resolve options multiple times to trigger channel configuration | ||||||||||||||||||||||||||
| IOptionsMonitor<GrpcDurableTaskClientOptions> optionsMonitor = provider.GetRequiredService<IOptionsMonitor<GrpcDurableTaskClientOptions>>(); | ||||||||||||||||||||||||||
| GrpcDurableTaskClientOptions options1 = optionsMonitor.Get(Options.DefaultName); | ||||||||||||||||||||||||||
| GrpcDurableTaskClientOptions options2 = optionsMonitor.Get(Options.DefaultName); | ||||||||||||||||||||||||||
|
Comment on lines
+296
to
+301
|
||||||||||||||||||||||||||
| ServiceProvider provider = services.BuildServiceProvider(); | |
| // Resolve options multiple times to trigger channel configuration | |
| IOptionsMonitor<GrpcDurableTaskClientOptions> optionsMonitor = provider.GetRequiredService<IOptionsMonitor<GrpcDurableTaskClientOptions>>(); | |
| GrpcDurableTaskClientOptions options1 = optionsMonitor.Get(Options.DefaultName); | |
| GrpcDurableTaskClientOptions options2 = optionsMonitor.Get(Options.DefaultName); | |
| using ServiceProvider provider = services.BuildServiceProvider(); | |
| // Resolve options multiple times to trigger channel configuration | |
| IOptionsFactory<GrpcDurableTaskClientOptions> optionsFactory = provider.GetRequiredService<IOptionsFactory<GrpcDurableTaskClientOptions>>(); | |
| GrpcDurableTaskClientOptions options1 = optionsFactory.Create(Options.DefaultName); | |
| GrpcDurableTaskClientOptions options2 = optionsFactory.Create(Options.DefaultName); |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test uses different endpoints for different named options, so it will pass even if the cache key accidentally ignores the options name. To validate name isolation in the cache key, use the same endpoint/task hub for both names and assert the channels differ; also dispose the ServiceProvider to avoid leaking channels.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR description mentions IDisposable and a volatile disposed flag, but this implementation is IAsyncDisposable-only and the disposed field isn’t volatile (and is read without Volatile.Read). Either update the PR description or adjust the implementation to match (e.g., implement IDisposable and use volatile/Volatile.Read for the disposed check).