diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs index d9508a81..c9c87752 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs @@ -26,6 +26,18 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable /// Channel sends by every connection in the process (keep-alives included). Gate diagnostics. internal static int RequestsSent; + /// Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by + /// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics. + internal static int OpenAttempts; + + /// + /// Process-wide cap on ; -1 (the default) is unlimited. An attempt beyond + /// the cap fails with before any I/O — no TCP connect, no Forward Open. Only the + /// read-only differential harness (Task 18/21) sets it, so a production press that refuses or drops the + /// connection sees exactly one open attempt instead of one per remaining operation. + /// + internal static int OpenAttemptBudget = -1; + /// Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID). internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01]; @@ -176,6 +188,11 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable private async Task OpenAsync(CancellationToken ct) { ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle + var attempt = Interlocked.Increment(ref OpenAttempts); + var budget = Volatile.Read(ref OpenAttemptBudget); + if (budget >= 0 && attempt > budget) + throw new AbTransportException(AbStatus.Abort, + $"Open attempt {attempt} to {_key.Host}:{_key.Port} refused before any I/O: the process-wide open-attempt budget of {budget} is spent."); var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false); if (Volatile.Read(ref _disposed) != 0) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs index 84c25f02..e76e9c98 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs @@ -106,6 +106,35 @@ public sealed class CipConnectionTests 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 process 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); + var before = Volatile.Read(ref CipConnection.OpenAttempts); + Volatile.Write(ref CipConnection.OpenAttemptBudget, before + 1); + try + { + await Should.ThrowAsync(() => 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(() => retry.WaitAsync(Guard, TestCt)); + + refused.Status.ShouldBe(AbStatus.Abort); + refused.Message.ShouldContain("budget"); + target.Server.Connections.ShouldBe(1); + target.ForwardOpens.ShouldBe(1); + } + finally + { + Volatile.Write(ref CipConnection.OpenAttemptBudget, -1); + } + } + [Fact] public async Task Connection_in_use_does_not_fall_back() {