From b7f783c371d1b1568a2872e7e9710438a4d07f64 Mon Sep 17 00:00:00 2001 From: Jesse Klaasse <11753409+JesseKlaasse@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:03:37 +0200 Subject: [PATCH] Resolve embedded assemblies by name and version, not by simple name The hoist's resolver keyed loaded assemblies on their simple name. One process (a compiler server, an IDE) hosts several generators and several builds of one, so the first assembly loaded under a name shadowed every later one: - an older build of a generator without embedded assemblies took the name, and the newer build never unpacked its own: FileNotFoundException, CS8784; - two generators embedding different versions of a dependency (Contracts 2.0.14 and 2.0.16) got whichever loaded first: TypeLoadException, #73. The comparer now matches name and version. A request without an exact match gets the newest loaded version that is at least the requested one, as the runtime binds a reference to a newer version; never an older one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AssemblyResolverTests.cs | 100 ++++++++++++++++++ .../Templates/AssemblyResolver.cs | 7 +- .../Templates/SourceGeneratorHoistBase.cs | 21 ++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 src/Sandbox/ConsoleApp.SourceGenerator.Tests/AssemblyResolverTests.cs 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";