feat(ab-transport): process-wide open-attempt budget so the differential never retries an open (Task 18 review r1)

This commit is contained in:
Joseph Doherty
2026-10-07 18:10:25 -04:00
parent 0e20a8e5a9
commit 1d6501f427
2 changed files with 46 additions and 0 deletions
@@ -26,6 +26,18 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
internal static int RequestsSent;
/// <summary>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.</summary>
internal static int OpenAttempts;
/// <summary>
/// Process-wide cap on <see cref="OpenAttempts"/>; <c>-1</c> (the default) is unlimited. An attempt beyond
/// the cap fails with <see cref="AbStatus.Abort"/> 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.
/// </summary>
internal static int OpenAttemptBudget = -1;
/// <summary>Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).</summary>
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<Open> 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)
{