From 2c8690a34fcf0f0ad133b47538838769910d3e1f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 12 Aug 2026 16:33:52 -0400 Subject: [PATCH] feat(scripts): add process-wide caching metadata resolver for script compiles --- .../CachingScriptMetadataResolver.cs | 91 ++++++++++++++++++ .../CachingScriptMetadataResolverTests.cs | 92 +++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs new file mode 100644 index 00000000..0ee74998 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Scripting; + +namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +/// +/// Process-wide memoizing decorator over Roslyn's default script metadata +/// resolver, closing the second (dominant) half of the script-compile native +/// memory leak fixed in part by 5a781c70. +/// +/// +/// Why this exists. The shared ScriptOptions on every compile +/// surface carry only the direct API-surface references; each +/// script.Compile() binds their transitive closure, and every +/// transitively-referenced assembly is resolved through the options' +/// . Roslyn's +/// RuntimeMetadataReferenceResolver has NO cross-compilation cache: each +/// resolution calls MetadataReference.CreateFromFile, which eagerly +/// copies the whole assembly into native memory (AssemblyMetadata → +/// PEReaderNativeHeapMemoryBlock). Measured on Roslyn 5.0.0: +/// a 5-assembly explicit set resolves a 74-assembly closure afresh on EVERY +/// compile — ~74 fresh native metadata copies per compiled script, pinned for +/// the process lifetime by the compile caches. This decorator memoizes, so each +/// distinct assembly is materialized once per process regardless of compile count. +/// +/// +/// +/// Trust model unaffected. Resolution RESULTS are identical to the inner +/// resolver's — only object identity is de-duplicated. The resolver can never +/// resolve anything the undecorated options would not have resolved. +/// +/// +/// +/// Cache-correctness assumptions. The missing-assembly cache keys on the +/// assembly identity display name and deliberately ignores the requesting +/// definition (whose directory is a search path in the inner resolver): +/// every ScadaBridge node runs from a single publish directory plus the shared +/// framework, so identity → path is stable process-wide. A +/// GetOrAdd factory race can mint one duplicate — bounded, benign. +/// Entries are never disposed; the cache is bounded by the distinct assemblies +/// on disk, the same order as the static . +/// +/// +public sealed class CachingScriptMetadataResolver : MetadataReferenceResolver +{ + /// + /// The shared process-wide instance every script-compile surface attaches via + /// ScriptOptions.WithMetadataResolver. Decorates + /// 's resolver — the exact resolver those + /// surfaces used implicitly before this fix. + /// + public static readonly CachingScriptMetadataResolver Instance = + new(ScriptOptions.Default.MetadataResolver); + + private readonly MetadataReferenceResolver _inner; + + private readonly ConcurrentDictionary _missingByIdentity = + new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary<(string Reference, string? BaseFilePath, MetadataReferenceProperties Properties), + ImmutableArray> _referencesByPath = new(); + + /// Creates a decorator over the given inner resolver. Exposed for tests; production uses . + /// The resolver whose results are memoized. + public CachingScriptMetadataResolver(MetadataReferenceResolver inner) => _inner = inner; + + /// + public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies; + + /// + public override PortableExecutableReference? ResolveMissingAssembly( + MetadataReference definition, AssemblyIdentity referenceIdentity) + => _missingByIdentity.GetOrAdd( + referenceIdentity.GetDisplayName(), + _ => _inner.ResolveMissingAssembly(definition, referenceIdentity)); + + /// + public override ImmutableArray ResolveReference( + string reference, string? baseFilePath, MetadataReferenceProperties properties) + => _referencesByPath.GetOrAdd( + (reference, baseFilePath, properties), + _ => _inner.ResolveReference(reference, baseFilePath, properties)); + + /// + public override bool Equals(object? other) => ReferenceEquals(this, other); + + /// + public override int GetHashCode() => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(this); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs new file mode 100644 index 00000000..b35b1c8d --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs @@ -0,0 +1,92 @@ +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); + } +}