Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs
T
Joseph Doherty 3edff3304b fix(ab-transport): per-pool diagnostics + per-device open-attempt limit, CIP-level close recycle, CipConnection/pool test gaps (#566, #581)
- 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
2026-10-07 23:21:02 -04:00

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();
}
}