using System.Buffers.Binary; using System.Diagnostics; using System.Globalization; using System.Net.Sockets; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix; using ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate; /// /// Gitea #566 hardware evidence (hw5): what a real Logix does with a class-3 connection that has been left idle /// past its connection timeout. READ-ONLY: three Read Tag requests of one tag on one private pool, nothing else. /// /// Opt-in: AB_DIFF_ENDPOINT (the target, see ) AND /// AB_EVID_IDLE=1. /// AB_EVID_IDLE_TAG — the tag read, default test2 (a DINT). /// AB_EVID_IDLE_SECONDS — idle time, default 60, in [1, 300]. It must exceed the expiry the /// controller grants (logged from the Forward Open reply), or the read just succeeds. /// /// The keep-alive is pushed out to one hour on this private pool so nothing touches the connection while it is /// idle (in production it fires every half expiry, which is why this case only arises after a controller /// restart / re-download or a network gap). The open-attempt budget is 2: the first open and one reconnect. /// [Trait("Category", "LiveIntegration")] public sealed class IdleConnectionEvidenceTests { private const string DefaultTag = "test2"; [Fact] public async Task An_idle_connection_past_its_timeout_reports_what_the_controller_does_and_recovers() { var target = DifferentialTarget.FromEnvironment(); if (target is null) Assert.Skip(DifferentialTarget.SkipReason); if (Environment.GetEnvironmentVariable("AB_EVID_IDLE") != "1") Assert.Skip("AB_EVID_IDLE=1 not set."); var tag = Environment.GetEnvironmentVariable("AB_EVID_IDLE_TAG") is { Length: > 0 } t ? t.Trim() : DefaultTag; var idleSeconds = 60; if (Environment.GetEnvironmentVariable("AB_EVID_IDLE_SECONDS") is { Length: > 0 } rawIdle && (!int.TryParse(rawIdle, NumberStyles.None, CultureInfo.InvariantCulture, out idleSeconds) || idleSeconds is < 1 or > 300)) throw new InvalidOperationException($"AB_EVID_IDLE_SECONDS='{rawIdle}' must be an integer in [1, 300]."); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(idleSeconds + 120)); var ct = cts.Token; var pool = new DeviceSessionPool(); var diagnostics = pool.Diagnostics; var p = target.Params(tag); var key = NativeAbCipDevice.Key(p); var options = NativeAbCipDevice.Options(p) with { KeepAliveInterval = TimeSpan.FromHours(1) }; var clock = Stopwatch.StartNew(); var log = new List(); void Note(string line) { var stamped = $"[{clock.Elapsed.TotalSeconds,7:F2} s] {line}"; log.Add(stamped); TestContext.Current.TestOutputHelper?.WriteLine(stamped); } diagnostics.ExchangeObserver = (req, rep) => Note(DescribeExchange(req.Span, rep.Span)); using var budget = diagnostics.LimitOpenAttempts(key, 2); var request = LogixServices.ReadTag(CipPath.EncodeSymbolic(tag), 1); Note($"Target {target}; tag '{tag}'; requested RPI {options.RpiMicros} us both ways, timeout multiplier " + $"{options.TimeoutMultiplier} (x{4 << options.TimeoutMultiplier}) => expiry {options.ConnectionExpiry.TotalSeconds:F1} s; " + $"keep-alive pushed to {options.KeepAliveInterval}; idle {idleSeconds} s"); var lease = pool.Acquire(key, options); try { var first = await ReadAsync("read 1 (opens)", lease.Channel, request, Note, ct); first.ShouldBeTrue("the first read must succeed, or there is no connection to idle"); Note($"Forward Opens {diagnostics.ForwardOpens}; now idle for {idleSeconds} s with no traffic"); await Task.Delay(TimeSpan.FromSeconds(idleSeconds), ct); var opensBefore = diagnostics.ForwardOpens; var second = await ReadAsync("read 2 (after idle)", lease.Channel, request, Note, ct); Note($"After read 2: Forward Opens {diagnostics.ForwardOpens} (delta {diagnostics.ForwardOpens - opensBefore}), " + $"closed-by-target {diagnostics.ConnectionsClosedByTarget}, success={second}"); await Task.Delay(TimeSpan.FromSeconds(2), ct); // past the 1 s Forward Open interval var third = await ReadAsync("read 3 (recovery)", lease.Channel, request, Note, ct); Note($"After read 3: Forward Opens {diagnostics.ForwardOpens}, closed-by-target " + $"{diagnostics.ConnectionsClosedByTarget}, open attempts used {budget.Attempts}/{budget.Budget}, success={third}"); third.ShouldBeTrue("the transport must recover on the request after the failed one"); } finally { await lease.DisposeAsync(); diagnostics.ExchangeObserver = null; Note($"Lease released: Forward Closes sent/acked {diagnostics.ForwardClosesSent}/{diagnostics.ForwardClosesAcked}"); } } private static async Task ReadAsync(string label, ICipChannel channel, byte[] request, Action note, CancellationToken ct) { var sw = Stopwatch.StartNew(); try { var reply = CipReply.Parse(await channel.SendAsync(request, ct)); note($"{label}: reply in {sw.ElapsedMilliseconds} ms, general 0x{reply.GeneralStatus:X2}" + $"{(reply.ExtendedStatus.Length > 0 ? $" extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}" : "")}"); return reply.GeneralStatus == 0; } catch (AbTransportException ex) { note($"{label}: THREW after {sw.ElapsedMilliseconds} ms: AbTransportException status={ex.Status} " + $"(OPC UA 0x{AbOpcUaStatusMap.MapStatus(ex.Status):X8}) message='{ex.Message}' inner={Inner(ex)}"); return false; } } private static string Inner(Exception ex) { var parts = new List(); for (var e = ex.InnerException; e is not null; e = e.InnerException) parts.Add(e is SocketException se ? $"SocketException({se.SocketErrorCode})" : $"{e.GetType().Name}('{e.Message}')"); return parts.Count == 0 ? "none" : string.Join(" <- ", parts); } private static string DescribeExchange(ReadOnlySpan request, ReadOnlySpan reply) { var service = request.Length > 0 ? request[0] : (byte)0; var hex = Convert.ToHexString(reply[..Math.Min(reply.Length, 48)]); if (service is 0x54 or 0x5B && reply.Length >= 4 && reply[2] == 0) { // Forward Open reply data: O->T id, T->O id, serial, vendor, originator serial, O->T API, T->O API. var d = reply[(4 + 2 * reply[3])..]; if (d.Length >= 24) return $"Forward Open 0x{service:X2} granted: O->T id 0x{BinaryPrimitives.ReadUInt32LittleEndian(d):X8}, " + $"O->T API {BinaryPrimitives.ReadUInt32LittleEndian(d[16..])} us, T->O API {BinaryPrimitives.ReadUInt32LittleEndian(d[20..])} us; raw {hex}"; } return $"exchange: request service 0x{service:X2} ({request.Length} B) -> reply {reply.Length} B: {hex}"; } }