Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs
T

357 lines
16 KiB
C#

using System.Collections;
using System.Diagnostics;
using System.Net;
using System.Net.NetworkInformation;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>One tag the harness reads, with the type it decodes under (null ⇒ raw comparison only).</summary>
internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin);
/// <summary>What one transport observed for one tag.</summary>
internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded)
{
public bool Succeeded => Status == (int)AbStatus.Ok && Error is null;
}
/// <summary>What one transport observed over its whole phase.</summary>
internal sealed class TransportObservations
{
public List<AbCipDiscoveredTag> Browse { get; } = [];
public string? BrowseError { get; set; }
public Dictionary<uint, AbCipUdtShape?> Shapes { get; } = [];
public Dictionary<uint, string> TemplateErrors { get; } = [];
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
}
/// <summary>The outcome of one whole differential run.</summary>
internal sealed class DifferentialResult
{
public required DifferentialTarget Target { get; init; }
public required TransportObservations Libplctag { get; init; }
public required TransportObservations Native { get; init; }
public required IReadOnlyList<SelectedTag> Selection { get; init; }
/// <summary>Native Forward Opens counted between the counter reset and the anchor release.</summary>
public required int NativeForwardOpens { get; init; }
/// <summary>TCP connections to the endpoint before libplctag started, and once its handles were disposed.</summary>
public required int? ConnectionsBaseline { get; init; }
public required int? ConnectionsBeforeNative { get; init; }
public required TimeSpan LibplctagDrainWait { get; init; }
}
/// <summary>
/// Runs the whole comparison ONCE: every libplctag operation first (handles disposed), then — only
/// once libplctag's session is gone — every native operation under one anchor lease. Reads only:
/// browse listings, template reads and tag reads. Nothing on this path encodes or sends a write.
/// </summary>
internal static class DifferentialRun
{
private const int MaxArrays = 20;
private const int MaxUdtMembers = 20;
private const int MaxTemplates = 256;
/// <summary>Upper bound on waiting for libplctag's session to close before native work may start.</summary>
private static readonly TimeSpan LibplctagDrainBudget = TimeSpan.FromSeconds(30);
/// <summary>Settle used when the platform cannot enumerate TCP connections.</summary>
private static readonly TimeSpan FallbackSettle = TimeSpan.FromSeconds(5);
public static async Task<DifferentialResult> ExecuteAsync(DifferentialTarget target, CancellationToken ct)
{
var baseline = CountConnections(target);
// ---- Phase 1: libplctag only. Every handle is disposed before the phase ends. ----
// libplctag shares one session (and one Forward Open) among the handles alive at the same time, and
// closes it when the last one is destroyed. The per-tag create/read/dispose below would therefore open
// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps
// every libplctag operation on ONE session — the mirror of the native anchor lease.
var lib = new TransportObservations();
var libFactories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
IReadOnlyList<SelectedTag> selection;
using (var libAnchor = libFactories.Tags.Create(target.Params("@tags")))
{
try
{
await libAnchor.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
throw new InvalidOperationException(
$"libplctag could not open its anchor session to {target}: {Describe(ex)}", ex);
}
await BrowseAsync(target, libFactories.Enumerators, lib, ct).ConfigureAwait(false);
await ReadTemplatesAsync(target, libFactories.Templates, lib, ct).ConfigureAwait(false);
selection = Select(target, lib);
await ReadTagsAsync(target, libFactories.Tags, selection, lib, ct).ConfigureAwait(false);
}
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
var drain = Stopwatch.StartNew();
var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, baseline, ct).ConfigureAwait(false);
drain.Stop();
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
var native = new TransportObservations();
var nativeFactories = AbCipTransportFactories.For(AbCipTransport.Native);
var anchorParams = target.Params("@tags");
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
int opens;
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
try
{
await BrowseAsync(target, nativeFactories.Enumerators, native, ct).ConfigureAwait(false);
await ReadTemplatesAsync(target, nativeFactories.Templates, native, ct).ConfigureAwait(false);
await ReadTagsAsync(target, nativeFactories.Tags, selection, native, ct).ConfigureAwait(false);
}
finally
{
opens = Volatile.Read(ref CipConnection.ForwardOpenCount);
await anchor.DisposeAsync().ConfigureAwait(false);
}
return new DifferentialResult
{
Target = target,
Libplctag = lib,
Native = native,
Selection = selection,
NativeForwardOpens = opens,
ConnectionsBaseline = baseline,
ConnectionsBeforeNative = beforeNative,
LibplctagDrainWait = drain.Elapsed,
};
}
private static async Task BrowseAsync(
DifferentialTarget target, IAbCipTagEnumeratorFactory factory, TransportObservations into, CancellationToken ct)
{
using var enumerator = factory.Create();
try
{
await foreach (var tag in enumerator.EnumerateAsync(target.Params("@tags"), ct).ConfigureAwait(false))
into.Browse.Add(tag);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
into.BrowseError = Describe(ex);
}
}
/// <summary>Reads every template id the browse surfaced, then every nested id those shapes name.</summary>
private static async Task ReadTemplatesAsync(
DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into, CancellationToken ct)
{
var queue = new Queue<uint>(into.Browse
.Where(t => t.TemplateInstanceId is not null)
.Select(t => t.TemplateInstanceId!.Value)
.Distinct()
.Order());
using var reader = factory.Create();
while (queue.Count > 0 && into.Shapes.Count + into.TemplateErrors.Count < MaxTemplates)
{
var id = queue.Dequeue();
if (into.Shapes.ContainsKey(id) || into.TemplateErrors.ContainsKey(id)) continue;
try
{
var shape = CipTemplateObjectDecoder.Decode(await reader.ReadAsync(target.Params($"@udt/{id}"), id, ct).ConfigureAwait(false));
into.Shapes[id] = shape;
foreach (var nested in shape?.Members ?? [])
if (nested.NestedTemplateId is { } n) queue.Enqueue(n);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
into.TemplateErrors[id] = Describe(ex);
}
}
}
/// <summary>
/// The tags to read, chosen from the libplctag browse (the run's first phase), so both transports
/// read exactly the same list.
/// </summary>
internal static IReadOnlyList<SelectedTag> Select(DifferentialTarget target, TransportObservations browsed)
{
var byName = new Dictionary<string, SelectedTag>(StringComparer.Ordinal);
var scalars = new List<SelectedTag>();
var arrays = new List<SelectedTag>();
var members = new List<SelectedTag>();
foreach (var tag in browsed.Browse.Where(t => !t.IsSystemTag))
{
var name = tag.ProgramScope is null ? tag.Name : $"Program:{tag.ProgramScope}.{tag.Name}";
var type = tag.DataType;
AbCipUdtShape? shape = null;
if (type == AbCipDataType.Structure)
{
shape = tag.TemplateInstanceId is { } id ? browsed.Shapes.GetValueOrDefault(id) : null;
// A string-family struct (STRING, STR0016, ...) is read and presented as one String value.
if (shape is { IsStringFamily: true }) type = AbCipDataType.String;
}
if (type != AbCipDataType.Structure)
{
var selected = new SelectedTag(name, type, tag.IsArray ? tag.ElementCount : 1, tag.IsArray,
tag.IsArray ? "array" : "scalar");
byName.TryAdd(name, selected);
(tag.IsArray ? arrays : scalars).Add(selected);
continue;
}
if (tag.IsArray || shape is null) continue;
foreach (var member in shape.Members)
{
var memberType = member.DataType;
if (memberType == AbCipDataType.Structure)
{
var nested = member.NestedTemplateId is { } n ? browsed.Shapes.GetValueOrDefault(n) : null;
if (nested is not { IsStringFamily: true }) continue;
memberType = AbCipDataType.String;
}
// Hidden host bytes of packed BOOL members ("ZZZZZZZZZZ..." in Logix) are not addressable.
if (member.Name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) continue;
var isArray = member.ArrayLength > 1;
var selected = new SelectedTag($"{name}.{member.Name}", memberType, isArray ? member.ArrayLength : 1,
isArray, "udt member");
byName.TryAdd(selected.Name, selected);
members.Add(selected);
}
}
IEnumerable<SelectedTag> chosen = target.Tags is { } explicitNames
? explicitNames.Select(n => byName.GetValueOrDefault(n) ?? new SelectedTag(n, null, 1, false, "explicit (type unknown)"))
: scalars.Concat(arrays.Take(MaxArrays)).Concat(members.Take(MaxUdtMembers));
return chosen.DistinctBy(t => t.Name).Take(target.MaxTags).ToList();
}
private static async Task ReadTagsAsync(
DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList<SelectedTag> selection,
TransportObservations into, CancellationToken ct)
{
foreach (var tag in selection)
{
ct.ThrowIfCancellationRequested();
var runtime = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type));
try
{
string? error = null;
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
error = Describe(ex);
}
var status = runtime.GetStatus();
byte[]? raw = null;
object? decoded = null;
if (error is null)
{
raw = Try(() => RawBufferOf(runtime).ToArray());
if (tag.Type is { } type)
decoded = Try(() => tag.IsArray ? runtime.DecodeArray(type, tag.ElementCount) : runtime.DecodeValue(type, null));
}
into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded);
}
finally
{
runtime.Dispose();
}
}
}
private static ReadOnlyMemory<byte> RawBufferOf(IAbCipTagRuntime runtime) => runtime switch
{
LibplctagTagRuntime l => l.RawBuffer,
NativeAbCipTagRuntime n => n.RawBuffer,
_ => throw new InvalidOperationException($"Unexpected runtime {runtime.GetType().Name}."),
};
/// <summary>
/// libplctag closes its session on a background thread some time after the last handle is
/// destroyed. Wait until the endpoint's TCP connection count is back at the pre-run baseline, so
/// the two transports are never connected to the controller at the same time.
/// </summary>
/// <returns>The connection count observed when native work may start (null when unobservable).</returns>
private static async Task<int?> WaitForLibplctagSessionToCloseAsync(
DifferentialTarget target, int? baseline, CancellationToken ct)
{
if (baseline is null)
{
await Task.Delay(FallbackSettle, ct).ConfigureAwait(false);
return null;
}
var deadline = Stopwatch.StartNew();
while (true)
{
var now = CountConnections(target);
if (now is null || now <= baseline) return now;
if (deadline.Elapsed > LibplctagDrainBudget)
throw new InvalidOperationException(
$"libplctag's session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after its " +
$"handles were disposed ({now} connection(s), baseline {baseline}); refusing to start the native phase alongside it.");
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
}
}
/// <summary>Open TCP connections from this machine to the target, or null when the OS cannot say.</summary>
internal static int? CountConnections(DifferentialTarget target)
{
try
{
var addresses = Dns.GetHostAddresses(target.Host).Select(a => a.MapToIPv6()).ToHashSet();
return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections().Count(c =>
c.RemoteEndPoint.Port == target.Port
&& addresses.Contains(c.RemoteEndPoint.Address.MapToIPv6())
&& c.State is not (TcpState.Closed or TcpState.TimeWait or TcpState.Unknown));
}
catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException or NotImplementedException)
{
return null;
}
}
/// <summary>Order-insensitive multiset difference: entries of <paramref name="a"/> missing from <paramref name="b"/>.</summary>
internal static List<T> Missing<T>(IEnumerable<T> a, IEnumerable<T> b) where T : notnull
{
var counts = b.GroupBy(x => x).ToDictionary(g => g.Key, g => g.Count());
var missing = new List<T>();
foreach (var x in a)
{
if (counts.TryGetValue(x, out var c) && c > 0) counts[x] = c - 1;
else missing.Add(x);
}
return missing;
}
/// <summary>Structural equality for decoded values: arrays element-wise, NaN equal to NaN.</summary>
internal static bool ValuesEqual(object? a, object? b) =>
a is Array || b is Array
? StructuralComparisons.StructuralEqualityComparer.Equals(a, b)
: Equals(a, b);
internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) =>
a is null || b is null
? a is null && b is null
: a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members);
private static T? Try<T>(Func<T> f)
{
try { return f(); }
catch (Exception) { return default; }
}
private static string Describe(Exception ex) => ex is AbTransportException ate
? $"{ate.Status}: {ate.Message}"
: $"{ex.GetType().Name}: {ex.Message}";
}