Merge R2-03 Virtual-Tag failure quality (arch-review round 2) [PR #430]
Findings 02/S13 (VT failure/timeout degrades node to Bad quality, no stale-Good forever), 02/S12 (evaluator retry-once on disposed-race), 02/P7 (compile-cache clear gated on changed expression set). T11/T12 deferred. STATUS.md conflict resolved additively. Build clean.
This commit is contained in:
+81
@@ -357,6 +357,87 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
CompiledCacheCount(sut).ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>02/S12 sequential pin: evaluating a compiled expression, clearing the cache, then
|
||||
/// evaluating the SAME source again succeeds both times and leaves the cache with exactly one entry
|
||||
/// (the recompile). This pins the clear-then-recompile contract the T8 retry-loop restructure must
|
||||
/// not regress.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";
|
||||
|
||||
var first = sut.Evaluate("vt-clear", expr, new Dictionary<string, object?> { ["a"] = 1 });
|
||||
first.Success.ShouldBeTrue(first.Reason);
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
|
||||
sut.ClearCompiledScripts();
|
||||
CompiledCacheCount(sut).ShouldBe(0);
|
||||
|
||||
var second = sut.Evaluate("vt-clear", expr, new Dictionary<string, object?> { ["a"] = 41 });
|
||||
second.Success.ShouldBeTrue(second.Reason);
|
||||
second.Value.ShouldBe(42);
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>02/S12 concurrent stress repro (RED on current code): while one loop runs many compiled
|
||||
/// (non-passthrough) evaluations of the same expression, a second loop hammers
|
||||
/// <see cref="RoslynVirtualTagEvaluator.ClearCompiledScripts"/> — the apply-boundary clear. On the
|
||||
/// unfixed tree a clear landing between a child's <c>GetOrCompile</c> and the run's disposed-guard
|
||||
/// surfaces an ObjectDisposedException as a <c>Failure("script threw: Cannot access a disposed
|
||||
/// object…")</c>; the fix retries once and every result stays Success. The whole test is WaitAsync-
|
||||
/// bounded so a regression fails fast rather than hanging.</summary>
|
||||
[Fact]
|
||||
public async Task Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed()
|
||||
{
|
||||
// Generous timeout so the wall-clock budget never fires and confounds the race assertion.
|
||||
using var sut = new RoslynVirtualTagEvaluator(
|
||||
NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromSeconds(5));
|
||||
const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";
|
||||
|
||||
const int loopCount = 12;
|
||||
const int iterations = 400;
|
||||
var failures = new System.Collections.Concurrent.ConcurrentBag<string>();
|
||||
var remaining = loopCount; // number of concurrent evaluation loops still running
|
||||
|
||||
// Many concurrent evaluation loops put the thread pool under enough pressure that the inner
|
||||
// Task.Run hop inside TimedScriptEvaluator is measurably delayed — widening the
|
||||
// fetch→run-disposed-guard window so a clear reliably lands inside SOME in-flight evaluation.
|
||||
// The clear loop clears on a short spin cadence (not back-to-back) so (a) most evaluations hit
|
||||
// the warm cache — keeping compile cost bounded — and (b) at most one clear lands within a
|
||||
// single evaluation, so the single retry the fix adds is sufficient (a back-to-back clear loop
|
||||
// would both compile-swamp the RED run into a timeout and defeat the one-shot retry). Pre-fix a
|
||||
// clear disposing the just-fetched evaluator surfaces as Failure("script threw: Cannot access a
|
||||
// disposed object…"); post-fix the retry re-fetches into the current generation → all Success.
|
||||
var evalLoops = Enumerable.Range(0, loopCount).Select(_ => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var r = sut.Evaluate("vt-race", expr, new Dictionary<string, object?> { ["a"] = i });
|
||||
if (!r.Success)
|
||||
{
|
||||
failures.Add(r.Reason ?? "<null reason>");
|
||||
}
|
||||
}
|
||||
Interlocked.Decrement(ref remaining);
|
||||
}, TestContext.Current.CancellationToken)).ToArray();
|
||||
|
||||
var clearLoop = Task.Run(() =>
|
||||
{
|
||||
while (Volatile.Read(ref remaining) > 0)
|
||||
{
|
||||
sut.ClearCompiledScripts();
|
||||
Thread.SpinWait(4000); // ~tens of µs between clears — frequent, but not back-to-back
|
||||
}
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.WhenAll(evalLoops.Append(clearLoop))
|
||||
.WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken);
|
||||
|
||||
failures.ShouldBeEmpty(
|
||||
$"every evaluation must succeed despite racing clears; observed: {string.Join(" | ", failures)}");
|
||||
}
|
||||
|
||||
/// <summary>Reads the count of the private compiled-script cache via reflection. The cache is a
|
||||
/// <c>CompiledScriptCache<,></c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
|
||||
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
|
||||
|
||||
Reference in New Issue
Block a user