Skip to content
116 changes: 115 additions & 1 deletion src/Build.UnitTests/BackEnd/SdkResolverLoader_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,114 @@ public void SdkResolverLoaderHonorsAdditionalResolversFolder()
}
}

/// <summary>
/// Test that LoadResolverAssembly handles fallback behavior correctly based on BuildEnvironment flags.
/// This test calls the actual LoadResolverAssembly method to ensure it fails when the fix is reverted.
/// </summary>
[Theory]
[InlineData(false, false)] // needsFallback = false (VS/MSBuild.exe), no fallback, should fail when Assembly.Load fails
[InlineData(true, true)] // needsFallback = true (API/dotnet CLI), has fallback, should succeed with LoadFrom
public void LoadResolverAssembly_MSBuildSdkResolver_WithAndWithoutFallback(bool needsFallback, bool shouldSucceed)
{
using (var env = TestEnvironment.Create(_output))
{
// Save current BuildEnvironment to restore later
var currentBuildEnvironment = BuildEnvironmentHelper.Instance;

try
{
// Setup BuildEnvironment based on test scenario
// needsFallback = true: Mode = Standalone && RunningInMSBuildExe = false (API/dotnet CLI)
// needsFallback = false: Mode = Standalone && RunningInMSBuildExe = true (MSBuild.exe direct usage)
// Note: We use Standalone mode for both cases to avoid VisualStudio mode requiring VisualStudioInstallRootDirectory
BuildEnvironmentMode mode = BuildEnvironmentMode.Standalone;
bool runningInMSBuildExe = !needsFallback;

// Use current MSBuild path or fallback to a valid path if null
// This ensures MSBuildToolsDirectory32 and MSBuildToolsDirectoryRoot are set correctly
string msBuildExePath = currentBuildEnvironment.CurrentMSBuildExePath;
if (string.IsNullOrEmpty(msBuildExePath))
{
// Use the executing assembly path as fallback
msBuildExePath = FileUtilities.ExecutingAssemblyPath;
// If that's also null/empty, use test assembly location
if (string.IsNullOrEmpty(msBuildExePath))
{
msBuildExePath = typeof(BuildEnvironmentHelper).Assembly.Location;
}
}

BuildEnvironmentHelper.ResetInstance_ForUnitTestsOnly(
new BuildEnvironment(
mode,
msBuildExePath,
currentBuildEnvironment.RunningTests,
runningInMSBuildExe,
currentBuildEnvironment.RunningInVisualStudio,
currentBuildEnvironment.VisualStudioInstallRootDirectory));

// Create resolver folder structure with the specific name that triggers special logic
var testRoot = env.CreateFolder().Path;
var resolverFolder = Path.Combine(testRoot, "Microsoft.DotNet.MSBuildSdkResolver");
Directory.CreateDirectory(resolverFolder);

var assemblyFile = Path.Combine(resolverFolder, "Microsoft.DotNet.MSBuildSdkResolver.dll");

// Create file based on test scenario
if (shouldSucceed)
{
// For fallback test: create a valid assembly file using the test assembly
// This avoids side effects from loading Microsoft.Build.dll copy
var sourceAssembly = typeof(MockSdkResolverWithAssemblyPath).Assembly;
string sourceLocation = sourceAssembly.Location;
if (string.IsNullOrEmpty(sourceLocation))
{
throw new InvalidOperationException("Source assembly location is null or empty");
}
File.Copy(sourceLocation, assemblyFile, true);
}
else
{
// For no-fallback test: create invalid assembly content to force Assembly.Load to fail
File.WriteAllText(assemblyFile, "invalid assembly content");
}

// Use MockSdkResolverLoader but don't mock LoadResolverAssemblyFunc
// This ensures we test the actual logic in SdkResolverLoader.cs
var loader = new MockSdkResolverLoader
{
FindPotentialSdkResolversFunc = (_, __) => new List<string> { assemblyFile },
GetResolverTypesFunc = assembly => new[] { typeof(MockSdkResolverWithAssemblyPath) }
// LoadResolverAssemblyFunc is not set, so it will call the real method
};

if (shouldSucceed)
{
// Test that loading succeeds with fallback logic
var resolvers = loader.LoadAllResolvers(new MockElementLocation("file"));
resolvers.ShouldNotBeNull();
resolvers.Count.ShouldBeGreaterThan(0);
}
else
{
// Should throw InvalidProjectFileException because:
// 1. needsFallback = false → no fallback, uses Assembly.Load directly
// 2. Assembly.Load fails on invalid assembly
// 3. No fallback → exception propagates
var exception = Should.Throw<InvalidProjectFileException>(() =>
loader.LoadAllResolvers(new MockElementLocation("file")));

exception.Message.ShouldContain("could not be loaded");
}
}
finally
{
// Restore original BuildEnvironment to avoid test pollution
BuildEnvironmentHelper.ResetInstance_ForUnitTestsOnly(currentBuildEnvironment);
}
}
}

private sealed class MockSdkResolverThatDoesNotLoad : SdkResolverBase
{
public const string ExpectedMessage = "A8BB8B3131D3475D881ACD3AF8D75BD6";
Expand Down Expand Up @@ -435,7 +543,13 @@ private sealed class MockSdkResolverWithAssemblyPath : SdkResolverBase
{
public string AssemblyPath;

public MockSdkResolverWithAssemblyPath(string assemblyPath = "")
// Parameterless constructor for reflection-based instantiation
public MockSdkResolverWithAssemblyPath()
: this("")
{
}

public MockSdkResolverWithAssemblyPath(string assemblyPath)
{
AssemblyPath = assemblyPath;
}
Expand Down
35 changes: 31 additions & 4 deletions src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,28 @@ protected virtual Assembly LoadResolverAssembly(string resolverPath)
{
// This will load the resolver assembly into the default load context if possible, and fall back to LoadFrom context.
// We very much prefer the default load context because it allows native images to be used by the CLR, improving startup perf.
AssemblyName assemblyName = new AssemblyName(resolverFileName)
var buildEnvironment = BuildEnvironmentHelper.Instance;
AssemblyName assemblyName = CreateAssemblyNameWithCodeBase(resolverFileName, resolverPath);

// Check if we're in a scenario that needs fallback (API usage or dotnet CLI)
// These scenarios are detected by: Mode = Standalone and not running in MSBuild.exe
// This matches the condition set by TryFromMSBuildAssembly when MSBuild is called from external APIs
// VS and MSBuild.exe direct usage can use Assembly.Load reliably, so they don't need fallback
bool needsFallback = buildEnvironment.Mode == BuildEnvironmentMode.Standalone && !buildEnvironment.RunningInMSBuildExe;

if (needsFallback)
{
CodeBase = resolverPath,
};
return Assembly.Load(assemblyName);
// For external API users and dotnet CLI, use LoadFrom directly
// Assembly.Load fails in these scenarios due to assembly resolution context,
// so we use LoadFrom which works reliably without needing try-catch
return Assembly.LoadFrom(resolverPath);
}
else
{
// VS and MSBuild.exe direct usage: use Assembly.Load directly without fallback
// These scenarios should work reliably with Assembly.Load and benefit from NGEN
return Assembly.Load(assemblyName);
}
}
}
return Assembly.LoadFrom(resolverPath);
Expand All @@ -251,6 +268,16 @@ protected virtual Assembly LoadResolverAssembly(string resolverPath)
#endif
}

#if !FEATURE_ASSEMBLYLOADCONTEXT
private AssemblyName CreateAssemblyNameWithCodeBase(string assemblyName, string codeBase)
{
return new AssemblyName(assemblyName)
{
CodeBase = codeBase,
};
}
#endif

protected internal virtual IReadOnlyList<SdkResolver> LoadResolversFromManifest(SdkResolverManifest manifest, ElementLocation location)
{
MSBuildEventSource.Log.SdkResolverLoadResolversStart();
Expand Down
Loading