test(abcip): differential stops on first connection failure, paces + caps requests, reserves array/UDT slots, fails vacuous template parity; write tests opt-in (Task 18 review r1)

This commit is contained in:
Joseph Doherty
2026-10-07 18:24:25 -04:00
parent 1d6501f427
commit 566ab20d72
11 changed files with 648 additions and 110 deletions
@@ -118,6 +118,73 @@ fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The
on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session
capture above.
### Review round 1 re-run (press-safety fixes)
Re-run 2026-10-07 after the review-r1 fixes, with the default pacing. `AB_SERVER_ENDPOINT=127.0.0.1:1`
kept the sibling suites' discovery probes off the network:
```bash
AB_SERVER_ENDPOINT=127.0.0.1:1 AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
--filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
```
**Result: 12/12 passed — 7 live tests and 5 offline safety tests — with 0 unexpected and 0 expected divergences.**
| Check | Result |
|---|---|
| Settings | pace 20 ms, max tags 200, max templates 50 |
| Browse | 9 = 9 |
| Selection (array round-robin first) | 1 array + 8 scalars. ab_server has no UDTs, so there were no UDT members and the coverage check had nothing to require |
| Reads | 9/9 equal |
| Templates | 0 ids, not truncated. Not vacuous by the new rule: ab_server's `STRING` is not a structure, so browse found no structured tags |
| Native Forward Opens | **1** |
| TCP connections to the target before native | 0 (baseline 0) |
**Wire capture of this run** (tcpdump in the ab_server container's namespace, decoded with tshark):
| TCP stream | Session | Forward Open | Forward Close | Requests | Window (s) |
|---|---|---|---|---|---|
| 0 | libplctag | 1 (Large, 0x5B) | 1 | 1× 0x55, 18× 0x52 | 0.000 → 0.487 |
| 1 | native | 1 (0x54) | 1 | 1× 0x55, 18× 0x52 | 0.510 → 0.984 |
- No discovery probes.
- Zero write services: no 0x4D, 0x53, 0x4B or 0x10.
- The two sessions are 23 ms apart and do not overlap.
**The Forward Opens differ between the transports, and that is expected.** libplctag always uses a *Large*
Forward Open. Native uses a regular one because the `CompactLogix` profile's connection size (504) is at most 511. Task 21 will
see the same split on the press.
**What changed for press safety (review r1):**
- **Stop on the first connection-level failure, on either transport.** That covers a refused connect,
RegisterSession or Forward Open, a timeout, and a lost session. Any status other than
NotFound/NotAllowed/OutOfBounds/TooLarge/TooSmall/Unsupported/TypeMismatch aborts the whole run with the
failing operation and status. Nothing is retried.
- **The native phase is capped at one open attempt.** It sets the new process-wide
`CipConnection.OpenAttemptBudget`, so a reconnect cannot reach the wire at all, even from inside an
enumerator that swallows a per-program failure.
- **The run also aborts the moment `ForwardOpenCount` passes 1.** This is checked after every browse entry,
template and tag.
- **Offline pin:** against a loopback listener that accepts and hangs up, the native phase aborts at browse after exactly
**one** TCP connect. Removing the harness abort turns the test red. Removing both the abort and the budget
turns the connect count red.
- **Pacing:** `AB_DIFF_PACE_MS` (default 20) is applied before every harness-issued request: browse,
each template read, each tag's create-read and read. Listing *pages* inside the enumerators are not
paced.
- **Volume caps:** `AB_DIFF_MAX_TEMPLATES` defaults to 50. `AB_DIFF_MAX_TAGS` and `AB_DIFF_MAX_TEMPLATES` must be at most 500;
a higher value fails fast instead of being clamped.
- **Selection:** the default selection takes arrays (up to 20), UDT members (up to 20) and scalars round-robin, so any cap
of 3 or more exercises all three kinds. The reads test fails if browse offered arrays or UDT members
but the selection holds none.
- **Template parity** fails when browse found structured tags but no template id was compared, and treats an
undecodable shape on either side as a mismatch.
- **Write-capable integration tests** now require `AB_SERVER_ALLOW_WRITES=1` and refuse while
`AB_DIFF_ENDPOINT` is set. Those tests are the Logix Emulate ALMD test and the AbLegacy SLC500 N7:5 round trip.
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
## AdminUI /run (T19)
_(pending)_
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin);
/// <summary>What one transport observed for one tag.</summary>
internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded)
internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null)
{
public bool Succeeded => Status == (int)AbStatus.Ok && Error is null;
}
@@ -23,6 +23,9 @@ internal sealed class TransportObservations
public string? BrowseError { get; set; }
public Dictionary<uint, AbCipUdtShape?> Shapes { get; } = [];
public Dictionary<uint, string> TemplateErrors { get; } = [];
/// <summary>Template ids that were queued but not read because <c>AB_DIFF_MAX_TEMPLATES</c> was reached.</summary>
public int TemplatesTruncated { get; set; }
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
}
@@ -43,16 +46,28 @@ internal sealed class DifferentialResult
public required TimeSpan LibplctagDrainWait { get; init; }
}
/// <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.
/// </summary>
internal sealed class DifferentialAbortedException(string message, Exception? inner = null)
: Exception(message, inner);
/// <summary>
/// Runs the whole comparison ONCE: every libplctag operation first (handles disposed), then — only
/// once libplctag's session is gone — every native operation under one anchor lease. Reads only:
/// browse listings, template reads and tag reads. Nothing on this path encodes or sends a write.
/// <para><b>Stop on first failure (review r1 C1).</b> The first connection-level failure on either side —
/// connect / RegisterSession / Forward Open refused, timeout, session lost — aborts the whole run with the
/// operation and status; nothing is retried. The native side is additionally capped at ONE open attempt
/// process-wide (<see cref="CipConnection.OpenAttemptBudget"/>), so a reconnect cannot reach the wire even
/// from inside an enumerator that swallows a per-program failure, and the run aborts the moment
/// <see cref="CipConnection.ForwardOpenCount"/> passes 1.</para>
/// </summary>
internal static class DifferentialRun
{
private const int MaxArrays = 20;
private const int MaxUdtMembers = 20;
private const int MaxTemplates = 256;
/// <summary>Upper bound on waiting for libplctag's session to close before native work may start.</summary>
private static readonly TimeSpan LibplctagDrainBudget = TimeSpan.FromSeconds(30);
@@ -60,58 +75,34 @@ internal static class DifferentialRun
/// <summary>Settle used when the platform cannot enumerate TCP connections.</summary>
private static readonly TimeSpan FallbackSettle = TimeSpan.FromSeconds(5);
/// <summary>Bound on resolving the endpoint's host name (M5).</summary>
private static readonly TimeSpan ResolveBudget = TimeSpan.FromSeconds(5);
/// <summary>
/// Statuses that describe ONE tag and leave the session healthy. Everything else — Timeout, BadConnection,
/// Abort, BadReply, Pending, any unexpected exception — is connection-level and aborts the run.
/// </summary>
private static readonly HashSet<AbStatus> TagLevelStatuses =
[
AbStatus.NotFound, AbStatus.NotAllowed, AbStatus.OutOfBounds, AbStatus.TooLarge, AbStatus.TooSmall,
AbStatus.Unsupported, AbStatus.TypeMismatch,
];
public static async Task<DifferentialResult> ExecuteAsync(DifferentialTarget target, CancellationToken ct)
{
var baseline = CountConnections(target);
var addresses = await ResolveAsync(target, ct).ConfigureAwait(false);
var baseline = LocalEndpointsTo(target, addresses);
// ---- Phase 1: libplctag only. Every handle is disposed before the phase ends. ----
// libplctag shares one session (and one Forward Open) among the handles alive at the same time, and
// closes it when the last one is destroyed. The per-tag create/read/dispose below would therefore open
// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps
// every libplctag operation on ONE session — the mirror of the native anchor lease.
var lib = new TransportObservations();
var libFactories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
IReadOnlyList<SelectedTag> selection;
using (var libAnchor = libFactories.Tags.Create(target.Params("@tags")))
{
try
{
await libAnchor.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
throw new InvalidOperationException(
$"libplctag could not open its anchor session to {target}: {Describe(ex)}", ex);
}
await BrowseAsync(target, libFactories.Enumerators, lib, ct).ConfigureAwait(false);
await ReadTemplatesAsync(target, libFactories.Templates, lib, ct).ConfigureAwait(false);
selection = Select(target, lib);
await ReadTagsAsync(target, libFactories.Tags, selection, lib, ct).ConfigureAwait(false);
}
var (lib, selection) = await RunLibplctagPhaseAsync(target, ct).ConfigureAwait(false);
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
var drain = Stopwatch.StartNew();
var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, baseline, ct).ConfigureAwait(false);
var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, addresses, baseline, ct).ConfigureAwait(false);
drain.Stop();
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
var native = new TransportObservations();
var nativeFactories = AbCipTransportFactories.For(AbCipTransport.Native);
var anchorParams = target.Params("@tags");
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
int opens;
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
try
{
await BrowseAsync(target, nativeFactories.Enumerators, native, ct).ConfigureAwait(false);
await ReadTemplatesAsync(target, nativeFactories.Templates, native, ct).ConfigureAwait(false);
await ReadTagsAsync(target, nativeFactories.Tags, selection, native, ct).ConfigureAwait(false);
}
finally
{
opens = Volatile.Read(ref CipConnection.ForwardOpenCount);
await anchor.DisposeAsync().ConfigureAwait(false);
}
var (native, opens) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
return new DifferentialResult
{
@@ -120,60 +111,147 @@ internal static class DifferentialRun
Native = native,
Selection = selection,
NativeForwardOpens = opens,
ConnectionsBaseline = baseline,
ConnectionsBaseline = baseline?.Count,
ConnectionsBeforeNative = beforeNative,
LibplctagDrainWait = drain.Elapsed,
};
}
private static async Task BrowseAsync(
DifferentialTarget target, IAbCipTagEnumeratorFactory factory, TransportObservations into, CancellationToken ct)
/// <summary>
/// libplctag shares one session (and one Forward Open) among the handles alive at the same time, and
/// closes it when the last one is destroyed. The per-tag create/read/dispose below would therefore open
/// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps
/// every libplctag operation on ONE session — the mirror of the native anchor lease.
/// </summary>
internal static async Task<(TransportObservations Observations, IReadOnlyList<SelectedTag> Selection)> RunLibplctagPhaseAsync(
DifferentialTarget target, CancellationToken ct)
{
const string phase = "libplctag";
var lib = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
using var anchor = factories.Tags.Create(target.Params("@tags"));
try
{
await anchor.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
throw Abort(phase, "anchor session (@tags)", ex);
}
await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false);
var selection = Select(target, lib);
await ReadTagsAsync(phase, target, factories.Tags, selection, lib, afterEach: null, ct).ConfigureAwait(false);
return (lib, selection);
}
/// <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(
DifferentialTarget target, IReadOnlyList<SelectedTag> selection, CancellationToken ct)
{
const string phase = "native";
var native = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Native);
var anchorParams = target.Params("@tags");
void CheckOpens(string operation)
{
var opens = Volatile.Read(ref CipConnection.ForwardOpenCount);
if (opens > 1)
throw new DifferentialAbortedException(
$"Differential ABORTED in the {phase} phase after {operation}: {opens} Forward Open frames on the wire, " +
"expected exactly one (a reconnect, or a Large→regular fallback). Nothing was retried.");
}
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1);
int forwardOpens;
try
{
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
try
{
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, CheckOpens, ct).ConfigureAwait(false);
}
finally
{
forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount);
await anchor.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
}
return (native, forwardOpens);
}
private static async Task BrowseAsync(
string phase, DifferentialTarget target, IAbCipTagEnumeratorFactory factory, TransportObservations into,
Action<string>? afterEach, CancellationToken ct)
{
await PaceAsync(target, ct).ConfigureAwait(false);
using var enumerator = factory.Create();
try
{
await foreach (var tag in enumerator.EnumerateAsync(target.Params("@tags"), ct).ConfigureAwait(false))
{
into.Browse.Add(tag);
afterEach?.Invoke($"browse entry '{tag.Name}'");
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
catch (Exception ex) when (ex is not DifferentialAbortedException && (ex is not OperationCanceledException || !ct.IsCancellationRequested))
{
if (IsConnectionLevel(ex)) throw Abort(phase, "browse (@tags)", ex);
into.BrowseError = Describe(ex);
}
afterEach?.Invoke("browse");
}
/// <summary>Reads every template id the browse surfaced, then every nested id those shapes name.</summary>
private static async Task ReadTemplatesAsync(
DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into, CancellationToken ct)
string phase, DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into,
Action<string>? afterEach, CancellationToken ct)
{
var queue = new Queue<uint>(into.Browse
.Where(t => t.TemplateInstanceId is not null)
.Select(t => t.TemplateInstanceId!.Value)
.Distinct()
.Order());
var seen = new HashSet<uint>(queue);
using var reader = factory.Create();
while (queue.Count > 0 && into.Shapes.Count + into.TemplateErrors.Count < MaxTemplates)
while (queue.Count > 0)
{
if (into.Shapes.Count + into.TemplateErrors.Count >= target.MaxTemplates)
{
into.TemplatesTruncated = queue.Count;
break;
}
var id = queue.Dequeue();
if (into.Shapes.ContainsKey(id) || into.TemplateErrors.ContainsKey(id)) continue;
await PaceAsync(target, ct).ConfigureAwait(false);
try
{
var shape = CipTemplateObjectDecoder.Decode(await reader.ReadAsync(target.Params($"@udt/{id}"), id, ct).ConfigureAwait(false));
into.Shapes[id] = shape;
foreach (var nested in shape?.Members ?? [])
if (nested.NestedTemplateId is { } n) queue.Enqueue(n);
if (nested.NestedTemplateId is { } n && seen.Add(n)) queue.Enqueue(n);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
if (IsConnectionLevel(ex)) throw Abort(phase, $"template {id}", ex);
into.TemplateErrors[id] = Describe(ex);
}
afterEach?.Invoke($"template {id}");
}
}
/// <summary>
/// The tags to read, chosen from the libplctag browse (the run's first phase), so both transports
/// read exactly the same list.
/// read exactly the same list. Without <c>AB_DIFF_TAGS</c> the three kinds are taken ROUND-ROBIN
/// (array, UDT member, scalar, ...) under their quotas — up to 20 arrays and 20 UDT members — so a
/// press with thousands of scalars still exercises arrays and UDT members at any cap (review r1 I2).
/// </summary>
internal static IReadOnlyList<SelectedTag> Select(DifferentialTarget target, TransportObservations browsed)
{
@@ -223,15 +301,63 @@ internal static class DifferentialRun
}
}
IEnumerable<SelectedTag> chosen = target.Tags is { } explicitNames
? explicitNames.Select(n => byName.GetValueOrDefault(n) ?? new SelectedTag(n, null, 1, false, "explicit (type unknown)"))
: scalars.Concat(arrays.Take(MaxArrays)).Concat(members.Take(MaxUdtMembers));
return chosen.DistinctBy(t => t.Name).Take(target.MaxTags).ToList();
if (target.Tags is { } explicitNames)
return explicitNames
.Select(n => byName.GetValueOrDefault(n) ?? new SelectedTag(n, null, 1, false, "explicit (type unknown)"))
.DistinctBy(t => t.Name).Take(target.MaxTags).ToList();
var chosen = new List<SelectedTag>();
var seen = new HashSet<string>(StringComparer.Ordinal);
// Reference-type enumerators created ONCE: a boxed List<T>.Enumerator struct would restart per round.
IEnumerator<SelectedTag>[] sources =
[
arrays.Take(MaxArrays).GetEnumerator(),
members.Take(MaxUdtMembers).GetEnumerator(),
scalars.AsEnumerable().Take(int.MaxValue).GetEnumerator(),
];
try
{
var live = true;
while (live && chosen.Count < target.MaxTags)
{
live = false;
foreach (var source in sources)
{
if (chosen.Count >= target.MaxTags) break;
while (source.MoveNext())
{
live = true;
if (seen.Add(source.Current.Name)) { chosen.Add(source.Current); break; }
}
}
}
}
finally
{
foreach (var source in sources) source.Dispose();
}
return chosen;
}
/// <summary>
/// Coverage problems of a default (browse-driven) selection: browse offered arrays or UDT members but
/// none were selected. Empty for an explicit <c>AB_DIFF_TAGS</c> list, which is the operator's choice.
/// </summary>
internal static List<string> SelectionCoverageProblems(
DifferentialTarget target, TransportObservations browsed, IReadOnlyList<SelectedTag> selection)
{
var problems = new List<string>();
if (target.Tags is not null) return problems;
var available = Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed);
foreach (var origin in new[] { "array", "udt member" })
if (available.Any(t => t.Origin == origin) && selection.All(t => t.Origin != origin))
problems.Add($"browse offered {origin}s but the selection holds none");
return problems;
}
private static async Task ReadTagsAsync(
DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList<SelectedTag> selection,
TransportObservations into, CancellationToken ct)
string phase, DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList<SelectedTag> selection,
TransportObservations into, Action<string>? afterEach, CancellationToken ct)
{
foreach (var tag in selection)
{
@@ -242,30 +368,41 @@ internal static class DifferentialRun
string? error = null;
try
{
await PaceAsync(target, ct).ConfigureAwait(false);
await runtime.InitializeAsync(ct).ConfigureAwait(false);
await PaceAsync(target, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
if (IsConnectionLevel(ex)) throw Abort(phase, $"tag '{tag.Name}'", ex);
error = Describe(ex);
}
var status = runtime.GetStatus();
byte[]? raw = null;
object? decoded = null;
string? decodeError = null;
if (error is null)
{
raw = Try(() => RawBufferOf(runtime).ToArray());
if (tag.Type is { } type)
decoded = Try(() => tag.IsArray ? runtime.DecodeArray(type, tag.ElementCount) : runtime.DecodeValue(type, null));
try
{
raw = RawBufferOf(runtime).ToArray();
if (tag.Type is { } type)
decoded = tag.IsArray ? runtime.DecodeArray(type, tag.ElementCount) : runtime.DecodeValue(type, null);
}
catch (Exception ex)
{
decodeError = Describe(ex); // recorded, and a NO verdict (review r1 M3)
}
}
into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded);
into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError);
}
finally
{
runtime.Dispose();
}
afterEach?.Invoke($"tag '{tag.Name}'");
}
}
@@ -276,14 +413,49 @@ internal static class DifferentialRun
_ => throw new InvalidOperationException($"Unexpected runtime {runtime.GetType().Name}."),
};
/// <summary>True when the failure is about the session, not one tag: the run must stop.</summary>
internal static bool IsConnectionLevel(Exception ex) => ex switch
{
AbTransportException ate => !TagLevelStatuses.Contains(ate.Status),
// Local, pre-I/O refusals of one tag name (malformed path, unsupported type): no session involved.
ArgumentException or FormatException or NotSupportedException => false,
_ => true,
};
private static DifferentialAbortedException Abort(string phase, string operation, Exception ex) =>
new($"Differential ABORTED in the {phase} phase at {operation}: {Describe(ex)}. " +
"Stopped on the first connection-level failure; nothing was retried.", ex);
private static Task PaceAsync(DifferentialTarget target, CancellationToken ct) =>
target.PaceMs > 0 ? Task.Delay(target.PaceMs, ct) : Task.CompletedTask;
/// <summary>Resolves the endpoint once, bounded (an IP literal needs no lookup).</summary>
private static async Task<IReadOnlySet<IPAddress>> ResolveAsync(DifferentialTarget target, CancellationToken ct)
{
if (IPAddress.TryParse(target.Host, out var literal)) return new HashSet<IPAddress> { literal.MapToIPv6() };
using var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct);
bounded.CancelAfter(ResolveBudget);
try
{
var resolved = await Dns.GetHostAddressesAsync(target.Host, bounded.Token).ConfigureAwait(false);
return resolved.Select(x => x.MapToIPv6()).ToHashSet();
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
throw new DifferentialAbortedException(
$"Differential ABORTED before any I/O: could not resolve '{target.Host}' within {ResolveBudget.TotalSeconds:0}s ({ex.Message}).", ex);
}
}
/// <summary>
/// libplctag closes its session on a background thread some time after the last handle is
/// destroyed. Wait until the endpoint's TCP connection count is back at the pre-run baseline, so
/// the two transports are never connected to the controller at the same time.
/// libplctag closes its session on a background thread some time after the last handle is destroyed.
/// Wait until no TCP connection to the endpoint remains that was not already there before the run
/// (compared by local endpoint, not by count — review r1 M6), so the two transports are never
/// connected to the controller at the same time.
/// </summary>
/// <returns>The connection count observed when native work may start (null when unobservable).</returns>
private static async Task<int?> WaitForLibplctagSessionToCloseAsync(
DifferentialTarget target, int? baseline, CancellationToken ct)
DifferentialTarget target, IReadOnlySet<IPAddress> addresses, IReadOnlySet<IPEndPoint>? baseline, CancellationToken ct)
{
if (baseline is null)
{
@@ -293,26 +465,29 @@ internal static class DifferentialRun
var deadline = Stopwatch.StartNew();
while (true)
{
var now = CountConnections(target);
if (now is null || now <= baseline) return now;
var now = LocalEndpointsTo(target, addresses);
if (now is null) return null;
var added = now.Where(e => !baseline.Contains(e)).ToList();
if (added.Count == 0) return now.Count;
if (deadline.Elapsed > LibplctagDrainBudget)
throw new InvalidOperationException(
throw new DifferentialAbortedException(
$"libplctag's session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after its " +
$"handles were disposed ({now} connection(s), baseline {baseline}); refusing to start the native phase alongside it.");
$"handles were disposed (local {string.Join(", ", added)}); refusing to start the native phase alongside it.");
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
}
}
/// <summary>Open TCP connections from this machine to the target, or null when the OS cannot say.</summary>
internal static int? CountConnections(DifferentialTarget target)
/// <summary>Local endpoints of this machine's open TCP connections to the target, or null when the OS cannot say.</summary>
private static IReadOnlySet<IPEndPoint>? LocalEndpointsTo(DifferentialTarget target, IReadOnlySet<IPAddress> addresses)
{
try
{
var addresses = Dns.GetHostAddresses(target.Host).Select(a => a.MapToIPv6()).ToHashSet();
return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections().Count(c =>
c.RemoteEndPoint.Port == target.Port
&& addresses.Contains(c.RemoteEndPoint.Address.MapToIPv6())
&& c.State is not (TcpState.Closed or TcpState.TimeWait or TcpState.Unknown));
return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections()
.Where(c => c.RemoteEndPoint.Port == target.Port
&& addresses.Contains(c.RemoteEndPoint.Address.MapToIPv6())
&& c.State is not (TcpState.Closed or TcpState.TimeWait or TcpState.Unknown))
.Select(c => c.LocalEndPoint)
.ToHashSet();
}
catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException or NotImplementedException)
{
@@ -339,18 +514,35 @@ internal static class DifferentialRun
? StructuralComparisons.StructuralEqualityComparer.Equals(a, b)
: Equals(a, b);
/// <summary>Equal only when BOTH decoded: an undecodable shape is never parity (review r1 I3).</summary>
internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) =>
a is null || b is null
? a is null && b is null
: a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members);
a is not null && b is not null
&& a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members);
private static T? Try<T>(Func<T> f)
/// <summary>
/// Template-parity problems; empty means parity. Vacuity is a problem: browse found structured tags
/// but no template id was compared (review r1 I3).
/// </summary>
internal static List<string> TemplateProblems(TransportObservations lib, TransportObservations native)
{
try { return f(); }
catch (Exception) { return default; }
var problems = new List<string>();
var ids = lib.Shapes.Keys.Concat(lib.TemplateErrors.Keys)
.Concat(native.Shapes.Keys).Concat(native.TemplateErrors.Keys)
.Distinct().Order().ToList();
var structured = lib.Browse.Concat(native.Browse).Count(t => t.DataType == AbCipDataType.Structure);
if (structured > 0 && ids.Count == 0)
problems.Add($"vacuous: browse found {structured} structured tag(s) but no template id was compared");
foreach (var id in ids)
{
var libShape = lib.Shapes.GetValueOrDefault(id);
var nativeShape = native.Shapes.GetValueOrDefault(id);
if (!ShapesEqual(libShape, nativeShape))
problems.Add($"template {id}: " + (libShape is null || nativeShape is null ? "undecodable or unread on a side" : "shapes differ"));
}
return problems;
}
private static string Describe(Exception ex) => ex is AbTransportException ate
internal static string Describe(Exception ex) => ex is AbTransportException ate
? $"{ate.Status}: {ate.Message}"
: $"{ex.GetType().Name}: {ex.Message}";
}
@@ -0,0 +1,179 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>
/// Offline pins for the differential's press-safety rules (Task 18 review r1). No controller is touched:
/// the "endpoint" is a loopback listener that accepts and immediately hangs up.
/// </summary>
[Collection(DifferentialCollection.Name)]
public sealed class DifferentialSafetyTests
{
[Fact]
public async Task A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect()
{
await using var endpoint = HangUpListener.Start();
var target = Target(endpoint.Port);
IReadOnlyList<SelectedTag> selection =
[
new("A", AbCipDataType.DInt, 1, false, "scalar"),
new("B", AbCipDataType.DInt, 1, false, "scalar"),
new("C", AbCipDataType.DInt, 1, false, "scalar"),
];
var ex = await Should.ThrowAsync<DifferentialAbortedException>(
() => DifferentialRun.RunNativePhaseAsync(target, selection, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("native phase at browse");
// Long enough for a throttled (≥ 1 s) reconnect to have reached the listener, had one been allowed.
await Task.Delay(TimeSpan.FromSeconds(2.5), TestContext.Current.CancellationToken);
endpoint.Connections.ShouldBe(1);
Volatile.Read(ref CipConnection.OpenAttemptBudget).ShouldBe(-1, "the budget is restored after the phase");
}
[Fact]
public async Task A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read()
{
await using var endpoint = HangUpListener.Start();
var target = Target(endpoint.Port);
var ex = await Should.ThrowAsync<DifferentialAbortedException>(
() => DifferentialRun.RunLibplctagPhaseAsync(target, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("libplctag phase at");
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
// libplctag's own session layer may redial inside its one anchor-create timeout; the harness cannot
// reach below that. Recorded, and bounded: one anchor create, never one per tag.
TestContext.Current.TestOutputHelper?.WriteLine($"libplctag connects before the abort: {endpoint.Connections}");
endpoint.Connections.ShouldBeInRange(1, 2);
}
[Fact]
public void The_default_selection_keeps_arrays_and_udt_members_at_any_cap()
{
var browsed = new TransportObservations();
for (var i = 0; i < 300; i++) browsed.Browse.Add(new AbCipDiscoveredTag($"S{i}", null, AbCipDataType.DInt, false));
for (var i = 0; i < 30; i++)
browsed.Browse.Add(new AbCipDiscoveredTag($"A{i}", null, AbCipDataType.Real, false, ElementCount: 4, IsArray: true));
browsed.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
browsed.Shapes[7] = new AbCipUdtShape("Udt", 400,
[.. Enumerable.Range(0, 25).Select(i => new AbCipUdtMember($"M{i}", i * 4, AbCipDataType.DInt, 1))]);
var at200 = DifferentialRun.Select(Target(1) with { MaxTags = 200 }, browsed);
at200.Count.ShouldBe(200);
at200.Count(t => t.Origin == "array").ShouldBe(20);
at200.Count(t => t.Origin == "udt member").ShouldBe(20);
at200.Count(t => t.Origin == "scalar").ShouldBe(160);
var at25 = DifferentialRun.Select(Target(1) with { MaxTags = 25 }, browsed);
at25.Count.ShouldBe(25);
at25.ShouldContain(t => t.Origin == "array");
at25.ShouldContain(t => t.Origin == "udt member");
at25.ShouldContain(t => t.Origin == "scalar");
DifferentialRun.SelectionCoverageProblems(Target(1) with { MaxTags = 25 }, browsed, at25).ShouldBeEmpty();
// The coverage check is not vacuous: a selection of scalars only is flagged for both kinds.
DifferentialRun.SelectionCoverageProblems(Target(1), browsed, [.. at200.Where(t => t.Origin == "scalar")])
.Count.ShouldBe(2);
}
[Fact]
public void Template_parity_fails_when_vacuous_or_undecodable()
{
var lib = new TransportObservations();
var native = new TransportObservations();
lib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("vacuous");
lib.Shapes[7] = null;
native.Shapes[7] = null;
DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("undecodable");
var shape = new AbCipUdtShape("Udt", 4, [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)]);
lib.Shapes[7] = shape;
native.Shapes[7] = shape with { Members = [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)] };
DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty();
}
[Fact]
public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping()
{
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar);
try
{
Environment.SetEnvironmentVariable(DifferentialTarget.EndpointVar, "127.0.0.1:1");
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "501");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTagsVar);
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "500");
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, "9999");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTemplatesVar);
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.PaceVar, null);
var t = DifferentialTarget.FromEnvironment()!;
t.MaxTags.ShouldBe(500);
t.MaxTemplates.ShouldBe(DifferentialTarget.DefaultMaxTemplates);
t.PaceMs.ShouldBe(DifferentialTarget.DefaultPaceMs);
}
finally
{
foreach (var (k, v) in saved) Environment.SetEnvironmentVariable(k, v);
}
}
private static DifferentialTarget Target(int port) =>
new("127.0.0.1", port, "1,0", AbCipPlcFamily.CompactLogix, null, 200, CountOpens: false, MaxTemplates: 50, PaceMs: 0);
private static (string, string?)[] Snapshot(params string[] names) =>
[.. names.Select(n => (n, Environment.GetEnvironmentVariable(n)))];
/// <summary>Accepts every connection and closes it at once, counting them.</summary>
private sealed class HangUpListener : IAsyncDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _stop = new();
private readonly Task _loop;
private int _connections;
private HangUpListener()
{
_listener.Start();
_loop = Task.Run(AcceptLoopAsync);
}
public static HangUpListener Start() => new();
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
public int Connections => Volatile.Read(ref _connections);
private async Task AcceptLoopAsync()
{
try
{
while (!_stop.IsCancellationRequested)
{
using var socket = await _listener.AcceptSocketAsync(_stop.Token);
Interlocked.Increment(ref _connections);
socket.Close();
}
}
catch (OperationCanceledException) { }
catch (SocketException) { }
catch (ObjectDisposedException) { }
}
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
_listener.Stop();
try { await _loop; } catch { /* stopped */ }
_stop.Dispose();
}
}
}
@@ -10,7 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <item><c>AB_DIFF_PATH</c> — CIP path, default <c>1,0</c>.</item>
/// <item><c>AB_DIFF_FAMILY</c> — an <see cref="AbCipPlcFamily"/> name, default <c>CompactLogix</c>.</item>
/// <item><c>AB_DIFF_TAGS</c> — optional comma list of tag names; absent ⇒ selected from browse.</item>
/// <item><c>AB_DIFF_MAX_TAGS</c> — cap on the selected tags, default 200.</item>
/// <item><c>AB_DIFF_MAX_TAGS</c> — cap on the selected tags, default 200, at most 500 (higher fails fast).</item>
/// <item><c>AB_DIFF_MAX_TEMPLATES</c> — cap on template reads per transport, default 50, at most 500.</item>
/// <item><c>AB_DIFF_PACE_MS</c> — delay before every harness-issued request, default 20 ms; 0 for ab_server.</item>
/// <item><c>AB_DIFF_COUNT_OPENS</c> — <c>1</c> ⇒ assert exactly one native Forward Open over the run.</item>
/// </list>
/// </summary>
@@ -21,7 +23,9 @@ internal sealed record DifferentialTarget(
AbCipPlcFamily Family,
IReadOnlyList<string>? Tags,
int MaxTags,
bool CountOpens)
bool CountOpens,
int MaxTemplates = DifferentialTarget.DefaultMaxTemplates,
int PaceMs = DifferentialTarget.DefaultPaceMs)
{
public const string EndpointVar = "AB_DIFF_ENDPOINT";
public const string PathVar = "AB_DIFF_PATH";
@@ -29,6 +33,13 @@ internal sealed record DifferentialTarget(
public const string TagsVar = "AB_DIFF_TAGS";
public const string MaxTagsVar = "AB_DIFF_MAX_TAGS";
public const string CountOpensVar = "AB_DIFF_COUNT_OPENS";
public const string MaxTemplatesVar = "AB_DIFF_MAX_TEMPLATES";
public const string PaceVar = "AB_DIFF_PACE_MS";
/// <summary>Hard ceiling on <c>AB_DIFF_MAX_TAGS</c> / <c>AB_DIFF_MAX_TEMPLATES</c>: a production press is the target.</summary>
public const int Ceiling = 500;
public const int DefaultMaxTemplates = 50;
public const int DefaultPaceMs = 20;
private const int DefaultPort = 44818;
private const string DefaultPath = "1,0";
@@ -69,13 +80,23 @@ internal sealed record DifferentialTarget(
if (Environment.GetEnvironmentVariable(TagsVar) is { Length: > 0 } rawTags)
tags = rawTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var max = DefaultMaxTags;
if (Environment.GetEnvironmentVariable(MaxTagsVar) is { Length: > 0 } rawMax
&& (!int.TryParse(rawMax, NumberStyles.None, CultureInfo.InvariantCulture, out max) || max < 1))
throw new InvalidOperationException($"{MaxTagsVar}='{rawMax}' is not a positive integer.");
var max = BoundedInt(MaxTagsVar, DefaultMaxTags, 1, Ceiling);
var maxTemplates = BoundedInt(MaxTemplatesVar, DefaultMaxTemplates, 1, Ceiling);
var pace = BoundedInt(PaceVar, DefaultPaceMs, 0, 10_000);
if (tags is { Count: > Ceiling })
throw new InvalidOperationException($"{TagsVar} names {tags.Count} tags; the ceiling is {Ceiling}.");
var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1";
return new DifferentialTarget(host, port, path, family, tags, max, countOpens);
return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace);
}
/// <summary>Reads an integer knob; out of range FAILS rather than clamping, so a typo never silently widens a press run.</summary>
private static int BoundedInt(string name, int fallback, int min, int max)
{
if (Environment.GetEnvironmentVariable(name) is not { Length: > 0 } raw) return fallback;
if (!int.TryParse(raw.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var value) || value < min || value > max)
throw new InvalidOperationException($"{name}='{raw}' must be an integer in [{min}, {max}].");
return value;
}
/// <summary>The family profile the driver would use for this target.</summary>
@@ -99,5 +120,6 @@ internal sealed record DifferentialTarget(
/// <inheritdoc />
public override string ToString() =>
$"{Host}:{Port} path '{CipPath}' family {Family} ({Profile.LibplctagPlcAttribute}, connection size {Profile.DefaultConnectionSize})";
$"{Host}:{Port} path '{CipPath}' family {Family} ({Profile.LibplctagPlcAttribute}, connection size {Profile.DefaultConnectionSize}), " +
$"max tags {MaxTags}, max templates {MaxTemplates}, pace {PaceMs} ms";
}
@@ -70,24 +70,22 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
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)");
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("|---|---|---|---|");
}
var unequal = new List<uint>();
foreach (var id in ids)
{
var lib = Shape(r.Libplctag, id);
var nat = Shape(r.Native, id);
var equal = r.Libplctag.Shapes.ContainsKey(id) && r.Native.Shapes.ContainsKey(id)
&& DifferentialRun.ShapesEqual(r.Libplctag.Shapes[id], r.Native.Shapes[id]);
if (!equal) unequal.Add(id);
Log($"| {id} | {lib} | {nat} | {(equal ? "yes" : "**NO**")} |");
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**")} |");
}
unequal.ShouldBeEmpty();
var problems = DifferentialRun.TemplateProblems(r.Libplctag, r.Native);
foreach (var p in problems) Log($"- PROBLEM: {p}");
problems.ShouldBeEmpty();
}
[Fact]
@@ -95,6 +93,8 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
{
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("|---|---|---|---|---|");
@@ -116,8 +116,12 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
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]
@@ -158,6 +162,9 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
[
"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");
@@ -175,7 +182,8 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
var callHits = (from t in harnessTypes
from m in MethodsOf(t)
from callee in CalleesOf(m)
where forbidden.Contains(callee.Name, StringComparer.Ordinal)
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();
}
@@ -223,13 +231,14 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
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.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}";
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 ? "…" : "");
@@ -121,6 +121,19 @@ records a skip reason when unreachable, so tests stay green on a fresh
clone without the container running. Tests use `[AbServerFact]` /
`[AbServerTheory]` which check the same probe.
**Writes are opt-in.** Every write-capable test (today the Logix Emulate ALMD
test, which pulses `SimulateAlarm`) also requires `AB_SERVER_ALLOW_WRITES=1`,
and refuses while `AB_DIFF_ENDPOINT` is set. Simulator / Emulate runs set it;
**never** set it when `AB_SERVER_ENDPOINT` points at production hardware. The
ab_server controllogix matrix itself is read-only and needs no opt-in.
**Read-only differential (Task 18).** `Differential/` compares libplctag and the
native transport against one endpoint, configured only by `AB_DIFF_*`
variables (see `Differential/DifferentialTarget.cs`). Always run it with
`--filter "FullyQualifiedName~Differential"`, and keep the sibling suites'
discovery probes off-network with `AB_SERVER_ENDPOINT=127.0.0.1:1` when the
target is a real controller.
## What each family seeds
Tag sets match `AbServerProfile.cs` exactly — changing seeds in one
@@ -46,6 +46,7 @@ public sealed class AbCipEmulateAlmdTests
public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection(AbCipTransport transport)
{
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
IntegrationWriteGate.SkipUnlessWritesAllowed(); // pulses SimulateAlarm on the controller
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
?? throw new InvalidOperationException(
@@ -0,0 +1,26 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Opt-in gate for every integration test that WRITES to the endpoint under test. A write test runs only
/// when <c>AB_SERVER_ALLOW_WRITES=1</c>, in addition to its own reachability / profile conditions, so
/// pointing an endpoint variable at a real controller (a production press in Task 21) can never write to
/// it by accident. It also refuses while <c>AB_DIFF_ENDPOINT</c> is set, because that variable means a
/// read-only differential run is in progress. The ab_server / simulator runs set the opt-in explicitly.
/// </summary>
public static class IntegrationWriteGate
{
public const string AllowWritesVar = "AB_SERVER_ALLOW_WRITES";
public const string DifferentialEndpointVar = "AB_DIFF_ENDPOINT";
/// <summary>Skips the calling test unless writes are explicitly allowed for this run.</summary>
public static void SkipUnlessWritesAllowed()
{
if (Environment.GetEnvironmentVariable(DifferentialEndpointVar) is { Length: > 0 })
Assert.Skip($"Write test refused: {DifferentialEndpointVar} is set, so a read-only differential run is in progress.");
if (Environment.GetEnvironmentVariable(AllowWritesVar) != "1")
Assert.Skip($"Write test: set {AllowWritesVar}=1 to allow writes to the endpoint under test " +
"(simulator runs only — never a production controller).");
}
}
@@ -71,6 +71,7 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
// container held the port; it survives below only for real-hardware runs, where
// AB_LEGACY_ENDPOINT pins one device and the family IS a property of that device.
if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
IntegrationWriteGate.SkipUnlessWritesAllowed(); // writes N7:5
var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE");
if (sim.EndpointPinned && !string.IsNullOrEmpty(only) &&
@@ -53,6 +53,7 @@ families stop the current service + start another.
|---|---|---|
| `AB_LEGACY_ENDPOINT` | *unset* — per-family ports on `10.100.0.35` | `host:port` of a single PCCC device, pinning **every** family to it (real-hardware mode). |
| `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/<path>` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. |
| `AB_SERVER_ALLOW_WRITES` | *unset* | **Write opt-in.** Every write-capable test (today: the SLC500 N7:5 write-then-read round trip) skips unless this is `1`. Set it for simulator runs; **never** set it when an endpoint variable points at production hardware. Write tests also refuse while `AB_DIFF_ENDPOINT` (the AbCip read-only differential) is set. |
| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. |
## Run the integration tests
@@ -63,6 +64,7 @@ running so only the matching theory-parameterization executes:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
$env:AB_LEGACY_COMPOSE_PROFILE = "slc500" # or "micrologix" / "plc5"
$env:AB_SERVER_ALLOW_WRITES = "1" # simulator only: lets the write round-trip test run
dotnet test tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
```
@@ -0,0 +1,26 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Opt-in gate for every integration test that WRITES to the endpoint under test. A write test runs only
/// when <c>AB_SERVER_ALLOW_WRITES=1</c>, in addition to its own reachability / profile conditions, so
/// pointing an endpoint variable at a real controller (a production press in Task 21) can never write to
/// it by accident. It also refuses while <c>AB_DIFF_ENDPOINT</c> is set, because that variable means a
/// read-only differential run is in progress. The ab_server / simulator runs set the opt-in explicitly.
/// </summary>
public static class IntegrationWriteGate
{
public const string AllowWritesVar = "AB_SERVER_ALLOW_WRITES";
public const string DifferentialEndpointVar = "AB_DIFF_ENDPOINT";
/// <summary>Skips the calling test unless writes are explicitly allowed for this run.</summary>
public static void SkipUnlessWritesAllowed()
{
if (Environment.GetEnvironmentVariable(DifferentialEndpointVar) is { Length: > 0 })
Assert.Skip($"Write test refused: {DifferentialEndpointVar} is set, so a read-only differential run is in progress.");
if (Environment.GetEnvironmentVariable(AllowWritesVar) != "1")
Assert.Skip($"Write test: set {AllowWritesVar}=1 to allow writes to the endpoint under test " +
"(simulator runs only — never a production controller).");
}
}