feat(scripts): add process-wide caching metadata resolver for script compiles

This commit is contained in:
Joseph Doherty
2026-08-12 16:33:52 -04:00
parent 901cec9026
commit 2c8690a34f
2 changed files with 183 additions and 0 deletions
@@ -0,0 +1,92 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Scripting;
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
/// <summary>
/// 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.
/// </summary>
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<PortableExecutableReference> 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);
}
}