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
601 lines
29 KiB
C#
601 lines
29 KiB
C#
using System.Buffers.Binary;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
|
|
|
|
/// <summary>Serializes the loopback-socket test classes. Their diagnostic counters are per connection / per
|
|
/// pool since Gitea #581 (no longer process-wide statics), but <see cref="DeviceSessionPoolTests"/> still
|
|
/// reconfigures <see cref="DeviceSessionPool.Shared"/>'s logger, and the timing-sensitive storm test is kept
|
|
/// off a thread pool its neighbours are saturating.</summary>
|
|
[CollectionDefinition(Name, DisableParallelization = true)]
|
|
public sealed class CipConnectionStatics
|
|
{
|
|
public const string Name = "CipConnection statics";
|
|
}
|
|
|
|
/// <summary><see cref="CipConnection"/> against <see cref="FakeCipTarget"/>. Wire I/O is real loopback;
|
|
/// the throttle, deadlines and keep-alive run on <see cref="ManualTimeProvider"/>.</summary>
|
|
[Collection(CipConnectionStatics.Name)]
|
|
public sealed class CipConnectionTests
|
|
{
|
|
private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10);
|
|
private static readonly byte[] ReadTag = [0x4C, 0x02, 0x91, 0x03, 0x54, 0x61, 0x67, 0x00, 0x01, 0x00];
|
|
private static CancellationToken TestCt => TestContext.Current.CancellationToken;
|
|
|
|
private static CipConnection Create(FakeCipTarget target, ConnectionOptions o, ManualTimeProvider time, string path = "1,0") =>
|
|
new(new DeviceKey("127.0.0.1", target.Port, path, "ControlLogix"), o,
|
|
new ForwardOpenThrottle(o.MinForwardOpenInterval, time), time);
|
|
|
|
private static ConnectionOptions Options(CipRouting routing = CipRouting.Connected, int size = 4002, double timeoutSeconds = 2) =>
|
|
new() { Routing = routing, ConnectionSize = size, Timeout = TimeSpan.FromSeconds(timeoutSeconds) };
|
|
|
|
private static ushort RegularOTParams(byte[] fo) => BinaryPrimitives.ReadUInt16LittleEndian(fo.AsSpan(32));
|
|
|
|
// ---- options ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Defaults_match_the_press_safety_values()
|
|
{
|
|
var o = new ConnectionOptions();
|
|
o.MinForwardOpenInterval.ShouldBe(TimeSpan.FromSeconds(1));
|
|
o.RpiMicros.ShouldBe(2_000_000u);
|
|
o.TimeoutMultiplier.ShouldBe((byte)1);
|
|
o.ConnectionExpiry.ShouldBe(TimeSpan.FromSeconds(16));
|
|
o.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(8));
|
|
o.VendorId.ShouldBe((ushort)0x1337);
|
|
new ConnectionOptions().OriginatorSerial.ShouldBe(o.OriginatorSerial); // per process, not per instance
|
|
}
|
|
|
|
public static TheoryData<TimeSpan> UnusableTimeouts => new() { Timeout.InfiniteTimeSpan, TimeSpan.Zero, TimeSpan.FromSeconds(-1) };
|
|
|
|
[Theory]
|
|
[MemberData(nameof(UnusableTimeouts))]
|
|
public void Options_reject_a_non_finite_or_non_positive_timeout(TimeSpan timeout) =>
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new ConnectionOptions { Timeout = timeout });
|
|
|
|
[Fact]
|
|
public void Options_reject_a_forward_open_interval_below_one_second() =>
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new ConnectionOptions { MinForwardOpenInterval = TimeSpan.FromMilliseconds(999) });
|
|
|
|
// ---- Large Forward Open fallback -------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Large_open_service_not_supported_falls_back_to_regular_open_once()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x08)(fo) : FakeCipTarget.Accept(fo);
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
var before = conn.Diagnostics.ForwardOpens;
|
|
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
var fos = target.ForwardOpenRequests;
|
|
fos.Count.ShouldBe(2);
|
|
fos[0][0].ShouldBe((byte)0x5B);
|
|
fos[1][0].ShouldBe((byte)0x54);
|
|
RegularOTParams(fos[1]).ShouldBe((ushort)(0x4200 | 504));
|
|
conn.MaxRequestBytes.ShouldBe(502);
|
|
(conn.Diagnostics.ForwardOpens - before).ShouldBe(2);
|
|
target.ConnectedRequests.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Large_open_bad_size_falls_back_to_regular_open_once()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x01, 0x0109)(fo) : FakeCipTarget.Accept(fo);
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
target.ForwardOpenRequests.Select(f => f[0]).ShouldBe(new byte[] { 0x5B, 0x54 });
|
|
RegularOTParams(target.ForwardOpenRequests[1]).ShouldBe((ushort)(0x4200 | 504));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_refused_fallback_is_not_retried_again()
|
|
{
|
|
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x08) };
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
|
|
await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
|
|
|
target.ForwardOpens.ShouldBe(2); // Large, then exactly one regular
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_spent_open_attempt_budget_refuses_the_reconnect_before_any_io()
|
|
{
|
|
// Task 18 review r1 (C1): the differential harness caps the press at one open attempt, so a press
|
|
// that refuses the Forward Open sees one TCP connect and one Forward Open, never one per remaining tag.
|
|
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) };
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(size: 504), time);
|
|
using (conn.Diagnostics.LimitOpenAttempts(Key(target), 1))
|
|
{
|
|
await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
|
var retry = conn.SendAsync(ReadTag, TestCt);
|
|
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // parked in the throttle
|
|
time.Advance(TimeSpan.FromSeconds(1));
|
|
var refused = await Should.ThrowAsync<AbTransportException>(() => retry.WaitAsync(Guard, TestCt));
|
|
|
|
refused.Status.ShouldBe(AbStatus.Abort);
|
|
refused.Message.ShouldContain("budget");
|
|
target.Server.Connections.ShouldBe(1);
|
|
target.ForwardOpens.ShouldBe(1);
|
|
conn.Diagnostics.OpenAttempts.ShouldBe(2); // the refused one is counted too
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_open_attempt_limit_is_scoped_to_one_device_and_lifted_by_its_scope()
|
|
{
|
|
// Gitea #566 / #581: the limit is per DeviceKey on one pool's diagnostics, not a process-wide static, so
|
|
// limiting the press under test can never refuse an open to any other device.
|
|
await using var limited = new FakeCipTarget();
|
|
await using var other = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
var pool = new DeviceSessionPool(time);
|
|
var limitedKey = Key(limited);
|
|
|
|
using (pool.Diagnostics.LimitOpenAttempts(limitedKey, 0))
|
|
{
|
|
await using var otherLease = pool.Acquire(Key(other), Options());
|
|
(await otherLease.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
|
|
|
await using var limitedLease = pool.Acquire(limitedKey, Options());
|
|
var refused = await Should.ThrowAsync<AbTransportException>(() => limitedLease.Scheduler.SendAsync(ReadTag, TestCt));
|
|
refused.Status.ShouldBe(AbStatus.Abort);
|
|
refused.Message.ShouldContain("budget");
|
|
Should.Throw<InvalidOperationException>(() => pool.Diagnostics.LimitOpenAttempts(limitedKey, 5)); // one at a time
|
|
}
|
|
|
|
pool.Diagnostics.HasOpenAttemptLimit(limitedKey).ShouldBeFalse();
|
|
time.Advance(TimeSpan.FromSeconds(5)); // the refused attempt still started the device's throttle interval
|
|
await using var lifted = pool.Acquire(limitedKey, Options());
|
|
(await lifted.Scheduler.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
|
limited.ForwardOpens.ShouldBe(1); // the refused attempt never reached the wire; the lifted one did
|
|
other.ForwardOpens.ShouldBe(1);
|
|
}
|
|
|
|
private static DeviceKey Key(FakeCipTarget target) => new("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
|
|
|
[Fact]
|
|
public async Task Connection_in_use_does_not_fall_back()
|
|
{
|
|
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) };
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
|
|
var ex = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
|
|
|
ex.Message.ShouldContain("Forward Open refused");
|
|
target.ForwardOpens.ShouldBe(1);
|
|
target.ConnectedRequests.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_small_connection_size_uses_a_regular_open()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(size: 504), time);
|
|
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
target.ForwardOpenRequests.Single()[0].ShouldBe((byte)0x54);
|
|
conn.MaxRequestBytes.ShouldBe(502);
|
|
}
|
|
|
|
// ---- routing (B3) ----------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Direct_routing_sends_bare_cip_even_with_a_path()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(CipRouting.Direct), time, path: "1,0");
|
|
|
|
var reply = await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
CipReply.Parse(reply).GeneralStatus.ShouldBe((byte)0);
|
|
target.DirectCips.Single().ShouldBe(ReadTag);
|
|
target.ForwardOpens.ShouldBe(0);
|
|
target.UnconnectedSends.ShouldBe(0);
|
|
target.ConnectedRequests.ShouldBe(0);
|
|
conn.MaxRequestBytes.ShouldBe(504);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnconnectedSend_routing_wraps_in_0x52()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(CipRouting.UnconnectedSend), time, path: "1,0");
|
|
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
target.UnconnectedEmbedded.Single().ShouldBe(ReadTag);
|
|
var raw = FakeEipServer.RequestCip(target.Server.Received.Last(f => FakeEipServer.Command(f) == 0x006F));
|
|
raw.ShouldBe(ConnectionManagerServices.UnconnectedSend(ReadTag, new byte[] { 0x01, 0x00 }));
|
|
target.ForwardOpens.ShouldBe(0);
|
|
target.DirectRequests.ShouldBe(0);
|
|
conn.MaxRequestBytes.ShouldBe(504 - (12 + 2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connected_routing_never_sends_bare()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
|
|
for (var i = 0; i < 3; i++) await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
target.ConnectedCips.Count.ShouldBe(3);
|
|
target.ConnectedCips.ShouldAllBe(c => c.SequenceEqual(ReadTag));
|
|
target.ForwardOpens.ShouldBe(1);
|
|
target.DirectRequests.ShouldBe(0);
|
|
target.UnconnectedSends.ShouldBe(0);
|
|
conn.MaxRequestBytes.ShouldBe(4000);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connected_routing_with_an_empty_route_opens_straight_to_the_message_router()
|
|
{
|
|
// R9-1: Micro800 is Connected with an empty route; libplctag's Forward Open path is just 20 02 24 01.
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(size: 504), time, path: "");
|
|
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
var fo = target.ForwardOpenRequests.Single();
|
|
fo[^6..].ShouldBe(new byte[] { 0xA3, 0x02, 0x20, 0x02, 0x24, 0x01 });
|
|
target.ConnectedRequests.ShouldBe(1);
|
|
}
|
|
|
|
// ---- reconnect -------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Caller_cancellation_does_not_reconnect()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var held = 0;
|
|
target.ConnectedHandler = async cip =>
|
|
{
|
|
if (Interlocked.Increment(ref held) == 1) await release.Task;
|
|
return [(byte)(cip[0] | 0x80), 0, 0, 0];
|
|
};
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
var before = conn.Diagnostics.ForwardOpens;
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCt);
|
|
var pending = conn.SendAsync(ReadTag, cts.Token);
|
|
await FakeCipTarget.Until(() => target.ConnectedRequests == 1, Guard, "the connected request");
|
|
await cts.CancelAsync();
|
|
await Should.ThrowAsync<OperationCanceledException>(() => pending);
|
|
release.SetResult();
|
|
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
(conn.Diagnostics.ForwardOpens - before).ShouldBe(1);
|
|
target.ForwardOpens.ShouldBe(1);
|
|
target.Server.Connections.ShouldBe(1);
|
|
target.ConnectedRequests.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Timeout_reconnects_through_the_throttle()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var answer = 1;
|
|
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(
|
|
Volatile.Read(ref answer) == 1 ? [(byte)(cip[0] | 0x80), 0, 0, 0] : null);
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(timeoutSeconds: 0.3), time);
|
|
|
|
await conn.SendAsync(ReadTag, TestCt); // first Forward Open, at fake t0
|
|
Volatile.Write(ref answer, 0);
|
|
var timedOut = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
|
timedOut.Status.ShouldBe(AbStatus.Timeout); // the failing call keeps its own error
|
|
target.ForwardOpens.ShouldBe(1);
|
|
|
|
Volatile.Write(ref answer, 1);
|
|
var reconnect = conn.SendAsync(ReadTag, TestCt);
|
|
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // parked in the throttle
|
|
target.ForwardOpens.ShouldBe(1);
|
|
reconnect.IsCompleted.ShouldBeFalse();
|
|
|
|
time.Advance(TimeSpan.FromSeconds(1));
|
|
await reconnect.WaitAsync(Guard, TestCt);
|
|
|
|
target.ForwardOpens.ShouldBe(2);
|
|
target.Server.Connections.ShouldBe(2);
|
|
// A fresh connection serial per attempt, so the target never sees a duplicate of the stale connection.
|
|
var serials = target.ForwardOpenRequests.Select(f => BinaryPrimitives.ReadUInt16LittleEndian(f.AsSpan(16))).ToArray();
|
|
serials[1].ShouldNotBe(serials[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stalled_open_is_bounded_by_the_deadline()
|
|
{
|
|
// R9-3: no caller token at all, and the target never answers the Forward Open.
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(timeoutSeconds: 30), time);
|
|
target.Server.Stall = true;
|
|
|
|
var send = conn.SendAsync(ReadTag, CancellationToken.None);
|
|
await FakeCipTarget.Until(() => target.ForwardOpens == 0 && target.Server.Received.Count >= 2, Guard, "the Forward Open frame");
|
|
var openBudget = TimeSpan.FromSeconds(1) + 4 * TimeSpan.FromSeconds(30);
|
|
await time.WaitForTimerDueAtAsync(time.Start + openBudget, Guard);
|
|
time.Advance(openBudget);
|
|
|
|
var ex = await Should.ThrowAsync<AbTransportException>(() => send.WaitAsync(Guard, TestCt));
|
|
ex.Status.ShouldBe(AbStatus.Timeout);
|
|
}
|
|
|
|
// ---- keep-alive ------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Idle_connection_sends_one_keepalive_per_interval()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
var before = conn.Diagnostics.RequestsSent;
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
time.Advance(TimeSpan.FromSeconds(8));
|
|
conn.KeepAlivesSent.ShouldBe(1);
|
|
await FakeCipTarget.Until(() => target.ConnectedRequests == 2, Guard, "the keep-alive");
|
|
target.ConnectedCips[1].ShouldBe(CipConnection.KeepAliveRequest);
|
|
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(16), Guard); // re-armed
|
|
|
|
time.Advance(TimeSpan.FromSeconds(7));
|
|
conn.KeepAlivesSent.ShouldBe(1); // exactly one per idle interval
|
|
|
|
time.Advance(TimeSpan.FromSeconds(1));
|
|
conn.KeepAlivesSent.ShouldBe(2);
|
|
await FakeCipTarget.Until(() => target.ConnectedRequests == 3, Guard, "the second keep-alive");
|
|
(conn.Diagnostics.RequestsSent - before).ShouldBe(3); // keep-alives count
|
|
target.ForwardOpens.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Traffic_suppresses_keepalive()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
for (var i = 0; i < 4; i++)
|
|
{
|
|
time.Advance(TimeSpan.FromSeconds(5));
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
}
|
|
|
|
conn.KeepAlivesSent.ShouldBe(0);
|
|
target.ConnectedCips.ShouldAllBe(c => c.SequenceEqual(ReadTag));
|
|
target.ConnectedRequests.ShouldBe(5);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task No_keepalive_is_sent_when_the_connection_was_never_opened()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
|
|
time.Advance(TimeSpan.FromSeconds(30));
|
|
|
|
conn.KeepAlivesSent.ShouldBe(0);
|
|
target.ForwardOpens.ShouldBe(0);
|
|
target.Server.Connections.ShouldBe(0);
|
|
}
|
|
|
|
// ---- disposal --------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Send_after_dispose_throws_Abort()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
var time = new ManualTimeProvider();
|
|
var conn = Create(target, Options(), time);
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
await conn.DisposeAsync();
|
|
|
|
var ex = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
|
ex.Status.ShouldBe(AbStatus.Abort);
|
|
target.ForwardCloses.ShouldBe(1);
|
|
await conn.DisposeAsync(); // idempotent
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Callers_queued_behind_an_open_get_Abort_when_disposed()
|
|
{
|
|
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0113) };
|
|
var time = new ManualTimeProvider();
|
|
var conn = Create(target, Options(), time);
|
|
await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt)); // attempt 1, t0
|
|
|
|
var parked = conn.SendAsync(ReadTag, TestCt); // waits in the throttle
|
|
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard);
|
|
var queued = Enumerable.Range(0, 5).Select(_ => conn.SendAsync(ReadTag, TestCt)).ToArray();
|
|
var dispose = conn.DisposeAsync().AsTask();
|
|
time.Advance(TimeSpan.FromSeconds(1));
|
|
|
|
(await Should.ThrowAsync<AbTransportException>(() => parked.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
|
|
foreach (var q in queued)
|
|
(await Should.ThrowAsync<AbTransportException>(() => q.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
|
|
await dispose.WaitAsync(Guard, TestCt);
|
|
target.ForwardOpens.ShouldBe(1); // nothing is opened once disposal has begun
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disposing_during_the_session_registration_never_puts_a_Forward_Open_on_the_wire()
|
|
{
|
|
// Gitea #566: disposal that begins while the TCP connect / RegisterSession is on the wire lets that step
|
|
// finish (it is bounded), then releases the session instead of sending a Forward Open nobody would close.
|
|
await using var target = new FakeCipTarget();
|
|
var registerHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
target.Server.RegisterSessionGate = () => registerHeld.Task;
|
|
var time = new ManualTimeProvider();
|
|
var conn = Create(target, Options(), time);
|
|
|
|
var send = conn.SendAsync(ReadTag, TestCt);
|
|
await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.RegisterSession, Guard);
|
|
var dispose = conn.DisposeAsync().AsTask();
|
|
conn.OpenInProgress.ShouldBeTrue(); // disposal is waiting out the attempt on the wire...
|
|
dispose.IsCompleted.ShouldBeFalse(); // ...rather than abandoning it
|
|
registerHeld.SetResult();
|
|
|
|
(await Should.ThrowAsync<AbTransportException>(() => send.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
|
|
await dispose.WaitAsync(Guard, TestCt);
|
|
// The session is released (positive control); the server records frames in arrival order, so a Forward
|
|
// Open sent before the UnRegisterSession would already be counted.
|
|
await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
|
target.ForwardOpens.ShouldBe(0);
|
|
target.ForwardCloses.ShouldBe(0);
|
|
conn.Diagnostics.OpenAttempts.ShouldBe(1);
|
|
}
|
|
|
|
private const ushort AbandonedOpenRefusal = 0x7A11; // an extended status no other test uses
|
|
|
|
[Fact]
|
|
public async Task An_open_every_waiter_abandoned_leaves_no_unobserved_task_exception()
|
|
{
|
|
// Gitea #566: the shared open fails AFTER its only waiter gave up (its own token). Nothing awaits the
|
|
// open's task then; without the observer it would surface as UnobservedTaskException at finalization.
|
|
var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonAFailingOpenAsync, Guard);
|
|
|
|
unobserved.ShouldNotContain(e => e.Message.Contains($"0x{AbandonedOpenRefusal:X4}"));
|
|
}
|
|
|
|
// Kept out of the test body so no local in the test's own state machine roots the abandoned open.
|
|
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
|
private static async Task AbandonAFailingOpenAsync()
|
|
{
|
|
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, AbandonedOpenRefusal) };
|
|
var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
target.ForwardOpenGate = () => openHeld.Task;
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(size: 504), time);
|
|
using (var cts = new CancellationTokenSource())
|
|
{
|
|
var waiter = conn.SendAsync(ReadTag, cts.Token);
|
|
await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open");
|
|
await cts.CancelAsync();
|
|
await Should.ThrowAsync<OperationCanceledException>(() => waiter);
|
|
}
|
|
openHeld.SetResult(); // the target refuses, after every waiter has gone
|
|
await FakeCipTarget.Until(() => !conn.OpenInProgress, Guard, "the abandoned open to fail");
|
|
}
|
|
|
|
// ---- CIP-level close (Gitea #566) ------------------------------------------------------------
|
|
|
|
private static byte[] Ok(byte[] cip) => [(byte)(cip[0] | 0x80), 0, 0, 0];
|
|
|
|
private static byte[] Status(byte[] cip, byte general, ushort extended) =>
|
|
[(byte)(cip[0] | 0x80), 0, general, 1, (byte)extended, (byte)(extended >> 8)];
|
|
|
|
[Theory]
|
|
[InlineData(ConnectionManagerServices.TargetConnectionNotFound)]
|
|
[InlineData(ConnectionManagerServices.ConnectionTimedOut)]
|
|
public async Task A_reply_saying_the_CIP_connection_is_gone_retires_it_and_the_next_send_reopens(ushort extended)
|
|
{
|
|
// The target dropped the class-3 connection but the TCP session is still up: every later connected request
|
|
// would get the same answer with the session looking healthy, so the connection is recycled at once.
|
|
await using var target = new FakeCipTarget();
|
|
var gone = 0;
|
|
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(
|
|
Interlocked.Exchange(ref gone, 0) == 1 ? Status(cip, 0x01, extended) : Ok(cip));
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
Volatile.Write(ref gone, 1);
|
|
var ex = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
|
|
|
ex.Status.ShouldBe(AbStatus.BadConnection);
|
|
ex.Message.ShouldContain($"0x{extended:X4}");
|
|
conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(1);
|
|
await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
|
await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close");
|
|
target.ForwardCloses.ShouldBe(0); // the target just said there is nothing to close
|
|
|
|
var reopen = conn.SendAsync(ReadTag, TestCt);
|
|
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // through the throttle
|
|
time.Advance(TimeSpan.FromSeconds(1));
|
|
CipReply.Parse(await reopen.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
|
target.ForwardOpens.ShouldBe(2);
|
|
target.Server.Connections.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_keepalive_told_the_connection_is_gone_recycles_it_before_any_request_fails()
|
|
{
|
|
await using var target = new FakeCipTarget();
|
|
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(
|
|
cip.SequenceEqual(CipConnection.KeepAliveRequest) ? Status(cip, 0x01, 0x0107) : Ok(cip));
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
await conn.SendAsync(ReadTag, TestCt);
|
|
|
|
time.Advance(TimeSpan.FromSeconds(8)); // idle: the keep-alive goes out and is told the connection is gone
|
|
await FakeCipTarget.Until(() => conn.Diagnostics.ConnectionsClosedByTarget == 1, Guard, "the retirement");
|
|
await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close");
|
|
|
|
// The next request reopens (the throttle interval is long past) instead of failing on a dead connection.
|
|
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
|
target.ForwardOpens.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Other_error_statuses_describe_the_request_and_leave_the_connection_alone()
|
|
{
|
|
// Negative control for the recycle: a tag-level error and a connection-failure status that is not about
|
|
// this connection come back as ordinary replies, and the next request rides the same connection.
|
|
await using var target = new FakeCipTarget();
|
|
var replies = new Queue<Func<byte[], byte[]>>(
|
|
[
|
|
cip => Status(cip, 0x04, 0x0000), // path segment error: the tag
|
|
cip => Status(cip, 0x01, 0x0204), // unconnected request timed out: not this connection
|
|
Ok,
|
|
]);
|
|
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(replies.Dequeue()(cip));
|
|
var time = new ManualTimeProvider();
|
|
await using var conn = Create(target, Options(), time);
|
|
|
|
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x04);
|
|
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x01);
|
|
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); // same connection
|
|
|
|
conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(0);
|
|
target.ForwardOpens.ShouldBe(1);
|
|
target.Server.Connections.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Only_connection_failure_0107_and_0203_mean_the_connection_is_gone()
|
|
{
|
|
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x07, 0x01]).ShouldBe((ushort)0x0107);
|
|
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x03, 0x02]).ShouldBe((ushort)0x0203);
|
|
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x04, 0x02]).ShouldBeNull(); // 0x0204
|
|
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 0]).ShouldBeNull(); // no extended word
|
|
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x05, 1, 0x07, 0x01]).ShouldBeNull(); // not 0x01
|
|
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0, 0]).ShouldBeNull();
|
|
}
|
|
}
|