From 9e88bd4a5da884424649d2d7f7e62443fd464d34 Mon Sep 17 00:00:00 2001 From: Ghost93 Date: Mon, 15 Jun 2026 11:44:46 +0300 Subject: [PATCH 1/6] Fix Grpc.Net.ClientFactory version range check Normalize NuGet version range metadata before calling VersionLessThan in the Http.Resilience buildTransitive target. Add regression coverage for bracket-pinned versions across all target inputs. Fixes #7565 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...crosoft.Extensions.Http.Resilience.targets | 30 +- .../GrpcNetClientFactoryVersionTargetTests.cs | 304 ++++++++++++++++++ 2 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets b/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets index 268f0720433..4f2cec7de14 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets @@ -2,6 +2,7 @@ <_GrpcNetClientFactory>Grpc.Net.ClientFactory <_CompatibleGrpcNetClientFactoryVersion>2.64.0 + <_GrpcNetClientFactoryComparableVersionPattern>^\s*[\[\(]?\s*(\d+(?:\.\d+){1,3})(?=\s*(?:[,)\]]|$)) <_GrpcNetClientFactoryVersionIsIncorrect>Grpc.Net.ClientFactory 2.63.0 or earlier could cause issues when used together with Microsoft.Extensions.Http.Resilience. For more details, see https://learn.microsoft.com/dotnet/core/resilience/http-resilience#known-issues. Consider using Grpc.Net.ClientFactory $(_CompatibleGrpcNetClientFactoryVersion) or later. To suppress the warning set SuppressCheckGrpcNetClientFactoryVersion=true. @@ -15,40 +16,47 @@ Condition=" '$(SuppressCheckGrpcNetClientFactoryVersion)' != 'true' "> - <_GrpcNetClientFactoryPackageReference Include="@(PackageReference)" Condition=" '%(PackageReference.Identity)' == '$(_GrpcNetClientFactory)' " /> + <_GrpcNetClientFactoryPackageReference Include="@(PackageReference)" Condition=" '%(PackageReference.Identity)' == '$(_GrpcNetClientFactory)' "> + <_ComparableVersion>$([System.Text.RegularExpressions.Regex]::Match('%(PackageReference.Version)', '$(_GrpcNetClientFactoryComparableVersionPattern)').Groups[1].Value) + <_ComparableVersionOverride>$([System.Text.RegularExpressions.Regex]::Match('%(PackageReference.VersionOverride)', '$(_GrpcNetClientFactoryComparableVersionPattern)').Groups[1].Value) + - <_GrpcNetClientFactoryPackageVersion Include="@(PackageVersion)" Condition=" '%(PackageVersion.Identity)' == '$(_GrpcNetClientFactory)' " /> + <_GrpcNetClientFactoryPackageVersion Include="@(PackageVersion)" Condition=" '%(PackageVersion.Identity)' == '$(_GrpcNetClientFactory)' "> + <_ComparableVersion>$([System.Text.RegularExpressions.Regex]::Match('%(PackageVersion.Version)', '$(_GrpcNetClientFactoryComparableVersionPattern)').Groups[1].Value) + - <_GrpcNetClientFactoryTransitiveDependency Include="@(ReferencePath)" Condition=" '%(ReferencePath.NuGetPackageId)' == '$(_GrpcNetClientFactory)' " /> + <_GrpcNetClientFactoryTransitiveDependency Include="@(ReferencePath)" Condition=" '%(ReferencePath.NuGetPackageId)' == '$(_GrpcNetClientFactory)' "> + <_ComparableVersion>$([System.Text.RegularExpressions.Regex]::Match('%(ReferencePath.NuGetPackageVersion)', '$(_GrpcNetClientFactoryComparableVersionPattern)').Groups[1].Value) + diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs new file mode 100644 index 00000000000..36116b3ecfc --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs @@ -0,0 +1,304 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Microsoft.Extensions.Http.Resilience.Test.BuildTransitive; + +public class GrpcNetClientFactoryVersionTargetTests +{ + private const string WarningMessage = "Grpc.Net.ClientFactory 2.63.0 or earlier could cause issues"; + + public static TheoryData CompatibleVersionSources => new() + { + { + "PackageReference.Version", + """ + + + + """ + }, + { + "PackageReference.VersionOverride", + """ + + true + + + + + """ + }, + { + "PackageVersion.Version", + """ + + true + + + + + + """ + }, + { + "ReferencePath.NuGetPackageVersion", + """ + + + + """ + }, + }; + + public static TheoryData IncompatibleVersionSources => new() + { + { + "PackageReference.Version", + """ + + + + """ + }, + { + "PackageReference.VersionOverride", + """ + + true + + + + + """ + }, + { + "PackageVersion.Version", + """ + + true + + + + + + """ + }, + { + "ReferencePath.NuGetPackageVersion", + """ + + + + """ + }, + }; + + [Theory] + [MemberData(nameof(CompatibleVersionSources))] + public async Task CheckGrpcNetClientFactoryVersion_CompatibleBracketPinnedVersion_DoesNotWarnOrFail( + string scenario, + string projectItems) + { + var result = await RunTargetAsync(projectItems); + + result.ExitCode.Should().Be(0, result.ToString()); + result.Output.Should().NotContain("MSB4184", scenario); + result.Output.Should().NotContain(WarningMessage, scenario); + } + + [Theory] + [MemberData(nameof(IncompatibleVersionSources))] + public async Task CheckGrpcNetClientFactoryVersion_IncompatibleBracketPinnedVersion_Warns( + string scenario, + string projectItems) + { + var result = await RunTargetAsync(projectItems); + + result.ExitCode.Should().Be(0, result.ToString()); + result.Output.Should().NotContain("MSB4184", scenario); + result.Output.Should().Contain(WarningMessage, scenario); + } + + [Theory] + [InlineData("(,2.80.0]")] + [InlineData("[2.64.0-preview.1]")] + public async Task CheckGrpcNetClientFactoryVersion_VersionWithoutComparableSystemVersion_DoesNotFail(string version) + { + var result = await RunTargetAsync($""" + + + + """); + + result.ExitCode.Should().Be(0, result.ToString()); + result.Output.Should().NotContain("MSB4184"); + result.Output.Should().NotContain(WarningMessage); + } + + private static async Task RunTargetAsync(string projectItems) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"GrpcNetClientFactoryTargetTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + + try + { + var projectPath = Path.Combine(tempDirectory, "test.proj"); + var project = $""" + + + {projectItems} + + """; + + File.WriteAllText(projectPath, project); + + return await RunDotNetAsync(tempDirectory, "msbuild", projectPath, "-nologo", "-v:minimal", "-t:_CheckGrpcNetClientFactoryVersion").ConfigureAwait(false); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static async Task RunDotNetAsync(string workingDirectory, params string[] arguments) + { + var processStartInfo = new ProcessStartInfo(GetDotNetPath()) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + Arguments = CreateArguments(arguments), + }; + + using var process = Process.Start(processStartInfo) ?? throw new InvalidOperationException("Failed to start dotnet."); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)TimeSpan.FromSeconds(30).TotalMilliseconds)) + { + process.Kill(); + throw new TimeoutException("Timed out while running dotnet msbuild."); + } + + var standardOutput = await standardOutputTask.ConfigureAwait(false); + var standardError = await standardErrorTask.ConfigureAwait(false); + + return new CommandResult(process.ExitCode, standardOutput, standardError); + } + + private static string CreateArguments(params string[] arguments) + { + return string.Join(" ", Array.ConvertAll(arguments, QuoteArgument)); + } + + private static string QuoteArgument(string argument) + { + var quoted = new StringBuilder(); + quoted.Append('"'); + + var backslashCount = 0; + foreach (var character in argument) + { + if (character == '\\') + { + backslashCount++; + } + else if (character == '"') + { + quoted.Append('\\', (backslashCount * 2) + 1); + quoted.Append('"'); + backslashCount = 0; + } + else + { + quoted.Append('\\', backslashCount); + quoted.Append(character); + backslashCount = 0; + } + } + + quoted.Append('\\', backslashCount * 2); + quoted.Append('"'); + + return quoted.ToString(); + } + + private static string GetTargetPath() + { + var repoRoot = GetRepoRoot(); + return Path.Combine( + repoRoot, + "src", + "Libraries", + "Microsoft.Extensions.Http.Resilience", + "buildTransitive", + "Microsoft.Extensions.Http.Resilience.targets"); + } + + private static string GetDotNetPath() + { + var dotnetFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + var repoDotnetPath = Path.Combine(GetRepoRoot(), ".dotnet", dotnetFileName); + + return File.Exists(repoDotnetPath) ? repoDotnetPath : dotnetFileName; + } + + private static string GetRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null) + { + var targetPath = Path.Combine( + directory.FullName, + "src", + "Libraries", + "Microsoft.Extensions.Http.Resilience", + "buildTransitive", + "Microsoft.Extensions.Http.Resilience.targets"); + + if (File.Exists(targetPath)) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Failed to locate the repository root."); + } + + private static string EscapeXml(string value) + { + return SecurityElement.Escape(value) ?? string.Empty; + } + + private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError) + { + public string Output => StandardOutput + StandardError; + + public override string ToString() + { + return $""" + Exit code: {ExitCode} + Standard output: + {StandardOutput} + Standard error: + {StandardError} + """; + } + } +} From c8cae4fe06be24335d1fb0b986e2f032f6d37b00 Mon Sep 17 00:00:00 2001 From: Ghost93 Date: Mon, 15 Jun 2026 15:37:34 +0300 Subject: [PATCH 2/6] Make build target test cleanup best effort Avoid letting temporary directory cleanup failures mask the original test assertion or process failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GrpcNetClientFactoryVersionTargetTests.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs index 36116b3ecfc..ac9d195a0c8 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs @@ -166,7 +166,27 @@ private static async Task RunTargetAsync(string projectItems) } finally { - Directory.Delete(tempDirectory, recursive: true); + DeleteDirectoryBestEffort(tempDirectory); + } + } + + private static void DeleteDirectoryBestEffort(string directory) + { + for (var retry = 0; retry < 3; retry++) + { + try + { + Directory.Delete(directory, recursive: true); + return; + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + System.Threading.Thread.Sleep(TimeSpan.FromMilliseconds(50 * (retry + 1))); } } From 8e2685642eb04d09681d4670288f640e6e28b162 Mon Sep 17 00:00:00 2001 From: Ghost93 Date: Mon, 15 Jun 2026 15:38:19 +0300 Subject: [PATCH 3/6] Harden build target test timeout handling Kill the process tree where supported, wait for termination, and observe output reader tasks before reporting a timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GrpcNetClientFactoryVersionTargetTests.cs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs index ac9d195a0c8..74dec3e99b3 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs @@ -209,7 +209,10 @@ private static async Task RunDotNetAsync(string workingDirectory, if (!process.WaitForExit((int)TimeSpan.FromSeconds(30).TotalMilliseconds)) { - process.Kill(); + KillProcess(process); + process.WaitForExit(); + await ObserveOutputTaskAsync(standardOutputTask).ConfigureAwait(false); + await ObserveOutputTaskAsync(standardErrorTask).ConfigureAwait(false); throw new TimeoutException("Timed out while running dotnet msbuild."); } @@ -219,6 +222,35 @@ private static async Task RunDotNetAsync(string workingDirectory, return new CommandResult(process.ExitCode, standardOutput, standardError); } + private static void KillProcess(Process process) + { + try + { +#if NETFRAMEWORK + process.Kill(); +#else + process.Kill(entireProcessTree: true); +#endif + } + catch (InvalidOperationException) + { + } + } + + private static async Task ObserveOutputTaskAsync(Task outputTask) + { + try + { + await outputTask.ConfigureAwait(false); + } + catch (IOException) + { + } + catch (ObjectDisposedException) + { + } + } + private static string CreateArguments(params string[] arguments) { return string.Join(" ", Array.ConvertAll(arguments, QuoteArgument)); From c5421fb54870707762cdb229f8c1a2a4544bc03e Mon Sep 17 00:00:00 2001 From: Ghost93 Date: Mon, 15 Jun 2026 15:38:45 +0300 Subject: [PATCH 4/6] Use native process arguments where supported Prefer ProcessStartInfo.ArgumentList on modern target frameworks while keeping the quoted Arguments fallback for net462. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GrpcNetClientFactoryVersionTargetTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs index 74dec3e99b3..85ba3e9a274 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs @@ -6,7 +6,9 @@ using System.IO; using System.Runtime.InteropServices; using System.Security; +#if NETFRAMEWORK using System.Text; +#endif using System.Threading.Tasks; using FluentAssertions; using Xunit; @@ -199,9 +201,18 @@ private static async Task RunDotNetAsync(string workingDirectory, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, +#if NETFRAMEWORK Arguments = CreateArguments(arguments), +#endif }; +#if !NETFRAMEWORK + foreach (var argument in arguments) + { + processStartInfo.ArgumentList.Add(argument); + } +#endif + using var process = Process.Start(processStartInfo) ?? throw new InvalidOperationException("Failed to start dotnet."); var standardOutputTask = process.StandardOutput.ReadToEndAsync(); @@ -251,6 +262,7 @@ private static async Task ObserveOutputTaskAsync(Task outputTask) } } +#if NETFRAMEWORK private static string CreateArguments(params string[] arguments) { return string.Join(" ", Array.ConvertAll(arguments, QuoteArgument)); @@ -287,6 +299,7 @@ private static string QuoteArgument(string argument) return quoted.ToString(); } +#endif private static string GetTargetPath() { From c4a3caf7b2a58f861287f8a5888ba875aa0082f9 Mon Sep 17 00:00:00 2001 From: Ghost93 Date: Mon, 15 Jun 2026 15:39:13 +0300 Subject: [PATCH 5/6] Document Grpc.Net.ClientFactory version parsing Explain the comparable version regex inputs so future changes preserve the MSBuild VersionLessThan guard behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../buildTransitive/Microsoft.Extensions.Http.Resilience.targets | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets b/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets index 4f2cec7de14..0c6c9fc41fb 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets @@ -2,6 +2,7 @@ <_GrpcNetClientFactory>Grpc.Net.ClientFactory <_CompatibleGrpcNetClientFactoryVersion>2.64.0 + <_GrpcNetClientFactoryComparableVersionPattern>^\s*[\[\(]?\s*(\d+(?:\.\d+){1,3})(?=\s*(?:[,)\]]|$)) <_GrpcNetClientFactoryVersionIsIncorrect>Grpc.Net.ClientFactory 2.63.0 or earlier could cause issues when used together with Microsoft.Extensions.Http.Resilience. For more details, see https://learn.microsoft.com/dotnet/core/resilience/http-resilience#known-issues. Consider using Grpc.Net.ClientFactory $(_CompatibleGrpcNetClientFactoryVersion) or later. To suppress the warning set SuppressCheckGrpcNetClientFactoryVersion=true. From 4c7e5737d18d2598bed237ff20c98c2682987643 Mon Sep 17 00:00:00 2001 From: Ghost93 Date: Mon, 15 Jun 2026 16:54:58 +0300 Subject: [PATCH 6/6] Fix build target test analyzer warnings Avoid empty catch blocks while preserving best-effort cleanup and output task observation behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GrpcNetClientFactoryVersionTargetTests.cs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs index 85ba3e9a274..3b862370d96 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/BuildTransitive/GrpcNetClientFactoryVersionTargetTests.cs @@ -183,15 +183,22 @@ private static void DeleteDirectoryBestEffort(string directory) } catch (IOException) { + // Best-effort cleanup should not mask the test failure. + DelayBeforeCleanupRetry(retry); } catch (UnauthorizedAccessException) { + // Best-effort cleanup should not mask the test failure. + DelayBeforeCleanupRetry(retry); } - - System.Threading.Thread.Sleep(TimeSpan.FromMilliseconds(50 * (retry + 1))); } } + private static void DelayBeforeCleanupRetry(int retry) + { + System.Threading.Thread.Sleep(TimeSpan.FromMilliseconds(50 * (retry + 1))); + } + private static async Task RunDotNetAsync(string workingDirectory, params string[] arguments) { var processStartInfo = new ProcessStartInfo(GetDotNetPath()) @@ -222,8 +229,7 @@ private static async Task RunDotNetAsync(string workingDirectory, { KillProcess(process); process.WaitForExit(); - await ObserveOutputTaskAsync(standardOutputTask).ConfigureAwait(false); - await ObserveOutputTaskAsync(standardErrorTask).ConfigureAwait(false); + await ObserveOutputTasksAsync(standardOutputTask, standardErrorTask).ConfigureAwait(false); throw new TimeoutException("Timed out while running dotnet msbuild."); } @@ -243,22 +249,27 @@ private static void KillProcess(Process process) process.Kill(entireProcessTree: true); #endif } - catch (InvalidOperationException) + catch (InvalidOperationException exception) { + _ = exception; } } - private static async Task ObserveOutputTaskAsync(Task outputTask) + private static async Task ObserveOutputTasksAsync(Task standardOutputTask, Task standardErrorTask) { try { - await outputTask.ConfigureAwait(false); +#pragma warning disable VSTHRD003 // Output reader tasks are intentionally observed after the process exits. + await Task.WhenAll(standardOutputTask, standardErrorTask).ConfigureAwait(false); +#pragma warning restore VSTHRD003 } - catch (IOException) + catch (IOException exception) { + _ = exception; } - catch (ObjectDisposedException) + catch (ObjectDisposedException exception) { + _ = exception; } }