Merge #570/#579: native AbCip per-tag config errors, no mistype churn, STRING 82, LWORD type checks; non-blocking lease release (AbCip + AbLegacy)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe # Conflicts: # src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs
This commit is contained in:
@@ -33,6 +33,23 @@ public sealed class CipPathTests
|
||||
public void Symbolic_rejects_bit_selector() =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic("Tag.5"));
|
||||
|
||||
/// <summary>Gitea #570: names that used to be encoded silently wrong (a non-ASCII name as '?', an over-long
|
||||
/// one with a wrapped length byte, an empty member as a zero-length segment) or that threw an unrelated
|
||||
/// exception (an unterminated subscript) are refused as invalid arguments before any I/O.</summary>
|
||||
[Theory]
|
||||
[InlineData("Tëmp")]
|
||||
[InlineData("A..B")]
|
||||
[InlineData("Arr[1")]
|
||||
public void Symbolic_rejects_names_it_cannot_encode_faithfully(string name) =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic(name));
|
||||
|
||||
[Fact]
|
||||
public void Symbolic_rejects_a_member_longer_than_its_length_byte()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic(new string('A', 256)));
|
||||
CipPath.EncodeSymbolic(new string('A', 255))[1].ShouldBe((byte)255); // the boundary still encodes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Splits_bit_selector()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,6 +29,7 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
private readonly ConcurrentBag<TcpClient> _clients = [];
|
||||
private readonly ConcurrentBag<Exception> _handlerErrors = [];
|
||||
private int _connections;
|
||||
private int _open;
|
||||
private int _outstanding;
|
||||
private int _maxOutstanding;
|
||||
|
||||
@@ -45,6 +46,9 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
|
||||
public int Connections => Volatile.Read(ref _connections);
|
||||
|
||||
/// <summary>TCP connections accepted and not yet closed by the client (Gitea #579 leak checks).</summary>
|
||||
public int OpenConnections => Volatile.Read(ref _open);
|
||||
|
||||
/// <summary>Largest number of non-session requests ever received but not yet answered.</summary>
|
||||
public int MaxOutstanding => Volatile.Read(ref _maxOutstanding);
|
||||
|
||||
@@ -108,12 +112,19 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
try { c = await _listener.AcceptTcpClientAsync(_cts.Token); }
|
||||
catch { return; }
|
||||
Interlocked.Increment(ref _connections);
|
||||
Interlocked.Increment(ref _open);
|
||||
_clients.Add(c);
|
||||
_ = Serve(c);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Serve(TcpClient c)
|
||||
{
|
||||
try { await ServeCore(c); }
|
||||
finally { Interlocked.Decrement(ref _open); }
|
||||
}
|
||||
|
||||
private async Task ServeCore(TcpClient c)
|
||||
{
|
||||
using var _ = c;
|
||||
var s = c.GetStream();
|
||||
|
||||
Reference in New Issue
Block a user