fix(ab): a refused operation is BadNotSupported, not a comms fault (Task 22 review r1; closes #571)
Native is the default, so a locally refused write must not look like a comms fault. - AbLegacy: an AbTransportException with AbStatus.Unsupported (status-bit sub-element writes, PLC-5 non-N reads, all PLC-5 writes; libplctag's ErrorUnsupported maps here too) now returns BadNotSupported on both the read and the write path (incl. the bit-RMW path). The tag handle is not evicted and driver health is not touched. A native runtime whose init read is refused locally is CACHED rather than disposed, so a poll of a refused tag does not re-create (and re-lease the pooled connection) every cycle. - AbCip: the same mapping on the write path and both read paths (single + whole-UDT). - Uses the existing named BadNotSupported constants (StatusCodeParityTests guard). - Tests (falsified by disabling the mapping, and separately the keep-on-refusal): status-bit writes and PLC-5 read/write through the driver over the native runtime — BadNotSupported, zero wire requests, one create, health unchanged; AbCip refused read/write via a refusing fake plus a control that a non-refusal failure still evicts and degrades. - Docs/CLAUDE.md: BadNotSupported; #571 known-gap wording removed. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -984,7 +984,7 @@ CLI share them) — except an AbLegacy driver with any PLC-5 / `LogixPccc` devic
|
|||||||
"Default" is encoded by omitting the key; the AdminUI must never re-emit a concrete default. Evidence: AbCip
|
"Default" is encoded by omitting the key; the AdminUI must never re-emit a concrete default. Evidence: AbCip
|
||||||
reads proven on a real CompactLogix (Z-35162, read-only); AbCip writes and all AbLegacy are `ab_server`-only.
|
reads proven on a real CompactLogix (Z-35162, read-only); AbCip writes and all AbLegacy are `ab_server`-only.
|
||||||
Fall back with `"transport": "Libplctag"`. ⚠️ AbLegacy native refuses status-bit sub-element
|
Fall back with `"transport": "Libplctag"`. ⚠️ AbLegacy native refuses status-bit sub-element
|
||||||
writes (`T4:0.DN`…, client sees `BadCommunicationError`) and its ST byte order is unverified. Client-visible: native reports `BadTypeMismatch` where libplctag returned
|
writes (`T4:0.DN`…, client sees `BadNotSupported`; refusals never evict or degrade) and its ST byte order is unverified. Client-visible: native reports `BadTypeMismatch` where libplctag returned
|
||||||
Good garbage for a same-width mis-authored type (DINT authored, REAL returned). libplctag is confined to
|
Good garbage for a same-width mis-authored type (DINT authored, REAL returned). libplctag is confined to
|
||||||
`Libplctag*.cs` adapters by `LibplctagConfinementGuardTests`; do not reference it elsewhere. See
|
`Libplctag*.cs` adapters by `LibplctagConfinementGuardTests`; do not reference it elsewhere. See
|
||||||
`docs/drivers/AbCip.md` §Transport, `docs/drivers/AbLegacy.md`, and
|
`docs/drivers/AbCip.md` §Transport, `docs/drivers/AbLegacy.md`, and
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
|||||||
alignment pad (84, not 82) (#570).
|
alignment pad (84, not 82) (#570).
|
||||||
- Status-bit sub-element writes (e.g. `T4:0.DN`) are Unsupported natively (they would clobber the control
|
- Status-bit sub-element writes (e.g. `T4:0.DN`) are Unsupported natively (they would clobber the control
|
||||||
word); nothing goes on the wire. Sub-element arrays are Unsupported natively.
|
word); nothing goes on the wire. Sub-element arrays are Unsupported natively.
|
||||||
|
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
|
||||||
|
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
|
||||||
|
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
|
|||||||
@@ -134,10 +134,8 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected
|
|||||||
|
|
||||||
- ⚠️ **Status-bit writes are refused on the default.** An SLC 500 / MicroLogix driver on the native default
|
- ⚠️ **Status-bit writes are refused on the default.** An SLC 500 / MicroLogix driver on the native default
|
||||||
**refuses writes to status-bit sub-elements** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`,
|
**refuses writes to status-bit sub-elements** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`,
|
||||||
`R6:0.EN`, and the rest. The client sees **`BadCommunicationError`** (not `BadNotSupported`: the driver's
|
`R6:0.EN`, and the rest. The client sees **`BadNotSupported`**. It is not treated as a comms fault: the
|
||||||
write path maps the refusal like any transport failure, as for the PLC-5 refusal in #571), the tag's handle
|
tag's handle is kept and driver health is unchanged. **Nothing is written to the PLC.** libplctag performed a masked bit write here, so an
|
||||||
is evicted, and driver health goes `Degraded` with "Writes to a status-bit sub-element … are not supported by
|
|
||||||
the native transport". **Nothing is written to the PLC.** libplctag performed a masked bit write here, so an
|
|
||||||
authored writable status-bit tag that worked before the upgrade fails after it. This is deliberate: a native
|
authored writable status-bit tag that worked before the upgrade fails after it. This is deliberate: a native
|
||||||
read-modify-write of the control word would race the PLC updating TT/DN and could clobber EN/TT/DN, and such
|
read-modify-write of the control word would race the PLC updating TT/DN and could clobber EN/TT/DN, and such
|
||||||
writes are rare. **Drivers that need them must set `"transport": "Libplctag"`.** Reads of status bits are
|
writes are rare. **Drivers that need them must set `"transport": "Libplctag"`.** Reads of status bits are
|
||||||
@@ -164,8 +162,8 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected
|
|||||||
|
|
||||||
- ⚠️ **Simulator-only.** PCCC reads and writes are verified against `ab_server` only. No real SLC/MicroLogix/
|
- ⚠️ **Simulator-only.** PCCC reads and writes are verified against `ab_server` only. No real SLC/MicroLogix/
|
||||||
PLC-5 has been exercised; the real-press gate (Task 21) was read-only and AbCip-only.
|
PLC-5 has been exercised; the real-press gate (Task 21) was read-only and AbCip-only.
|
||||||
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused, currently
|
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the
|
||||||
surfacing as `BadCommunicationError` rather than `BadNotSupported` (#571). The default excludes PLC-5 and
|
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). The default excludes PLC-5 and
|
||||||
`logixpccc` (they resolve to libplctag unless `Native` is set explicitly).
|
`logixpccc` (they resolve to libplctag unless `Native` is set explicitly).
|
||||||
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
|
- 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. Sub-element arrays are Unsupported natively. L-file bit indexes
|
||||||
|
|||||||
@@ -854,6 +854,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||||
|
{
|
||||||
|
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
|
||||||
|
// health signal (Task 22 review r1).
|
||||||
|
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||||
|
return ReadOutcome.None;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
|
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
|
||||||
@@ -928,6 +935,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||||
|
{
|
||||||
|
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||||
|
return ReadOutcome.None;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
EvictRuntime(device, parent.Name);
|
EvictRuntime(device, parent.Name);
|
||||||
@@ -1064,6 +1076,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
"AbCip write value out of range for tag {Tag} on device {Device}",
|
"AbCip write value out of range for tag {Tag} on device {Device}",
|
||||||
w.FullReference, def.DeviceHostAddress);
|
w.FullReference, def.DeviceHostAddress);
|
||||||
}
|
}
|
||||||
|
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||||
|
{
|
||||||
|
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
|
||||||
|
// fault: BadNotSupported, keep the handle, leave health alone (Task 22 review r1).
|
||||||
|
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||||
|
_logger.LogWarning(ate,
|
||||||
|
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||||
|
w.FullReference, def.DeviceHostAddress);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Transport / wire error — evict so the next write creates a fresh handle.
|
// Transport / wire error — evict so the next write creates a fresh handle.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
@@ -376,6 +377,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||||
|
{
|
||||||
|
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571).
|
||||||
|
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
|
||||||
|
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
|
// Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
|
||||||
@@ -478,6 +485,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
}
|
}
|
||||||
|
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||||
|
{
|
||||||
|
// The runtime refused the write before the wire — a native status-bit sub-element write or a
|
||||||
|
// PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone.
|
||||||
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
|
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
|
||||||
@@ -744,16 +757,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
TagName: parentName,
|
TagName: parentName,
|
||||||
Timeout: _options.Timeout));
|
Timeout: _options.Timeout));
|
||||||
try
|
await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
|
||||||
{
|
.ConfigureAwait(false);
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
runtime.Dispose();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
device.ParentRuntimes[parentName] = runtime;
|
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -780,6 +785,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
|
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
|
||||||
def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
|
def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for a deterministic refusal of the operation itself (<see cref="AbStatus.Unsupported"/>) — the
|
||||||
|
/// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and
|
||||||
|
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
|
||||||
|
/// device's reachability, so it must not evict the handle or degrade driver health.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsLocalRefusal(Exception ex) =>
|
||||||
|
ex is AbTransportException { Status: AbStatus.Unsupported };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
|
||||||
|
/// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and
|
||||||
|
/// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime)
|
||||||
|
{
|
||||||
|
keep();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
runtime.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
keep();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -806,16 +844,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
TagName: parsed.ToLibplctagName(),
|
TagName: parsed.ToLibplctagName(),
|
||||||
Timeout: _options.Timeout,
|
Timeout: _options.Timeout,
|
||||||
ElementCount: EffectiveArrayLength(def)));
|
ElementCount: EffectiveArrayLength(def)));
|
||||||
try
|
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
|
||||||
{
|
.ConfigureAwait(false);
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
runtime.Dispose();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
device.Runtimes[def.Name] = runtime;
|
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 22 review r1: a runtime that refuses an operation with <see cref="AbStatus.Unsupported"/> (the native
|
||||||
|
/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported —
|
||||||
|
/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipLocalRefusalTests
|
||||||
|
{
|
||||||
|
private const string Host = "ab://10.0.0.5/1,0";
|
||||||
|
|
||||||
|
private sealed class RefusingTag(AbCipTagCreateParams p, bool refuseRead, bool refuseWrite) : FakeAbCipTag(p)
|
||||||
|
{
|
||||||
|
public override Task ReadAsync(CancellationToken cancellationToken) =>
|
||||||
|
refuseRead
|
||||||
|
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read"))
|
||||||
|
: base.ReadAsync(cancellationToken);
|
||||||
|
|
||||||
|
public override Task WriteAsync(CancellationToken cancellationToken) =>
|
||||||
|
refuseWrite
|
||||||
|
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write"))
|
||||||
|
: base.WriteAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func<int> Creates)> NewAsync(
|
||||||
|
bool refuseRead, bool refuseWrite, AbCipDataType type = AbCipDataType.DInt)
|
||||||
|
{
|
||||||
|
var creates = 0;
|
||||||
|
var factory = new FakeAbCipTagFactory
|
||||||
|
{
|
||||||
|
Customise = p => { Interlocked.Increment(ref creates); return new RefusingTag(p, refuseRead, refuseWrite) { Value = 1 }; },
|
||||||
|
};
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Host)],
|
||||||
|
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", type)),
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
return (drv, factory, () => Volatile.Read(ref creates));
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma warning disable OTOPCUA0001 // the driver's own status mapping is under test, not resilience wrapping
|
||||||
|
[Fact]
|
||||||
|
public async Task A_refused_write_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone()
|
||||||
|
{
|
||||||
|
var (drv, factory, creates) = await NewAsync(refuseRead: false, refuseWrite: true);
|
||||||
|
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
var health = drv.GetHealth();
|
||||||
|
|
||||||
|
(await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode
|
||||||
|
.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||||
|
|
||||||
|
drv.GetHealth().ShouldBe(health);
|
||||||
|
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||||
|
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
creates().ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_refused_read_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone()
|
||||||
|
{
|
||||||
|
var (drv, factory, creates) = await NewAsync(refuseRead: true, refuseWrite: false);
|
||||||
|
var health = drv.GetHealth();
|
||||||
|
|
||||||
|
for (var n = 0; n < 2; n++)
|
||||||
|
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||||
|
.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||||
|
|
||||||
|
drv.GetHealth().ShouldBe(health);
|
||||||
|
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||||
|
creates().ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Control: any OTHER transport failure is still a comms fault (evicted, Degraded).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_non_refusal_write_failure_still_evicts_and_degrades()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
factory.Customise = p => new ThrowingWriteTag(p) { Value = 1 };
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Host)],
|
||||||
|
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)),
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
(await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode
|
||||||
|
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||||
|
factory.Tags["T"].Disposed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
#pragma warning restore OTOPCUA0001
|
||||||
|
|
||||||
|
private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||||
|
{
|
||||||
|
public override Task WriteAsync(CancellationToken cancellationToken) =>
|
||||||
|
Task.FromException(new AbTransportException(AbStatus.BadConnection, "socket closed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-33
@@ -382,61 +382,121 @@ public sealed class NativeAbLegacyTagRuntimeTests
|
|||||||
h.Channel.SentCount.ShouldBe(0);
|
h.Channel.SentCount.ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// These two tests call the driver's WriteAsync directly to pin its OWN status mapping; resilience wrapping
|
// These tests call the driver's WriteAsync/ReadAsync directly to pin its OWN status mapping; resilience
|
||||||
// (CapabilityInvoker) is out of scope and would retry/mask the very status under test.
|
// wrapping (CapabilityInvoker) is out of scope and would retry/mask the very status under test.
|
||||||
#pragma warning disable OTOPCUA0001
|
#pragma warning disable OTOPCUA0001
|
||||||
|
/// <summary>Native runtimes over the harness channel; counts creates so a test can prove a handle was KEPT.</summary>
|
||||||
private sealed class HarnessFactory(Harness h) : IAbLegacyTagFactory
|
private sealed class HarnessFactory(Harness h) : IAbLegacyTagFactory
|
||||||
{
|
{
|
||||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) =>
|
public int Creates;
|
||||||
new NativeAbLegacyTagRuntime(p, h.Scheduler, Vendor, Serial);
|
|
||||||
|
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref Creates);
|
||||||
|
return new NativeAbLegacyTagRuntime(p, h.Scheduler, Vendor, Serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string Host = "ab://10.0.0.1:44818/1,0";
|
||||||
|
|
||||||
|
private static async Task<AbLegacyDriver> DriverAsync(
|
||||||
|
HarnessFactory factory, AbLegacyPlcFamily family, AbLegacyTagDefinition tag)
|
||||||
|
{
|
||||||
|
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(Host, family)],
|
||||||
|
RawTags = AbLegacyRawTags.Entries([tag]),
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, "drv-sb", factory);
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
return driver;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pins the CLIENT-VISIBLE status of the native status-bit write refusal, through the driver (Task 22
|
/// Task 22 review r1 / #571: a native status-bit sub-element write is refused locally and reaches the
|
||||||
/// review r1 I2 — the release note states it). The runtime throws AbTransportException(Unsupported);
|
/// client as BadNotSupported — NOT a comms fault: nothing on the wire, the tag handle is kept (no
|
||||||
/// the driver's write path does not special-case it, so the client sees the generic transport
|
/// re-create), and driver health is untouched. The tag is read first so its handle exists and the
|
||||||
/// status, and nothing reaches the wire.
|
/// wire count isolates the write.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("T4:0.DN")]
|
[InlineData("T4:0.DN")]
|
||||||
[InlineData("C5:0.CU")]
|
[InlineData("C5:0.CU")]
|
||||||
[InlineData("R6:0.EN")]
|
[InlineData("R6:0.EN")]
|
||||||
public async Task A_status_bit_write_through_the_driver_reports_the_documented_status(string address)
|
public async Task A_status_bit_write_is_BadNotSupported_without_wire_eviction_or_degraded_health(string address)
|
||||||
{
|
{
|
||||||
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
|
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
|
||||||
const string host = "ab://10.0.0.1:44818/1,0";
|
var factory = new HarnessFactory(h);
|
||||||
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
var driver = await DriverAsync(factory, AbLegacyPlcFamily.Slc500,
|
||||||
{
|
new AbLegacyTagDefinition("Bit", Host, address, AbLegacyDataType.Bit));
|
||||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
(await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Bit", host, address, AbLegacyDataType.Bit)]),
|
var health = driver.GetHealth();
|
||||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
var sent = h.Channel.SentCount;
|
||||||
}, "drv-sb", new HarnessFactory(h));
|
|
||||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
||||||
var results = await driver.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
var results = await driver.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||||
|
|
||||||
// BadCommunicationError, NOT BadNotSupported: the driver's catch-all maps any runtime exception to
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||||
// it (same as the PLC-5 write refusal, #571), evicts the handle and marks health Degraded.
|
h.Channel.SentCount.ShouldBe(sent); // zero wire requests for the refused write
|
||||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
driver.GetHealth().ShouldBe(health); // health unchanged (not Degraded)
|
||||||
driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("status-bit sub-element");
|
(await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
// Refused before the wire: the lazy runtime's init read may go out, but never a write (fnc 0xAA).
|
factory.Creates.ShouldBe(1); // handle kept: the read after reused it
|
||||||
lock (h.Channel.Sent) h.Channel.Sent.ShouldNotContain(r => FncOf(r) == 0xAA);
|
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Positive control for the "no 0xAA on the wire" assertion above: the same driver path for a WORD
|
/// <summary>
|
||||||
/// sub-element does send a typed write, so the detector can see one.</summary>
|
/// #571, read side: a native PLC-5 non-N read is refused locally — BadNotSupported, nothing on the wire,
|
||||||
|
/// health unchanged, and the refused handle is KEPT (one create across repeated reads, so a poll does
|
||||||
|
/// not re-lease the device connection every cycle).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_PLC5_non_N_read_is_BadNotSupported_without_wire_eviction_or_degraded_health()
|
||||||
|
{
|
||||||
|
await using var h = new Harness("plc5", "N7:0").Replies(Words(0));
|
||||||
|
var factory = new HarnessFactory(h);
|
||||||
|
var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5,
|
||||||
|
new AbLegacyTagDefinition("F", Host, "F8:0", AbLegacyDataType.Float));
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
for (var n = 0; n < 3; n++)
|
||||||
|
(await driver.ReadAsync(["F"], CancellationToken.None)).Single().StatusCode
|
||||||
|
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||||
|
|
||||||
|
h.Channel.SentCount.ShouldBe(0);
|
||||||
|
driver.GetHealth().ShouldBe(health);
|
||||||
|
factory.Creates.ShouldBe(1);
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#571, write side: a native PLC-5 write is refused locally — BadNotSupported, no write on the wire,
|
||||||
|
/// handle kept, health unchanged.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_PLC5_write_is_BadNotSupported_without_wire_eviction_or_degraded_health()
|
||||||
|
{
|
||||||
|
await using var h = new Harness("plc5", "N7:0").Replies(Words(0));
|
||||||
|
var factory = new HarnessFactory(h);
|
||||||
|
var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5,
|
||||||
|
new AbLegacyTagDefinition("N", Host, "N7:0", AbLegacyDataType.Int));
|
||||||
|
(await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
var sent = h.Channel.SentCount;
|
||||||
|
|
||||||
|
(await driver.WriteAsync([new WriteRequest("N", (short)5)], CancellationToken.None)).Single().StatusCode
|
||||||
|
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||||
|
|
||||||
|
h.Channel.SentCount.ShouldBe(sent);
|
||||||
|
driver.GetHealth().ShouldBe(health);
|
||||||
|
(await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
factory.Creates.ShouldBe(1);
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Positive control for the wire-count assertions above: the same driver path for a WORD sub-element
|
||||||
|
/// does send a typed write (fnc 0xAA), so the harness can see one.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_word_sub_element_write_through_the_driver_sends_a_typed_write()
|
public async Task A_word_sub_element_write_through_the_driver_sends_a_typed_write()
|
||||||
{
|
{
|
||||||
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
|
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
|
||||||
const string host = "ab://10.0.0.1:44818/1,0";
|
var driver = await DriverAsync(new HarnessFactory(h), AbLegacyPlcFamily.Slc500,
|
||||||
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
new AbLegacyTagDefinition("Pre", Host, "T4:0.PRE", AbLegacyDataType.Int));
|
||||||
{
|
|
||||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
|
||||||
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Pre", host, "T4:0.PRE", AbLegacyDataType.Int)]),
|
|
||||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
||||||
}, "drv-sb", new HarnessFactory(h));
|
|
||||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
||||||
|
|
||||||
var results = await driver.WriteAsync([new WriteRequest("Pre", (short)5)], CancellationToken.None);
|
var results = await driver.WriteAsync([new WriteRequest("Pre", (short)5)], CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user