diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md index 3a856d0f..b0993d34 100644 --- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md +++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md @@ -45,7 +45,78 @@ defect is pre-existing and libplctag-side; it is recorded, not fixed, here. ## Differential vs ab_server (T18) -_(pending)_ +Run 2026-10-07 on branch `feat/native-ab-transport`, harness +`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/`, against `ab_server` +(`controllogix` profile only, `/opt/otopcua-abcip` on 10.100.0.35): + +```bash +AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \ + dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \ + --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed" +``` + +Target as the harness resolved it: `10.100.0.35:44818`, path `1,0`, family `CompactLogix` +(the defaults; libplctag attribute `compactlogix`, connection size 504). + +**Result: 7/7 passed, 0 unexpected divergences, 0 expected divergences.** + +| Check | Result | +|---|---| +| Browse parity (`@tags` + program listings) | 9 tags via libplctag, 9 via native, identical as multisets | +| Template parity | vacuous: ab_server serves no UDTs, so 0 template ids. Task 21's press is the first real exercise | +| Read parity | 9/9 tags equal on status, raw buffer and decoded value | +| Native Forward Opens over the run (`AB_DIFF_COUNT_OPENS=1`) | **1** | +| TCP connections to the target before the native phase | 0 (baseline 0). libplctag's session closed within 1 ms of the last handle being disposed | +| Read-only guard (offline, runs without the endpoint) | pass | +| Comparison falsifiability (offline) | pass | + +| Tag | Type | libplctag status | native status | Equal? | +|---|---|---|---|---| +| `Motor_Run` | Bool | Ok | Ok | yes | +| `Line1_Count` | DInt | Ok | Ok | yes | +| `Pump_1_Speed` | Real | Ok | Ok | yes | +| `TestString` | String | Ok | Ok | yes | +| `TestSINT` | SInt | Ok | Ok | yes | +| `TestBOOL` | Bool | Ok | Ok | yes | +| `TestREAL` | Real | Ok | Ok | yes | +| `TestDINT` | DInt | Ok | Ok | yes | +| `TestArray` | DInt[16] | Ok | Ok | yes | + +**The values were made non-zero first.** An all-zero simulator would make raw and value equality +vacuous. Before the run, the simulator was seeded through a throwaway libplctag scratch program +outside the repo. The harness itself never writes. Seeded values: + +- `TestDINT` = −123456789 +- `TestREAL` = 3.14159 +- `Pump_1_Speed` = 1234.5 +- `Line1_Count` = 987654 +- `TestArray[i]` = (i−8)·100003+7, which includes negatives +- `TestString` = `"Hello T18"` + +ab_server refuses 1-byte writes (`ErrorBadParam`), so `TestBOOL`, `TestSINT` and `Motor_Run` stay 0. +Their parity is therefore weaker evidence. + +**Wire capture of the same run.** tcpdump ran in the ab_server container's network namespace +(`nicolaka/netshoot`), and the capture was decoded with tshark: + +| TCP stream | Session | RegisterSession | Forward Open | Forward Close | Requests | Window (s, relative) | +|---|---|---|---|---|---|---| +| 12 | libplctag | 1 | 1 (Large, 0x5B) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.243 → 0.336 | +| 13 | native | 1 | 1 (0x54) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.349 → 0.452 | + +- Exactly two sessions, with no overlap: a 13 ms gap between them. +- Zero write services on the wire: no 0x4D, 0x53 or 0x10. +- The 12 earlier sub-10 ms TCP connections are the `AbServerFact`/`AbServerTheory` reachability probes. xUnit runs them during discovery for the *other* suites, even under the filter. They target `AB_SERVER_ENDPOINT` (default 10.100.0.35), never `AB_DIFF_ENDPOINT`. + +**Explicit-list path.** `AB_DIFF_TAGS="TestDINT,NoSuchTag,TestArray,TestString"`: 4/4 equal. +`NoSuchTag` returned `NotFound` under both transports, and there was still exactly one native Forward Open. + +**Transport fixes found by the differential: none.** One harness defect was found and fixed before +this result. libplctag closes a session as soon as its last handle is destroyed. The first version of the +libplctag phase created, read and disposed one handle at a time, so it opened a fresh session (and a +fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The libplctag phase now holds an anchor handle +on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session +capture above. ## AdminUI /run (T19) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index 3087492a..f0655678 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -53,6 +53,12 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime /// public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize()); + /// + /// A copy of the bytes of the last read, for the read-only differential harness (Task 18) to compare + /// against the native runtime byte for byte. Not part of . + /// + internal ReadOnlyMemory RawBuffer => LibplctagStatusTranslation.WrapSync(() => _tag.GetBuffer()); + /// public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs index f445bd2d..60910133 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs @@ -209,6 +209,12 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime /// public int GetSize() => _data.Length; + /// + /// The bytes of the last read, for the read-only differential harness (Task 18) to compare against + /// libplctag byte for byte. Not part of . + /// + internal ReadOnlyMemory RawBuffer => _data; + /// public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj index 13e4c766..59436b5e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj @@ -28,6 +28,7 @@ + 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 new file mode 100644 index 00000000..ee988d06 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs @@ -0,0 +1,356 @@ +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; + +/// One tag the harness reads, with the type it decodes under (null ⇒ raw comparison only). +internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin); + +/// What one transport observed for one tag. +internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded) +{ + public bool Succeeded => Status == (int)AbStatus.Ok && Error is null; +} + +/// What one transport observed over its whole phase. +internal sealed class TransportObservations +{ + public List Browse { get; } = []; + public string? BrowseError { get; set; } + public Dictionary Shapes { get; } = []; + public Dictionary TemplateErrors { get; } = []; + public Dictionary Reads { get; } = new(StringComparer.Ordinal); +} + +/// The outcome of one whole differential run. +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 Selection { get; init; } + + /// Native Forward Opens counted between the counter reset and the anchor release. + public required int NativeForwardOpens { 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; } + public required TimeSpan LibplctagDrainWait { get; init; } +} + +/// +/// 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. +/// +internal static class DifferentialRun +{ + private const int MaxArrays = 20; + private const int MaxUdtMembers = 20; + private const int MaxTemplates = 256; + + /// Upper bound on waiting for libplctag's session to close before native work may start. + private static readonly TimeSpan LibplctagDrainBudget = TimeSpan.FromSeconds(30); + + /// Settle used when the platform cannot enumerate TCP connections. + private static readonly TimeSpan FallbackSettle = TimeSpan.FromSeconds(5); + + public static async Task 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 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); + } + } + + /// Reads every template id the browse surfaced, then every nested id those shapes name. + private static async Task ReadTemplatesAsync( + DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into, CancellationToken ct) + { + var queue = new Queue(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); + } + } + } + + /// + /// The tags to read, chosen from the libplctag browse (the run's first phase), so both transports + /// read exactly the same list. + /// + internal static IReadOnlyList Select(DifferentialTarget target, TransportObservations browsed) + { + var byName = new Dictionary(StringComparer.Ordinal); + var scalars = new List(); + var arrays = new List(); + var members = new List(); + + 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 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 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 RawBufferOf(IAbCipTagRuntime runtime) => runtime switch + { + LibplctagTagRuntime l => l.RawBuffer, + NativeAbCipTagRuntime n => n.RawBuffer, + _ => throw new InvalidOperationException($"Unexpected runtime {runtime.GetType().Name}."), + }; + + /// + /// 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. + /// + /// The connection count observed when native work may start (null when unobservable). + private static async Task 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); + } + } + + /// Open TCP connections from this machine to the target, or null when the OS cannot say. + 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; + } + } + + /// Order-insensitive multiset difference: entries of missing from . + internal static List Missing(IEnumerable a, IEnumerable b) where T : notnull + { + var counts = b.GroupBy(x => x).ToDictionary(g => g.Key, g => g.Count()); + var missing = new List(); + foreach (var x in a) + { + if (counts.TryGetValue(x, out var c) && c > 0) counts[x] = c - 1; + else missing.Add(x); + } + return missing; + } + + /// Structural equality for decoded values: arrays element-wise, NaN equal to NaN. + 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(Func 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}"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs new file mode 100644 index 00000000..b977d6a0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// +/// The one endpoint the read-only differential harness targets, read from the environment. +/// +/// AB_DIFF_ENDPOINT — host:port (port defaults to 44818). Absent ⇒ every live test skips. +/// AB_DIFF_PATH — CIP path, default 1,0. +/// AB_DIFF_FAMILY — an name, default CompactLogix. +/// AB_DIFF_TAGS — optional comma list of tag names; absent ⇒ selected from browse. +/// AB_DIFF_MAX_TAGS — cap on the selected tags, default 200. +/// AB_DIFF_COUNT_OPENS — 1 ⇒ assert exactly one native Forward Open over the run. +/// +/// +internal sealed record DifferentialTarget( + string Host, + int Port, + string CipPath, + AbCipPlcFamily Family, + IReadOnlyList? Tags, + int MaxTags, + bool CountOpens) +{ + public const string EndpointVar = "AB_DIFF_ENDPOINT"; + public const string PathVar = "AB_DIFF_PATH"; + public const string FamilyVar = "AB_DIFF_FAMILY"; + public const string TagsVar = "AB_DIFF_TAGS"; + public const string MaxTagsVar = "AB_DIFF_MAX_TAGS"; + public const string CountOpensVar = "AB_DIFF_COUNT_OPENS"; + + private const int DefaultPort = 44818; + private const string DefaultPath = "1,0"; + private const AbCipPlcFamily DefaultFamily = AbCipPlcFamily.CompactLogix; + private const int DefaultMaxTags = 200; + + /// Per-operation timeout for both transports. + public static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(5); + + /// Skip reason used by every live test when the endpoint is not configured. + public static string SkipReason => + $"{EndpointVar} is not set — point it at host:port of the one controller to compare (e.g. 10.100.0.35:44818 for ab_server)."; + + /// The configured target, or null when AB_DIFF_ENDPOINT is absent. + /// An env var is present but malformed. + public static DifferentialTarget? FromEnvironment() + { + var endpoint = Environment.GetEnvironmentVariable(EndpointVar)?.Trim(); + if (string.IsNullOrEmpty(endpoint)) return null; + + var parts = endpoint.Split(':', 2); + var host = parts[0]; + var port = DefaultPort; + if (parts.Length == 2 && (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535)) + throw new InvalidOperationException($"{EndpointVar}='{endpoint}' is not host:port."); + if (host.Length == 0) throw new InvalidOperationException($"{EndpointVar}='{endpoint}' has no host."); + + // An explicitly empty AB_DIFF_PATH is honoured (direct / no-backplane targets). + var path = Environment.GetEnvironmentVariable(PathVar) ?? DefaultPath; + + var family = DefaultFamily; + if (Environment.GetEnvironmentVariable(FamilyVar) is { Length: > 0 } rawFamily + && !Enum.TryParse(rawFamily.Trim(), ignoreCase: true, out family)) + throw new InvalidOperationException( + $"{FamilyVar}='{rawFamily}' is not one of {string.Join(", ", Enum.GetNames())}."); + + IReadOnlyList? tags = null; + if (Environment.GetEnvironmentVariable(TagsVar) is { Length: > 0 } rawTags) + tags = rawTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var max = DefaultMaxTags; + if (Environment.GetEnvironmentVariable(MaxTagsVar) is { Length: > 0 } rawMax + && (!int.TryParse(rawMax, NumberStyles.None, CultureInfo.InvariantCulture, out max) || max < 1)) + throw new InvalidOperationException($"{MaxTagsVar}='{rawMax}' is not a positive integer."); + + var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1"; + return new DifferentialTarget(host, port, path, family, tags, max, countOpens); + } + + /// The family profile the driver would use for this target. + public AbCipPlcFamilyProfile Profile => AbCipPlcFamilyProfile.ForFamily(Family); + + /// Create params for one tag (or listing pseudo-tag), built the way the driver builds them. + public AbCipTagCreateParams Params( + string tagName, int elementCount = 1, bool isArray = false, AbCipDataType? authoredType = null) => new( + Gateway: Host, + Port: Port, + CipPath: CipPath, + LibplctagPlcAttribute: Profile.LibplctagPlcAttribute, + TagName: tagName, + Timeout: OperationTimeout, + AllowPacking: Profile.SupportsRequestPacking, + ConnectionSize: Profile.DefaultConnectionSize, + ElementCount: elementCount < 1 ? 1 : elementCount, + IsArray: isArray, + AuthoredType: authoredType, + UseConnectedMessaging: Profile.SupportsConnectedMessaging); + + /// + public override string ToString() => + $"{Host}:{Port} path '{CipPath}' family {Family} ({Profile.LibplctagPlcAttribute}, connection size {Profile.DefaultConnectionSize})"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs new file mode 100644 index 00000000..0129a198 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs @@ -0,0 +1,287 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// Runs the differential alone: no other suite may hold a session to the target meanwhile. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class DifferentialCollection : ICollectionFixture +{ + public const string Name = "AbCip transport differential"; +} + +/// Performs the one run all differential tests read from, on first use. +public sealed class DifferentialRunFixture +{ + private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(15); + private readonly Lazy> _run = new(RunAsync); + + internal Task GetAsync() => _run.Value; + + private static async Task RunAsync() + { + var target = DifferentialTarget.FromEnvironment() + ?? throw new InvalidOperationException(DifferentialTarget.SkipReason); + using var cts = new CancellationTokenSource(RunBudget); + return await DifferentialRun.ExecuteAsync(target, cts.Token).ConfigureAwait(false); + } +} + +/// +/// Read-only differential (Task 18): the same tags through libplctag and the native transport against one +/// endpoint — browse listings, UDT templates and tag reads must agree byte for byte and value for value. +/// Task 21 runs this against a production press, so the suite is read-only by construction (see +/// ), libplctag and native are never connected at the +/// same time, and the native side runs on one connection held open by an anchor lease. +/// +[Collection(DifferentialCollection.Name)] +[Trait("Category", "LiveIntegration")] +public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) +{ + [Fact] + public async Task Browse_listings_decode_to_identical_tag_sets_through_both_enumerators() + { + var r = await RunOrSkipAsync(); + Log($"## Browse — {r.Target}"); + Log($"libplctag: {r.Libplctag.Browse.Count} tag(s){ErrorSuffix(r.Libplctag.BrowseError)}; " + + $"native: {r.Native.Browse.Count} tag(s){ErrorSuffix(r.Native.BrowseError)}"); + + var onlyLib = DifferentialRun.Missing(r.Libplctag.Browse, r.Native.Browse); + var onlyNative = DifferentialRun.Missing(r.Native.Browse, r.Libplctag.Browse); + foreach (var t in onlyLib) Log($"- only libplctag: {t}"); + foreach (var t in onlyNative) Log($"- only native: {t}"); + + r.Libplctag.BrowseError.ShouldBeNull(); + r.Native.BrowseError.ShouldBeNull(); + r.Libplctag.Browse.ShouldNotBeEmpty("a browse that returns nothing proves nothing"); + onlyLib.ShouldBeEmpty(); + onlyNative.ShouldBeEmpty(); + } + + [Fact] + public async Task Every_template_decodes_to_an_identical_shape_through_both_readers() + { + var r = await RunOrSkipAsync(); + var ids = r.Libplctag.Shapes.Keys.Concat(r.Libplctag.TemplateErrors.Keys) + .Concat(r.Native.Shapes.Keys).Concat(r.Native.TemplateErrors.Keys) + .Distinct().Order().ToList(); + Log($"## Templates — {ids.Count} id(s)"); + if (ids.Count > 0) + { + Log("| Template | libplctag | native | Equal? |"); + Log("|---|---|---|---|"); + } + + var unequal = new List(); + foreach (var id in ids) + { + var lib = Shape(r.Libplctag, id); + var nat = Shape(r.Native, id); + var equal = r.Libplctag.Shapes.ContainsKey(id) && r.Native.Shapes.ContainsKey(id) + && DifferentialRun.ShapesEqual(r.Libplctag.Shapes[id], r.Native.Shapes[id]); + if (!equal) unequal.Add(id); + Log($"| {id} | {lib} | {nat} | {(equal ? "yes" : "**NO**")} |"); + } + unequal.ShouldBeEmpty(); + } + + [Fact] + public async Task Every_selected_tag_reads_identically_through_both_transports() + { + var r = await RunOrSkipAsync(); + Log($"## Reads — {r.Selection.Count} tag(s), {r.Target}"); + Log("| Tag | Type | libplctag status | native status | Equal? |"); + Log("|---|---|---|---|---|"); + + var unexpected = new List(); + var expected = new List(); + foreach (var tag in r.Selection) + { + var lib = r.Libplctag.Reads[tag.Name]; + var nat = r.Native.Reads[tag.Name]; + var verdict = Compare(tag, lib, nat); + if (verdict.StartsWith("expected", StringComparison.Ordinal)) expected.Add($"{tag.Name}: {verdict}"); + else if (verdict != "yes") unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); + Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " + + $"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {verdict} |"); + } + + Log(""); + Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}."); + foreach (var e in expected) Log($"- expected: {e}"); + foreach (var u in unexpected) Log($"- UNEXPECTED: {u}"); + + r.Selection.ShouldNotBeEmpty("a run that reads nothing proves nothing"); + unexpected.ShouldBeEmpty(); + } + + [Fact] + public async Task Libplctag_and_native_are_never_connected_at_the_same_time() + { + var r = await RunOrSkipAsync(); + Log($"TCP connections to the target: baseline {r.ConnectionsBaseline?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " + + $"before native {r.ConnectionsBeforeNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " + + $"libplctag drain wait {r.LibplctagDrainWait.TotalMilliseconds:0} ms."); + if (r.ConnectionsBaseline is null) + Assert.Skip("This OS cannot enumerate TCP connections; the run fell back to a fixed settle before the native phase."); + // The run itself refuses to start the native phase while libplctag's session is open; this pins it. + r.ConnectionsBeforeNative.ShouldNotBeNull(); + r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value); + } + + [Fact] + public async Task The_native_side_makes_exactly_one_forward_open_over_the_whole_run() + { + var r = await RunOrSkipAsync(); + if (!r.Target.CountOpens) + Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Open counter is only " + + "trusted when the differential runs alone."); + Log($"Native Forward Opens over the run: {r.NativeForwardOpens}."); + r.NativeForwardOpens.ShouldBe(1); + } + + /// + /// Offline guard — deliberately NOT gated on the endpoint, so it runs in every build. Fails if any + /// source file of this harness names a write-capable member, and, independently of the source text, + /// if any compiled method of a harness type calls one. + /// + [Fact] + public void The_harness_is_read_only_by_construction() + { + // Built by concatenation so this file's own text never matches itself. + string[] forbidden = + [ + "Write" + "Async", "Encode" + "Value", "WriteBit" + "InDInt", "Send" + "Async", "AbCip" + "Driver", + ]; + + var dir = Path.GetDirectoryName(ThisFile())!; + var sources = Directory.GetFiles(dir, "*.cs"); + sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs"); + var textHits = (from f in sources + let text = File.ReadAllText(f) + from token in forbidden + where text.Contains(token, StringComparison.Ordinal) + select $"{Path.GetFileName(f)} names {token}").ToList(); + textHits.ShouldBeEmpty(); + + var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes() + .Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList(); + harnessTypes.ShouldContain(typeof(TransportDifferentialTests)); + var callHits = (from t in harnessTypes + from m in MethodsOf(t) + from callee in CalleesOf(m) + where forbidden.Contains(callee.Name, StringComparer.Ordinal) + select $"{t.Name}.{m.Name} calls {callee.DeclaringType?.Name}.{callee.Name}").ToList(); + callHits.ShouldBeEmpty(); + } + + /// + /// Offline falsifiability check of the verdicts: a harness whose comparison could never say NO would + /// pass every live run vacuously. + /// + [Fact] + public void The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch() + { + var dint = new SelectedTag("T", AbCipDataType.DInt, 1, false, "scalar"); + var ok = new ReadObservation((int)AbStatus.Ok, null, [1, 0, 0, 0], 1); + Compare(dint, ok, ok with { }).ShouldBe("yes"); + Compare(dint, ok, ok with { Raw = [2, 0, 0, 0] }).ShouldBe("**NO: raw**"); + Compare(dint, ok, ok with { Raw = null }).ShouldBe("**NO: raw**"); + Compare(dint, ok, ok with { Decoded = 2 }).ShouldBe("**NO: value**"); + Compare(dint, ok, new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null)).ShouldBe("**NO: status**"); + Compare(dint, ok, ok with { Error = "boom" }).ShouldBe("**NO: error**"); + var notFound = new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null); + Compare(dint, notFound, notFound with { }).ShouldBe("yes"); + Compare(dint, ok, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null)) + .ShouldStartWith("expected"); + // Only that direction is excused: libplctag refusing what native accepts is a real divergence. + Compare(dint, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null), ok) + .ShouldBe("**NO: status**"); + + var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array"); + var arr = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { float.NaN, 1f }); + Compare(array, arr, arr with { Decoded = new[] { float.NaN, 1f } }).ShouldBe("yes"); + Compare(array, arr, arr with { Decoded = new[] { float.NaN, 2f } }).ShouldBe("**NO: value**"); + } + + private async Task RunOrSkipAsync() + { + if (DifferentialTarget.FromEnvironment() is null) Assert.Skip(DifferentialTarget.SkipReason); + return await fixture.GetAsync(); + } + + internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat) + { + // Spec §5: native refuses a returned type that disagrees with the authored one; libplctag decodes it anyway. + if (nat.Status == (int)AbStatus.TypeMismatch && lib.Status == (int)AbStatus.Ok) + return "expected (native TypeMismatch, libplctag Ok)"; + if (lib.Status != nat.Status) return "**NO: status**"; + if (!lib.Succeeded || !nat.Succeeded) + return lib.Succeeded == nat.Succeeded ? "yes" : "**NO: error**"; + if (lib.Raw is null || nat.Raw is null || !lib.Raw.AsSpan().SequenceEqual(nat.Raw)) return "**NO: raw**"; + if (tag.Type is not null && !DifferentialRun.ValuesEqual(lib.Decoded, nat.Decoded)) return "**NO: value**"; + return "yes"; + } + + private static string Detail(ReadObservation o) => + o.Error ?? $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}"; + + private static string Hex(byte[]? raw) => + raw is null ? "-" : Convert.ToHexString(raw.AsSpan(0, Math.Min(raw.Length, 32))) + (raw.Length > 32 ? "…" : ""); + + private static string Show(object? v) => v switch + { + null => "null", + Array a => "[" + string.Join(",", a.Cast().Take(8).Select(Show)) + (a.Length > 8 ? ",…" : "") + "]", + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => v.ToString() ?? "", + }; + + private static string Shape(TransportObservations o, uint id) => + o.TemplateErrors.TryGetValue(id, out var err) ? $"error {err}" + : o.Shapes.TryGetValue(id, out var s) ? s is null ? "undecodable" : $"{s.TypeName} ({s.Members.Count} members, {s.TotalSize} B)" + : "not read"; + + private static string StatusName(int status) => + Enum.IsDefined(typeof(AbStatus), status) ? ((AbStatus)status).ToString() : status.ToString(CultureInfo.InvariantCulture); + + private static string ErrorSuffix(string? error) => error is null ? "" : $" (error: {error})"; + + private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line); + + private static string ThisFile([CallerFilePath] string path = "") => path; + + private static IEnumerable MethodsOf(Type t) + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance + | BindingFlags.Static | BindingFlags.DeclaredOnly; + return t.GetMethods(all).Cast().Concat(t.GetConstructors(all)); + } + + /// + /// Every method a body's IL references by token (call / callvirt / newobj / ldftn). Scans every offset + /// for a MethodDef / MemberRef / MethodSpec token — over-inclusive by design: a misaligned hit that + /// happens to resolve can only add a false alarm, never hide a real call. + /// + private static IEnumerable CalleesOf(MethodBase m) + { + var il = m.GetMethodBody()?.GetILAsByteArray(); + if (il is null) yield break; + var typeArgs = m.DeclaringType is { IsGenericType: true } dt ? dt.GetGenericArguments() : null; + var methodArgs = m.IsGenericMethod ? m.GetGenericArguments() : null; + for (var i = 0; i + 4 <= il.Length; i++) + { + var token = BinaryPrimitives.ReadInt32LittleEndian(il.AsSpan(i, 4)); + if ((token >> 24) is not (0x06 or 0x0A or 0x2B)) continue; + MethodBase? callee; + try { callee = m.Module.ResolveMethod(token, typeArgs, methodArgs); } + catch (Exception) { continue; } + if (callee is not null) yield return callee; + } + } +}