Driver.AbLegacy-001 — PCCC bit-index range. AbLegacyAddress.TryParse accepted a bit index of 0..31 for every file type, but a 16-bit N/B/I/O/S/A word only has bits 0..15. TryParse now range-checks the bit index against the file's word width (0..15 for 16-bit element files, 0..31 for the 32-bit L file, no bits on float files), so addresses like N7:0/20 are rejected at parse time instead of silently truncating in the (short) cast. WriteBitInWordAsync reads and writes an L-file parent word as 32-bit Long and masks the RMW arithmetic to the native width, so a sign-extended 16-bit decode can no longer corrupt the high bits. Driver.AbLegacy-006 — shared-runtime concurrency. A per-tag libplctag Tag handle is cached and reused by both the server read path and the poll loop, with no synchronisation around Read/GetStatus/DecodeValue. Added a per-runtime SemaphoreSlim (DeviceState.GetRuntimeLock, keyed by tag name); ReadAsync and WriteAsync now hold it across the whole Read -> GetStatus -> Decode / Encode -> Write -> GetStatus sequence so no two threads touch the same Tag handle concurrently. Added xUnit + Shouldly regression coverage: AbLegacyBitIndexRangeTests (per-file bit-range validation + L-file 32-bit RMW + sign-extension safety) and AbLegacyRuntimeConcurrencyTests (overlap-detecting fake proving concurrent read/read and read/write are serialised). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
4.5 KiB
C#
121 lines
4.5 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression coverage for Driver.AbLegacy-006 — a per-tag libplctag runtime (a single
|
|
/// <c>Tag</c> handle) is cached and shared between the server read path and the poll loop.
|
|
/// A <c>Tag</c> is not safe for concurrent operations, so the driver must serialise the
|
|
/// Read → GetStatus → DecodeValue (and Encode → Write → GetStatus) sequence per runtime.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbLegacyRuntimeConcurrencyTests
|
|
{
|
|
/// <summary>
|
|
/// A fake runtime that records the maximum number of operations in flight against the
|
|
/// <em>same</em> handle. If the driver fails to serialise, two callers overlap inside the
|
|
/// Read → GetStatus → Decode window and <see cref="MaxConcurrent"/> exceeds 1.
|
|
/// </summary>
|
|
private sealed class OverlapDetectingFake : FakeAbLegacyTag
|
|
{
|
|
private int _inFlight;
|
|
public int MaxConcurrent { get; private set; }
|
|
|
|
public OverlapDetectingFake(AbLegacyTagCreateParams p) : base(p) { }
|
|
|
|
public override async Task ReadAsync(CancellationToken ct)
|
|
{
|
|
EnterOp();
|
|
try
|
|
{
|
|
// Yield + small delay so an unserialised second caller is guaranteed to overlap.
|
|
await Task.Delay(15, ct).ConfigureAwait(false);
|
|
await base.ReadAsync(ct).ConfigureAwait(false);
|
|
}
|
|
finally { LeaveOp(); }
|
|
}
|
|
|
|
public override async Task WriteAsync(CancellationToken ct)
|
|
{
|
|
EnterOp();
|
|
try
|
|
{
|
|
await Task.Delay(15, ct).ConfigureAwait(false);
|
|
await base.WriteAsync(ct).ConfigureAwait(false);
|
|
}
|
|
finally { LeaveOp(); }
|
|
}
|
|
|
|
private void EnterOp()
|
|
{
|
|
var n = Interlocked.Increment(ref _inFlight);
|
|
lock (this) { if (n > MaxConcurrent) MaxConcurrent = n; }
|
|
}
|
|
|
|
private void LeaveOp() => Interlocked.Decrement(ref _inFlight);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_reads_of_same_tag_are_serialised_against_the_shared_runtime()
|
|
{
|
|
OverlapDetectingFake? shared = null;
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p =>
|
|
{
|
|
shared = new OverlapDetectingFake(p) { Value = 7 };
|
|
return shared;
|
|
},
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Eight callers race for the same tag — mimics the server read path + poll loop(s)
|
|
// hitting one cached runtime at once.
|
|
var reads = Enumerable.Range(0, 8)
|
|
.Select(_ => drv.ReadAsync(["X"], CancellationToken.None))
|
|
.ToArray();
|
|
await Task.WhenAll(reads);
|
|
|
|
shared.ShouldNotBeNull();
|
|
shared!.MaxConcurrent.ShouldBe(1, "operations on a shared libplctag Tag must not overlap");
|
|
reads.ShouldAllBe(r => r.Result.Single().Value!.Equals(7));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_read_and_write_of_same_tag_do_not_overlap()
|
|
{
|
|
OverlapDetectingFake? shared = null;
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p =>
|
|
{
|
|
shared = new OverlapDetectingFake(p) { Value = 1 };
|
|
return shared;
|
|
},
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var readTask = drv.ReadAsync(["X"], CancellationToken.None);
|
|
var writeTask = drv.WriteAsync([new WriteRequest("X", 99)], CancellationToken.None);
|
|
await Task.WhenAll(readTask, writeTask);
|
|
|
|
shared.ShouldNotBeNull();
|
|
shared!.MaxConcurrent.ShouldBe(1);
|
|
}
|
|
}
|