test(scripts): pin that repeat compiles resolve the assembly closure zero times

This commit is contained in:
Joseph Doherty
2026-08-12 16:34:37 -04:00
parent 2c8690a34f
commit 431a5f6433
@@ -89,4 +89,66 @@ public class CachingScriptMetadataResolverTests
var sut = new CachingScriptMetadataResolver(new CountingResolver()); var sut = new CachingScriptMetadataResolver(new CountingResolver());
Assert.True(sut.ResolveMissingAssemblies); 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 (~74) — one fresh native metadata copy per assembly per compile
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;
}
} }