diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json index bbaea7c8..35acaa3d 100644 --- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json +++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json @@ -48,7 +48,7 @@ { "id": "R2-03-T7", "subject": "S12 concurrent stress repro (RED): 300 compiled evaluations racing ClearCompiledScripts, WaitAsync-bounded, assert zero disposed-reason failures", - "status": "pending", + "status": "completed", "blockedBy": [] }, { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs index eb191c0f..d4edc62d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs @@ -380,6 +380,64 @@ public sealed class RoslynVirtualTagEvaluatorTests CompiledCacheCount(sut).ShouldBe(1); } + /// 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 + /// — the apply-boundary clear. On the + /// unfixed tree a clear landing between a child's GetOrCompile and the run's disposed-guard + /// surfaces an ObjectDisposedException as a Failure("script threw: Cannot access a disposed + /// object…"); the fix retries once and every result stays Success. The whole test is WaitAsync- + /// bounded so a regression fails fast rather than hanging. + [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.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(); + 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 { ["a"] = i }); + if (!r.Success) + { + failures.Add(r.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)}"); + } + /// Reads the count of the private compiled-script cache via reflection. The cache is a /// CompiledScriptCache<,> which exposes a Count property (not ICollection). private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)