Skip to content

Commit

Permalink
Enable hosts to provide custom assembly resolution (#73185)
Browse files Browse the repository at this point in the history
Allow a host to override how the AnalyzerAssemblyLoader resolves assemblies:
- Have the loader take a set of 'IAnalyzerAssemblyResolvers' which can provide an already loaded assembly
- Re-implement compiler specific ALC loading as a built-in resolver
- Add MEF imports for the resolvers in the IDE layer and pass them into the loader
- Add a specific Razor assembly resolver in external access
- Add empty resolver arrays for various test scenarios
- Add tests
  • Loading branch information
chsienki committed May 9, 2024
1 parent 7b8f135 commit 095bde2
Show file tree
Hide file tree
Showing 14 changed files with 310 additions and 52 deletions.
121 changes: 117 additions & 4 deletions src/Compilers/Core/CodeAnalysisTest/AnalyzerAssemblyLoaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public enum AnalyzerTestKind
///
/// Limitation 1: .NET Framework probing path.
///
/// The .NET Framework assembly loader will only call AppDomain.AssemblyResolve when it cannot satifisfy a load
/// The .NET Framework assembly loader will only call AppDomain.AssemblyResolve when it cannot satisfy a load
/// request. One of the places the assembly loader will always consider when looking for dependencies of A.dll
/// is the directory that A.dll was loading from (it's added to the probing path). That means if B.dll is in the
/// same directory then the runtime will silently load it without a way for us to intervene.
Expand Down Expand Up @@ -95,25 +95,27 @@ public AnalyzerAssemblyLoaderTests(ITestOutputHelper testOutputHelper, AssemblyL

#if NETCOREAPP

private void Run(AnalyzerTestKind kind, Action<AnalyzerAssemblyLoader, AssemblyLoadTestFixture> testAction, [CallerMemberName] string? memberName = null) =>
private void Run(AnalyzerTestKind kind, Action<AnalyzerAssemblyLoader, AssemblyLoadTestFixture> testAction, IAnalyzerAssemblyResolver[]? externalResolvers = null, [CallerMemberName] string? memberName = null) =>
Run(
kind,
static (_, _) => { },
testAction,
externalResolvers,
memberName);

private void Run(
AnalyzerTestKind kind,
Action<AssemblyLoadContext, AssemblyLoadTestFixture> prepLoadContextAction,
Action<AnalyzerAssemblyLoader, AssemblyLoadTestFixture> testAction,
IAnalyzerAssemblyResolver[]? externalResolvers = null,
[CallerMemberName] string? memberName = null)
{
var alc = new AssemblyLoadContext($"Test {memberName}", isCollectible: true);
try
{
prepLoadContextAction(alc, TestFixture);
var util = new InvokeUtil();
util.Exec(TestOutputHelper, alc, TestFixture, kind, testAction.Method.DeclaringType!.FullName!, testAction.Method.Name);
util.Exec(TestOutputHelper, alc, TestFixture, kind, testAction.Method.DeclaringType!.FullName!, testAction.Method.Name, externalResolvers ?? []);
}
finally
{
Expand All @@ -126,6 +128,7 @@ public AnalyzerAssemblyLoaderTests(ITestOutputHelper testOutputHelper, AssemblyL
private void Run(
AnalyzerTestKind kind,
Action<AnalyzerAssemblyLoader, AssemblyLoadTestFixture> testAction,
IAnalyzerAssemblyResolver[]? externalResolvers = null,
[CallerMemberName] string? memberName = null)
{
AppDomain? appDomain = null;
Expand All @@ -135,7 +138,7 @@ public AnalyzerAssemblyLoaderTests(ITestOutputHelper testOutputHelper, AssemblyL
var testOutputHelper = new AppDomainTestOutputHelper(TestOutputHelper);
var type = typeof(InvokeUtil);
var util = (InvokeUtil)appDomain.CreateInstanceAndUnwrap(type.Assembly.FullName, type.FullName);
util.Exec(testOutputHelper, TestFixture, kind, testAction.Method.DeclaringType.FullName, testAction.Method.Name);
util.Exec(testOutputHelper, TestFixture, kind, testAction.Method.DeclaringType.FullName, testAction.Method.Name, externalResolvers ?? []);
}
finally
{
Expand Down Expand Up @@ -1421,5 +1424,115 @@ public void AssemblyLoadingInNonDefaultContext_AnalyzerReferencesSystemCollectio
});
}
#endif

[Theory]
[CombinatorialData]
public void ExternalResolver_CanIntercept_ReturningNull(AnalyzerTestKind kind)
{
var resolver = new TestAnalyzerAssemblyResolver(n => null);
Run(kind, (AnalyzerAssemblyLoader loader, AssemblyLoadTestFixture testFixture) =>
{
loader.AddDependencyLocation(testFixture.Delta1);
Assembly delta = loader.LoadFromPath(testFixture.Delta1);
Assert.NotNull(delta);
VerifyDependencyAssemblies(loader, testFixture.Delta1);
}, externalResolvers: [resolver]);
Assert.Collection(resolver.CalledFor, (a => Assert.Equal("Delta", a.Name)));
}

[Theory]
[CombinatorialData]
public void ExternalResolver_CanIntercept_ReturningAssembly(AnalyzerTestKind kind)
{
var resolver = new TestAnalyzerAssemblyResolver(n => GetType().Assembly);
Run(kind, (AnalyzerAssemblyLoader loader, AssemblyLoadTestFixture testFixture) =>
{
// net core assembly loader checks that the resolved assembly name is the same as the requested one
// so we use the assembly the tests are contained in as its already be loaded
var thisAssembly = typeof(AnalyzerAssemblyLoaderTests).Assembly;
loader.AddDependencyLocation(thisAssembly.Location);
Assembly loaded = loader.LoadFromPath(thisAssembly.Location);
Assert.Equal(thisAssembly, loaded);
}, externalResolvers: [resolver]);
Assert.Collection(resolver.CalledFor, (a => Assert.Equal(GetType().Assembly.GetName().Name, a.Name)));
}

[Theory]
[CombinatorialData]
public void ExternalResolver_CanIntercept_ReturningAssembly_Or_Null(AnalyzerTestKind kind)
{
var thisAssemblyName = GetType().Assembly.GetName();
var resolver = new TestAnalyzerAssemblyResolver(n => n == thisAssemblyName ? GetType().Assembly : null);
Run(kind, (AnalyzerAssemblyLoader loader, AssemblyLoadTestFixture testFixture) =>
{
var thisAssembly = typeof(AnalyzerAssemblyLoaderTests).Assembly;
loader.AddDependencyLocation(testFixture.Alpha);
Assembly alpha = loader.LoadFromPath(testFixture.Alpha);
Assert.NotNull(alpha);
loader.AddDependencyLocation(thisAssembly.Location);
Assembly loaded = loader.LoadFromPath(thisAssembly.Location);
Assert.Equal(thisAssembly, loaded);
loader.AddDependencyLocation(testFixture.Delta1);
Assembly delta = loader.LoadFromPath(testFixture.Delta1);
Assert.NotNull(delta);
}, externalResolvers: [resolver]);
Assert.Collection(resolver.CalledFor, (a => Assert.Equal("Alpha", a.Name)), a => Assert.Equal(thisAssemblyName.Name, a.Name), a => Assert.Equal("Delta", a.Name));
}

[Theory]
[CombinatorialData]
public void ExternalResolver_MultipleResolvers_CanIntercept_ReturningNull(AnalyzerTestKind kind)
{
var resolver1 = new TestAnalyzerAssemblyResolver(n => null);
var resolver2 = new TestAnalyzerAssemblyResolver(n => null);
Run(kind, (AnalyzerAssemblyLoader loader, AssemblyLoadTestFixture testFixture) =>
{
loader.AddDependencyLocation(testFixture.Delta1);
Assembly delta = loader.LoadFromPath(testFixture.Delta1);
Assert.NotNull(delta);
VerifyDependencyAssemblies(loader, testFixture.Delta1);
}, externalResolvers: [resolver1, resolver2]);
Assert.Collection(resolver1.CalledFor, (a => Assert.Equal("Delta", a.Name)));
Assert.Collection(resolver2.CalledFor, (a => Assert.Equal("Delta", a.Name)));
}

[Theory]
[CombinatorialData]
public void ExternalResolver_MultipleResolvers_ResolutionStops_AfterFirstResolve(AnalyzerTestKind kind)
{
var resolver1 = new TestAnalyzerAssemblyResolver(n => GetType().Assembly);
var resolver2 = new TestAnalyzerAssemblyResolver(n => null);
Run(kind, (AnalyzerAssemblyLoader loader, AssemblyLoadTestFixture testFixture) =>
{
var thisAssembly = typeof(AnalyzerAssemblyLoaderTests).Assembly;
loader.AddDependencyLocation(thisAssembly.Location);
Assembly loaded = loader.LoadFromPath(thisAssembly.Location);
Assert.Equal(thisAssembly, loaded);
}, externalResolvers: [resolver1, resolver2]);
Assert.Collection(resolver1.CalledFor, (a => Assert.Equal(GetType().Assembly.GetName().Name, a.Name)));
Assert.Empty(resolver2.CalledFor);
}

[Serializable]
private class TestAnalyzerAssemblyResolver(Func<AssemblyName, Assembly?> func) : MarshalByRefObject, IAnalyzerAssemblyResolver
{
private readonly Func<AssemblyName, Assembly?> _func = func;

public List<AssemblyName> CalledFor { get; } = [];

public Assembly? ResolveAssembly(AssemblyName assemblyName)
{
CalledFor.Add(assemblyName);
return _func(assemblyName);
}
}
}
}
14 changes: 7 additions & 7 deletions src/Compilers/Core/CodeAnalysisTest/InvokeUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ namespace Microsoft.CodeAnalysis.UnitTests

public sealed class InvokeUtil
{
public void Exec(ITestOutputHelper testOutputHelper, AssemblyLoadContext compilerContext, AssemblyLoadTestFixture fixture, AnalyzerTestKind kind, string typeName, string methodName)
internal void Exec(ITestOutputHelper testOutputHelper, AssemblyLoadContext compilerContext, AssemblyLoadTestFixture fixture, AnalyzerTestKind kind, string typeName, string methodName, IAnalyzerAssemblyResolver[] externalResolvers)
{
// Ensure that the test did not load any of the test fixture assemblies into
// the default load context. That should never happen. Assemblies should either
Expand All @@ -48,9 +48,9 @@ public void Exec(ITestOutputHelper testOutputHelper, AssemblyLoadContext compile
using var tempRoot = new TempRoot();
AnalyzerAssemblyLoader loader = kind switch
{
AnalyzerTestKind.LoadDirect => new DefaultAnalyzerAssemblyLoader(compilerContext, AnalyzerLoadOption.LoadFromDisk),
AnalyzerTestKind.LoadStream => new DefaultAnalyzerAssemblyLoader(compilerContext, AnalyzerLoadOption.LoadFromStream),
AnalyzerTestKind.ShadowLoad => new ShadowCopyAnalyzerAssemblyLoader(compilerContext, tempRoot.CreateDirectory().Path),
AnalyzerTestKind.LoadDirect => new DefaultAnalyzerAssemblyLoader(compilerContext, AnalyzerLoadOption.LoadFromDisk, externalResolvers.ToImmutableArray()),
AnalyzerTestKind.LoadStream => new DefaultAnalyzerAssemblyLoader(compilerContext, AnalyzerLoadOption.LoadFromStream, externalResolvers.ToImmutableArray()),
AnalyzerTestKind.ShadowLoad => new ShadowCopyAnalyzerAssemblyLoader(compilerContext, tempRoot.CreateDirectory().Path, externalResolvers.ToImmutableArray()),
_ => throw ExceptionUtilities.Unreachable()
};

Expand Down Expand Up @@ -93,13 +93,13 @@ public void Exec(ITestOutputHelper testOutputHelper, AssemblyLoadContext compile

public sealed class InvokeUtil : MarshalByRefObject
{
public void Exec(ITestOutputHelper testOutputHelper, AssemblyLoadTestFixture fixture, AnalyzerTestKind kind, string typeName, string methodName)
internal void Exec(ITestOutputHelper testOutputHelper, AssemblyLoadTestFixture fixture, AnalyzerTestKind kind, string typeName, string methodName, IAnalyzerAssemblyResolver[] externalResolvers)
{
using var tempRoot = new TempRoot();
AnalyzerAssemblyLoader loader = kind switch
{
AnalyzerTestKind.LoadDirect => new DefaultAnalyzerAssemblyLoader(),
AnalyzerTestKind.ShadowLoad => new ShadowCopyAnalyzerAssemblyLoader(tempRoot.CreateDirectory().Path),
AnalyzerTestKind.LoadDirect => new DefaultAnalyzerAssemblyLoader(externalResolvers.ToImmutableArray()),
AnalyzerTestKind.ShadowLoad => new ShadowCopyAnalyzerAssemblyLoader(tempRoot.CreateDirectory().Path, externalResolvers.ToImmutableArray()),
_ => throw ExceptionUtilities.Unreachable()
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,16 @@ internal partial class AnalyzerAssemblyLoader
internal AssemblyLoadContext CompilerLoadContext => _compilerLoadContext;
internal AnalyzerLoadOption AnalyzerLoadOption => _loadOption;

internal AnalyzerAssemblyLoader()
: this(null, AnalyzerLoadOption.LoadFromDisk)
internal AnalyzerAssemblyLoader(ImmutableArray<IAnalyzerAssemblyResolver> externalResolvers)
: this(null, AnalyzerLoadOption.LoadFromDisk, externalResolvers)
{
}

internal AnalyzerAssemblyLoader(AssemblyLoadContext? compilerLoadContext, AnalyzerLoadOption loadOption)
internal AnalyzerAssemblyLoader(AssemblyLoadContext? compilerLoadContext, AnalyzerLoadOption loadOption, ImmutableArray<IAnalyzerAssemblyResolver> externalResolvers)
{
_loadOption = loadOption;
_compilerLoadContext = compilerLoadContext ?? AssemblyLoadContext.GetLoadContext(typeof(AnalyzerAssemblyLoader).GetTypeInfo().Assembly)!;
_externalResolvers = [.. externalResolvers, new CompilerAnalyzerAssemblyResolver(_compilerLoadContext)];
}

public bool IsHostAssembly(Assembly assembly)
Expand All @@ -69,7 +70,7 @@ public bool IsHostAssembly(Assembly assembly)
{
if (!_loadContextByDirectory.TryGetValue(fullDirectoryPath, out loadContext))
{
loadContext = new DirectoryLoadContext(fullDirectoryPath, this, _compilerLoadContext);
loadContext = new DirectoryLoadContext(fullDirectoryPath, this);
_loadContextByDirectory[fullDirectoryPath] = loadContext;
}
}
Expand Down Expand Up @@ -107,33 +108,23 @@ internal sealed class DirectoryLoadContext : AssemblyLoadContext
{
internal string Directory { get; }
private readonly AnalyzerAssemblyLoader _loader;
private readonly AssemblyLoadContext _compilerLoadContext;

public DirectoryLoadContext(string directory, AnalyzerAssemblyLoader loader, AssemblyLoadContext compilerLoadContext)
public DirectoryLoadContext(string directory, AnalyzerAssemblyLoader loader)
: base(isCollectible: true)
{
Directory = directory;
_loader = loader;
_compilerLoadContext = compilerLoadContext;
}

protected override Assembly? Load(AssemblyName assemblyName)
{
var simpleName = assemblyName.Name!;
try
if (_loader.ResolveAssemblyExternally(assemblyName) is { } externallyResolvedAssembly)
{
if (_compilerLoadContext.LoadFromAssemblyName(assemblyName) is { } compilerAssembly)
{
return compilerAssembly;
}
}
catch
{
// Expected to happen when the assembly cannot be resolved in the compiler / host
// AssemblyLoadContext.
return externallyResolvedAssembly;
}

// Prefer registered dependencies in the same directory first.
var simpleName = assemblyName.Name!;
var assemblyPath = Path.Combine(Directory, simpleName + ".dll");
if (_loader.IsAnalyzerDependencyPath(assemblyPath))
{
Expand All @@ -147,7 +138,7 @@ public DirectoryLoadContext(string directory, AnalyzerAssemblyLoader loader, Ass
// Note: when loading from disk the .NET runtime has a fallback step that will handle
// satellite assembly loading if the call to Load(satelliteAssemblyName) fails. This
// loader has a mode where it loads from Stream though and the runtime will not handle
// that automatically. Rather than bifurate our loading behavior between Disk and
// that automatically. Rather than bifurcate our loading behavior between Disk and
// Stream both modes just handle satellite loading directly
if (assemblyName.CultureInfo is not null && simpleName.EndsWith(".resources", StringComparison.Ordinal))
{
Expand Down Expand Up @@ -201,6 +192,27 @@ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
return IntPtr.Zero;
}
}

/// <summary>
/// A resolver which allows a passed in <see cref="AssemblyLoadContext"/> from the compiler
/// to control assembly resolution. This is important because there are many exchange types
/// that need to unify across the multiple analyzer ALCs. These include common types from
/// <c>Microsoft.CodeAnalysis.dll</c> etc, as well as platform assemblies provided by a
/// host such as visual studio.
/// </summary>
/// <remarks>
/// This resolver essentially forces any assembly that was loaded as a 'core' part of the
/// compiler to be shared across analyzers, and not loaded multiple times into each individual
/// analyzer ALC, even if the analyzer itself shipped a copy of said assembly.
/// </remarks>
/// <param name="compilerContext">The <see cref="AssemblyLoadContext"/> that the core
/// compiler assemblies are already loaded into.</param>
internal sealed class CompilerAnalyzerAssemblyResolver(AssemblyLoadContext compilerContext) : IAnalyzerAssemblyResolver
{
private readonly AssemblyLoadContext _compilerAlc = compilerContext;

public Assembly? ResolveAssembly(AssemblyName assemblyName) => _compilerAlc.LoadFromAssemblyName(assemblyName);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#if !NETCOREAPP

using System;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Reflection;
Expand All @@ -28,8 +29,9 @@ internal partial class AnalyzerAssemblyLoader
{
private bool _hookedAssemblyResolve;

internal AnalyzerAssemblyLoader()
internal AnalyzerAssemblyLoader(ImmutableArray<IAnalyzerAssemblyResolver> externalResolvers)
{
_externalResolvers = externalResolvers;
}

public bool IsHostAssembly(Assembly assembly)
Expand Down Expand Up @@ -57,6 +59,10 @@ public bool IsHostAssembly(Assembly assembly)
private partial Assembly? Load(AssemblyName assemblyName, string assemblyOriginalPath)
{
EnsureResolvedHooked();
if (ResolveAssemblyExternally(assemblyName) is { } externallyResolvedAssembly)
{
return externallyResolvedAssembly;
}

return AppDomain.CurrentDomain.Load(assemblyName);
}
Expand Down

0 comments on commit 095bde2

Please sign in to comment.