using System.Diagnostics; 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 timed burst: every tag read once, concurrently. /// Native only: delta over the pass (keep-alives included). /// Reads that ended with a tag-level status (a connection-level one aborts instead). /// Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here). /// Native only: round trip of each CIP exchange in the pass, from (a slow /// controller shows here; a slow pass with fast exchanges points at the client). internal sealed record LatencyPass( int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, ConnectionDiagnostics.ExchangeStats? Rtt = null); /// GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports). internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2) { public static GcDelta Now() => new(GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2)); public static GcDelta operator -(GcDelta a, GcDelta b) => new(a.Gen0 - b.Gen0, a.Gen1 - b.Gen1, a.Gen2 - b.Gen2); public override string ToString() => $"{Gen0}/{Gen1}/{Gen2}"; } /// The outcome of the latency comparison. internal sealed class LatencyResult { public required DifferentialTarget Target { get; init; } /// The fixed set read every pass, in order (a name appears more than once only when ). public required IReadOnlyList Tags { get; init; } public required int DistinctTags { get; init; } public required bool Repeated { get; init; } /// Names libplctag could not read at setup (tag-level status): dropped from BOTH transports' sets. public required IReadOnlyList Dropped { get; init; } public required IReadOnlyList Libplctag { get; init; } public required IReadOnlyList Native { get; init; } public required int NativeForwardOpens { get; init; } /// Forward Closes the native phase sent / got a reply to, its anchor release included (review r1 M9). public required NativeCloses NativeForwardCloses { get; init; } /// TCP connections to the endpoint once the native phase's session was released and drained. public required int? ConnectionsAfterNative { get; init; } /// Native requests spent creating the runtimes (one read each, before any timed pass). public required int NativeSetupRequests { get; init; } public required List Problems { get; init; } } /// /// Task 21's poll-latency comparison, read-only and built on the differential's machinery: the same /// endpoint parsing, the same anchors (one libplctag handle, one native lease), the same open-attempt budget, /// the same connection barrier and the same stop-on-first-connection-failure rule. /// Order: the differential's run (its browse supplies the tags — no second listing), a barrier until its /// native session is gone, every libplctag pass with every handle then disposed, a barrier until libplctag's /// session is gone, then every native pass under one anchor lease. The two transports never overlap. /// Per transport: create and read every tag once (untimed, paced by AB_DIFF_PACE_MS), then /// AB_LAT_PASSES timed passes, each issuing all AB_LAT_TAGS reads at once (Task.WhenAll) /// so the native shared scheduler can pack them into Multiple Service Packets as libplctag packs internally, /// with AB_LAT_PASS_GAP_MS of quiet between passes. /// internal static class PollLatencyRun { private static readonly HashSet Atomic = [ AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int, AbCipDataType.DInt, AbCipDataType.LInt, AbCipDataType.USInt, AbCipDataType.UInt, AbCipDataType.UDInt, AbCipDataType.ULInt, AbCipDataType.Real, AbCipDataType.LReal, ]; /// /// The fixed tag set: atomic, non-array scalars, from the differential's libplctag browse — or, when /// AB_DIFF_TAGS is set, those of its names whose browsed type is an atomic scalar. When fewer /// distinct tags exist than AB_LAT_TAGS, the set is REPEATED cyclically up to the count (each /// repeat is its own runtime/handle on the same name) and reported as such — never silently. /// internal static (IReadOnlyList Tags, int Distinct, bool Repeated) PickTags( DifferentialTarget target, TransportObservations browsed) { var candidates = DifferentialRun.Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed) .Where(t => !t.IsArray && t.Type is { } type && Atomic.Contains(type)) .DistinctBy(t => t.Name) .Take(target.LatencyTags) .ToList(); if (candidates.Count == 0 || candidates.Count >= target.LatencyTags) return (candidates, candidates.Count, false); var repeated = Enumerable.Range(0, target.LatencyTags).Select(i => candidates[i % candidates.Count]).ToList(); return (repeated, candidates.Count, true); } public static async Task ExecuteAsync(DifferentialTarget target, DifferentialResult differential, CancellationToken ct) { var (picked, distinct, repeated) = PickTags(target, differential.Libplctag); if (picked.Count == 0) throw new InvalidOperationException("The browse offered no atomic scalar tags to time; nothing was read."); // The differential's native session must be gone before libplctag connects. await DifferentialRun.WaitForSessionsToCloseAsync( target, differential.Addresses, differential.BaselineEndpoints, "the differential's native", ct).ConfigureAwait(false); // ---- libplctag: setup, passes, every handle disposed (anchor last). ---- var dropped = new List(); var libFactory = AbCipTransportFactories.For(AbCipTransport.Libplctag).Tags; // Anchor on a tag the differential already read Ok through libplctag, so the anchor is never the refusal. var anchorTag = picked.FirstOrDefault(t => differential.Libplctag.Reads.TryGetValue(t.Name, out var o) && o.Succeeded) ?? picked[0]; var (libPasses, tags) = await DifferentialRun.WithLibplctagAnchorAsync("latency libplctag", target, anchorTag.Name, async () => { var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>(); try { foreach (var tag in picked) { var rt = await CreateAsync("latency libplctag", target, libFactory, tag, ct).ConfigureAwait(false); if (rt is null) { dropped.Add(tag.Name); continue; } runtimes.Add((tag, rt)); } var passes = await RunPassesAsync("latency libplctag", target, runtimes, countRequests: false, afterEach: null, ct) .ConfigureAwait(false); return (passes, (IReadOnlyList)runtimes.Select(r => r.Tag).ToList()); } finally { foreach (var (_, rt) in runtimes) rt.Dispose(); } }, ct).ConfigureAwait(false); // ---- Barrier: libplctag's session must be gone before the native side connects. ---- await DifferentialRun.WaitForSessionsToCloseAsync( target, differential.Addresses, differential.BaselineEndpoints, "libplctag", ct).ConfigureAwait(false); // ---- native: the same (post-drop) set, under one anchor lease. ---- var problems = new List(); var nativeFactory = AbCipTransportFactories.For(AbCipTransport.Native).Tags; var ((nativePasses, setupRequests), opens, closes) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens => { var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>(); try { var before = DeviceSessionPool.Shared.Diagnostics.RequestsSent; foreach (var tag in tags) { var rt = await CreateAsync("latency native", target, nativeFactory, tag, ct).ConfigureAwait(false); checkOpens($"setup of '{tag.Name}'"); if (rt is null) { problems.Add($"native could not read '{tag.Name}', which libplctag read"); continue; } runtimes.Add((tag, rt)); } var setup = DeviceSessionPool.Shared.Diagnostics.RequestsSent - before; var passes = await RunPassesAsync("latency native", target, runtimes, countRequests: true, checkOpens, ct) .ConfigureAwait(false); return (passes, setup); } finally { foreach (var (_, rt) in runtimes) rt.Dispose(); } }).ConfigureAwait(false); // ---- Barrier: the native session must be gone before the run reports (review r1 M9). ---- var afterNative = await DifferentialRun.WaitForSessionsToCloseAsync( target, differential.Addresses, differential.BaselineEndpoints, "latency native", ct).ConfigureAwait(false); return new LatencyResult { Target = target, Tags = tags, DistinctTags = distinct, Repeated = repeated, Dropped = dropped, Libplctag = libPasses, Native = nativePasses, NativeForwardOpens = opens, NativeForwardCloses = closes, ConnectionsAfterNative = afterNative, NativeSetupRequests = setupRequests, Problems = problems, }; } /// Create + first read of one tag. Null on a tag-level refusal; a connection-level failure aborts. private static async Task CreateAsync( string phase, DifferentialTarget target, IAbCipTagFactory factory, SelectedTag tag, CancellationToken ct) { var rt = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type)); try { await DifferentialRun.PaceAsync(target, ct).ConfigureAwait(false); await rt.InitializeAsync(ct).ConfigureAwait(false); if (rt.GetStatus() == (int)AbStatus.Ok) return rt; } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { if (DifferentialRun.IsConnectionLevel(ex)) { rt.Dispose(); throw DifferentialRun.Abort(phase, $"setup of '{tag.Name}'", ex); } } catch { rt.Dispose(); throw; } rt.Dispose(); return null; } private static async Task> RunPassesAsync( string phase, DifferentialTarget target, List<(SelectedTag Tag, IAbCipTagRuntime Runtime)> runtimes, bool countRequests, Action? afterEach, CancellationToken ct) { var passes = new List(target.LatencyPasses); for (var p = 1; p <= target.LatencyPasses; p++) { if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false); var requestsBefore = DeviceSessionPool.Shared.Diagnostics.RequestsSent; DeviceSessionPool.Shared.Diagnostics.ResetExchangeStats(); var gcBefore = GcDelta.Now(); var clock = Stopwatch.StartNew(); var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false); clock.Stop(); var gc = GcDelta.Now() - gcBefore; var rtt = countRequests ? DeviceSessionPool.Shared.Diagnostics.ReadExchangeStats() : (ConnectionDiagnostics.ExchangeStats?)null; var requests = DeviceSessionPool.Shared.Diagnostics.RequestsSent - requestsBefore; var failures = 0; for (var i = 0; i < outcomes.Length; i++) { if (outcomes[i] is { } ex) { if (DifferentialRun.IsConnectionLevel(ex)) throw DifferentialRun.Abort(phase, $"pass {p}, tag '{runtimes[i].Tag.Name}'", ex); failures++; } else if (runtimes[i].Runtime.GetStatus() != (int)AbStatus.Ok) { failures++; } } afterEach?.Invoke($"pass {p}"); passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures, gc, rtt)); } return passes; } private static async Task ReadOnceAsync(IAbCipTagRuntime runtime, CancellationToken ct) { try { await runtime.ReadAsync(ct).ConfigureAwait(false); return null; } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { return ex; } } /// Nearest-rank percentile (p in (0,100]) of the pass wall times, in milliseconds. internal static double Percentile(IEnumerable walls, double p) { var sorted = walls.Select(w => w.TotalMilliseconds).Order().ToList(); if (sorted.Count == 0) throw new InvalidOperationException("No passes to take a percentile of."); var rank = (int)Math.Ceiling(p / 100.0 * sorted.Count); return sorted[Math.Clamp(rank, 1, sorted.Count) - 1]; } }