From 120e885b3159a1f075c96f311e06d0a3b63c234f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 05:39:47 -0400 Subject: [PATCH] test(s7): assert the stale handle is disposed, not just replaced, in the cancellation-reopen pins --- .../S7SpanReadParityTests.cs | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs index ac18e4ba..73f661b1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs @@ -473,12 +473,13 @@ public sealed class S7SpanReadParityTests await Should.ThrowAsync(async () => await reading); - // The reopen is the observable half of the contract: the desynced handle was discarded, so - // the next read builds a NEW connection rather than reusing it. + // The reopen is the observable half of the contract: the desynced handle was DISPOSED — not + // merely leaked behind a second connection — and the next read builds a new one. factory.HangReadBytes = false; var recovered = await driver.ReadAsync(refs, ct); recovered.ShouldAllBe(s => s.StatusCode == 0u); factory.CreatedPlcCount.ShouldBe(2); + factory.Plcs[0].Disposed.ShouldBeTrue(); } /// @@ -507,6 +508,7 @@ public sealed class S7SpanReadParityTests var recovered = await driver.ReadAsync(refs, ct); recovered.ShouldAllBe(s => s.StatusCode == 0u); factory.CreatedPlcCount.ShouldBe(2); + factory.Plcs[0].Disposed.ShouldBeTrue(); } // ── Configurability ────────────────────────────────────────────────────────────────── @@ -640,18 +642,30 @@ public sealed class S7SpanReadParityTests /// How many connections this factory has been asked to build — the reopen observable. public int CreatedPlcCount; + /// + /// Every connection this factory built, in build order, so a test can assert what became + /// of the STALE one — a driver that leaked the old handle and merely opened a second + /// would satisfy alone. + /// + public List Plcs { get; } = new(); + /// public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) { Interlocked.Increment(ref CreatedPlcCount); - return new ImageS7Plc(this); + var plc = new ImageS7Plc(this); + lock (Plcs) Plcs.Add(plc); + return plc; } - private sealed class ImageS7Plc(ImageS7PlcFactory owner) : IS7Plc + public sealed class ImageS7Plc(ImageS7PlcFactory owner) : IS7Plc { /// public bool IsConnected { get; private set; } + /// True once the driver has disposed this handle — the discard half of a reopen. + public bool Disposed { get; private set; } + /// public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; } @@ -771,7 +785,11 @@ public sealed class S7SpanReadParityTests public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask; /// - public void Dispose() => IsConnected = false; + public void Dispose() + { + IsConnected = false; + Disposed = true; + } } } }