fix(driver-ablegacy): resolve High code-review findings (Driver.AbLegacy-001, Driver.AbLegacy-006)

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>
This commit is contained in:
Joseph Doherty
2026-05-22 06:33:10 -04:00
parent 8a7668c678
commit d89be2a011
5 changed files with 334 additions and 20 deletions

View File

@@ -0,0 +1,106 @@
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-001 — a PCCC bit index must be range-checked
/// against the parent word width: 0..15 for 16-bit element files (N/B/I/O/S/A), 0..31 for
/// the 32-bit L file. Float files are not bit-addressable.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyBitIndexRangeTests
{
[Theory]
[InlineData("N7:0/15")]
[InlineData("B3:0/15")]
[InlineData("I:0/15")]
[InlineData("O:1/15")]
[InlineData("S:1/15")]
[InlineData("A10:0/15")]
public void Bit_index_0_to_15_accepted_on_16bit_files(string input) =>
AbLegacyAddress.TryParse(input).ShouldNotBeNull();
[Theory]
[InlineData("N7:0/16")] // first bit past a 16-bit word
[InlineData("N7:0/20")]
[InlineData("N7:0/31")]
[InlineData("B3:0/16")]
[InlineData("I:0/16")]
[InlineData("O:1/16")]
[InlineData("S:1/16")]
[InlineData("A10:0/16")]
public void Bit_index_above_15_rejected_on_16bit_files(string input) =>
AbLegacyAddress.TryParse(input).ShouldBeNull();
[Theory]
[InlineData("L9:0/0")]
[InlineData("L9:0/15")]
[InlineData("L9:0/16")] // L-file words are 32-bit, so 16..31 are valid
[InlineData("L9:0/31")]
public void Bit_index_0_to_31_accepted_on_L_file(string input) =>
AbLegacyAddress.TryParse(input).ShouldNotBeNull();
[Fact]
public void Bit_index_above_31_rejected_on_L_file() =>
AbLegacyAddress.TryParse("L9:0/32").ShouldBeNull();
[Theory]
[InlineData("F8:0/0")] // float files are not bit-addressable at all
[InlineData("F8:0/3")]
public void Bit_index_rejected_on_float_file(string input) =>
AbLegacyAddress.TryParse(input).ShouldBeNull();
[Fact]
public void Negative_bit_index_still_rejected() =>
AbLegacyAddress.TryParse("N7:0/-1").ShouldBeNull();
[Fact]
public async Task Bit_in_word_RMW_against_L_file_uses_32bit_parent_and_high_bit()
{
// L9:0/20 — bit 20 of a 32-bit L-file word. The parent must be read/written as a
// 32-bit Long so the high bits are addressable; a 16-bit (short)cast would truncate.
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = 0 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("LBit20", "ab://10.0.0.5/1,0", "L9:0/20", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("LBit20", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ShouldContainKey("L9:0");
Convert.ToInt32(factory.Tags["L9:0"].Value).ShouldBe(1 << 20);
}
[Fact]
public async Task Bit_in_word_RMW_high_bit_15_does_not_corrupt_via_sign_extension()
{
// Parent word has bit 15 set (0x8000) — DecodeValue returns a sign-extended negative
// int. Setting bit 0 must yield exactly 0x8001, not a sign-extended value.
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0x8000) },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
// (short)0x8001 round-trips through the fake as -32767.
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0x8001));
}
}

View File

@@ -0,0 +1,120 @@
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);
}
}