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];
}
}