diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs
index ce983425..5be2d046 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs
@@ -50,6 +50,13 @@ internal sealed class DifferentialResult
/// Native Forward Opens counted between the counter reset and the anchor release.
public required int NativeForwardOpens { get; init; }
+ /// Forward Close frames the native side put on the wire (sent) and got a reply to (acked), over the
+ /// same window as — the anchor's release included (T21 review r1 M9).
+ public required NativeCloses NativeForwardCloses { get; init; }
+
+ /// TCP connections to the endpoint once the native session was released and drained (null when unobservable).
+ public required int? ConnectionsAfterNative { get; init; }
+
/// TCP connections to the endpoint before libplctag started, and once its handles were disposed.
public required int? ConnectionsBaseline { get; init; }
public required int? ConnectionsBeforeNative { get; init; }
@@ -61,6 +68,9 @@ internal sealed class DifferentialResult
public required IReadOnlySet? BaselineEndpoints { get; init; }
}
+/// Forward Close frames the native side sent, and how many of them got a reply within their 1 s budget.
+internal readonly record struct NativeCloses(int Sent, int Acked);
+
///
/// The run stopped at its first connection-level failure, or when the native side opened more than once.
/// Thrown, never swallowed: every live test then fails with this message.
@@ -117,7 +127,10 @@ internal static class DifferentialRun
drain.Stop();
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
- var (native, opens) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
+ var (native, opens, closes) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
+
+ // ---- Barrier: the native session must be gone too (its Forward Close is awaited in the anchor's release). ----
+ var afterNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "native", ct).ConfigureAwait(false);
return new DifferentialResult
{
@@ -126,6 +139,8 @@ internal static class DifferentialRun
Native = native,
Selection = selection,
NativeForwardOpens = opens,
+ NativeForwardCloses = closes,
+ ConnectionsAfterNative = afterNative,
ConnectionsBaseline = baseline?.Count,
ConnectionsBeforeNative = beforeNative,
LibplctagDrainWait = drain.Elapsed,
@@ -182,20 +197,20 @@ internal static class DifferentialRun
}
/// The native phase alone; internal so the offline abort test can drive it against a fake endpoint.
- internal static async Task<(TransportObservations Observations, int ForwardOpens)> RunNativePhaseAsync(
+ internal static async Task<(TransportObservations Observations, int ForwardOpens, NativeCloses ForwardCloses)> RunNativePhaseAsync(
DifferentialTarget target, IReadOnlyList selection, CancellationToken ct)
{
const string phase = "native";
var native = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Native);
- var (_, forwardOpens) = await WithNativeAnchorAsync(phase, target, async checkOpens =>
+ var (_, forwardOpens, forwardCloses) = await WithNativeAnchorAsync(phase, target, async checkOpens =>
{
await BrowseAsync(phase, target, factories.Enumerators, native, checkOpens, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, native, checkOpens, ct).ConfigureAwait(false);
await ReadTagsAsync(phase, target, factories.Tags, selection, native.Reads, checkOpens, ct).ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
- return (native, forwardOpens);
+ return (native, forwardOpens, forwardCloses);
}
///
@@ -205,8 +220,9 @@ internal static class DifferentialRun
/// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in
/// finally. Shared by the differential and the latency comparison.
///
- /// The body's result and the Forward Opens counted between the reset and the anchor release.
- internal static async Task<(T Result, int ForwardOpens)> WithNativeAnchorAsync(
+ /// The body's result, the Forward Opens counted between the reset and the anchor release, and the
+ /// Forward Closes counted between the reset and the END of the anchor release (which awaits the close).
+ internal static async Task<(T Result, int ForwardOpens, NativeCloses ForwardCloses)> WithNativeAnchorAsync(
string phase, DifferentialTarget target, Func, Task> body)
{
var anchorParams = target.Params("@tags");
@@ -221,9 +237,12 @@ internal static class DifferentialRun
}
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
+ Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0);
+ Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0);
Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1);
T result;
int forwardOpens;
+ NativeCloses forwardCloses;
try
{
// Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry.
@@ -236,13 +255,15 @@ internal static class DifferentialRun
{
forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount);
await anchor.DisposeAsync().ConfigureAwait(false);
+ forwardCloses = new NativeCloses(
+ Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked));
}
}
finally
{
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
}
- return (result, forwardOpens);
+ return (result, forwardOpens, forwardCloses);
}
private static async Task BrowseAsync(
@@ -582,6 +603,47 @@ internal static class DifferentialRun
probe is not null && lib.Succeeded && probe.Succeeded && lib.Raw is not null && probe.Raw is not null
&& !lib.Raw.AsSpan().SequenceEqual(probe.Raw);
+ ///
+ /// The value check that survives for a LIVE tag (T21 review r1 I1). A scalar is one element, an array element-wise
+ /// over the decoded values:
+ ///
+ /// an element EQUAL in both libplctag reads did not move, so native must read it equal too —
+ /// otherwise **NO: stable element i** (a stride, offset or byte-order defect lands here);
+ /// an element that moved is excused its value, but native must not read a NaN/∞ that neither
+ /// libplctag read saw — **NO: non-finite element i**.
+ ///
+ /// Null means the check passed. Shapes must already agree between libplctag and native.
+ ///
+ internal static string? LiveElementMismatch(object? lib, object? probe, object? nat)
+ {
+ var l = Elements(lib);
+ var p = Elements(probe);
+ var n = Elements(nat);
+ if (l.Length != p.Length) return "**NO: probe shape**"; // libplctag disagreed with itself: never excuse it
+ if (l.Length != n.Length) return "**NO: shape**";
+ for (var i = 0; i < l.Length; i++)
+ {
+ if (Equals(l[i], p[i]))
+ {
+ if (!Equals(l[i], n[i])) return $"**NO: stable element {i}**";
+ }
+ else if (!IsFinite(n[i]) && !Equals(n[i], l[i]) && !Equals(n[i], p[i]))
+ {
+ return $"**NO: non-finite element {i}**";
+ }
+ }
+ return null;
+ }
+
+ private static object?[] Elements(object? v) => v is Array a ? a.Cast