using System.Net; 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; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; /// /// Offline pins for the differential's press-safety rules (Task 18 review r1). No controller is touched: /// the "endpoint" is a loopback listener that accepts and immediately hangs up. /// [Collection(DifferentialCollection.Name)] public sealed class DifferentialSafetyTests { [Fact] public async Task A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect() { await using var endpoint = HangUpListener.Start(); var target = Target(endpoint.Port); IReadOnlyList selection = [ new("A", AbCipDataType.DInt, 1, false, "scalar"), new("B", AbCipDataType.DInt, 1, false, "scalar"), new("C", AbCipDataType.DInt, 1, false, "scalar"), ]; var ex = await Should.ThrowAsync( () => DifferentialRun.RunNativePhaseAsync(target, selection, TestContext.Current.CancellationToken)); ex.Message.ShouldContain("native phase at browse"); DeviceSessionPool.Shared.Diagnostics.HasOpenAttemptLimit(Key(target)).ShouldBeFalse("the limit is lifted after the phase"); await ControlOpenAsync(target); endpoint.Connections.ShouldBe(2, "the phase's one connect, then the control's — no reconnect from the phase"); } /// /// Positive control (Gitea #583) for "no reconnect reached the listener": one more open to the same device, /// made after the phase, once its limit is lifted. It goes through the same per-device Forward Open throttle, /// whose waiters are served in arrival order, so by the time it has reached the listener (it is counted on /// accept, before the hang-up that fails it) any reconnect the phase had queued would already have got there /// — and, the limit being gone, nothing would have refused it. /// private static async Task ControlOpenAsync(DifferentialTarget target) { // A native tag initialisation (an existence read), like the harness's own: same pool, same device key. using var runtime = AbCipTransportFactories.For(AbCipTransport.Native).Tags .Create(target.Params("A", 1, false, AbCipDataType.DInt)); await Should.ThrowAsync(() => runtime.InitializeAsync(TestContext.Current.CancellationToken)); } private static DeviceKey Key(DifferentialTarget target) => NativeAbCipDevice.Key(target.Params("@tags")); [Fact] public async Task A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read() { await using var endpoint = HangUpListener.Start(); var target = Target(endpoint.Port); var ex = await Should.ThrowAsync( () => DifferentialRun.RunLibplctagPhaseAsync(target, TestContext.Current.CancellationToken)); ex.Message.ShouldContain("libplctag phase at"); await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); // libplctag's own session layer may redial inside its one anchor-create timeout; the harness cannot // reach below that. Recorded, and bounded: one anchor create, never one per tag. TestContext.Current.TestOutputHelper?.WriteLine($"libplctag connects before the abort: {endpoint.Connections}"); endpoint.Connections.ShouldBeInRange(1, 2); } [Fact] public void The_default_selection_keeps_arrays_and_udt_members_at_any_cap() { var browsed = new TransportObservations(); for (var i = 0; i < 300; i++) browsed.Browse.Add(new AbCipDiscoveredTag($"S{i}", null, AbCipDataType.DInt, false)); for (var i = 0; i < 30; i++) browsed.Browse.Add(new AbCipDiscoveredTag($"A{i}", null, AbCipDataType.Real, false, ElementCount: 4, IsArray: true)); browsed.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7)); browsed.Shapes[7] = new AbCipUdtShape("Udt", 400, [.. Enumerable.Range(0, 25).Select(i => new AbCipUdtMember($"M{i}", i * 4, AbCipDataType.DInt, 1))]); var at200 = DifferentialRun.Select(Target(1) with { MaxTags = 200 }, browsed); at200.Count.ShouldBe(200); at200.Count(t => t.Origin == "array").ShouldBe(20); at200.Count(t => t.Origin == "udt member").ShouldBe(20); at200.Count(t => t.Origin == "scalar").ShouldBe(160); var at25 = DifferentialRun.Select(Target(1) with { MaxTags = 25 }, browsed); at25.Count.ShouldBe(25); at25.ShouldContain(t => t.Origin == "array"); at25.ShouldContain(t => t.Origin == "udt member"); at25.ShouldContain(t => t.Origin == "scalar"); DifferentialRun.SelectionCoverageProblems(Target(1) with { MaxTags = 25 }, browsed, at25).ShouldBeEmpty(); // The coverage check is not vacuous: a selection of scalars only is flagged for both kinds. DifferentialRun.SelectionCoverageProblems(Target(1), browsed, [.. at200.Where(t => t.Origin == "scalar")]) .Count.ShouldBe(2); } [Fact] public void Template_parity_fails_when_vacuous_or_undecodable() { var lib = new TransportObservations(); var native = new TransportObservations(); lib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7)); DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("vacuous"); lib.Shapes[7] = null; native.Shapes[7] = null; DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("undecodable"); var shape = new AbCipUdtShape("Udt", 4, [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)]); lib.Shapes[7] = shape; native.Shapes[7] = shape with { Members = [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)] }; DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty(); } [Fact] public void A_template_both_transports_refuse_with_the_same_status_is_an_equal_refusal_not_a_problem() { var shape = new AbCipUdtShape("Udt", 4, [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)]); var lib = new TransportObservations(); var native = new TransportObservations(); lib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7)); lib.Browse.Add(new AbCipDiscoveredTag("V", null, AbCipDataType.Structure, false, TemplateInstanceId: 8)); // 8 decodes on both sides (a real comparison, so the run is not vacuous); 7 is refused by both. lib.Shapes[8] = shape; native.Shapes[8] = shape; lib.TemplateErrors[7] = "NotFound: general 0x05 (libplctag wording)"; native.TemplateErrors[7] = "NotFound: Read Template 7 failed: general 0x05 (native wording)"; lib.TemplateErrorStatuses[7] = AbStatus.NotFound; native.TemplateErrorStatuses[7] = AbStatus.NotFound; DifferentialRun.EqualRefusals(lib, native).ShouldBe(new uint[] { 7 }); DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty(); // Positive control: the same ids with DIFFERENT statuses are still a problem, naming both statuses. native.TemplateErrorStatuses[7] = AbStatus.NotAllowed; DifferentialRun.EqualRefusals(lib, native).ShouldBeEmpty(); var differ = DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem(); differ.ShouldContain("template 7"); differ.ShouldContain("different statuses"); // One side refusing while the other decoded is a problem too. native.TemplateErrors.Remove(7); native.TemplateErrorStatuses.Remove(7); native.Shapes[7] = shape; DifferentialRun.EqualRefusals(lib, native).ShouldBeEmpty(); DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("template 7"); // A run whose only templates are equal refusals compared no shape: still vacuous, not silently green. var onlyLib = new TransportObservations(); var onlyNative = new TransportObservations(); onlyLib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7)); onlyLib.TemplateErrorStatuses[7] = onlyNative.TemplateErrorStatuses[7] = AbStatus.NotFound; onlyLib.TemplateErrors[7] = onlyNative.TemplateErrors[7] = "NotFound"; DifferentialRun.TemplateProblems(onlyLib, onlyNative).ShouldHaveSingleItem().ShouldContain("vacuous"); } [Fact] public async Task The_open_attempt_budget_alone_stops_a_second_open_even_when_nothing_aborts_the_phase() { // Unlike the abort test above, the body here SWALLOWS every failure and keeps going, so the harness's // abort-on-first-failure can never end the phase: only the per-device open-attempt limit stands between // the second request and the wire. await using var endpoint = HangUpListener.Start(); var target = Target(endpoint.Port); var statuses = new List(); string? secondMessage = null; var attemptsBefore = DeviceSessionPool.Shared.Diagnostics.OpenAttempts; var tags = AbCipTransportFactories.For(AbCipTransport.Native).Tags; await DifferentialRun.WithNativeAnchorAsync("native", target, async _ => { // Two independent tag initialisations, each of which needs the connection open. Failures are // swallowed here on purpose (the real harness would abort on the first). for (var i = 0; i < 2; i++) { using var runtime = tags.Create(target.Params("A", 1, false, AbCipDataType.DInt)); try { await runtime.InitializeAsync(TestContext.Current.CancellationToken); } catch (AbTransportException ex) { statuses.Add(ex.Status); if (i == 1) secondMessage = ex.Message; } } return true; }); statuses.Count.ShouldBe(2, "both requests failed; neither aborted the phase"); statuses[1].ShouldBe(AbStatus.Abort); secondMessage.ShouldNotBeNull().ShouldContain("budget"); // The pool is process-wide, so another test's opens could move this counter; at least these two are in it. (DeviceSessionPool.Shared.Diagnostics.OpenAttempts - attemptsBefore).ShouldBeGreaterThanOrEqualTo(2, "the second attempt was counted, then refused"); DeviceSessionPool.Shared.Diagnostics.HasOpenAttemptLimit(Key(target)).ShouldBeFalse("the limit is lifted afterwards"); await ControlOpenAsync(target); endpoint.Connections.ShouldBe(2, "exactly the one budgeted open attempt reached the wire, then the control's"); } [Fact] public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping() { var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar, DifferentialTarget.LatencyTagsVar, DifferentialTarget.LatencyPassesVar, DifferentialTarget.LatencyPassGapVar); try { Environment.SetEnvironmentVariable(DifferentialTarget.EndpointVar, "127.0.0.1:1"); Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "501"); Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTagsVar); Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "500"); Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, "9999"); Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTemplatesVar); Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, null); Environment.SetEnvironmentVariable(DifferentialTarget.PaceVar, null); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, null); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, null); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, null); var t =DifferentialTarget.FromEnvironment()!; t.MaxTags.ShouldBe(500); t.MaxTemplates.ShouldBe(DifferentialTarget.DefaultMaxTemplates); t.PaceMs.ShouldBe(DifferentialTarget.DefaultPaceMs); t.LatencyTags.ShouldBe(100); t.LatencyPasses.ShouldBe(30); t.LatencyPassGapMs.ShouldBe(200); // Latency knobs (Task 21): the same fail-fast rule, with tighter ceilings. Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "201"); Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyTagsVar); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "200"); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "51"); Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassesVar); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "50"); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "10001"); Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassGapVar); Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "0"); var lat = DifferentialTarget.FromEnvironment()!; (lat.LatencyTags, lat.LatencyPasses, lat.LatencyPassGapMs).ShouldBe((200, 50, 0)); } finally { foreach (var (k, v) in saved) Environment.SetEnvironmentVariable(k, v); } } private static DifferentialTarget Target(int port) => new("127.0.0.1", port, "1,0", AbCipPlcFamily.CompactLogix, null, 200, CountOpens: false, MaxTemplates: 50, PaceMs: 0); private static (string, string?)[] Snapshot(params string[] names) => [.. names.Select(n => (n, Environment.GetEnvironmentVariable(n)))]; /// Accepts every connection and closes it at once, counting them. private sealed class HangUpListener : IAsyncDisposable { private readonly TcpListener _listener = new(IPAddress.Loopback, 0); private readonly CancellationTokenSource _stop = new(); private readonly Task _loop; private int _connections; private HangUpListener() { _listener.Start(); _loop = Task.Run(AcceptLoopAsync); } public static HangUpListener Start() => new(); public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; public int Connections => Volatile.Read(ref _connections); private async Task AcceptLoopAsync() { try { while (!_stop.IsCancellationRequested) { using var socket = await _listener.AcceptSocketAsync(_stop.Token); Interlocked.Increment(ref _connections); socket.Close(); } } catch (OperationCanceledException) { } catch (SocketException) { } catch (ObjectDisposedException) { } } public async ValueTask DisposeAsync() { await _stop.CancelAsync(); _listener.Stop(); try { await _loop; } catch { /* stopped */ } _stop.Dispose(); } } }