feat(ab-transport): process-wide open-attempt budget so the differential never retries an open (Task 18 review r1)
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connection_in_use_does_not_fall_back()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user