From bbc1b55cc43d940aa6c9dece234deeff3902f0dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 08:56:50 -0400 Subject: [PATCH] =?UTF-8?q?test(siteeventlog):=20make=20P4-5's=20ExecuteAs?= =?UTF-8?q?ync-yield=20test=20deterministic=20=E2=80=94=20park=20the=20pur?= =?UTF-8?q?ge=20on=20the=20isActiveNode=20gate=20instead=20of=20racing=20t?= =?UTF-8?q?he=20pool=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The T4 test asserted 'reflection Invoke returned AND the row is still present', but the yielded continuation runs RunPurge on a pool thread and can delete the row before the test thread reaches its assert — lost 2/2 full-suite sweeps on the integration branch, green in isolation (row-40 class, introduced by this wave). The purge now blocks inside the isActiveNode check (consulted before any delete) until the test releases it: fixed code returns from Invoke while the gate is held; unfixed code blocks on the gate itself and the bounded wait fails. Proof of failure re-run: with Task.Yield() removed the test fails with the intended message after the 10 s bound; 3/3 green with it restored. --- .../EventLogPurgeServiceTests.cs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs index 325b59b3..920ed4ad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs @@ -381,22 +381,39 @@ public class EventLogPurgeServiceTests : IDisposable // expired row before this method's Assert ever executes. With Task.Yield() as the // very first statement, Invoke returns as soon as MoveNext() hits the (always // IsCompleted == false) yield point, before RunPurge has been called at all. + // + // Made deterministic at integration (2026-08-20): "Invoke returned, row still present" + // on its own is a RACE — the yielded continuation runs RunPurge on a pool thread and + // can delete the row before this thread reaches its assert (lost 2/2 full-suite + // sweeps, green in isolation). So the purge is parked on a gate instead: RunPurge + // consults the isActiveNode check BEFORE any delete, and the check blocks until the + // test releases it. Fixed code: Invoke returns while the gate is held (the continuation + // is the one waiting). Unfixed code: Invoke itself blocks on the gate, so the + // bounded wait below fails — no timing assumption in either direction. InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31)); Assert.Equal(1, GetEventCount()); - var purge = CreatePurgeService(); + using var gate = new ManualResetEventSlim(initialState: false); + var purge = CreatePurgeService(isActiveNode: () => { gate.Wait(); return true; }); var executeAsync = typeof(EventLogPurgeService).GetMethod( "ExecuteAsync", BindingFlags.NonPublic | BindingFlags.Instance); Assert.NotNull(executeAsync); using var cts = new CancellationTokenSource(); - var task = (Task)executeAsync!.Invoke(purge, [cts.Token])!; + Task? executeTask = null; + var invoke = Task.Factory.StartNew( + () => executeTask = (Task)executeAsync!.Invoke(purge, [cts.Token])!, + TaskCreationOptions.LongRunning); - // Reflection Invoke has already returned control here — a synchronous - // RunPurge() ahead of any await would have already deleted the row by now. + // With the gate held, Invoke can only return if ExecuteAsync yielded before RunPurge. + var returned = await Task.WhenAny(invoke, Task.Delay(TimeSpan.FromSeconds(10))) == invoke; + Assert.True(returned, + "ExecuteAsync did not return control before running the initial purge — " + + "it is running RunPurge synchronously ahead of its first await."); Assert.Equal(1, GetEventCount()); - // The initial purge still runs, just after control has been yielded back. + // Release the purge; it still runs, just after control was yielded back. + gate.Set(); var deadline = DateTime.UtcNow.AddSeconds(5); while (GetEventCount() != 0 && DateTime.UtcNow < deadline) { @@ -407,7 +424,7 @@ public class EventLogPurgeServiceTests : IDisposable await cts.CancelAsync(); try { - await task; + await executeTask!; } catch (OperationCanceledException) {