3edff3304b
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test. - LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload. - A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3 connection, TCP still up) retires the session at once; the next send reopens through the throttle. - New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device limit scoping, CIP-close recycle (request + keep-alive + negative control). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
303 lines
16 KiB
C#
303 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<SelectedTag> 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<DifferentialAbortedException>(
|
|
() => 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static async Task ControlOpenAsync(DifferentialTarget target)
|
|
{
|
|
var p = target.Params("@tags");
|
|
await using var lease = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
|
|
await Should.ThrowAsync<AbTransportException>(
|
|
() => lease.Scheduler.SendAsync([0x4C, 0x00], 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<DifferentialAbortedException>(
|
|
() => 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<AbStatus>();
|
|
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<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTagsVar);
|
|
|
|
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "500");
|
|
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, "9999");
|
|
Should.Throw<InvalidOperationException>(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<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyTagsVar);
|
|
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "200");
|
|
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "51");
|
|
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassesVar);
|
|
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "50");
|
|
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "10001");
|
|
Should.Throw<InvalidOperationException>(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)))];
|
|
|
|
/// <summary>Accepts every connection and closes it at once, counting them.</summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|