test(abcip): live tags keep a per-element value check; close barrier and GC/RTT attribution (T21 review r1 I1/M9/M8/M4)

I1: a live verdict no longer waives the value check. Only elements that moved between the two libplctag reads are excused.
- An element equal in both libplctag reads must also be equal through native; otherwise **NO: stable element i**.
- A moved element must not read NaN or infinity on native unless libplctag saw it too; otherwise **NO: non-finite element i**.
- Live tags log their full raw bytes and every element, and the summary reports live tags as N of the total.
- The offline pin adds the pass-1 Real[5] shape: a stable element shifted on native fails. It is red with the element check disabled.

M9: after each native phase (differential and latency), wait for the session close. With AB_DIFF_COUNT_OPENS=1, assert exactly one Forward Close was sent, using the new CipConnection counters; acked is logged.

M8: each latency pass logs its GC collection deltas (gen0/1/2) for both transports, plus native per-exchange round-trip min/mean/max.

M4: the latency test also asserts that no libplctag orphan had to be rooted.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 20:16:33 -04:00
parent c90b1a8214
commit 43b81b6643
4 changed files with 182 additions and 27 deletions
@@ -50,6 +50,13 @@ internal sealed class DifferentialResult
/// <summary>Native Forward Opens counted between the counter reset and the anchor release.</summary>
public required int NativeForwardOpens { get; init; }
/// <summary>Forward Close frames the native side put on the wire (sent) and got a reply to (acked), over the
/// same window as <see cref="NativeForwardOpens"/> — the anchor's release included (T21 review r1 M9).</summary>
public required NativeCloses NativeForwardCloses { get; init; }
/// <summary>TCP connections to the endpoint once the native session was released and drained (null when unobservable).</summary>
public required int? ConnectionsAfterNative { get; init; }
/// <summary>TCP connections to the endpoint before libplctag started, and once its handles were disposed.</summary>
public required int? ConnectionsBaseline { get; init; }
public required int? ConnectionsBeforeNative { get; init; }
@@ -61,6 +68,9 @@ internal sealed class DifferentialResult
public required IReadOnlySet<IPEndPoint>? BaselineEndpoints { get; init; }
}
/// <summary>Forward Close frames the native side sent, and how many of them got a reply within their 1 s budget.</summary>
internal readonly record struct NativeCloses(int Sent, int Acked);
/// <summary>
/// The run stopped at its first connection-level failure, or when the native side opened more than once.
/// Thrown, never swallowed: every live test then fails with this message.
@@ -117,7 +127,10 @@ internal static class DifferentialRun
drain.Stop();
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
var (native, opens) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
var (native, opens, closes) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
// ---- Barrier: the native session must be gone too (its Forward Close is awaited in the anchor's release). ----
var afterNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "native", ct).ConfigureAwait(false);
return new DifferentialResult
{
@@ -126,6 +139,8 @@ internal static class DifferentialRun
Native = native,
Selection = selection,
NativeForwardOpens = opens,
NativeForwardCloses = closes,
ConnectionsAfterNative = afterNative,
ConnectionsBaseline = baseline?.Count,
ConnectionsBeforeNative = beforeNative,
LibplctagDrainWait = drain.Elapsed,
@@ -182,20 +197,20 @@ internal static class DifferentialRun
}
/// <summary>The native phase alone; internal so the offline abort test can drive it against a fake endpoint.</summary>
internal static async Task<(TransportObservations Observations, int ForwardOpens)> RunNativePhaseAsync(
internal static async Task<(TransportObservations Observations, int ForwardOpens, NativeCloses ForwardCloses)> RunNativePhaseAsync(
DifferentialTarget target, IReadOnlyList<SelectedTag> selection, CancellationToken ct)
{
const string phase = "native";
var native = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Native);
var (_, forwardOpens) = await WithNativeAnchorAsync(phase, target, async checkOpens =>
var (_, forwardOpens, forwardCloses) = await WithNativeAnchorAsync(phase, target, async checkOpens =>
{
await BrowseAsync(phase, target, factories.Enumerators, native, checkOpens, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, native, checkOpens, ct).ConfigureAwait(false);
await ReadTagsAsync(phase, target, factories.Tags, selection, native.Reads, checkOpens, ct).ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
return (native, forwardOpens);
return (native, forwardOpens, forwardCloses);
}
/// <summary>
@@ -205,8 +220,9 @@ internal static class DifferentialRun
/// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in
/// <c>finally</c>. Shared by the differential and the latency comparison.
/// </summary>
/// <returns>The body's result and the Forward Opens counted between the reset and the anchor release.</returns>
internal static async Task<(T Result, int ForwardOpens)> WithNativeAnchorAsync<T>(
/// <returns>The body's result, the Forward Opens counted between the reset and the anchor release, and the
/// Forward Closes counted between the reset and the END of the anchor release (which awaits the close).</returns>
internal static async Task<(T Result, int ForwardOpens, NativeCloses ForwardCloses)> WithNativeAnchorAsync<T>(
string phase, DifferentialTarget target, Func<Action<string>, Task<T>> body)
{
var anchorParams = target.Params("@tags");
@@ -221,9 +237,12 @@ internal static class DifferentialRun
}
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0);
Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0);
Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1);
T result;
int forwardOpens;
NativeCloses forwardCloses;
try
{
// Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry.
@@ -236,13 +255,15 @@ internal static class DifferentialRun
{
forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount);
await anchor.DisposeAsync().ConfigureAwait(false);
forwardCloses = new NativeCloses(
Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked));
}
}
finally
{
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
}
return (result, forwardOpens);
return (result, forwardOpens, forwardCloses);
}
private static async Task BrowseAsync(
@@ -582,6 +603,47 @@ internal static class DifferentialRun
probe is not null && lib.Succeeded && probe.Succeeded && lib.Raw is not null && probe.Raw is not null
&& !lib.Raw.AsSpan().SequenceEqual(probe.Raw);
/// <summary>
/// The value check that survives for a LIVE tag (T21 review r1 I1). A scalar is one element, an array element-wise
/// over the decoded values:
/// <list type="bullet">
/// <item>an element EQUAL in both libplctag reads did not move, so native must read it equal too —
/// otherwise <c>**NO: stable element i**</c> (a stride, offset or byte-order defect lands here);</item>
/// <item>an element that moved is excused its value, but native must not read a NaN/∞ that neither
/// libplctag read saw — <c>**NO: non-finite element i**</c>.</item>
/// </list>
/// Null means the check passed. Shapes must already agree between libplctag and native.
/// </summary>
internal static string? LiveElementMismatch(object? lib, object? probe, object? nat)
{
var l = Elements(lib);
var p = Elements(probe);
var n = Elements(nat);
if (l.Length != p.Length) return "**NO: probe shape**"; // libplctag disagreed with itself: never excuse it
if (l.Length != n.Length) return "**NO: shape**";
for (var i = 0; i < l.Length; i++)
{
if (Equals(l[i], p[i]))
{
if (!Equals(l[i], n[i])) return $"**NO: stable element {i}**";
}
else if (!IsFinite(n[i]) && !Equals(n[i], l[i]) && !Equals(n[i], p[i]))
{
return $"**NO: non-finite element {i}**";
}
}
return null;
}
private static object?[] Elements(object? v) => v is Array a ? a.Cast<object?>().ToArray() : [v];
private static bool IsFinite(object? v) => v switch
{
float f => float.IsFinite(f),
double d => double.IsFinite(d),
_ => true,
};
/// <summary>The same decoded shape: both scalars of one CLR type, or arrays of one element type and length.</summary>
internal static bool SameShape(object? a, object? b) => (a, b) switch
{
@@ -37,7 +37,10 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
$"); passes per transport: {r.Target.LatencyPasses}; gap between passes: {r.Target.LatencyPassGapMs} ms.");
Log("Types: " + string.Join(", ", r.Tags.GroupBy(t => t.Type).Select(g => $"{g.Key} {g.Count()}")));
if (r.Dropped.Count > 0) Log($"Dropped (libplctag refused at setup, removed from both sets): {string.Join(", ", r.Dropped)}");
Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}.");
Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}; " +
$"native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " +
$"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}.");
Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}.");
Log("");
var libP50 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 50);
@@ -54,14 +57,17 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
Log($"Criterion: native p95 {Ms(natP95)} ms ≤ libplctag p95 {Ms(libP95)} ms × {Tolerance:0.00} = {Ms(limit)} ms → " +
(natP95 <= limit ? "PASS" : "**FAIL**"));
Log("");
Log("| Pass | libplctag ms | native ms | native requests |");
Log("|---|---|---|---|");
// Attribution (review r1 M8): GC collections during each pass (gen0/gen1/gen2) on both sides, and the
// round trip of each native CIP exchange — a slow pass with fast exchanges is a client-side stall.
Log("| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |");
Log("|---|---|---|---|---|---|---|");
for (var i = 0; i < Math.Max(r.Libplctag.Count, r.Native.Count); i++)
{
var lib = i < r.Libplctag.Count ? r.Libplctag[i] : null;
var nat = i < r.Native.Count ? r.Native[i] : null;
Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | " +
$"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} |");
Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {lib?.Gc.ToString() ?? "-"} | " +
$"{(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | {nat?.Gc.ToString() ?? "-"} | " +
$"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} | {Rtt(nat?.Rtt)} |");
}
foreach (var p in r.Problems) Log($"- PROBLEM: {p}");
@@ -70,7 +76,15 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
r.Native.Count.ShouldBe(r.Target.LatencyPasses);
r.Libplctag.Sum(p => p.TagFailures).ShouldBe(0, "every timed libplctag read must succeed");
r.Native.Sum(p => p.TagFailures).ShouldBe(0, "every timed native read must succeed");
if (r.Target.CountOpens) r.NativeForwardOpens.ShouldBe(1);
if (r.Target.CountOpens)
{
r.NativeForwardOpens.ShouldBe(1);
r.NativeForwardCloses.Sent.ShouldBe(1, "the native phase's one connection must be closed with exactly one Forward Close");
}
if (r.ConnectionsAfterNative is { } after && differential.ConnectionsBaseline is { } baseline)
after.ShouldBeLessThanOrEqualTo(baseline, "the native session must be gone when the run reports");
// Review r1 M4: the latency setup's refused libplctag tags are orphan candidates too — checked after them.
LibplctagOrphanRelease.RootedCount.ShouldBe(0);
natP95.ShouldBeLessThanOrEqualTo(limit, $"native p95 must be ≤ libplctag p95 × {Tolerance:0.00}");
}
@@ -111,6 +125,9 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
$"{passes.Sum(p => p.TagFailures)} |";
}
private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.CipConnection.ExchangeStats? s) =>
s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}";
private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture);
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
@@ -7,7 +7,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>One timed burst: every tag read once, concurrently.</summary>
/// <param name="Requests">Native only: <see cref="CipConnection.RequestsSent"/> delta over the pass (keep-alives included).</param>
/// <param name="TagFailures">Reads that ended with a tag-level status (a connection-level one aborts instead).</param>
internal sealed record LatencyPass(int Index, TimeSpan Wall, int? Requests, int TagFailures);
/// <param name="Gc">Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here).</param>
/// <param name="Rtt">Native only: round trip of each CIP exchange in the pass, from <see cref="CipConnection"/> (a slow
/// controller shows here; a slow pass with fast exchanges points at the client).</param>
internal sealed record LatencyPass(
int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, CipConnection.ExchangeStats? Rtt = null);
/// <summary>GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports).</summary>
internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2)
{
public static GcDelta Now() => new(GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2));
public static GcDelta operator -(GcDelta a, GcDelta b) => new(a.Gen0 - b.Gen0, a.Gen1 - b.Gen1, a.Gen2 - b.Gen2);
public override string ToString() => $"{Gen0}/{Gen1}/{Gen2}";
}
/// <summary>The outcome of the latency comparison.</summary>
internal sealed class LatencyResult
@@ -25,6 +37,12 @@ internal sealed class LatencyResult
public required IReadOnlyList<LatencyPass> Native { get; init; }
public required int NativeForwardOpens { get; init; }
/// <summary>Forward Closes the native phase sent / got a reply to, its anchor release included (review r1 M9).</summary>
public required NativeCloses NativeForwardCloses { get; init; }
/// <summary>TCP connections to the endpoint once the native phase's session was released and drained.</summary>
public required int? ConnectionsAfterNative { get; init; }
/// <summary>Native requests spent creating the runtimes (one read each, before any timed pass).</summary>
public required int NativeSetupRequests { get; init; }
public required List<string> Problems { get; init; }
@@ -114,7 +132,7 @@ internal static class PollLatencyRun
// ---- native: the same (post-drop) set, under one anchor lease. ----
var problems = new List<string>();
var nativeFactory = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
var ((nativePasses, setupRequests), opens) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens =>
var ((nativePasses, setupRequests), opens, closes) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens =>
{
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
try
@@ -138,6 +156,10 @@ internal static class PollLatencyRun
}
}).ConfigureAwait(false);
// ---- Barrier: the native session must be gone before the run reports (review r1 M9). ----
var afterNative = await DifferentialRun.WaitForSessionsToCloseAsync(
target, differential.Addresses, differential.BaselineEndpoints, "latency native", ct).ConfigureAwait(false);
return new LatencyResult
{
Target = target,
@@ -148,6 +170,8 @@ internal static class PollLatencyRun
Libplctag = libPasses,
Native = nativePasses,
NativeForwardOpens = opens,
NativeForwardCloses = closes,
ConnectionsAfterNative = afterNative,
NativeSetupRequests = setupRequests,
Problems = problems,
};
@@ -191,9 +215,13 @@ internal static class PollLatencyRun
if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false);
var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent);
CipConnection.ResetExchangeStats();
var gcBefore = GcDelta.Now();
var clock = Stopwatch.StartNew();
var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false);
clock.Stop();
var gc = GcDelta.Now() - gcBefore;
var rtt = countRequests ? CipConnection.ReadExchangeStats() : (CipConnection.ExchangeStats?)null;
var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore;
var failures = 0;
@@ -211,7 +239,7 @@ internal static class PollLatencyRun
}
}
afterEach?.Invoke($"pass {p}");
passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures));
passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures, gc, rtt));
}
return passes;
}
@@ -110,7 +110,10 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
switch (Classify(verdict))
{
case Verdict.Expected: expected.Add($"{tag.Name}: {verdict}"); break;
case Verdict.Live: live.Add($"{tag.Name} (lib {Detail(lib)}; lib probe {Detail(probe!)}; native {Detail(nat)})"); break;
case Verdict.Live: live.Add($"{tag.Name} (lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); break;
case Verdict.Unexpected when DifferentialRun.IsLive(lib, probe):
unexpected.Add($"{tag.Name}: {verdict} (live; lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})");
break;
case Verdict.Unexpected: unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); break;
}
Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " +
@@ -119,7 +122,8 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
Log("");
Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}; " +
$"live tags (value moved between the two libplctag reads; compared by status, length, type code and shape): {live.Count}.");
$"live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every " +
$"element that held still): {live.Count} of {r.Selection.Count}.");
foreach (var e in expected) Log($"- expected: {e}");
foreach (var l in live) Log($"- live: {l}");
foreach (var u in unexpected) Log($"- UNEXPECTED: {u}");
@@ -172,6 +176,25 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
r.NativeForwardOpens.ShouldBe(1);
}
/// <summary>
/// T21 review r1 M9: the native session is closed properly — exactly one Forward Close on the wire (a skipped
/// close, e.g. on a socket already dropped, would otherwise be visible only in a capture), and no connection to
/// the target left once the anchor is released.
/// </summary>
[Fact]
public async Task The_native_side_closes_its_one_connection_with_exactly_one_forward_close()
{
var r = await RunOrSkipAsync();
Log($"Native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " +
$"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}.");
if (r.ConnectionsAfterNative is { } after && r.ConnectionsBaseline is { } baseline)
after.ShouldBeLessThanOrEqualTo(baseline);
if (!r.Target.CountOpens)
Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Close counter is only " +
"trusted when the differential runs alone.");
r.NativeForwardCloses.Sent.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,
@@ -278,9 +301,26 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array");
var arrLib = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { 1f, 2f });
var arrProbe = arrLib with { Raw = [1, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 1.1f, 2f } };
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe).ShouldBe("live");
// Element 0 moved (1 → 1.1), element 1 held at 2: native may read anything finite for 0, but must read 2 for 1.
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 2f } }, arrProbe).ShouldBe("live");
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe)
.ShouldBe("**NO: stable element 1**");
Compare(array, arrLib, arrLib with { Decoded = new[] { 1f, 2f, 3f } }, arrProbe).ShouldBe("**NO: shape**");
// Review r1 I1, the pass-1 shape: Real[5] where element 0 (400000) holds still and 1..4 move. A native defect
// that shifts the stable element must FAIL even though the tag is live.
var fout = new SelectedTag("Program:EM01_PowerUnit.__fout9FB1561FA00D73F5", AbCipDataType.Real, 5, true, "udt member");
ReadObservation Arr(params float[] v) => new((int)AbStatus.Ok, null, v.SelectMany(BitConverter.GetBytes).ToArray(), v);
var foutLib = Arr(400000f, 4.2464733f, 5.2464733f, -6.7535267f, 4.2464733f);
var foutProbe = Arr(400000f, 4.2461f, 5.2461f, -6.7539f, 4.2461f);
Compare(fout, foutLib, Arr(400000f, 4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe).ShouldBe("live");
var shifted = Compare(fout, foutLib, Arr(4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f, 400000f), foutProbe);
shifted.ShouldBe("**NO: stable element 0**");
Classify(shifted).ShouldBe(Verdict.Unexpected);
Compare(fout, foutLib, Arr(400000f, float.NaN, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe)
.ShouldBe("**NO: non-finite element 1**");
Compare(real, lib, nat with { Decoded = float.PositiveInfinity }, probe).ShouldBe("**NO: non-finite element 0**");
// Stable: the two libplctag reads agree, so native reading anything else is a real decoding divergence.
var stableProbe = lib with { };
DifferentialRun.IsLive(lib, stableProbe).ShouldBeFalse();
@@ -326,16 +366,21 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
// Everything that does not depend on the value must still agree.
if (nat.Raw is null || nat.Raw.Length != lib.Raw!.Length) return "**NO: raw length**";
if (lib.TypeCode is { } lt && nat.TypeCode is { } nt && lt != nt) return "**NO: type code**";
if (tag.Type is not null && !DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**";
return "live";
if (tag.Type is null) return "live"; // raw-only (type unknown): nothing decoded to compare element-wise
if (!DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**";
// Only what MOVED between libplctag's two reads is excused (review r1 I1): every element that held still
// must read the same through native, so a native offset/stride/byte-order defect cannot hide as "live".
return DifferentialRun.LiveElementMismatch(lib.Decoded, libProbe!.Decoded, nat.Decoded) ?? "live";
}
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)}";
/// <param name="full">Every raw byte and every element (live tags: the evidence must be re-checkable by hand).</param>
private static string Detail(ReadObservation o, bool full = false) =>
o.Error ?? o.DecodeError ??
$"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw, full)} = {Show(o.Decoded, full)}";
private static string ProbeColumn(ReadObservation lib, ReadObservation? probe) =>
probe is null ? "-"
@@ -343,13 +388,16 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
: probe.Succeeded && lib.Succeeded ? "stable"
: StatusName(probe.Status);
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 Hex(byte[]? raw, bool full = false) =>
raw is null ? "-"
: full || raw.Length <= 32 ? Convert.ToHexString(raw)
: Convert.ToHexString(raw.AsSpan(0, 32)) + "…";
private static string Show(object? v) => v switch
private static string Show(object? v, bool full = false) => v switch
{
null => "null",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(8).Select(Show)) + (a.Length > 8 ? ",…" : "") + "]",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(full ? int.MaxValue : 8).Select(x => Show(x))) +
(!full && a.Length > 8 ? ",…" : "") + "]",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => v.ToString() ?? "",
};