merge: Task 10 — RequestScheduler (MSP coalescing) + LogixTagClient fragment loops
This commit is contained in:
+393
@@ -0,0 +1,393 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic scheduler tests: requests are held in flight with <see cref="FakeCipChannel.Gate"/>
|
||||
/// (or a per-call hold) and the queue is observed through test hooks, never with sleeps.
|
||||
/// </summary>
|
||||
public sealed class RequestSchedulerTests
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>A request of exactly <paramref name="size"/> bytes, service 0x4C, tagged by <paramref name="tag"/>.</summary>
|
||||
private static byte[] Req(int size, byte tag = 0)
|
||||
{
|
||||
var r = new byte[size];
|
||||
r[0] = 0x4C;
|
||||
if (size > 1) r[^1] = tag;
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>Puts <paramref name="first"/> in flight behind the channel gate and returns its task.</summary>
|
||||
private static async Task<Task<CipReply>> HoldFirstAsync(RequestScheduler s, FakeCipChannel ch, byte[] first)
|
||||
{
|
||||
var expected = ch.SentCount + 1;
|
||||
ch.Gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var t = s.SendAsync(first, CancellationToken.None);
|
||||
await ch.WaitForSendsAsync(expected);
|
||||
return t;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Requests_queued_behind_an_inflight_one_are_packed_into_one_msp()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1));
|
||||
var b = s.SendAsync(Req(8, 2), CancellationToken.None);
|
||||
var c = s.SendAsync(Req(8, 3), CancellationToken.None);
|
||||
var d = s.SendAsync(Req(8, 4), CancellationToken.None);
|
||||
s.QueuedCount.ShouldBe(3);
|
||||
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, b, c, d).WaitAsync(Timeout, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(2);
|
||||
ch.Sent[0].ShouldBe(Req(8, 1));
|
||||
ch.Sent[1][0].ShouldBe((byte)0x0A);
|
||||
FakeCipChannel.SplitMsp(ch.Sent[1]).ShouldBe([Req(8, 2), Req(8, 3), Req(8, 4)]); // FIFO
|
||||
foreach (var t in new[] { a, b, c, d }) (await t).GeneralStatus.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Msp_exactly_at_limit_packs()
|
||||
{
|
||||
// MSP of two 10-byte requests = 6 + 2 + 2*2 + 20 = 32.
|
||||
MultipleServicePacket.BuildSize([10, 10]).ShouldBe(32);
|
||||
var ch = new FakeCipChannel(maxRequestBytes: 32);
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(4));
|
||||
var x = s.SendAsync(Req(10, 1), CancellationToken.None);
|
||||
var y = s.SendAsync(Req(10, 2), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, x, y).WaitAsync(Timeout, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(2);
|
||||
ch.Sent[1].Length.ShouldBe(32);
|
||||
FakeCipChannel.SplitMsp(ch.Sent[1]).ShouldBe([Req(10, 1), Req(10, 2)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Msp_one_byte_over_limit_splits()
|
||||
{
|
||||
var ch = new FakeCipChannel(maxRequestBytes: 31);
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(4));
|
||||
var x = s.SendAsync(Req(10, 1), CancellationToken.None);
|
||||
var y = s.SendAsync(Req(10, 2), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, x, y).WaitAsync(Timeout, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(3);
|
||||
ch.Sent[1].ShouldBe(Req(10, 1));
|
||||
ch.Sent[2].ShouldBe(Req(10, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_that_does_not_fit_never_lets_a_later_one_overtake_it()
|
||||
{
|
||||
var ch = new FakeCipChannel(maxRequestBytes: 100);
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(4, 1));
|
||||
var b = s.SendAsync(Req(10, 2), CancellationToken.None);
|
||||
var big = s.SendAsync(Req(400, 3), CancellationToken.None);
|
||||
var d = s.SendAsync(Req(10, 4), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, b, big, d).WaitAsync(Timeout, Ct);
|
||||
|
||||
// b + big does not fit, so b goes bare; big exceeds the limit alone and goes bare; then d.
|
||||
ch.Sent.Select(r => r[^1]).ShouldBe(new byte[] { 1, 2, 3, 4 });
|
||||
ch.Sent.ShouldAllBe(r => r[0] == 0x4C);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_request_larger_than_the_limit_is_sent_bare()
|
||||
{
|
||||
var ch = new FakeCipChannel(maxRequestBytes: 20);
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var reply = await s.SendAsync(Req(30, 9), CancellationToken.None).WaitAsync(Timeout, Ct);
|
||||
|
||||
reply.GeneralStatus.ShouldBe((byte)0);
|
||||
ch.Sent.Count.ShouldBe(1);
|
||||
ch.Sent[0].ShouldBe(Req(30, 9));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Packing_disabled_sends_each_bare()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: false);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1));
|
||||
var b = s.SendAsync(Req(8, 2), CancellationToken.None);
|
||||
var c = s.SendAsync(Req(8, 3), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, b, c).WaitAsync(Timeout, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(3);
|
||||
ch.Sent.Select(r => r[^1]).ShouldBe(new byte[] { 1, 2, 3 });
|
||||
ch.Sent.ShouldAllBe(r => r[0] == 0x4C);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_service_error_fails_only_its_waiter()
|
||||
{
|
||||
var ch = new FakeCipChannel
|
||||
{
|
||||
RespondPerService = r => r[^1] == 2
|
||||
? FakeCipChannel.Reply(r[0], 0x04, [], 0x0000)
|
||||
: FakeCipChannel.Reply(r[0], 0x00, [0xAB]),
|
||||
};
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 0));
|
||||
var ok = s.SendAsync(Req(8, 1), CancellationToken.None);
|
||||
var bad = s.SendAsync(Req(8, 2), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, ok, bad).WaitAsync(Timeout, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(2);
|
||||
var okReply = await ok;
|
||||
okReply.GeneralStatus.ShouldBe((byte)0x00);
|
||||
okReply.Status.ShouldBe(AbStatus.Ok);
|
||||
okReply.Data.ToArray().ShouldBe(new byte[] { 0xAB });
|
||||
var badReply = await bad;
|
||||
badReply.GeneralStatus.ShouldBe((byte)0x04);
|
||||
badReply.ExtendedStatus.ShouldBe(new ushort[] { 0x0000 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Msp_envelope_failure_faults_every_waiter_in_the_batch_with_the_same_exception()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 0));
|
||||
ch.Respond = r => Task.FromResult(r[0] == 0x0A
|
||||
? FakeCipChannel.Reply(0x0A, 0x08, []) // service not supported on the envelope
|
||||
: FakeCipChannel.OkReply(r));
|
||||
var x = s.SendAsync(Req(8, 1), CancellationToken.None);
|
||||
var y = s.SendAsync(Req(8, 2), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
|
||||
(await a.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
|
||||
var ex1 = await Should.ThrowAsync<AbTransportException>(() => x.WaitAsync(Timeout, Ct));
|
||||
var ex2 = await Should.ThrowAsync<AbTransportException>(() => y.WaitAsync(Timeout, Ct));
|
||||
ex1.ShouldBeSameAs(ex2);
|
||||
ex1.Status.ShouldBe(AbStatusCodes.FromCip(0x08, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Channel_failure_faults_every_waiter_in_the_batch()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 0));
|
||||
var boom = new AbTransportException(AbStatus.Timeout, "reply timeout");
|
||||
ch.Respond = _ => Task.FromException<byte[]>(boom);
|
||||
var x = s.SendAsync(Req(8, 1), CancellationToken.None);
|
||||
var y = s.SendAsync(Req(8, 2), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
|
||||
// a's own exchange also failed (the responder was swapped before release).
|
||||
(await Should.ThrowAsync<AbTransportException>(() => a.WaitAsync(Timeout, Ct))).ShouldBeSameAs(boom);
|
||||
(await Should.ThrowAsync<AbTransportException>(() => x.WaitAsync(Timeout, Ct))).ShouldBeSameAs(boom);
|
||||
(await Should.ThrowAsync<AbTransportException>(() => y.WaitAsync(Timeout, Ct))).ShouldBeSameAs(boom);
|
||||
|
||||
// The scheduler survives a failed exchange.
|
||||
ch.Respond = ch.DefaultRespond;
|
||||
(await s.SendAsync(Req(8, 3), CancellationToken.None).WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_transport_channel_exception_is_wrapped_and_shared_by_the_batch()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 0));
|
||||
ch.Respond = _ => Task.FromException<byte[]>(new IOException("socket reset"));
|
||||
var x = s.SendAsync(Req(8, 1), CancellationToken.None);
|
||||
var y = s.SendAsync(Req(8, 2), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
|
||||
await Should.ThrowAsync<AbTransportException>(() => a.WaitAsync(Timeout, Ct));
|
||||
var ex1 = await Should.ThrowAsync<AbTransportException>(() => x.WaitAsync(Timeout, Ct));
|
||||
var ex2 = await Should.ThrowAsync<AbTransportException>(() => y.WaitAsync(Timeout, Ct));
|
||||
ex1.ShouldBeSameAs(ex2);
|
||||
ex1.InnerException.ShouldBeOfType<IOException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queued_waiter_cancelled_before_send_is_never_sent()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1));
|
||||
using var cts = new CancellationTokenSource();
|
||||
var b = s.SendAsync(Req(8, 2), cts.Token);
|
||||
s.QueuedCount.ShouldBe(1);
|
||||
|
||||
await cts.CancelAsync();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => b.WaitAsync(Timeout, Ct));
|
||||
b.IsCanceled.ShouldBeTrue();
|
||||
s.QueuedCount.ShouldBe(0);
|
||||
|
||||
var c = s.SendAsync(Req(8, 3), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, c).WaitAsync(Timeout, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(2);
|
||||
ch.Sent.Select(r => r[^1]).ShouldBe(new byte[] { 1, 3 });
|
||||
ch.Sent[1][0].ShouldBe((byte)0x4C); // c went bare: the cancelled b was not packed with it
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scheduler_fault_outside_the_exchange_fails_every_pending_waiter_and_keeps_pumping()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1));
|
||||
var b = s.SendAsync(Req(8, 2), CancellationToken.None);
|
||||
var c = s.SendAsync(Req(8, 3), CancellationToken.None);
|
||||
var d = s.SendAsync(Req(8, 4), CancellationToken.None);
|
||||
var broken = new InvalidOperationException("channel torn");
|
||||
ch.MaxRequestBytesThrows = broken; // TakeBatch reads it when packing c behind b
|
||||
ch.Gate!.SetResult();
|
||||
|
||||
(await a.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
|
||||
var exB = await Should.ThrowAsync<AbTransportException>(() => b.WaitAsync(Timeout, Ct)); // already taken
|
||||
var exC = await Should.ThrowAsync<AbTransportException>(() => c.WaitAsync(Timeout, Ct)); // still queued
|
||||
var exD = await Should.ThrowAsync<AbTransportException>(() => d.WaitAsync(Timeout, Ct));
|
||||
exB.Status.ShouldBe(AbStatus.BadConnection);
|
||||
exB.InnerException.ShouldBeSameAs(broken);
|
||||
exC.ShouldBeSameAs(exB);
|
||||
exD.ShouldBeSameAs(exB);
|
||||
s.QueuedCount.ShouldBe(0);
|
||||
ch.SentCount.ShouldBe(1);
|
||||
|
||||
// The pump survived: a later request is answered, not left hanging.
|
||||
ch.MaxRequestBytesThrows = null;
|
||||
(await s.SendAsync(Req(8, 5), CancellationToken.None).WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_already_cancelled_is_never_queued()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var t = s.SendAsync(Req(8), new CancellationToken(canceled: true));
|
||||
t.IsCanceled.ShouldBeTrue();
|
||||
s.QueuedCount.ShouldBe(0);
|
||||
s.LiveRegistrations.ShouldBe(0);
|
||||
ch.SentCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_waiter_cancelled_while_its_batch_is_in_flight_is_abandoned_and_the_others_complete()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
var mspHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ch.Respond = async r =>
|
||||
{
|
||||
if (r[0] == 0x0A) await mspHold.Task;
|
||||
return await ch.DefaultRespond(r);
|
||||
};
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1));
|
||||
using var cts = new CancellationTokenSource();
|
||||
var b = s.SendAsync(Req(8, 2), cts.Token);
|
||||
var c = s.SendAsync(Req(8, 3), CancellationToken.None);
|
||||
ch.Gate!.SetResult();
|
||||
await ch.WaitForSendsAsync(2); // the MSP carrying b and c is now in flight
|
||||
s.QueuedCount.ShouldBe(0);
|
||||
|
||||
await cts.CancelAsync();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => b.WaitAsync(Timeout, Ct));
|
||||
c.IsCompleted.ShouldBeFalse();
|
||||
ch.Tokens[1].IsCancellationRequested.ShouldBeFalse(); // the shared exchange was not aborted
|
||||
|
||||
mspHold.SetResult();
|
||||
(await c.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
|
||||
(await a.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
|
||||
s.LiveRegistrations.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waiter_cancellation_never_reaches_the_channel_token()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
using var w1 = new CancellationTokenSource();
|
||||
using var w2 = new CancellationTokenSource();
|
||||
using var w3 = new CancellationTokenSource();
|
||||
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1)); // bare single, no waiter token
|
||||
var single = s.SendAsync(Req(8, 2), w1.Token);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(a, single).WaitAsync(Timeout, Ct); // bare single with a waiter token
|
||||
|
||||
var held = await HoldFirstAsync(s, ch, Req(8, 3));
|
||||
var m1 = s.SendAsync(Req(8, 4), w2.Token);
|
||||
var m2 = s.SendAsync(Req(8, 5), w3.Token);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(held, m1, m2).WaitAsync(Timeout, Ct); // MSP with waiter tokens
|
||||
|
||||
ch.Sent.Count.ShouldBe(4);
|
||||
ch.Sent[3][0].ShouldBe((byte)0x0A);
|
||||
var waiterTokens = new[] { w1.Token, w2.Token, w3.Token };
|
||||
ch.Tokens.ShouldAllBe(t => !waiterTokens.Contains(t));
|
||||
ch.Tokens.Distinct().Count().ShouldBe(1); // every call used the scheduler's own stop token
|
||||
ch.Tokens[0].CanBeCanceled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_registrations_are_released()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
using var longLived = new CancellationTokenSource();
|
||||
|
||||
// The hook really counts: queued waiters hold live registrations.
|
||||
var a = await HoldFirstAsync(s, ch, Req(8));
|
||||
var queued = Enumerable.Range(0, 5).Select(_ => s.SendAsync(Req(8), longLived.Token)).ToArray();
|
||||
s.LiveRegistrations.ShouldBe(5);
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(queued.Append(a)).WaitAsync(Timeout, Ct);
|
||||
s.LiveRegistrations.ShouldBe(0);
|
||||
ch.Gate = null;
|
||||
|
||||
var tasks = new Task<CipReply>[10_000];
|
||||
for (var i = 0; i < tasks.Length; i++) tasks[i] = s.SendAsync(Req(8), longLived.Token);
|
||||
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(60), Ct);
|
||||
|
||||
s.LiveRegistrations.ShouldBe(0);
|
||||
// Cancelling the long-lived token afterwards touches nothing: every registration is gone.
|
||||
await longLived.CancelAsync();
|
||||
s.CancellationCallbacksRun.ShouldBe(0);
|
||||
tasks.ShouldAllBe(t => t.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_faults_queued_and_in_flight_waiters_as_a_communication_failure_not_a_cancellation()
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1)); // in flight
|
||||
using var own = new CancellationTokenSource();
|
||||
var b = s.SendAsync(Req(8, 2), own.Token); // queued, with a live token
|
||||
|
||||
await s.DisposeAsync();
|
||||
|
||||
// Not OperationCanceledException: upstream would swallow that as "unsubscribed".
|
||||
var inFlight = await Should.ThrowAsync<AbTransportException>(() => a.WaitAsync(Timeout, Ct));
|
||||
inFlight.Status.ShouldBe(AbStatus.BadConnection);
|
||||
var queued = await Should.ThrowAsync<AbTransportException>(() => b.WaitAsync(Timeout, Ct));
|
||||
queued.Status.ShouldBe(AbStatus.BadConnection);
|
||||
a.IsCanceled.ShouldBeFalse();
|
||||
b.IsCanceled.ShouldBeFalse();
|
||||
s.LiveRegistrations.ShouldBe(0);
|
||||
ch.SentCount.ShouldBe(1);
|
||||
Should.Throw<ObjectDisposedException>(() => s.SendAsync(Req(8), CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Buffers.Binary;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Scriptable <see cref="ICipChannel"/>: settable <see cref="MaxRequestBytes"/>, records every request
|
||||
/// and the token it was sent with, and answers via <see cref="Respond"/>. The default responder
|
||||
/// answers any service with an empty success reply and splits a Multiple Service Packet into
|
||||
/// per-service replies via <see cref="RespondPerService"/>. Reused by Tasks 12 and 13.
|
||||
/// </summary>
|
||||
internal sealed class FakeCipChannel : ICipChannel
|
||||
{
|
||||
private readonly SemaphoreSlim _entered = new(0);
|
||||
|
||||
public FakeCipChannel(int maxRequestBytes = 504)
|
||||
{
|
||||
MaxRequestBytes = maxRequestBytes;
|
||||
Respond = DefaultRespond;
|
||||
}
|
||||
|
||||
private int _maxRequestBytes;
|
||||
|
||||
public int MaxRequestBytes
|
||||
{
|
||||
get => MaxRequestBytesThrows is { } ex ? throw ex : _maxRequestBytes;
|
||||
set => _maxRequestBytes = value;
|
||||
}
|
||||
|
||||
/// <summary>When set, reading <see cref="MaxRequestBytes"/> throws it (simulates a broken channel).</summary>
|
||||
public Exception? MaxRequestBytesThrows { get; set; }
|
||||
|
||||
/// <summary>Every request, in send order.</summary>
|
||||
public List<byte[]> Sent { get; } = [];
|
||||
|
||||
/// <summary>The token each request was sent with, parallel to <see cref="Sent"/>.</summary>
|
||||
public List<CancellationToken> Tokens { get; } = [];
|
||||
|
||||
/// <summary>Answers one request with raw CIP reply bytes.</summary>
|
||||
public Func<byte[], Task<byte[]>> Respond { get; set; }
|
||||
|
||||
/// <summary>Answers one embedded (non-MSP) service; used by the default <see cref="Respond"/>.</summary>
|
||||
public Func<byte[], byte[]> RespondPerService { get; set; } = OkReply;
|
||||
|
||||
/// <summary>When set, every send blocks on it (after being recorded) until the test completes it.</summary>
|
||||
public TaskCompletionSource? Gate { get; set; }
|
||||
|
||||
public int SentCount { get { lock (Sent) return Sent.Count; } }
|
||||
|
||||
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
var req = cipRequest.ToArray();
|
||||
lock (Sent)
|
||||
{
|
||||
Sent.Add(req);
|
||||
Tokens.Add(cancellationToken);
|
||||
}
|
||||
_entered.Release();
|
||||
if (Gate is { } g) await g.Task.WaitAsync(cancellationToken);
|
||||
return await Respond(req);
|
||||
}
|
||||
|
||||
/// <summary>Completes once the channel has received <paramref name="count"/> requests in total.</summary>
|
||||
public async Task WaitForSendsAsync(int count)
|
||||
{
|
||||
while (SentCount < count)
|
||||
{
|
||||
if (!await _entered.WaitAsync(TimeSpan.FromSeconds(10)))
|
||||
throw new TimeoutException($"Expected {count} sends, saw {SentCount}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A success reply for <paramref name="request"/> with optional data.</summary>
|
||||
public static byte[] OkReply(byte[] request) => Reply(request[0], 0x00, []);
|
||||
|
||||
public static byte[] Reply(byte requestService, byte generalStatus, byte[] data, params ushort[] extended)
|
||||
{
|
||||
var r = new byte[4 + 2 * extended.Length + data.Length];
|
||||
r[0] = (byte)(requestService | 0x80);
|
||||
r[2] = generalStatus;
|
||||
r[3] = (byte)extended.Length;
|
||||
for (var i = 0; i < extended.Length; i++) BinaryPrimitives.WriteUInt16LittleEndian(r.AsSpan(4 + 2 * i), extended[i]);
|
||||
data.CopyTo(r, 4 + 2 * extended.Length);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>Splits an MSP request into its embedded requests.</summary>
|
||||
public static List<byte[]> SplitMsp(byte[] msp)
|
||||
{
|
||||
const int countAt = 6; // 0A 02 20 02 24 01
|
||||
var data = msp.AsSpan(countAt);
|
||||
int n = BinaryPrimitives.ReadUInt16LittleEndian(data);
|
||||
var offsets = new int[n];
|
||||
for (var i = 0; i < n; i++) offsets[i] = BinaryPrimitives.ReadUInt16LittleEndian(data[(2 + 2 * i)..]);
|
||||
var result = new List<byte[]>(n);
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var end = i + 1 < n ? offsets[i + 1] : data.Length;
|
||||
result.Add(data[offsets[i]..end].ToArray());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>An MSP reply (service 0x8A) carrying the given embedded replies.</summary>
|
||||
public static byte[] MspReply(IReadOnlyList<byte[]> replies, byte generalStatus = 0x00)
|
||||
{
|
||||
var body = new List<byte>();
|
||||
var header = new byte[2 + 2 * replies.Count];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header, (ushort)replies.Count);
|
||||
var offset = header.Length;
|
||||
for (var i = 0; i < replies.Count; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(2 + 2 * i), (ushort)offset);
|
||||
offset += replies[i].Length;
|
||||
}
|
||||
body.AddRange(header);
|
||||
foreach (var r in replies) body.AddRange(r);
|
||||
return Reply(0x0A, generalStatus, [.. body]);
|
||||
}
|
||||
|
||||
/// <summary>The default responder: per-service replies, split and re-packed for an MSP.</summary>
|
||||
public Task<byte[]> DefaultRespond(byte[] request)
|
||||
{
|
||||
if (request[0] != 0x0A) return Task.FromResult(RespondPerService(request));
|
||||
var replies = SplitMsp(request).Select(RespondPerService).ToList();
|
||||
var anyFailed = replies.Any(r => r[2] != 0);
|
||||
return Task.FromResult(MspReply(replies, anyFailed ? (byte)0x1E : (byte)0x00));
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
|
||||
|
||||
public sealed class LogixTagClientTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly byte[] Path = CipPath.EncodeSymbolic("TestArray");
|
||||
|
||||
/// <summary>Offset field of a Read/Write Tag Fragmented request (after service, size, path, type?, elements).</summary>
|
||||
private static uint ReadOffset(byte[] req) => BinaryPrimitives.ReadUInt32LittleEndian(req.AsSpan(2 + Path.Length + 2));
|
||||
|
||||
private static byte[] Bytes(int count, int seed) => Enumerable.Range(0, count).Select(i => (byte)(seed + i)).ToArray();
|
||||
|
||||
private static byte[] TypedData(ushort type, byte[] data, ushort? handle = null)
|
||||
{
|
||||
var header = new List<byte> { (byte)type, (byte)(type >> 8) };
|
||||
if (handle is { } h) header.AddRange([(byte)h, (byte)(h >> 8)]);
|
||||
return [.. header, .. data];
|
||||
}
|
||||
|
||||
private static (FakeCipChannel Channel, LogixTagClient Client, RequestScheduler Scheduler) Create(int max = 504)
|
||||
{
|
||||
var ch = new FakeCipChannel(max);
|
||||
var s = new RequestScheduler(ch, allowPacking: true);
|
||||
return (ch, new LogixTagClient(s, ch), s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_that_fits_is_one_request_at_offset_zero()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.DInt, [0x87, 0xD6, 0x12, 0x00]));
|
||||
|
||||
var (type, handle, data) = await client.ReadAsync(Path, 1, Ct);
|
||||
|
||||
type.ShouldBe(LogixTypeCode.DInt);
|
||||
handle.ShouldBeNull();
|
||||
data.ShouldBe(new byte[] { 0x87, 0xD6, 0x12, 0x00 });
|
||||
ch.Sent.Count.ShouldBe(1);
|
||||
ch.Sent[0].ShouldBe(LogixServices.ReadTagFragmented(Path, 1, 0)); // libplctag's wire shape (golden captures)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fragmented_read_keeps_the_first_partial_and_continues()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => ReadOffset(r) switch
|
||||
{
|
||||
0 => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, Bytes(100, 0))),
|
||||
100 => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, Bytes(100, 100))),
|
||||
200 => FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.DInt, Bytes(40, 200))),
|
||||
var o => throw new InvalidOperationException($"unexpected offset {o}"),
|
||||
};
|
||||
|
||||
var (type, _, data) = await client.ReadAsync(Path, 60, Ct);
|
||||
|
||||
type.ShouldBe(LogixTypeCode.DInt);
|
||||
data.Length.ShouldBe(240);
|
||||
data.ShouldBe(Bytes(240, 0)); // first chunk kept once, type headers stripped, in order
|
||||
ch.Sent.Select(ReadOffset).ShouldBe(new uint[] { 0, 100, 200 });
|
||||
ch.Sent.ShouldAllBe(r => r[0] == 0x52);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fragmented_struct_read_keeps_the_handle_from_the_first_reply()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => ReadOffset(r) == 0
|
||||
? FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.Struct, Bytes(8, 0), handle: 0x0FCE))
|
||||
: FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.Struct, Bytes(4, 8), handle: 0x0FCE));
|
||||
|
||||
var (type, handle, data) = await client.ReadAsync(Path, 1, Ct);
|
||||
|
||||
type.ShouldBe(LogixTypeCode.Struct);
|
||||
handle.ShouldBe((ushort)0x0FCE);
|
||||
data.ShouldBe(Bytes(12, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_read_throws_with_the_reply_status()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x04, [], 0x0000);
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => client.ReadAsync(Path, 1, Ct));
|
||||
ex.Status.ShouldBe(AbStatusCodes.FromCip(0x04, [0x0000]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fragment_failing_mid_read_throws_with_its_status()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => ReadOffset(r) == 0
|
||||
? FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, Bytes(100, 0)))
|
||||
: FakeCipChannel.Reply(r[0], 0x05, []);
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => client.ReadAsync(Path, 60, Ct));
|
||||
ex.Status.ShouldBe(AbStatusCodes.FromCip(0x05, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_partial_reply_with_no_data_does_not_loop_forever()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, []));
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => client.ReadAsync(Path, 60, Ct));
|
||||
ex.Status.ShouldBe(AbStatus.BadReply);
|
||||
ch.Sent.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_that_fits_is_one_write_tag()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
var data = Bytes(16, 1);
|
||||
|
||||
await client.WriteAsync(Path, LogixTypeCode.DInt, null, 4, data, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(1);
|
||||
ch.Sent[0].ShouldBe(LogixServices.WriteTag(Path, LogixTypeCode.DInt, null, 4, data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_exactly_at_the_limit_is_still_one_write_tag()
|
||||
{
|
||||
var data = Bytes(16, 1);
|
||||
var size = LogixServices.WriteTag(Path, LogixTypeCode.DInt, null, 4, data).Length;
|
||||
var (ch, client, s) = Create(max: size);
|
||||
await using var _ = s;
|
||||
|
||||
await client.WriteAsync(Path, LogixTypeCode.DInt, null, 4, data, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(1);
|
||||
ch.Sent[0][0].ShouldBe((byte)0x4D);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LogixTypeCode.DInt, 52)] // room 54 -> 52 (13 DINTs)
|
||||
[InlineData(LogixTypeCode.LInt, 48)] // room 54 -> 48 (6 LINTs)
|
||||
[InlineData(LogixTypeCode.Int, 54)] // room 54 -> 54 (27 INTs)
|
||||
public async Task Fragmented_write_chunks_align_to_element_width(ushort type, int expectedChunk)
|
||||
{
|
||||
const int room = 54;
|
||||
var (ch, client, s) = Create(max: room + 12 + Path.Length);
|
||||
await using var _ = s;
|
||||
var width = LogixTypeCode.ByteWidth(type);
|
||||
var data = Bytes(width * 40, 7); // 40 elements, more than fits in one frame
|
||||
var elements = (ushort)40;
|
||||
|
||||
await client.WriteAsync(Path, type, null, elements, data, Ct);
|
||||
|
||||
ch.Sent.ShouldAllBe(r => r[0] == 0x53);
|
||||
ch.Sent.ShouldAllBe(r => r.Length <= ch.MaxRequestBytes);
|
||||
var reassembled = new List<byte>();
|
||||
foreach (var r in ch.Sent)
|
||||
{
|
||||
var body = r.AsSpan(2 + Path.Length);
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body).ShouldBe(type);
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body[2..]).ShouldBe(elements); // total element count
|
||||
var offset = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]);
|
||||
offset.ShouldBe((uint)reassembled.Count);
|
||||
var chunk = body[8..];
|
||||
(offset % (uint)width).ShouldBe(0u);
|
||||
reassembled.AddRange(chunk.ToArray());
|
||||
}
|
||||
reassembled.ShouldBe(data);
|
||||
ch.Sent.Take(ch.Sent.Count - 1).ShouldAllBe(r => r.Length - (2 + Path.Length + 8) == expectedChunk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fragmented_struct_write_carries_the_handle_in_every_chunk()
|
||||
{
|
||||
const int room = 40;
|
||||
var (ch, client, s) = Create(max: room + 12 + Path.Length);
|
||||
await using var _ = s;
|
||||
var data = Bytes(100, 3); // 10 elements of a 10-byte struct
|
||||
|
||||
await client.WriteAsync(Path, LogixTypeCode.Struct, 0x1234, 10, data, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(3); // 40 + 40 + 20
|
||||
foreach (var r in ch.Sent)
|
||||
{
|
||||
var body = r.AsSpan(2 + Path.Length);
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body).ShouldBe(LogixTypeCode.Struct);
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body[2..]).ShouldBe((ushort)0x1234);
|
||||
r.Length.ShouldBeLessThanOrEqualTo(ch.MaxRequestBytes);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_struct_element_wider_than_one_frame_is_written_in_8_byte_aligned_contiguous_chunks()
|
||||
{
|
||||
const int room = 45; // aligns down to 40
|
||||
var (ch, client, s) = Create(max: room + 12 + Path.Length);
|
||||
await using var _ = s;
|
||||
var data = Bytes(200, 9); // 2 elements of a 100-byte struct (> room)
|
||||
|
||||
await client.WriteAsync(Path, LogixTypeCode.Struct, 0x0ABC, 2, data, Ct);
|
||||
|
||||
var reassembled = new List<byte>();
|
||||
foreach (var r in ch.Sent)
|
||||
{
|
||||
r[0].ShouldBe((byte)0x53);
|
||||
r.Length.ShouldBeLessThanOrEqualTo(ch.MaxRequestBytes);
|
||||
var body = r.AsSpan(2 + Path.Length);
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body).ShouldBe(LogixTypeCode.Struct);
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body[2..]).ShouldBe((ushort)0x0ABC); // handle
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body[4..]).ShouldBe((ushort)2); // total elements
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body[6..]).ShouldBe((uint)reassembled.Count); // contiguous
|
||||
var chunk = body[10..];
|
||||
(chunk.Length % 8).ShouldBe(0);
|
||||
reassembled.AddRange(chunk.ToArray());
|
||||
}
|
||||
ch.Sent.Count.ShouldBe(5);
|
||||
reassembled.ShouldBe(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_that_never_stops_answering_partial_is_capped()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, new byte[4000]));
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => client.ReadAsync(Path, 1000, Ct));
|
||||
|
||||
ex.Status.ShouldBe(AbStatus.TooLarge);
|
||||
ch.Sent.Count.ShouldBe(LogixTagClient.MaxReadBytes / 4000 + 1); // stops at the first chunk past the cap
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LogixTypeCode.DInt, 4, 15)]
|
||||
[InlineData(LogixTypeCode.DInt, 4, 17)]
|
||||
[InlineData(LogixTypeCode.LReal, 2, 8)]
|
||||
public async Task An_atomic_write_whose_data_does_not_match_its_element_count_is_rejected_before_the_wire(
|
||||
ushort type, int elements, int length)
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
() => client.WriteAsync(Path, type, null, (ushort)elements, Bytes(length, 0), Ct));
|
||||
ch.SentCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_write_throws_with_the_reply_status()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x0F, []);
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(
|
||||
() => client.WriteAsync(Path, LogixTypeCode.DInt, null, 1, Bytes(4, 0), Ct));
|
||||
ex.Status.ShouldBe(AbStatusCodes.FromCip(0x0F, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_fragment_stops_the_write()
|
||||
{
|
||||
var (ch, client, s) = Create(max: 54 + 12 + Path.Length);
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x08, []);
|
||||
|
||||
await Should.ThrowAsync<AbTransportException>(
|
||||
() => client.WriteAsync(Path, LogixTypeCode.DInt, null, 40, Bytes(160, 0), Ct));
|
||||
ch.Sent.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user