test(opcuaclient): add AcknowledgeAsync stale-session race test + trim fallback delay
Closes the code-review coverage gap: AcknowledgeAsync now has its own swap-across-the-gate regression test (CallAsync lands on the post-gate session), the subscribe-path coverage gap is documented, and the bounded fallback drops from 2s to 250ms (the buggy-path signal is a synchronous pre-await read, so it always wins well inside the bound).
This commit is contained in:
+65
-5
@@ -3,6 +3,7 @@ using Opc.Ua;
|
|||||||
using Opc.Ua.Client;
|
using Opc.Ua.Client;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
@@ -63,6 +64,18 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
|||||||
It.IsAny<HistoryReadValueIdCollection>(),
|
It.IsAny<HistoryReadValueIdCollection>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(resp);
|
.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<RequestHeader>(),
|
||||||
|
It.IsAny<CallMethodRequestCollection>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new CallResponse
|
||||||
|
{
|
||||||
|
ResponseHeader = new ResponseHeader(),
|
||||||
|
Results = new CallMethodResultCollection(),
|
||||||
|
DiagnosticInfos = new DiagnosticInfoCollection(),
|
||||||
|
});
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +91,16 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
|||||||
Times.Never);
|
Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
|
||||||
|
{
|
||||||
|
newSession.Verify(s => s.CallAsync(
|
||||||
|
It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
oldSession.Verify(s => s.CallAsync(
|
||||||
|
It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the
|
/// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the
|
||||||
/// HistoryRead against the session current <i>after</i> the gate, not a reference
|
/// HistoryRead against the session current <i>after</i> the gate, not a reference
|
||||||
@@ -146,16 +169,53 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
|||||||
VerifyWireHitNewSessionNotOld(newSession, oldSession);
|
VerifyWireHitNewSessionNotOld(newSession, oldSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AcknowledgeAsync issues its Acknowledge method <c>CallAsync</c> 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 (<c>SubscribeAsync</c>,
|
||||||
|
/// <c>SubscribeAlarmsAsync</c>) received the identical fix but are not covered here
|
||||||
|
/// because asserting them requires a live <c>Subscription</c> object (the SDK's
|
||||||
|
/// <c>AddSubscription</c>/<c>CreateAsync</c> can't be exercised through this mock seam);
|
||||||
|
/// their correctness is reviewable by inspection against this same idiom.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Awaits the buggy path's pre-gate-capture signal, or a bounded fallback when it never
|
/// 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
|
/// fires (the fixed path performs no pre-gate session member read). The buggy-path signal
|
||||||
/// buggy-path signal effectively always wins first, keeping the RED outcome
|
/// fires from a synchronous <c>MessageContext</c> read that runs before any await, so it
|
||||||
/// deterministic; the fallback only matters for the GREEN (fixed) path where swap
|
/// always wins the race well inside the fallback — keeping the RED outcome deterministic.
|
||||||
/// ordering is irrelevant.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task WaitForPreGateCaptureOrFallbackAsync(Task signal, CancellationToken ct)
|
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);
|
await Task.WhenAny(signal, fallback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user