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:
+69
-7
@@ -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
|
||||
{
|
||||
|
||||
+23
-6
@@ -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);
|
||||
|
||||
+31
-3
@@ -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;
|
||||
}
|
||||
|
||||
+59
-11
@@ -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() ?? "",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user