using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Scripting; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; /// /// Pins the per-compile native-metadata fix (2026-08-12 follow-up to 5a781c70): /// Roslyn's default ScriptMetadataResolver re-resolves the transitive assembly /// closure through MetadataReference.CreateFromFile on EVERY script compile — /// each call minting a fresh AssemblyMetadata → PEReader → NativeHeapMemoryBlock. /// The decorator memoizes so each distinct assembly is resolved once per process. /// public class CachingScriptMetadataResolverTests { private sealed class CountingResolver : MetadataReferenceResolver { public int MissingCalls; public int ReferenceCalls; public override bool ResolveMissingAssemblies => true; public override PortableExecutableReference? ResolveMissingAssembly( MetadataReference definition, AssemblyIdentity referenceIdentity) { Interlocked.Increment(ref MissingCalls); return MetadataReference.CreateFromFile(typeof(object).Assembly.Location); } public override ImmutableArray ResolveReference( string reference, string? baseFilePath, MetadataReferenceProperties properties) { Interlocked.Increment(ref ReferenceCalls); return [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]; } public override bool Equals(object? other) => ReferenceEquals(this, other); public override int GetHashCode() => 0; } private static readonly AssemblyIdentity SomeIdentity = new("System.Fake", new Version(1, 0, 0, 0)); private static readonly MetadataReference SomeDefinition = MetadataReference.CreateFromFile(typeof(object).Assembly.Location); [Fact] public void ResolveMissingAssembly_SameIdentityTwice_ResolvesOnceAndSharesInstance() { var inner = new CountingResolver(); var sut = new CachingScriptMetadataResolver(inner); var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity); var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity); Assert.Equal(1, inner.MissingCalls); Assert.Same(first, second); // one AssemblyMetadata/PEReader/native block, not two } [Fact] public void ResolveMissingAssembly_DistinctIdentities_ResolveIndependently() { var inner = new CountingResolver(); var sut = new CachingScriptMetadataResolver(inner); sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity); sut.ResolveMissingAssembly(SomeDefinition, new AssemblyIdentity("System.Other", new Version(1, 0, 0, 0))); Assert.Equal(2, inner.MissingCalls); } [Fact] public void ResolveReference_SameArgsTwice_ResolvesOnceAndSharesInstances() { var inner = new CountingResolver(); var sut = new CachingScriptMetadataResolver(inner); var first = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly); var second = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly); Assert.Equal(1, inner.ReferenceCalls); Assert.Same(first[0], second[0]); } [Fact] public void Instance_IsProcessWideSingleton() => Assert.Same(CachingScriptMetadataResolver.Instance, CachingScriptMetadataResolver.Instance); [Fact] public void ResolveMissingAssemblies_DelegatesToInner() { var sut = new CachingScriptMetadataResolver(new CountingResolver()); Assert.True(sut.ResolveMissingAssemblies); } /// /// The decisive regression: with options shaped like the real compile /// surfaces (small explicit set, large transitive closure), the FIRST /// compile resolves the closure through the resolver and every subsequent /// compile — same or different code — resolves NOTHING. Before the fix, /// every compile re-resolved the full closure (measured: 74 fresh /// PortableExecutableReferences per compile on Roslyn 5.0.0). /// A probe wraps the DEFAULT resolver so the test also proves resolution /// actually flows through this path at all (first > 0) — guarding against /// the whole mechanism silently changing in a Roslyn upgrade. /// [Fact] public void Compile_RepeatAndDistinctScripts_ResolveClosureOnlyOnce() { var probe = new ProbeResolver(ScriptOptions.Default.MetadataResolver); var caching = new CachingScriptMetadataResolver(probe); var options = ScriptOptions.Default .WithReferences( typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly) .WithImports("System", "System.Linq") .WithMetadataResolver(caching); long CompileAndCount(string code) { long before = probe.MissingCalls; var script = Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.Create(code, options); var errors = script.Compile().Count(d => d.Severity == DiagnosticSeverity.Error); Assert.Equal(0, errors); return probe.MissingCalls - before; } var first = CompileAndCount("return Enumerable.Range(1, 3).Sum();"); var repeat = CompileAndCount("return Enumerable.Range(1, 3).Sum();"); var distinct = CompileAndCount("return string.Join(\",\", Enumerable.Range(1, 2)).Length;"); Assert.True(first > 0, "expected the first compile to resolve the transitive closure through the resolver"); Assert.Equal(0, repeat); // pre-fix: == first (measured 20 for this test's reference set; ~74 on the real compile surfaces) Assert.Equal(0, distinct); } private sealed class ProbeResolver : MetadataReferenceResolver { private readonly MetadataReferenceResolver _inner; public long MissingCalls; public ProbeResolver(MetadataReferenceResolver inner) => _inner = inner; public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies; public override PortableExecutableReference? ResolveMissingAssembly( MetadataReference definition, AssemblyIdentity referenceIdentity) { Interlocked.Increment(ref MissingCalls); return _inner.ResolveMissingAssembly(definition, referenceIdentity); } public override ImmutableArray ResolveReference( string reference, string? baseFilePath, MetadataReferenceProperties properties) => _inner.ResolveReference(reference, baseFilePath, properties); public override bool Equals(object? other) => ReferenceEquals(this, other); public override int GetHashCode() => 0; } }