222 lines
9.7 KiB
C#
222 lines
9.7 KiB
C#
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;
|
|
}
|
|
|
|
/// <summary>Inner resolver that finds nothing — models an assembly absent from every search path.</summary>
|
|
private sealed class NullReturningResolver : MetadataReferenceResolver
|
|
{
|
|
public int MissingCalls;
|
|
public override bool ResolveMissingAssemblies => true;
|
|
|
|
public override PortableExecutableReference? ResolveMissingAssembly(
|
|
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
|
{
|
|
Interlocked.Increment(ref MissingCalls);
|
|
return null;
|
|
}
|
|
|
|
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
|
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
|
=> [];
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A not-found result is cached and sticky BY DESIGN: the inner resolver
|
|
/// searches a fixed set of directories that does not change over a node's
|
|
/// lifetime, so re-asking for an assembly that was not there only pays the
|
|
/// probe cost again. Pinned because caching nulls is the kind of thing a
|
|
/// future reader "fixes" without realising it is deliberate.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ResolveMissingAssembly_NotFound_CachesTheNullResult()
|
|
{
|
|
var inner = new NullReturningResolver();
|
|
var sut = new CachingScriptMetadataResolver(inner);
|
|
|
|
var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
|
var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
|
|
|
Assert.Equal(1, inner.MissingCalls);
|
|
Assert.Null(first);
|
|
Assert.Null(second);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Locks in the documented cache-key contract: the key is the assembly
|
|
/// identity ALONE, and the requesting <c>definition</c> (whose directory is a
|
|
/// search path in the inner resolver) is deliberately ignored, because every
|
|
/// ScadaBridge node resolves from one publish directory plus the shared
|
|
/// framework. If that assumption ever stops holding, this test fails and the
|
|
/// widening becomes an intentional diff rather than a silent one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ResolveMissingAssembly_SameIdentityDifferentDefinitions_ResolvesOnce()
|
|
{
|
|
var otherDefinition = MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location);
|
|
Assert.NotEqual(
|
|
typeof(object).Assembly.Location,
|
|
typeof(Enumerable).Assembly.Location); // guard: the two definitions really are distinct files
|
|
|
|
var inner = new CountingResolver();
|
|
var sut = new CachingScriptMetadataResolver(inner);
|
|
|
|
sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
|
sut.ResolveMissingAssembly(otherDefinition, SomeIdentity);
|
|
|
|
Assert.Equal(1, 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<object?>(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<PortableExecutableReference> 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;
|
|
}
|
|
}
|