diff --git a/test/Microsoft.DotNet.Cli.Utils.Tests/TransientSdkResolutionErrorDetectorTests.cs b/test/Microsoft.DotNet.Cli.Utils.Tests/TransientSdkResolutionErrorDetectorTests.cs new file mode 100644 index 000000000000..793954e8856e --- /dev/null +++ b/test/Microsoft.DotNet.Cli.Utils.Tests/TransientSdkResolutionErrorDetectorTests.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Cli.Utils.Tests +{ + public class TransientSdkResolutionErrorDetectorTests + { + [Fact] + public void TransientInBoxSdkResolutionFailureIsDetected() + { + string input = + "/datadisks/disk1/work/99FC086F/p/d/sdk/11.0.100-ci/Sdks/Microsoft.NET.Sdk.Razor/Sdk/Sdk.props(20,3): " + + "error : Could not resolve SDK \"Microsoft.NET.Sdk.StaticWebAssets\". Exactly one of the probing " + + "messages below indicates why we could not resolve the SDK.\r\n" + + "Sdk.props(20,3): error : SDK resolver \"Microsoft.DotNet.MSBuildWorkloadSdkResolver\" returned null.\r\n" + + "Sdk.props(20,3): error : The NuGetSdkResolver did not resolve this SDK because there was no version " + + "specified in the project or global.json.\r\n" + + "Sdk.props(20,11): error MSB4236: The SDK 'Microsoft.NET.Sdk.StaticWebAssets' specified could not be " + + "found. [/work/InstantiatePr.csproj]"; + TransientSdkResolutionErrorDetector.IsTransientError(input).Should().BeTrue(); + } + + [Fact] + public void NullInputIsNotTransient() + { + TransientSdkResolutionErrorDetector.IsTransientError(null).Should().BeFalse(); + } + + [Fact] + public void SuccessfulBuildIsNotTransient() + { + string input = + " Determining projects to restore...\r\n" + + " Restored /work/InstantiatePr.csproj (in 131 ms).\r\n" + + " InstantiatePr -> /work/bin/Debug/net11.0/InstantiatePr.dll\r\n" + + "Build succeeded.\r\n 0 Warning(s)\r\n 0 Error(s)"; + TransientSdkResolutionErrorDetector.IsTransientError(input).Should().BeFalse(); + } + + [Fact] + public void MissingVersionedSdkWithoutResolverNullIsNotTransient() + { + // A genuinely missing, version-specified SDK is a deterministic failure (no workload resolver + // "returned null" deferral), so it must not be treated as transient. + string input = + "error MSB4236: The SDK 'Contoso.Custom.Sdk' specified could not be found. [/work/project.csproj]"; + TransientSdkResolutionErrorDetector.IsTransientError(input).Should().BeFalse(); + } + + [Fact] + public void OtherResolverReturningNullIsNotTransient() + { + // A "returned null" message from a different resolver must not trigger a retry: only the in-box + // workload resolver deferral (Microsoft.DotNet.MSBuildWorkloadSdkResolver) is the transient flake. + string input = + "Sdk.props(20,3): error : SDK resolver \"Contoso.CustomSdkResolver\" returned null.\r\n" + + "Sdk.props(20,11): error MSB4236: The SDK 'Microsoft.NET.Sdk.StaticWebAssets' specified could not be " + + "found. [/work/InstantiatePr.csproj]"; + TransientSdkResolutionErrorDetector.IsTransientError(input).Should().BeFalse(); + } + } +} diff --git a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs index a18270066625..7245ec028e65 100644 --- a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs +++ b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs @@ -165,7 +165,8 @@ private bool ShouldStopRetry(CommandResult result) return false; } - return !NuGetTransientErrorDetector.IsTransientError(result.StdOut); + return !NuGetTransientErrorDetector.IsTransientError(result.StdOut) + && !TransientSdkResolutionErrorDetector.IsTransientError(result.StdOut); } public virtual CommandResult Execute(IEnumerable args) diff --git a/test/Microsoft.NET.TestFramework/TransientSdkResolutionErrorDetector.cs b/test/Microsoft.NET.TestFramework/TransientSdkResolutionErrorDetector.cs new file mode 100644 index 000000000000..4c51efa4a59e --- /dev/null +++ b/test/Microsoft.NET.TestFramework/TransientSdkResolutionErrorDetector.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.NET.TestFramework +{ + /// + /// Detects transient failures to resolve an in-box .NET SDK (for example + /// Microsoft.NET.Sdk.StaticWebAssets imported by the Razor SDK). + /// + /// + /// Under heavy parallel I/O (many concurrent dotnet build/dotnet new invocations sharing a single + /// SDK-under-test) the MSBuild default SDK resolver intermittently fails to probe the + /// Sdks/<name>/Sdk folder, producing an MSB4236 "could not be found" error even though the + /// SDK is present on disk. These failures are not deterministic and succeed when retried, so they are treated as + /// transient for the purposes of retry logic. A genuinely missing SDK still + /// fails after the retries are exhausted. + /// + public static class TransientSdkResolutionErrorDetector + { + public static bool IsTransientError(string? errorMessage) + { + if (errorMessage is null) + { + return false; + } + + // The combination below is specific to the transient in-box SDK resolution flake: + // - MSB4236 is raised for an SDK that "could not be found", + // - for an in-box SDK in the Microsoft.NET.Sdk family, and + // - after the .NET SDK workload resolver (Microsoft.DotNet.MSBuildWorkloadSdkResolver) deferred + // resolution by returning null. + // Keying on the specific resolver name (rather than a bare "returned null" substring) avoids + // accidental retries when some other resolver emits a similar message, and requiring all three + // avoids retrying legitimate failures such as a NuGet-versioned SDK that is intentionally absent. + return errorMessage.Contains("MSB4236") + && errorMessage.Contains("Microsoft.NET.Sdk") + && errorMessage.Contains("Microsoft.DotNet.MSBuildWorkloadSdkResolver") + && errorMessage.Contains("returned null"); + } + } +} diff --git a/test/dotnet-new.IntegrationTests/WebProjectsTests.cs b/test/dotnet-new.IntegrationTests/WebProjectsTests.cs index 59cc08e55c0c..0d181221ed06 100644 --- a/test/dotnet-new.IntegrationTests/WebProjectsTests.cs +++ b/test/dotnet-new.IntegrationTests/WebProjectsTests.cs @@ -43,7 +43,11 @@ public void AllWebProjectsRestoreAndBuild(string testName, params string[] args) string workingDir = Path.Combine(_fixture.BaseWorkingDirectory, testName); Directory.CreateDirectory(workingDir); - new DotnetNewCommand(_log, args) + // Run instantiation without the implicit post-action restore: that restore is redundant with the + // explicit DotnetRestoreCommand below, and its transient in-box SDK resolution failures surface as + // stderr while 'dotnet new' still exits 0, which cannot be retried at the command level. The explicit + // restore and build (which retry transient SDK resolution failures) provide the actual coverage. + new DotnetNewCommand(_log, [.. args, "--no-restore"]) .WithCustomHive(_fixture.HomeDirectory) .WithWorkingDirectory(workingDir) .Execute()