diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 10d40b5e..9d6e3799 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -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`) @@ -325,7 +326,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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/LibplctagOrphanRelease.cs similarity index 92% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/LibplctagOrphanRelease.cs index f0defa6e..5ac1f8d1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/LibplctagOrphanRelease.cs @@ -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; /// /// 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 (Tag._tag, NativeTagWrapper.nativeTagHandle). 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 LibplctagOrphanReleaseTests fails, so the bump is noticed. +/// create, and the data path re-creates a NotFound tag on every read — so LibplctagOrphanReleaseTests / LibplctagLegacyOrphanReleaseTests fail, so the bump is noticed. +/// SHARED SOURCE (#580): this directory has no project. Driver.AbCip and Driver.AbLegacy each +/// compile this file in (<Compile Include>), so each assembly has its own internal copy and its own +/// counters. It must keep a Libplctag*.cs name — LibplctagConfinementGuardTests allows libplctag +/// references only in such files. /// internal static class LibplctagOrphanRelease { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbOpcUaStatusMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbOpcUaStatusMap.cs new file mode 100644 index 00000000..9404df57 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbOpcUaStatusMap.cs @@ -0,0 +1,55 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +/// +/// The single to OPC UA StatusCode table shared by AbCipStatusMapper and +/// AbLegacyStatusMapper (#580) — the two drivers used to carry identical copies. +/// +public static class AbOpcUaStatusMap +{ + /// OPC UA Good. + public const uint Good = 0u; + /// OPC UA GoodMoreData. + public const uint GoodMoreData = 0x00A60000u; + /// OPC UA BadNodeIdUnknown. + public const uint BadNodeIdUnknown = 0x80340000u; + /// OPC UA BadNotWritable. + public const uint BadNotWritable = 0x803B0000u; + /// OPC UA BadOutOfRange. + public const uint BadOutOfRange = 0x803C0000u; + /// OPC UA BadNotSupported. + public const uint BadNotSupported = 0x803D0000u; + /// OPC UA BadDeviceFailure. + public const uint BadDeviceFailure = 0x808B0000u; + /// OPC UA BadCommunicationError. + public const uint BadCommunicationError = 0x80050000u; + /// OPC UA BadTimeout. + public const uint BadTimeout = 0x800A0000u; + /// OPC UA BadTypeMismatch. + public const uint BadTypeMismatch = 0x80740000u; + + /// Maps a transport-neutral to an OPC UA StatusCode. + /// The transport status. + /// The corresponding OPC UA status code. + 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 + }; + + /// + /// Status for an exception thrown while creating / reading a tag: a transport exception maps through its + /// ; anything else is . + /// + /// The exception. + /// The OPC UA status code. + public static uint MapException(Exception ex) => + ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs index 7b026d30..b1cf27ba 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs @@ -3,35 +3,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// -/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of -/// ModbusDriver.MapModbusExceptionToStatus so Admin UI status displays stay -/// uniform across drivers. +/// Maps transport values to OPC UA StatusCodes through the table shared with AbLegacy +/// (): 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 +/// BadCommunicationError. (The raw CIP general-status mapper that used to live here had no callers — the +/// transports surface an , not the status byte — and was removed, #580.) /// -/// -/// 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: -/// -/// 0x00 success — OPC UA Good (0). -/// 0x04 path segment error / 0x05 path destination unknown — BadNodeIdUnknown -/// (tag doesn't exist). -/// 0x06 partial data transfer — GoodMoreData (fragmented read underway). -/// 0x08 service not supported — BadNotSupported (e.g. write on a safety -/// partition tag from a non-safety task). -/// 0x0A / 0x13 attribute-list error / insufficient data — BadOutOfRange -/// (type mismatch or truncated buffer). -/// 0x0B already in requested mode — benign, treated as Good. -/// 0x0E attribute not settable — BadNotWritable. -/// 0x10 device state conflict — BadDeviceFailure (program-mode protected -/// writes during download / test-mode transitions). -/// 0x16 object does not exist — BadNodeIdUnknown. -/// 0x1E embedded service error — unwrap to the extended status when possible. -/// Transport errors — mapped per-member by -/// : timeout, not-found, not-allowed, and -/// out-of-bounds get their specific OPC UA codes; the remaining transport errors -/// fold into BadCommunicationError. -/// -/// 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; - /// Map a CIP general-status byte to an OPC UA StatusCode. - /// The CIP general-status byte value. - /// The mapped OPC UA StatusCode value. - 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, - }; - /// /// Map a transport status code to an OPC UA StatusCode. The integer is /// , i.e. the underlying value of . @@ -78,19 +38,7 @@ public static class AbCipStatusMapper /// /// The transport status. /// The mapped OPC UA StatusCode value. - 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); /// /// Status for an exception thrown while creating / reading a tag: a transport exception maps @@ -101,5 +49,5 @@ public static class AbCipStatusMapper /// The exception. /// The OPC UA status code. public static uint MapException(Exception ex) => - ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError; + AbOpcUaStatusMap.MapException(ex); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs index 11dd941a..21cfa9da 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using libplctag; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index d1c4436b..a12885ff 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using libplctag; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs index 26f3d838..3fff5d69 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using libplctag; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj index 59436b5e..04a183c4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj @@ -24,6 +24,8 @@ Decision #11 — EtherNet/IP + CIP + Logix symbolic against ControlLogix / CompactLogix / Micro800 / SLC500 / PLC-5. --> + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs index d5746476..0178c032 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs @@ -3,9 +3,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// -/// 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 +/// (). /// public static class AbLegacyStatusMapper { @@ -32,45 +31,5 @@ public static class AbLegacyStatusMapper /// Map a transport-neutral to an OPC UA StatusCode. /// The transport status. /// The corresponding OPC UA status code. - 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 - }; - - /// Map an exception thrown by a transport to an OPC UA StatusCode. - /// The exception. - /// The mapped status for an , else BadCommunicationError. - public static uint MapException(Exception ex) => - ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError; - - /// - /// 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 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. - /// - /// The PCCC STS byte. - /// The corresponding OPC UA status code. - 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); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index 4067d0b2..96634516 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using libplctag; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs deleted file mode 100644 index 7b4ec909..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using libplctag; -using libplctag.NativeImport; - -namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; - -/// -/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash). -/// -/// -/// libplctag.NET 1.5.2's NativeTagWrapper.InitializeAsync calls plc_tag_create_ex, which returns a -/// native handle with the wrapper's callback_func_ex delegate registered, and only then waits for the -/// Created event. When that event carries an error (a tag the controller does not have, a refused path, ...) -/// it throws WITHOUT setting _isInitialized, and Dispose() unregisters and destroys only -/// if (_isInitialized). 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 DESTROYED event from plc_tag_shutdown, which the native library runs from -/// atexit — 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. -/// The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned -/// 1.5.2 wrapper (Tag._tag, NativeTagWrapper.nativeTagHandle). 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 LibplctagLegacyOrphanReleaseTests fails, so the bump is noticed. -/// -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; - - /// Tags whose handle could not be read: kept reachable so their callback delegate is never collected. - private static readonly ConcurrentBag Rooted = []; - - private static int _released; - - /// True when the pinned wrapper's private fields were found, so a failed tag can be destroyed. - internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest; - - /// Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed. - internal static bool ForceRootingForTest { get; set; } - - /// Orphaned native tags destroyed so far (process-wide; diagnostics and tests). - internal static int Released => Volatile.Read(ref _released); - - /// Orphaned tags rooted for the process lifetime because their handle could not be read. - internal static int RootedCount => Rooted.Count; - - /// 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. - 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; - } - } - - /// Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2). - private static void ClearHandle(Tag tag) - { - if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0); - } - - /// - /// Runs a libplctag operation that may initialise (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. - /// - internal static async Task RunAsync(Tag tag, Func op) - { - try - { - await op().ConfigureAwait(false); - } - catch - { - ReleaseIfOrphaned(tag); - throw; - } - } - - /// - internal static async Task RunAsync(Tag tag, Func> op) - { - try - { - return await op().ConfigureAwait(false); - } - catch - { - ReleaseIfOrphaned(tag); - throw; - } - } - - /// - /// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an - /// initialised tag (its own Dispose handles it) or one that never got a handle. Never throws. - /// - /// The status the native tag held just before it was destroyed (what GetStatus() would have - /// reported), or null when nothing was destroyed. - 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; - } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj index b4fe82f3..ad6047cd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj @@ -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. --> + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs index c9cc37d5..e63e6c4a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs @@ -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 : OptionsValidatorBaseComposition-root registration of . +public static class AbCipDriverDefaultsRegistration +{ + /// + /// Binds + validates Drivers:AbCip. 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. Program.cs must call this + /// (pinned by AbCipDriverDefaultsOptionsTests): without it an operator's pin is silently ignored (#585). + /// + /// The service collection. + /// The host configuration. + /// The same collection. + public static IServiceCollection AddAbCipDriverDefaults(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions( + configuration, AbCipDriverDefaultsOptions.SectionName); + return services; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index a2723220..33277f90 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -179,8 +179,7 @@ builder.Services.AddValidatedOptions(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( - builder.Configuration, AbCipDriverDefaultsOptions.SectionName); +builder.Services.AddAbCipDriverDefaults(builder.Configuration); if (hasDriver) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs index 1ee25c6d..efc8f491 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs @@ -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( + @"]*\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}"); + + /// + /// 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 + /// // — a URL literal used to truncate the line and hide a reference after it, and a + /// /* */ block comment was never stripped (#569). + /// + /// The C# source. + /// The source with only code left. + 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(); 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)); } + /// #569: the libplctag PACKAGE may be referenced only by the two drivers that still carry the adapters. + [Fact] + public void Only_the_two_AB_driver_projects_reference_the_libplctag_package() + { + var root = RepoRoot(); + var offenders = new List(); + 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)); + } + + /// The csproj regex matches the shapes a libplctag package reference can take (positive control). + /// A package reference element. + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void Csproj_package_references_trip_the_guard(string xml) => PackageRef.IsMatch(xml).ShouldBeTrue(xml); + + /// An unrelated package that merely starts with the same letters does not trip it. + [Fact] + public void Unrelated_packages_do_not_trip_the_csproj_guard() => + PackageRef.IsMatch("").ShouldBeFalse(); + + /// A reference AFTER a string literal containing // is still seen (#569 item 1). + [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); + } + + /// A libplctag mention inside a string literal is text, not a reference. + [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(); + } + + /// Block comments are stripped; code after them on the same line is kept (#569 item 2). + [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(); + } + + /// Stripping keeps the line count, so offender line numbers stay accurate. + [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() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs index 46255cbd..283cd63a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using System.Globalization; using Shouldly; using Xunit; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs index 453cb1f8..7b3cb020 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using System.Buffers.Binary; using System.Globalization; using System.Reflection; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs index 5c9bf71c..480b16af 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using libplctag.NativeImport; using Shouldly; using Xunit; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs index 8c813c63..7c3c63df 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs @@ -9,27 +9,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; [Trait("Category", "Unit")] public sealed class AbCipStatusMapperTests { - /// Verifies that CIP general status codes are mapped to OPC UA status codes. - /// The raw CIP general status byte. - /// The expected OPC UA status code. - [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); - } - /// Verifies that transport status values are mapped to OPC UA status codes. /// The transport status to map. /// The expected OPC UA status code. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs index d44485c9..ec5f77f8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs @@ -77,6 +77,38 @@ public sealed class AbCipTypeMismatchTests MismatchWarnings(log).ShouldBe(1); } + /// #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. + [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() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs index 3d1b3a62..228a602c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using libplctag.NativeImport; using Shouldly; using Xunit; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs index 67751521..39bc958a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs @@ -39,6 +39,66 @@ public sealed class LibplctagStatusTranslationTests LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection); } + /// + /// #568: the message-parse is pinned against the REAL exception libplctag builds from a + /// (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. + /// + [Fact] + public void A_real_LibPlcTagException_built_from_a_Status_round_trips_through_the_message_parse() + { + foreach (var status in Enum.GetValues()) + { + 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}"); + } + } + + /// #568: a cancellation is not a libplctag failure — WrapSync must not translate or swallow it. + [Fact] + public void WrapSync_passes_OperationCanceledException_through() + { + Should.Throw( + () => LibplctagStatusTranslation.WrapSync(() => throw new OperationCanceledException())); + Should.Throw( + () => LibplctagStatusTranslation.WrapSync(() => throw new OperationCanceledException())); + } + + /// WrapSync translates a libplctag failure and returns a successful result unchanged. + [Fact] + public void WrapSync_translates_libplctag_failures_and_returns_results() + { + LibplctagStatusTranslation.WrapSync(() => 42).ShouldBe(42); + var ex = Should.Throw( + () => LibplctagStatusTranslation.WrapSync(() => throw new LibPlcTagException("ErrorTimeout"))); + ex.Status.ShouldBe(AbStatus.Timeout); + } + + /// + /// #568: the ADAPTER (not just the helper) routes decode through WrapSync. A real, never-initialised native + /// Tag (no network I/O — nothing was created) throws a libplctag exception on a decode, and the runtime + /// must surface it as an . Remove the WrapSync around the decode and this + /// sees a raw (or ) instead. + /// + [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(() => rt.DecodeValueAt(AbCipDataType.DInt, 0, null)); + } + + /// Wrap passes a cancellation through untranslated. + [Fact] + public async Task Wrap_passes_OperationCanceledException_through() + { + await Should.ThrowAsync( + () => LibplctagStatusTranslation.Wrap(() => throw new OperationCanceledException())); + } + /// Wrap rethrows as a transport exception keeping the original as InnerException. [Fact] public async Task Wrap_rethrows_as_AbTransportException_with_inner() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs index 07c5abdb..8338dc3b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs @@ -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"); } - /// Verifies that PCCC status codes are mapped to OPC UA status codes correctly. - /// The PCCC status byte to map. - /// The expected OPC UA status code. - [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); - } - /// Verifies that AbStatus members map to OPC UA status codes correctly. /// The AbStatus value to map. /// The expected OPC UA status code. @@ -92,16 +75,6 @@ public sealed class AbLegacyHostAndStatusTests AbLegacyStatusMapper.MapStatus((int)status).ShouldBe(expected); } - /// MapException reads the status off an AbTransportException, else falls back to comms error. - [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); - } - /// /// The regression oracle: the OPC UA code the pre-seam MapLibplctagStatus(Status) returned, /// copied verbatim from the deleted switch. For every libplctag member the new diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs index 913eff67..8d6adfa7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared; using libplctag.NativeImport; using Shouldly; using Xunit; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbCipDriverDefaultsOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbCipDriverDefaultsOptionsTests.cs index 9565cdc2..9299f3b2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbCipDriverDefaultsOptionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbCipDriverDefaultsOptionsTests.cs @@ -32,8 +32,7 @@ public sealed class AbCipDriverDefaultsOptionsTests var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new StubSecretResolver()); - services.AddValidatedOptions( - 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().TryGet(DriverTypeNames.AbCip)!("drv-i1", json); + /// + /// #585 composition-root pin: Program.cs is top-level statements and cannot be booted in a unit test, so + /// the registration lives in (which every + /// test here builds through) and this asserts Program.cs still CALLS it. Deleting the call silently ignores an + /// operator's Drivers:AbCip:DefaultTransport pin — every behaviour test above would stay green. + /// + [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() {