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)