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()
{