diff --git a/src/Sandbox/ConsoleApp.SourceGenerator.Tests/AssemblyResolverTests.cs b/src/Sandbox/ConsoleApp.SourceGenerator.Tests/AssemblyResolverTests.cs new file mode 100644 index 0000000..14666ec --- /dev/null +++ b/src/Sandbox/ConsoleApp.SourceGenerator.Tests/AssemblyResolverTests.cs @@ -0,0 +1,100 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; + +namespace ConsoleApp.SourceGenerator.Tests +{ + /// + /// The hoist's assembly resolver is process-wide, and a compiler server hosts the analyzers of every project + /// it ever built. So the resolver meets assemblies it was not built with: another build of the same generator, + /// or another generator that embeds a different version of the same dependency. + /// + public class AssemblyResolverTests + { + public AssemblyResolverTests() + { + // The resolver attaches in the hoist's module initializer. + RuntimeHelpers.RunModuleConstructor(typeof(ConsoleAppSourceGeneratorHoist).Module.ModuleHandle); + } + + [Fact] + public void Embedded_Assembly_Resolves_When_An_Assembly_With_The_Same_Name_Was_Loaded_First() + { + // An older build of the generator, without embedded assemblies, takes the simple name first. + LoadInOwnContext(Emit("ShadowedGenerator", "1.0.0.0")); + byte[] dependency = Emit("ShadowedDependency", "2.0.0.0"); + LoadInOwnContext(Emit("ShadowedGenerator", "2.0.0.0", ("SGF.Assembly::ShadowedDependency.dll", dependency))); + + Assembly resolved = Assembly.Load(new AssemblyName("ShadowedDependency, Version=2.0.0.0")); + + Assert.Equal(new Version(2, 0, 0, 0), resolved.GetName().Version); + } + + [Fact] + public void Embedded_Assembly_Resolves_In_The_Requested_Version() + { + byte[] older = Emit("SharedDependency", "1.0.0.0"); + byte[] newer = Emit("SharedDependency", "2.0.0.0"); + LoadInOwnContext(Emit("OlderGenerator", "1.0.0.0", ("SGF.Assembly::SharedDependency.dll", older))); + LoadInOwnContext(Emit("NewerGenerator", "1.0.0.0", ("SGF.Assembly::SharedDependency.dll", newer))); + + Assembly resolved = Assembly.Load(new AssemblyName("SharedDependency, Version=2.0.0.0")); + + Assert.Equal(new Version(2, 0, 0, 0), resolved.GetName().Version); + } + + [Fact] + public void Embedded_Assembly_Resolves_For_A_Request_Without_A_Version() + { + byte[] dependency = Emit("UnversionedDependency", "3.0.0.0"); + LoadInOwnContext(Emit("UnversionedGenerator", "1.0.0.0", ("SGF.Assembly::UnversionedDependency.dll", dependency))); + + Assembly resolved = Assembly.Load(new AssemblyName("UnversionedDependency")); + + Assert.Equal(new Version(3, 0, 0, 0), resolved.GetName().Version); + } + + [Fact] + public void Request_For_An_Older_Version_Resolves_To_The_Newer_One_Loaded() + { + byte[] dependency = Emit("UnifiedDependency", "2.0.0.0"); + LoadInOwnContext(Emit("UnifyingGenerator", "1.0.0.0", ("SGF.Assembly::UnifiedDependency.dll", dependency))); + + Assembly resolved = Assembly.Load(new AssemblyName("UnifiedDependency, Version=1.0.0.0")); + + Assert.Equal(new Version(2, 0, 0, 0), resolved.GetName().Version); + } + + [Fact] + public void Request_For_A_Newer_Version_Does_Not_Resolve_To_An_Older_One() + { + byte[] dependency = Emit("OutdatedDependency", "1.0.0.0"); + LoadInOwnContext(Emit("OutdatedGenerator", "1.0.0.0", ("SGF.Assembly::OutdatedDependency.dll", dependency))); + + Assert.Throws(() => Assembly.Load(new AssemblyName("OutdatedDependency, Version=2.0.0.0"))); + } + + private static byte[] Emit(string name, string version, params (string Name, byte[] Content)[] resources) + { + CSharpCompilation compilation = CSharpCompilation.Create( + name, + [CSharpSyntaxTree.ParseText($"[assembly: System.Reflection.AssemblyVersion(\"{version}\")]")], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using MemoryStream stream = new(); + EmitResult result = compilation.Emit( + stream, + manifestResources: resources.Select(r => new ResourceDescription(r.Name, () => new MemoryStream(r.Content), isPublic: true))); + Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics)); + return stream.ToArray(); + } + + // Roslyn loads each analyzer directory in a context of its own; the resolver sees them all through the AppDomain. + private static void LoadInOwnContext(byte[] image) + => new AssemblyLoadContext(name: null).LoadFromStream(new MemoryStream(image)); + } +} diff --git a/src/SourceGenerator.Foundations/Templates/AssemblyResolver.cs b/src/SourceGenerator.Foundations/Templates/AssemblyResolver.cs index 4c570a9..4b27d5a 100644 --- a/src/SourceGenerator.Foundations/Templates/AssemblyResolver.cs +++ b/src/SourceGenerator.Foundations/Templates/AssemblyResolver.cs @@ -17,7 +17,10 @@ public static SourceText Render(string @namespace) => SourceText.From($$""" namespace {{@namespace}} { /// - /// Used to compare two to pull them out of the dictionary of types + /// Used to compare two to pull them out of the dictionary of types. Compares + /// name and version: one process (a compiler server, an IDE) hosts several generators, and several builds + /// of one, side by side. By name alone the first one loaded shadows the rest, and a generator gets a + /// dependency it was not built against, or none at all. /// internal class AssemblyNameComparer : IEqualityComparer { @@ -36,7 +39,7 @@ public bool Equals(AssemblyName? x, AssemblyName? y) return false; } - return string.Equals(GetName(x!), GetName(y!)); + return string.Equals(GetName(x!), GetName(y!)) && x!.Version == y!.Version; } public int GetHashCode(AssemblyName obj) diff --git a/src/SourceGenerator.Foundations/Templates/SourceGeneratorHoistBase.cs b/src/SourceGenerator.Foundations/Templates/SourceGeneratorHoistBase.cs index 913d0df..f2b28ed 100644 --- a/src/SourceGenerator.Foundations/Templates/SourceGeneratorHoistBase.cs +++ b/src/SourceGenerator.Foundations/Templates/SourceGeneratorHoistBase.cs @@ -139,6 +139,27 @@ private static void AddAssembly(Assembly assembly) return assembly; } + // No exact version: the newest one loaded that still satisfies the request, as the runtime binds a + // reference to a newer version. Never an older one: it can lack what the caller was built against. + Assembly? newest = null; + Version newestVersion = new(0, 0); + foreach (KeyValuePair loaded in s_loadedAssemblies) + { + Version version = loaded.Key.Version ?? new Version(0, 0); + bool sameName = string.Equals(loaded.Key.Name, assemblyName.Name); + bool satisfies = assemblyName.Version == null || version >= assemblyName.Version; + if (sameName && satisfies && (newest == null || version > newestVersion)) + { + newest = loaded.Value; + newestVersion = version; + } + } + + if (newest != null) + { + return newest; + } + foreach (Assembly loadedAssembly in s_assembliesWithResources) { string resourceName = $"SGF.Assembly::{assemblyName.Name}.dll";