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(); + } + } +}