diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs
new file mode 100644
index 00000000..d68d3cb2
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs
@@ -0,0 +1,117 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using libplctag;
+using libplctag.NativeImport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// 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 (no crash, a bounded leak) — and LibplctagOrphanReleaseTests 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);
+ private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", Private);
+
+ /// 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;
+
+ /// 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).
+ internal static int HandleOf(Tag tag) =>
+ HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(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);
+ 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.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
index 486dcbfc..11dd941a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
@@ -12,12 +12,13 @@ internal static class LibplctagListingReader
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
- return await LibplctagStatusTranslation.Wrap(async () =>
+ // A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
+ return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
- }).ConfigureAwait(false);
+ })).ConfigureAwait(false);
}
///
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 cfe3761c..6f509db9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -14,6 +14,12 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
+ ///
+ /// The status the native tag held when a failed initialisation destroyed it (see
+ /// ); null while the wrapper's own handle is authoritative.
+ ///
+ private Status? _releasedStatus;
+
/// Initializes a new instance of the LibplctagTagRuntime class.
/// The tag creation parameters.
public LibplctagTagRuntime(AbCipTagCreateParams p)
@@ -42,18 +48,42 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
///
public Task InitializeAsync(CancellationToken cancellationToken) =>
- LibplctagStatusTranslation.Wrap(() => _tag.InitializeAsync(cancellationToken));
+ RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
///
+ /// libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.
public Task ReadAsync(CancellationToken cancellationToken) =>
- LibplctagStatusTranslation.Wrap(() => _tag.ReadAsync(cancellationToken));
+ RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
///
public Task WriteAsync(CancellationToken cancellationToken) =>
- LibplctagStatusTranslation.Wrap(() => _tag.WriteAsync(cancellationToken));
+ RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
+
+ ///
+ /// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
+ /// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
+ /// pass 1 crash). The status that tag held is kept for .
+ ///
+ private async Task RunReleasingOrphanAsync(Func op)
+ {
+ try
+ {
+ await LibplctagStatusTranslation.Wrap(op).ConfigureAwait(false);
+ _releasedStatus = null;
+ }
+ catch
+ {
+ if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status) _releasedStatus = status;
+ throw;
+ }
+ }
+
+ /// The native libplctag handle this runtime's tag holds or last held (0 if none was created). Tests only.
+ internal int NativeHandle => LibplctagOrphanRelease.HandleOf(_tag);
///
- public int GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus());
+ public int GetStatus() =>
+ (int)LibplctagStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
///
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
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 e04864d6..26f3d838 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs
@@ -21,12 +21,13 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
_tag?.Dispose();
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
var tag = _tag;
- return await LibplctagStatusTranslation.Wrap(async () =>
+ // A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
+ return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
- }).ConfigureAwait(false);
+ })).ConfigureAwait(false);
}
/// Builds the (uninitialized) libplctag tag for the @udt/{id} pseudo.
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 5cb03a08..e99b097c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
@@ -13,6 +13,12 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
+ ///
+ /// The status the native tag held when a failed initialisation destroyed it (see
+ /// ); null while the wrapper's own handle is authoritative.
+ ///
+ private Status? _releasedStatus;
+
/// Initializes a new instance of the class.
/// The parameters for tag creation.
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
@@ -38,18 +44,42 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
///
public Task InitializeAsync(CancellationToken cancellationToken) =>
- LibplctagLegacyStatusTranslation.Wrap(() => _tag.InitializeAsync(cancellationToken));
+ RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
///
+ /// libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.
public Task ReadAsync(CancellationToken cancellationToken) =>
- LibplctagLegacyStatusTranslation.Wrap(() => _tag.ReadAsync(cancellationToken));
+ RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
///
public Task WriteAsync(CancellationToken cancellationToken) =>
- LibplctagLegacyStatusTranslation.Wrap(() => _tag.WriteAsync(cancellationToken));
+ RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
+
+ ///
+ /// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
+ /// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
+ /// pass 1 crash). The status that tag held is kept for .
+ ///
+ private async Task RunReleasingOrphanAsync(Func op)
+ {
+ try
+ {
+ await LibplctagLegacyStatusTranslation.Wrap(op).ConfigureAwait(false);
+ _releasedStatus = null;
+ }
+ catch
+ {
+ if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status) _releasedStatus = status;
+ throw;
+ }
+ }
+
+ /// The native libplctag handle this runtime's tag holds or last held (0 if none was created). Tests only.
+ internal int NativeHandle => LibplctagOrphanRelease.HandleOf(_tag);
///
- public int GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus());
+ public int GetStatus() =>
+ (int)LibplctagLegacyStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
///
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs
new file mode 100644
index 00000000..642ba5f3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs
@@ -0,0 +1,117 @@
+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 (no crash, a bounded leak) — and 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);
+ private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", Private);
+
+ /// 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;
+
+ /// 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).
+ internal static int HandleOf(Tag tag) =>
+ HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(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);
+ 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/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 d4b4494f..5469f843 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
@@ -138,6 +138,21 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value);
}
+ ///
+ /// T21 press pass 1 ended in "A callback was made on a garbage collected delegate": libplctag tags whose
+ /// initialisation failed (NotFound tags, refused latency setups) were left native-side with a live callback.
+ /// Every libplctag tag this harness creates goes through the driver's adapters, which now destroy such a tag
+ /// (); this pins that none had to fall back to being rooted instead.
+ ///
+ [Fact]
+ public async Task No_failed_libplctag_tag_is_left_with_a_live_native_callback()
+ {
+ await RunOrSkipAsync();
+ Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}.");
+ LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
+ LibplctagOrphanRelease.RootedCount.ShouldBe(0);
+ }
+
[Fact]
public async Task The_native_side_makes_exactly_one_forward_open_over_the_whole_run()
{
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
new file mode 100644
index 00000000..5c9bf71c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs
@@ -0,0 +1,43 @@
+using libplctag.NativeImport;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
+
+///
+/// T21 press pass 1 crash, the variant that killed the test host: a tag the controller does not have. libplctag
+/// raises Created with NotFound, so libplctag.NET 1.5.2 throws WITHOUT unregistering its callback, and its
+/// Dispose() skips the uninitialised tag. Before the fix the native tag survived with a live callback into a
+/// delegate the GC was free to collect; libplctag's atexit shutdown then raised DESTROYED into it ("A callback
+/// was made on a garbage collected delegate"). This pins that the native tag is destroyed instead.
+///
+[Trait("Category", "Integration")]
+[Trait("Requires", "AbServer")]
+public sealed class LibplctagOrphanReleaseLiveTests
+{
+ [AbServerFact]
+ public async Task A_tag_the_controller_lacks_leaves_no_native_tag_behind()
+ {
+ var fixture = new AbServerFixture();
+ var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
+ Gateway: fixture.Host, Port: fixture.Port, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
+ TagName: "T21_NoSuchTag", Timeout: TimeSpan.FromSeconds(5)));
+ try
+ {
+ var ex = await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.NotFound);
+ rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
+
+ var handle = rt.NativeHandle;
+ handle.ShouldBeGreaterThan(0);
+ var exists = plctag.plc_tag_lock(handle) == 0;
+ if (exists) plctag.plc_tag_unlock(handle);
+ exists.ShouldBeFalse("a NotFound tag must not survive with its callback registered");
+ }
+ finally
+ {
+ rt.Dispose();
+ }
+ }
+}
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
new file mode 100644
index 00000000..52e51759
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs
@@ -0,0 +1,56 @@
+using libplctag.NativeImport;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// T21 press pass 1: a libplctag tag whose initialisation FAILED used to leave its native tag behind, because
+/// libplctag.NET 1.5.2's Dispose() only destroys an initialised tag. Its callback delegate then died with
+/// the wrapper, and libplctag's atexit shutdown raised DESTROYED into it: "A callback was made on a garbage
+/// collected delegate". These pin that a failed initialisation destroys the native tag before it propagates.
+/// The NotFound variant (the one that crashed, callback still registered) needs a controller and lives in
+/// AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests; this offline one is the refused-connection
+/// variant, whose native tag otherwise keeps reconnecting in the background for the life of the process.
+///
+public sealed class LibplctagOrphanReleaseTests
+{
+ [Fact]
+ public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle()
+ {
+ // A libplctag bump that renames Tag._tag / NativeTagWrapper.nativeTagHandle disables the release (the tag is
+ // then rooted for the process lifetime instead). Fail here so the bump is reviewed against the release.
+ LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
+ {
+ // Port 1 on loopback refuses the connection; libplctag keeps retrying until the timeout.
+ var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
+ Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
+ TagName: "TestDINT", Timeout: TimeSpan.FromMilliseconds(300)));
+ try
+ {
+ await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None));
+ var handle = rt.NativeHandle;
+ handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure");
+ NativeTagExists(handle).ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned");
+ // The status the tag held is kept, not replaced by the destroyed handle's NotFound.
+ rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound);
+ }
+ finally
+ {
+ rt.Dispose();
+ }
+ }
+
+ /// plc_tag_lock answers PLCTAG_ERR_NOT_FOUND for a handle the library no longer holds.
+ internal static bool NativeTagExists(int handle)
+ {
+ if (plctag.plc_tag_lock(handle) != 0) return false;
+ plctag.plc_tag_unlock(handle);
+ return true;
+ }
+}
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
new file mode 100644
index 00000000..412d95f8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs
@@ -0,0 +1,40 @@
+using libplctag.NativeImport;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// T21 press pass 1, AbLegacy side: a failed libplctag initialisation must destroy the native tag it created
+/// (libplctag.NET 1.5.2's Dispose() skips an uninitialised tag, leaving a native tag whose callback can fire
+/// into a collected delegate and kill the process). See LibplctagOrphanRelease.
+///
+public sealed class LibplctagLegacyOrphanReleaseTests
+{
+ [Fact]
+ public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle() =>
+ LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
+
+ [Fact]
+ public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
+ {
+ var rt = new LibplctagLegacyTagRuntime(new AbLegacyTagCreateParams(
+ Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "slc500",
+ TagName: "N7:0", Timeout: TimeSpan.FromMilliseconds(300)));
+ try
+ {
+ await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None));
+ var handle = rt.NativeHandle;
+ handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure");
+ var exists = plctag.plc_tag_lock(handle) == 0;
+ if (exists) plctag.plc_tag_unlock(handle);
+ exists.ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned");
+ rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound);
+ }
+ finally
+ {
+ rt.Dispose();
+ }
+ }
+}