feat(scripts): add process-wide caching metadata resolver for script compiles
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why this exists.</b> The shared <c>ScriptOptions</c> on every compile
|
||||||
|
/// surface carry only the direct API-surface references; each
|
||||||
|
/// <c>script.Compile()</c> binds their transitive closure, and every
|
||||||
|
/// transitively-referenced assembly is resolved through the options'
|
||||||
|
/// <see cref="MetadataReferenceResolver"/>. Roslyn's
|
||||||
|
/// <c>RuntimeMetadataReferenceResolver</c> has NO cross-compilation cache: each
|
||||||
|
/// resolution calls <c>MetadataReference.CreateFromFile</c>, which eagerly
|
||||||
|
/// copies the whole assembly into native memory (<c>AssemblyMetadata</c> →
|
||||||
|
/// <c>PEReader</c> → <c>NativeHeapMemoryBlock</c>). 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.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Trust model unaffected.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Cache-correctness assumptions.</b> The missing-assembly cache keys on the
|
||||||
|
/// assembly identity display name and deliberately ignores the requesting
|
||||||
|
/// <c>definition</c> (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
|
||||||
|
/// <c>GetOrAdd</c> 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 <see cref="ScriptTrustPolicy.AnalysisReferences"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CachingScriptMetadataResolver : MetadataReferenceResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The shared process-wide instance every script-compile surface attaches via
|
||||||
|
/// <c>ScriptOptions.WithMetadataResolver</c>. Decorates
|
||||||
|
/// <see cref="ScriptOptions.Default"/>'s resolver — the exact resolver those
|
||||||
|
/// surfaces used implicitly before this fix.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly CachingScriptMetadataResolver Instance =
|
||||||
|
new(ScriptOptions.Default.MetadataResolver);
|
||||||
|
|
||||||
|
private readonly MetadataReferenceResolver _inner;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, PortableExecutableReference?> _missingByIdentity =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<(string Reference, string? BaseFilePath, MetadataReferenceProperties Properties),
|
||||||
|
ImmutableArray<PortableExecutableReference>> _referencesByPath = new();
|
||||||
|
|
||||||
|
/// <summary>Creates a decorator over the given inner resolver. Exposed for tests; production uses <see cref="Instance"/>.</summary>
|
||||||
|
/// <param name="inner">The resolver whose results are memoized.</param>
|
||||||
|
public CachingScriptMetadataResolver(MetadataReferenceResolver inner) => _inner = inner;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||||
|
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||||
|
=> _missingByIdentity.GetOrAdd(
|
||||||
|
referenceIdentity.GetDisplayName(),
|
||||||
|
_ => _inner.ResolveMissingAssembly(definition, referenceIdentity));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||||
|
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||||
|
=> _referencesByPath.GetOrAdd(
|
||||||
|
(reference, baseFilePath, properties),
|
||||||
|
_ => _inner.ResolveReference(reference, baseFilePath, properties));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override int GetHashCode() => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(this);
|
||||||
|
}
|
||||||
+92
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user