Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs
T
Joseph Doherty 6cd6876229 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
2026-10-07 19:49:03 -04:00

318 lines
17 KiB
C#

using System.Buffers.Binary;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>Runs the differential alone: no other suite may hold a session to the target meanwhile.</summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class DifferentialCollection : ICollectionFixture<DifferentialRunFixture>
{
public const string Name = "AbCip transport differential";
}
/// <summary>Performs the one run all differential tests read from, on first use.</summary>
public sealed class DifferentialRunFixture
{
private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(15);
private readonly Lazy<Task<DifferentialResult>> _run = new(RunAsync);
internal Task<DifferentialResult> GetAsync() => _run.Value;
private static async Task<DifferentialResult> RunAsync()
{
var target = DifferentialTarget.FromEnvironment()
?? throw new InvalidOperationException(DifferentialTarget.SkipReason);
using var cts = new CancellationTokenSource(RunBudget);
return await DifferentialRun.ExecuteAsync(target, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Read-only differential (Task 18): the same tags through libplctag and the native transport against one
/// endpoint — browse listings, UDT templates and tag reads must agree byte for byte and value for value.
/// Task 21 runs this against a production press, so the suite is read-only by construction (see
/// <see cref="The_harness_is_read_only_by_construction"/>), libplctag and native are never connected at the
/// same time, and the native side runs on one connection held open by an anchor lease.
/// </summary>
[Collection(DifferentialCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
{
[Fact]
public async Task Browse_listings_decode_to_identical_tag_sets_through_both_enumerators()
{
var r = await RunOrSkipAsync();
Log($"## Browse — {r.Target}");
Log($"libplctag: {r.Libplctag.Browse.Count} tag(s){ErrorSuffix(r.Libplctag.BrowseError)}; " +
$"native: {r.Native.Browse.Count} tag(s){ErrorSuffix(r.Native.BrowseError)}");
var onlyLib = DifferentialRun.Missing(r.Libplctag.Browse, r.Native.Browse);
var onlyNative = DifferentialRun.Missing(r.Native.Browse, r.Libplctag.Browse);
foreach (var t in onlyLib) Log($"- only libplctag: {t}");
foreach (var t in onlyNative) Log($"- only native: {t}");
r.Libplctag.BrowseError.ShouldBeNull();
r.Native.BrowseError.ShouldBeNull();
r.Libplctag.Browse.ShouldNotBeEmpty("a browse that returns nothing proves nothing");
onlyLib.ShouldBeEmpty();
onlyNative.ShouldBeEmpty();
}
[Fact]
public async Task Every_template_decodes_to_an_identical_shape_through_both_readers()
{
var r = await RunOrSkipAsync();
var ids = r.Libplctag.Shapes.Keys.Concat(r.Libplctag.TemplateErrors.Keys)
.Concat(r.Native.Shapes.Keys).Concat(r.Native.TemplateErrors.Keys)
.Distinct().Order().ToList();
Log($"## Templates — {ids.Count} id(s); truncated by {DifferentialTarget.MaxTemplatesVar}: " +
$"libplctag {r.Libplctag.TemplatesTruncated}, native {r.Native.TemplatesTruncated}");
if (ids.Count > 0)
{
Log("| Template | libplctag | native | Equal? |");
Log("|---|---|---|---|");
}
foreach (var id in ids)
{
var equal = DifferentialRun.ShapesEqual(r.Libplctag.Shapes.GetValueOrDefault(id), r.Native.Shapes.GetValueOrDefault(id));
Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {(equal ? "yes" : "**NO**")} |");
}
var problems = DifferentialRun.TemplateProblems(r.Libplctag, r.Native);
foreach (var p in problems) Log($"- PROBLEM: {p}");
problems.ShouldBeEmpty();
}
[Fact]
public async Task Every_selected_tag_reads_identically_through_both_transports()
{
var r = await RunOrSkipAsync();
Log($"## Reads — {r.Selection.Count} tag(s), {r.Target}");
Log("Selection by origin: " + string.Join(", ",
r.Selection.GroupBy(t => t.Origin).Select(g => $"{g.Key} {g.Count()}")));
Log("| Tag | Type | libplctag status | native status | Equal? |");
Log("|---|---|---|---|---|");
var unexpected = new List<string>();
var expected = new List<string>();
foreach (var tag in r.Selection)
{
var lib = r.Libplctag.Reads[tag.Name];
var nat = r.Native.Reads[tag.Name];
var verdict = Compare(tag, lib, nat);
if (verdict.StartsWith("expected", StringComparison.Ordinal)) expected.Add($"{tag.Name}: {verdict}");
else if (verdict != "yes") unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})");
Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " +
$"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {verdict} |");
}
Log("");
Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}.");
foreach (var e in expected) Log($"- expected: {e}");
foreach (var u in unexpected) Log($"- UNEXPECTED: {u}");
var coverage = DifferentialRun.SelectionCoverageProblems(r.Target, r.Libplctag, r.Selection);
foreach (var c in coverage) Log($"- COVERAGE: {c}");
r.Selection.ShouldNotBeEmpty("a run that reads nothing proves nothing");
unexpected.ShouldBeEmpty();
coverage.ShouldBeEmpty();
}
[Fact]
public async Task Libplctag_and_native_are_never_connected_at_the_same_time()
{
var r = await RunOrSkipAsync();
Log($"TCP connections to the target: baseline {r.ConnectionsBaseline?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " +
$"before native {r.ConnectionsBeforeNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " +
$"libplctag drain wait {r.LibplctagDrainWait.TotalMilliseconds:0} ms.");
if (r.ConnectionsBaseline is null)
Assert.Skip("This OS cannot enumerate TCP connections; the run fell back to a fixed settle before the native phase.");
// The run itself refuses to start the native phase while libplctag's session is open; this pins it.
r.ConnectionsBeforeNative.ShouldNotBeNull();
r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value);
}
/// <summary>
/// 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
/// (<see cref="LibplctagOrphanRelease"/>); this pins that none had to fall back to being rooted instead.
/// </summary>
[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()
{
var r = await RunOrSkipAsync();
if (!r.Target.CountOpens)
Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Open counter is only " +
"trusted when the differential runs alone.");
Log($"Native Forward Opens over the run: {r.NativeForwardOpens}.");
r.NativeForwardOpens.ShouldBe(1);
}
/// <summary>
/// Offline guard — deliberately NOT gated on the endpoint, so it runs in every build. Fails if any
/// source file of this harness names a write-capable member, and, independently of the source text,
/// if any compiled method of a harness type calls one.
/// </summary>
[Fact]
public void The_harness_is_read_only_by_construction()
{
// Built by concatenation so this file's own text never matches itself.
string[] forbidden =
[
"Write" + "Async", "Encode" + "Value", "WriteBit" + "InDInt", "Send" + "Async", "AbCip" + "Driver",
];
// IL half (review r1 M1): method names match by prefix; the driver type matches by declaring type,
// since the driver type's own members are named .ctor / ReadAsync / ..., never the type name.
var forbiddenType = "AbCip" + "Driver";
var dir = Path.GetDirectoryName(ThisFile())!;
var sources = Directory.GetFiles(dir, "*.cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs");
// Task 21's latency comparison and its run helper are part of the harness the press sees: pinned in scope,
// so moving either out of this folder (or namespace) fails the guard instead of silently escaping it.
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyComparisonTests) + ".cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyRun) + ".cs");
var textHits = (from f in sources
let text = File.ReadAllText(f)
from token in forbidden
where text.Contains(token, StringComparison.Ordinal)
select $"{Path.GetFileName(f)} names {token}").ToList();
textHits.ShouldBeEmpty();
var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList();
harnessTypes.ShouldContain(typeof(TransportDifferentialTests));
harnessTypes.ShouldContain(typeof(PollLatencyComparisonTests));
harnessTypes.ShouldContain(typeof(PollLatencyRun));
var callHits = (from t in harnessTypes
from m in MethodsOf(t)
from callee in CalleesOf(m)
where forbidden.Any(f => callee.Name.StartsWith(f, StringComparison.Ordinal))
|| callee.DeclaringType?.Name.StartsWith(forbiddenType, StringComparison.Ordinal) == true
select $"{t.Name}.{m.Name} calls {callee.DeclaringType?.Name}.{callee.Name}").ToList();
callHits.ShouldBeEmpty();
}
/// <summary>
/// Offline falsifiability check of the verdicts: a harness whose comparison could never say NO would
/// pass every live run vacuously.
/// </summary>
[Fact]
public void The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch()
{
var dint = new SelectedTag("T", AbCipDataType.DInt, 1, false, "scalar");
var ok = new ReadObservation((int)AbStatus.Ok, null, [1, 0, 0, 0], 1);
Compare(dint, ok, ok with { }).ShouldBe("yes");
Compare(dint, ok, ok with { Raw = [2, 0, 0, 0] }).ShouldBe("**NO: raw**");
Compare(dint, ok, ok with { Raw = null }).ShouldBe("**NO: raw**");
Compare(dint, ok, ok with { Decoded = 2 }).ShouldBe("**NO: value**");
Compare(dint, ok, new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null)).ShouldBe("**NO: status**");
Compare(dint, ok, ok with { Error = "boom" }).ShouldBe("**NO: error**");
var notFound = new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null);
Compare(dint, notFound, notFound with { }).ShouldBe("yes");
Compare(dint, ok, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null))
.ShouldStartWith("expected");
// Only that direction is excused: libplctag refusing what native accepts is a real divergence.
Compare(dint, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null), ok)
.ShouldBe("**NO: status**");
var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array");
var arr = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { float.NaN, 1f });
Compare(array, arr, arr with { Decoded = new[] { float.NaN, 1f } }).ShouldBe("yes");
Compare(array, arr, arr with { Decoded = new[] { float.NaN, 2f } }).ShouldBe("**NO: value**");
}
private async Task<DifferentialResult> RunOrSkipAsync()
{
if (DifferentialTarget.FromEnvironment() is null) Assert.Skip(DifferentialTarget.SkipReason);
return await fixture.GetAsync();
}
internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat)
{
// Spec §5: native refuses a returned type that disagrees with the authored one; libplctag decodes it anyway.
if (nat.Status == (int)AbStatus.TypeMismatch && lib.Status == (int)AbStatus.Ok)
return "expected (native TypeMismatch, libplctag Ok)";
if (lib.Status != nat.Status) return "**NO: status**";
if (!lib.Succeeded || !nat.Succeeded)
return lib.Succeeded == nat.Succeeded ? "yes" : "**NO: error**";
if (lib.DecodeError is not null || nat.DecodeError is not null) return "**NO: decode**";
if (lib.Raw is null || nat.Raw is null || !lib.Raw.AsSpan().SequenceEqual(nat.Raw)) return "**NO: raw**";
if (tag.Type is not null && !DifferentialRun.ValuesEqual(lib.Decoded, nat.Decoded)) return "**NO: value**";
return "yes";
}
private static string Detail(ReadObservation o) =>
o.Error ?? o.DecodeError ?? $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}";
private static string Hex(byte[]? raw) =>
raw is null ? "-" : Convert.ToHexString(raw.AsSpan(0, Math.Min(raw.Length, 32))) + (raw.Length > 32 ? "…" : "");
private static string Show(object? v) => v switch
{
null => "null",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(8).Select(Show)) + (a.Length > 8 ? ",…" : "") + "]",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => v.ToString() ?? "",
};
private static string Shape(TransportObservations o, uint id) =>
o.TemplateErrors.TryGetValue(id, out var err) ? $"error {err}"
: o.Shapes.TryGetValue(id, out var s) ? s is null ? "undecodable" : $"{s.TypeName} ({s.Members.Count} members, {s.TotalSize} B)"
: "not read";
private static string StatusName(int status) =>
Enum.IsDefined(typeof(AbStatus), status) ? ((AbStatus)status).ToString() : status.ToString(CultureInfo.InvariantCulture);
private static string ErrorSuffix(string? error) => error is null ? "" : $" (error: {error})";
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
private static string ThisFile([CallerFilePath] string path = "") => path;
private static IEnumerable<MethodBase> MethodsOf(Type t)
{
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance
| BindingFlags.Static | BindingFlags.DeclaredOnly;
return t.GetMethods(all).Cast<MethodBase>().Concat(t.GetConstructors(all));
}
/// <summary>
/// Every method a body's IL references by token (call / callvirt / newobj / ldftn). Scans every offset
/// for a MethodDef / MemberRef / MethodSpec token — over-inclusive by design: a misaligned hit that
/// happens to resolve can only add a false alarm, never hide a real call.
/// </summary>
private static IEnumerable<MethodBase> CalleesOf(MethodBase m)
{
var il = m.GetMethodBody()?.GetILAsByteArray();
if (il is null) yield break;
var typeArgs = m.DeclaringType is { IsGenericType: true } dt ? dt.GetGenericArguments() : null;
var methodArgs = m.IsGenericMethod ? m.GetGenericArguments() : null;
for (var i = 0; i + 4 <= il.Length; i++)
{
var token = BinaryPrimitives.ReadInt32LittleEndian(il.AsSpan(i, 4));
if ((token >> 24) is not (0x06 or 0x0A or 0x2B)) continue;
MethodBase? callee;
try { callee = m.Module.ResolveMethod(token, typeArgs, methodArgs); }
catch (Exception) { continue; }
if (callee is not null) yield return callee;
}
}
}