fix(ab): final review M2 — a retyped libplctag handle field degrades to keep-alive

HandleOf cast the reflected nativeTagHandle with (int) and was called
inside the runtimes' catch blocks, so a libplctag bump that retyped the
field would replace every create failure with an InvalidCastException.
The field is now accepted only if it is still an int (otherwise
CanReadHandles is false and orphans are rooted), and HandleOf never
throws. Both projects pin the field type explicitly.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 21:41:03 -04:00
parent 352ea449e9
commit cc3bea8370
4 changed files with 82 additions and 8 deletions
@@ -36,6 +36,28 @@ public sealed class LibplctagOrphanReleaseTests
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
}
/// <summary>
/// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr,
/// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block,
/// and this names the reason when it happens.
/// </summary>
[Fact]
public void The_pinned_wrapper_handle_field_is_still_an_int()
{
const System.Reflection.BindingFlags priv =
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull();
wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int));
}
/// <summary>HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0.</summary>
[Fact]
public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw()
{
using var tag = new libplctag.Tag();
LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0);
}
[Fact]
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
{
@@ -16,6 +16,28 @@ public sealed class LibplctagLegacyOrphanReleaseTests
public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle() =>
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
/// <summary>
/// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr,
/// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block,
/// and this names the reason when it happens.
/// </summary>
[Fact]
public void The_pinned_wrapper_handle_field_is_still_an_int()
{
const System.Reflection.BindingFlags priv =
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull();
wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int));
}
/// <summary>HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0.</summary>
[Fact]
public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw()
{
using var tag = new libplctag.Tag();
LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0);
}
[Fact]
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
{