Merge #572/#575: native browse paging/lease/pacing, differential equal-refusal + budget isolation
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe # Conflicts: # docs/drivers/AbCip.md
This commit is contained in:
+11
@@ -56,6 +56,17 @@ public sealed class SymbolObjectServicesTests
|
||||
more.ShouldBeTrue(); last.ShouldBe(5u); entries.ShouldBe(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxInstance_is_the_highest_in_the_page_whatever_its_order()
|
||||
{
|
||||
static string Entry(uint i) => BitConverter.ToString(BitConverter.GetBytes(i)).Replace("-", "")
|
||||
+ "C400" + "0400" + "00000000" + "00000000" + "00000000" + "0100" + "41";
|
||||
var entries = Convert.FromHexString(Entry(9) + Entry(3) + Entry(5));
|
||||
var (_, max, more) = SymbolObjectServices.ParseListingReply(new CipReply(0x55, 0x06, [], entries));
|
||||
more.ShouldBeTrue();
|
||||
max.ShouldBe(9u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_status_throws() =>
|
||||
Should.Throw<AbTransportException>(() =>
|
||||
|
||||
+34
-3
@@ -27,6 +27,13 @@ internal sealed class TransportObservations
|
||||
public Dictionary<uint, AbCipUdtShape?> Shapes { get; } = [];
|
||||
public Dictionary<uint, string> TemplateErrors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The transport status of each refused template, where the refusal was an <see cref="AbTransportException"/>.
|
||||
/// Lets parity tell a template BOTH transports refuse with the same status (an equal refusal) from a real
|
||||
/// divergence without parsing <see cref="TemplateErrors"/> text.
|
||||
/// </summary>
|
||||
public Dictionary<uint, AbStatus> TemplateErrorStatuses { get; } = [];
|
||||
|
||||
/// <summary>Template ids that were queued but not read because <c>AB_DIFF_MAX_TEMPLATES</c> was reached.</summary>
|
||||
public int TemplatesTruncated { get; set; }
|
||||
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
|
||||
@@ -320,6 +327,7 @@ internal static class DifferentialRun
|
||||
{
|
||||
if (IsConnectionLevel(ex)) throw Abort(phase, $"template {id}", ex);
|
||||
into.TemplateErrors[id] = Describe(ex);
|
||||
if (ex is AbTransportException ate) into.TemplateErrorStatuses[id] = ate.Status;
|
||||
}
|
||||
afterEach?.Invoke($"template {id}");
|
||||
}
|
||||
@@ -659,9 +667,22 @@ internal static class DifferentialRun
|
||||
a is not null && b is not null
|
||||
&& a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members);
|
||||
|
||||
/// <summary>
|
||||
/// Template ids BOTH transports refused with the SAME <see cref="AbStatus"/> and no shape on either side:
|
||||
/// the controller refuses that template and both transports report it identically, which is parity
|
||||
/// (Gitea #575) — not a divergence, and not a shape comparison either. A refusal with differing statuses,
|
||||
/// or one side refusing while the other decoded, is not listed here and stays a problem.
|
||||
/// </summary>
|
||||
internal static List<uint> EqualRefusals(TransportObservations lib, TransportObservations native) =>
|
||||
[.. lib.TemplateErrorStatuses.Keys
|
||||
.Where(id => !lib.Shapes.ContainsKey(id) && !native.Shapes.ContainsKey(id)
|
||||
&& native.TemplateErrorStatuses.TryGetValue(id, out var n) && n == lib.TemplateErrorStatuses[id])
|
||||
.Order()];
|
||||
|
||||
/// <summary>
|
||||
/// Template-parity problems; empty means parity. Vacuity is a problem: browse found structured tags
|
||||
/// but no template id was compared (review r1 I3).
|
||||
/// but no template id was compared (review r1 I3). An equal refusal (<see cref="EqualRefusals"/>) is
|
||||
/// parity and is not a problem, but is not a comparison either: it does not satisfy the vacuity check.
|
||||
/// </summary>
|
||||
internal static List<string> TemplateProblems(TransportObservations lib, TransportObservations native)
|
||||
{
|
||||
@@ -669,19 +690,29 @@ internal static class DifferentialRun
|
||||
var ids = lib.Shapes.Keys.Concat(lib.TemplateErrors.Keys)
|
||||
.Concat(native.Shapes.Keys).Concat(native.TemplateErrors.Keys)
|
||||
.Distinct().Order().ToList();
|
||||
var refused = EqualRefusals(lib, native).ToHashSet();
|
||||
var structured = lib.Browse.Concat(native.Browse).Count(t => t.DataType == AbCipDataType.Structure);
|
||||
if (structured > 0 && ids.Count == 0)
|
||||
if (structured > 0 && ids.Count(id => !refused.Contains(id)) == 0)
|
||||
problems.Add($"vacuous: browse found {structured} structured tag(s) but no template id was compared");
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (refused.Contains(id)) continue;
|
||||
var libShape = lib.Shapes.GetValueOrDefault(id);
|
||||
var nativeShape = native.Shapes.GetValueOrDefault(id);
|
||||
if (!ShapesEqual(libShape, nativeShape))
|
||||
problems.Add($"template {id}: " + (libShape is null || nativeShape is null ? "undecodable or unread on a side" : "shapes differ"));
|
||||
problems.Add($"template {id}: " + Divergence(lib, native, id, libShape, nativeShape));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
private static string Divergence(
|
||||
TransportObservations lib, TransportObservations native, uint id, AbCipUdtShape? libShape, AbCipUdtShape? nativeShape)
|
||||
{
|
||||
if (lib.TemplateErrorStatuses.TryGetValue(id, out var l) && native.TemplateErrorStatuses.TryGetValue(id, out var n) && l != n)
|
||||
return $"refused with different statuses (libplctag {l}, native {n})";
|
||||
return libShape is null || nativeShape is null ? "undecodable or unread on a side" : "shapes differ";
|
||||
}
|
||||
|
||||
internal static string Describe(Exception ex) => ex is AbTransportException ate
|
||||
? $"{ate.Status}: {ate.Message}"
|
||||
: $"{ex.GetType().Name}: {ex.Message}";
|
||||
|
||||
+86
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
@@ -99,6 +100,91 @@ public sealed class DifferentialSafetyTests
|
||||
DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_template_both_transports_refuse_with_the_same_status_is_an_equal_refusal_not_a_problem()
|
||||
{
|
||||
var shape = new AbCipUdtShape("Udt", 4, [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)]);
|
||||
var lib = new TransportObservations();
|
||||
var native = new TransportObservations();
|
||||
lib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
|
||||
lib.Browse.Add(new AbCipDiscoveredTag("V", null, AbCipDataType.Structure, false, TemplateInstanceId: 8));
|
||||
// 8 decodes on both sides (a real comparison, so the run is not vacuous); 7 is refused by both.
|
||||
lib.Shapes[8] = shape;
|
||||
native.Shapes[8] = shape;
|
||||
lib.TemplateErrors[7] = "NotFound: general 0x05 (libplctag wording)";
|
||||
native.TemplateErrors[7] = "NotFound: Read Template 7 failed: general 0x05 (native wording)";
|
||||
lib.TemplateErrorStatuses[7] = AbStatus.NotFound;
|
||||
native.TemplateErrorStatuses[7] = AbStatus.NotFound;
|
||||
|
||||
DifferentialRun.EqualRefusals(lib, native).ShouldBe(new uint[] { 7 });
|
||||
DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty();
|
||||
|
||||
// Positive control: the same ids with DIFFERENT statuses are still a problem, naming both statuses.
|
||||
native.TemplateErrorStatuses[7] = AbStatus.NotAllowed;
|
||||
DifferentialRun.EqualRefusals(lib, native).ShouldBeEmpty();
|
||||
var differ = DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem();
|
||||
differ.ShouldContain("template 7");
|
||||
differ.ShouldContain("different statuses");
|
||||
|
||||
// One side refusing while the other decoded is a problem too.
|
||||
native.TemplateErrors.Remove(7);
|
||||
native.TemplateErrorStatuses.Remove(7);
|
||||
native.Shapes[7] = shape;
|
||||
DifferentialRun.EqualRefusals(lib, native).ShouldBeEmpty();
|
||||
DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("template 7");
|
||||
|
||||
// A run whose only templates are equal refusals compared no shape: still vacuous, not silently green.
|
||||
var onlyLib = new TransportObservations();
|
||||
var onlyNative = new TransportObservations();
|
||||
onlyLib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
|
||||
onlyLib.TemplateErrorStatuses[7] = onlyNative.TemplateErrorStatuses[7] = AbStatus.NotFound;
|
||||
onlyLib.TemplateErrors[7] = onlyNative.TemplateErrors[7] = "NotFound";
|
||||
DifferentialRun.TemplateProblems(onlyLib, onlyNative).ShouldHaveSingleItem().ShouldContain("vacuous");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_open_attempt_budget_alone_stops_a_second_open_even_when_nothing_aborts_the_phase()
|
||||
{
|
||||
// Unlike the abort test above, the body here SWALLOWS every failure and keeps going, so the harness's
|
||||
// abort-on-first-failure can never end the phase: only CipConnection.OpenAttemptBudget stands between
|
||||
// the second request and the wire.
|
||||
await using var endpoint = HangUpListener.Start();
|
||||
var target = Target(endpoint.Port);
|
||||
var statuses = new List<AbStatus>();
|
||||
string? secondMessage = null;
|
||||
var attemptsBefore = Volatile.Read(ref CipConnection.OpenAttempts);
|
||||
var tags = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
|
||||
|
||||
await DifferentialRun.WithNativeAnchorAsync("native", target, async _ =>
|
||||
{
|
||||
// Two independent tag initialisations, each of which needs the connection open. Failures are
|
||||
// swallowed here on purpose (the real harness would abort on the first).
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var runtime = tags.Create(target.Params("A", 1, false, AbCipDataType.DInt));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
statuses.Add(ex.Status);
|
||||
if (i == 1) secondMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
statuses.Count.ShouldBe(2, "both requests failed; neither aborted the phase");
|
||||
statuses[1].ShouldBe(AbStatus.Abort);
|
||||
secondMessage.ShouldNotBeNull().ShouldContain("budget");
|
||||
Volatile.Read(ref CipConnection.OpenAttempts).ShouldBe(attemptsBefore + 2, "the second attempt was counted, then refused");
|
||||
// Long enough for a throttled reconnect to have reached the listener had the budget let one through.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1.5), TestContext.Current.CancellationToken);
|
||||
endpoint.Connections.ShouldBe(1, "exactly the one budgeted open attempt reached the wire");
|
||||
Volatile.Read(ref CipConnection.OpenAttemptBudget).ShouldBe(-1, "the budget is restored afterwards");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping()
|
||||
{
|
||||
|
||||
+5
-1
@@ -78,11 +78,15 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
|
||||
Log("| Template | libplctag | native | Equal? |");
|
||||
Log("|---|---|---|---|");
|
||||
}
|
||||
var refused = DifferentialRun.EqualRefusals(r.Libplctag, r.Native).ToHashSet();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var equal = DifferentialRun.ShapesEqual(r.Libplctag.Shapes.GetValueOrDefault(id), r.Native.Shapes.GetValueOrDefault(id));
|
||||
Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {(equal ? "yes" : "**NO**")} |");
|
||||
var verdict = refused.Contains(id) ? "equal refusal" : equal ? "yes" : "**NO**";
|
||||
Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {verdict} |");
|
||||
}
|
||||
if (refused.Count > 0)
|
||||
Log($"- EQUAL REFUSAL (not a divergence; both transports refuse with the same status): {string.Join(", ", refused)}");
|
||||
|
||||
var problems = DifferentialRun.TemplateProblems(r.Libplctag, r.Native);
|
||||
foreach (var p in problems) Log($"- PROBLEM: {p}");
|
||||
|
||||
@@ -253,6 +253,50 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #572: a browse that fetches many templates (a UDT and its nested UDT here) reads them all
|
||||
/// through ONE batch reader, so the native transport holds one device lease across them rather than
|
||||
/// leasing per template. The batch reader is disposed when the device's browse ends.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discovery_reads_every_template_through_one_batch_reader()
|
||||
{
|
||||
const string device = "ab://10.0.0.5/1,0";
|
||||
const uint motorTemplateId = 0x2A;
|
||||
const uint statusTemplateId = 0x123;
|
||||
var enumeratorFactory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: motorTemplateId),
|
||||
new AbCipDiscoveredTag("Motor2", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: 0x2B));
|
||||
var templates = new IdKeyedTemplateReaderFactory
|
||||
{
|
||||
Templates =
|
||||
{
|
||||
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12,
|
||||
("Speed", 0xCA, 0, 0), ("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)),
|
||||
[0x2B] = BuildSimpleTemplate("Motor2Udt", 4, ("Speed", 0xCA, 0, 0)),
|
||||
[statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4, ("Code", 0xC4, 0, 0)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(device)],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templates);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
|
||||
templates.RequestedIds.Count.ShouldBe(3);
|
||||
templates.BatchReaders.ShouldBe(1);
|
||||
templates.PerCallReaders.ShouldBe(0);
|
||||
templates.BatchReadersDisposed.ShouldBe(1);
|
||||
|
||||
// Outside a discovery run a fetch is still a self-contained per-call read.
|
||||
await drv.FetchUdtShapeAsync(device, 0x2B, CancellationToken.None);
|
||||
await drv.FetchUdtShapeAsync(device, 0x999, CancellationToken.None);
|
||||
templates.PerCallReaders.ShouldBe(1, "0x2B is cached; 0x999 is not");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PRODUCTION PATH (no test seam): a controller-discovered UDT whose Template Object carries
|
||||
/// a NESTED struct member fans the nested member out into its own atomic leaves WITHOUT any
|
||||
@@ -799,10 +843,20 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
/// <summary>Gets every template id any reader was asked for, in request order.</summary>
|
||||
public List<uint> RequestedIds { get; } = new();
|
||||
|
||||
/// <summary>Creates a reader over the shared template map.</summary>
|
||||
public IAbCipTemplateReader Create() => new Reader(this);
|
||||
/// <summary>Gets how many per-call readers were created.</summary>
|
||||
public int PerCallReaders { get; private set; }
|
||||
/// <summary>Gets how many batch readers were created.</summary>
|
||||
public int BatchReaders { get; private set; }
|
||||
/// <summary>Gets how many batch readers have been disposed.</summary>
|
||||
public int BatchReadersDisposed { get; set; }
|
||||
|
||||
private sealed class Reader(IdKeyedTemplateReaderFactory outer) : IAbCipTemplateReader
|
||||
/// <summary>Creates a reader over the shared template map.</summary>
|
||||
public IAbCipTemplateReader Create() { PerCallReaders++; return new Reader(this); }
|
||||
|
||||
/// <summary>Creates a batch reader (disposal is counted).</summary>
|
||||
public IAbCipTemplateReader CreateBatch() { BatchReaders++; return new Reader(this, batch: true); }
|
||||
|
||||
private sealed class Reader(IdKeyedTemplateReaderFactory outer, bool batch = false) : IAbCipTemplateReader
|
||||
{
|
||||
public Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
|
||||
@@ -813,7 +867,7 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
public void Dispose() { if (batch) outer.BatchReadersDisposed++; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -260,4 +260,18 @@ public sealed class AbCipTransportSwitchTests
|
||||
pinned.Ok.ShouldBeFalse();
|
||||
pinned.Message!.ShouldNotContain("Connection lost");
|
||||
}
|
||||
|
||||
/// <summary>Gitea #572: <c>browsePageDelayMs</c> parses (default 5 ms, 0 allowed, negative clamps to 0).</summary>
|
||||
[Theory]
|
||||
[InlineData(null, 5)]
|
||||
[InlineData("0", 0)]
|
||||
[InlineData("25", 25)]
|
||||
[InlineData("-3", 0)]
|
||||
public void Config_browsePageDelayMs_parses(string? json, int expectedMs)
|
||||
{
|
||||
var config = $$"""{{{(json is null ? "" : $"\"browsePageDelayMs\":{json},")}}"devices":[{{Device}}]}""";
|
||||
|
||||
AbCipDriverFactoryExtensions.ParseOptions("ab1", config).BrowsePageDelay
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(expectedMs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,108 @@ public sealed class NativeAbCipTagEnumeratorTests
|
||||
ch.Gate.TrySetResult();
|
||||
}
|
||||
|
||||
/// <summary>One listing entry named "A" with the given instance id.</summary>
|
||||
private static byte[] EntryOf(uint instance)
|
||||
{
|
||||
var entry = new byte[22 + 1];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(entry, instance);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(20), 1);
|
||||
entry[22] = (byte)'A';
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>The start instance of a controller-scope 0x55 listing request.</summary>
|
||||
private static uint StartOf(byte[] req) =>
|
||||
req.AsSpan(2, req[1] * 2)[2] == 0x25
|
||||
? BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(2 + 4))
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(req.AsSpan(2 + 4));
|
||||
|
||||
/// <summary>Serves scripted pages (instances per page, in the order given); the last page is final.</summary>
|
||||
private static FakeCipChannel ServePages(List<uint> starts, params uint[][] pages)
|
||||
{
|
||||
var ch = new FakeCipChannel();
|
||||
var i = 0;
|
||||
ch.RespondPerService = req =>
|
||||
{
|
||||
starts.Add(StartOf(req));
|
||||
var page = pages[Math.Min(i, pages.Length - 1)];
|
||||
var last = i >= pages.Length - 1;
|
||||
i++;
|
||||
return FakeCipChannel.Reply(0x55, last ? (byte)0x00 : (byte)0x06, [.. page.SelectMany(EntryOf)]);
|
||||
};
|
||||
return ch;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paging_continues_past_the_highest_instance_even_when_a_page_is_listed_out_of_order()
|
||||
{
|
||||
// Page 1 ends on a LOW instance (5) though it holds 9: continuing at last + 1 would restart at 6 and
|
||||
// re-list 9. The reader must continue at max + 1 = 10.
|
||||
var starts = new List<uint>();
|
||||
var ch = ServePages(starts, [9, 3, 5], [12, 10]);
|
||||
await using var sched = new RequestScheduler(ch, allowPacking: false);
|
||||
|
||||
var bytes = await NativeListingReader.ReadAsync(sched, "@tags", TestContext.Current.CancellationToken);
|
||||
|
||||
starts.ShouldBe(new uint[] { 0, 10 });
|
||||
bytes.Length.ShouldBe(5 * 23);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_out_of_order_page_that_stays_below_its_start_is_a_no_progress_failure()
|
||||
{
|
||||
// Page 2 starts at 10 but only answers instances below it: highest-so-far did not advance.
|
||||
var starts = new List<uint>();
|
||||
var ch = new FakeCipChannel();
|
||||
var n = 0;
|
||||
ch.RespondPerService = req =>
|
||||
{
|
||||
starts.Add(StartOf(req));
|
||||
return FakeCipChannel.Reply(0x55, 0x06, [.. (n++ == 0 ? new uint[] { 9, 3 } : new uint[] { 4, 2 }).SelectMany(EntryOf)]);
|
||||
};
|
||||
await using var sched = new RequestScheduler(ch, allowPacking: false);
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(
|
||||
() => NativeListingReader.ReadAsync(sched, "@tags", TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Status.ShouldBe(AbStatus.BadReply);
|
||||
ex.Message.ShouldContain("no progress");
|
||||
starts.ShouldBe(new uint[] { 0, 10 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pages_after_the_first_are_paced_and_a_zero_delay_is_back_to_back()
|
||||
{
|
||||
var time = new InstantTimeProvider();
|
||||
var pages = new uint[][] { [1], [2], [3], [4] };
|
||||
await using var sched = new RequestScheduler(ServePages([], pages), allowPacking: false);
|
||||
|
||||
await NativeListingReader.ReadAsync(sched, "@tags", TestContext.Current.CancellationToken,
|
||||
TimeSpan.FromMilliseconds(7), time);
|
||||
|
||||
// 4 pages => 3 pauses, none before the first request.
|
||||
time.Delays.ShouldBe(Enumerable.Repeat(TimeSpan.FromMilliseconds(7), 3));
|
||||
|
||||
var none = new InstantTimeProvider();
|
||||
await using var sched2 = new RequestScheduler(ServePages([], pages), allowPacking: false);
|
||||
await NativeListingReader.ReadAsync(sched2, "@tags", TestContext.Current.CancellationToken, TimeSpan.Zero, none);
|
||||
none.Delays.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_enumerator_hands_its_page_delay_to_every_listing_it_reads()
|
||||
{
|
||||
var time = new InstantTimeProvider();
|
||||
var ch = Serve(new() { [""] = Entries(ControllerEntries) }); // 3 pages
|
||||
await using var sched = new RequestScheduler(ch, allowPacking: false);
|
||||
using var enumerator = new NativeAbCipTagEnumerator(
|
||||
_ => (sched, () => ValueTask.CompletedTask), pageDelay: TimeSpan.FromMilliseconds(3), time: time);
|
||||
|
||||
await foreach (var _ in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken)) { }
|
||||
|
||||
time.Delays.Count.ShouldBe(2 + 0); // controller listing: 3 pages => 2 pauses; the program listing answers 0x05
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_release_delegate_never_escapes_ReleaseBounded()
|
||||
{
|
||||
@@ -209,4 +311,24 @@ public sealed class NativeAbCipTagEnumeratorTests
|
||||
NativeLease.ReleaseBounded(() => ValueTask.FromException(new ObjectDisposedException("x")),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
|
||||
}
|
||||
|
||||
/// <summary>A clock whose timers fire at once, recording each requested due time.</summary>
|
||||
private sealed class InstantTimeProvider : TimeProvider
|
||||
{
|
||||
public List<TimeSpan> Delays { get; } = [];
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
lock (Delays) Delays.Add(dueTime);
|
||||
ThreadPool.QueueUserWorkItem(_ => callback(state));
|
||||
return new NoopTimer();
|
||||
}
|
||||
|
||||
private sealed class NoopTimer : ITimer
|
||||
{
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
||||
public void Dispose() { }
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,4 +194,80 @@ public sealed class NativeAbCipTemplateReaderTests
|
||||
ex.Status.ShouldBe(AbStatus.BadReply);
|
||||
ch.Sent.ShouldNotContain(r => r[0] == 0x4C);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_batch_reader_leases_once_for_many_reads_and_releases_on_dispose()
|
||||
{
|
||||
var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate);
|
||||
var id = BinaryPrimitives.ReadUInt16LittleEndian(captured);
|
||||
var ch = ServeTemplate(captured, 504);
|
||||
await using var sched = new RequestScheduler(ch, allowPacking: false);
|
||||
int acquired = 0, released = 0;
|
||||
var reader = new NativeAbCipTemplateReader(_ =>
|
||||
{
|
||||
acquired++;
|
||||
return (sched, ch, () => { released++; return ValueTask.CompletedTask; });
|
||||
}, holdLeases: true);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await reader.ReadAsync(Params, id, TestContext.Current.CancellationToken);
|
||||
|
||||
acquired.ShouldBe(1);
|
||||
released.ShouldBe(0, "the lease is held across the batch");
|
||||
reader.Dispose();
|
||||
released.ShouldBe(1);
|
||||
reader.Dispose();
|
||||
released.ShouldBe(1, "a second dispose releases nothing more");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_batch_reader_leases_each_device_once()
|
||||
{
|
||||
var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate);
|
||||
var id = BinaryPrimitives.ReadUInt16LittleEndian(captured);
|
||||
var ch = ServeTemplate(captured, 504);
|
||||
await using var sched = new RequestScheduler(ch, allowPacking: false);
|
||||
var acquired = new List<string>();
|
||||
using var reader = new NativeAbCipTemplateReader(p =>
|
||||
{
|
||||
acquired.Add(p.Gateway);
|
||||
return (sched, ch, () => ValueTask.CompletedTask);
|
||||
}, holdLeases: true);
|
||||
|
||||
foreach (var host in new[] { "10.0.0.5", "10.0.0.6", "10.0.0.5", "10.0.0.6" })
|
||||
await reader.ReadAsync(Params with { Gateway = host }, id, TestContext.Current.CancellationToken);
|
||||
|
||||
acquired.ShouldBe(["10.0.0.5", "10.0.0.6"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_default_reader_still_leases_per_read()
|
||||
{
|
||||
var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate);
|
||||
var id = BinaryPrimitives.ReadUInt16LittleEndian(captured);
|
||||
var ch = ServeTemplate(captured, 504);
|
||||
await using var sched = new RequestScheduler(ch, allowPacking: false);
|
||||
int acquired = 0, released = 0;
|
||||
using var reader = new NativeAbCipTemplateReader(_ =>
|
||||
{
|
||||
acquired++;
|
||||
return (sched, ch, () => { released++; return ValueTask.CompletedTask; });
|
||||
});
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
await reader.ReadAsync(Params, id, TestContext.Current.CancellationToken);
|
||||
|
||||
(acquired, released).ShouldBe((3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_native_factory_hands_out_a_lease_holding_reader_for_a_batch()
|
||||
{
|
||||
var factory = new NativeAbCipTemplateReaderFactory();
|
||||
using var batch = factory.CreateBatch();
|
||||
batch.ShouldBeOfType<NativeAbCipTemplateReader>();
|
||||
// The libplctag factory keeps the default: a batch is just a reader.
|
||||
using var lib = ((IAbCipTemplateReaderFactory)new LibplctagTemplateReaderFactory()).CreateBatch();
|
||||
lib.ShouldBeOfType<LibplctagTemplateReader>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user