fix(abcip): native runtime STRING capacity, mistype churn, malformed-name config error, LWORD type check; non-blocking lease release (#570, #579)

#570
- STRING: the built-in 88-byte Logix STRING holds 82 characters (LEN + DATA[82] + 2 pad), not 84; the
  write still sends the whole 88-byte structure and a read never decodes the pad as characters.
- Mistype churn: a type refusal (CIP 0xFF/0x2107) on the existence read no longer throws from
  InitializeAsync, so the runtime is not disposed/re-created (and re-Forward-Opened) every poll; the driver
  also keeps a runtime that throws TypeMismatch from init and records the device as reachable.
- Malformed tag name: a TagPath that does not parse, or a name the CIP symbolic encoder refuses (now also
  non-ASCII, empty member, >255-char member, unterminated subscript), is AbCipTagConfigurationException ->
  per-tag BadConfigurationError (named const in AbCipStatusMapper), warned once, never feeding the device
  backoff or health; siblings keep reading. A malformed probe tag no longer feeds the backoff either.
- Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are type-checked as integers of their width: REAL/LREAL or a
  narrower integer over LWORD is TypeMismatch, not decoded garbage.

#579
- ConnectionLease.ReleaseInBackground: one non-blocking release shape for both native AB runtimes. The
  AbCip runtime's Dispose no longer waits up to 2 s under the per-tag lock; the pool still registers the
  close synchronously so a re-create cannot overlap it. Offline soak tests (25 cycles, AbCip + AbLegacy)
  prove no leaked connection; FakeEipServer gains OpenConnections.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 23:05:49 -04:00
parent 04b6563286
commit 4e6eed7454
17 changed files with 799 additions and 46 deletions
@@ -267,6 +267,43 @@ public sealed class DeviceSessionPoolTests
public void Dispose() { }
}
/// <summary>
/// Gitea #579: <see cref="ConnectionLease.ReleaseInBackground"/> returns before the last release's Forward
/// Close completes, yet a successor acquired that same instant still cannot open until the close is over —
/// the closing chain is registered synchronously, inside the call.
/// </summary>
[Fact]
public async Task Release_in_background_returns_before_the_close_and_a_successor_still_waits_for_it()
{
await using var target = new FakeCipTarget();
var closeHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
target.ForwardCloseGate = () => closeHeld.Task;
var time = new ManualTimeProvider();
var pool = new DeviceSessionPool(time);
var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix");
var a = pool.Acquire(key, Options());
await a.Scheduler.SendAsync(ReadTag, TestCt);
var teardown = a.ReleaseInBackground();
pool.EntryCount.ShouldBe(0); // bookkeeping done before it returned
await FakeCipTarget.Until(() => target.ForwardCloses == 1, Guard, "the Forward Close");
teardown.IsCompleted.ShouldBeFalse(); // ...but the close is still pending
time.Advance(TimeSpan.FromSeconds(5)); // the throttle is no obstacle
await using var b = pool.Acquire(key, Options());
var bSend = b.Scheduler.SendAsync(ReadTag, TestCt);
await Task.Delay(300, TestCt);
target.ForwardOpens.ShouldBe(1); // held behind A's close
bSend.IsCompleted.ShouldBeFalse();
closeHeld.SetResult();
await teardown.WaitAsync(Guard, TestCt);
(await bSend.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
target.ForwardOpens.ShouldBe(2);
await a.ReleaseInBackground().WaitAsync(Guard, TestCt); // idempotent: b's reference is untouched
pool.EntryCount.ShouldBe(1);
}
[Fact]
public void Shared_is_one_process_wide_instance() =>
DeviceSessionPool.Shared.ShouldBeSameAs(DeviceSessionPool.Shared);