From 31af7acde0c9ddb37a857b68e2b4e437c4d04b23 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 16:29:04 -0400 Subject: [PATCH] test(mtconnect): make the STAB-20 retention test deterministic again (review I-2) 29c829b5 let the test accept 'already finished' for the stale-session pump, which since T-1 is nearly always true, so deleting the retention reintroduced STAB-20 silently. A new CannedAgentClient.HoldSampleExit knob keeps that pump alive past the retire point; the test now asserts strictly that it is still running (positive control) AND retained, then releases it and asserts shutdown joined it. Red with the _supersededPumps.Add removed. The pump's own self-exit on a gone generation is covered by MTConnectStaleCursorTests (pump A completes, the current pump keeps streaming). Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../CannedAgentClient.cs | 12 ++++++++++++ .../MTConnectPumpGenerationTests.cs | 11 ++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs index 164457a8..1e9ec961 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -178,6 +178,13 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable /// public TaskCompletionSource? CurrentGate { get; set; } + /// + /// When set, a /sample enumeration that is cancelled or whose client is disposed does NOT end until this + /// source completes — a pump that cannot unwind (a wedged socket read). The deterministic way to keep a retired + /// pump alive past the point a lifecycle restart retires it. + /// + public TaskCompletionSource? HoldSampleExit { get; set; } + /// /// Signalled the moment a /current request lands — before /// parks it. This is the deterministic "the caller is now inside @@ -360,6 +367,11 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable { scripted = await chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false); } + catch (Exception ex) when (HoldSampleExit is { } hold && ex is OperationCanceledException or ChannelClosedException) + { + await hold.Task.ConfigureAwait(false); + throw; + } catch (ChannelClosedException) { // Mirrors the production client: a stream that stops for any reason other than the diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectPumpGenerationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectPumpGenerationTests.cs index 476b8d53..2c093abb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectPumpGenerationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectPumpGenerationTests.cs @@ -37,6 +37,7 @@ public sealed class MTConnectPumpGenerationTests await driver.InitializeAsync("{}", Ct); Task[] retired = []; + var holdStalePump = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var blockOnce = 0; var blocking = new ManualResetEventSlim(false); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -68,7 +69,10 @@ public sealed class MTConnectPumpGenerationTests (++spins).ShouldBeLessThan(500, "restart never cleared the pump slot"); } - // A second subscriber arrives mid-restart: it starts a pump on the (still-live) old client. + // A second subscriber arrives mid-restart: it starts a pump on the (still-live) old client. That pump is + // held alive past the retire point (it cannot unwind until released), so the retention is observable + // deterministically rather than racing the pump's own exit. + clientA.HoldSampleExit = holdStalePump; await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); var staleSessionPump = driver.SampleStreamTask.ShouldNotBeNull(); await clientA.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct); @@ -82,8 +86,8 @@ public sealed class MTConnectPumpGenerationTests // pump also stops by itself once it sees its session generation is gone, so it may already have // finished before the restart got to retire it — either way it is not left running unjoined. retired = driver.SupersededPumpTasks; - (retired.Contains(staleSessionPump) || staleSessionPump.IsCompleted).ShouldBeTrue( - "the stale-session pump must be retained for shutdown to join, or already be gone"); + staleSessionPump.IsCompleted.ShouldBeFalse("positive control: the stale pump is still running here"); + retired.ShouldContain(staleSessionPump, "a retired pump that is still running must be retained for shutdown to join"); // The restart installed a new client; the standing subscription must be streaming from IT. await clientB.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct); @@ -92,6 +96,7 @@ public sealed class MTConnectPumpGenerationTests finally { blocking.Set(); + holdStalePump.TrySetResult(); await driver.ShutdownAsync(CancellationToken.None); }