fix(ab): share status map + libplctag orphan release, harden confinement guard, pin width-mismatch + DefaultTransport wiring (#568 #569 #580 #585)
- Ab.Transport: AbOpcUaStatusMap is the single AbStatus->OPC UA table; AbCip/AbLegacy mappers delegate. - Remove dead MapCipGeneralStatus / MapPcccStatus / AbLegacy MapException (+ tests). - LibplctagOrphanRelease is one source file (Ab.LibplctagShared) compiled into both drivers. - LibplctagConfinementGuardTests: tokenising comment/literal stripper, block comments, csproj PackageReference guard. - LibplctagStatusTranslationTests: real LibPlcTagException round trip, WrapSync/Wrap OCE pass-through, adapter routes decode via WrapSync. - AbCipTypeMismatchTests: libplctag width-mismatch path (BadTypeMismatch, handle kept, no Degraded, warn once). - Host: AddAbCipDriverDefaults extension + Program.cs source pin for Drivers:AbCip:DefaultTransport. - docs/drivers/AbCip.md: layout rows + benign Degraded-after-outage note. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -20,6 +20,7 @@ coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/` | Project-less shared source (`LibplctagOrphanRelease.cs`, #578/#580) compiled into both `Driver.AbCip` and `Driver.AbLegacy`. The `AbStatus` → OPC UA table is shared in `Driver.Ab.Transport` (`AbOpcUaStatusMap`). |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
|
||||
|
||||
Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`)
|
||||
@@ -312,7 +313,7 @@ UDT-typed tags found in the walk.
|
||||
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
|
||||
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
|
||||
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)).
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)). The width-mismatch refusal is pinned by `AbCipTypeMismatchTests.A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once`: `BadTypeMismatch`, handle kept, no `Degraded`, one Warning per tag. *Benign edge (#585):* a driver left `Degraded` by an earlier outage whose only remaining tags are mis-typed gets one extra comms-loss-watchdog reconnect ~90 s later before settling, because a type refusal deliberately writes no health and so never clears the stale `Degraded`.
|
||||
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
|
||||
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
|
||||
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
|
||||
|
||||
+6
-2
@@ -3,7 +3,7 @@ using System.Reflection;
|
||||
using libplctag;
|
||||
using libplctag.NativeImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
|
||||
/// <summary>
|
||||
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
|
||||
@@ -24,7 +24,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> fails, so the bump is noticed.</para>
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> / <c>LibplctagLegacyOrphanReleaseTests</c> fail, so the bump is noticed.</para>
|
||||
/// <para>SHARED SOURCE (#580): this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
|
||||
/// compile this file in (<c><Compile Include></c>), so each assembly has its own internal copy and its own
|
||||
/// counters. It must keep a <c>Libplctag*.cs</c> name — <c>LibplctagConfinementGuardTests</c> allows libplctag
|
||||
/// references only in such files.</para>
|
||||
/// </remarks>
|
||||
internal static class LibplctagOrphanRelease
|
||||
{
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// The single <see cref="AbStatus"/> to OPC UA StatusCode table shared by <c>AbCipStatusMapper</c> and
|
||||
/// <c>AbLegacyStatusMapper</c> (#580) — the two drivers used to carry identical copies.
|
||||
/// </summary>
|
||||
public static class AbOpcUaStatusMap
|
||||
{
|
||||
/// <summary>OPC UA <c>Good</c>.</summary>
|
||||
public const uint Good = 0u;
|
||||
/// <summary>OPC UA <c>GoodMoreData</c>.</summary>
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
/// <summary>OPC UA <c>BadNodeIdUnknown</c>.</summary>
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
/// <summary>OPC UA <c>BadNotWritable</c>.</summary>
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
/// <summary>OPC UA <c>BadOutOfRange</c>.</summary>
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
/// <summary>OPC UA <c>BadNotSupported</c>.</summary>
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
/// <summary>OPC UA <c>BadDeviceFailure</c>.</summary>
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
/// <summary>OPC UA <c>BadCommunicationError</c>.</summary>
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
/// <summary>OPC UA <c>BadTimeout</c>.</summary>
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
/// <summary>OPC UA <c>BadTypeMismatch</c>.</summary>
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Maps a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Status for an exception thrown while creating / reading a tag: a transport exception maps through its
|
||||
/// <see cref="AbStatus"/>; anything else is <see cref="BadCommunicationError"/>.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
}
|
||||
@@ -3,35 +3,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of
|
||||
/// <c>ModbusDriver.MapModbusExceptionToStatus</c> so Admin UI status displays stay
|
||||
/// uniform across drivers.
|
||||
/// Maps transport <see cref="AbStatus"/> values to OPC UA StatusCodes through the table shared with AbLegacy
|
||||
/// (<see cref="AbOpcUaStatusMap"/>): timeout, not-found, not-allowed, out-of-bounds, unsupported, type-mismatch
|
||||
/// and device-state-conflict get their specific codes; the remaining transport errors fold into
|
||||
/// <c>BadCommunicationError</c>. (The raw CIP general-status mapper that used to live here had no callers — the
|
||||
/// transports surface an <see cref="AbStatus"/>, not the status byte — and was removed, #580.)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Coverage: the CIP general-status values an AB PLC actually returns during normal
|
||||
/// driver operation. Full CIP Volume 1 Appendix B lists 50+ codes; the ones here are the
|
||||
/// ones that move the driver's status needle:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>0x00 success — OPC UA <c>Good (0)</c>.</item>
|
||||
/// <item>0x04 path segment error / 0x05 path destination unknown — <c>BadNodeIdUnknown</c>
|
||||
/// (tag doesn't exist).</item>
|
||||
/// <item>0x06 partial data transfer — <c>GoodMoreData</c> (fragmented read underway).</item>
|
||||
/// <item>0x08 service not supported — <c>BadNotSupported</c> (e.g. write on a safety
|
||||
/// partition tag from a non-safety task).</item>
|
||||
/// <item>0x0A / 0x13 attribute-list error / insufficient data — <c>BadOutOfRange</c>
|
||||
/// (type mismatch or truncated buffer).</item>
|
||||
/// <item>0x0B already in requested mode — benign, treated as <c>Good</c>.</item>
|
||||
/// <item>0x0E attribute not settable — <c>BadNotWritable</c>.</item>
|
||||
/// <item>0x10 device state conflict — <c>BadDeviceFailure</c> (program-mode protected
|
||||
/// writes during download / test-mode transitions).</item>
|
||||
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
|
||||
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
|
||||
/// <item>Transport <see cref="AbStatus"/> errors — mapped per-member by
|
||||
/// <see cref="MapStatus(AbStatus)"/>: timeout, not-found, not-allowed, and
|
||||
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
|
||||
/// fold into <c>BadCommunicationError</c>.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
@@ -46,23 +23,6 @@ public static class AbCipStatusMapper
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapCipGeneralStatus(byte status) => status switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x04 or 0x05 => BadNodeIdUnknown,
|
||||
0x06 => GoodMoreData,
|
||||
0x08 => BadNotSupported,
|
||||
0x0A or 0x13 => BadOutOfRange,
|
||||
0x0B => Good,
|
||||
0x0E => BadNotWritable,
|
||||
0x10 => BadDeviceFailure,
|
||||
0x16 => BadNodeIdUnknown,
|
||||
_ => BadInternalError,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map a transport status code to an OPC UA StatusCode. The integer is
|
||||
/// <see cref="IAbCipTagRuntime.GetStatus"/>, i.e. the underlying value of <see cref="AbStatus"/>.
|
||||
@@ -78,19 +38,7 @@ public static class AbCipStatusMapper
|
||||
/// </summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
public static uint MapStatus(AbStatus status) => AbOpcUaStatusMap.MapStatus(status);
|
||||
|
||||
/// <summary>
|
||||
/// Status for an exception thrown while creating / reading a tag: a transport exception maps
|
||||
@@ -101,5 +49,5 @@ public static class AbCipStatusMapper
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
AbOpcUaStatusMap.MapException(ex);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
Decision #11 — EtherNet/IP + CIP + Logix symbolic against ControlLogix / CompactLogix /
|
||||
Micro800 / SLC500 / PLC-5. -->
|
||||
<PackageReference Include="libplctag"/>
|
||||
<!-- #580: orphan-release helper shared with the sibling AB driver (source-linked; keeps a Libplctag*.cs name). -->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared\LibplctagOrphanRelease.cs" Link="LibplctagOrphanRelease.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -3,9 +3,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps transport status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
|
||||
/// AbCip mapper — PCCC errors roughly align with CIP general-status in shape but with a
|
||||
/// different byte vocabulary (PCCC STS nibble-low + EXT_STS on code 0x0F).
|
||||
/// Maps transport status codes to OPC UA StatusCodes via the table shared with AbCip
|
||||
/// (<see cref="AbOpcUaStatusMap"/>).
|
||||
/// </summary>
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
@@ -32,45 +31,5 @@ public static class AbLegacyStatusMapper
|
||||
/// <summary>Map a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
|
||||
/// <summary>Map an exception thrown by a transport to an OPC UA StatusCode.</summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The mapped status for an <see cref="AbTransportException"/>, else BadCommunicationError.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
|
||||
/// <summary>
|
||||
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
|
||||
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
|
||||
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
|
||||
/// the transports surface only an <see cref="AbStatus"/> rather than exposing
|
||||
/// the raw STS byte, so this method is not wired into the current read/write path.
|
||||
/// It is retained as the reference mapping for future PCCC-STS inspection.
|
||||
/// </summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapPcccStatus(byte sts) => sts switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x10 => BadNotSupported,
|
||||
0x20 => BadNodeIdUnknown,
|
||||
0x30 => BadNotWritable,
|
||||
0x40 => BadDeviceFailure,
|
||||
0x50 => BadDeviceFailure,
|
||||
0xF0 => BadInternalError, // extended status not inspected at this layer
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
public static uint MapStatus(AbStatus status) => AbOpcUaStatusMap.MapStatus(status);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using libplctag;
|
||||
using libplctag.NativeImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
|
||||
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
|
||||
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
|
||||
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
|
||||
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
|
||||
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
|
||||
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
|
||||
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
|
||||
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
|
||||
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
|
||||
/// background for the life of the process.</para>
|
||||
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
|
||||
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagLegacyOrphanReleaseTests</c> fails, so the bump is noticed.</para>
|
||||
/// </remarks>
|
||||
internal static class LibplctagOrphanRelease
|
||||
{
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
|
||||
|
||||
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
|
||||
private static readonly ConcurrentBag<Tag> Rooted = [];
|
||||
|
||||
private static int _released;
|
||||
|
||||
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
|
||||
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
|
||||
|
||||
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
|
||||
internal static bool ForceRootingForTest { get; set; }
|
||||
|
||||
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
|
||||
internal static int Released => Volatile.Read(ref _released);
|
||||
|
||||
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
|
||||
internal static int RootedCount => Rooted.Count;
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
|
||||
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
{
|
||||
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
|
||||
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
|
||||
/// created is unregistered and destroyed before the failure propagates.
|
||||
/// </summary>
|
||||
internal static async Task RunAsync(Tag tag, Func<Task> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
|
||||
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
|
||||
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
|
||||
/// </summary>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tag.IsInitialized) return null;
|
||||
if (!CanReadHandles)
|
||||
{
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
var handle = HandleOf(tag);
|
||||
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
|
||||
var status = (Status)plctag.plc_tag_status(handle);
|
||||
plctag.plc_tag_unregister_callback(handle);
|
||||
plctag.plc_tag_destroy(handle);
|
||||
ClearHandle(tag);
|
||||
Interlocked.Increment(ref _released);
|
||||
return status;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
|
||||
// state the native side is in, its callback delegate stays alive.
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
Decision #41 — AbLegacy split from AbCip since PCCC addressing (file-based N7:0) and
|
||||
Logix addressing (symbolic Motor1.Speed) pull the abstraction in incompatible directions. -->
|
||||
<PackageReference Include="libplctag"/>
|
||||
<!-- #580: orphan-release helper shared with the sibling AB driver (source-linked; keeps a Libplctag*.cs name). -->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared\LibplctagOrphanRelease.cs" Link="LibplctagOrphanRelease.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -56,3 +58,23 @@ public sealed class AbCipDriverDefaultsOptionsValidator : OptionsValidatorBase<A
|
||||
Message(options.DefaultTransport));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Composition-root registration of <see cref="AbCipDriverDefaultsOptions"/>.</summary>
|
||||
public static class AbCipDriverDefaultsRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Binds + validates <c>Drivers:AbCip</c>. Unconditional on every role: BOTH consumers need it — the driver
|
||||
/// factory (driver role) and the Test Connect probe (admin role, which must probe the stack the driver would
|
||||
/// run). A bad value fails host start rather than silently falling back. <c>Program.cs</c> must call this
|
||||
/// (pinned by <c>AbCipDriverDefaultsOptionsTests</c>): without it an operator's pin is silently ignored (#585).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The host configuration.</param>
|
||||
/// <returns>The same collection.</returns>
|
||||
public static IServiceCollection AddAbCipDriverDefaults(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +179,7 @@ builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.
|
||||
// key runs on. Unconditional because BOTH consumers need it — the driver factory (driver role) and the
|
||||
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
|
||||
// start here rather than silently falling back.
|
||||
builder.Services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
builder.Configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
builder.Services.AddAbCipDriverDefaults(builder.Configuration);
|
||||
|
||||
if (hasDriver)
|
||||
{
|
||||
|
||||
+207
-9
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -10,33 +11,230 @@ public sealed class LibplctagConfinementGuardTests
|
||||
@"\busing\s+(static\s+)?(\w+\s*=\s*)?libplctag\b|\blibplctag\.[A-Z]",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
[Fact]
|
||||
public void Only_the_libplctag_adapters_reference_libplctag()
|
||||
// Directories whose Libplctag*.cs files may reference libplctag. LibplctagShared is the project-less home of
|
||||
// sources compiled into BOTH drivers (#580).
|
||||
private static readonly string[] AllowedSourceDirs =
|
||||
[
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbCip",
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbLegacy",
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared",
|
||||
];
|
||||
|
||||
// The only projects that legitimately carry the libplctag package today (#569).
|
||||
private static readonly string[] AllowedPackageProjects =
|
||||
[
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj",
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj",
|
||||
];
|
||||
|
||||
private static readonly Regex PackageRef = new(
|
||||
@"<PackageReference\b[^>]*\b(Include|Update)\s*=\s*[""']libplctag(\.[\w.]*)?[""']",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static DirectoryInfo RepoRoot()
|
||||
{
|
||||
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (root is not null && !File.Exists(Path.Combine(root.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) root = root.Parent;
|
||||
root.ShouldNotBeNull("could not find the repo root");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static bool IsBuildOutput(string file) =>
|
||||
file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}");
|
||||
|
||||
/// <summary>
|
||||
/// Reduces C# source to its code: comments are dropped and string / char literal CONTENT is emptied
|
||||
/// (newlines are kept, so line numbers hold). Strings are tokenised properly rather than split on
|
||||
/// <c>//</c> — a URL literal used to truncate the line and hide a reference after it, and a
|
||||
/// <c>/* */</c> block comment was never stripped (#569).
|
||||
/// </summary>
|
||||
/// <param name="text">The C# source.</param>
|
||||
/// <returns>The source with only code left.</returns>
|
||||
internal static string StripCommentsAndLiterals(string text)
|
||||
{
|
||||
var sb = new StringBuilder(text.Length);
|
||||
var i = 0;
|
||||
while (i < text.Length)
|
||||
{
|
||||
var c = text[i];
|
||||
var next = i + 1 < text.Length ? text[i + 1] : '\0';
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
while (i < text.Length && text[i] != '\n') i++;
|
||||
}
|
||||
else if (c == '/' && next == '*')
|
||||
{
|
||||
i += 2;
|
||||
while (i < text.Length && !(text[i] == '*' && i + 1 < text.Length && text[i + 1] == '/'))
|
||||
{
|
||||
if (text[i] == '\n') sb.Append('\n');
|
||||
i++;
|
||||
}
|
||||
i = Math.Min(i + 2, text.Length);
|
||||
}
|
||||
else if (c == '"' && next == '"' && i + 2 < text.Length && text[i + 2] == '"')
|
||||
{
|
||||
// Raw string literal: a run of >= 3 quotes opens it and closes on a run of at least that length.
|
||||
var open = 0;
|
||||
while (i + open < text.Length && text[i + open] == '"') open++;
|
||||
i += open;
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (text[i] == '"')
|
||||
{
|
||||
var run = 0;
|
||||
while (i + run < text.Length && text[i + run] == '"') run++;
|
||||
i += run;
|
||||
if (run >= open) break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (text[i] == '\n') sb.Append('\n');
|
||||
i++;
|
||||
}
|
||||
}
|
||||
sb.Append("\"\"");
|
||||
}
|
||||
else if (c == '"' || (c is '@' or '$' && IsStringPrefix(text, i)))
|
||||
{
|
||||
var verbatim = false;
|
||||
while (text[i] != '"')
|
||||
{
|
||||
if (text[i] == '@') verbatim = true;
|
||||
i++;
|
||||
}
|
||||
i++; // opening quote
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (verbatim && text[i] == '"' && i + 1 < text.Length && text[i + 1] == '"') { i += 2; continue; }
|
||||
if (!verbatim && text[i] == '\\') { i += 2; continue; }
|
||||
if (text[i] == '"') { i++; break; }
|
||||
if (text[i] == '\n') sb.Append('\n');
|
||||
i++;
|
||||
}
|
||||
sb.Append("\"\"");
|
||||
}
|
||||
else if (c == '\'')
|
||||
{
|
||||
i++;
|
||||
while (i < text.Length && text[i] != '\'')
|
||||
{
|
||||
if (text[i] == '\\') i++;
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
sb.Append("''");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// @"..", $"..", $@"..", @$".." — a run of prefix characters followed by a quote.
|
||||
private static bool IsStringPrefix(string text, int i)
|
||||
{
|
||||
var j = i;
|
||||
while (j < text.Length && text[j] is '@' or '$') j++;
|
||||
return j < text.Length && text[j] == '"';
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_libplctag_adapters_reference_libplctag()
|
||||
{
|
||||
var root = RepoRoot();
|
||||
|
||||
var offenders = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, "src"), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) continue;
|
||||
if (IsBuildOutput(file)) continue;
|
||||
var name = Path.GetFileName(file);
|
||||
var dir = Path.GetFileName(Path.GetDirectoryName(file)!);
|
||||
var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal)
|
||||
&& dir is "ZB.MOM.WW.OtOpcUa.Driver.AbCip" or "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy";
|
||||
var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal) && AllowedSourceDirs.Contains(dir);
|
||||
if (allowed) continue;
|
||||
var lines = File.ReadAllLines(file);
|
||||
var raw = File.ReadAllLines(file);
|
||||
var lines = StripCommentsAndLiterals(File.ReadAllText(file)).Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var code = lines[i].Split("//")[0];
|
||||
if (Reference.IsMatch(code)) offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {lines[i].Trim()}");
|
||||
if (Reference.IsMatch(lines[i]))
|
||||
offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {(i < raw.Length ? raw[i].Trim() : lines[i].Trim())}");
|
||||
}
|
||||
}
|
||||
offenders.ShouldBeEmpty(string.Join(Environment.NewLine, offenders));
|
||||
}
|
||||
|
||||
/// <summary>#569: the libplctag PACKAGE may be referenced only by the two drivers that still carry the adapters.</summary>
|
||||
[Fact]
|
||||
public void Only_the_two_AB_driver_projects_reference_the_libplctag_package()
|
||||
{
|
||||
var root = RepoRoot();
|
||||
var offenders = new List<string>();
|
||||
foreach (var top in new[] { "src", "tests" })
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, top), "*.csproj", SearchOption.AllDirectories))
|
||||
{
|
||||
if (IsBuildOutput(file)) continue;
|
||||
if (AllowedPackageProjects.Contains(Path.GetFileName(file))) continue;
|
||||
if (PackageRef.IsMatch(File.ReadAllText(file))) offenders.Add(Path.GetRelativePath(root.FullName, file));
|
||||
}
|
||||
}
|
||||
offenders.ShouldBeEmpty("only the AbCip / AbLegacy driver projects may reference the libplctag package:" +
|
||||
Environment.NewLine + string.Join(Environment.NewLine, offenders));
|
||||
}
|
||||
|
||||
/// <summary>The csproj regex matches the shapes a libplctag package reference can take (positive control).</summary>
|
||||
/// <param name="xml">A package reference element.</param>
|
||||
[Theory]
|
||||
[InlineData("<PackageReference Include=\"libplctag\"/>")]
|
||||
[InlineData("<PackageReference Include=\"libplctag\" Version=\"1.5.2\" />")]
|
||||
[InlineData("<PackageReference Update='libplctag'/>")]
|
||||
[InlineData("<PackageReference Include=\"libplctag.NativeImport\"/>")]
|
||||
public void Csproj_package_references_trip_the_guard(string xml) => PackageRef.IsMatch(xml).ShouldBeTrue(xml);
|
||||
|
||||
/// <summary>An unrelated package that merely starts with the same letters does not trip it.</summary>
|
||||
[Fact]
|
||||
public void Unrelated_packages_do_not_trip_the_csproj_guard() =>
|
||||
PackageRef.IsMatch("<PackageReference Include=\"libplctagger\"/>").ShouldBeFalse();
|
||||
|
||||
/// <summary>A reference AFTER a string literal containing <c>//</c> is still seen (#569 item 1).</summary>
|
||||
[Fact]
|
||||
public void A_reference_after_a_url_literal_is_not_hidden()
|
||||
{
|
||||
var code = StripCommentsAndLiterals("var u = \"http://example\"; var t = new libplctag.Tag();");
|
||||
Reference.IsMatch(code).ShouldBeTrue(code);
|
||||
}
|
||||
|
||||
/// <summary>A libplctag mention inside a string literal is text, not a reference.</summary>
|
||||
[Fact]
|
||||
public void A_reference_inside_a_string_literal_is_ignored()
|
||||
{
|
||||
Reference.IsMatch(StripCommentsAndLiterals("var s = \"using libplctag;\";")).ShouldBeFalse();
|
||||
Reference.IsMatch(StripCommentsAndLiterals("var s = @\"a \"\"quoted\"\" libplctag.Tag\";")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Block comments are stripped; code after them on the same line is kept (#569 item 2).</summary>
|
||||
[Fact]
|
||||
public void Block_comments_are_stripped_but_code_after_them_is_not()
|
||||
{
|
||||
Reference.IsMatch(StripCommentsAndLiterals("/* using libplctag; */ var x = 1;")).ShouldBeFalse();
|
||||
Reference.IsMatch(StripCommentsAndLiterals("/* a\n using libplctag;\n b */ var x = 1;")).ShouldBeFalse();
|
||||
Reference.IsMatch(StripCommentsAndLiterals("/* c */ var t = new libplctag.Tag();")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Stripping keeps the line count, so offender line numbers stay accurate.</summary>
|
||||
[Fact]
|
||||
public void Stripping_preserves_line_numbers()
|
||||
{
|
||||
var src = "a\n/* x\ny */\nvar s = @\"p\nq\";\nusing libplctag;\n";
|
||||
var stripped = StripCommentsAndLiterals(src).Split('\n');
|
||||
stripped.Length.ShouldBe(src.Split('\n').Length);
|
||||
Reference.IsMatch(stripped[5]).ShouldBeTrue(string.Join("|", stripped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prose_mentions_of_libplctag_do_not_trip_the_guard()
|
||||
{
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using System.Globalization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag.NativeImport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -9,27 +9,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipStatusMapperTests
|
||||
{
|
||||
/// <summary>Verifies that CIP general status codes are mapped to OPC UA status codes.</summary>
|
||||
/// <param name="status">The raw CIP general status byte.</param>
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x05, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x06, AbCipStatusMapper.GoodMoreData)]
|
||||
[InlineData((byte)0x08, AbCipStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x0A, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x13, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x0B, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x0E, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x10, AbCipStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x16, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0xFF, AbCipStatusMapper.BadInternalError)]
|
||||
public void MapCipGeneralStatus_maps_known_codes(byte status, uint expected)
|
||||
{
|
||||
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that transport status values are mapped to OPC UA status codes.</summary>
|
||||
/// <param name="status">The transport status to map.</param>
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
|
||||
@@ -77,6 +77,38 @@ public sealed class AbCipTypeMismatchTests
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>#585: the libplctag-style width-mismatch path (status Ok, but the buffer is not the authored
|
||||
/// type's width — a REAL authored as INT) gets the same treatment as the native TypeMismatch status.</summary>
|
||||
[Fact]
|
||||
public async Task A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once()
|
||||
{
|
||||
var creates = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => { Interlocked.Increment(ref creates); return new FakeAbCipTag(p) { Value = 1, Size = 4 }; },
|
||||
};
|
||||
var log = new CapturingLogger();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.Int)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-585", factory, logger: log);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var health = drv.GetHealth();
|
||||
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_type_mismatched_write_keeps_the_handle_leaves_health_alone_and_warns_once()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag.NativeImport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -39,6 +39,66 @@ public sealed class LibplctagStatusTranslationTests
|
||||
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #568: the message-parse is pinned against the REAL exception libplctag builds from a <see cref="Status"/>
|
||||
/// (not a hand-typed message), for every defined status — if a libplctag bump changes the message format
|
||||
/// the translation would silently degrade to BadConnection, and this fails instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_real_LibPlcTagException_built_from_a_Status_round_trips_through_the_message_parse()
|
||||
{
|
||||
foreach (var status in Enum.GetValues<Status>())
|
||||
{
|
||||
if (status == Status.Ok || status == Status.Pending) continue; // libplctag throws only on errors
|
||||
var ex = new LibPlcTagException(status);
|
||||
LibplctagStatusTranslation.ToAbStatus(ex).ShouldBe(LibplctagStatusTranslation.ToAbStatus(status), $"{status}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#568: a cancellation is not a libplctag failure — WrapSync must not translate or swallow it.</summary>
|
||||
[Fact]
|
||||
public void WrapSync_passes_OperationCanceledException_through()
|
||||
{
|
||||
Should.Throw<OperationCanceledException>(
|
||||
() => LibplctagStatusTranslation.WrapSync<int>(() => throw new OperationCanceledException()));
|
||||
Should.Throw<OperationCanceledException>(
|
||||
() => LibplctagStatusTranslation.WrapSync(() => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
/// <summary>WrapSync translates a libplctag failure and returns a successful result unchanged.</summary>
|
||||
[Fact]
|
||||
public void WrapSync_translates_libplctag_failures_and_returns_results()
|
||||
{
|
||||
LibplctagStatusTranslation.WrapSync(() => 42).ShouldBe(42);
|
||||
var ex = Should.Throw<AbTransportException>(
|
||||
() => LibplctagStatusTranslation.WrapSync<int>(() => throw new LibPlcTagException("ErrorTimeout")));
|
||||
ex.Status.ShouldBe(AbStatus.Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #568: the ADAPTER (not just the helper) routes decode through WrapSync. A real, never-initialised native
|
||||
/// <c>Tag</c> (no network I/O — nothing was created) throws a libplctag exception on a decode, and the runtime
|
||||
/// must surface it as an <see cref="AbTransportException"/>. Remove the WrapSync around the decode and this
|
||||
/// sees a raw <see cref="LibPlcTagException"/> (or <see cref="InvalidOperationException"/>) instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LibplctagTagRuntime_decode_on_an_unread_tag_surfaces_AbTransportException()
|
||||
{
|
||||
var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
|
||||
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
|
||||
TagName: "NeverInitialised", Timeout: TimeSpan.FromMilliseconds(100)));
|
||||
|
||||
Should.Throw<AbTransportException>(() => rt.DecodeValueAt(AbCipDataType.DInt, 0, null));
|
||||
}
|
||||
|
||||
/// <summary>Wrap passes a cancellation through untranslated.</summary>
|
||||
[Fact]
|
||||
public async Task Wrap_passes_OperationCanceledException_through()
|
||||
{
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => LibplctagStatusTranslation.Wrap(() => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
/// <summary>Wrap rethrows as a transport exception keeping the original as InnerException.</summary>
|
||||
[Fact]
|
||||
public async Task Wrap_rethrows_as_AbTransportException_with_inner()
|
||||
|
||||
@@ -49,23 +49,6 @@ public sealed class AbLegacyHostAndStatusTests
|
||||
new AbLegacyHostAddress("10.0.0.5", 2222, "1,0").ToString().ShouldBe("ab://10.0.0.5:2222/1,0");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PCCC status codes are mapped to OPC UA status codes correctly.</summary>
|
||||
/// <param name="sts">The PCCC status byte to map.</param>
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbLegacyStatusMapper.Good)]
|
||||
[InlineData((byte)0x10, AbLegacyStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x20, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x30, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x40, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x50, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0xF0, AbLegacyStatusMapper.BadInternalError)]
|
||||
[InlineData((byte)0xFF, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
public void PcccStatus_maps_known_codes(byte sts, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AbStatus members map to OPC UA status codes correctly.</summary>
|
||||
/// <param name="status">The AbStatus value to map.</param>
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
@@ -92,16 +75,6 @@ public sealed class AbLegacyHostAndStatusTests
|
||||
AbLegacyStatusMapper.MapStatus((int)status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>MapException reads the status off an AbTransportException, else falls back to comms error.</summary>
|
||||
[Fact]
|
||||
public void MapException_uses_transport_status()
|
||||
{
|
||||
AbLegacyStatusMapper.MapException(new AbTransportException(AbStatus.Timeout, "t"))
|
||||
.ShouldBe(AbLegacyStatusMapper.BadTimeout);
|
||||
AbLegacyStatusMapper.MapException(new InvalidOperationException("x"))
|
||||
.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The regression oracle: the OPC UA code the pre-seam <c>MapLibplctagStatus(Status)</c> returned,
|
||||
/// copied verbatim from the deleted switch. For every libplctag member the new
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag.NativeImport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
+22
-2
@@ -32,8 +32,7 @@ public sealed class AbCipDriverDefaultsOptionsTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||
services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
services.AddAbCipDriverDefaults(configuration);
|
||||
services.AddOtOpcUaDriverFactories();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
@@ -48,6 +47,27 @@ public sealed class AbCipDriverDefaultsOptionsTests
|
||||
private static AbCipDriver Create(ServiceProvider sp, string json) =>
|
||||
(AbCipDriver)sp.GetRequiredService<DriverFactoryRegistry>().TryGet(DriverTypeNames.AbCip)!("drv-i1", json);
|
||||
|
||||
/// <summary>
|
||||
/// #585 composition-root pin: <c>Program.cs</c> is top-level statements and cannot be booted in a unit test, so
|
||||
/// the registration lives in <see cref="AbCipDriverDefaultsRegistration.AddAbCipDriverDefaults"/> (which every
|
||||
/// test here builds through) and this asserts Program.cs still CALLS it. Deleting the call silently ignores an
|
||||
/// operator's <c>Drivers:AbCip:DefaultTransport</c> pin — every behaviour test above would stay green.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Program_registers_the_node_default_transport()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent;
|
||||
dir.ShouldNotBeNull();
|
||||
var program = File.ReadAllText(Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host", "Program.cs"));
|
||||
|
||||
System.Text.RegularExpressions.Regex.IsMatch(
|
||||
program, @"^builder\.Services\s*\.\s*AddAbCipDriverDefaults\(\s*builder\.Configuration\s*\)\s*;",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline)
|
||||
.ShouldBeTrue("Program.cs must call builder.Services.AddAbCipDriverDefaults(builder.Configuration), unconditionally " +
|
||||
"(at top level, not inside an if (hasDriver/hasAdmin) block — the probe on admin nodes needs it too)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absent_section_defaults_to_Native()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user