Merge #593 follow-up: AbLegacy native write refusals decided before any wire traffic (status-bit sub-elements of any type, PLC-5)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:29:18 -04:00
7 changed files with 281 additions and 21 deletions
+15 -4
View File
@@ -142,9 +142,17 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected
No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
controller.
- ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would
race the PLC updating TT/DN. Reads of status bits are unaffected.
and the rest return **`BadNotSupported`** whatever the tag's authored data type (the refusal keys on the
address); nothing is written to the PLC; health is unchanged. The driver answers the refusal **before it
creates the tag's runtime**, so a first-touch write sends nothing at all — not even the init read, which
against a controller without that file used to surface as `BadCommunicationError` + Degraded health (#593
follow-up). PLC-5 writes are refused the same way. libplctag performs a masked bit write here; a native
read-modify-write of the control word would race the PLC updating TT/DN. Reads of status bits are unaffected.
- ⚠️ **On libplctag (the default) a status-bit write is NOT refused** — it goes to the controller as a write to the
timer/counter/control element (libplctag's bit-tag write; not exercised on a wire — `ab_server` has no T/C/R
files). Nothing in authoring prevents it: the address is free text,
any `dataType` may be chosen (a status bit is naturally authored `Bit`), and `writable` defaults to `true`.
Author status-bit tags with `"writable": false` unless writing them is genuinely intended.
- **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
`LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
@@ -175,7 +183,10 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
control word); nothing goes on the wire — the driver refuses before creating the tag's runtime, so not even an
init read is sent (`NativeAbLegacyTagRuntime.WriteRefusal` is the single predicate, asked through
`IAbLegacyTagFactory.WriteRefusal`). Pinned offline by `NativeAbLegacyRefusedWriteTests` and on `ab_server` by
`AbLegacyUnsupportedPathTests` (whose SLC500 fixture has no T/C/R files, which is what makes it discriminating). Sub-element arrays are Unsupported natively. L-file bit indexes
above 15 are allowed.
- **ST (string) file layout is uncaptured**, so the string byte order is unverified natively.
- Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see
@@ -428,11 +428,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
var parsed = AbLegacyAddress.TryParse(def.Address);
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
// or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init
// READ, and against a device lacking that file the read failure surfaced as a comms fault —
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal (#593 follow-up).
// Ahead of the bit-RMW branch too, which would otherwise read the parent word first.
if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null)
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
continue;
}
// PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N
// bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every
// bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files
// (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements
// don't reach this path because they're not Bit typed. NOTE: an Input-image (I) write is sent
// (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a
// sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent
// to the PLC like any other write — the device drives the input image from physical inputs and
// may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit)
@@ -817,6 +829,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
keep();
}
/// <summary>The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so
/// the refusal is asked about exactly the runtime a write would otherwise create.</summary>
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
new(Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
ElementCount: EffectiveArrayLength(def));
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
@@ -835,14 +858,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
?? throw new InvalidOperationException(
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
ElementCount: EffectiveArrayLength(def)));
var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed));
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
.ConfigureAwait(false);
return runtime;
@@ -58,6 +58,16 @@ public interface IAbLegacyTagFactory
/// <param name="createParams">The tag creation parameters.</param>
/// <returns>The created tag runtime instance.</returns>
IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams);
/// <summary>
/// Why a runtime from this factory would refuse a WRITE to <paramref name="createParams"/> before anything
/// reached the wire, or <see langword="null"/> when it would attempt it. The driver asks this BEFORE it
/// creates or initialises a runtime for a write, so a refused write never costs the init read either —
/// which against a device lacking the file would otherwise surface as a comms fault.
/// </summary>
/// <param name="createParams">The parameters the write's runtime would be created with.</param>
/// <returns>The refusal reason, or <see langword="null"/> when the write is attempted.</returns>
string? WriteRefusal(AbLegacyTagCreateParams createParams) => null;
}
/// <param name="Gateway">The PLC gateway host (IP or DNS).</param>
@@ -11,4 +11,7 @@ internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) :
/// <inheritdoc />
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
new NativeAbLegacyTagRuntime(createParams, _pool);
/// <inheritdoc />
public string? WriteRefusal(AbLegacyTagCreateParams createParams) => NativeAbLegacyTagRuntime.WriteRefusal(createParams);
}
@@ -31,6 +31,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
private readonly int _elementSize;
private readonly int _count;
private readonly byte[] _buffer;
private readonly string? _writeRefusal;
private int _status;
/// <summary>Creates a runtime that leases the shared connection for its device from <paramref name="pool"/>.</summary>
@@ -64,6 +65,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
BinaryPrimitives.WriteUInt16LittleEndian(_requestorId.AsSpan(1), vendor);
BinaryPrimitives.WriteUInt32LittleEndian(_requestorId.AsSpan(3), serial);
_count = Math.Max(1, p.ElementCount);
_writeRefusal = WriteRefusal(p);
try
{
@@ -113,6 +115,45 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
};
}
/// <summary>
/// The single authority for the writes this runtime refuses before the wire, or <see langword="null"/>
/// when it attempts the write. <see cref="WriteCoreAsync"/> throws on it, and the driver asks it (via
/// <see cref="NativeAbLegacyTagFactory.WriteRefusal"/>) before creating a runtime, so a refused write never
/// costs the init read either (Gitea #593 follow-up).
/// </summary>
/// <remarks>
/// <para><b>PLC-5:</b> every write — no capture of the PLC-5 write frames exists.</para>
/// <para><b>Status-bit sub-elements</b> (<c>T4:0.DN</c>, <c>C5:0.CU</c>, <c>R6:0.EN</c>…, whatever the tag's
/// authored type): libplctag does a masked bit write (0xAB) here; there is no capture of it, and a
/// read-modify-write would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN.</para>
/// <para>An address this runtime cannot parse is NOT a write refusal: the runtime's own address check refuses
/// every operation on it, read included, with the same <see cref="AbStatus.Unsupported"/>.</para>
/// </remarks>
/// <param name="p">The tag's create parameters (family + address).</param>
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
internal static string? WriteRefusal(AbLegacyTagCreateParams p)
{
if (p.LibplctagPlcAttribute == "plc5") return "PLC-5 writes are not capture-verified.";
return StatusBitOf(p.TagName) is not null
? "Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport."
: null;
}
/// <summary>The control-word bit a status-bit sub-element address names, else null (word sub-elements,
/// plain addresses and unparseable ones all yield null).</summary>
private static int? StatusBitOf(string tagName)
{
if (AbLegacyAddress.TryParse(tagName) is not { SubElement: { } sub } a) return null;
try
{
return PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, sub).Bit;
}
catch (AbTransportException)
{
return null;
}
}
private static PcccAddress ToPcccAddress(string tagName)
{
var a = AbLegacyAddress.TryParse(tagName)
@@ -218,14 +259,9 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
private async Task WriteCoreAsync(CancellationToken ct)
{
if (_plc == "plc5")
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 writes are not capture-verified.");
// libplctag does a masked bit write (0xAB) here; there is no capture of it, and a read-modify-write
// would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN, so refuse
// before anything reaches the wire.
if (StatusBit is not null)
throw new AbTransportException(AbStatus.Unsupported,
"Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport.");
// Refuse before anything reaches the wire (see WriteRefusal for why each case is refused).
if (_writeRefusal is not null)
throw new AbTransportException(AbStatus.Unsupported, _writeRefusal);
var (chunk, address) = Plan();
for (var done = 0; done < _count; done += chunk)
{
@@ -81,6 +81,13 @@ public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim)
}
/// <summary>A native status-bit sub-element write is refused locally on the families that carry T/C/R files.</summary>
/// <remarks>
/// ⚠️ <b>The fixture has NO T/C/R files</b> (the SLC500 ab_server serves N7/F8/B3/L19 only), and that is what
/// makes this test discriminating: the write is FIRST-TOUCH (nothing reads the status-bit tag beforehand),
/// so a driver that created and initialised the tag's runtime before refusing would issue an init READ of a
/// missing file and report <c>BadCommunicationError</c> + Degraded health — exactly how this test first
/// failed (#593 follow-up). It passes only if the driver refuses before any wire request for the tag.
/// </remarks>
/// <param name="address">The status-bit address.</param>
[AbLegacyTheory]
[InlineData("T4:0.DN")]
@@ -103,5 +110,9 @@ public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim)
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// The refusal left the connection usable.
(await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode
.ShouldBe(AbLegacyStatusMapper.Good);
}
}
@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Gitea #593 follow-up: a native write the runtime would refuse (a T/C/R status-bit sub-element such as
/// <c>T4:0.DN</c>, or any PLC-5 write) is refused by the DRIVER before a runtime is created — so the
/// refusal holds on a FIRST-TOUCH write too, with zero PCCC requests on the wire.
/// </summary>
/// <remarks>
/// Before the fix the write path created and initialised the tag's runtime first, and a native init is one
/// READ. Against a device whose T/C/R file is missing — the <c>ab_server</c> fixture has none — that read
/// failed, so the write surfaced as <c>BadCommunicationError</c>, evicted the handle and degraded health
/// (the comms-loss watchdog's input) instead of the documented <c>BadNotSupported</c>. These tests run the
/// PRODUCTION <see cref="NativeAbLegacyTagFactory"/> over a real socket to a <see cref="FakeEipServer"/>
/// that, like <c>ab_server</c>, only has an N7 file, and count the PCCC requests it receives.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class NativeAbLegacyRefusedWriteTests
{
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
/// <summary>A PCCC-only fake: N7 exists (reads answer one zero word, writes succeed); every other file answers
/// "file does not exist" (STS F0 / EXT 06 → NotFound), as a device without that file would.</summary>
private sealed class N7OnlyServer : IAsyncDisposable
{
private int _pccc;
public FakeEipServer Server { get; } = new() { ZeroReplyContext = true };
/// <summary>Execute PCCC requests received (reads AND writes).</summary>
public int PcccRequests => Volatile.Read(ref _pccc);
public N7OnlyServer()
{
Server.Handler = req =>
{
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
var cip = FakeEipServer.RequestCip(req);
Interlocked.Increment(ref _pccc);
// cip: 4B 02 20 67 24 01 | id(7) | cmd 0F, sts, tns(2), fnc, byteCount, file#, fileType, element, sub
var fileNumber = cip[19];
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
return fileNumber == 7
? FakeEipServer.RRReply(req, [.. head, 0x00, cip[15], cip[16], .. cip[17] == 0xA2 ? new byte[] { 0, 0 } : []])
: FakeEipServer.RRReply(req, [.. head, 0xF0, cip[15], cip[16], 0x06]); // EXT 06: file does not exist
};
}
public ValueTask DisposeAsync() => Server.DisposeAsync();
}
// These tests call the driver's WriteAsync/ReadAsync directly to pin its OWN status mapping; resilience
// wrapping (CapabilityInvoker) is out of scope and would retry/mask the very status under test.
#pragma warning disable OTOPCUA0001
private static async Task<AbLegacyDriver> DriverAsync(
N7OnlyServer s, AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags)
{
var host = $"ab://127.0.0.1:{s.Server.Port}/1,0";
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, family)],
RawTags = AbLegacyRawTags.Entries(tags.Select(t => t with { DeviceHostAddress = host }).ToArray()),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-refused-write", new NativeAbLegacyTagFactory(new DeviceSessionPool()));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return driver;
}
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type, bool writable = false) =>
new(name, "", address, type, Writable: writable);
/// <summary>
/// A first-touch status-bit write is BadNotSupported with nothing on the wire and health unchanged, even
/// though the device has no such file. The positive control (an N7 read on the same driver afterwards) is
/// what proves the fake counts requests — and that the refusal left the connection usable.
/// </summary>
[Theory]
[InlineData("T4:0.DN", AbLegacyDataType.Bit)]
[InlineData("C5:0.CU", AbLegacyDataType.Bit)]
[InlineData("R6:0.EN", AbLegacyDataType.Bit)]
[InlineData("T4:0.TT", AbLegacyDataType.Int)] // the refusal follows the ADDRESS, not the authored type
[InlineData("C5:0.DN", AbLegacyDataType.TimerElement)]
public async Task A_first_touch_status_bit_write_is_BadNotSupported_with_nothing_on_the_wire(
string address, AbLegacyDataType type)
{
var ct = TestContext.Current.CancellationToken;
await using var s = new N7OnlyServer();
var driver = await DriverAsync(s, AbLegacyPlcFamily.Slc500,
Tag("Scratch", "N7:5", AbLegacyDataType.Int),
Tag("Bit", address, type, writable: true));
var health = driver.GetHealth();
var results = await driver.WriteAsync([new WriteRequest("Bit", true)], ct);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
s.PcccRequests.ShouldBe(0);
driver.GetHealth().ShouldBe(health);
// Positive control: the same driver does reach this server, and the refusal did not poison anything.
(await driver.ReadAsync(["Scratch"], ct)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
s.PcccRequests.ShouldBeGreaterThan(0); // init read + read
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
await driver.ShutdownAsync(ct);
s.Server.HandlerErrors.ShouldBeEmpty();
}
/// <summary>A PLC-5 write (including a bit-within-word RMW, which would otherwise READ the parent word
/// first) is refused with nothing on the wire.</summary>
[Theory]
[InlineData("N7:5", AbLegacyDataType.Int, (short)7)]
[InlineData("N7:5/3", AbLegacyDataType.Bit, true)]
public async Task A_first_touch_PLC5_write_is_BadNotSupported_with_nothing_on_the_wire(
string address, AbLegacyDataType type, object value)
{
var ct = TestContext.Current.CancellationToken;
await using var s = new N7OnlyServer();
var driver = await DriverAsync(s, AbLegacyPlcFamily.Plc5, Tag("W", address, type, writable: true));
var health = driver.GetHealth();
(await driver.WriteAsync([new WriteRequest("W", value)], ct)).Single().StatusCode
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
s.PcccRequests.ShouldBe(0);
driver.GetHealth().ShouldBe(health);
await driver.ShutdownAsync(ct);
}
/// <summary>Positive control for the refusal's scope: an ordinary SLC write still reaches the wire, so the
/// pre-check is not swallowing every write (the T/C/R word sub-element case is
/// <c>NativeAbLegacyTagRuntimeTests.A_word_sub_element_write_through_the_driver_sends_a_typed_write</c>).</summary>
[Fact]
public async Task An_SLC_word_write_still_reaches_the_wire()
{
var ct = TestContext.Current.CancellationToken;
await using var s = new N7OnlyServer();
var driver = await DriverAsync(s, AbLegacyPlcFamily.Slc500, Tag("W", "N7:5", AbLegacyDataType.Int, writable: true));
(await driver.WriteAsync([new WriteRequest("W", (short)7)], ct)).Single().StatusCode
.ShouldBe(AbLegacyStatusMapper.Good);
s.PcccRequests.ShouldBeGreaterThanOrEqualTo(1);
await driver.ShutdownAsync(ct);
}
#pragma warning restore OTOPCUA0001
[Theory]
[InlineData("slc500", "T4:0.DN", true)]
[InlineData("slc500", "C5:0.UA", true)]
[InlineData("micrologix", "R6:0.FD", true)]
[InlineData("slc500", "T4:0.PRE", false)]
[InlineData("slc500", "T4:0.ACC", false)]
[InlineData("slc500", "R6:0.POS", false)]
[InlineData("slc500", "N7:0/3", false)]
[InlineData("slc500", "B3:0/0", false)]
[InlineData("slc500", "N7:0", false)]
[InlineData("plc5", "N7:0", true)]
[InlineData("plc5", "T4:0.PRE", true)]
[InlineData("slc500", "T4:0.NOPE", false)] // unknown sub-element: refused by the runtime's own address check
[InlineData("slc500", "garbage", false)]
public void The_write_refusal_predicate_covers_status_bits_and_PLC5_only(string plc, string tag, bool refused)
{
var p = new AbLegacyTagCreateParams("h", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(1));
(NativeAbLegacyTagRuntime.WriteRefusal(p) is not null).ShouldBe(refused);
(new NativeAbLegacyTagFactory().WriteRefusal(p) is not null).ShouldBe(refused);
}
}