Merge #568/#569/#580/#585: shared AB status map + orphan release, confinement guard tokeniser, adapter + DefaultTransport tests

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 22:59:30 -04:00
25 changed files with 430 additions and 310 deletions
@@ -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)
{