diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs index 64f973e6..89bbe95c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs @@ -3,6 +3,7 @@ using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; @@ -63,6 +64,18 @@ public sealed class OpcUaClientStaleSessionRaceTests It.IsAny(), It.IsAny())) .ReturnsAsync(resp); + // Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to + // completion against whichever session it ends up binding to. + mock.Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = new CallMethodResultCollection(), + DiagnosticInfos = new DiagnosticInfoCollection(), + }); return mock; } @@ -78,6 +91,16 @@ public sealed class OpcUaClientStaleSessionRaceTests Times.Never); } + private static void VerifyAckWireHitNewSessionNotOld(Mock newSession, Mock oldSession) + { + newSession.Verify(s => s.CallAsync( + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + oldSession.Verify(s => s.CallAsync( + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + /// /// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the /// HistoryRead against the session current after the gate, not a reference @@ -146,16 +169,53 @@ public sealed class OpcUaClientStaleSessionRaceTests VerifyWireHitNewSessionNotOld(newSession, oldSession); } + /// + /// AcknowledgeAsync issues its Acknowledge method CallAsync against the session + /// current after the gate — and parses the namespace-relative ConditionId against that + /// same session — rather than a reference captured before WaitAsync. This is the third + /// gate-protected wire site; the two subscribe paths (SubscribeAsync, + /// SubscribeAlarmsAsync) received the identical fix but are not covered here + /// because asserting them requires a live Subscription object (the SDK's + /// AddSubscription/CreateAsync can't be exercised through this mock seam); + /// their correctness is reviewable by inspection against this same idiom. + /// + [Fact] + public async Task AcknowledgeAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one() + { + var ct = TestContext.Current.CancellationToken; + using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-stale-ack"); + + var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var oldSession = NewSessionMock(prologueReached); + var newSession = NewSessionMock(); + drv.SetSessionForTest(oldSession.Object); + + await drv.Gate.WaitAsync(ct); + + var call = drv.AcknowledgeAsync( + [new AlarmAcknowledgeRequest("ns=2;s=Pump17", "ns=2;s=Pump17", "operator ack")], ct); + + await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct); + drv.SetSessionForTest(newSession.Object); + drv.Gate.Release(); + + await call; + + VerifyAckWireHitNewSessionNotOld(newSession, oldSession); + } + /// /// Awaits the buggy path's pre-gate-capture signal, or a bounded fallback when it never - /// fires (the fixed path performs no pre-gate session member read). Generous so the - /// buggy-path signal effectively always wins first, keeping the RED outcome - /// deterministic; the fallback only matters for the GREEN (fixed) path where swap - /// ordering is irrelevant. + /// fires (the fixed path performs no pre-gate session member read). The buggy-path signal + /// fires from a synchronous MessageContext read that runs before any await, so it + /// always wins the race well inside the fallback — keeping the RED outcome deterministic. + /// The fallback only matters for the GREEN (fixed) path where swap ordering is irrelevant + /// (the fixed read happens only after the gate is released), so a short bound keeps the + /// suite fast without weakening the guarantee. /// private static async Task WaitForPreGateCaptureOrFallbackAsync(Task signal, CancellationToken ct) { - var fallback = Task.Delay(TimeSpan.FromSeconds(2), ct); + var fallback = Task.Delay(TimeSpan.FromMilliseconds(250), ct); await Task.WhenAny(signal, fallback); } }