test(host): red repro — apply-boundary cache clear races in-flight VT evaluation into ObjectDisposedException (02/S12)

This commit is contained in:
Joseph Doherty
2026-07-13 10:09:37 -04:00
parent 05c35c93d9
commit d67900e455
2 changed files with 59 additions and 1 deletions
@@ -380,6 +380,64 @@ public sealed class RoslynVirtualTagEvaluatorTests
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&lt;,&gt;</c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)