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:
Joseph Doherty
2026-10-07 22:59:06 -04:00
parent 04b6563286
commit 341417f193
25 changed files with 430 additions and 310 deletions
+2 -1
View File
@@ -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.
@@ -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>&lt;Compile Include&gt;</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;
}
}
+1 -2
View File
@@ -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)
{
@@ -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,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
using System.Globalization;
using Shouldly;
using Xunit;
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
using System.Buffers.Binary;
using System.Globalization;
using System.Reflection;
@@ -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,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
using libplctag.NativeImport;
using Shouldly;
using Xunit;
@@ -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()
{