Merge branch 'fix/archreview-crit2-vt-timeout'

This commit is contained in:
Joseph Doherty
2026-07-09 06:07:33 -04:00
5 changed files with 149 additions and 17 deletions
@@ -251,7 +251,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
}
/// <summary>Decisive proof the fast-path skips Roslyn: the compiled-script cache (which every
/// Roslyn evaluation populates via <c>GetOrAdd</c>) stays EMPTY after a mirror evaluation, then
/// Roslyn evaluation populates via <c>GetOrCompile</c>) stays EMPTY after a mirror evaluation, then
/// grows to one entry once a genuine (non-mirror) expression forces compilation.</summary>
[Fact]
public void Passthrough_does_not_populate_the_compiled_script_cache()
@@ -299,13 +299,74 @@ public sealed class RoslynVirtualTagEvaluatorTests
entry.VirtualTagId.ShouldBe("vt-log");
}
/// <summary>Reads the count of the private compiled-script cache via reflection.</summary>
// ── U2 — production timeout must be REAL: a CPU-bound/infinite-loop script must fail within the
// wall-clock budget, never hang the owning actor. The pre-fix CTS-only path could not interrupt
// a synchronous Roslyn script, so this class of script wedged the VirtualTagActor forever. ──
/// <summary>An infinite-loop script returns Failure ("timed out") within the timeout budget instead
/// of hanging. The whole call is bounded by <c>WaitAsync</c> so a regression FAILS the test (with a
/// TimeoutException) rather than wedging the suite.</summary>
[Fact]
public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang()
{
using var sut = new RoslynVirtualTagEvaluator(
NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));
var evalTask = Task.Run(
() => sut.Evaluate("vt-loop", "while(true){}return 0;", new Dictionary<string, object?>()),
TestContext.Current.CancellationToken);
var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Reason.ShouldNotBeNull();
result.Reason.ShouldContain("timed out");
}
/// <summary>Regression: routing through <see cref="TimedScriptEvaluator{TContext, TResult}"/> did not
/// break the happy path — a fast compiled script still returns its value well within the budget.</summary>
[Fact]
public void Evaluate_fast_script_still_returns_value_after_timeout_wrapping()
{
using var sut = new RoslynVirtualTagEvaluator(
NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));
var result = sut.Evaluate("vt-fast", "return (int)ctx.GetTag(\"a\").Value + 1;",
new Dictionary<string, object?> { ["a"] = 41 });
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(42);
}
// ── U3 — the apply-boundary clear (IScriptCacheOwner) drops compiled entries so their collectible
// AssemblyLoadContexts are reclaimed. (The ALC-unload-on-Clear proof lives in CompiledScriptCacheTests;
// here we assert the adapter's ClearCompiledScripts actually empties its cache.) ──
/// <summary>Verifies <see cref="RoslynVirtualTagEvaluator.ClearCompiledScripts"/> drops every cached
/// compile — the hook the host actor calls per deploy generation to release stale ALCs.</summary>
[Fact]
public void ClearCompiledScripts_empties_the_compile_cache()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
sut.Evaluate("vt-a", "return (int)ctx.GetTag(\"a\").Value + 1;", new Dictionary<string, object?> { ["a"] = 1 });
CompiledCacheCount(sut).ShouldBe(1);
sut.ClearCompiledScripts();
CompiledCacheCount(sut).ShouldBe(0);
}
/// <summary>Reads the count of the private compiled-script cache via reflection. The cache is a
/// <c>CompiledScriptCache&lt;,&gt;</c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
{
var field = typeof(RoslynVirtualTagEvaluator)
.GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic);
field.ShouldNotBeNull();
var cache = (ICollection)field.GetValue(sut)!;
return cache.Count;
var cache = field.GetValue(sut)!;
var countProp = cache.GetType().GetProperty("Count");
countProp.ShouldNotBeNull();
return (int)countProp.GetValue(cache)!;
}
}