From 6cd687622928104041a3a9727fa2def9b728aa46 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 19:49:03 -0400 Subject: [PATCH] fix(ab): destroy the native libplctag tag a failed initialisation leaves behind (T21 press pass 1 crash) The T21 press pass 1 test host died at exit with "A callback was made on a garbage collected delegate of type ...plctag+callback_func_ex::Invoke". The cause is in libplctag.NET 1.5.2. When a tag's create fails, for example with NotFound, InitializeAsync throws with the callback still registered. Dispose() destroys a tag only if it is initialised, so the native tag outlives its wrapper. The GC then frees the delegate, and libplctag's atexit plc_tag_shutdown raises DESTROYED into it. Reproduced against ab_server: the production LibplctagTagRuntime with a NotFound tag exits 134 with that exact message, and exits 0 with this fix. A timed-out create unregisters its callback but never destroys the handle either, so that native tag keeps reconnecting for the life of the process. The fix adds LibplctagOrphanRelease (AbCip and AbLegacy). After a failed initialise/read/write that leaves the tag uninitialised, it reads the handle through the pinned wrapper's private fields, unregisters the callback and destroys the tag. If those fields cannot be read, it roots the tag for the life of the process instead. It is applied in both tag runtimes, the symbol-listing reader and the template reader. GetStatus keeps the status the tag held before it was destroyed. Pins: offline refused-connection tests in AbCip.Tests and AbLegacy.Tests, a live NotFound test on ab_server, and a reflection-shape pin. All go red with the release disabled. The differential harness also asserts that no orphan had to be rooted. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../LibplctagOrphanRelease.cs | 117 ++++++++++++++++++ .../LibplctagTagEnumerator.cs | 5 +- .../LibplctagTagRuntime.cs | 38 +++++- .../LibplctagTemplateReader.cs | 5 +- .../LibplctagLegacyTagRuntime.cs | 38 +++++- .../LibplctagOrphanRelease.cs | 117 ++++++++++++++++++ .../TransportDifferentialTests.cs | 15 +++ .../LibplctagOrphanReleaseLiveTests.cs | 43 +++++++ .../LibplctagOrphanReleaseTests.cs | 56 +++++++++ .../LibplctagLegacyOrphanReleaseTests.cs | 40 ++++++ 10 files changed, 462 insertions(+), 12 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs 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(); + } + } +}