Merge commit '7feffc96' into remed/lane-Modbus

This commit is contained in:
Joseph Doherty
2026-10-22 16:19:17 -04:00
105 changed files with 11332 additions and 2594 deletions
+50 -8
View File
@@ -88,6 +88,9 @@ After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET d
default) completes the handshake but denies every read, so the probe fails with an actionable "enable
PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no
`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message.
Both are recognised in the bare-`Exception` shape S7.Net's read path really raises. The handshake and
test read are bounded by the config's `timeoutMs` (the driver's own per-operation timeout), capped by the
Test Connect timeout — before R07:H7 the config value was ignored.
## Address forms
@@ -103,9 +106,18 @@ Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
| Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) |
| Counter | `C0` / `C10` | Counter area (read-only — see Wide types section) |
Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
at load instead of surfacing as `BadInternalError` on every read. Bit offsets
must be 0-7, byte offsets non-negative, DB numbers >= 1.
Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by
`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate
(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads
`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a
wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the
wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed
`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start
at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes
0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor
retried the same init every 10 s forever (R07:H3).
## Data types
@@ -277,7 +289,7 @@ AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics
| `IWritable` | `WriteAsync` → `S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` |
| `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse |
| `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) |
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged`. A tick that finds the gate held past the probe deadline (a long read pass) gives **no verdict** — it no longer reports `Stopped` for a connection that is busy, not lost (R07:H7) |
### Single-connection policy
@@ -302,8 +314,9 @@ Portal under *Protection & Security* for the CPU.
| Tag not in config | `BadNodeIdUnknown` | unchanged |
| Read-only tag written | `BadNotWritable` | unchanged |
| Unimplemented data type | `BadNotSupported` | unchanged |
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) — also counts as *answered*, advances `LastSuccessfulRead` (no watchdog reconnect loop) |
| CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag |
| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` |
| Socket / timeout | `BadCommunicationError` | `Degraded` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
@@ -314,7 +327,36 @@ accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`)
`Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is
**sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves
`Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A
transient `Degraded` write failure is still cleared by a clean read, as before.
transient `Degraded` write failure is cleared by a clean read, or by the next write batch the CPU
answers without a failure.
**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare
`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its
write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the
access-denied text inside either (S7.Net's exact "Accessing object not allowed", and never when the chain
also carries a transport fault) is the `Faulted` alert — which is ALSO an answer: it advances
`LastSuccessfulRead`, so a factory-default CPU with PUT/GET disabled stays `Faulted` instead of being
reconnected by the comms-loss watchdog every 90 s. A multi-var chunk that falls back per tag memoises the
members refused on their own, like a refused span. A refusal is deterministic, so it neither
degrades health nor reopens the connection, and because it proves the CPU is reachable it advances
`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose
every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss
watchdog reconnected it every 90 s against a perfectly reachable PLC.
**A dead handle ends the pass's wire traffic (R07:P1).** Once a read pass or write batch marks the
connection dead (a wire op blew its deadline on a frozen-but-established peer, a socket dropped), the
remaining spans / multi-var chunks / tags / writes of that pass are stamped `BadCommunicationError`
without I/O; the next call reopens. A multi-var chunk that dies on a transport loss no longer falls back
to one doomed per-tag read per member. Without this an op that ignores its cancellation token would cost
one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's
clock) only became visible after the whole pass.
**A refused span falls back per member (R07:P2).** A grouped block read covers the union of its members'
ranges, so one member the CPU refuses (running past the DB's real end, an over-declared `stringLength`)
used to make every neighbour in the block Bad on every poll. When the CPU refuses a multi-member span the
driver re-reads its members one at a time for that pass and remembers which tags must not be grouped —
the members refused on their own, or every member when none was — so later passes group only the rest.
The memo lasts until the next initialize. A span failure that is not a refusal still stamps every member.
**Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s
cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast
@@ -359,7 +401,7 @@ normal dev (the S7 sim is on the shared Docker host but currently offline).
**Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array
is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
health impact). **Writable `Bool` arrays are refused per tag** (see the tag shape rules above): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
/// Any other exception — and an OCE the operator did not ask for — still propagates.
/// </summary>
/// <param name="console">The CLI console.</param>
/// <param name="ct">The console's Ctrl-C token.</param>
/// <param name="body">The command body.</param>
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func<Task> body)
{
try
{
await body();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
await console.Error.WriteLineAsync("Cancelled.");
}
}
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
});
}
finally
{
@@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
});
}
finally
{
@@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
});
}
finally
{
@@ -48,6 +48,16 @@ public sealed class AbLegacyDriverOptions
/// <summary>Gets or sets the default timeout for read/write operations.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
/// (driver-config key <c>coalesceReads</c>, default <see langword="true"/>). A block the device refuses falls
/// back to per-tag reads on its own; set this false to read every tag singly. <see langword="null"/> (the key
/// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI
/// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9).
/// </summary>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public bool? CoalesceReads { get; init; }
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
@@ -77,6 +77,10 @@ public static class AbLegacyTagDefinitionFactory
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
// A deterministic refusal (review 07 H16): String / Timer / Counter / Control elements are structured
// or variable-width and do not lay out as a flat array, so both runtimes refuse to decode them. Accepting
// the tag only bought a handle create + a NotSupportedException on every poll.
if (arrayLength is not null && !IsArrayElementTypeSupported(dataType)) return false;
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable.
var writable = TagConfigJson.ReadWritable(root);
@@ -173,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory
{
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
else if (accepted.ArrayLength is not null
&& ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError)
warnings.Add(widthError);
return warnings;
}
@@ -224,6 +231,78 @@ public static class AbLegacyTagDefinitionFactory
}
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType") is { } w) warnings.Add(w);
if (IsArrayFlag(root) && ReadInt(root, "arrayLength") >= 1
&& TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dt)
&& !IsArrayElementTypeSupported(dt))
warnings.Add(ArrayElementTypeError(dt));
}
/// <summary>
/// Whether <paramref name="type"/> can be the element type of an array tag. Only the flat word/long/float/bit
/// files lay out as a contiguous array; String, Timer, Counter and Control elements are structured.
/// </summary>
/// <param name="type">The authored element data type.</param>
/// <returns><see langword="true"/> for Bit, Int, AnalogInt, Long and Float.</returns>
public static bool IsArrayElementTypeSupported(AbLegacyDataType type) => type is
AbLegacyDataType.Bit or AbLegacyDataType.Int or AbLegacyDataType.AnalogInt
or AbLegacyDataType.Long or AbLegacyDataType.Float;
/// <summary>The authoring / deploy-gate message for an array of an unsupported element type.</summary>
/// <param name="type">The authored element data type.</param>
/// <returns>The message.</returns>
public static string ArrayElementTypeError(AbLegacyDataType type) =>
$"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are "
+ "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown.";
/// <summary>
/// The element width an array on <paramref name="address"/>'s data file has, as the data type that decodes /
/// encodes it: N, B, A and the I/O/S word files are 16-bit words (<see cref="AbLegacyDataType.Int"/>), L is
/// 32-bit (<see cref="AbLegacyDataType.Long"/>), F is IEEE float (<see cref="AbLegacyDataType.Float"/>).
/// <see langword="null"/> for a file with no flat array layout (ST, T, C, R, function files) or an address that
/// does not parse.
/// </summary>
/// <param name="address">The authored address.</param>
/// <returns>The file's element type, or <see langword="null"/>.</returns>
public static AbLegacyDataType? ArrayFileElementType(string address) =>
AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null;
/// <inheritdoc cref="ArrayFileElementType(string)"/>
public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch
{
"N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int,
"L" => AbLegacyDataType.Long,
"F" => AbLegacyDataType.Float,
_ => null,
};
/// <summary>
/// Why an ARRAY authored as <paramref name="type"/> on <paramref name="address"/> does not match its data
/// file's element width (review 07 r0 I3), or <see langword="null"/> when it does. Int / AnalogInt need a 16-bit
/// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit
/// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it
/// (<c>BadTypeMismatch</c>), because encoding at the authored width would corrupt the neighbouring elements.
/// </summary>
/// <param name="type">The authored element data type.</param>
/// <param name="address">The authored address.</param>
/// <returns>The mismatch message, or <see langword="null"/>.</returns>
public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
{
if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
// The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move —
// so the width rule applies to it too (review 07 r1 N5).
if (AbLegacyAddress.TryParse(address) is not { } a) return null;
var file = ArrayFileElementType(a);
var ok = type switch
{
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int,
_ => file == type,
};
return ok
? null
: $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width "
+ "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width "
+ "and writes are refused BadTypeMismatch.";
}
private static int ReadInt(JsonElement o, string name)
@@ -0,0 +1,79 @@
using System.Collections;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
/// <see cref="IAbLegacyTagRuntime.EncodeArray"/> takes. Pure: it runs BEFORE any runtime is created, so a refused
/// write never costs a wire round trip and never touches driver health.
/// </summary>
/// <remarks>
/// <para><b>Elements are converted, not exact-matched.</b> ScadaBridge writes CLR lists such as <c>int[]</c> to
/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
/// <see cref="Convert"/> the scalar path uses. A value that does not fit is <c>BadOutOfRange</c>; one that is not
/// a number at all is <c>BadTypeMismatch</c>.</para>
/// <para><b>The length must equal the tag's element count</b> (<c>BadOutOfRange</c> otherwise): a shorter write
/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.</para>
/// <para><b>Bit arrays are refused</b> (<c>BadNotSupported</c>): a PCCC bit array is bit-packed into words, so
/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
/// Control arrays never reach here — the mapper rejects them.</para>
/// </remarks>
internal static class AbLegacyArrayWriteEncoder
{
/// <summary>Converts <paramref name="value"/> to a typed array of exactly <paramref name="count"/> elements.</summary>
/// <param name="type">The tag's element data type.</param>
/// <param name="value">The value the client wrote.</param>
/// <param name="count">The tag's element count.</param>
/// <param name="typed">The converted array when the result is Good.</param>
/// <returns><see cref="AbLegacyStatusMapper.Good"/>, or the Bad status the write is answered with.</returns>
public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
{
typed = null;
if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
return AbLegacyStatusMapper.BadNotSupported;
if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
var elements = items.Cast<object?>().ToList();
if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange;
try
{
switch (type)
{
case AbLegacyDataType.Long:
{
var a = new int[count];
for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i]));
typed = a;
break;
}
case AbLegacyDataType.Float:
{
var a = new float[count];
for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i]));
typed = a;
break;
}
default:
{
var a = new short[count];
for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i]));
typed = a;
break;
}
}
return AbLegacyStatusMapper.Good;
}
catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; }
catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; }
}
// A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries.
private static object Element(object? e) => e switch
{
null => throw new InvalidCastException("null array element"),
string => throw new InvalidCastException("string array element"),
bool => throw new InvalidCastException("bool array element"),
_ => e,
};
}
@@ -0,0 +1,250 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Adjacent-element read coalescing (review 07 P7): scalar tags on the same N / B / F / L data file whose
/// elements are close together are read as ONE multi-element PCCC read (the same path array tags already use),
/// then each tag is decoded from its offset in the block. One round trip instead of one per tag.
/// </summary>
/// <remarks>
/// <para><b>Only shapes the single-tag path decodes identically are coalesced</b>: a word tag
/// (<c>Int</c>/<c>AnalogInt</c>) or a bit of a word (<c>Bit</c>, with or without <c>/n</c>) on an N or B file; a
/// <c>Float</c> on an F file; a <c>Long</c> or a bit of a long on an L file. Indirect addresses, sub-elements
/// (<c>T4:0.ACC</c>), arrays, the file-number-less system files (<c>S:</c>, <c>I:</c>, <c>O:</c>) and any tag
/// authored with a type that does not match its file's element width keep the single-tag path, so a
/// mis-authored tag still gets exactly the answer it got before.</para>
/// <para><b>The span is safe to read.</b> A block runs from its lowest to its highest member, so every element
/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags
/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file)
/// — or ANY other status the device returned, incl. libplctag's remote error — falls back to single reads for that
/// block's members in the same pass, so one bad tag never blackens its neighbours; a shape refused twice in a row
/// is remembered so it is not retried every poll. Only a NO-REPLY failure fails the members together — they
/// share the connection — exactly as their single reads would have.</para>
/// <para>Turned off with the driver-config key <c>coalesceReads: false</c>.</para>
/// </remarks>
public sealed partial class AbLegacyDriver
{
/// <summary>Largest block, in data bytes. Below the 236-byte PCCC typed-read limit, so libplctag never needs
/// to fragment a coalesced read (the native runtime would chunk it anyway).</summary>
internal const int MaxCoalescedBytes = 200;
/// <summary>Largest run of UNAUTHORED elements a block may read across to join two members.</summary>
internal const int MaxCoalesceGap = 8;
/// <summary>One unit of a device pass: a single tag, or a coalesced block of several.</summary>
private sealed record ReadUnit(ReadItem? Single, CoalescedBlock? Block);
/// <summary>A coalesced block: <paramref name="Count"/> elements of one file from <paramref name="Start"/>.</summary>
private sealed record CoalescedBlock(
string FileLetter, int FileNumber, int Start, int Count, AbLegacyDataType ElementType, List<ReadItem> Members)
{
/// <summary>The runtime-cache key; distinct from any RawPath (RawPaths never contain '|').</summary>
public string Key => $"|block|{FileLetter}{FileNumber}:{Start}+{Count}";
/// <summary>The block's base address in libplctag form.</summary>
public string StartName => $"{FileLetter}{FileNumber}:{Start}";
}
/// <summary>
/// The element type a scalar tag is decoded from when it rides a coalesced block, or <see langword="null"/>
/// when the tag keeps the single-tag path.
/// </summary>
private static AbLegacyDataType? CoalescedElementType(ReadItem item)
{
var a = item.Parsed;
if (a.IsIndirect || a.SubElement is not null || a.FileNumber is null || IsArrayTag(item.Def)) return null;
return (a.FileLetter, item.Def.DataType) switch
{
("N" or "B", AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit) => AbLegacyDataType.Int,
("F", AbLegacyDataType.Float) => AbLegacyDataType.Float,
("L", AbLegacyDataType.Long) => AbLegacyDataType.Long,
("L", AbLegacyDataType.Bit) when a.BitIndex is not null => AbLegacyDataType.Long,
_ => null,
};
}
/// <summary>
/// Plans a device pass: coalescable tags grouped per file into runs of nearby elements (a run of two or more
/// tags becomes a block), everything else a single unit. Block shapes the device refused before stay single.
/// </summary>
private List<ReadUnit> PlanReadUnits(DeviceState device, List<ReadItem> items)
{
if (_options.CoalesceReads == false || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))];
var units = new List<ReadUnit>(items.Count);
var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List<ReadItem>>();
foreach (var item in items)
{
if (CoalescedElementType(item) is not { } type)
{
units.Add(new ReadUnit(item, null));
continue;
}
var key = (item.Parsed.FileLetter, item.Parsed.FileNumber!.Value, type);
if (!groups.TryGetValue(key, out var list)) groups[key] = list = [];
list.Add(item);
}
foreach (var ((letter, file, type), members) in groups)
{
var width = type == AbLegacyDataType.Int ? 2 : 4;
var maxElements = MaxCoalescedBytes / width;
var ordered = members.OrderBy(m => m.Parsed.WordNumber).ToList();
var run = new List<ReadItem> { ordered[0] };
for (var i = 1; i <= ordered.Count; i++)
{
var next = i < ordered.Count ? ordered[i] : null;
var start = run[0].Parsed.WordNumber;
if (next is not null
&& next.Parsed.WordNumber - run[^1].Parsed.WordNumber <= MaxCoalesceGap + 1
&& next.Parsed.WordNumber - start + 1 <= maxElements)
{
run.Add(next);
continue;
}
var count = run[^1].Parsed.WordNumber - start + 1;
var block = new CoalescedBlock(letter, file, start, count, type, run);
if (run.Count < 2 || device.RefusedBlocks.ContainsKey(block.Key))
units.AddRange(run.Select(r => new ReadUnit(r, null)));
else
units.Add(new ReadUnit(null, block));
if (next is not null) run = [next];
}
}
return units;
}
/// <summary>What a block read concluded; <see cref="Fallback"/> = read the members singly instead.</summary>
private enum BlockOutcome { Done, Fallback }
/// <summary>
/// Reads one coalesced block and decodes each member from its offset. A device refusal or a local refusal
/// returns <see cref="BlockOutcome.Fallback"/> (and remembers the shape) without writing any member's result.
/// </summary>
private async Task<(BlockOutcome Outcome, OpVerdict Verdict, string? Message)> ReadBlockAsync(
DeviceState device, CoalescedBlock block, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
void FailAll(uint status)
{
foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(null, status, null, now);
}
(BlockOutcome, OpVerdict, string?) Refused(string why)
{
// The handle is never kept for a refused shape (review 07 r0 M5): it may never be planned again.
EvictRuntime(device, block.Key);
// Every refusal falls back to per-member reads THIS pass, so each member gets its own answer and a bad
// member can never black out its neighbours. The shape is only REMEMBERED after a repeat — one refusal can
// be transient (program mode, a download in progress), two in a row are deterministic.
var refusals = device.BlockRefusals.AddOrUpdate(block.Key, 1, (_, n) => n + 1);
if (refusals >= 2 && device.RefusedBlocks.TryAdd(block.Key, 0))
{
device.BlockRefusals.TryRemove(block.Key, out _);
_logger.LogInformation(
"AbLegacy coalesced read {Block} refused twice ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}",
block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress);
}
return (BlockOutcome.Fallback, OpVerdict.Neutral, null);
}
try
{
var createParams = new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: block.StartName,
Timeout: _options.Timeout,
ElementCount: block.Count);
var runtime = await CreateSingleFlightAsync(
device, device.Runtimes, block.Key, () => _tagFactory.Create(createParams), ct).ConfigureAwait(false);
int status;
Array? data = null;
var opLock = device.GetRuntimeLock(block.Key);
await opLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await runtime.ReadAsync(ct).ConfigureAwait(false);
status = runtime.GetStatus();
if (status == 0) data = runtime.DecodeArray(block.ElementType, block.Count) as Array;
}
finally
{
opLock.Release();
}
if (status != 0)
{
// ANY status the device returned (a refusal, a remote error, an unmapped reply, Unsupported) falls back
// to per-member reads; only a no-reply status fails the block's members together (review 07 r0 I1).
if (!IsNoReply((AbStatus)status))
return Refused($"device status {(AbStatus)status}");
EvictRuntime(device, block.Key);
FailAll(AbLegacyStatusMapper.MapStatus(status));
return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}");
}
if (data is null || data.Length < block.Count)
return Refused("the block decoded short");
foreach (var m in block.Members)
results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now);
device.BlockRefusals.TryRemove(block.Key, out _);
device.FirstNonZeroStatusLogged = false;
return (BlockOutcome.Done, OpVerdict.Answered, null);
}
catch (OperationCanceledException) { throw; }
catch (ConnectionThrottledException tex)
{
FailAll(AbLegacyStatusMapper.BadCommunicationError);
return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message);
}
catch (AbTransportException ex) when (!IsNoReply(ex.Status))
{
return Refused(ex.Message);
}
catch (NotSupportedException nse)
{
return Refused(nse.Message);
}
catch (Exception ex)
{
EvictRuntime(device, block.Key);
FailAll(AbLegacyStatusMapper.BadCommunicationError);
return (BlockOutcome.Done, OpVerdict.Fault, ex.Message);
}
}
/// <summary>
/// Decodes one member from the block, returning exactly the CLR type and value the single-tag path's
/// <see cref="IAbLegacyTagRuntime.DecodeValue"/> would (<c>int</c> for a word or long, <c>float</c>, <c>bool</c>).
/// </summary>
private static object DecodeMember(CoalescedBlock block, ReadItem m, Array data)
{
var offset = m.Parsed.WordNumber - block.Start;
var bit = m.Parsed.BitIndex;
switch (data)
{
case short[] words:
{
var w = words[offset];
return m.Def.DataType == AbLegacyDataType.Bit
? ((ushort)w >> (bit ?? 0) & 1) != 0
: (int)w;
}
case int[] longs:
{
var l = longs[offset];
return m.Def.DataType == AbLegacyDataType.Bit ? ((uint)l >> bit!.Value & 1) != 0 : l;
}
case float[] floats:
return floats[offset];
default:
throw new InvalidOperationException($"Unexpected block buffer {data.GetType().Name}.");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
CoalesceReads = dto.CoalesceReads,
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
@@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions
/// </summary>
public string? Transport { get; init; }
/// <summary>Gets or sets whether adjacent scalar reads are coalesced; absent means <see langword="true"/>.</summary>
public bool? CoalesceReads { get; init; }
/// <summary>
/// Gets or sets the list of devices to connect to.
/// </summary>
@@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
// The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by
// nothing before: Test Connect always ran on the caller's budget alone.
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
var (parsed, firstDevice) = ExtractTarget(opts);
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
return new(false, "Config has no host/port to probe.", null);
@@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
/// <returns>Whether the status proves the controller answered.</returns>
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
/// <summary>
/// The deadline the probe runs under: the caller's budget, tightened by the config's
/// <see cref="AbLegacyDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (same rule as the
/// Galaxy probe). A non-positive caller budget is floored to 1 s first.
/// </summary>
/// <param name="callerTimeout">The budget the host handed the probe.</param>
/// <param name="configuredSeconds">The config's <c>probeTimeoutSeconds</c>; ignored when not positive.</param>
/// <returns>The effective probe deadline.</returns>
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
{
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
if (configuredSeconds <= 0) return caller;
var configured = TimeSpan.FromSeconds(configuredSeconds);
return configured < caller ? configured : caller;
}
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
/// <param name="value">The value to encode.</param>
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
/// <summary>
/// Encodes EVERY element of a multi-element tag for the next <see cref="WriteAsync"/>, at the element width
/// of <paramref name="type"/>. <paramref name="values"/> is already element-typed by the driver
/// (<c>short[]</c> for Int/AnalogInt, <c>int[]</c> for Long, <c>float[]</c> for Float) and its length must
/// equal the runtime's element count (<see cref="AbLegacyTagCreateParams.ElementCount"/>), so no element of
/// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
/// </summary>
/// <param name="type">The PCCC element data type.</param>
/// <param name="values">The typed elements, exactly element-count long.</param>
/// <exception cref="ArgumentException">The length differs from the element count, or the array's element type
/// does not match <paramref name="type"/>.</exception>
/// <exception cref="NotSupportedException"><paramref name="type"/> has no flat array encoding.</exception>
void EncodeArray(AbLegacyDataType type, Array values);
}
public interface IAbLegacyTagFactory
@@ -23,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
// A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP
// general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the
// end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed
// out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault
// (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and
// trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error"
// value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot
// classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError.
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
/// <summary>The element count the tag was created with (1 for a scalar).</summary>
private readonly int _count;
/// <summary>Why a write to this tag is refused before the wire (<see cref="WriteRefusal"/>), else null.</summary>
private readonly string? _writeRefusal;
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = BuildTag(p);
_count = Math.Max(1, p.ElementCount);
_writeRefusal = WriteRefusal(p);
}
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
}
}
/// <inheritdoc />
public void EncodeArray(AbLegacyDataType type, Array values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Length != _count)
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
// Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
// last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
switch (type, values)
{
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
break;
case (AbLegacyDataType.Long, int[] l):
for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
break;
case (AbLegacyDataType.Float, float[] f):
for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
break;
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
default:
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
}
}
/// <summary>Disposes the tag and releases its resources.</summary>
public void Dispose() => _tag.Dispose();
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
}
}
/// <inheritdoc />
public void EncodeArray(AbLegacyDataType type, Array values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Length != _count)
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
// Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
// write sends comes from the last read.
switch (type, values)
{
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
break;
case (AbLegacyDataType.Long, int[] l):
for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
break;
case (AbLegacyDataType.Float, float[] f):
for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
break;
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
default:
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
}
}
/// <summary>
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid S7 address object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def)
=> FromTagConfig(tagConfig, rawPath, out def, out _);
/// <summary>
/// <see cref="FromTagConfig(string, string, out S7TagDefinition)"/>, also reporting WHY a structurally
/// valid blob was refused by <see cref="S7TagShapeRules"/> (an unparseable address, or a shape the
/// driver cannot serve). <paramref name="shapeRejection"/> stays <see langword="null"/> for a JSON-level
/// refusal (missing keys, bad enum), which <see cref="Inspect"/> explains on its own.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <param name="shapeRejection">The shape/address rejection reason, when that is why it refused.</param>
/// <returns><see langword="true"/> when the driver can serve the tag.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection)
{
def = null!;
shapeRejection = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable.
var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
var mapped = new S7TagDefinition(
Name: rawPath,
Address: address,
DataType: dataType,
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount);
// The address must parse and the shape must be one the driver can serve. These used to be
// init-time guards that THREW and faulted every tag on the PLC; refusing here skips only
// this tag, and the editor + deploy gate (which ask this method) refuse it too.
if (S7TagShapeRules.Validate(mapped, out _) is { } reason)
{
shapeRejection = reason;
return false;
}
def = mapped;
return true;
}
catch (JsonException) { return false; }
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>address</c>, an <b>absent</b> <c>dataType</c> (see <see cref="DeclaresDataType"/>), a
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT, so a
/// typo rejects the tag), or an out-of-range <c>stringLength</c> on a <c>String</c> tag. Such a tag
/// typo rejects the tag), an out-of-range <c>stringLength</c> on a <c>String</c> tag, or a shape
/// <see cref="S7TagShapeRules"/> refuses (an unparseable address, a wrong access width, a mis-typed
/// Timer/Counter, an unsupported array). Such a tag
/// is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
/// </summary>
@@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
if (FromTagConfig(reference, InspectionRawPath, out _, out var shapeRejection)) return warnings;
if (shapeRejection is not null)
{
warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown.");
return warnings;
}
try
{
@@ -0,0 +1,149 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// <b>The single authority on which S7 tag shapes the driver can serve.</b> Consulted by
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/>, so the AdminUI editor, the deploy gate
/// (<see cref="S7TagDefinitionFactory.Inspect"/>) and the driver's own init all refuse the same
/// tags with the same words.
/// </summary>
/// <remarks>
/// <para>
/// These rules used to be init-time guards in <c>S7Driver</c> that THREW. One mis-authored tag
/// (<c>DB1.DBZ0</c>, <c>DB1.DBW0</c> typed <c>Float64</c>, <c>T5</c> typed <c>Int32</c>) faulted the
/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on
/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob
/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads
/// <c>BadNodeIdUnknown</c>) and the deploy gate warns about it.
/// </para>
/// <para>
/// Every rule rejects a tag that could never be served correctly. None of them can newly break
/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded)
/// every read of that tag.
/// </para>
/// </remarks>
public static class S7TagShapeRules
{
/// <summary>
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an access-width
/// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a
/// non-byte suffix (DBW/DBD/...) would mis-frame its value.
/// </summary>
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes =
[
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
S7DataType.WString,
S7DataType.Char,
S7DataType.WChar,
S7DataType.Dtl,
S7DataType.Date,
S7DataType.Tod,
S7DataType.Time,
S7DataType.S5Time,
];
/// <summary>True when <paramref name="dataType"/> is a wide/structured type read from a byte-anchored block.</summary>
/// <param name="dataType">The declared data type.</param>
/// <returns><see langword="true"/> for the byte-anchored types.</returns>
public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType);
/// <summary>
/// Judges one tag definition. Returns <see langword="null"/> when the driver can serve it, or
/// the operator-facing reason it cannot.
/// </summary>
/// <param name="def">The mapped tag definition.</param>
/// <param name="parsed">The parsed address when this returns <see langword="null"/>.</param>
/// <returns>The rejection reason, or <see langword="null"/> when the tag is servable.</returns>
public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed)
{
ArgumentNullException.ThrowIfNull(def);
parsed = default;
S7ParsedAddress addr;
try
{
addr = S7AddressParser.Parse(def.Address);
}
catch (FormatException ex)
{
return $"S7 address '{def.Address}' does not parse: {ex.Message}";
}
var isWide = IsByteAnchored(def.DataType);
var isArray = def.ArrayCount is >= 1;
// Wide-type arrays have no decoder.
if (isWide && isArray)
return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " +
"Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array.";
// Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count.
if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64)
return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " +
"DataType=Float64 (decoded to seconds, read-only).";
if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32)
return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " +
"DataType=Int32 (decoded to a count, read-only).";
if (addr.Area is S7Area.Timer or S7Area.Counter)
{
if (isArray)
return $"S7 Timer/Counter address '{def.Address}' cannot be an array.";
if (def.Writable)
return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " +
"are read-only; set Writable=false.";
parsed = addr;
return null;
}
// Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB).
if (isWide)
{
if (addr.Size != S7Size.Byte)
return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " +
"must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'.";
parsed = addr;
return null;
}
// Narrow types: the declared type must match the address's access width, or every read fails
// (or decodes garbage) with a type mismatch.
var required = def.DataType switch
{
S7DataType.Bool => S7Size.Bit,
S7DataType.Byte => S7Size.Byte,
S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word,
_ => S7Size.DWord, // Int32 / UInt32 / Float32
};
if (addr.Size != required)
return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " +
$"A {def.DataType} tag needs a {SuffixHint(required)} address.";
if (isArray && def.DataType == S7DataType.Bool)
{
// Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A
// non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n).
if (addr.BitOffset != 0)
return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " +
"one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0').";
// A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches.
if (def.Writable)
return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " +
"supported (a byte-granular write would clobber neighbouring bits); set Writable=false " +
"or write the bits as individual scalar Bool tags.";
}
parsed = addr;
return null;
}
private static string SuffixHint(S7Size size) => size switch
{
S7Size.Bit => "bit (DBX/M/I/Q n.b)",
S7Size.Byte => "byte (DBB/MB/IB/QB)",
S7Size.Word => "word (DBW/MW/IW/QW)",
_ => "double-word (DBD/MD/ID/QD)",
};
}
File diff suppressed because it is too large Load Diff
@@ -42,6 +42,14 @@ public sealed class S7DriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
// The S7 handshake + test read are bounded by the driver's own per-operation timeout
// (config "timeoutMs", what the deployed driver will wait), capped by the caller's overall
// Test Connect timeout. The options type carries it as a TimeSpan the JSON never names, so it
// used to be ignored and every handshake waited the full Test Connect timeout (R07:H7).
var opTimeout = ReadTimeoutMs(configJson) is { } ms && TimeSpan.FromMilliseconds(ms) < timeout
? TimeSpan.FromMilliseconds(ms)
: timeout;
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
var sw = Stopwatch.StartNew();
try
@@ -71,9 +79,9 @@ public sealed class S7DriverProbe : IDriverProbe
var plc = S7Tsap.Resolve(opts) is { } tsap
? new Plc(host, port, tsap)
: new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
plc.ReadTimeout = (int)timeout.TotalMilliseconds;
plc.ReadTimeout = (int)opTimeout.TotalMilliseconds;
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(timeout);
handshakeCts.CancelAfter(opTimeout);
try
{
await plc.OpenAsync(handshakeCts.Token);
@@ -128,15 +136,17 @@ public sealed class S7DriverProbe : IDriverProbe
return null;
}
catch (OperationCanceledException) { throw; }
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
// S7.Net's read paths raise the CPU's item error as a BARE Exception ("Received error from
// PLC: …"), not a PlcException, so both classifiers walk the whole chain (the driver's own).
catch (Exception ex) when (S7Driver.IsAccessDenied(ex))
{
return new(false,
"S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null);
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}", null);
}
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
catch (Exception ex) when (S7Driver.IsRefusal(ex))
{
return null; // address not available on this CPU — not a connection problem
return null; // address not available on this CPU — the CPU answered; not a connection problem
}
catch (Exception ex)
{
@@ -146,4 +156,31 @@ public sealed class S7DriverProbe : IDriverProbe
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port);
/// <summary>
/// The config's <c>timeoutMs</c> (any casing, as the driver factory binds it), or null when absent
/// or not a positive integer.
/// </summary>
/// <param name="configJson">The driver config.</param>
/// <returns>The configured per-operation timeout in milliseconds, or null.</returns>
internal static int? ReadTimeoutMs(string configJson)
{
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "timeoutMs", StringComparison.OrdinalIgnoreCase)) continue;
return prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var ms) && ms > 0
? ms
: null;
}
return null;
}
catch (JsonException)
{
return null;
}
}
}
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <b>The safety invariant is that a span never extends past its last member's end.</b> A
/// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the
/// gap budget buys), but every byte it requests lies inside a range some member was going
/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where
/// the individual per-tag reads would have succeeded.
/// to read anyway.
/// </para>
/// <para>
/// <b>That does NOT make a grouped read as safe as the per-tag reads</b> (R07:P2): the block is
/// the union of its members' ranges, so ONE member the CPU refuses (running past the DB's real
/// end, an over-declared STRING) makes it refuse the whole block, neighbours included.
/// <see cref="S7Driver.ReadAsync"/> owns that case: a refused multi-member span is re-read one
/// member at a time, and the members that must not be grouped are left out of later plans.
/// </para>
/// <para>
/// <b>Timer and Counter tags are never grouped.</b> Their
@@ -28,6 +28,13 @@
</InputSelect>
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.CoalesceReads" @bind-Value:after="EmitAsync" class="form-check-input" id="ablegacyCoalesceReads" />
<label class="form-check-label" for="ablegacyCoalesceReads">Coalesce adjacent reads</label>
</div>
<div class="form-text">Read nearby scalar tags on one N/B/F/L file as a single PCCC request. A block the PLC refuses falls back to per-tag reads automatically. Default on.</div>
</div>
</div>
</div>
</section>
@@ -97,7 +104,7 @@
/// <summary>JSON paths this form edits; everything else in the stored config is preserved.</summary>
internal static readonly string[] OwnedPaths =
[
"transport", "probeTimeoutSeconds",
"transport", "probeTimeoutSeconds", "coalesceReads",
"probe.enabled", "probe.probeAddress",
];
@@ -163,6 +170,7 @@
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbLegacyTransport? Transport { get; set; }
public bool CoalesceReads { get; set; } = true;
// Probe
public bool ProbeEnabled { get; set; } = true;
@@ -200,6 +208,7 @@
_loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
CoalesceReads = o.CoalesceReads ?? true,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -220,6 +229,8 @@
},
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
// On = the default: OMIT the key (never pin a concrete default); off is the only value written.
CoalesceReads = CoalesceReads ? null : false,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
@@ -87,6 +87,16 @@ public sealed class AbLegacyTagConfigModel
if (isArray && arrayLength > AbLegacyArray.MaxElements)
return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit).";
// Same rule the reader enforces (review 07 H16): structured elements never form an array. Stated here so
// the CSV-import / API paths get the specific reason instead of the parse gate's generic one.
if (isArray && arrayLength >= 1 && !AbLegacyTagDefinitionFactory.IsArrayElementTypeSupported(DataType))
return AbLegacyTagDefinitionFactory.ArrayElementTypeError(DataType);
// An array must match its data file's element width (review 07 r0 I3): the driver refuses to write a
// mismatched one, and reads it at the wrong width.
if (isArray && arrayLength >= 1 && AbLegacyTagDefinitionFactory.ArrayFileMismatch(DataType, Address.Trim()) is { } widthError)
return widthError;
// Parse-gate backstop: the checks above mirror the reader's whole rejection surface today, so this is
// expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap.
return TagConfigParseGate.Describe(
@@ -112,17 +112,15 @@ public sealed class S7TagConfigModel
+ string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
}
// Array shape is judged against the driver's INIT guards, which THROW (failing the whole
// driver's init, not just this tag) for a wide-type array and for a writable Bool array. The
// Array shape: the friendlier editor wording for two of the S7TagShapeRules refusals (a wide-type
// array, a writable Bool array). The reader below refuses them too (and the rest of the shape
// rules), so this is wording, not a second authority. The
// isArray / arrayLength keys themselves belong to the TagModal's generic array controls
// (TagArrayConfig) and are intentionally not fields of this model, so read them off the key bag.
if (TagConfigJson.GetBool(_bag, "isArray"))
{
var dt = DataType;
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
or S7DataType.Time or S7DataType.S5Time)
if (S7TagShapeRules.IsByteAnchored(dt))
{
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
+ "UInt32 or Float32 for an array tag, or a scalar tag.";
@@ -93,37 +93,33 @@ public sealed class AddressSpaceApplier
}
var ts = DateTime.UtcNow;
var removedCount = 0;
// A removed equipment is a FOLDER: it gets no terminal condition write (review 01 L4 — the write the
// applier used to make against the equipment id never found a condition and minted a placeholder
// variable that shadowed the folder's SDK lookup entry). Its subtree teardown is in ApplyPureRemove.
var removedCount = plan.RemovedEquipment.Count;
// Swallowed removal-pass condition-write failures are tallied here (not lost to a per-node Warning)
// so a degraded removal is operator-visible via AddressSpaceApplyOutcome.FailedNodes (archreview 01/S-1).
var failedNodes = 0;
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++;
}
foreach (var alarm in plan.RemovedAlarms)
{
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++;
}
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
// removed raw containers/tags + UNS reference variables are likewise real removals.
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
// Removed VirtualTags are plain variable nodes (no Part 9 condition to write before tear-down), but
// they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes is accurate on a
// removed-vtag-only deploy. v3 Batch 4: removed raw containers/tags + UNS reference variables are
// likewise real removals.
removedCount += plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count +
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
// A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
@@ -145,7 +141,7 @@ public sealed class AddressSpaceApplier
// order even when an id is reused across the remove + add sets (the recreated node is a fresh
// NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback.
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
// ChangedAlarms / node-relevant ChangedEquipmentVirtualTags / non-surgical raw-tag deltas)
// — the classifier's rule 2, which also catches any future plan field that makes a changed
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
@@ -156,7 +152,6 @@ public sealed class AddressSpaceApplier
// afterward by the publish actor's Materialise passes + announce).
var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix;
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
@@ -176,7 +171,7 @@ public sealed class AddressSpaceApplier
rebuildFailed = !SafeRebuild();
rebuilt = true;
}
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass ||
else if (renamedFolders.Count > 0 || hasRemovePass ||
surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0)
{
if (_sink is ISurgicalAddressSpaceSink surgical)
@@ -194,25 +189,6 @@ public sealed class AddressSpaceApplier
}
if (!ok) { allApplied = false; break; }
}
foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
{
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
// would so the in-place update matches what a rebuild would have produced. Array tags are
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
var writable = d.Current.Writable && !d.Current.IsArray;
var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
: null;
bool ok;
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
ok = false;
}
if (!ok) { allApplied = false; break; }
}
// v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE
// UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths,
// and before this path existed that tore down every node on the server for an edit that creates
@@ -243,6 +219,9 @@ public sealed class AddressSpaceApplier
ok = false;
}
if (!ok) { allApplied = false; break; }
RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId,
d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength,
d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength);
}
// v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's
// IDENTITY (2026-08-15 review I2).
@@ -294,6 +273,9 @@ public sealed class AddressSpaceApplier
updated = false;
}
if (!updated) { allApplied = false; break; }
RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId,
d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength,
d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength);
continue;
}
@@ -320,8 +302,8 @@ public sealed class AddressSpaceApplier
}
_logger.LogInformation(
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count,
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
kind, addedCount, removedCount, changedCount,
rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count,
rebuilt ? 0 : renamedFolders.Count, rebuilt);
@@ -342,10 +324,9 @@ public sealed class AddressSpaceApplier
/// Apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
/// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose
/// <c>EquipmentId</c> is itself in <see cref="AddressSpacePlan.RemovedEquipment"/> is SUBSUMED by the
/// subtree removal and skipped individually. For surviving-equipment removals: a removed value
/// subtree removal and skipped individually. For surviving-equipment removals: a removed VirtualTag
/// variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
/// a removed alarm-bearing tag (previously it got no terminal condition write) and a
/// removed scripted alarm get a terminal <see cref="RemovedConditionState"/> write then
/// a removed scripted alarm gets a terminal <see cref="RemovedConditionState"/> write then
/// <see cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>; each removed equipment gets one
/// <see cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>. The terminal write is what an
/// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets
@@ -363,25 +344,6 @@ public sealed class AddressSpaceApplier
plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal);
bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId);
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
{
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
// closed), then remove the condition in place.
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
else
{
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
}
// Removed VirtualTags (always plain value variables) for surviving equipment.
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
{
@@ -550,7 +512,6 @@ public sealed class AddressSpaceApplier
ArgumentNullException.ThrowIfNull(plan);
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
// v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its
@@ -1074,96 +1035,9 @@ public sealed class AddressSpaceApplier
return failed;
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot.
/// For each <see cref="EquipmentTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
/// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
/// materialised to a plan's added tags; <paramref name="composition"/> stays the FULL composition for
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
/// </summary>
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
/// <param name="only">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
public int MaterialiseEquipmentTags(
AddressSpaceComposition composition,
IReadOnlyList<EquipmentTagPlan>? only = null)
{
ArgumentNullException.ThrowIfNull(composition);
var equipmentTags = only ?? composition.EquipmentTags;
if (equipmentTags.Count == 0) return 0;
var failed = 0;
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (never re-create
// the equipment folder here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in equipmentTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
// ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
// Per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in equipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
}
else
{
// Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective
// historian tagname HERE (default-vs-override): a null/blank override falls back to the
// driver-side FullName; null means the tag is not historized at all.
string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
// Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
// here. The live array-write capability is carried on RawTagPlan /
// UnsReferenceVariable only — see RawNodeWritable.
var writable = tag.Writable && !tag.IsArray;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
}
}
_logger.LogInformation(
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
equipmentTags.Count,
equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
only is not null,
failed);
return failed;
}
/// <summary>
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
/// analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
/// computed-signal pass. For each <see cref="EquipmentVirtualTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
/// practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
/// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
@@ -1285,10 +1159,53 @@ public sealed class AddressSpaceApplier
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; }
try
{
_sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
_recording?.Ensured.Add((realm, nodeId));
return true;
}
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
}
/// <summary>Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness /
/// array length): the node manager drops such a node's value to BadWaitingForInitialData.</summary>
private void RecordReshape(
AddressSpaceRealm realm, string nodeId,
string previousDataType, bool previousIsArray, uint? previousArrayLength,
string dataType, bool isArray, uint? arrayLength)
{
if (_recording is null) return;
if (string.Equals(previousDataType, dataType, StringComparison.Ordinal)
&& previousIsArray == isArray
&& (!isArray || previousArrayLength == arrayLength))
{
return;
}
_recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength);
}
/// <summary>The recording in progress, or null. See <see cref="BeginRecording"/>.</summary>
private MaterialisationRecording? _recording;
/// <summary>
/// Starts recording what the following apply and materialise passes do to VARIABLES, until
/// <see cref="EndRecording"/>: every variable ensured, and every variable an in-place attribute update
/// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan
/// was in flight to these — a variable the apply kept took the live write already, and rewriting it would
/// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply.
/// </summary>
/// <returns>The live recording.</returns>
public MaterialisationRecording BeginRecording()
{
_recording = new MaterialisationRecording();
return _recording;
}
/// <summary>Stops the recording <see cref="BeginRecording"/> started (the returned recording is kept).</summary>
public void EndRecording() => _recording = null;
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the
// whole delta classification is one pure, table-testable unit; the applier references them there (and the
// classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there
@@ -1401,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome(
bool RebuildCalled,
bool RebuildFailed = false,
int FailedNodes = 0);
/// <summary>What one apply did to variables (see <see cref="AddressSpaceApplier.BeginRecording"/>).</summary>
public sealed class MaterialisationRecording
{
/// <summary>Every variable an <c>EnsureVariable</c> was issued for, as (realm, NodeId).</summary>
public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new();
/// <summary>Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape.</summary>
public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new();
}
/// <summary>A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length.</summary>
/// <param name="DataType">The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits).</param>
/// <param name="IsArray">Whether the node is a one-dimensional array.</param>
/// <param name="ArrayLength">The fixed array length, when known (null / 0 ⇒ any length).</param>
public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength)
{
/// <summary>
/// Fits <paramref name="value"/> to this shape for a write onto a re-shaped node: unchanged when it already
/// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an
/// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that).
/// A null value always fits (a status annotation carries none).
/// </summary>
/// <param name="value">The value to fit.</param>
/// <param name="fitted">The fitted value, when this returns true.</param>
/// <returns>True when the value fits (as is, or converted).</returns>
public bool TryFit(object? value, out object? fitted)
{
fitted = null;
if (value is null) return true;
var element = ElementType(DataType);
if (!IsArray)
{
if (value is Array) return false;
return TryConvert(value, element, out fitted);
}
if (value is not Array array || value is string) return false;
if (ArrayLength is > 0 && array.Length != ArrayLength) return false;
if (element is null) { fitted = value; return true; }
var result = Array.CreateInstance(element, array.Length);
for (var i = 0; i < array.Length; i++)
{
if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false;
result.SetValue(item, i);
}
fitted = result;
return true;
}
private static bool TryConvert(object? value, Type? target, out object? converted)
{
converted = value;
if (value is null || target is null || target.IsInstanceOfType(value)) return true;
if (value is not IConvertible) return false;
try
{
converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture);
return true;
}
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
{
converted = null;
return false;
}
}
/// <summary>The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set);
/// null ⇒ BaseDataType, where any value fits.</summary>
private static Type? ElementType(string dataType) => dataType switch
{
"Boolean" => typeof(bool),
"SByte" => typeof(sbyte),
"Byte" => typeof(byte),
"Int16" => typeof(short),
"UInt16" => typeof(ushort),
"Int32" => typeof(int),
"UInt32" => typeof(uint),
"Int64" => typeof(long),
"UInt64" => typeof(ulong),
"Float" => typeof(float),
"Double" => typeof(double),
"String" => typeof(string),
"DateTime" => typeof(DateTime),
_ => null,
};
}
@@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
Empty,
/// <summary>Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas,
/// <summary>Only surgical-eligible changed raw tags, surgical UNS-reference deltas,
/// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b
/// in-place path. No adds, no removes.</summary>
AttributeOnly,
@@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind
AddRemoveMix,
/// <summary>Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers,
/// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant
/// alarm-bearing/identity-changing ChangedRawTags, node-relevant
/// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild.</summary>
Rebuild,
}
@@ -53,8 +53,7 @@ public static class AddressSpaceChangeClassifier
if (plan.IsEmpty) return AddressSpaceChangeKind.Empty;
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is
// node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is
// RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the
// add/remove split so a non-surgical change alongside pure adds still rebuilds.
//
@@ -94,7 +93,6 @@ public static class AddressSpaceChangeClassifier
plan.ChangedAlarms.Count > 0 ||
plan.ChangedRawContainers.Count > 0 ||
plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) ||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
{
return AddressSpaceChangeKind.Rebuild;
@@ -105,12 +103,12 @@ public static class AddressSpaceChangeClassifier
// add/remove split below and leave a driver-only plan classified AttributeOnly.
var hasAdds =
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
plan.AddedUnsReferenceVariables.Count > 0;
var hasRemoves =
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
plan.RemovedUnsReferenceVariables.Count > 0;
@@ -141,36 +139,6 @@ public static class AddressSpaceChangeClassifier
Historize = d.Current.Historize,
}).Equals(d.Current);
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
// override-unequal default also covers any future field.
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
/// <summary>Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding.</summary>
/// <param name="d">The equipment-tag delta to classify.</param>
/// <returns><see langword="true"/> when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise <see langword="false"/>.</returns>
internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
d.Previous.Alarm is null && d.Current.Alarm is null &&
(d.Previous with
{
Writable = d.Current.Writable,
IsHistorized = d.Current.IsHistorized,
HistorianTagname = d.Current.HistorianTagname,
DataType = d.Current.DataType,
IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength,
}).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
@@ -23,28 +23,15 @@ public sealed record AddressSpacePlan(
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
IReadOnlyList<AddressSpacePlan.AlarmDelta> ChangedAlarms)
{
/// <summary>
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
/// init-only members (defaulting empty) rather than positional parameters so existing
/// <c>AddressSpacePlan</c> construction sites compile unchanged — consistent with how
/// <see cref="AddressSpaceComposition.EquipmentTags"/> was added. Without these, an
/// incremental deploy that changes ONLY equipment tags produced an empty plan and
/// <c>OpcUaPublishActor.HandleRebuild</c> short-circuited before materialising them.
/// </summary>
public IReadOnlyList<EquipmentTagPlan> AddedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagPlan> RemovedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
/// <summary>
/// Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
/// The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
/// A VirtualTag carries an
/// <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
/// compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) so existing
/// construction sites compile unchanged. (The retired v3 equipment-tag diff sets — review 01 U2 — are gone:
/// device values live in the Raw subtree and reach equipment as <see cref="UnsReferenceVariable"/>s.)
/// </summary>
public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
@@ -65,7 +52,7 @@ public sealed record AddressSpacePlan(
/// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
/// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
/// every existing <c>AddressSpacePlan</c> construction site compiles unchanged — consistent with
/// the EquipmentTag / EquipmentVirtualTag diff sets above.
/// the EquipmentVirtualTag diff sets above.
/// </summary>
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
@@ -74,7 +61,7 @@ public sealed record AddressSpacePlan(
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
/// unchanged (consistent with the EquipmentTag diff sets above).
/// unchanged.
/// </summary>
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
/// <inheritdoc cref="AddedRawContainers"/>
@@ -114,7 +101,6 @@ public sealed record AddressSpacePlan(
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
@@ -124,7 +110,6 @@ public sealed record AddressSpacePlan(
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
@@ -177,12 +162,7 @@ public static class AddressSpacePlanner
a => a.ScriptedAlarmId,
(a, b) => new AddressSpacePlan.AlarmDelta(a, b));
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
previous.EquipmentTags, next.EquipmentTags,
t => t.TagId,
(a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b));
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
// VirtualTags diff by VirtualTagId. EquipmentVirtualTagPlan
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
// list instances, identical contents) correctly diffs to empty; only a real content change is
@@ -232,9 +212,6 @@ public static class AddressSpacePlanner
addedDrv, removedDrv, changedDrv,
addedAlarm, removedAlarm, changedAlarm)
{
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,
ChangedEquipmentTags = changedEqTags,
AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags,
@@ -105,6 +105,55 @@ public sealed class OpcUaApplicationHostOptions
/// is always element 0.
/// </summary>
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
/// <summary>
/// SDK request-processing and transport quotas (<c>OpcUa:Quotas:*</c>, review 01 P6). The defaults are
/// exactly the values that used to be hard-coded (5 / 100 / 200 request threads and queue) and the SDK's
/// own transport defaults, so an absent section changes nothing.
/// </summary>
public OpcUaQuotaOptions Quotas { get; set; } = new();
}
/// <summary>
/// The <c>OpcUa:Quotas</c> section: the SDK's request-thread / queue ceilings and its transport quotas.
/// Request quotas carry the former hard-coded defaults; a transport quota left <c>null</c> keeps the SDK
/// default (<see cref="TransportQuotas"/>'s constructor values).
/// </summary>
public sealed class OpcUaQuotaOptions
{
/// <summary>Minimum SDK request-processing threads (default 5).</summary>
public int MinRequestThreadCount { get; set; } = 5;
/// <summary>Maximum SDK request-processing threads (default 100).</summary>
public int MaxRequestThreadCount { get; set; } = 100;
/// <summary>Requests the SDK queues before refusing with <c>BadTooManyOperations</c> (default 200) — the
/// burst ceiling review 01 P6 flagged.</summary>
public int MaxQueuedRequestCount { get; set; } = 200;
/// <summary>Per-operation timeout in milliseconds; <c>null</c> keeps the SDK default.</summary>
public int? OperationTimeoutMs { get; set; }
/// <summary>Largest string the transport decodes; <c>null</c> keeps the SDK default.</summary>
public int? MaxStringLength { get; set; }
/// <summary>Largest byte string the transport decodes; <c>null</c> keeps the SDK default.</summary>
public int? MaxByteStringLength { get; set; }
/// <summary>Largest array the transport decodes; <c>null</c> keeps the SDK default.</summary>
public int? MaxArrayLength { get; set; }
/// <summary>Largest message the transport accepts, in bytes; <c>null</c> keeps the SDK default.</summary>
public int? MaxMessageSize { get; set; }
/// <summary>Transport buffer size in bytes; <c>null</c> keeps the SDK default.</summary>
public int? MaxBufferSize { get; set; }
/// <summary>Secure-channel lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
public int? ChannelLifetimeMs { get; set; }
/// <summary>Security-token lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
public int? SecurityTokenLifetimeMs { get; set; }
}
/// <summary>
@@ -435,10 +484,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
var serverConfig = new ServerConfiguration
{
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
};
ApplyRequestQuotas(serverConfig, _options.Quotas);
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
{
@@ -469,7 +516,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
},
TransportQuotas = new TransportQuotas(),
TransportQuotas = BuildTransportQuotas(_options.Quotas),
ClientConfiguration = new ClientConfiguration(),
TraceConfiguration = new TraceConfiguration(),
};
@@ -479,6 +526,40 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return config;
}
/// <summary>
/// Applies the request-processing quotas (<c>OpcUa:Quotas:*</c>, review 01 P6 — they were hard-coded at
/// 5 / 100 / 200) to the SDK server configuration.
/// </summary>
/// <param name="serverConfig">The configuration to set.</param>
/// <param name="quotas">The configured quotas.</param>
internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas)
{
serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount;
serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount;
serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount;
}
/// <summary>
/// Builds the SDK transport quotas from <c>OpcUa:Quotas:*</c>: every member starts at the SDK default and
/// only a configured (non-null) value replaces it, so an absent section is byte-identical to the former
/// <c>new TransportQuotas()</c>.
/// </summary>
/// <param name="quotas">The configured quotas.</param>
/// <returns>The transport quotas to install.</returns>
internal static TransportQuotas BuildTransportQuotas(OpcUaQuotaOptions quotas)
{
var t = new TransportQuotas();
if (quotas.OperationTimeoutMs is { } operationTimeout) t.OperationTimeout = operationTimeout;
if (quotas.MaxStringLength is { } maxString) t.MaxStringLength = maxString;
if (quotas.MaxByteStringLength is { } maxByteString) t.MaxByteStringLength = maxByteString;
if (quotas.MaxArrayLength is { } maxArray) t.MaxArrayLength = maxArray;
if (quotas.MaxMessageSize is { } maxMessage) t.MaxMessageSize = maxMessage;
if (quotas.MaxBufferSize is { } maxBuffer) t.MaxBufferSize = maxBuffer;
if (quotas.ChannelLifetimeMs is { } channelLifetime) t.ChannelLifetime = channelLifetime;
if (quotas.SecurityTokenLifetimeMs is { } tokenLifetime) t.SecurityTokenLifetime = tokenLifetime;
return t;
}
/// <summary>
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
File diff suppressed because it is too large Load Diff
@@ -86,19 +86,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
/// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which
/// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision).
/// Triggers an address-space rebuild of the artifact carried in <paramref name="Artifact"/>.
/// <paramref name="DeploymentId"/> is the deployment just applied by the host (echoed in the reply and
/// the logs). Review 02 P5: the ConfigDb fallback for a message carrying no bytes is gone — it had no
/// production producer and would have blocked the pinned OPC UA thread on central SQL — so a message
/// without bytes is "no answer" and holds the served address space.
/// </summary>
/// <param name="Correlation">Correlation id for tracing this rebuild.</param>
/// <param name="DeploymentId">The applied deployment whose artifact to materialise, or null for the latest-sealed fallback.</param>
/// <param name="DeploymentId">The applied deployment whose artifact this is, or null for a legacy caller.</param>
/// <param name="Artifact">
/// The artifact bytes already in hand, used INSTEAD of loading them from the ConfigDb. This is
/// what makes boot-from-cache actually serve its address space: on a central-SQL outage the
/// host has the cached blob but <see cref="DeploymentId"/> alone would drive a ConfigDb read
/// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null
/// on the normal path, where loading from the ConfigDb by id is correct.
/// The artifact bytes. Every producer (the deploy path, boot-from-cache, FetchAndCache) has them in
/// hand; null or empty is treated as no answer.
/// </param>
/// <param name="ReportOutcome">
/// when <see langword="true"/>, the actor replies to the sender with a
@@ -128,11 +126,110 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
public sealed record ServiceLevelChanged(byte ServiceLevel);
/// <summary>
/// Review 02 P5 — a rebuild whose artifact parse, cluster-scoped composition and plan were computed
/// OFF the pinned OPC UA thread (a <c>Task.Run</c> piped back to <c>Self</c>), ready for the sink passes,
/// which are the only part that must run on this actor's thread. Carries the <see cref="Request"/> and
/// the original <see cref="Requester"/> so the <see cref="RebuildCompleted"/> reply reaches whoever asked.
/// </summary>
/// <para><see cref="Incarnation"/> stamps the actor instance that started the plan: a <c>PipeTo(Self)</c>
/// outlives a restart (the ActorRef survives it), and a plan computed against a previous instance's
/// <see cref="_lastApplied"/> must not be applied by the next one. <see cref="AuthorityError"/> carries a
/// data-plane-authority parse failure, which never fails the address-space apply (review fix I2).</para>
/// </summary>
private sealed record PlanReady(
object Incarnation, RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact,
DataPlaneAuthorityProjection? Authority, Exception? AuthorityError,
AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList<string> Inconsistencies);
/// <summary>The off-thread parse / plan step threw (unreadable bytes, a composer fault).</summary>
private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error);
/// <summary>Test seams for the off-thread plan step. Production passes none.</summary>
internal sealed class TestHooks
{
/// <summary>Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight).</summary>
internal Action? BeforePlan { get; init; }
/// <summary>Replaces <see cref="ParsedDeploymentArtifact.ParseDataPlaneAuthority"/> (a test makes it throw).</summary>
internal Func<ParsedDeploymentArtifact, string, DataPlaneAuthorityProjection>? ParseAuthority { get; init; }
}
/// <summary>The default bound on <see cref="_deferred"/>: distinct (realm, id) keys held while a plan is in flight.</summary>
internal const int DefaultDeferredWriteCapacity = 250_000;
/// <summary>Identity of THIS actor instance (see <see cref="PlanReady.Incarnation"/>).</summary>
private readonly object _incarnation = new();
private readonly TestHooks? _hooks;
/// <summary>
/// Review fix C1 — the latest value / status / alarm write per (realm, id) received while a rebuild's plan is
/// in flight (or queued), replayed through the sink after each apply. Writes are non-creating (review 01 L1),
/// so a driver's first publish for a node the in-flight plan ADDS used to be skipped — and a change-driven
/// driver (or a poll group whose last-value cache already holds it) never sends it again, leaving the node
/// <c>BadWaitingForInitialData</c> indefinitely. On a full rebuild the same holds for every node: the
/// re-subscribe's first values land on old nodes the apply then discards.
/// <para>Writes are still applied IMMEDIATELY (existing nodes keep updating during the plan); the buffer
/// only remembers each key's latest so the replay can land it on a node the apply created. Replaying onto a
/// node that already holds it is inert: the same value/status/timestamp is filtered by every monitored
/// item's data-change filter, and an alarm write is delta-gated against the node's current state. That is
/// also why alarms coalesce to the latest state per id rather than replaying every transition: replaying an
/// older transition onto a live condition would move it backwards and fire spurious events.</para>
/// </summary>
private readonly DeferredWrites _deferred;
/// <summary>
/// Review fix round 2 (N2 / M1) — WHICH deferred writes the next replay may land: only the ones whose live write
/// could not have, i.e. onto nodes the apply just CREATED. A node the apply kept took the live write already,
/// and replaying it is not inert: a client changed it in the window (an SDK-applied Acknowledge / Confirm /
/// Shelve on a condition, an optimistic Write on a variable) and the replay would roll that back and fire
/// spurious Part 9 events. Set by <see cref="ApplyPlan"/>, consumed by <see cref="FinishRebuild"/>.
/// <list type="bullet">
/// <item>Variables: after a FULL rebuild every one (each was recreated, its live write landed on the
/// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them).</item>
/// <item>Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to
/// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape
/// (<see cref="VariableShape.TryFit"/>); a value that does not fit is dropped and the node left waiting.
/// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment.</item>
/// <item>Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO
/// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over
/// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition
/// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not
/// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is
/// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it.</item>
/// <item>Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance
/// <c>_lastApplied</c>, so the next apply counts its conditions as added again and may replay onto them;
/// and the empty-plan repair of a degraded composition scopes no condition (the composition is
/// unchanged), so a condition the repair newly places gets no window state — writes to it between the
/// two applies were already dropped before this replay existed.</item>
/// </list>
/// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through
/// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.)
/// </summary>
private ReplayScope _replayScope = ReplayScope.Nothing;
private sealed record ReplayScope(
bool AllVariables,
IReadOnlySet<(AddressSpaceRealm, string)> Variables,
IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped,
IReadOnlySet<(AddressSpaceRealm, string)> Conditions)
{
private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>();
public static readonly ReplayScope Nothing =
new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None);
}
/// <summary>True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in
/// arrival order — the plan is a diff against <see cref="_lastApplied"/>, which only the apply advances — so a
/// rebuild arriving meanwhile waits in <see cref="_pendingRebuilds"/>; value, status and alarm writes do not — they
/// are applied at once AND remembered in <see cref="_deferred"/> for replay after the apply.</summary>
private bool _planInFlight;
private readonly Queue<(RebuildAddressSpace Request, IActorRef Requester)> _pendingRebuilds = new();
private readonly IOpcUaAddressSpaceSink _sink;
private readonly IServiceLevelPublisher _serviceLevel;
private readonly bool _subscribeRedundancyTopic;
private readonly NodeId? _localNode;
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
private readonly AddressSpaceApplier? _applier;
/// <summary>Receives the API keys + ACL grants carried by each applied artifact, so
/// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node
@@ -184,9 +281,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <summary>Production Props — pins the OPC UA dispatcher + subscribes to the
/// <c>redundancy-state</c> DPS topic so cluster transitions drive the local ServiceLevel
/// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied,
/// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the
/// applier through the sink. When <paramref name="dbHealthProbe"/> is supplied the local
/// publish path. When <paramref name="applier"/> is supplied, <see cref="RebuildAddressSpace"/>
/// diffs + applies the artifact bytes it carries through the sink (<paramref name="dbFactory"/> is
/// accepted for wiring compatibility only — review 02 P5 removed the ConfigDb fallback). When <paramref name="dbHealthProbe"/> is supplied the local
/// ServiceLevel is computed via <see cref="ServiceLevelCalculator"/> from real DB-health +
/// staleness + role-leader inputs; otherwise the legacy role-only switch is used.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
@@ -293,6 +390,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
dataPlaneAuthority,
utcNow));
/// <summary>Test-only Props exposing the deferred-write bound and the plan-step seams.</summary>
internal static Props PropsForTests(
IOpcUaAddressSpaceSink sink,
AddressSpaceApplier? applier,
TestHooks? hooks,
int deferredWriteCapacity = DefaultDeferredWriteCapacity,
NodeId? localNode = null,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
// CreateBy, not Create(expression): Akka's expression path activates through a PUBLIC constructor, and the
// seam-carrying constructor is internal so the test seams stay off the public API (review M3).
Akka.Actor.Props.CreateBy(new TestProducer(() => new OpcUaPublishActor(
sink, NullServiceLevelPublisher.Instance, false, localNode, null, applier, null, null, null, null,
false, dataPlaneAuthority, null, deferredWriteCapacity, hooks)));
/// <summary>Produces the actor through its internal (seam-carrying) constructor, for <see cref="PropsForTests(IOpcUaAddressSpaceSink, AddressSpaceApplier?, TestHooks?, int, NodeId?, IDataPlaneAuthoritySink?)"/>.</summary>
private sealed class TestProducer(Func<OpcUaPublishActor> create) : IIndirectActorProducer
{
public Type ActorType => typeof(OpcUaPublishActor);
public ActorBase Produce() => create();
public void Release(ActorBase actor) { }
}
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param>
@@ -329,13 +448,41 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
Func<DateTime>? utcNow = null)
: this(sink, serviceLevel, subscribeRedundancyTopic, localNode, dbFactory, applier, dbHealthProbe, staleWindow,
probeFreshnessWindow, healthTickInterval, dbLess, dataPlaneAuthority, utcNow, DefaultDeferredWriteCapacity, hooks: null)
{
}
/// <summary>The full constructor: also the deferred-write bound and the test seams (internal — tests only).</summary>
/// <param name="deferredWriteCapacity">The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight.</param>
/// <param name="hooks">Test seams for the off-thread plan step; null in production.</param>
internal OpcUaPublishActor(
IOpcUaAddressSpaceSink sink,
IServiceLevelPublisher serviceLevel,
bool subscribeRedundancyTopic,
NodeId? localNode,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
AddressSpaceApplier? applier,
IActorRef? dbHealthProbe,
TimeSpan? staleWindow,
TimeSpan? probeFreshnessWindow,
TimeSpan? healthTickInterval,
bool dbLess,
IDataPlaneAuthoritySink? dataPlaneAuthority,
Func<DateTime>? utcNow,
int deferredWriteCapacity,
TestHooks? hooks)
{
_deferred = new DeferredWrites(Math.Max(1, deferredWriteCapacity));
_hooks = hooks;
_sink = sink;
_dataPlaneAuthority = dataPlaneAuthority;
_serviceLevel = serviceLevel;
_subscribeRedundancyTopic = subscribeRedundancyTopic;
_localNode = localNode;
_dbFactory = dbFactory;
// dbFactory is accepted for wiring compatibility only (review 02 P5): the bytes-less ConfigDb read the
// rebuild used to fall back to is gone — every producer carries the artifact bytes in the message.
_ = dbFactory;
_applier = applier;
_dbHealthProbe = dbHealthProbe;
_dbLess = dbLess;
@@ -350,6 +497,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
Receive<RebuildAddressSpace>(HandleRebuild);
Receive<PlanReady>(HandlePlanReady);
Receive<PlanFailed>(HandlePlanFailed);
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
@@ -358,6 +507,24 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
}
/// <summary>
/// Review M5 — a restart discards <see cref="_pendingRebuilds"/>; answer each queued requester (degraded) rather
/// than leave it to the deploy timeout. The in-flight plan's requester is answered when its plan pipes back to
/// the new instance (<see cref="IsFromAnotherIncarnation"/>).
/// </summary>
/// <inheritdoc />
protected override void PreRestart(Exception reason, object message)
{
while (_pendingRebuilds.TryDequeue(out var queued))
{
if (!queued.Request.ReportOutcome) continue;
queued.Requester.Tell(new RebuildCompleted(queued.Request.Correlation, queued.Request.DeploymentId, true,
"the address-space publisher restarted before this rebuild was applied; it was not applied"));
}
base.PreRestart(reason, message);
}
/// <inheritdoc />
protected override void PreStart()
{
@@ -380,6 +547,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
try
{
if (PlanPipelineBusy) DeferValue(new ValueWrite(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm));
_sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
@@ -401,6 +569,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// a whole batch because one item was bad.</para></summary>
private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg)
{
if (PlanPipelineBusy)
foreach (var item in msg.Items) DeferValue(item);
try
{
_sink.WriteValues(msg.Items);
@@ -431,6 +601,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// value write on the same counter.</summary>
private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg)
{
if (PlanPipelineBusy)
foreach (var item in msg.Items) DeferStatus(item);
try
{
_sink.WriteStatuses(msg.Items);
@@ -454,6 +626,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private void HandleAlarmUpdate(AlarmStateUpdate msg)
{
if (PlanPipelineBusy) DeferAlarm(msg);
try
{
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
@@ -468,6 +641,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
{
if (PlanPipelineBusy) DeferAlarmQuality(msg);
try
{
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm);
@@ -480,114 +654,467 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
private void HandleRebuild(RebuildAddressSpace msg)
private void HandleRebuild(RebuildAddressSpace msg) => StartRebuild(msg, Sender);
/// <summary>
/// The entry half of a rebuild, on the actor thread. The raw-sink fallback (no applier) and the
/// no-artifact case complete synchronously; otherwise the artifact parse, the cluster-scoped composition
/// and the plan — the O(artifact) work that used to serialise every live value batch and (because the
/// node manager's lock is the SDK's) every client Publish behind it — run on the thread pool and come
/// back as a <see cref="PlanReady"/>. Only the sink materialisation (<see cref="ApplyPlan"/>) runs here.
/// </summary>
private void StartRebuild(RebuildAddressSpace msg, IActorRef requester)
{
// Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply
// must reach whoever asked even if a future edit introduces an await into it.
var requester = Sender;
(bool Degraded, string? Reason) outcome;
try
// Strict arrival order for EVERY rebuild, including the ones that complete synchronously (no applier, no
// bytes): a bytes-less request arriving mid-plan used to answer before the plan ahead of it, so replies went
// out of order. HandleRebuild is the only entry that can find the pipeline busy; the drain calls this only
// when it is idle.
if (_planInFlight)
{
outcome = HandleRebuildCore(msg);
}
finally
{
// close the apply on EVERY exit, early returns and a throwing pass included: the node
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
// pending; never throws out of the handler.
try { _sink.CompleteAddressSpaceApply(); }
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
}
_pendingRebuilds.Enqueue((msg, requester));
return;
}
// the outcome goes out only after the apply (including its completion step) is done.
if (msg.ReportOutcome)
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
}
/// <summary>The rebuild pipeline. Returns whether the apply was degraded, and why.</summary>
private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg)
{
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
// Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the
// artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose
// ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in
// msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the
// Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev
// seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space
// without re-materialising, so it must NEVER be reached by a real driver node that has an
// applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below).
// Only a node with NO applier at all (the F10b/dev seam) falls back to the raw-sink rebuild — and that
// fallback WIPES the served address space without re-materialising, so it must NEVER be reached by a
// real driver node that has an applier (that node diff-and-applies from its in-hand bytes below).
if (_applier is null)
{
// Every rebuild is traced, whichever path completes it: the applier path opens its span in ApplyPlan
// (the actor-thread half), the two synchronous completions below open their own.
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
(bool Degraded, string? Reason) outcome;
try
{
_sink.RebuildAddressSpace();
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
outcome = (false, null);
}
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
msg.Correlation);
return (true, $"address-space rebuild threw: {ex.Message}");
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", msg.Correlation);
outcome = (true, $"address-space rebuild threw: {ex.Message}");
}
return (false, null);
FinishRebuild(msg, requester, outcome);
return;
}
// An artifact we could not obtain is NOT an empty configuration. Parsing zero bytes yields an empty
// composition, which the planner diffs against the live one as a PureRemove and the applier then
// faithfully tears down. Nothing legitimate produces a zero-length blob (an operator who really deletes
// everything still deploys a JSON document with empty arrays), so the only honest reading of "no bytes"
// is "no answer" — hold the last-known-good address space and let the next deploy (or the next boot)
// supply a real one. Leaving _lastApplied intact means the retry diffs against what is materialised.
if (msg.Artifact is null or { Length: 0 })
{
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
if (_hasAppliedComposition)
_log.Warning(
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
msg.DeploymentId, msg.Correlation);
else
_log.Debug(
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
msg.DeploymentId, msg.Correlation);
FinishRebuild(msg, requester, (true, "no usable artifact reached the address-space apply; the previously served address space was kept"));
return;
}
_planInFlight = true;
var artifact = msg.Artifact;
var baseComposition = _lastApplied;
var nodeId = _localNode?.Value;
var wantAuthority = _dataPlaneAuthority is not null;
var incarnation = _incarnation;
var hooks = _hooks;
Task.Run(() =>
{
hooks?.BeforePlan?.Invoke();
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an EMPTY
// result — and an empty composition is not a benign no-op here (see the no-bytes guard above). The
// throw lands in PlanFailed: logged at Error, _lastApplied untouched, last-known-good still served.
using var parsed = DeploymentArtifact.Open(artifact);
// The authority parse is isolated from the address-space plan (review fix I2): before the plan moved
// off-thread a throw here was contained by FeedDataPlaneAuthority's own catch; it must not now
// become PlanFailed and degrade an apply whose topology is perfectly readable.
DataPlaneAuthorityProjection? authority = null;
Exception? authorityError = null;
if (wantAuthority)
{
try
{
authority = hooks?.ParseAuthority is { } parse
? parse(parsed, nodeId ?? string.Empty)
: parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty);
}
catch (Exception ex)
{
authorityError = ex;
}
}
var inconsistencies = new List<string>();
var composition = nodeId is not null
? parsed.ParseComposition(nodeId, inconsistencies.Add)
: parsed.ParseComposition();
var plan = AddressSpacePlanner.Compute(baseComposition, composition);
return new PlanReady(incarnation, msg, requester, artifact, authority, authorityError, composition, plan, inconsistencies);
})
.PipeTo(Self, failure: ex => new PlanFailed(incarnation, msg, requester, ex));
}
private void HandlePlanReady(PlanReady ready)
{
if (IsFromAnotherIncarnation(ready.Incarnation, ready.Request, ready.Requester)) return;
if (ready.AuthorityError is { } authorityError)
_log.Error(authorityError,
"OpcUaPublish: data-plane authority parse threw (correlation={Correlation}) — the address space is applied; the previously installed keys and grants stay in force",
ready.Request.Correlation);
(bool Degraded, string? Reason) outcome;
try
{
outcome = ApplyPlan(ready);
}
finally
{
_planInFlight = false;
}
FinishRebuild(ready.Request, ready.Requester, outcome);
AfterPlanSettled();
}
private void HandlePlanFailed(PlanFailed failed)
{
if (IsFromAnotherIncarnation(failed.Incarnation, failed.Request, failed.Requester)) return;
_planInFlight = false;
_log.Error(failed.Error, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", failed.Request.Correlation);
FinishRebuild(failed.Request, failed.Requester, (true, $"address-space apply threw: {failed.Error.Message}"));
AfterPlanSettled();
}
/// <summary>A plan piped back to a RESTARTED instance (the ActorRef survives a restart) was computed against the
/// previous instance's <see cref="_lastApplied"/>: never applied. Its requester is still owed an answer.</summary>
private bool IsFromAnotherIncarnation(object incarnation, RebuildAddressSpace request, IActorRef requester)
{
if (ReferenceEquals(incarnation, _incarnation)) return false;
_log.Warning(
"OpcUaPublish: ignoring a rebuild plan started by a previous incarnation of this actor (correlation={Correlation})",
request.Correlation);
if (request.ReportOutcome)
requester.Tell(new RebuildCompleted(request.Correlation, request.DeploymentId, true,
"the address-space publisher restarted while this rebuild was being planned; it was not applied"));
return true;
}
/// <summary>After an apply (or a failed plan) has been answered — its deferred writes already replayed by
/// <see cref="FinishRebuild"/> — start what queued behind it, and drop the buffer once nothing is in flight or
/// queued (a queued plan may add nodes the buffer names, so it is replayed again after that apply).</summary>
private void AfterPlanSettled()
{
StartNextPendingRebuild();
if (!PlanPipelineBusy) _deferred.Clear();
}
/// <summary>Starts queued rebuilds in arrival order until one goes off-thread (or the queue empties) — the
/// synchronous completions (no bytes) answer in their turn.</summary>
private void StartNextPendingRebuild()
{
while (!_planInFlight && _pendingRebuilds.TryDequeue(out var next))
StartRebuild(next.Request, next.Requester);
}
/// <summary>True while a plan is computing or a rebuild is queued behind one.</summary>
private bool PlanPipelineBusy => _planInFlight || _pendingRebuilds.Count > 0;
private void DeferValue(in ValueWrite write)
{
if (!_deferred.Value(write)) WarnDeferredOverflow();
}
private void DeferStatus(in StatusWrite write)
{
if (!_deferred.Status(write)) WarnDeferredOverflow();
}
private void DeferAlarm(AlarmStateUpdate msg)
{
if (!_deferred.Alarm(msg)) WarnDeferredOverflow();
}
private void DeferAlarmQuality(AlarmQualityUpdate msg)
{
if (!_deferred.AlarmQuality(msg)) WarnDeferredOverflow();
}
private void WarnDeferredOverflow()
{
if (_deferred.Dropped == 1)
_log.Warning(
"OpcUaPublish: more than {Capacity} distinct nodes written while a rebuild plan is in flight — later new nodes are not held for replay (their first value lands only if the driver republishes it)",
_deferred.Capacity);
}
/// <summary>Re-applies the deferred writes through the sink: values, then status-only annotations, then alarm
/// states and alarm qualities. Each pass isolated like the live handlers; not re-metered (counted on arrival).</summary>
private void ReplayDeferredWrites(ReplayScope scope)
{
if (_deferred.IsEmpty) return;
var (allValues, allStatuses) = _deferred.VariableWrites(
key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key));
var values = new List<ValueWrite>(allValues.Count);
var statuses = new List<StatusWrite>(allStatuses.Count);
var unfit = 0;
foreach (var v in allValues)
{
if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; }
if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; }
unfit++;
_log.Debug(
"OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value",
v.Realm, v.NodeId, shape.DataType, shape.IsArray);
}
foreach (var st in allStatuses)
{
// A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the
// node's honest state is BadWaitingForInitialData until a value of the new shape arrives.
if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; }
statuses.Add(st);
}
if (values.Count > 0)
{
try { _sink.WriteValues(values); }
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred value writes threw", values.Count); }
}
if (statuses.Count > 0)
{
try { _sink.WriteStatuses(statuses); }
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); }
}
var alarms = 0;
foreach (var alarm in _deferred.Alarms)
{
if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue;
alarms++;
try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); }
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); }
}
var qualities = 0;
foreach (var quality in _deferred.AlarmQualities)
{
if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue;
qualities++;
try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); }
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); }
}
_log.Debug(
"OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan",
values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit);
}
/// <summary>
/// The latest write per (realm, id) received while a plan is in flight — see <see cref="_deferred"/>. A value
/// followed by a status-only write for the same node composes to the value under the new status (exactly what
/// the two live writes leave on the node). Bounded by <see cref="Capacity"/> distinct keys across all kinds;
/// beyond it a NEW key is not held (an existing key still updates), counted in <see cref="Dropped"/>.
/// </summary>
private sealed class DeferredWrites(int capacity)
{
private struct VariableEntry
{
public bool HasValue;
public object? Value;
public uint Status;
public DateTime TimestampUtc;
}
private readonly Dictionary<(AddressSpaceRealm, string), VariableEntry> _variables = new();
private readonly Dictionary<(AddressSpaceRealm, string), AlarmStateUpdate> _alarms = new();
private readonly Dictionary<(AddressSpaceRealm, string), AlarmQualityUpdate> _qualities = new();
public int Capacity { get; } = capacity;
public long Dropped { get; private set; }
public int Count => _variables.Count + _alarms.Count + _qualities.Count;
public bool IsEmpty => Count == 0;
public IEnumerable<AlarmStateUpdate> Alarms => _alarms.Values;
public IEnumerable<AlarmQualityUpdate> AlarmQualities => _qualities.Values;
public bool Value(in ValueWrite write)
{
if (string.IsNullOrEmpty(write.NodeId)) return true;
var key = (write.Realm, write.NodeId);
if (!_variables.ContainsKey(key) && !HasRoom()) return false;
_variables[key] = new VariableEntry
{
HasValue = true, Value = write.Value, Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc,
};
return true;
}
public bool Status(in StatusWrite write)
{
if (string.IsNullOrEmpty(write.NodeId)) return true;
var key = (write.Realm, write.NodeId);
if (_variables.TryGetValue(key, out var entry))
{
entry.Status = write.StatusCode;
entry.TimestampUtc = write.SourceTimestampUtc;
_variables[key] = entry;
return true;
}
if (!HasRoom()) return false;
_variables[key] = new VariableEntry { Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc };
return true;
}
/// <summary>A full snapshot also sets the condition's Quality (from its StatusCode), so it SUPERSEDES an
/// earlier quality-only write for the same id (review N1): replayed state-then-quality, the stale quality
/// would overwrite the snapshot's. A quality arriving AFTER the state is kept and replays after it.</summary>
public bool Alarm(AlarmStateUpdate msg)
{
var key = (msg.Realm, msg.AlarmNodeId);
if (!Put(_alarms, key, msg)) return false;
_qualities.Remove(key);
return true;
}
public bool AlarmQuality(AlarmQualityUpdate msg) => Put(_qualities, (msg.Realm, msg.AlarmNodeId), msg);
public (List<ValueWrite> Values, List<StatusWrite> Statuses) VariableWrites(Func<(AddressSpaceRealm, string), bool> include)
{
var values = new List<ValueWrite>();
var statuses = new List<StatusWrite>();
foreach (var ((realm, id), e) in _variables)
{
if (!include((realm, id))) continue;
if (e.HasValue) values.Add(new ValueWrite(id, e.Value, e.Status, e.TimestampUtc, realm));
else statuses.Add(new StatusWrite(id, e.Status, e.TimestampUtc, realm));
}
return (values, statuses);
}
public void Clear()
{
_variables.Clear();
_alarms.Clear();
_qualities.Clear();
Dropped = 0;
}
private bool Put<T>(Dictionary<(AddressSpaceRealm, string), T> map, (AddressSpaceRealm, string) key, T value)
{
if (string.IsNullOrEmpty(key.Item2)) return true;
if (!map.ContainsKey(key) && !HasRoom()) return false;
map[key] = value;
return true;
}
private bool HasRoom()
{
if (Count < Capacity) return true;
Dropped++;
return false;
}
}
/// <summary>
/// Closes an apply on EVERY exit — early returns and a throwing pass included: the node manager reports
/// here the native conditions a rebuild carried over (after the notifier wiring the passes did) and
/// discards any captured state no materialise claimed. Skipping it on a throw would leave that state for a
/// later surgical re-add of the same id to resurrect. No-op when nothing is pending; never throws. The
/// outcome goes out only after the apply (including its completion step) is done.
/// </summary>
private void FinishRebuild(RebuildAddressSpace msg, IActorRef requester, (bool Degraded, string? Reason) outcome)
{
try { _sink.CompleteAddressSpaceApply(); }
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
}
// Land what arrived while the plan was in flight on the nodes the apply just created — before the answer,
// so "applied" means the deferred first values are on the address space too (review fix C1).
ReplayDeferredWrites(_replayScope);
_replayScope = ReplayScope.Nothing;
if (msg.ReportOutcome)
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
}
/// <summary>The actor-thread half of a rebuild: install the artifact's authority, apply the plan through the
/// sink passes, announce. Returns whether the apply was degraded, and why.</summary>
private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready)
{
var previous = _lastApplied;
var recording = _applier!.BeginRecording();
try
{
var result = ApplyPlanCore(ready, out var rebuilt);
// What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did.
var conditions = AddedConditions(previous, ready.Composition);
if (rebuilt)
{
// Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only
// NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert
// (or any transition) handled in the window landed on the discarded instance, so every kept scripted
// condition must be replayed onto its new one. Native kept conditions stay "added only".
foreach (var a in ready.Composition.EquipmentScriptedAlarms)
if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
}
_replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions);
return result;
}
finally
{
_applier.EndRecording();
}
}
/// <summary>The condition ids in <paramref name="next"/> that <paramref name="previous"/> did not materialise:
/// enabled scripted alarms (Uns, by ScriptedAlarmId) and native alarms (Raw, at their RawPath).</summary>
private static HashSet<(AddressSpaceRealm, string)> AddedConditions(
AddressSpaceComposition previous, AddressSpaceComposition next)
{
var added = ConditionIds(next);
added.ExceptWith(ConditionIds(previous));
return added;
static HashSet<(AddressSpaceRealm, string)> ConditionIds(AddressSpaceComposition c)
{
var ids = new HashSet<(AddressSpaceRealm, string)>();
foreach (var a in c.EquipmentScriptedAlarms)
if (a.Enabled) ids.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
foreach (var t in c.RawTags)
if (t.Alarm is not null) ids.Add((AddressSpaceRealm.Raw, t.NodeId));
return ids;
}
}
private (bool Degraded, string? Reason) ApplyPlanCore(PlanReady ready, out bool rebuilt)
{
rebuilt = false;
var msg = ready.Request;
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
try
{
// An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the
// cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what
// is unreachable during the outage this exists to survive — and is simply absent on a
// driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode),
// prefer the artifact of the deployment the host just applied — at apply time it is not yet
// Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale
// composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed
// only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing
// to load, so yield Array.Empty<byte>() — the guard below reads that as "no answer" and
// abandons the rebuild, holding last-known-good rather than wiping the address space.
var artifact = msg.Artifact
?? (_dbFactory is not null
? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact())
: Array.Empty<byte>());
// An artifact we could not obtain is NOT an empty configuration. Parsing zero
// bytes yields an empty composition, which the planner diffs against the live one as a
// PureRemove and the applier then faithfully tears down: a transient ConfigDb blip empties the
// served address space. Nothing legitimate produces a zero-length blob (an operator who really
// deletes everything still deploys a JSON document with empty arrays), so the only honest
// reading of "no bytes" is "no answer" — hold the last-known-good address space and let the
// next deploy (or the next boot) supply a real one. Returning here also leaves _lastApplied
// intact, so the retry diffs against what is actually materialised.
if (artifact is null or { Length: 0 })
{
if (_hasAppliedComposition)
_log.Warning(
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
msg.DeploymentId, msg.Correlation);
else
_log.Debug(
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
msg.DeploymentId, msg.Correlation);
return (true, "no usable artifact reached the address-space apply; the previously served address space was kept");
}
// Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the
// composition) reads this one JsonDocument instead of rebuilding it per question — the rebuild
// used to pay a full parse-and-throw-away per static call. Disposed when the handler returns.
//
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an
// EMPTY result — and an empty composition is not a benign no-op here: the planner diffs it as a
// PureRemove and the applier tears the served address space down. So the throw is the honest
// outcome, and it lands in this method's catch below: logged at Error, _lastApplied untouched,
// last-known-good still served. Same "no answer ≠ empty configuration" rule as the guard
// immediately above, applied to bytes that arrived unreadable rather than absent.
using var parsed = DeploymentArtifact.Open(artifact);
foreach (var inconsistency in ready.Inconsistencies)
_log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", _localNode, inconsistency);
// install this artifact's API keys + ACL grants BEFORE the address-space work
// below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a
@@ -595,13 +1122,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// feeding this after that return would leave a revoked key authenticating until some unrelated
// structural change happened to redeploy. Authorization and topology change on their own
// schedules.
FeedDataPlaneAuthority(parsed, artifact);
if (ready.Authority is not null) FeedDataPlaneAuthority(ready.Authority, ready.Artifact);
var composition = _localNode is { } ln
? parsed.ParseComposition(ln.Value,
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
: parsed.ParseComposition();
var plan = AddressSpacePlanner.Compute(_lastApplied, composition);
var composition = ready.Composition;
var plan = ready.Plan;
if (plan.IsEmpty)
{
@@ -610,7 +1134,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// The composition is unchanged but the last apply of it was a degraded
// rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the
// re-deploy actually repairs what that rebuild could not place.
var repairFailed = _applier.MaterialiseHierarchy(composition)
var repairFailed = _applier!.MaterialiseHierarchy(composition)
+ MaterialiseNodes(plan, composition, fullComposition: true);
if (repairFailed > 0)
{
@@ -632,7 +1156,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
return (false, null);
}
var outcome = _applier.Apply(plan);
var outcome = _applier!.Apply(plan);
rebuilt = outcome.RebuildCalled;
// Sum swallowed per-node materialise failures across every pass together with Apply's own
// removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node
@@ -725,8 +1250,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
/// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over
/// Run the four node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
/// equipment VirtualTags — either over the FULL composition (after a rebuild) or over
/// just this plan's delta (surgical deploys). <c>MaterialiseHierarchy</c> is NOT one of them:
/// the caller always runs it in full, because parents must exist before any add.
/// </summary>
@@ -809,21 +1334,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms);
}
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
// FolderPath sub-folder) exists under its already-materialised equipment folder so
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
// each subscription cycle; variables show BadWaitingForInitialData only until the first
// publish interval fires.
if (fullComposition)
{
failedNodes += applier.MaterialiseEquipmentTags(composition);
}
else if (plan.AddedEquipmentTags.Count > 0)
{
failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags);
}
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
// Equipment-namespace VirtualTags get their own pass right after the scripted alarms:
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
// equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live
// values once the first dependency update arrives; until then variables show BadWaitingForInitialData.
@@ -881,17 +1392,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// the safe direction for availability but means a revocation carried by an artifact this node
/// could not decode has not taken effect. It is logged at Error for exactly that reason.
/// </remarks>
/// <param name="parsed">The rebuild's single parse of the artifact, read for the keys + grants.</param>
/// <param name="projection">The keys + grants decoded (off-thread) from the rebuild's single parse of the artifact.</param>
/// <param name="artifactBytes">The RAW artifact bytes — hashed for the revision id, which must be
/// SHA-256 of the exact blob (see below); never re-parsed here.</param>
private void FeedDataPlaneAuthority(ParsedDeploymentArtifact parsed, byte[] artifactBytes)
private void FeedDataPlaneAuthority(DataPlaneAuthorityProjection projection, byte[] artifactBytes)
{
if (_dataPlaneAuthority is null) return;
try
{
var nodeId = _localNode?.Value ?? string.Empty;
var projection = parsed.ParseDataPlaneAuthority(nodeId);
// The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses,
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
// Hashed off the RAW bytes for exactly that reason: a re-serialisation of the parsed document
@@ -936,46 +1445,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
/// "no answer" and abandons the rebuild, leaving the served address space untouched.</summary>
private byte[] LoadArtifact(DeploymentId deploymentId)
{
try
{
using var db = _dbFactory!.CreateDbContext();
return db.Deployments.AsNoTracking()
.Where(d => d.DeploymentId == deploymentId.Value)
.Select(d => d.ArtifactBlob)
.FirstOrDefault() ?? Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId);
return Array.Empty<byte>();
}
}
/// <summary>Read the most recent <c>Sealed</c> deployment's artifact blob from ConfigDb.
/// Empty array on any failure — see <see cref="LoadArtifact"/> for how that is handled.</summary>
private byte[] LoadLatestArtifact()
{
try
{
using var db = _dbFactory!.CreateDbContext();
return db.Deployments.AsNoTracking()
.Where(d => d.Status == Configuration.Enums.DeploymentStatus.Sealed)
.OrderByDescending(d => d.SealedAtUtc)
.Select(d => d.ArtifactBlob)
.FirstOrDefault() ?? Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned");
return Array.Empty<byte>();
}
}
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
{
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
@@ -0,0 +1,52 @@
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
/// <summary>
/// Review 07 U7: the AbLegacy probe/read/write commands had no <see cref="OperationCanceledException"/> arm, so a
/// Ctrl-C printed a stack trace. They now run their driver work through
/// <see cref="AbLegacyCommandBase.RunCancellableAsync"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CancellationHandlingTests
{
[Fact]
public async Task Operator_cancellation_prints_Cancelled_instead_of_throwing()
{
using var console = new FakeInMemoryConsole();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Should.NotThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync(
console, cts.Token, () => Task.FromCanceled(cts.Token)));
console.ReadErrorString().ShouldContain("Cancelled.");
}
[Fact]
public async Task A_cancellation_the_operator_did_not_request_still_propagates()
{
using var console = new FakeInMemoryConsole();
await Should.ThrowAsync<OperationCanceledException>(async () => await AbLegacyCommandBase.RunCancellableAsync(
console, CancellationToken.None, () => throw new OperationCanceledException()));
}
[Fact]
public async Task A_completed_body_runs_to_completion()
{
using var console = new FakeInMemoryConsole();
var ran = false;
await AbLegacyCommandBase.RunCancellableAsync(console, CancellationToken.None, () =>
{
ran = true;
return Task.CompletedTask;
});
ran.ShouldBeTrue(); // positive control
console.ReadErrorString().ShouldBeEmpty();
}
}
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Review 07 U10: an N-file array write (4 elements) round-trips every element on both transports and leaves the
/// words either side untouched. Gated by <see cref="IntegrationWriteGate"/>. Doubles as the real-controller
/// hardware gate: <c>AB_LEGACY_ARRAY_WRITE_BASE</c> (default <c>N7:1</c>) names the first of the SIX consecutive
/// words it uses (base = "before" sentinel, base+1..base+4 = the array, base+5 = "after" sentinel); every one is
/// read first and written back in a <c>finally</c>, so a run restores them byte-exact.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim)
{
/// <summary>The env var naming the first word of the six the test may overwrite (and restores).</summary>
public const string BaseVar = "AB_LEGACY_ARRAY_WRITE_BASE";
/// <summary>Seeds the neighbours, writes an <c>int[]</c> to a 4-element Int array, reads it all back, restores.</summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task N_file_array_write_round_trips_every_element(AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
IntegrationWriteGate.SkipUnlessWritesAllowed();
var baseText = Environment.GetEnvironmentVariable(BaseVar) is { Length: > 0 } b ? b : "N7:1";
var baseAddr = AbLegacyAddress.TryParse(baseText, profile.Family);
if (baseAddr is not { FileLetter: "N", FileNumber: int file, BitIndex: null, SubElement: null } || baseAddr.IsIndirect)
throw new InvalidOperationException($"{BaseVar}='{baseText}' must be a plain N-file word such as N7:200.");
string At(int offset) => $"N{file}:{baseAddr.WordNumber + offset}";
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
var words = Enumerable.Range(0, 6)
.Select(i => new AbLegacyTagDefinition($"W{i}", deviceUri, At(i), AbLegacyDataType.Int))
.ToArray();
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(
[.. words, new AbLegacyTagDefinition("Arr", deviceUri, At(1), AbLegacyDataType.Int, ArrayLength: 4)]),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
CoalesceReads = false, // read each word on its own, independent of the array path under test
}, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}");
var ct = TestContext.Current.CancellationToken;
await drv.InitializeAsync("{}", ct);
string[] wordRefs = [.. words.Select(w => w.Name)];
if (profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native)
{
// Native PLC-5 writes are refused before the wire (no capture of the PLC-5 write frames exists).
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], ct)).Single()
.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
return;
}
var original = await drv.ReadAsync(wordRefs, ct);
original.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, "the six words must exist before anything is written");
try
{
(await drv.WriteAsync([new WriteRequest("W0", (short)111), new WriteRequest("W5", (short)666)], ct))
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
// ScadaBridge shape: a CLR int[] to an Int16 array node.
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single()
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var arr = (await drv.ReadAsync(["Arr"], ct)).Single();
arr.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
arr.Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
var back = await drv.ReadAsync(wordRefs, ct);
back.Select(s => Convert.ToInt32(s.Value)).ShouldBe(new[] { 111, 10, -20, 30, 32767, 666 }); // per word, neighbours untouched
// A length mismatch is refused before the wire and changes nothing.
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single()
.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
(await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
}
finally
{
// Restore all six words exactly as found (CancellationToken.None: restore even if the run was cancelled).
var restore = words.Select((w, i) => new WriteRequest(w.Name, Convert.ToInt16(original[i].Value))).ToArray();
(await drv.WriteAsync(restore, CancellationToken.None))
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good, "restoring the original words must succeed");
}
}
}
@@ -0,0 +1,69 @@
using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Review 07 P7 (High), wire level: one driver serving a live <c>ab_server</c> device AND a dead device (a
/// blackholed address, so every connect waits out the full timeout). Before the fix the dead device's 20 tags
/// cost 20 x Timeout per poll and the live device was read once per cycle; now the live device reads Good in
/// the same call, the dead device costs at most two timeouts, and the next poll does not touch it at all.
/// Read-only.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyDeadDeviceIsolationTests(AbLegacyServerFixture sim)
{
// TEST-NET-1 (RFC 5737): never routed, so a connect blackholes until the driver's timeout.
private const string DeadUri = "ab://192.0.2.1:44818/1,0";
/// <summary>The live device is served promptly while a blackholed sibling is throttled.</summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task Dead_device_does_not_stall_the_live_one(AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
var liveUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
var timeout = TimeSpan.FromSeconds(2);
var dead = Enumerable.Range(0, 20)
.Select(i => new AbLegacyTagDefinition($"Dead{i}", DeadUri, $"N7:{i}", AbLegacyDataType.Int))
.ToArray();
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(liveUri, profile.Family), new AbLegacyDeviceOptions(DeadUri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(
[new AbLegacyTagDefinition("Live", liveUri, "N7:0", AbLegacyDataType.Int), .. dead]),
Timeout = timeout,
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
}, driverInstanceId: $"ablegacy-p7-{profile.Family}-{transport}");
var ct = TestContext.Current.CancellationToken;
await drv.InitializeAsync("{}", ct);
string[] refs = ["Live", .. dead.Select(d => d.Name)];
// Two consecutive comms faults mark the device down. With coalescing on, the dead device's 20 tags are ONE
// block (one fault per poll), so the window can open on the second poll rather than the first — either way
// at most two timeouts are paid before the device stops being dialled.
var total = TimeSpan.Zero;
TimeSpan last = default;
for (var poll = 0; poll < 3; poll++)
{
var sw = Stopwatch.StartNew();
var r = await drv.ReadAsync(refs, ct);
sw.Stop();
total += last = sw.Elapsed;
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: the live device must be served in the same poll");
r.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good);
}
total.ShouldBeLessThan(timeout * 2.5, "a dead device may cost at most two timeouts per backoff window");
last.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled");
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
}
@@ -0,0 +1,146 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
/// otherwise compares whatever the simulator holds.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
{
/// <summary>Coalesced and per-tag reads of the same tags agree value for value.</summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
var ct = TestContext.Current.CancellationToken;
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
AbLegacyTagDefinition[] tags =
[
new("W0", uri, "N7:0", AbLegacyDataType.Int),
new("W1", uri, "N7:1", AbLegacyDataType.Int),
new("W4", uri, "N7:4", AbLegacyDataType.Int),
new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
];
string[] refs = [.. tags.Select(t => t.Name)];
AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(tags),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
CoalesceReads = coalesce,
}, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
await using var single = Make(coalesce: false);
await using var coalesced = Make(coalesce: true);
await single.InitializeAsync("{}", ct);
await coalesced.InitializeAsync("{}", ct);
var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
&& Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
&& !writesRefused)
{
(await single.WriteAsync(
[new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
}
var a = await single.ReadAsync(refs, ct);
var b = await coalesced.ReadAsync(refs, ct);
a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
}
/// <summary>
/// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10
/// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its
/// neighbours must still read Good on every poll, and the device must never be failed fast or degraded.
/// </summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours(
AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
var ct = TestContext.Current.CancellationToken;
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int),
new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int),
new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
}, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}");
await drv.InitializeAsync("{}", ct);
for (var poll = 0; poll < 3; poll++)
{
var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A");
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B");
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag");
}
// The refusal (libplctag: an opaque ERR_REMOTE_ERR; native: a mapped STS) is never a comms fault: the
// answering neighbours keep the driver Healthy and the device is never failed fast.
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// Review 07 r1 N1, on the wire: libplctag reports the past-the-end refusal as the OPAQUE ERR_REMOTE_ERR —
/// the same code a bridge returns for a powered-off PLC behind it — so a device whose ONLY traffic is that
/// reply must not look reachable: Degraded, <c>LastSuccessfulRead</c> frozen (the comms-loss watchdog's
/// evidence). The native transport maps the STS itself and is only checked to stay non-Good.
/// </summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task An_opaque_only_device_never_proves_reachability(AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
var ct = TestContext.Current.CancellationToken;
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
}, driverInstanceId: $"ablegacy-opaque-{profile.Family}-{transport}");
await drv.InitializeAsync("{}", ct);
var initLastRead = drv.GetHealth().LastSuccessfulRead;
await Task.Delay(20, ct);
for (var poll = 0; poll < 2; poll++)
(await drv.ReadAsync(["PastEnd"], ct))[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
if (transport == AbLegacyTransport.Libplctag)
{
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(initLastRead);
}
}
}
@@ -0,0 +1,231 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Pins BOTH sides of the reply-classification line (review 07 r0 I1 + r1 N1).
/// <list type="bullet">
/// <item><b>No reply</b> (Timeout / BadConnection / Abort / Pending / unknown) — comms fault: evict, Degraded,
/// counts toward fail-fast.</item>
/// <item><b>Answered</b> (an allow-list: NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
/// DeviceStateConflict) — proves reachability: per-tag Bad, Healthy, <c>LastSuccessfulRead</c> advances.</item>
/// <item><b>Opaque</b> (BadReply / Partial — incl. libplctag's <c>ERR_REMOTE_ERR</c>, which folds a PLC refusal
/// and a routing hop's "end device gone" together) — neither: per-tag Bad, never fail-fast, never blacks out
/// coalesced neighbours, but NEVER advances <c>LastSuccessfulRead</c>; an opaque-only device degrades so the
/// comms-loss watchdog can reach a dead PLC behind a bridge.</item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyAnsweredStatusTests
{
private const string Host = "ab://10.0.0.5/1,0";
/// <summary>What the libplctag runtime's GetStatus() reports for ERR_REMOTE_ERR (through the real translation).</summary>
private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr);
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) =>
new(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
CoalesceReads = coalesce,
}, "drv-i1", factory);
// ---- the classification table ----
[Theory]
[InlineData(AbStatus.NotFound)]
[InlineData(AbStatus.NotAllowed)]
[InlineData(AbStatus.OutOfBounds)]
[InlineData(AbStatus.TooLarge)]
[InlineData(AbStatus.TooSmall)]
[InlineData(AbStatus.TypeMismatch)]
[InlineData(AbStatus.DeviceStateConflict)]
public void The_answered_class_is_an_allow_list(AbStatus status)
{
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue();
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
}
[Theory]
[InlineData(AbStatus.BadReply)]
[InlineData(AbStatus.Partial)]
public void Unclassifiable_replies_are_opaque_not_answers(AbStatus status)
{
AbLegacyDriver.IsOpaque(status).ShouldBeTrue();
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
}
[Theory]
[InlineData(AbStatus.Timeout)]
[InlineData(AbStatus.BadConnection)]
[InlineData(AbStatus.Abort)]
[InlineData(AbStatus.Pending)]
[InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply
public void Only_no_reply_statuses_are_comms_faults(AbStatus status)
{
AbLegacyDriver.IsNoReply(status).ShouldBeTrue();
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
}
[Fact]
public void Every_non_Ok_status_lands_in_at_most_one_class_and_Unsupported_in_none()
{
foreach (var s in Enum.GetValues<AbStatus>().Where(s => s != AbStatus.Ok))
{
var classes = new[] { AbLegacyDriver.IsNoReply(s), AbLegacyDriver.IsDeviceRefusal(s), AbLegacyDriver.IsOpaque(s) }
.Count(c => c);
if (s == AbStatus.Unsupported) classes.ShouldBe(0, "Unsupported is neutral");
else classes.ShouldBe(1, s.ToString());
}
}
[Fact]
public void libplctag_remote_errors_translate_to_opaque_not_to_an_answer_or_a_fault()
{
foreach (var st in new[] { Status.ErrorRemoteErr, Status.ErrorBadStatus })
{
var s = LibplctagLegacyStatusTranslation.ToAbStatus(st);
AbLegacyDriver.IsOpaque(s).ShouldBeTrue(st.ToString());
AbLegacyDriver.IsDeviceRefusal(s).ShouldBeFalse(st.ToString());
AbLegacyDriver.IsNoReply(s).ShouldBeFalse(st.ToString());
}
// Positive controls on the other two sides.
AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue();
AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorNotFound)).ShouldBeTrue();
}
// ---- behaviour: the I1 side (an opaque member never blacks out its neighbours, never trips fail-fast) ----
[Fact]
public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours()
{
// N7:52 is past the end of the file: the block N7:48+5 and the single N7:52 both draw ERR_REMOTE_ERR.
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.ElementCount > 1 || p.TagName == "N7:52"
? new FakeAbLegacyTag(p) { Status = RemoteErr }
: new FakeAbLegacyTag(p) { Value = 48 },
};
var drv = NewDriver(factory, coalesce: true,
new AbLegacyTagDefinition("A", Host, "N7:48", AbLegacyDataType.Int),
new AbLegacyTagDefinition("B", Host, "N7:49", AbLegacyDataType.Int),
new AbLegacyTagDefinition("C", Host, "N7:52", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
for (var poll = 0; poll < 4; poll++)
{
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}");
}
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // the neighbours answered
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Consecutive_opaque_replies_do_not_trip_fail_fast_on_an_answering_device()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Value = 9 } : new FakeAbLegacyTag(p) { Status = RemoteErr },
};
var drv = NewDriver(factory, coalesce: false,
new AbLegacyTagDefinition("R1", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("R2", Host, "N7:3", AbLegacyDataType.Int),
new AbLegacyTagDefinition("R3", Host, "N7:6", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Good", Host, "N7:9", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
for (var poll = 0; poll < 3; poll++)
{
var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None);
r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: never failed fast");
}
factory.Tags["N7:9"].ReadCount.ShouldBe(3);
factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // opaque handles are kept, not churned
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- behaviour: the N1 side (an opaque reply never proves reachability) ----
[Fact]
public async Task Remote_error_only_reads_never_advance_LastSuccessfulRead_and_degrade()
{
// A PLC behind a bridge is powered off: every request comes back fast with ERR_REMOTE_ERR.
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = NewDriver(factory, coalesce: false,
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Y", Host, "N7:5", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
var healthyAt = drv.GetHealth().LastSuccessfulRead; // positive control: Good reads DID advance it
healthyAt.ShouldNotBeNull();
foreach (var t in factory.Tags.Values) t.Status = RemoteErr;
await Task.Delay(5);
for (var poll = 0; poll < 3; poll++)
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
var h = drv.GetHealth();
h.LastSuccessfulRead.ShouldBe(healthyAt); // frozen: the comms-loss watchdog can reach this device
h.State.ShouldBe(DriverState.Degraded);
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); // but no fail-fast window
factory.Tags["N7:0"].ReadCount.ShouldBe(4); // every poll still asked
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class ThrowingTag(AbLegacyTagCreateParams p, AbStatus status) : FakeAbLegacyTag(p)
{
public override Task ReadAsync(CancellationToken ct) => throw new AbTransportException(status, $"native {status}");
}
[Theory]
[InlineData(AbStatus.BadReply)]
[InlineData(AbStatus.Partial)]
public async Task A_native_unclassifiable_reply_is_not_counted_as_an_answer(AbStatus status)
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var healthyAt = drv.GetHealth().LastSuccessfulRead;
drv.GetDeviceState(Host)!.Runtimes.TryRemove("X", out _);
factory.Customise = p => new ThrowingTag(p, status);
await Task.Delay(5);
var r = await drv.ReadAsync(["X"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(status));
drv.GetHealth().LastSuccessfulRead.ShouldBe(healthyAt);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Positive_control_a_real_device_refusal_alone_still_proves_reachability()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound } };
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead;
await Task.Delay(5);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,75 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL array encoder
/// (<see cref="AbLegacyArrayWriteEncoder"/>), the same way the Modbus and S7 parity tests do: for every element
/// type the server must advertise an array node writable exactly when the driver encodes it.
/// </summary>
/// <remarks>
/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
/// <see cref="DriverArrayWriteSupport"/> (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
/// adds the row, delete the Skip; this test then holds the table and the encoder together.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class AbLegacyArrayWriteSupportParityTests
{
public static TheoryData<AbLegacyDataType> AllTypes() => new(Enum.GetValues<AbLegacyDataType>());
private static string AddressFor(AbLegacyDataType t) => t switch
{
AbLegacyDataType.Float => "F8:0",
AbLegacyDataType.Long => "L9:0",
AbLegacyDataType.Bit => "B3:0",
AbLegacyDataType.String => "ST9:0",
AbLegacyDataType.TimerElement => "T4:0",
AbLegacyDataType.CounterElement => "C5:0",
AbLegacyDataType.ControlElement => "R6:0",
_ => "N7:0",
};
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
[MemberData(nameof(AllTypes))]
public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
{
var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
&& AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
}
/// <summary>
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
/// a native PLC-5 device. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
/// advertise them writable. Skipped with the parity theory until F1 lands.
/// </summary>
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
[InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"F8:0","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"N7:0","dataType":"Long","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":2}""",
"""{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.5/1,0","plcFamily":"Plc5"}]}""")]
public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
[Theory]
[InlineData(AbLegacyDataType.Int, true)]
[InlineData(AbLegacyDataType.AnalogInt, true)]
[InlineData(AbLegacyDataType.Long, true)]
[InlineData(AbLegacyDataType.Float, true)]
[InlineData(AbLegacyDataType.Bit, false)]
[InlineData(AbLegacyDataType.String, false)]
[InlineData(AbLegacyDataType.TimerElement, false)]
public void Encoder_accepts_exactly_the_flat_numeric_element_types(AbLegacyDataType dataType, bool accepts) =>
(AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good)
.ShouldBe(accepts);
}
@@ -0,0 +1,260 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
/// <c>int[]</c> threw <see cref="InvalidCastException"/> into <c>BadTypeMismatch</c> + Degraded, and a scalar to an
/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes
/// exactly <c>count</c> elements at the element width, converting each element (ScadaBridge writes CLR lists
/// such as <c>int[]</c> to Int16 nodes), and refuses a length mismatch with <c>BadOutOfRange</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyArrayWriteTests
{
private const string Host = "ab://10.0.0.5/1,0";
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public Array? Encoded { get; private set; }
public AbLegacyDataType? EncodedType { get; private set; }
public override void EncodeArray(AbLegacyDataType type, Array values)
{
EncodedType = type;
Encoded = values;
}
}
private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync(
AbLegacyDataType type, string address, int length)
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-u10", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
public static TheoryData<string> IntPayloads => ["int[]", "short[]", "List<int>", "object[]"];
private static object Payload(string kind) => kind switch
{
"int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node
"short[]" => new short[] { 1, -2, 3 },
"List<int>" => new List<int> { 1, -2, 3 },
_ => new object[] { 1L, (short)-2, 3.0 },
};
[Theory]
[MemberData(nameof(IntPayloads))]
public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind)
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var tag = (RecordingTag)f.Tags["N7:0"];
tag.CreationParams.ElementCount.ShouldBe(3);
tag.EncodedType.ShouldBe(AbLegacyDataType.Int);
tag.Encoded.ShouldBe(new short[] { 1, -2, 3 });
tag.WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Review 07 r0 I3: every data file type encodes at ITS element width (one case per file).</summary>
[Theory]
[InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)]
[InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)]
[InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)]
public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth)
{
var (drv, f) = await NewAsync(authored, address, 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var tag = (RecordingTag)f.Tags[address];
tag.EncodedType.ShouldBe(fileWidth);
tag.Encoded!.GetType().ShouldBe(fileWidth switch
{
AbLegacyDataType.Long => typeof(int[]),
AbLegacyDataType.Float => typeof(float[]),
_ => typeof(short[]),
});
tag.WriteCount.ShouldBe(1);
}
/// <summary>
/// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on
/// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the
/// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists.
/// </summary>
[Theory]
[InlineData("F8:0", AbLegacyDataType.Int)]
[InlineData("N7:0", AbLegacyDataType.Long)]
[InlineData("N7:0", AbLegacyDataType.Float)]
[InlineData("L9:0", AbLegacyDataType.Float)]
[InlineData("L9:0", AbLegacyDataType.Int)]
public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored)
{
var (drv, f) = await NewAsync(authored, address, 4);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
AbLegacyTagDefinitionFactory.Inspect(
$$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""")
.ShouldContain(w => w.Contains("element width")); // and the deploy gate names it
}
/// <summary>Review 07 r1 N5: the file letter of an indirect address is static, so the deploy gate checks it too.</summary>
[Theory]
[InlineData("N7:[N7:0]", "Float", true)]
[InlineData("F[N7:0]:2", "Int", true)]
[InlineData("N7:[N7:0]", "Int", false)]
[InlineData("F[N7:0]:2", "Float", false)]
public void An_indirect_array_type_file_mismatch_is_reported_at_the_gate(string address, string dataType, bool mismatch)
{
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
Enum.TryParse<AbLegacyDataType>(dataType, out var type).ShouldBeTrue();
(AbLegacyTagDefinitionFactory.ArrayFileMismatch(type, address) is not null).ShouldBe(mismatch);
AbLegacyTagDefinitionFactory.Inspect(json).Any(w => w.Contains("element width")).ShouldBe(mismatch);
}
[Fact]
public async Task Positive_control_a_Float_array_on_an_F_file_is_written()
{
var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4);
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0]
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f });
AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""")
.ShouldBeEmpty();
}
[Fact]
public async Task Float_and_Long_arrays_encode_at_their_element_width()
{
var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2);
(await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0]
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f });
var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2);
(await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0]
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 });
}
[Theory]
[InlineData(2)]
[InlineData(4)]
public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied)
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent()
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
}
[Theory]
[InlineData(5)]
[InlineData("1,2")]
public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload)
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None);
var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime()
{
// Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read.
var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16);
var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
f.Tags.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Native_array_encode_writes_every_element_at_its_offset()
{
var channel = new FakeCipChannel();
await using var scheduler = new RequestScheduler(channel, allowPacking: false);
var rt = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3),
scheduler, 0xF33D, 0x21504345);
channel.Respond = req => Task.FromResult(NativeEcho(req));
rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 });
await rt.WriteAsync(CancellationToken.None);
var data = channel.Sent.Single()[23..];
data.Length.ShouldBe(6);
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10);
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1);
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300);
rt.Dispose();
}
[Fact]
public void Native_array_encode_refuses_a_length_other_than_the_runtime_count()
{
var channel = new FakeCipChannel();
var scheduler = new RequestScheduler(channel, allowPacking: false);
var rt = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3),
scheduler, 0xF33D, 0x21504345);
Should.Throw<ArgumentException>(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2]));
rt.Dispose();
}
// Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data.
private static byte[] NativeEcho(byte[] req)
{
var body = new List<byte>(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
}
}
@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 02 P1: AbLegacy ran <see cref="PollGroupEngine"/> but did not implement <see cref="IBatchSubscribable"/>,
/// so every changed tag paid its own trip through the actor chain. It now raises one
/// <see cref="IBatchSubscribable.OnDataChangeBatch"/> per poll cycle, alongside the per-tag event.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyBatchSubscribeTests
{
private const string Host = "ab://10.0.0.5/1,0";
[Fact]
public async Task One_batch_per_poll_carries_every_changed_tag_and_per_tag_events_still_fire()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? 1 : 2 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("A", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("B", Host, "N9:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-batch", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
IBatchSubscribable batching = drv; // the capability the actor prefers
var batches = new System.Collections.Concurrent.ConcurrentQueue<DataChangeBatchEventArgs>();
var singles = new System.Collections.Concurrent.ConcurrentQueue<DataChangeEventArgs>();
batching.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
drv.OnDataChange += (_, e) => singles.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (batches.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(10);
batches.TryPeek(out var first).ShouldBeTrue();
first!.SubscriptionHandle.ShouldBe(handle);
first.Items.Select(i => i.FullReference).ShouldBe(new[] { "A", "B" }, ignoreOrder: true);
first.Items.Single(i => i.FullReference == "A").Snapshot.Value.ShouldBe(1);
singles.Count.ShouldBeGreaterThanOrEqualTo(2); // positive control: the legacy per-tag event still fires
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -205,7 +205,7 @@ public sealed class AbLegacyBitRmwTests
var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None);
firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
// Teardown — DisposeRuntimes must dispose + clear the RMW lock map.
// Teardown — retirement drops the device state (and its RMW lock map) with it.
await drv.DisposeAsync();
// Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior).
@@ -0,0 +1,136 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 H16: an array of String/Timer/Counter/Control was accepted by the mapper, so every poll created a
/// handle (one init read), threw <see cref="NotSupportedException"/> from the decode, disposed the handle and
/// latched Degraded. A deterministic refusal is now rejected at the mapper; a runtime
/// <see cref="NotSupportedException"/> that still occurs is per-tag <c>BadNotSupported</c>, keeps its handle,
/// warns once and leaves driver health alone.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDeterministicRefusalTests
{
private const string Host = "ab://10.0.0.5/1,0";
[Theory]
[InlineData("String", "ST9:0")]
[InlineData("TimerElement", "T4:0")]
[InlineData("CounterElement", "C5:0")]
[InlineData("ControlElement", "R6:0")]
public void Mapper_rejects_arrays_of_structured_element_types(string dataType, string address)
{
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _).ShouldBeFalse();
AbLegacyTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("not supported as an array"));
// Positive control: the same type as a SCALAR is still accepted.
AbLegacyTagDefinitionFactory.FromTagConfig(
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""", "p", out _).ShouldBeTrue();
}
[Theory]
[InlineData("Int", "N7:0")]
[InlineData("Float", "F8:0")]
[InlineData("Long", "L9:0")]
[InlineData("Bit", "B3:0")]
public void Mapper_keeps_arrays_of_flat_element_types(string dataType, string address)
{
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out var def).ShouldBeTrue();
def.ArrayLength.ShouldBe(4);
AbLegacyTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
}
[Fact]
public async Task Rejected_array_tag_is_skipped_with_its_reason_and_creates_no_runtime()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags =
[
new RawTagEntry("Strs", """{"address":"ST9:0","dataType":"String","isArray":true,"arrayLength":3}""", false, Host),
new RawTagEntry("Good", """{"address":"N7:0","dataType":"Int"}""", false, Host),
],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-h16", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Strs", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
factory.Tags.Keys.ShouldNotContain("ST9:0");
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
skipped.Sample.ShouldContain(s => s.RawPath == "Strs" && s.Reason.Contains("not supported as an array"));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class RefusingDecodeTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public override object? DecodeArray(AbLegacyDataType type, int count) =>
throw new NotSupportedException("decode refused");
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) =>
throw new NotSupportedException("encode refused");
}
[Fact]
public async Task Runtime_NotSupported_on_read_is_BadNotSupported_keeps_handle_no_health_warns_once()
{
var logger = new AbLegacyTestLogger();
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.TagName == "N7:0" ? new RefusingDecodeTag(p) : new FakeAbLegacyTag(p) { Value = 7 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Arr", Host, "N7:0", AbLegacyDataType.Int, ArrayLength: 4),
new AbLegacyTagDefinition("Good", Host, "N9:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-h16", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
var second = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
var tag = factory.Tags["N7:0"];
tag.InitializeCount.ShouldBe(1); // handle kept, not churned
tag.Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
logger.Warnings.Count(w => w.Contains("Arr") && w.Contains("decode refused")).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Runtime_NotSupported_on_write_is_BadNotSupported_with_no_health_write()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new RefusingDecodeTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-h16", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var w = await drv.WriteAsync([new WriteRequest("X", 5)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
factory.Tags["N7:0"].Disposed.ShouldBeFalse();
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,255 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 P7 (High): reads were strictly serial across every device with no per-device backoff, so one dead
/// device cost <c>Timeout</c> per tag per poll and starved every healthy device — long enough to trip the
/// comms-loss watchdog against a reachable one. Devices are now read in parallel (bounded), each device pass
/// stops paying for a dead device after two consecutive comms faults and opens a per-device
/// <see cref="ConnectionBackoff"/> window, and cold connects are single-flight per device.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDeviceIsolationTests
{
private const string HostA = "ab://10.0.0.5/1,0";
private const string HostB = "ab://10.0.0.6/1,0";
/// <summary>A tag on a dead device: each wire operation burns <paramref name="cost"/> and then fails.</summary>
private sealed class DeadTag(AbLegacyTagCreateParams p, TimeSpan cost) : FakeAbLegacyTag(p)
{
public static int WireOps;
public override async Task InitializeAsync(CancellationToken ct)
{
Interlocked.Increment(ref WireOps);
await Task.Delay(cost, ct);
throw new System.Net.Sockets.SocketException(10060);
}
public override async Task ReadAsync(CancellationToken ct)
{
Interlocked.Increment(ref WireOps);
await Task.Delay(cost, ct);
throw new System.Net.Sockets.SocketException(10060);
}
}
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, params AbLegacyTagDefinition[] tags) =>
new(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
CoalesceReads = false, // per-tag isolation is under test here; coalescing has its own tests
}, "drv-p7", factory);
private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") =>
[.. Enumerable.Range(0, n).Select(i => new AbLegacyTagDefinition($"{prefix}{i}", host, $"{file}:{i * 3}", AbLegacyDataType.Int))];
[Fact]
public async Task Dead_device_costs_at_most_two_timeouts_then_fails_fast_and_never_stalls_the_healthy_one()
{
var cost = TimeSpan.FromMilliseconds(150);
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.Gateway == "10.0.0.6" ? new DeadTag(p, cost) : new FakeAbLegacyTag(p) { Value = 7 },
};
var a = Tags("A", HostA, 5);
var b = Tags("B", HostB, 20);
var drv = NewDriver(factory, [.. a, .. b]);
await drv.InitializeAsync("{}", CancellationToken.None);
var refs = a.Concat(b).Select(t => t.Name).ToList();
var before = drv.GetHealth().LastSuccessfulRead;
Interlocked.Exchange(ref DeadTag.WireOps, 0);
var first = await drv.ReadAsync(refs, CancellationToken.None);
// The proof is the wire-op count, not a stopwatch: serial-with-no-backoff would dial B 20 times.
Volatile.Read(ref DeadTag.WireOps).ShouldBeLessThanOrEqualTo(2);
first.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); // positive control: A is served
first.Skip(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldNotBeNull();
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before ?? DateTime.MinValue); // A's success advances it
// Inside B's backoff window the next poll touches B's wire not at all.
var opsAfterFirst = Volatile.Read(ref DeadTag.WireOps);
var second = await drv.ReadAsync(refs, CancellationToken.None);
Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst);
second.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Devices_are_read_in_parallel()
{
// Device A's read cannot complete until device B's read has STARTED: a serial walk deadlocks here.
var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.Gateway == "10.0.0.5"
? new GatedTag(p, wait: bStarted.Task) { Value = 1 }
: new GatedTag(p, signal: bStarted) { Value = 2 },
};
var drv = NewDriver(factory,
new AbLegacyTagDefinition("A", HostA, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
r[0].Value.ShouldBe(1);
r[1].Value.ShouldBe(2);
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class GatedTag(AbLegacyTagCreateParams p, Task? wait = null, TaskCompletionSource? signal = null)
: FakeAbLegacyTag(p)
{
public override async Task ReadAsync(CancellationToken ct)
{
signal?.TrySetResult();
if (wait is not null) await wait.WaitAsync(ct);
await base.ReadAsync(ct);
}
}
[Fact]
public async Task One_timing_out_tag_does_not_blacken_the_rest_of_its_device()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.TagName == "N7:0"
? new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException() }
: new FakeAbLegacyTag(p) { Value = 5 },
};
var drv = NewDriver(factory, Tags("A", HostA, 4));
await drv.InitializeAsync("{}", CancellationToken.None);
for (var poll = 0; poll < 3; poll++)
{
var r = await drv.ReadAsync(["A0", "A1", "A2", "A3"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
r.Skip(1).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, $"poll {poll}");
}
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Device_refusal_is_per_tag_Bad_advances_LastSuccessfulRead_and_keeps_the_handle()
{
var logger = new AbLegacyTestLogger();
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(HostA)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", HostA, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-p7", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
var initHealth = drv.GetHealth();
await Task.Delay(20);
var r1 = await drv.ReadAsync(["X"], CancellationToken.None);
var r2 = await drv.ReadAsync(["X"], CancellationToken.None);
r1[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
r2[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy); // the device answered: not a comms fault
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initHealth.LastSuccessfulRead!.Value);
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
factory.Tags["N7:0"].ReadCount.ShouldBe(2); // positive control: both polls really asked the device
logger.Warnings.Count(w => w.Contains("Reference=X")).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Cold_connects_on_one_device_are_single_flight()
{
var inFlight = 0;
var maxInFlight = 0;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new CountingInitTag(p, () =>
{
var now = Interlocked.Increment(ref inFlight);
int seen;
while (now > (seen = Volatile.Read(ref maxInFlight)) && Interlocked.CompareExchange(ref maxInFlight, now, seen) != seen) { }
}, () => Interlocked.Decrement(ref inFlight)) { Value = 1 },
};
var tags = Tags("A", HostA, 6);
var drv = NewDriver(factory, tags);
await drv.InitializeAsync("{}", CancellationToken.None);
// Three concurrent callers (poll loop, server read, write) each hitting different cold tags of one device.
await Task.WhenAll(
drv.ReadAsync(["A0", "A1"], CancellationToken.None),
drv.ReadAsync(["A2", "A3"], CancellationToken.None),
drv.WriteAsync([new WriteRequest("A4", 3), new WriteRequest("A5", 4)], CancellationToken.None));
maxInFlight.ShouldBe(1);
factory.Tags.Values.Count(t => t.InitializeCount == 1).ShouldBe(6); // positive control: all six connected
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class CountingInitTag(AbLegacyTagCreateParams p, Action enter, Action exit) : FakeAbLegacyTag(p)
{
public override async Task InitializeAsync(CancellationToken ct)
{
enter();
try
{
await Task.Delay(30, ct);
await base.InitializeAsync(ct);
}
finally { exit(); }
}
}
[Fact]
public async Task A_recovered_device_is_read_again_once_its_window_expires()
{
var dead = true;
var creates = 0;
var factory = new FakeAbLegacyTagFactory
{
Customise = p =>
{
Interlocked.Increment(ref creates);
return dead
? new FakeAbLegacyTag(p) { ThrowOnInitialize = true, Exception = new System.Net.Sockets.SocketException(10061) }
: new FakeAbLegacyTag(p) { Value = 9 };
},
};
var drv = NewDriver(factory, new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
drv.Clock = () => now;
await drv.InitializeAsync("{}", CancellationToken.None);
// Two consecutive comms faults mark the device down and open its first (1 s) window.
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
var attempts = Volatile.Read(ref creates);
dead = false;
// Inside the window: fails fast without a connect attempt, although the device is back.
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
Volatile.Read(ref creates).ShouldBe(attempts);
now = now.AddMilliseconds(1100); // cross the first (1 s) window on the driver's clock, no sleeping
var recovered = await drv.ReadAsync(["B"], CancellationToken.None);
recovered[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
recovered[0].Value.ShouldBe(9);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -192,6 +192,40 @@ public sealed class AbLegacyDriverProbeTests
LibplctagLegacyName.Translate(status!).ShouldBe("S2:0");
}
private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct);
}
/// <summary>
/// The authored <c>probeTimeoutSeconds</c> ("Admin UI probe timeout") was parsed by nothing — Test Connect ran
/// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps
/// the caller's budget (the positive control).
/// </summary>
[Theory]
[InlineData(",\"probeTimeoutSeconds\":1", "1s")]
[InlineData(",\"probeTimeoutSeconds\":30", "2s")]
[InlineData("", "2s")]
[InlineData(",\"probeTimeoutSeconds\":0", "2s")]
public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) };
var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}";
var result = await new AbLegacyDriverProbe(factory)
.ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldBe($"Probe timed out after {expectedBudget}.");
}
finally { listener.Stop(); }
}
[Fact]
public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception()
{
@@ -0,0 +1,350 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 r0 I2 + M1/M2/M4/M10: any evidence that the device answered — a Good value, a device refusal, a Good
/// or refused write — restores Healthy and advances <c>LastSuccessfulRead</c>; a no-reply failure on either path
/// degrades; writes respect the device's backoff window; teardown health is epoch-guarded.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyHealthEvidenceTests
{
private const string Host = "ab://10.0.0.5/1,0";
private sealed class SwitchableFactory : IAbLegacyTagFactory
{
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag> Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 };
public System.Collections.Concurrent.ConcurrentDictionary<string, FakeAbLegacyTag> Tags { get; } = new();
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Tags[p.TagName] = Make(p);
}
private static FakeAbLegacyTag Dead(AbLegacyTagCreateParams p) =>
new(p) { ThrowOnRead = true, ThrowOnWrite = true, Exception = new System.Net.Sockets.SocketException(10060) };
private static AbLegacyDriver NewDriver(IAbLegacyTagFactory f, params AbLegacyTagDefinition[] tags) =>
new(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
CoalesceReads = false,
}, "drv-health", f);
private static async Task<AbLegacyDriver> DegradedDriverAsync(SwitchableFactory f, params AbLegacyTagDefinition[] tags)
{
var drv = NewDriver(f, tags);
await drv.InitializeAsync("{}", CancellationToken.None);
f.Make = Dead;
(await drv.ReadAsync([tags[0].Name], CancellationToken.None))[0].StatusCode
.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded); // precondition
return drv;
}
[Fact]
public async Task A_refusal_only_read_after_a_fault_restores_Healthy_and_advances_LastSuccessfulRead()
{
var f = new SwitchableFactory();
var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
var before = drv.GetHealth().LastSuccessfulRead;
f.Make = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound };
await Task.Delay(5);
var r = await drv.ReadAsync(["X"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Theory]
[InlineData(0)] // a Good write
[InlineData((int)AbStatus.NotAllowed)] // a write the device refused
public async Task A_good_or_refused_write_after_a_fault_restores_Healthy(int writeStatus)
{
var f = new SwitchableFactory();
var drv = await DegradedDriverAsync(f,
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
var before = drv.GetHealth().LastSuccessfulRead;
f.Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = writeStatus };
await Task.Delay(5);
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
w[0].StatusCode.ShouldBe(writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.BadNotWritable);
f.Tags["N7:5"].WriteCount.ShouldBe(1); // positive control: the write reached the device
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_no_reply_write_status_degrades_like_the_exception_path_does()
{
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = (int)AbStatus.Timeout } };
var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTimeout);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task One_write_call_is_worst_wins_but_still_advances_LastSuccessfulRead()
{
var f = new SwitchableFactory
{
Make = p => p.TagName == "N7:6" ? Dead(p) : new FakeAbLegacyTag(p) { Value = 1 },
};
var drv = NewDriver(f,
new AbLegacyTagDefinition("Good", Host, "N7:5", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Bad", Host, "N7:6", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead;
await Task.Delay(5);
var w = await drv.WriteAsync([new WriteRequest("Good", 1), new WriteRequest("Bad", 2)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
w[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Degraded);
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_write_inside_the_backoff_window_fails_fast_even_with_a_cached_handle()
{
var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
var f = new SwitchableFactory();
var drv = NewDriver(f,
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int),
new AbLegacyTagDefinition("D1", Host, "N7:6", AbLegacyDataType.Int),
new AbLegacyTagDefinition("D2", Host, "N7:7", AbLegacyDataType.Int));
drv.Clock = () => now;
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["W"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // cache W
var cached = f.Tags["N7:5"];
f.Make = Dead;
await drv.ReadAsync(["D1", "D2"], CancellationToken.None); // two consecutive no-reply faults: device down
drv.GetDeviceState(Host)!.IsThrottled(now).ShouldBeTrue();
var w = await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
cached.WriteCount.ShouldBe(0);
// Positive control: once the window has passed, the same cached handle is written.
now = now.AddSeconds(2);
(await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
cached.WriteCount.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task An_indirect_bit_write_releases_the_previous_parent_word_handle()
{
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)0 } };
var drv = NewDriver(f, new AbLegacyTagDefinition("IndBit", Host, "N7:[N7:0]/3", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var state = drv.GetDeviceState(Host)!;
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeTrue(); // positive control: the parent word was RMW'd
f.Tags["N7:0"].Value = (short)6;
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeFalse();
state.ParentRuntimes.ContainsKey("N7:6").ShouldBeTrue();
f.Tags["N7:5"].Disposed.ShouldBeTrue();
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A probe read that parks ignoring cancellation, so ShutdownAsync is still joining.</summary>
private sealed class ParkingTag(AbLegacyTagCreateParams p, Task release, TaskCompletionSource entered) : FakeAbLegacyTag(p)
{
public override async Task ReadAsync(CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
}
}
[Fact]
public async Task A_slow_teardown_does_not_overwrite_a_newer_initialise_health()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var parked = false;
var f = new SwitchableFactory
{
Make = p =>
{
if (parked) return new FakeAbLegacyTag(p) { Value = 1 };
parked = true;
return new ParkingTag(p, release.Task, entered);
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-m1", f);
await drv.InitializeAsync("{}", CancellationToken.None);
await entered.Task;
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
await Task.Delay(50);
shutdown.IsCompleted.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None); // a newer initialise wins
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
release.SetResult();
await shutdown;
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Positive control: an uncontested shutdown still writes Unknown.
await drv.ShutdownAsync(CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public override Task ReadAsync(CancellationToken ct) =>
Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct);
}
/// <summary>
/// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and
/// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves
/// the handle once the gate is held.
/// </summary>
[Fact]
public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved()
{
var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } };
var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var state = drv.GetDeviceState(Host)!;
var first = f.Tags["N7:0"];
var gate = state.GetRuntimeLock("X");
await gate.WaitAsync();
// Signalled at exactly "handle fetched, about to wait on the gate" — no sleep, so the eviction below always
// lands between the reader's fetch and its use (review 07 r1 test nit: the sleep could pass vacuously).
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
state.BeforeOperationGate = key => { if (key == "X") atGate.TrySetResult(); };
var read = drv.ReadAsync(["X"], CancellationToken.None);
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue();
evicted!.Dispose();
gate.Release();
var r = await read;
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review 07 r1 N3: a reader parked on a resolved INDIRECT key's gate while the index sweep evicted that key and
/// forgot its gate used to RECONNECT it under the lock — resurrecting a stale resolution under a gate nobody
/// else would get. It now answers that one call neutrally and leaves the key gone.
/// </summary>
[Fact]
public async Task A_swept_indirect_resolution_is_never_resurrected_under_a_forgotten_gate()
{
var creates = new System.Collections.Concurrent.ConcurrentQueue<string>();
var f = new SwitchableFactory
{
Make = p =>
{
creates.Enqueue(p.TagName);
return new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)42 };
},
};
var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["Ind"], CancellationToken.None))[0].Value.ShouldBe(42); // positive control
var state = drv.GetDeviceState(Host)!;
const string key = "Ind=>N7:5";
state.Runtimes.ContainsKey(key).ShouldBeTrue();
var gate = state.GetRuntimeLock(key);
await gate.WaitAsync();
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
state.BeforeOperationGate = k => { if (k == key) atGate.TrySetResult(); };
var read = drv.ReadAsync(["Ind"], CancellationToken.None);
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
// What the sweep does when the index moves: evict the resolution, then forget its gate.
state.Runtimes.TryRemove(key, out var swept).ShouldBeTrue();
swept!.Dispose();
state.ForgetGates(key);
gate.Release();
var r = await read;
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
state.Runtimes.ContainsKey(key).ShouldBeFalse(); // not resurrected
creates.Count(n => n == "N7:5").ShouldBe(1); // no reconnect of the stale resolution
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // neutral: no health evidence either way
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Review 07 r1 N4: an index read answered Unsupported is neutral, not a transport fault.</summary>
[Fact]
public async Task An_index_read_answered_Unsupported_is_neutral()
{
var f = new SwitchableFactory
{
Make = p => p.TagName == "N7:0"
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.Unsupported }
: new FakeAbLegacyTag(p) { Value = 1 },
};
var drv = NewDriver(f,
new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Plain", Host, "N9:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Ind", "Plain"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetDeviceState(Host)!.ParentRuntimes.ContainsKey("N7:0").ShouldBeTrue(); // handle kept, not evicted
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Review 07 r1 N2: a persisted mismatched array READS BadTypeMismatch instead of garbage as Good.</summary>
[Fact]
public async Task A_mismatched_array_reads_BadTypeMismatch_not_garbage()
{
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1f, 2f } } };
var drv = NewDriver(f,
new AbLegacyTagDefinition("Bad", Host, "F8:0", AbLegacyDataType.Int, ArrayLength: 2),
new AbLegacyTagDefinition("Good", Host, "F9:0", AbLegacyDataType.Float, ArrayLength: 2));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
f.Tags.ContainsKey("F8:0").ShouldBeFalse(); // nothing read at the wrong width
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
r[1].Value.ShouldBe(new[] { 1f, 2f });
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -128,4 +128,32 @@ public sealed class AbLegacyIndirectAddressingTests
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
}
/// <summary>
/// Review 07 L7: every index value an indirect tag sweeps through used to leave one per-runtime semaphore and
/// one creation semaphore behind until shutdown. They are pruned with the stale resolution's handle, so the
/// per-key gate tables stay bounded by the authored set however far the index sweeps.
/// </summary>
[Fact]
public async Task Sweeping_index_does_not_accumulate_per_key_gates()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)10));
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var state = drv.GetDeviceState(Host)!;
var baseline = state.GateCount;
for (short i = 11; i < 60; i++)
{
f.Tags["N7:0"].Value = i;
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)(p.TagName == "N7:0" ? i : 1000 + i) };
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: every resolution really read
r.Value.ShouldBe((short)(1000 + i));
}
state.GateCount.ShouldBe(baseline);
// Only the live resolution's handle (plus the index word's parent runtime) remains cached.
state.Runtimes.Keys.Count(k => k.StartsWith("Ind=>", StringComparison.Ordinal)).ShouldBe(1);
}
}
@@ -0,0 +1,255 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared
/// in place under a reader), <c>Dispose</c> may run concurrently with a still-running <c>ShutdownAsync</c>, and
/// an abandoned <c>InitializeAsync</c> that overlaps the next <c>ShutdownAsync</c> must not resurrect state.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyLifecycleConcurrencyTests
{
private const string Host = "ab://10.0.0.5/1,0";
/// <summary>A probe tag whose read parks until released, ignoring cancellation (a wedged device call).</summary>
private sealed class ParkingProbeTag(AbLegacyTagCreateParams p, Task release) : FakeAbLegacyTag(p)
{
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override async Task ReadAsync(CancellationToken ct)
{
Entered.TrySetResult();
await release.ConfigureAwait(false);
await base.ReadAsync(ct).ConfigureAwait(false);
}
}
private static async Task WaitUntilAsync(Func<bool> condition, string what)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(10);
}
}
/// <summary>
/// Re-initialising in a loop while other threads read the tables never throws "Collection was modified":
/// the readers see either the old or the new table, never one being cleared.
/// </summary>
[Fact]
public async Task Reinit_under_concurrent_readers_never_throws()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0")],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Y", "ab://10.0.0.6/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-swap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var failures = new System.Collections.Concurrent.ConcurrentQueue<Exception>();
var reads = 0;
var readers = Enumerable.Range(0, 3).Select(n => Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
_ = drv.ResolveHost("X");
_ = await drv.ReadAsync(["X", "Y"], CancellationToken.None);
Interlocked.Increment(ref reads);
}
catch (Exception ex) { failures.Enqueue(ex); }
}
})).ToArray();
// Keep re-initialising until the readers have demonstrably overlapped many swaps.
var deadline = DateTime.UtcNow.AddSeconds(10);
for (var i = 0; i < 100 || Volatile.Read(ref reads) < 200; i++)
{
if (DateTime.UtcNow > deadline) break;
await drv.InitializeAsync("{}", CancellationToken.None);
}
stop.Cancel();
await Task.WhenAll(readers);
failures.ShouldBeEmpty();
reads.ShouldBeGreaterThan(0); // positive control: the readers really ran against the swapping tables
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A re-init publishes a NEW device state; the old one is retired, not mutated in place.</summary>
[Fact]
public async Task Reinit_publishes_a_new_table_and_retires_the_old_state()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-swap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var old = drv.GetDeviceState(Host)!;
var oldTag = factory.Tags["N7:0"];
await drv.InitializeAsync("{}", CancellationToken.None);
var fresh = drv.GetDeviceState(Host)!;
fresh.ShouldNotBeSameAs(old);
old.IsRetired.ShouldBeTrue();
oldTag.Disposed.ShouldBeTrue();
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A runtime created by a read that was already in flight when shutdown retired its device state is disposed
/// instead of being cached on the retired state (where nothing would ever release it).
/// </summary>
[Fact]
public async Task Runtime_created_after_retirement_is_disposed_not_cached()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
FakeAbLegacyTag? created = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => created = new InitParkingTag(p, entered, release.Task) { Value = 1 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-late", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var state = drv.GetDeviceState(Host)!;
var read = drv.ReadAsync(["X"], CancellationToken.None);
await entered.Task;
await drv.ShutdownAsync(CancellationToken.None);
release.SetResult();
_ = await read;
created.ShouldNotBeNull();
created!.Disposed.ShouldBeTrue();
state.Runtimes.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Unknown); // the late read did not overwrite shutdown's health
}
private sealed class InitParkingTag(AbLegacyTagCreateParams p, TaskCompletionSource entered, Task release)
: FakeAbLegacyTag(p)
{
public override async Task InitializeAsync(CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
await base.InitializeAsync(ct).ConfigureAwait(false);
}
}
/// <summary>
/// Lane A's final stop calls ShutdownAsync and then DisposeAsync/Dispose; under the 5 s bound Dispose can run
/// while ShutdownAsync is still joining a wedged probe loop. Both complete without throwing and every runtime
/// is released.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_running_shutdown_is_safe()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
ParkingProbeTag? probe = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.TagName == "S:0"
? probe = new ParkingProbeTag(p, release.Task)
: new FakeAbLegacyTag(p) { Value = 1 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
await WaitUntilAsync(() => probe is not null, "probe never created");
await probe!.Entered.Task;
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
await Task.Delay(50);
shutdown.IsCompleted.ShouldBeFalse();
Should.NotThrow(() => drv.Dispose());
await Should.NotThrowAsync(async () => await drv.DisposeAsync());
release.SetResult();
await shutdown;
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
probe.Disposed.ShouldBeTrue();
drv.DeviceCount.ShouldBe(0);
}
/// <summary>
/// An InitializeAsync the runtime abandoned (it is parked releasing a wedged prior probe loop) overlaps the next
/// ShutdownAsync. When it resumes it must NOT publish its tables or start probe loops — that state would sit
/// behind a driver the runtime believes is stopped. A later Initialize publishes normally (positive control).
/// </summary>
[Fact]
public async Task Abandoned_initialize_overlapping_shutdown_does_not_resurrect_state()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var probes = new System.Collections.Concurrent.ConcurrentQueue<FakeAbLegacyTag>();
ParkingProbeTag? first = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p =>
{
FakeAbLegacyTag t = first is null ? first = new ParkingProbeTag(p, release.Task) : new FakeAbLegacyTag(p);
probes.Enqueue(t);
return t;
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-abandon", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => first is not null, "probe never created");
await first!.Entered.Task;
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // parks joining the wedged loop
await Task.Delay(50);
abandoned.IsCompleted.ShouldBeFalse();
await drv.ShutdownAsync(CancellationToken.None);
release.SetResult();
await Should.ThrowAsync<OperationCanceledException>(() => abandoned);
var createdAfter = probes.Count;
await Task.Delay(150); // several probe intervals
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
probes.Count.ShouldBe(createdAfter);
// Positive control: a fresh Initialize after the stop publishes and probes as normal.
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await WaitUntilAsync(() => probes.Count > createdAfter, "fresh init never probed");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,184 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag),
/// although the array path proves the transport can read a span. Scalar tags near each other on one file now
/// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag
/// path produces — and a block the device refuses falls back to per-tag reads.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyReadCoalescingTests
{
private const string Host = "ab://10.0.0.5/1,0";
private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List<AbLegacyTagCreateParams> Created) NewDriver(
Func<AbLegacyTagCreateParams, FakeAbLegacyTag> make, bool coalesce, params AbLegacyTagDefinition[] tags)
{
var created = new List<AbLegacyTagCreateParams>();
var factory = new FakeAbLegacyTagFactory
{
Customise = p =>
{
lock (created) created.Add(p);
return make(p);
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
CoalesceReads = coalesce,
}, "drv-coalesce", factory);
return (drv, factory, created);
}
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) =>
new(name, Host, address, type);
[Fact]
public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads()
{
var (drv, _, created) = NewDriver(
p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } },
coalesce: true,
Tag("W0", "N7:0", AbLegacyDataType.Int),
Tag("W1", "N7:1", AbLegacyDataType.AnalogInt),
Tag("B32", "N7:3/2", AbLegacyDataType.Bit),
Tag("B31", "N7:3/1", AbLegacyDataType.Bit),
Tag("W5", "N7:5", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns
r[1].Value.ShouldBe(11);
r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set
r[3].Value.ShouldBe(false);
r[4].Value.ShouldBe(-15);
created.Count.ShouldBe(1);
created[0].TagName.ShouldBe("N7:0");
created[0].ElementCount.ShouldBe(6);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Float_and_long_files_coalesce_at_their_element_width()
{
var (drv, _, created) = NewDriver(
p => p.TagName.StartsWith('F')
? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } }
: new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } },
coalesce: true,
Tag("F0", "F8:0", AbLegacyDataType.Float),
Tag("F2", "F8:2", AbLegacyDataType.Float),
Tag("L0", "L9:0", AbLegacyDataType.Long),
Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true });
created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName)
.ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) });
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path()
{
var (drv, _, created) = NewDriver(
p => new FakeAbLegacyTag(p) { Value = 1 },
coalesce: true,
Tag("Near", "N7:0", AbLegacyDataType.Int),
Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap
Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before
Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element
Tag("Status", "S:1", AbLegacyDataType.Int), // no file number
Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None);
created.ShouldAllBe(c => c.ElementCount == 1);
created.Select(c => c.TagName).ShouldContain("N7:40");
created.Select(c => c.TagName).ShouldContain("T4:0.ACC");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_refused_block_falls_back_to_single_reads_and_is_remembered_after_a_repeat()
{
// N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is.
var (drv, _, created) = NewDriver(
p => p.ElementCount > 1 || p.TagName == "N7:9"
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds }
: new FakeAbLegacyTag(p) { Value = 3 },
coalesce: true,
Tag("A", "N7:0", AbLegacyDataType.Int),
Tag("B", "N7:2", AbLegacyDataType.Int),
Tag("C", "N7:9", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
for (var poll = 0; poll < 3; poll++)
{
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds));
}
// Refused on polls 0 and 1 (each falling back to singles in the same pass); remembered after the repeat,
// so poll 2 does not try the block again.
created.Count(c => c.ElementCount > 1).ShouldBe(2);
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_block_comms_fault_fails_its_members_together_and_degrades()
{
var (drv, _, _) = NewDriver(
p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) },
coalesce: true,
Tag("A", "N7:0", AbLegacyDataType.Int),
Tag("B", "N7:1", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task CoalesceReads_false_reads_every_tag_singly()
{
var (drv, _, created) = NewDriver(
p => new FakeAbLegacyTag(p) { Value = 1 },
coalesce: false,
Tag("A", "N7:0", AbLegacyDataType.Int),
Tag("B", "N7:1", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
created.Count.ShouldBe(2);
created.ShouldAllBe(c => c.ElementCount == 1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Theory]
[InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
[InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)]
[InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) =>
(AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected);
}
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>Thread-safe capturing logger shared by the remediation tests (poll loops log from pool threads).</summary>
internal sealed class AbLegacyTestLogger : ILogger<AbLegacyDriver>
{
/// <summary>Every entry logged, in order.</summary>
public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
/// <summary>The Warning-level messages logged.</summary>
public IReadOnlyList<string> Warnings => [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) => Entries.Enqueue((logLevel, formatter(state, exception)));
}
@@ -109,6 +109,11 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
/// <param name="value">The value to encode.</param>
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
/// <summary>Encodes a whole typed array (array writes); stored as <see cref="ArrayValue"/>.</summary>
/// <param name="type">The AbLegacy element type.</param>
/// <param name="values">The typed elements.</param>
public virtual void EncodeArray(AbLegacyDataType type, Array values) => ArrayValue = values;
/// <summary>Disposes the tag.</summary>
public virtual void Dispose() => Disposed = true;
}
@@ -0,0 +1,104 @@
using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
/// <summary>
/// An in-test TCP forwarder in front of the S7 fixture that can be FROZEN: while frozen it keeps
/// every socket open but forwards nothing in either direction — the frozen-but-established peer
/// (cable pull, firewall DROP) that only a wire-op deadline can detect, produced without touching
/// the shared fixture. It counts the ISO-on-TCP (TPKT) request frames the client sends while frozen,
/// so a test can assert how many wire operations went out without timing anything.
/// </summary>
internal sealed class FreezableTcpProxy : IAsyncDisposable
{
private readonly string _targetHost;
private readonly int _targetPort;
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _cts = new();
private readonly List<TcpClient> _sockets = [];
private volatile bool _frozen;
private int _framesWhileFrozen;
/// <summary>Starts a proxy forwarding to <paramref name="targetHost"/>:<paramref name="targetPort"/>.</summary>
/// <param name="targetHost">The fixture host.</param>
/// <param name="targetPort">The fixture port.</param>
public FreezableTcpProxy(string targetHost, int targetPort)
{
_targetHost = targetHost;
_targetPort = targetPort;
_listener.Start();
_ = AcceptLoopAsync();
}
/// <summary>The local port clients connect to.</summary>
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
/// <summary>TPKT request frames the client sent while the proxy was frozen.</summary>
public int FramesWhileFrozen => Volatile.Read(ref _framesWhileFrozen);
/// <summary>Stops forwarding (sockets stay open).</summary>
public void Freeze() => _frozen = true;
/// <summary>Resumes forwarding for connections made from now on.</summary>
public void Unfreeze() => _frozen = false;
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
var upstream = new TcpClient();
await upstream.ConnectAsync(_targetHost, _targetPort, _cts.Token).ConfigureAwait(false);
lock (_sockets) { _sockets.Add(client); _sockets.Add(upstream); }
// A connection accepted while frozen stays frozen for its whole life (its peer is "gone").
var frozenAtBirth = _frozen;
_ = PumpAsync(client.GetStream(), upstream.GetStream(), countFrames: true, frozenAtBirth);
_ = PumpAsync(upstream.GetStream(), client.GetStream(), countFrames: false, frozenAtBirth);
}
}
catch { /* disposed */ }
}
private async Task PumpAsync(NetworkStream from, NetworkStream to, bool countFrames, bool frozenAtBirth)
{
var buffer = new byte[8192];
var pending = new List<byte>();
try
{
while (!_cts.IsCancellationRequested)
{
var n = await from.ReadAsync(buffer, _cts.Token).ConfigureAwait(false);
if (n == 0) return;
var frozen = _frozen || frozenAtBirth;
if (countFrames && frozen)
{
// Count complete TPKT frames: 0x03 0x00 then a big-endian total length.
pending.AddRange(buffer.AsSpan(0, n).ToArray());
while (pending.Count >= 4)
{
var len = (pending[2] << 8) | pending[3];
if (len < 4 || pending.Count < len) break;
pending.RemoveRange(0, len);
Interlocked.Increment(ref _framesWhileFrozen);
}
}
if (frozen) continue; // swallow — the peer never answers
await to.WriteAsync(buffer.AsMemory(0, n), _cts.Token).ConfigureAwait(false);
}
}
catch { /* socket closed / disposed */ }
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
_cts.Cancel();
_listener.Stop();
lock (_sockets) foreach (var s in _sockets) s.Dispose();
_cts.Dispose();
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,71 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:P1 over real S7comm: a frozen-but-established peer (produced by an in-test
/// <see cref="FreezableTcpProxy"/>, so the shared fixture is never touched). The first wire op of
/// the pass blows its deadline and marks the connection dead; the rest of the pass must NOT go to
/// the wire. The assertion counts request frames, not seconds.
/// </summary>
/// <remarks>
/// <b>What this gate does and does not prove.</b> Verified by mutation (2026-10-22): on the real
/// stack this test also passes with the driver's dead-handle guard removed, because S7.Net 0.20
/// honours the deadline's cancellation token and closes its own socket, so the next op fails at once
/// with <c>ConnectionError</c> and sends nothing. The driver guard (proven by
/// <c>S7DeadHandleShortCircuitTests</c> against a fake that does not self-close) is what covers an
/// op that ignores its token — the <c>WaitAsync</c> leg of <c>S7OperationDeadline</c>, where the
/// abandoned socket stays open and every further op would pay the full deadline. This test pins the
/// end-to-end property (one request per frozen pass, then recovery) against S7.Net upgrades.
/// </remarks>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500FrozenPeerTests(Snap7ServerFixture sim)
{
/// <summary>A pass against a frozen peer issues ONE request, then fails the rest fast; it recovers after.</summary>
[Fact]
public async Task A_frozen_peer_costs_one_wire_op_per_pass_not_one_per_tag()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await using var proxy = new FreezableTcpProxy(sim.Host, sim.Port);
var baseOptions = S7_1500Profile.BuildOptions("127.0.0.1", proxy.Port);
var options = new S7DriverOptions
{
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
Rack = baseOptions.Rack, Slot = baseOptions.Slot,
Timeout = TimeSpan.FromSeconds(1), // the per-op deadline that detects the frozen peer
Probe = baseOptions.Probe,
SpanReadMaxGapBytes = -1, // one wire request per tag, so "one per tag" is countable
RawTags = baseOptions.RawTags,
};
string[] refs =
[
S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeI32Tag,
S7_1500Profile.SmokeF32Tag, S7_1500Profile.SmokeBoolTag, S7_1500Profile.WriteScratchTag,
];
await using var drv = new S7Driver(options, driverInstanceId: "s7-frozen-peer-live");
await drv.InitializeAsync("{}", ct);
// Positive control: through the proxy, every tag reads Good.
(await drv.ReadAsync(refs, ct)).ShouldAllBe(s => s.StatusCode == 0u);
proxy.Freeze();
var frozen = await drv.ReadAsync(refs, ct);
frozen.ShouldAllBe(s => s.StatusCode != 0u);
proxy.FramesWhileFrozen.ShouldBe(1, "only the request that discovered the dead connection may go out");
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
// Recovery: the next pass reopens (a fresh proxy connection) and reads Good again.
proxy.Unfreeze();
var recovered = await drv.ReadAsync(refs, ct);
recovered.ShouldAllBe(s => s.StatusCode == 0u);
Convert.ToInt32(recovered[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -0,0 +1,85 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:H4 — a real CPU refusal over real S7comm. The snap7 server has no DB99, so it
/// answers a read of <c>DB99.DBW0</c> with an S7 item error. That is a per-tag Bad with no health
/// impact (and LastSuccessfulRead advances), not a Degraded that would trip the comms-loss watchdog
/// against a reachable PLC.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500RefusalTests(Snap7ServerFixture sim)
{
/// <summary>A refused DB read is per-tag Bad, keeps the driver Healthy, and its neighbour reads Good.</summary>
[Fact]
public async Task A_read_of_a_missing_DB_is_refused_per_tag_without_degrading()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var options = new S7DriverOptions
{
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
Probe = baseOptions.Probe,
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
new S7TagDefinition("Missing/Db99", "DB99.DBW0", S7DataType.UInt16, Writable: false))],
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-live");
await drv.InitializeAsync("{}", ct);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
// Let the wall clock move past the init stamp so "advanced" is asserted strictly (review m-1).
while (DateTime.UtcNow <= before) await Task.Yield();
// Read the refused tag ALONE first: an all-refused pass is exactly the case that used to
// freeze LastSuccessfulRead and latch Degraded.
var refused = await drv.ReadAsync(["Missing/Db99"], ct);
refused[0].StatusCode.ShouldNotBe(0u);
refused[0].Value.ShouldBeNull();
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy, "a CPU refusal is not a comms fault");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before, "the refusal is an answer: the stamp advances");
// Positive control: the same connection still serves the seeded neighbour.
var good = await drv.ReadAsync([S7_1500Profile.ProbeTag], ct);
good[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(good[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
}
/// <summary>A refused write is per-tag Bad and keeps the driver Healthy; a good write still lands.</summary>
[Fact]
public async Task A_write_to_a_missing_DB_is_refused_per_tag_without_degrading()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var options = new S7DriverOptions
{
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
Probe = baseOptions.Probe,
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
new S7TagDefinition("Missing/Db99W", "DB99.DBW0", S7DataType.UInt16, Writable: true))],
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-write-live");
await drv.InitializeAsync("{}", ct);
var refused = await drv.WriteAsync([new WriteRequest("Missing/Db99W", (ushort)5)], ct);
refused[0].StatusCode.ShouldNotBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a CPU write refusal is not a comms fault");
// Positive control: a write the CPU accepts still lands (and reads back).
var ok = await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)4711)], ct);
ok[0].StatusCode.ShouldBe(0u);
var back = await drv.ReadAsync([S7_1500Profile.WriteScratchTag], ct);
Convert.ToInt32(back[0].Value).ShouldBe(4711);
await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)0)], ct);
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:P2 over real S7comm. The fixture's M area is 256 bytes. <c>MW10</c> is seeded
/// (4097); <c>MD254</c> runs two bytes past the end of the area. With the gap budget opened wide
/// the planner merges them into ONE block <c>MB10..258</c>, which the CPU refuses — before the fix
/// that made the perfectly readable <c>MW10</c> Bad (and the driver Degraded) on every poll.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500SpanRefusalTests(Snap7ServerFixture sim)
{
/// <summary>The good neighbour of an over-running member keeps reading its seeded value, every pass.</summary>
[Fact]
public async Task A_member_past_the_area_end_does_not_spoil_its_span_neighbour()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = new S7DriverOptions
{
Host = sim.Host, Port = sim.Port, CpuType = S7CpuType.S71500,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
// Wide enough that MW10 and MD254 land in one planned span.
SpanReadMaxGapBytes = 256,
SpanReadMaxSpanBytes = 256,
RawTags = S7RawTags.Entries(
new S7TagDefinition("Mk/Word10", "MW10", S7DataType.UInt16, Writable: false),
new S7TagDefinition("Mk/PastEnd", "MD254", S7DataType.Int32, Writable: false)),
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-span-refusal-live");
await drv.InitializeAsync("{}", ct);
for (var pass = 0; pass < 3; pass++)
{
var r = await drv.ReadAsync(["Mk/Word10", "Mk/PastEnd"], ct);
r[0].StatusCode.ShouldBe(0u, $"pass {pass}: the in-range neighbour must be served");
Convert.ToInt32(r[0].Value).ShouldBe(4097);
r[1].StatusCode.ShouldNotBe(0u, "the over-running member is refused on its own");
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
}
}
}
@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:H3 — one mis-authored tag must not darken the PLC. Before the fix each of
/// these shapes threw at init, so the driver Faulted and served nothing (and the actor retried the
/// same init every 10 s forever). Now each is skipped on its own and the good tags keep reading
/// their seeded values over real S7comm.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500TagShapeSkipTests(Snap7ServerFixture sim)
{
/// <summary>Unservable tags are skipped per tag; the seeded neighbours still read Good.</summary>
[Fact]
public async Task Mis_authored_tags_are_skipped_and_the_rest_of_the_PLC_still_reads()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var good = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var bad = new[]
{
new S7TagDefinition("Bad/Typo", "DB1.DBZ0", S7DataType.Int16),
new S7TagDefinition("Bad/WideOnWord", "DB1.DBW0", S7DataType.Float64),
new S7TagDefinition("Bad/TimerAsInt", "T5", S7DataType.Int32, Writable: false),
new S7TagDefinition("Bad/OffsetBoolArray", "DB1.DBX50.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
};
var options = new S7DriverOptions
{
Host = good.Host, Port = good.Port, CpuType = good.CpuType, Rack = good.Rack, Slot = good.Slot,
Timeout = good.Timeout, Probe = good.Probe,
RawTags = [.. good.RawTags, .. S7RawTags.Entries(bad)],
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-shape-skip-live");
await drv.InitializeAsync("{}", ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetSkippedTags().ShouldNotBeNull().Sample.Select(s => s.RawPath)
.ShouldBe(bad.Select(b => b.Name), ignoreOrder: true);
var r = await drv.ReadAsync(
[S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeBoolTag, bad[0].Name], ct);
r[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
r[1].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[1].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
r[2].StatusCode.ShouldBe(0u);
Convert.ToBoolean(r[2].Value).ShouldBeTrue();
r[3].StatusCode.ShouldBe(0x80340000u, "a skipped tag reads BadNodeIdUnknown");
}
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>An <see cref="ILogger{T}"/> that records (level, message) pairs.</summary>
internal sealed class CapturingS7Logger : ILogger<S7Driver>
{
private readonly List<(LogLevel Level, string Message)> _entries = [];
/// <summary>A snapshot of every entry logged so far.</summary>
public IReadOnlyList<(LogLevel Level, string Message)> Entries
{
get { lock (_entries) return [.. _entries]; }
}
/// <summary>The Warning messages logged so far.</summary>
public IReadOnlyList<string> Warnings
=> [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (_entries) _entries.Add((logLevel, formatter(state, exception)));
}
}
@@ -243,7 +243,9 @@ public sealed class S7ArrayWriteAndHealthTests
var (drv, factory) = await TwoTagDriver();
using var _ = drv;
factory.Created[0].WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed");
factory.Created[0].WriteFaults["DB1.DBW2"] = new IOException("socket glitch");
// A Degraded failure that is NOT connection-fatal: since R07:P1 a fatal one (an IOException)
// would fail the rest of the batch without I/O, and C must reach the wire to be the success.
factory.Created[0].WriteFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "device glitch");
var res = await drv.WriteAsync(
[new WriteRequest("A", (ushort)1), new WriteRequest("B", (ushort)2), new WriteRequest("C", (ushort)3)],
@@ -0,0 +1,167 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:P1 — once a pass has marked the connection dead (a wire op blew its deadline on a frozen
/// peer), the rest of that pass must not go to the wire: each further op would pay the full
/// timeout under the gate, so a 20-span pass took ~100 s and the comms-loss watchdog's clock only
/// started after it. The remaining items are stamped <c>BadCommunicationError</c> without I/O.
/// Every assertion counts wire operations; nothing is timed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DeadHandleShortCircuitTests
{
private const uint BadCommunicationError = 0x80050000u;
private static S7DriverOptions Options(int maxGap, params S7TagDefinition[] tags) => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
SpanReadMaxGapBytes = maxGap,
RawTags = S7RawTags.Entries(tags),
};
/// <summary>Five isolated words — far enough apart that each is its own span.</summary>
private static S7TagDefinition[] FarApartWords() =>
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: true))];
private static string[] Refs(int n) => [.. Enumerable.Range(0, n).Select(i => $"W{i}")];
private static async Task<(S7Driver, ScriptedS7PlcFactory)> StartAsync(S7DriverOptions options)
{
var factory = new ScriptedS7PlcFactory();
var drv = new S7Driver(options, "s7-dead-short-circuit", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (drv, factory);
}
/// <summary>Per-tag path: the first timeout ends the pass's wire traffic.</summary>
[Fact]
public async Task Per_tag_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
using var _ = drv;
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(1, "only the op that discovered the dead handle may go to the wire");
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
// Positive control: the next pass reopens and serves every tag.
factory.Read = (_, _) => Task.FromResult<object?>((ushort)1);
var recovered = await drv.ReadAsync(Refs(5), ct);
recovered.ShouldAllBe(s => s.StatusCode == 0u);
factory.Created.Count.ShouldBe(2);
}
/// <summary>Span path: arrays are block reads even alone, so five arrays are five spans.</summary>
[Fact]
public async Task Span_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
{
var ct = TestContext.Current.CancellationToken;
S7TagDefinition[] arrays =
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 2))];
var (drv, factory) = await StartAsync(Options(maxGap: 16, arrays));
using var _ = drv;
factory.ReadBytes = (_, _, _, _, _) => throw new TimeoutException("frozen peer");
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(1);
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
}
/// <summary>
/// Multi-var path: a chunk that dies on a transport loss used to fall back to one per-tag read
/// per member — each paying the timeout again. Now it marks the handle dead and the fallback is
/// short-circuited.
/// </summary>
[Fact]
public async Task Multi_var_transport_loss_does_not_fall_back_to_per_tag_wire_reads()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
using var _ = drv;
factory.ReadMulti = (_, _) => throw new TimeoutException("frozen peer");
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(1);
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// Positive control for the classification: a multi-var REFUSAL (not a transport loss) still
/// falls back per tag, so the members the CPU will serve are served.
/// </summary>
[Fact]
public async Task Multi_var_refusal_still_falls_back_per_tag()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
using var _ = drv;
// The live snap7 shape of a multi-var with one refused item.
factory.ReadMulti = (_, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes));
factory.Read = (_, _) => Task.FromResult<object?>((ushort)9);
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(6, "one multi-var request, then one per-tag read each");
r.ShouldAllBe(s => s.StatusCode == 0u);
factory.Created.Count.ShouldBe(1, "a refusal is not a reason to reopen");
}
/// <summary>A refusal does not short-circuit the pass: the CPU answered, the handle is fine.</summary>
[Fact]
public async Task A_refusal_does_not_short_circuit_the_pass()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
using var _ = drv;
factory.Read = (addr, _) => addr == "DB1.DBW0"
? throw new Exception("Received error from PLC: Object does not exist.")
: Task.FromResult<object?>((ushort)3);
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(5);
r.Skip(1).ShouldAllBe(s => s.StatusCode == 0u);
}
/// <summary>Write batch: the first transport loss fails the rest of the batch without I/O.</summary>
[Fact]
public async Task Write_batch_stops_issuing_wire_ops_once_the_handle_is_dead()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
using var _ = drv;
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
var before = factory.WireOps;
var w = await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct);
(factory.WireOps - before).ShouldBe(1);
w.ShouldAllBe(x => x.StatusCode == BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
// Positive control: the next batch reopens and writes.
factory.Write = (_, _, _) => Task.CompletedTask;
(await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct))
.ShouldAllBe(x => x.StatusCode == 0u);
}
}
@@ -25,26 +25,10 @@ public sealed class S7DriverCodeReviewFixTests
[InlineData("C10")]
public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address)
{
// A Timer/Counter address parses cleanly but the read path has no decode case for it,
// so it must fail fast at init rather than throw a misleading type-mismatch on every
// read. The host is reserved-for-documentation so the TCP connect can never succeed —
// the unsupported-address guard runs before the connect, so the NotSupportedException
// is what surfaces.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
};
using var drv = new S7Driver(opts, "s7-tc");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(address);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver");
health.LastError.ShouldNotBeNull();
// A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded.
// It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC.
await S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address);
}
/// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary>
@@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests
[Fact]
public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson()
{
// The Timer/Counter guard must run against the *re-parsed* config, not just the
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly.
// The shape gate must run against the *re-parsed* config, not just the constructor options —
// proves the per-tag skip and Driver.S7-011 compose correctly.
var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc");
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc", new ScriptedS7PlcFactory());
const string json = """
{ "Host": "192.0.2.1", "TimeoutMs": 250,
{ "Host": "192.0.2.1", "TimeoutMs": 250, "Probe": { "Enabled": false },
"RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
""";
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("T5");
await drv.InitializeAsync(json, TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem();
skipped.RawPath.ShouldBe("TimerTag");
skipped.Reason.ShouldContain("T5");
}
/// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary>
@@ -158,23 +158,9 @@ public sealed class S7DriverCodeReviewFixTests2
[InlineData(S7DataType.DateTime)]
public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
// DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape
// for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip;
// see S7ScalarBlockTests.)
RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
};
using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(dt.ToString());
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
// DB1.DBD0 is a DWord (non-byte) address — a wide type there would mis-frame its value. (At a
// byte address like DB1.DBB0 the 8-byte numerics round-trip; see S7ScalarBlockTests.)
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("X", "DB1.DBD0", dt), dt.ToString(), "byte-addressed");
}
/// <summary>Verifies that Initialize accepts implemented data types.</summary>
@@ -17,22 +17,11 @@ public sealed class S7DriverReadWriteTests
[Fact]
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
{
// Bad address at init time must throw; the alternative (deferring the parse to the
// first read) would surface the config bug as BadInternalError on every subsequent
// Read which is impossible for an operator to diagnose from the OPC UA client.
var opts = new S7DriverOptions
{
Host = "192.0.2.1", // reserved — will never complete TCP handshake
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)),
};
using var drv = new S7Driver(opts, "s7-bad-tag");
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
// — both are acceptable since both are init-time fail-fast. What matters is that we
// don't return a "healthy" driver with a latent bad tag.
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
// A bad address is refused PER TAG at every gate (mapper/editor, deploy gate, driver init):
// it reads BadNodeIdUnknown while its neighbours keep working. It used to throw at init and
// fault every tag on the PLC, with the actor retrying the same init every 10 s forever.
await S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16), "does not parse");
}
/// <summary>Verifies that ReadAsync without initialize throws InvalidOperationException.</summary>
@@ -101,80 +101,32 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_wide_type_array_with_clear_message()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)),
};
using var drv = new S7Driver(opts, "s7-wide-array");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Batch64");
ex.Message.ShouldContain("array", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4), "Int64", "array");
}
/// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary>
[Fact]
public async Task Initialize_rejects_wide_scalar_with_non_byte_address()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)),
};
using var drv = new S7Driver(opts, "s7-wide-nonbyte");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("WideWord");
ex.Message.ShouldContain("byte", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64), "Float64", "byte-addressed");
}
/// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary>
[Fact]
public async Task Initialize_rejects_Timer_tag_with_wrong_data_type()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)),
};
using var drv = new S7Driver(opts, "s7-timer-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Timer5");
ex.Message.ShouldContain("Float64");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Int32, Writable: false), "T5", "Float64");
}
/// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary>
[Fact]
public async Task Initialize_rejects_Counter_tag_with_wrong_data_type()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)),
};
using var drv = new S7Driver(opts, "s7-counter-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Counter3");
ex.Message.ShouldContain("Int32");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Float32, Writable: false), "C3", "Int32");
}
// ── Phase 4d bundle-review fix — Writable Timer/Counter guard ───────────────────────
@@ -188,42 +140,16 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_Timer_tag_declared_Writable()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)),
};
using var drv = new S7Driver(opts, "s7-timer-writable");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Timer5");
ex.Message.ShouldContain("Writable", Case.Insensitive);
ex.Message.ShouldContain("read-only", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true), "T5", "writable", "read-only");
}
/// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary>
[Fact]
public async Task Initialize_rejects_Counter_tag_declared_Writable()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)),
};
using var drv = new S7Driver(opts, "s7-counter-writable");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Counter3");
ex.Message.ShouldContain("Writable", Case.Insensitive);
ex.Message.ShouldContain("read-only", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true), "C3", "writable", "read-only");
}
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
@@ -236,20 +162,8 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4)),
};
using var drv = new S7Driver(opts, "s7-writable-bool-arr");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("ArrTag");
ex.Message.ShouldContain("Bool array", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4), "Bool array", "writable");
}
/// <summary>Verifies that writable non-bool arrays pass the init guard — the failure
@@ -0,0 +1,206 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H5 — overlapping lifecycle calls. <c>DriverInstanceActor</c> dispatches initialize on a
/// fire-and-forget task and a reconnect retry can start a second attempt while the first is still in
/// <c>OpenAsync</c>; an abandoned initialize can also still be running when the next shutdown starts.
/// Every wait here is a condition wait on the fake's own signals, never a sleep.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7LifecycleGateTests
{
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10);
private static S7DriverOptions Options(bool probe = false) => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(30), // the fake's gated open is released by the test, never timed out
Probe = new S7ProbeOptions { Enabled = probe, Interval = TimeSpan.FromSeconds(30) },
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
};
/// <summary>A factory whose FIRST open blocks until released; later opens complete immediately.</summary>
private sealed class FirstOpenBlocks
{
public readonly ScriptedS7PlcFactory Factory = new();
public readonly TaskCompletionSource FirstOpenEntered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public readonly TaskCompletionSource ReleaseFirstOpen = new(TaskCreationOptions.RunContinuationsAsynchronously);
public FirstOpenBlocks()
{
Factory.Open = async (index, ct) =>
{
if (index != 0) return;
FirstOpenEntered.TrySetResult();
await ReleaseFirstOpen.Task.WaitAsync(ct).ConfigureAwait(false);
};
}
}
/// <summary>
/// A second initialize waits for the first instead of running beside it, then releases the first
/// attempt's connection: exactly one live connection remains. Before the gate, attempt B opened
/// its own connection while A was still in <c>OpenAsync</c>, and A's published connection (and
/// probe loop) was overwritten and never released.
/// </summary>
[Fact]
public async Task Overlapping_initializes_are_serialised_and_leave_one_live_connection()
{
var ct = TestContext.Current.CancellationToken;
var f = new FirstOpenBlocks();
using var drv = new S7Driver(Options(), "s7-overlap", f.Factory);
var a = drv.InitializeAsync("{}", ct);
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
var b = drv.InitializeAsync("{}", ct);
// Positive control first: B is genuinely pending (not finished, not faulted)...
b.IsCompleted.ShouldBeFalse();
// ...and while A holds the gate B has not created a connection of its own. B can only reach
// its open after A completes, so this is not a race the test can lose by being slow.
f.Factory.Created.Count.ShouldBe(1, "a second initialize must not open beside the first");
f.ReleaseFirstOpen.SetResult();
await a.WaitAsync(Bound, ct);
await b.WaitAsync(Bound, ct);
f.Factory.Created.Count.ShouldBe(2);
f.Factory.Created[0].Disposed.ShouldBeTrue("B releases A's published connection before replacing it");
f.Factory.Created[1].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
}
/// <summary>
/// An initialize still running when a shutdown gives up waiting for it must not publish its
/// connection, its health or its probe loop over the shut-down driver; it fails and disposes what
/// it opened. Before the fix the late attempt published Healthy + a live connection after the
/// shutdown returned.
/// </summary>
[Fact]
public async Task An_initialize_superseded_by_shutdown_publishes_nothing()
{
var ct = TestContext.Current.CancellationToken;
var f = new FirstOpenBlocks();
using var drv = new S7Driver(Options(probe: true), "s7-superseded", f.Factory)
{
LifecycleGateTimeout = TimeSpan.FromSeconds(1),
};
var init = drv.InitializeAsync("{}", ct);
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
// The shutdown cannot get the lifecycle gate (the attempt holds it), gives up after its bound
// and tears down anyway.
await drv.ShutdownAsync(ct).WaitAsync(Bound, ct);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// The abandoned attempt now completes its open...
f.ReleaseFirstOpen.SetResult();
var ex = await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
ex.Message.ShouldContain("superseded");
// ...and published nothing: no connection kept, health untouched, the driver still shut down.
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded attempt must not write Healthy or Faulted");
await Should.ThrowAsync<InvalidOperationException>(() => drv.ReadAsync(["W0"], ct));
// Positive control: the driver is not wedged — a fresh initialize publishes normally.
await drv.InitializeAsync("{}", ct).WaitAsync(Bound, ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
}
/// <summary>
/// Review m-4: a retained poll that runs into a re-initialize in progress ("not initialized")
/// must not write Degraded over the new attempt's Initializing. Waits on the poll's own log line
/// (a counted signal), then asserts; the control is that a successful init then reports Healthy.
/// </summary>
[Fact]
public async Task A_retained_poll_does_not_overwrite_a_reinitialize_in_progress()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory();
var secondOpenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseSecondOpen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
factory.Open = async (index, token) =>
{
if (index != 1) return;
secondOpenEntered.TrySetResult();
await releaseSecondOpen.Task.WaitAsync(token);
};
var log = new CapturingS7Logger();
using var drv = new S7Driver(Options(), "s7-retained-poll", factory, log);
await drv.InitializeAsync("{}", ct);
await drv.SubscribeAsync(["W0"], TimeSpan.FromMilliseconds(100), ct);
var reinit = drv.InitializeAsync("{}", ct);
await secondOpenEntered.Task.WaitAsync(Bound, ct);
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
cts.CancelAfter(Bound);
while (log.Entries.Count(e => e.Message.Contains("during a lifecycle operation")) < 2)
await Task.Delay(20, cts.Token); // condition wait on the poll's counted log line
}
drv.GetHealth().State.ShouldBe(DriverState.Initializing, "a retained poll must not write over the new attempt");
releaseSecondOpen.SetResult();
await reinit.WaitAsync(Bound, ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// Review m-8: the SYNCHRONOUS <c>Dispose()</c> racing a still-running <c>ShutdownAsync</c> is safe too
/// — neither throws, the superseded init publishes nothing, and the driver ends torn down.
/// </summary>
[Fact]
public async Task Sync_dispose_while_a_shutdown_is_still_running_is_safe()
{
var ct = TestContext.Current.CancellationToken;
var f = new FirstOpenBlocks();
var drv = new S7Driver(Options(), "s7-sync-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
var init = drv.InitializeAsync("{}", ct);
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds
Should.NotThrow(() => drv.Dispose());
await shutdown.WaitAsync(Bound, ct);
f.ReleaseFirstOpen.SetResult();
await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>
/// Dispose racing a shutdown that is still running is safe: neither throws, and the driver ends
/// torn down. The semaphores are no longer disposed, so the running shutdown cannot hit an
/// ObjectDisposedException.
/// </summary>
[Fact]
public async Task Dispose_while_a_shutdown_is_still_running_is_safe()
{
var ct = TestContext.Current.CancellationToken;
var f = new FirstOpenBlocks();
var drv = new S7Driver(Options(), "s7-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
var init = drv.InitializeAsync("{}", ct);
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds
var dispose = drv.DisposeAsync().AsTask();
await shutdown.WaitAsync(Bound, ct);
await dispose.WaitAsync(Bound, ct);
f.ReleaseFirstOpen.SetResult();
await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
}
@@ -0,0 +1,121 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H7 — (a) the background probe used to report the host <c>Stopped</c> whenever a long read
/// pass held the gate past the probe deadline: contention is the connection being USED, not lost;
/// (b) Test Connect ignored the config's <c>timeoutMs</c>. Waits are condition waits on counters and
/// events; bounds are generous.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ProbeContentionAndTimeoutTests
{
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(15);
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(Bound);
while (!condition())
await Task.Delay(20, cts.Token); // polling a condition, not asserting on elapsed time
}
/// <summary>A read pass holding the gate past the probe deadline does not make the host Stopped.</summary>
[Fact]
public async Task Gate_contention_is_no_verdict_but_a_real_probe_failure_still_is()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory();
var readEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(30),
Probe = new S7ProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(1) },
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
}, "s7-probe-contention", factory);
var transitions = new List<HostState>();
drv.OnHostStatusChanged += (_, e) => { lock (transitions) transitions.Add(e.NewState); };
await drv.InitializeAsync("{}", ct);
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Running, ct);
// A read that holds the gate until released.
factory.Read = async (_, token) =>
{
readEntered.TrySetResult();
await releaseRead.Task.WaitAsync(token);
return (ushort)1;
};
var read = drv.ReadAsync(["W0"], ct);
await readEntered.Task.WaitAsync(Bound, ct);
// At least two probe ticks give up on the gate...
var contentionsBefore = drv.ProbeGateContentions;
await WaitUntilAsync(() => drv.ProbeGateContentions >= contentionsBefore + 2, ct);
// ...and the host is still Running — no Stopped transition was published for them.
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
lock (transitions) transitions.ShouldNotContain(HostState.Stopped);
releaseRead.SetResult();
(await read.WaitAsync(Bound, ct))[0].StatusCode.ShouldBe(0u);
// Positive control: a genuine probe failure still reports Stopped.
factory.ReadStatus = _ => throw new System.IO.IOException("connection reset");
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Stopped, ct);
}
/// <summary>Test Connect's handshake is bounded by the config's timeoutMs, not the caller's whole budget.</summary>
[Fact]
public async Task Test_connect_honours_the_config_timeoutMs()
{
var ct = TestContext.Current.CancellationToken;
// A TCP endpoint that accepts and then never answers the ISO-on-TCP handshake.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var held = new List<TcpClient>();
_ = Task.Run(async () =>
{
try { while (true) { var c = await listener.AcceptTcpClientAsync(ct); lock (held) held.Add(c); } }
catch { /* stopped */ }
}, ct);
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var json = $$"""{"host":"127.0.0.1","port":{{port}},"timeoutMs":1000}""";
// The caller's budget (60 s) is far beyond the safety token (20 s): only a probe that honours
// timeoutMs returns its own "handshake failed: timed out"; one that waits the caller's budget
// is cut by the safety token and reports the caller-timeout message instead.
using var safety = CancellationTokenSource.CreateLinkedTokenSource(ct);
safety.CancelAfter(TimeSpan.FromSeconds(20));
var r = await new S7DriverProbe().ProbeAsync(json, TimeSpan.FromSeconds(60), safety.Token);
r.Ok.ShouldBeFalse();
r.Message!.ShouldContain("handshake failed: timed out");
safety.IsCancellationRequested.ShouldBeFalse();
}
finally
{
listener.Stop();
lock (held) foreach (var c in held) c.Dispose();
}
}
/// <summary>The config reader: any casing, positive integers only.</summary>
[Fact]
public void ReadTimeoutMs_reads_the_driver_key()
{
S7DriverProbe.ReadTimeoutMs("""{"TimeoutMs":2500}""").ShouldBe(2500);
S7DriverProbe.ReadTimeoutMs("""{"timeoutms":10}""").ShouldBe(10);
S7DriverProbe.ReadTimeoutMs("""{"timeoutMs":0}""").ShouldBeNull();
S7DriverProbe.ReadTimeoutMs("""{"host":"x"}""").ShouldBeNull();
S7DriverProbe.ReadTimeoutMs("not json").ShouldBeNull();
}
}
@@ -0,0 +1,293 @@
using S7.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H4 — a deterministic PLC refusal (S7.Net <c>ReadData</c> / <c>WriteData</c> carrying an S7
/// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU
/// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances
/// <c>LastSuccessfulRead</c>, so a driver whose every tag is refused (wrong DB number) no longer
/// trips the 90 s comms-loss watchdog against a reachable PLC.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7RefusalHealthTests
{
private const uint BadDeviceFailure = 0x808B0000u;
/// <summary>
/// The shape S7.Net's READ paths really raise for a CPU item error — a BARE
/// <see cref="Exception"/>, live-probed against the snap7 fixture (not a PlcException).
/// </summary>
private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist.");
private static PlcException WriteRefusal() =>
new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range."));
private static S7DriverOptions Options() => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
// Grouping off: one wire request per tag keeps each tag's outcome its own.
SpanReadMaxGapBytes = -1,
RawTags = S7RawTags.Entries(
new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16),
new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)),
};
/// <summary>A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY.</summary>
private sealed class PassClock
{
public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Moves the clock one second on and returns the new time.</summary>
public DateTime Step() => Now = Now.AddSeconds(1);
}
private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log, PassClock Clock)> StartAsync()
{
var factory = new ScriptedS7PlcFactory();
var log = new CapturingS7Logger();
var clock = new PassClock();
var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now };
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock");
return (drv, factory, log, clock);
}
/// <summary>A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once.</summary>
[Fact]
public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, log, clock) = await StartAsync();
using var _ = drv;
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
for (var pass = 0; pass < 3; pass++)
{
var at = clock.Step();
var r = await drv.ReadAsync(["Missing"], ct);
r[0].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
drv.GetHealth().LastSuccessfulRead.ShouldBe(at,
"the CPU answered, so the all-refused pass advances LastSuccessfulRead to the pass time");
}
log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll");
// Positive control: the good tag still reads, and nothing reopened the connection.
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u);
factory.Created.Count.ShouldBe(1);
}
/// <summary>
/// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the
/// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the
/// watchdog's trip condition.
/// </summary>
[Fact]
public async Task A_refusal_after_a_comms_fault_restores_healthy()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
// Positive control: a genuine comms fault DOES degrade, and leaves LastSuccessfulRead frozen.
var initStamp = clock.Now;
clock.Step();
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Missing"], ct);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
degraded.LastSuccessfulRead.ShouldBe(initStamp);
var at = clock.Step();
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
var r = await drv.ReadAsync(["Missing"], ct);
r[0].StatusCode.ShouldBe(BadDeviceFailure);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Healthy);
after.LastSuccessfulRead.ShouldBe(at);
}
/// <summary>The ReadData-wrapped shape of a refusal is classified the same way.</summary>
[Fact]
public async Task A_read_data_wrapped_refusal_is_also_a_refusal()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal());
(await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert.
/// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read
/// refusal fell through to BadCommunicationError + Degraded.
/// </summary>
[Fact]
public async Task A_bare_access_denied_read_is_the_faulted_config_alert()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.GetHealth().LastError!.ShouldContain("PUT/GET");
}
/// <summary>
/// Review I-1: an access-denied CPU (PUT/GET left at its factory default — EVERY read denied) is
/// reachable and answering, so each pass advances LastSuccessfulRead while staying Faulted.
/// Before, the pass stayed Faulted with the stamp frozen — the comms-loss watchdog's trip
/// condition, so the actor reconnected + re-subscribed every 90 s, forever, which can never fix
/// a PUT/GET setting. The control: a comms fault afterwards leaves the stamp frozen.
/// </summary>
[Fact]
public async Task An_all_access_denied_pass_stays_faulted_but_advances_last_successful_read()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
DateTime last = default;
for (var pass = 0; pass < 2; pass++)
{
last = clock.Step();
await drv.ReadAsync(["Good", "Missing"], ct);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Faulted, "the config alert is kept");
h.LastSuccessfulRead.ShouldBe(last, "the CPU answered, so the stamp advances every pass");
}
// Positive control: a real comms loss does NOT advance it (and the sticky Faulted outranks it).
clock.Step();
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().LastSuccessfulRead.ShouldBe(last);
}
/// <summary>The write-side access-denied is an answer too, and its sticky Faulted still clears only on a real success.</summary>
[Fact]
public async Task A_write_access_denied_advances_last_successful_read_and_stays_sticky()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
new Exception("Received error from PLC: Accessing object not allowed."));
var at = clock.Step();
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode.ShouldBe(0x803D0000u);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.GetHealth().LastSuccessfulRead.ShouldBe(at);
// A clean read leaves the write-side Faulted in place (sticky) …
clock.Step();
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// … and a real write success clears it.
factory.Write = (_, _, _) => Task.CompletedTask;
clock.Step();
await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// Review m-7: a transport fault whose text happens to contain "not allowed" reopens (Degraded),
/// and only S7.Net's exact "Accessing object not allowed" is the access-denied alert.
/// </summary>
[Fact]
public async Task Only_the_exact_access_denied_wording_without_a_transport_fault_is_access_denied()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
clock.Step();
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
new System.IO.IOException("Accessing object not allowed: socket closed by peer"));
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode
.ShouldNotBe(0x803D0000u, "not the access-denied BadNotSupported");
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a transport fault is not a config alert");
S7Driver.IsAccessDenied(new PlcException(ErrorCode.WriteData, new Exception("operation not allowed"))).ShouldBeFalse();
S7Driver.IsAccessDenied(new Exception("Received error from PLC: Accessing object not allowed.")).ShouldBeTrue();
}
/// <summary>A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal.</summary>
[Fact]
public async Task A_read_data_wrapping_a_socket_fault_still_degrades()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset"));
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>A write refusal is per-tag Bad, counts as answered and leaves health alone.</summary>
[Fact]
public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, log, clock) = await StartAsync();
using var _ = drv;
factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask;
// Put the driver into a comms-fault Degraded first; the refusal must lift it.
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Good"], ct);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
for (var i = 0; i < 2; i++)
{
var at = clock.Step();
var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct);
w[0].StatusCode.ShouldBe(BadDeviceFailure);
var h = drv.GetHealth();
h.LastSuccessfulRead.ShouldBe(at, "a write refusal is an answer: the stamp advances strictly");
// The Degraded was written by the READ side (its own message), so it is the read pass's to clear.
h.State.ShouldBe(DriverState.Degraded);
}
var after = drv.GetHealth();
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value);
log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1);
}
/// <summary>
/// A good write clears a Degraded the write side wrote. Before the fix a success-only batch
/// never wrote health, so a transient write fault latched Degraded until some read pass ran.
/// </summary>
[Fact]
public async Task A_good_write_clears_a_write_side_degraded()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var _ = drv;
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
bad[0].StatusCode.ShouldNotBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades");
factory.Write = (_, _, _) => Task.CompletedTask;
var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct);
good[0].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -202,9 +202,10 @@ public sealed class S7SpanReadParityTests
public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path()
{
var ct = TestContext.Current.CancellationToken;
// ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine
// device-layer fault, which the driver maps to BadDeviceFailure + Degraded.
var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
// A device-layer fault that is neither a refusal (a ReadData item error is per-tag Bad with
// no health impact since R07:H4, and a refused span falls back per member since R07:P2) nor
// connection-fatal — the driver maps it to BadDeviceFailure + Degraded on every member.
var fault = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
@@ -468,8 +469,11 @@ public sealed class S7SpanReadParityTests
}
/// <summary>
/// The SPAN path obeys the same rule: one member that fails to decode leaves the pass
/// <c>Degraded</c> even though a later member of the same block decoded fine.
/// The SPAN path guards each member's decode: one member that fails to decode is Bad on its
/// own while a later member of the same block decodes fine. S7.Net rejects the malformed
/// STRING with a <c>ReadData</c> <see cref="PlcException"/> — the CPU answered, the buffer is
/// what is wrong — so since R07:H4 it is a per-tag refusal with no Degraded (like the
/// codec's own <c>BadOutOfRange</c>), and LastSuccessfulRead still advances.
/// </summary>
/// <remarks>
/// The block read itself succeeds — this is the per-member decode guard, the span-path
@@ -480,7 +484,7 @@ public sealed class S7SpanReadParityTests
/// last-writer-wins used to erase.
/// </remarks>
[Fact]
public async Task One_undecodable_span_member_leaves_the_pass_Degraded()
public async Task One_undecodable_span_member_is_bad_alone_without_degrading()
{
var ct = TestContext.Current.CancellationToken;
// The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is
@@ -516,7 +520,7 @@ public sealed class S7SpanReadParityTests
results[1].Value.ShouldNotBeNull();
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded, "a span member that failed to decode must not be erased");
health.State.ShouldBe(DriverState.Healthy, "an undecodable buffer is a data problem, not a comms fault");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
}
@@ -542,7 +546,7 @@ public sealed class S7SpanReadParityTests
// which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded,
// observed AFTER it. C: no fault at all.
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
// maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the
// faults can be aimed at individual addresses.
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory);
@@ -584,7 +588,7 @@ public sealed class S7SpanReadParityTests
var factory = new ImageS7PlcFactory(Images());
// A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert
// (Faulted) after it. C: no fault at all.
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory);
await driver.InitializeAsync("{}", ct);
@@ -0,0 +1,213 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:P2 — a span's block is the union of its members' ranges, so ONE member that runs past the
/// DB's real end (or over-declares a STRING) made the CPU refuse the whole block and every
/// neighbour inside it went Bad on every poll. A refused span now re-reads its members one at a
/// time for that pass and remembers which tags must not be grouped, so later passes group only the
/// members that can be grouped.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7SpanRefusalFallbackTests
{
private const uint BadDeviceFailure = 0x808B0000u;
/// <summary>DB1 is 4 bytes long on this fake CPU: anything reaching past byte 4 is refused.</summary>
private const int DbLength = 4;
private static Exception OutOfRange() => new("Received error from PLC: Address out of range.");
private static (S7Driver Driver, ScriptedS7PlcFactory Factory, Func<int> BlockReads, Func<int> TagReads) Start(
params S7TagDefinition[] tags)
{
var factory = new ScriptedS7PlcFactory();
var blockReads = 0;
var tagReads = 0;
factory.ReadBytes = (_, _, start, count, _) =>
{
Interlocked.Increment(ref blockReads);
if (start + count > DbLength) throw OutOfRange();
var b = new byte[count];
for (var i = 0; i < count; i++) b[i] = (byte)(start + i + 1);
return Task.FromResult(b);
};
factory.Read = (addr, _) =>
{
Interlocked.Increment(ref tagReads);
var offset = int.Parse(addr[(addr.LastIndexOf('W') + 1)..], System.Globalization.CultureInfo.InvariantCulture);
if (offset + 2 > DbLength) throw OutOfRange();
return Task.FromResult<object?>((ushort)(((offset + 1) << 8) | (offset + 2)));
};
var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(tags),
}, "s7-span-fallback", factory);
return (drv, factory, () => Volatile.Read(ref blockReads), () => Volatile.Read(ref tagReads));
}
private static S7TagDefinition Word(string name, int offset) =>
new(name, $"DB1.DBW{offset}", S7DataType.UInt16, Writable: false);
/// <summary>
/// The over-running member is refused on its own; its neighbours are served from the very first
/// pass, and from the second pass on the neighbours are grouped again WITHOUT the bad member.
/// </summary>
[Fact]
public async Task A_refused_span_falls_back_per_member_and_stops_grouping_the_bad_member()
{
var ct = TestContext.Current.CancellationToken;
var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4));
using var _ = drv;
await drv.InitializeAsync("{}", ct);
string[] refs = ["A", "B", "C"];
// Pass 1: the 6-byte span is refused, then each member is read on its own.
var r1 = await drv.ReadAsync(refs, ct);
r1[0].StatusCode.ShouldBe(0u);
r1[0].Value.ShouldBe((ushort)0x0102);
r1[1].StatusCode.ShouldBe(0u);
r1[1].Value.ShouldBe((ushort)0x0304);
r1[2].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
blockReads().ShouldBe(1);
tagReads().ShouldBe(3);
// Pass 2: A+B ride ONE block read again (the planner no longer sees C); C reads alone.
var r2 = await drv.ReadAsync(refs, ct);
r2[0].Value.ShouldBe((ushort)0x0102);
r2[1].Value.ShouldBe((ushort)0x0304);
r2[2].StatusCode.ShouldBe(BadDeviceFailure);
blockReads().ShouldBe(2, "A+B grouped again, without the refused member");
tagReads().ShouldBe(4, "only C took the per-tag path on pass 2");
}
/// <summary>
/// When the block is refused but every member reads fine alone, the span itself is the problem:
/// all of its members stop being grouped (and stop re-trying the refused block every pass).
/// </summary>
[Fact]
public async Task A_refused_span_whose_members_all_read_alone_is_not_grouped_again()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory();
var blockReads = 0;
factory.ReadBytes = (_, _, _, _, _) => { Interlocked.Increment(ref blockReads); throw OutOfRange(); };
factory.Read = (_, _) => Task.FromResult<object?>((ushort)7);
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)),
}, "s7-span-fallback-all", factory);
await drv.InitializeAsync("{}", ct);
(await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u);
(await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u);
Volatile.Read(ref blockReads).ShouldBe(1, "the refused block is not re-issued on later passes");
}
/// <summary>
/// Positive control: a span failure that is NOT a refusal (a device-layer fault) does not fall
/// back — re-reading N members one by one against a failing device would only multiply the fault.
/// </summary>
[Fact]
public async Task A_non_refusal_span_failure_does_not_fall_back()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory
{
ReadBytes = (_, _, _, _, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.SendData, "device fault"),
};
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)),
}, "s7-span-nofallback", factory);
await drv.InitializeAsync("{}", ct);
var before = factory.WireOps;
var r = await drv.ReadAsync(["A", "B"], ct);
r.ShouldAllBe(s => s.StatusCode == BadDeviceFailure);
(factory.WireOps - before).ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// Review m-5, the multi-var sibling: a leftover chunk holding one refused scalar used to fall back
/// per tag on EVERY poll (1 + N requests). The member refused on its own is now memoised, so the
/// next pass batches the rest and reads only the bad one alone.
/// </summary>
[Fact]
public async Task A_refused_multi_var_chunk_memoises_only_the_member_refused_alone()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory();
var multiCalls = 0;
factory.ReadMulti = (items, _) =>
{
Interlocked.Increment(ref multiCalls);
if (items.Any(i => i.DB == 3))
// The live snap7 shape of a multi-var request with one refused item.
throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes));
foreach (var i in items) i.Value = (ushort)5;
return Task.FromResult<IReadOnlyList<global::S7.Net.Types.DataItem>>(items);
};
factory.Read = (addr, _) => addr.StartsWith("DB3.", StringComparison.Ordinal)
? throw new Exception("Received error from PLC: Object does not exist.")
: Task.FromResult<object?>((ushort)5);
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(
new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false),
new S7TagDefinition("B", "DB2.DBW0", S7DataType.UInt16, Writable: false),
new S7TagDefinition("Bad", "DB3.DBW0", S7DataType.UInt16, Writable: false)),
}, "s7-mv-memo", factory);
await drv.InitializeAsync("{}", ct);
string[] refs = ["A", "B", "Bad"];
var before = factory.WireOps;
var r1 = await drv.ReadAsync(refs, ct);
r1[0].StatusCode.ShouldBe(0u);
r1[1].StatusCode.ShouldBe(0u);
r1[2].StatusCode.ShouldBe(BadDeviceFailure);
(factory.WireOps - before).ShouldBe(4, "pass 1: one refused multi-var, then one per-tag read each");
before = factory.WireOps;
var r2 = await drv.ReadAsync(refs, ct);
r2[0].StatusCode.ShouldBe(0u);
r2[1].StatusCode.ShouldBe(0u);
r2[2].StatusCode.ShouldBe(BadDeviceFailure);
(factory.WireOps - before).ShouldBe(2, "pass 2: A+B ride one multi-var, Bad reads alone");
Volatile.Read(ref multiCalls).ShouldBe(2);
}
/// <summary>The memo is per generation: a re-initialize (e.g. a corrected DB) groups everything again.</summary>
[Fact]
public async Task Reinitialize_clears_the_do_not_group_memo()
{
var ct = TestContext.Current.CancellationToken;
var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4));
using var _ = drv;
await drv.InitializeAsync("{}", ct);
await drv.ReadAsync(["A", "B", "C"], ct);
blockReads().ShouldBe(1);
tagReads().ShouldBe(3);
await drv.InitializeAsync("{}", ct);
await drv.ReadAsync(["A", "B", "C"], ct);
// With the memo still in force pass 2 would be one A+B block read plus ONE per-tag read (C).
// Cleared, the full A..C span is tried again, refused again, and all three re-read alone.
blockReads().ShouldBe(2);
tagReads().ShouldBe(6, "after re-init the full span is tried again (and refused again)");
}
}
@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H3 / R07:U4 — the tag shapes the driver cannot serve are refused per tag at the mapper,
/// the deploy gate and the driver's init (see <see cref="S7TagShapeRules"/>), instead of faulting
/// the whole driver at init or being silently mis-read.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7TagShapeGateTests
{
/// <summary>A typo'd area/size letter is refused per tag (it used to throw FormatException at init).</summary>
[Fact]
public Task Unparseable_address_is_refused_per_tag()
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Typo", "DB1.DBZ0", S7DataType.Int16), "DB1.DBZ0", "does not parse");
/// <summary>
/// R07:U4 — a Bool array reads bit 0 of each byte, so a non-zero start bit was silently ignored
/// (<c>DB1.DBX0.3</c> returned bit 0 of bytes 0..7). It is refused at authoring now.
/// </summary>
[Fact]
public Task Bool_array_starting_at_a_nonzero_bit_is_refused()
=> S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("OffsetBits", "DB1.DBX0.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
"Bool array", "bit 3");
/// <summary>A narrow type at the wrong access width fails every read with a type mismatch; refused up front.</summary>
/// <param name="address">The address.</param>
/// <param name="dt">The declared type, which does not match the address's access width.</param>
[Theory]
[InlineData("DB1.DBW0", S7DataType.Int32)]
[InlineData("DB1.DBD0", S7DataType.Int16)]
[InlineData("DB1.DBB0", S7DataType.Bool)]
[InlineData("M0.0", S7DataType.Byte)]
[InlineData("MW0", S7DataType.Float32)]
public Task Narrow_type_at_the_wrong_access_width_is_refused(string address, S7DataType dt)
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Mismatch", address, dt), "access width");
/// <summary>Positive control: the shapes the driver serves are all still accepted by the mapper and the gate.</summary>
/// <param name="address">The address.</param>
/// <param name="dt">The declared type.</param>
/// <param name="arrayCount">The array count, or 0 for a scalar.</param>
/// <param name="writable">Whether the tag is writable.</param>
[Theory]
[InlineData("DB1.DBX0.0", S7DataType.Bool, 8, false)]
[InlineData("DB1.DBX50.3", S7DataType.Bool, 0, true)]
[InlineData("DB1.DBB0", S7DataType.Byte, 4, true)]
[InlineData("DB1.DBW0", S7DataType.Int16, 0, true)]
[InlineData("MW2", S7DataType.UInt16, 3, true)]
[InlineData("DB1.DBD0", S7DataType.Float32, 0, true)]
[InlineData("QD4", S7DataType.Int32, 2, true)]
[InlineData("DB1.DBB0", S7DataType.Float64, 0, true)]
[InlineData("DB1.DBB0", S7DataType.String, 0, true)]
[InlineData("T5", S7DataType.Float64, 0, false)]
[InlineData("C3", S7DataType.Int32, 0, false)]
public void Servable_shapes_are_accepted(string address, S7DataType dt, int arrayCount, bool writable)
{
var def = new S7TagDefinition("Ok", address, dt, Writable: writable, ArrayCount: arrayCount == 0 ? null : arrayCount);
var blob = S7TagDefinitionFactory.ToTagConfig(def);
S7TagDefinitionFactory.FromTagConfig(blob, "Ok", out var mapped, out var reason).ShouldBeTrue(reason);
mapped.Address.ShouldBe(address);
S7TagDefinitionFactory.Inspect(blob).ShouldBeEmpty();
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Shared assertion for an S7 tag shape the driver cannot serve. It must be refused PER TAG at
/// every gate, never fault the whole driver: the mapper (and so the AdminUI editor) refuses it with
/// the reason, the deploy gate (<see cref="S7TagDefinitionFactory.Inspect"/>) warns, and a driver
/// carrying it next to a good tag initializes Healthy, reports the bad one through
/// <see cref="ISkippedTagReporter"/>, and still serves the good one (the positive control).
/// </summary>
internal static class S7TagSkipAssert
{
private const string GoodName = "Good";
/// <summary>Asserts <paramref name="bad"/> is refused per tag at the mapper, the deploy gate and the driver.</summary>
/// <param name="bad">The unservable tag.</param>
/// <param name="reasonFragments">Fragments the operator-facing rejection reason must contain.</param>
/// <returns>A task that completes when every gate has been checked.</returns>
public static async Task RefusedPerTagAsync(S7TagDefinition bad, params string[] reasonFragments)
{
var ct = TestContext.Current.CancellationToken;
var blob = S7TagDefinitionFactory.ToTagConfig(bad);
// Mapper (and therefore the AdminUI editor, which asks it) — refused, with the reason.
S7TagDefinitionFactory.FromTagConfig(blob, bad.Name, out _, out var reason).ShouldBeFalse();
reason.ShouldNotBeNull();
foreach (var f in reasonFragments) reason.ShouldContain(f, Case.Insensitive);
// Deploy gate — warns with the same reason.
var warnings = S7TagDefinitionFactory.Inspect(blob);
warnings.ShouldHaveSingleItem().ShouldContain(reason);
// Driver — initializes Healthy with the bad tag skipped and the good tag served.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(bad, new S7TagDefinition(GoodName, "DB1.DBW0", S7DataType.UInt16, Writable: false)),
};
using var drv = new S7Driver(opts, "s7-skip-" + bad.Name, new ScriptedS7PlcFactory());
await drv.InitializeAsync("{}", ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "one unservable tag must not fault the driver");
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
skipped.Sample.ShouldHaveSingleItem().RawPath.ShouldBe(bad.Name);
skipped.Sample[0].Reason.ShouldBe(reason);
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, ct);
builder.Variables.Select(v => v.Name).ShouldBe([GoodName]);
var results = await drv.ReadAsync([bad.Name, GoodName], ct);
results[0].StatusCode.ShouldBe(0x80340000u, "the skipped tag reads BadNodeIdUnknown");
results[1].StatusCode.ShouldBe(0u, "the good neighbour is still served");
}
}
@@ -224,6 +224,23 @@ public sealed class S7TsapAndPduTests
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
}
/// <summary>
/// The shapes a real read raises — S7.Net's read paths throw the CPU's item error as a BARE
/// Exception (live-probed on the snap7 fixture). Before, the probe only matched PlcException, so a
/// PUT/GET-disabled CPU got the generic "test read failed" text and a missing address failed the probe.
/// </summary>
[Fact]
public async Task Probe_classifies_the_bare_read_path_shapes()
{
var ct = TestContext.Current.CancellationToken;
var denied = await S7DriverProbe.CheckDataAccessAsync(
_ => throw new Exception("Received error from PLC: Accessing object not allowed."), ct);
denied.ShouldNotBeNull().Message!.ShouldContain("PUT/GET");
(await S7DriverProbe.CheckDataAccessAsync(
_ => throw new Exception("Received error from PLC: Object does not exist."), ct)).ShouldBeNull();
}
[Fact]
public async Task Probe_reports_any_other_read_fault_as_a_failure()
{
@@ -318,16 +318,9 @@ public sealed class S7ValueCodecTests
[Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{
S7DriverOptions With(S7TagDefinition t) => new()
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(t),
};
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
// Refused PER TAG at every gate (they used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Dtl) with { Name = "DtlArr", ArrayCount = 2 }, "Dtl", "array");
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Tod, address: "DB1.DBW0") with { Name = "TodWord" }, "Tod", "byte-addressed");
}
// ---- TagConfig ----
@@ -0,0 +1,142 @@
using S7DataItem = global::S7.Net.Types.DataItem;
using S7NetDataType = global::S7.Net.DataType;
using VarType = global::S7.Net.VarType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// A scriptable <see cref="IS7PlcFactory"/>: every connection it creates delegates its wire
/// operations to the factory's handlers, so a test scripts the device once and observes every
/// connection (including reopened ones). Counts wire operations so a test can prove what did
/// NOT go to the wire without timing anything.
/// </summary>
internal sealed class ScriptedS7PlcFactory : IS7PlcFactory
{
private int _wireOps;
/// <summary>Every connection created, in order.</summary>
public List<ScriptedS7Plc> Created { get; } = [];
/// <summary>Wire operations issued (reads, multi-var reads, writes; not opens or status probes).</summary>
public int WireOps => Volatile.Read(ref _wireOps);
/// <summary>Hook run inside every <see cref="IS7Plc.OpenAsync"/>; the default opens immediately.</summary>
public Func<int, CancellationToken, Task> Open { get; set; } = (_, _) => Task.CompletedTask;
/// <summary>Per-address string read; the default returns <c>(ushort)42</c>.</summary>
public Func<string, CancellationToken, Task<object?>> Read { get; set; } = (_, _) => Task.FromResult<object?>((ushort)42);
/// <summary>Block read; the default returns <c>count</c> zero bytes.</summary>
public Func<S7NetDataType, int, int, int, CancellationToken, Task<byte[]>> ReadBytes { get; set; }
= (_, _, _, count, _) => Task.FromResult(new byte[count]);
/// <summary>Multi-var read; the default answers every item with a zero of its var type.</summary>
public Func<List<S7DataItem>, CancellationToken, Task<IReadOnlyList<S7DataItem>>> ReadMulti { get; set; }
= (items, _) =>
{
foreach (var i in items)
i.Value = i.VarType switch
{
VarType.Bit => false,
VarType.Byte => (byte)0,
VarType.Word => (ushort)0,
_ => (uint)0,
};
return Task.FromResult<IReadOnlyList<S7DataItem>>(items);
};
/// <summary>Per-address string write; the default succeeds.</summary>
public Func<string, object, CancellationToken, Task> Write { get; set; } = (_, _, _) => Task.CompletedTask;
/// <summary>Block write; the default succeeds.</summary>
public Func<S7NetDataType, int, int, byte[], CancellationToken, Task> WriteBytes { get; set; }
= (_, _, _, _, _) => Task.CompletedTask;
/// <summary>CPU-status probe; the default answers.</summary>
public Func<CancellationToken, Task> ReadStatus { get; set; } = _ => Task.CompletedTask;
/// <inheritdoc />
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
{
lock (Created)
{
var plc = new ScriptedS7Plc(this, Created.Count);
Created.Add(plc);
return plc;
}
}
/// <summary>Counts one wire operation.</summary>
internal void CountWireOp() => Interlocked.Increment(ref _wireOps);
}
/// <summary>One connection created by <see cref="ScriptedS7PlcFactory"/>.</summary>
internal sealed class ScriptedS7Plc(ScriptedS7PlcFactory factory, int index) : IS7Plc
{
/// <summary>Zero-based creation index.</summary>
public int Index { get; } = index;
/// <inheritdoc />
public bool IsConnected { get; private set; }
/// <summary>True once disposed.</summary>
public bool Disposed { get; private set; }
/// <inheritdoc />
public int MaxPduSize => 240;
/// <inheritdoc />
public async Task OpenAsync(CancellationToken cancellationToken)
{
await factory.Open(Index, cancellationToken).ConfigureAwait(false);
IsConnected = true;
}
/// <inheritdoc />
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.Read(address, cancellationToken);
}
/// <inheritdoc />
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.ReadBytes(area, db, startByteAdr, count, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.ReadMulti(items, cancellationToken);
}
/// <inheritdoc />
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.Write(address, value, cancellationToken);
}
/// <inheritdoc />
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.WriteBytes(area, db, startByteAdr, value, cancellationToken);
}
/// <inheritdoc />
public Task ReadStatusAsync(CancellationToken cancellationToken) => factory.ReadStatus(cancellationToken);
/// <inheritdoc />
public void Close() => IsConnected = false;
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
IsConnected = false;
}
}
@@ -256,6 +256,27 @@ public sealed class AbLegacyDriverPageFormSerializationTests
bound.Probe.ProbeAddress.ShouldBe("S:1");
}
/// <summary>Review 07 P7: the coalesceReads switch is authored here and consumed by the driver factory.</summary>
[Theory]
[InlineData("""{"devices":[]}""", true, false)]
[InlineData("""{"coalesceReads":false,"devices":[]}""", false, true)]
public void CoalesceReads_round_trips_to_the_factory_bound_options(string original, bool loadedValue, bool edited)
{
var loaded = AbLegacyDriverForm.TryDeserialize(original)!;
var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded);
form.CoalesceReads.ShouldBe(loadedValue);
form.CoalesceReads = edited;
var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
(ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved)
.CoalesceReads != false).ShouldBe(edited);
// Review 07 r0 M9: ON is the default and is OMITTED, never pinned; only an explicit off is written.
var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject();
if (edited) root.ContainsKey("coalesceReads").ShouldBeFalse();
else root["coalesceReads"]!.GetValue<bool>().ShouldBeFalse();
}
[Fact]
public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed()
{
@@ -122,6 +122,38 @@ public sealed class AbLegacyTagConfigModelTests
m.Validate().ShouldBeNull();
}
/// <summary>Review 07 H16: an array of a structured element type is refused with the reader's own reason.</summary>
[Theory]
[InlineData("String", "ST9:0")]
[InlineData("TimerElement", "T4:0")]
public void Validate_refuses_an_array_of_a_structured_element_type(string dataType, string address)
{
var m = AbLegacyTagConfigModel.FromJson(
$$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""");
m.Validate().ShouldNotBeNull().ShouldContain("not supported as an array");
// Positive control: the same type as a scalar validates.
AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull();
}
/// <summary>Review 07 r0 I3: an array must match its data file's element width.</summary>
[Theory]
[InlineData("F8:0", "Int", false)]
[InlineData("N7:0", "Long", false)]
[InlineData("N7:0", "Int", true)]
[InlineData("L9:0", "Long", true)]
[InlineData("F8:0", "Float", true)]
public void Validate_refuses_an_array_whose_type_does_not_match_its_file(string address, string dataType, bool valid)
{
var m = AbLegacyTagConfigModel.FromJson(
$$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""");
if (valid) m.Validate().ShouldBeNull();
else m.Validate().ShouldNotBeNull().ShouldContain("element width");
// A SCALAR of the same shape is not this rule's business.
AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull();
}
[Fact]
public void Validate_returns_null_for_scalar_tag_regardless_of_no_array_length()
{
@@ -142,7 +142,7 @@ public sealed class CsvMergeEvidenceTests
var row = SingleRow(
DriverTypeNames.S7,
"Name,DataType,Address,S7DataType,TagConfigJson",
"t1,Int16,DB1.DBW0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
"t1,Int16,DB1.DBD0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
var blob = Blob(row);
blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
@@ -346,7 +346,11 @@ public sealed class RawTagConfigGateTests
public void A_browse_committed_config_passes_the_gate(string driverType)
{
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType));
driverType,
// Drivers whose address grammar is validated by their reader need a real address: AbLegacy's
// file grammar, and S7's (S7TagShapeRules — an unparseable or wrong-width address is refused).
driverType == DriverTypeNames.AbLegacy ? "N7:0" : driverType == DriverTypeNames.S7 ? "DB1.DBW0" : "SomeAddress",
BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses");
@@ -98,13 +98,13 @@ public sealed class S7TagConfigModelTests
/// driver serves. (An absent key is deliberately not asserted: S7 alone hard-requires the key, an
/// asymmetry that predates this change and that its own <c>Inspect</c> does not report either.)</summary>
[Theory]
[InlineData("""{"address":"DB1.DBW0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":7}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> S7TagConfigModel.FromJson(json).Validate().ShouldBeNull();
// ---- S7-016: array shape vs the driver's init guards (which fail the WHOLE driver init) ----
// ---- S7-016: array shape vs S7TagShapeRules (refused per tag at every gate) ----
[Theory]
[InlineData("Int64")]
@@ -160,9 +160,9 @@ public sealed class TagConfigNonEnumParityTests
/// <summary>S7's remaining non-enum refusal: a <c>String</c> tag whose <c>stringLength</c> falls outside
/// 0–254. The reader REJECTS it (it does not clamp), so the tag would never resolve.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":-1}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""")]
public void S7_refuses_a_string_length_its_reader_refuses(string blob)
{
RuntimeAccepts(DriverTypeNames.S7, blob).ShouldBeFalse(
@@ -174,9 +174,9 @@ public sealed class TagConfigNonEnumParityTests
/// stops this becoming an over-rejection — an out-of-range <c>stringLength</c> on a tag that is not a
/// String at all, which the reader ignores entirely.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")]
public void S7_accepts_a_string_length_its_reader_accepts(string blob)
{
@@ -360,8 +360,8 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBW0"}"""),
(DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.AbCip, "{}"),
(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed"}"""),
@@ -576,7 +576,7 @@ public sealed class TagConfigNonEnumParityTests
data.Add(DriverTypeNames.Modbus, new ModbusTagConfigModel { Address = 1 }.ToJson());
data.Add(DriverTypeNames.S7, new S7TagConfigModel { Address = "DB1.DBW0" }.ToJson());
data.Add(DriverTypeNames.S7,
new S7TagConfigModel { Address = "DB1.DBS0", DataType = S7DataType.String, StringLength = 24 }.ToJson());
new S7TagConfigModel { Address = "DB1.DBB0", DataType = S7DataType.String, StringLength = 24 }.ToJson());
data.Add(DriverTypeNames.AbCip, new AbCipTagConfigModel { TagPath = "Program:Main.Speed" }.ToJson());
data.Add(DriverTypeNames.AbLegacy, new AbLegacyTagConfigModel { Address = "N7:0" }.ToJson());
data.Add(DriverTypeNames.TwinCAT, new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.ToJson());
@@ -215,11 +215,13 @@ public sealed class HistoryUpdateOverTheWireTests
}
/// <summary>
/// Control (no retro-update): materialising the node BEFORE the updater is installed leaves it without
/// the HistoryWrite bit, so the SDK base answers BadHistoryOperationUnsupported — even for an editor.
/// Review 01 U3 (B7): an updater installed AFTER the node materialised re-composes the HistoryWrite bit on
/// the existing node (no rebuild), so an editor's insert reaches the updater over the wire; clearing the
/// updater again retracts the bit and the SDK base answers BadHistoryOperationUnsupported. (Before B7 the
/// bit was fixed at materialisation and a late install stayed unsupported.)
/// </summary>
[Fact]
public async Task An_updater_installed_after_the_node_materialised_is_unsupported()
public async Task An_updater_installed_after_the_node_materialised_grants_history_write_and_a_reset_takes_it_back()
{
var ct = TestContext.Current.CancellationToken;
var store = new SharedStore();
@@ -228,16 +230,27 @@ public sealed class HistoryUpdateOverTheWireTests
{
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
rig.Nm.HistorianDataUpdater = store;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var node = RawNode(session, id);
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
.ShouldBe(0, "the bit is fixed at materialisation");
.ShouldBe(0, "no updater at materialisation (non-vacuity for the refresh)");
var result = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0));
result.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
store.UpdateCalls.ShouldBe(0);
rig.Nm.HistorianDataUpdater = store;
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
.ShouldBe(AccessLevels.HistoryWrite, "a late updater lights the bit on the existing node");
var ok = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0));
ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
store.UpdateCalls.ShouldBe(1);
rig.Nm.HistorianDataUpdater = null;
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
.ShouldBe(0, "a reset updater takes the bit back");
var refused = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T2, 2.0));
refused.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
store.UpdateCalls.ShouldBe(1, "the reset updater is never reached");
}
finally { await rig.DisposeAsync(); }
}
@@ -0,0 +1,287 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Review 01 L2, over the wire against the real SDK: a rebuild (or a surgical remove + re-add) that
/// recreates a NodeId must not leave the SDK's per-NodeId <c>MonitoredNode2</c> — and its component-cache
/// entry — pointing at the DEAD <c>NodeState</c>. Before the fix, ANY new item on that NodeId, from any
/// session, bound to the dead node while an older item on it still existed: created <c>Good</c>, fed its
/// stale initial value, then never again.
/// <para>
/// Each test keeps the OLD item alive while creating the NEW one — that is exactly the trap — and
/// proves the positive path (the new item receives a value written AFTER its creation) before asserting
/// the old item's <c>BadNodeIdUnknown</c>. Deleting the old item afterwards must succeed too.
/// </para>
/// </summary>
public sealed class StaleMonitoredNodeWireTests
{
private const string ServerUri = "urn:OtOpcUa.StaleMonitoredNode";
private const string Device = "Plant/Modbus/dev1";
private const string Tag = "Plant/Modbus/dev1/Speed";
/// <summary>Rebuild + re-create the same NodeId: the old item is told, a new item on the same NodeId receives values.</summary>
[Fact]
public async Task A_new_item_after_a_rebuild_binds_to_the_recreated_node()
{
await RunAsync("rebuild", nm =>
{
nm.RebuildAddressSpace();
nm.CompleteAddressSpaceApply();
Materialise(nm);
});
}
/// <summary>Surgical remove + re-add of the same NodeId: same contract as the rebuild.</summary>
[Fact]
public async Task A_new_item_after_a_surgical_remove_and_readd_binds_to_the_recreated_node()
{
await RunAsync("surgical", nm =>
{
nm.RemoveVariableNode(Tag, AddressSpaceRealm.Raw).ShouldBeTrue();
nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
});
}
/// <summary>
/// The same trap from a SECOND session: a UaExpert operator connecting after a deploy while ScadaBridge's
/// original item is still alive.
/// </summary>
[Fact]
public async Task A_second_session_binds_to_the_recreated_node_while_the_first_sessions_item_lives()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-2s-{Guid.NewGuid():N}");
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host, port) = await BootServerAsync(pkiRoot, ServerUri + ".TwoSessions", ct);
await using var _ = host;
var nm = server.NodeManager!;
Materialise(nm);
nm.WriteValue(Tag, 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
using var sessionA = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var nodeId = new NodeId(Tag, (ushort)sessionA.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
var (subA, oldItem, oldValues, gate) = await SubscribeAsync(sessionA, nodeId, ct);
await WaitUntilAsync(() => { lock (gate) return oldValues.Count >= 1; }, TimeSpan.FromSeconds(5));
nm.RebuildAddressSpace();
nm.CompleteAddressSpaceApply();
Materialise(nm);
nm.WriteValue(Tag, 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
// Session B arrives AFTER the rebuild, while A's item is still in A's subscription.
using var sessionB = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var (subB, newItem, newValues, gateB) = await SubscribeAsync(sessionB, nodeId, ct);
await WaitUntilAsync(() => { lock (gateB) return newValues.Count >= 1; }, TimeSpan.FromSeconds(5));
nm.WriteValue(Tag, 3, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.Value, 3)); }, TimeSpan.FromSeconds(5),
"the second session's item must receive a value written after its creation");
lock (gate) oldValues.ShouldContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown, "the first session's item was told its node went away");
lock (gateB) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
subA.RemoveItem(oldItem);
await subA.ApplyChangesAsync(ct);
subB.RemoveItem(newItem);
await subB.ApplyChangesAsync(ct);
}
finally
{
SafeDelete(pkiRoot);
}
}
private static async Task RunAsync(string name, Action<OtOpcUaNodeManager> recreate)
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{name}-{Guid.NewGuid():N}");
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host, port) = await BootServerAsync(pkiRoot, $"{ServerUri}.{name}", ct);
await using var _ = host;
var nm = server.NodeManager!;
Materialise(nm);
nm.WriteValue(Tag, 41, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var nodeId = new NodeId(Tag, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
var (subscription, oldItem, oldValues, gate) = await SubscribeAsync(session, nodeId, ct);
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.Value, 41)); }, TimeSpan.FromSeconds(5));
nm.IsNodeMonitored(nodeId).ShouldBeTrue("non-vacuity: the SDK really holds a fan-out for the node");
// The node goes away and comes back under the same NodeId; a fresh value lands on the NEW node.
recreate(nm);
nm.WriteValue(Tag, 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
// A NEW item on the same NodeId, in the same subscription, with the old one still present.
var newValues = new List<DataValue>();
var newItem = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10,
};
newItem.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) newValues.Add(v); };
subscription.AddItem(newItem);
await subscription.ApplyChangesAsync(ct);
newItem.Status.Error.ShouldBeNull();
// Positive control: the initial value is the NEW node's, and a value written afterwards arrives.
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5),
"the new item's initial read must come from the re-created node");
nm.WriteValue(Tag, 43, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 43)); }, TimeSpan.FromSeconds(5),
"the new item must receive a value written after its creation");
lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
// Review fix I3: the cache entry the new item holds is ITS reference alone — the old item's was released
// by the removal.
CacheRefCount(nm, nodeId).ShouldBe(1, "one live item, one component-cache reference");
// The old item was told, and the dead node's fan-out is gone.
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5),
"the item on the removed node must be told BadNodeIdUnknown");
nm.IsNodeMonitored(nodeId).ShouldBeTrue("the NEW item's fan-out exists for the re-created node");
// The old item's delete completes — its node is gone, not its right to be deleted.
subscription.RemoveItem(oldItem);
await subscription.ApplyChangesAsync(ct);
(oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue(
$"deleting the orphaned item must succeed, got {oldItem.Status.Error}");
// The SDK releases a deleted item's cache reference on a Good delete; the old item's was released by the
// removal already, so a second release would evict the NEW item's live entry (review fix I3).
CacheRefCount(nm, nodeId).ShouldBe(1, "deleting the orphaned item must not take the new item's reference");
// And the new item is unaffected by that delete.
nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5));
// Positive control: deleting the live item gives its reference back.
subscription.RemoveItem(newItem);
await subscription.ApplyChangesAsync(ct);
CacheRefCount(nm, nodeId).ShouldBe(0);
}
finally
{
SafeDelete(pkiRoot);
}
}
/// <summary>The SDK's component-cache reference count for <paramref name="nodeId"/> (private SDK state, read by
/// reflection: <c>CustomNodeManager2.m_componentCache</c> → <c>CacheEntry.RefCount</c>); 0 when no entry.</summary>
private static int CacheRefCount(OtOpcUaNodeManager nm, NodeId nodeId)
{
const System.Reflection.BindingFlags Any =
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic;
var field = typeof(Opc.Ua.Server.CustomNodeManager2).GetField("m_componentCache", Any)
.ShouldNotBeNull("the SDK's component cache field (SDK internals moved?)");
lock (nm.Lock)
{
if (field.GetValue(nm) is not System.Collections.IDictionary cache || !cache.Contains(nodeId)) return 0;
var entry = cache[nodeId]!;
var member = (object?)entry.GetType().GetField("RefCount", Any) ?? entry.GetType().GetProperty("RefCount", Any);
return member switch
{
System.Reflection.FieldInfo f => (int)f.GetValue(entry)!,
System.Reflection.PropertyInfo p => (int)p.GetValue(entry)!,
_ => throw new InvalidOperationException("CacheEntry.RefCount not found"),
};
}
}
private static void Materialise(OtOpcUaNodeManager nm)
{
nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw);
nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
}
private static async Task<(Subscription Subscription, MonitoredItem Item, List<DataValue> Values, object Gate)> SubscribeAsync(
ISession session, NodeId nodeId, CancellationToken ct)
{
var values = new List<DataValue>();
var gate = new object();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10,
};
item.Notification += (i, _) => { foreach (var v in i.DequeueValues()) lock (gate) values.Add(v); };
subscription.AddItem(item);
await subscription.ApplyChangesAsync(ct);
return (subscription, item, values, gate);
}
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host, int Port)> BootServerAsync(
string pkiRoot, string appUri, CancellationToken ct)
{
var boundPort = 0;
var started = await OpcUaHostStart.StartAsync(
port =>
{
boundPort = port;
return new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = appUri,
ApplicationUri = appUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
},
NullLogger<OpcUaApplicationHost>.Instance);
},
() => new OtOpcUaSdkServer(), ct);
return (started.Server, started.Host, boundPort);
}
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.StaleMonitoredNodeClient",
ApplicationUri = $"urn:OtOpcUa.StaleMonitoredNodeClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig));
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "StaleMonitoredNodeWireTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string? because = null)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue(because ?? "condition not met within timeout");
}
private static void SafeDelete(string dir)
{
if (!Directory.Exists(dir)) return;
try { Directory.Delete(dir, recursive: true); }
catch { /* best-effort */ }
}
}
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// announce (T4b) land.
/// <para><b>Run note:</b> this is a heavy in-process server+client integration test — run it in the
/// serial integration pass (it is NOT part of the lightweight unit sweep). The <see cref="OtOpcUaNodeManager"/>
/// namespace URI is <see cref="OtOpcUaNodeManager.DefaultNamespaceUri"/>; the client resolves the
/// namespace URI is <see cref="V3NodeIds.RawNamespaceUri"/>; the client resolves the
/// folder-scoped variable NodeIds against it.</para>
/// </summary>
public sealed class SubscriptionSurvivalTests
@@ -68,15 +68,15 @@ public sealed class SubscriptionSurvivalTests
// --- Materialise tag A (equipment eq-1, tag "A") and seed it a Good value. ---
var withA = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
RawTag("tag-a", "A"));
applier.MaterialiseHierarchy(withA);
applier.MaterialiseEquipmentTags(withA);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
applier.MaterialiseRawSubtree(withA);
var nodeIdAString = "Plant/dev1/A";
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
// --- Open a client session + subscription + monitored item on A. ---
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
ns.ShouldBeGreaterThan((ushort)0);
var nodeIdA = new NodeId(nodeIdAString, ns);
@@ -106,22 +106,22 @@ public sealed class SubscriptionSurvivalTests
// --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). ---
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-b", "B"));
var plan = AddressSpacePlanner.Compute(withA, withAB);
plan.AddedEquipmentTags.Count.ShouldBe(1);
plan.AddedRawTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown
// Materialise passes (as the publish actor runs them) then announce — over the FULL composition.
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
applier.MaterialiseRawSubtree(withAB);
applier.AnnounceAddedNodes(plan);
// --- A's monitored item must still be alive: a new write to A is delivered. ---
lock (gate) received.Clear();
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
lock (gate)
@@ -131,8 +131,8 @@ public sealed class SubscriptionSurvivalTests
}
// --- B is browsable + readable (the add landed). ---
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var nodeIdB = new NodeId("Plant/dev1/B", ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var bValue = await session.ReadValueAsync(nodeIdB, ct);
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
bValue.Value.ShouldBe(7);
@@ -180,17 +180,17 @@ public sealed class SubscriptionSurvivalTests
// Materialise A + B and seed both Good.
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-b", "B"));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
applier.MaterialiseRawSubtree(withAB);
var nodeIdAString = "Plant/dev1/A";
var nodeIdBString = "Plant/dev1/B";
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var nodeIdA = new NodeId(nodeIdAString, ns);
var nodeIdB = new NodeId(nodeIdBString, ns);
@@ -211,9 +211,9 @@ public sealed class SubscriptionSurvivalTests
// Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}).
var withA = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
RawTag("tag-a", "A"));
var plan = AddressSpacePlanner.Compute(withAB, withA);
plan.RemovedEquipmentTags.Count.ShouldBe(1);
plan.RemovedRawTags.Count.ShouldBe(1);
lock (gate) { receivedA.Clear(); receivedB.Clear(); }
var outcome = applier.Apply(plan);
@@ -223,7 +223,7 @@ public sealed class SubscriptionSurvivalTests
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
// A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
@@ -273,15 +273,15 @@ public sealed class SubscriptionSurvivalTests
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-b", "B"));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
applier.MaterialiseRawSubtree(withAB);
var nodeIdAString = "Plant/dev1/A";
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var nodeIdA = new NodeId(nodeIdAString, ns);
var receivedA = new List<DataValue>();
@@ -297,32 +297,32 @@ public sealed class SubscriptionSurvivalTests
// MIXED apply: remove B, add C (previous={A,B}, next={A,C}).
var withAC = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null));
RawTag("tag-a", "A"),
RawTag("tag-c", "C"));
var plan = AddressSpacePlanner.Compute(withAB, withAC);
plan.RemovedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.Count.ShouldBe(1);
plan.RemovedRawTags.Count.ShouldBe(1);
plan.AddedRawTags.Count.ShouldBe(1);
lock (gate) receivedA.Clear();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild
// Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce.
applier.MaterialiseEquipmentTags(withAC);
applier.MaterialiseRawSubtree(withAC);
applier.AnnounceAddedNodes(plan);
// A stays alive.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
var nodeIdB = new NodeId("Plant/dev1/B", ns);
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
// C is browsable + readable.
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var nodeIdC = new NodeId("Plant/dev1/C", ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var cRead = await session.ReadValueAsync(nodeIdC, ct);
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
cRead.Value.ShouldBe(9);
@@ -333,7 +333,11 @@ public sealed class SubscriptionSurvivalTests
}
}
private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) =>
private static RawTagPlan RawTag(string tagId, string name) =>
new(tagId, $"Plant/dev1/{name}", ParentNodeId: "Plant/dev1", "drv", Name: name, DataType: "Int32", Writable: false,
Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
private static AddressSpaceComposition CompositionWith(params RawTagPlan[] tags) =>
new(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
@@ -341,7 +345,8 @@ public sealed class SubscriptionSurvivalTests
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
RawContainers = new[] { new RawContainerNode("Plant/dev1", null, "dev1", RawNodeKind.Device) },
RawTags = tags,
};
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
@@ -45,45 +45,56 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
// ---------------- T2: FailedNodes (removal-pass tally) ----------------
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).</summary>
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).
/// The terminal condition write is made for a removed SCRIPTED ALARM — a removed equipment gets none (review
/// 01 L4: an equipment id is a folder, never a condition, so writing one only minted a phantom).</summary>
[Fact]
public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes()
{
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(AlarmRemovalPlan("alm-1"));
outcome.RemovedNodes.ShouldBe(1);
outcome.FailedNodes.ShouldBe(1);
}
/// <summary>Review 01 L4 — removing an equipment writes NO condition state against the equipment id.</summary>
[Fact]
public void Apply_RemovedEquipment_WritesNoConditionAgainstTheEquipmentId()
{
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(EquipmentRemovalPlan("eq-1"));
outcome.RemovedNodes.ShouldBe(1);
outcome.FailedNodes.ShouldBe(1);
outcome.FailedNodes.ShouldBe(0, "no condition write happens for an equipment folder, so a throwing alarm write cannot be reached");
}
// ---------------- T3: Materialise* passes return failed-node counts ----------------
/// <summary>Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return.</summary>
/// <summary>Every EnsureVariable throwing in MaterialiseRawSubtree is tallied into the pass's int return.</summary>
[Fact]
public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount()
public void MaterialiseRawSubtree_WhenEnsureVariableThrows_ReturnsFailedCount()
{
var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = CompositionWithTags(
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null));
var composition = CompositionWithRawTags(RawTag("t1", "A"), RawTag("t2", "B"), RawTag("t3", "C"));
applier.MaterialiseEquipmentTags(composition).ShouldBe(3);
applier.MaterialiseRawSubtree(composition).ShouldBe(3);
}
/// <summary>All five Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
/// <summary>All four Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
[Fact]
public void Materialise_HappyPath_AllPassesReturnZero()
{
var sink = new ConfigurableThrowingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var tags = CompositionWithTags(
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var tags = CompositionWithRawTags(RawTag("t1", "A"));
var vtags = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>(), Historize: false));
var alarms = CompositionWithScriptedAlarms(
@@ -91,17 +102,21 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true));
applier.MaterialiseHierarchy(tags).ShouldBe(0);
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
applier.MaterialiseRawSubtree(tags).ShouldBe(0);
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
}
// ---------------- fixtures ----------------
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
private static RawTagPlan RawTag(string id, string name) =>
new(id, $"Plant/dev1/{name}", ParentNodeId: null, "drv", Name: name, DataType: "Float", Writable: false, Alarm: null,
ReferencingEquipmentPaths: Array.Empty<string>());
private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) =>
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
RawTags = tags,
};
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
@@ -135,6 +150,17 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
private static AddressSpacePlan AlarmRemovalPlan(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: ids.Select(id => new ScriptedAlarmPlan(id, "eq-1", "pred", "msg")).ToArray(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
@@ -110,13 +110,13 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
}
/// <summary>Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
/// <summary>Verifies MaterialiseRawSubtree is idempotent against a real SDK node manager:
/// applying the same composition twice yields a single Variable node (no duplicates). This is the
/// restart-safety guarantee — HandleRebuild runs on both the apply path and the
/// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
/// restart re-runs this pass and must not double-materialise.</summary>
[Fact]
public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
public async Task MaterialiseRawSubtree_against_real_SDK_node_manager_is_idempotent()
{
var applicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}";
var started = await OpcUaHostStart.StartAsync(
@@ -145,16 +145,17 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
RawTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new RawTagPlan("tag-1", "Plant/dev1/Speed", ParentNodeId: null, "drv", Name: "Speed", DataType: "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
// Equipment folder first (the variable's parent), then the tag pass — applied twice.
// The raw pass — applied twice.
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseRawSubtree(composition);
applier.MaterialiseRawSubtree(composition);
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
}
@@ -12,30 +12,6 @@ public sealed class AddressSpaceApplierTests
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
/// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.)</summary>
[Fact]
public void Added_equipment_tags_are_pure_add_and_skip_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
/// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒
/// rebuild" pin.)</summary>
@@ -61,27 +37,22 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on
/// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The
/// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is
/// surgical-eligible); the applier still runs the F10b surgical pass.</summary>
/// <summary>R2-07 T11 (raw-tag vehicle, review 01 U2 retired the equipment-tag one) — a remove-one-raw-tag
/// plan is a PureRemove: the applier writes a terminal Bad to the removed variable then removes it in place
/// via RemoveVariableNode in the RAW realm — NO rebuild. An alarm-bearing raw tag gets the terminal
/// RemovedConditionState and a condition-node removal instead.</summary>
[Fact]
public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place()
public void Removed_raw_tags_write_terminal_state_and_remove_in_place_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
RemovedRawTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
ChangedEquipmentTags = new[]
{
new AddressSpacePlan.EquipmentTagDelta(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
RawTag("tag-1", "Plant/dev1/Speed"),
RawTag("tag-alm", "Plant/dev1/OverTemp", alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
},
};
@@ -89,125 +60,16 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.Writable.ShouldBeTrue();
}
/// <summary>R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node
/// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is
/// preserved through the classifier routing).</summary>
[Fact]
public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { SurgicalReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
ChangedEquipmentTags = new[]
{
new AddressSpacePlan.EquipmentTagDelta(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
/// <summary>R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the
/// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2
/// phase pin.)</summary>
[Fact]
public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var nodeId = V3NodeIds.Uns("eq-1", "Speed");
// Terminal Bad written to the removed variable BEFORE the removal.
sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns));
}
/// <summary>R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing
/// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION
/// node in place (not a value variable).</summary>
[Fact]
public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
var nodeId = V3NodeIds.Uns("eq-1", "OverTemp");
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
sink.ValueWrites.ShouldContain(("Plant/dev1/Speed", OpcUaStatus.Bad, AddressSpaceRealm.Raw));
var write = sink.AlarmWrites.ShouldHaveSingleItem();
write.NodeId.ShouldBe(nodeId);
write.NodeId.ShouldBe("Plant/dev1/OverTemp");
(!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue();
// Removed as a condition node, not a value variable.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId, AddressSpaceRealm.Uns));
sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId);
}
/// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
/// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
[Fact]
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
sink.RemoveCalls.ShouldBe(new[]
{
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
RemovedEquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
// ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns));
("var", "Plant/dev1/Speed", AddressSpaceRealm.Raw),
("alarm", "Plant/dev1/OverTemp", AddressSpaceRealm.Raw),
});
outcome.RemovedNodes.ShouldBe(2);
}
/// <summary>R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier
@@ -220,11 +82,7 @@ public sealed class AddressSpaceApplierTests
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
},
RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A"), RawTag("tag-2", "Plant/dev1/B") },
};
var outcome = applier.Apply(plan);
@@ -243,13 +101,7 @@ public sealed class AddressSpaceApplierTests
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
},
};
var plan = EmptyPlan with { RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A") } };
var outcome = applier.Apply(plan);
@@ -259,7 +111,7 @@ public sealed class AddressSpaceApplierTests
/// <summary>R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE
/// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's
/// passes. (Supersedes the T2 phase pin.)</summary>
/// passes.</summary>
[Fact]
public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
{
@@ -268,14 +120,8 @@ public sealed class AddressSpaceApplierTests
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
};
var outcome = applier.Apply(plan);
@@ -283,40 +129,11 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old"), AddressSpaceRealm.Uns));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", "Plant/dev1/Old", AddressSpaceRealm.Raw));
outcome.AddedNodes.ShouldBe(1);
outcome.RemovedNodes.ShouldBe(1);
}
/// <summary>R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the
/// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the
/// recreate is the publish actor's job, a fresh NodeState). No rebuild.</summary>
[Fact]
public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var nodeId = V3NodeIds.Uns("eq-1", "Slot");
var plan = EmptyPlan with
{
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns));
}
/// <summary>R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet.</summary>
[Fact]
public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild()
@@ -326,14 +143,8 @@ public sealed class AddressSpaceApplierTests
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
};
var outcome = applier.Apply(plan);
@@ -342,38 +153,28 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the
/// driver-side <c>FullName</c> re-routes to a different point) must rebuild the address space. The planner
/// diffs the tag into <c>ChangedEquipmentTags</c> with no Added/Removed of anything else; the applier must
/// still drive exactly one rebuild so the running server drops the stale node and re-materialises it.
/// (Surgically-applicable tag changes — Writable/Historizing/DataType/array-shape — take the in-place path
/// instead; those are covered by the F10b + FB-7 surgical tests.)</summary>
/// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
/// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
[Fact]
public void Changed_equipment_tags_only_trigger_rebuild()
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
// Same tag id, but the driver-side FullName flipped — a non-surgical change, so the applier rebuilds.
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: ONLY ChangedEquipmentTags is populated.
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.AddedEquipment.ShouldBeEmpty();
plan.RemovedEquipment.ShouldBeEmpty();
var plan = EmptyPlan with
{
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
RemovedEquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.ChangedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
outcome.RebuildCalled.ShouldBeFalse();
// ONLY the subtree removal fires — no individual var removal for the subsumed child vtag.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns));
}
/// <summary>F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's <c>Expression</c>
@@ -553,26 +354,18 @@ public sealed class AddressSpaceApplierTests
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
new[] { new EquipmentNode("eq-1", "One", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }),
},
};
// Expression-only vtag edit (node-irrelevant) AND a non-surgical tag change (FullName re-route).
// Expression-only vtag edit (node-irrelevant) AND a node-affecting equipment change (rename).
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
@@ -582,9 +375,9 @@ public sealed class AddressSpaceApplierTests
var plan = AddressSpacePlanner.Compute(previous, next);
// Both a node-irrelevant vtag change AND a node-affecting tag change are present.
// Both a node-irrelevant vtag change AND a node-affecting equipment change are present.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.ChangedEquipment.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
@@ -670,7 +463,6 @@ public sealed class AddressSpaceApplierTests
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
var outcome = applier.Apply(plan);
@@ -732,7 +524,6 @@ public sealed class AddressSpaceApplierTests
plan.RemovedDrivers.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
plan.ChangedAlarms.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
@@ -748,7 +539,7 @@ public sealed class AddressSpaceApplierTests
/// to <c>removedCount</c> — <c>AddressSpaceApplyOutcome.RemovedNodes</c> reported 0, a misleading audit
/// entry. This pins both the rebuild and the accurate count.</summary>
[Fact]
public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted()
public void Removed_virtual_tags_only_remove_in_place_and_are_counted()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -756,10 +547,6 @@ public sealed class AddressSpaceApplierTests
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
@@ -771,440 +558,23 @@ public sealed class AddressSpaceApplierTests
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: ONLY the two Removed sets are populated.
plan.RemovedEquipmentTags.Count.ShouldBe(1);
// Guard the arrange: ONLY the removed-vtag set is populated.
plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipment.ShouldBeEmpty();
plan.RemovedAlarms.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in
// place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted.
// R2-07 T11 — a removed-vtag plan is a PureRemove: the variable is torn down in place
// (RemoveVariableNode), NO full rebuild; the removal is still counted.
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.RemovedNodes.ShouldBe(2); // both removals counted
sink.RemoveCalls.Count.ShouldBe(2);
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed"), AddressSpaceRealm.Uns));
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns));
outcome.RemovedNodes.ShouldBe(1);
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns));
}
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
/// <summary>F10b — a deploy that ONLY flips an existing equipment tag's <c>Writable</c> bit (a plain,
/// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change
/// IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateTagAttributes</c>, preserving every client's
/// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as
/// a change (ChangedNodes == 1).</summary>
[Fact]
public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
// Same TagId/identity; only Writable flips false → true.
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.Writable.ShouldBeTrue(); // the NEW Writable value
call.Historian.ShouldBeNull(); // not historized
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>F10b — flipping <c>IsHistorized</c> false → true (no override) updates in place with the
/// historian tagname defaulting to <c>FullName</c>; flipping true → false updates in place with a null
/// historian tagname. Both skip the rebuild.</summary>
[Fact]
public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian()
{
// false → true (no override) ⇒ historian defaults to FullName.
var sinkOn = new RecordingSink();
var applierOn = new AddressSpaceApplier(sinkOn, NullLogger<AddressSpaceApplier>.Instance);
var planOn = AddressSpacePlanner.Compute(
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)),
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)));
planOn.ChangedEquipmentTags.Count.ShouldBe(1);
var outcomeOn = applierOn.Apply(planOn);
outcomeOn.RebuildCalled.ShouldBeFalse();
sinkOn.RebuildCalls.ShouldBe(0);
sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A"); // default ⇒ FullName
// true → false ⇒ historian null.
var sinkOff = new RecordingSink();
var applierOff = new AddressSpaceApplier(sinkOff, NullLogger<AddressSpaceApplier>.Instance);
var planOff = AddressSpacePlanner.Compute(
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)),
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)));
planOff.ChangedEquipmentTags.Count.ShouldBe(1);
var outcomeOff = applierOff.Apply(planOff);
outcomeOff.RebuildCalled.ShouldBeFalse();
sinkOff.RebuildCalls.ShouldBe(0);
sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull(); // not historized ⇒ null
}
/// <summary>F10b — changing ONLY the <c>HistorianTagname</c> override on an already-historized tag
/// skips the rebuild and updates in place, passing the NEW override verbatim.</summary>
[Fact]
public void Changed_tag_historian_tagname_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old"));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New"));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New"); // override verbatim
}
/// <summary>FB-7 — a tag delta whose <c>DataType</c> changed is now surgical-eligible: the sink swaps the
/// node's DataType in place (and raises a GeneralModelChangeEvent), so the applier SKIPS the rebuild and
/// makes exactly one surgical call carrying the NEW DataType. Here Writable also flips, which the same
/// in-place update applies. Subscriptions are preserved.</summary>
[Fact]
public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
// DataType flips Float → Int32 AND Writable flips false → true — both are now surgically applied.
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.DataType.ShouldBe("Int32"); // the NEW DataType
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
call.IsArray.ShouldBeFalse();
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>FB-7 — a tag delta whose <c>IsArray</c> flag flips scalar → array is now surgical-eligible:
/// the sink swaps ValueRank + ArrayDimensions in place, so the applier skips the rebuild and the surgical
/// call carries the new array shape. An array tag is forced read-only (matching EnsureVariable), so the
/// surgical Writable is false even though the tag stays non-writable here.</summary>
[Fact]
public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.IsArray.ShouldBeTrue(); // the NEW array shape
call.ArrayLength.ShouldBe(16u);
call.DataType.ShouldBe("Int16"); // element type unchanged
call.Writable.ShouldBeFalse(); // array tag forced read-only
}
/// <summary>F10b safe-default — a tag delta whose driver-side <c>FullName</c> changed is NOT
/// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds.</summary>
[Fact]
public void Changed_tag_full_name_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty();
}
/// <summary>F10b safe-default — a tag delta whose <c>Name</c> changed is NOT surgical-eligible (the
/// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds.</summary>
[Fact]
public void Changed_tag_name_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty();
}
/// <summary>F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT
/// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only
/// a rebuild can materialise. No surgical call is made.</summary>
[Fact]
public void Changed_tag_alarm_presence_change_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty();
}
/// <summary>F10b guard — a tag that ALREADY carries an alarm (<c>Alarm is not null</c> on BOTH previous
/// and current, identical alarm info) is NOT surgical-eligible even when only <c>Writable</c> changes.
/// The explicit <c>Alarm is null</c> guard in <c>TagDeltaIsSurgicalEligible</c> prevents a false
/// positive: without it the <c>with { Writable = … }</c> override (which does NOT touch <c>Alarm</c>)
/// would leave both sides with equal <c>Alarm</c> values and the delta would WRONGLY look surgical.
/// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it
/// must go through a full rebuild.</summary>
[Fact]
public void Changed_alarm_bearing_tag_writable_only_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node.
var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
FullName: "00001", Writable: false, Alarm: alarm));
// Only Writable flips; alarm is unchanged (and still non-null on both sides).
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
FullName: "00001", Writable: true, Alarm: alarm));
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else.
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call.
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty(); // NO surgical write on an alarm-bearing tag
}
/// <summary>F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain
/// value variables, no alarm, both changing only <c>Writable</c>), the applier must apply BOTH in
/// place (two <c>UpdateTagAttributes</c> calls) without triggering any rebuild. Proves the surgical
/// path iterates all eligible deltas rather than stopping after the first.</summary>
[Fact]
public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible.
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
};
// Only Writable flips on both tags; everything else is identical.
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: true, Alarm: null),
},
};
var plan = AddressSpacePlanner.Compute(previous, next);
// Guard the arrange: exactly two changed tags, nothing else.
plan.ChangedEquipmentTags.Count.ShouldBe(2);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.AddedEquipment.ShouldBeEmpty();
plan.RemovedEquipment.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// No rebuild — both deltas are surgical-eligible.
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// Exactly two surgical calls — one per eligible delta.
sink.SurgicalCalls.Count.ShouldBe(2);
// Both expected node ids must appear (order is not guaranteed).
var nodeId1 = V3NodeIds.Uns("eq-1", "Speed");
var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure");
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
outcome.ChangedNodes.ShouldBe(2);
}
/// <summary>R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd:
/// the added equipment is created by the Materialise passes and the surgical tag change is applied IN
/// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07
/// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.)</summary>
[Fact]
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
// Surgical-eligible Writable flip on the tag AND a brand-new equipment node.
var next = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
},
};
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipment.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue();
}
/// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot
/// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full
/// rebuild (safe default).</summary>
[Fact]
public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b fallback — when the surgical sink reports the node MISSING
/// (<c>UpdateTagAttributes</c> returns false), the applier falls back to a full rebuild. The surgical
/// call is still attempted (recorded once) before the fallback fires.</summary>
[Fact]
public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild()
{
var sink = new RecordingSink { SurgicalReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
var plan = AddressSpacePlanner.Compute(previous, next);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
// ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) -----
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and
@@ -1337,42 +707,6 @@ public sealed class AddressSpaceApplierTests
// ----- R2-07 T3: pure-add NodeAdded announcements -----
/// <summary>Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the
/// equipment id.</summary>
[Fact]
public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
}
/// <summary>A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment.</summary>
[Fact]
public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder()
{
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") });
}
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
[Fact]
public void ComputeAddAnnouncements_added_alarm_announces_equipment()
@@ -1445,11 +779,11 @@ public sealed class AddressSpaceApplierTests
var plan = EmptyPlan with
{
AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
AddedEquipmentTags = new[]
AddedEquipmentVirtualTags = new[]
{
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null),
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "A", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>()),
new EquipmentVirtualTagPlan("vt-2", "eq-1", FolderPath: "", Name: "B", DataType: "Float", Expression: "2", DependencyRefs: Array.Empty<string>()),
new EquipmentVirtualTagPlan("vt-3", "eq-1", FolderPath: "Diag", Name: "C", DataType: "Float", Expression: "3", DependencyRefs: Array.Empty<string>()),
},
};
@@ -1855,13 +1189,6 @@ public sealed class AddressSpaceApplierTests
Array.Empty<UnsAreaProjection>(), lines, Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
};
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
@@ -1878,6 +1205,10 @@ public sealed class AddressSpaceApplierTests
new(
Array.Empty<EquipmentNode>(), drivers, Array.Empty<ScriptedAlarmPlan>());
private static RawTagPlan RawTag(string tagId, string rawPath, EquipmentTagAlarmInfo? alarm = null) =>
new(tagId, rawPath, ParentNodeId: null, "drv", Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float",
Writable: false, Alarm: alarm, ReferencingEquipmentPaths: Array.Empty<string>());
private static AddressSpacePlan EmptyPlan => new(
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<AddressSpacePlan.EquipmentDelta>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<AddressSpacePlan.DriverDelta>(),
@@ -35,7 +35,7 @@ public sealed class AddressSpaceChangeClassifierTests
}
[Fact]
public void Added_equipment_tag_only_is_PureAdd()
public void Added_raw_tag_only_is_PureAdd()
{
var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") });
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
@@ -65,7 +65,7 @@ public sealed class AddressSpaceChangeClassifierTests
}
[Fact]
public void Removed_equipment_tag_only_is_PureRemove()
public void Removed_raw_tag_only_is_PureRemove()
{
var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") });
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
@@ -116,19 +116,6 @@ public sealed class AddressSpaceChangeClassifierTests
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
[Fact]
public void Non_surgical_changed_tag_is_Rebuild()
{
// FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild.
var plan = Plan(changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", fullName: "40001"),
Tag("tag-1", "eq-1", fullName: "40002")),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
[Fact]
public void Node_relevant_changed_vtag_is_Rebuild()
{
@@ -144,19 +131,6 @@ public sealed class AddressSpaceChangeClassifierTests
// ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly -----
[Fact]
public void Surgical_eligible_changed_tag_only_is_AttributeOnly()
{
// Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes).
var plan = Plan(changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", writable: false),
Tag("tag-1", "eq-1", writable: true)),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
}
[Fact]
public void Node_irrelevant_changed_vtag_only_is_AttributeOnly()
{
@@ -195,36 +169,6 @@ public sealed class AddressSpaceChangeClassifierTests
// ----- mixed-with-surgical: adds still win over coincident surgical changes -----
[Fact]
public void Adds_with_coincident_surgical_change_is_PureAdd()
{
var plan = Plan(
addedTags: new[] { Tag("tag-new", "eq-1") },
changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", writable: false),
Tag("tag-1", "eq-1", writable: true)),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
}
[Fact]
public void Adds_with_coincident_non_surgical_change_is_Rebuild()
{
// Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical
// change forces Rebuild even alongside pure adds.
var plan = Plan(
addedTags: new[] { Tag("tag-new", "eq-1") },
changedTags: new[]
{
new AddressSpacePlan.EquipmentTagDelta(
Tag("tag-1", "eq-1", fullName: "40001"),
Tag("tag-1", "eq-1", fullName: "40002")),
});
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
[Fact]
public void Adds_with_coincident_folder_rename_is_PureAdd()
{
@@ -240,8 +184,9 @@ public sealed class AddressSpaceChangeClassifierTests
private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg");
private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) =>
new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null);
private static RawTagPlan Tag(string id, string device) =>
new(id, $"{device}/{id}", ParentNodeId: device, "drv", Name: id, DataType: "Float", Writable: false, Alarm: null,
ReferencingEquipmentPaths: Array.Empty<string>());
private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") =>
new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" });
@@ -256,9 +201,8 @@ public sealed class AddressSpaceChangeClassifierTests
IReadOnlyList<ScriptedAlarmPlan>? addedAlarms = null,
IReadOnlyList<ScriptedAlarmPlan>? removedAlarms = null,
IReadOnlyList<AddressSpacePlan.AlarmDelta>? changedAlarms = null,
IReadOnlyList<EquipmentTagPlan>? addedTags = null,
IReadOnlyList<EquipmentTagPlan>? removedTags = null,
IReadOnlyList<AddressSpacePlan.EquipmentTagDelta>? changedTags = null,
IReadOnlyList<RawTagPlan>? addedTags = null,
IReadOnlyList<RawTagPlan>? removedTags = null,
IReadOnlyList<EquipmentVirtualTagPlan>? addedVtags = null,
IReadOnlyList<EquipmentVirtualTagPlan>? removedVtags = null,
IReadOnlyList<AddressSpacePlan.EquipmentVirtualTagDelta>? changedVtags = null,
@@ -274,9 +218,8 @@ public sealed class AddressSpaceChangeClassifierTests
removedAlarms ?? Array.Empty<ScriptedAlarmPlan>(),
changedAlarms ?? Array.Empty<AddressSpacePlan.AlarmDelta>())
{
AddedEquipmentTags = addedTags ?? Array.Empty<EquipmentTagPlan>(),
RemovedEquipmentTags = removedTags ?? Array.Empty<EquipmentTagPlan>(),
ChangedEquipmentTags = changedTags ?? Array.Empty<AddressSpacePlan.EquipmentTagDelta>(),
AddedRawTags = addedTags ?? Array.Empty<RawTagPlan>(),
RemovedRawTags = removedTags ?? Array.Empty<RawTagPlan>(),
AddedEquipmentVirtualTags = addedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty<AddressSpacePlan.EquipmentVirtualTagDelta>(),
@@ -30,31 +30,6 @@ public sealed class AddressSpacePlannerTests
plan.IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change)
/// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the
/// IsEmpty gate before materialising the new equipment variables.</summary>
[Fact]
public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1");
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change)
/// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that
/// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate.</summary>
@@ -174,42 +149,6 @@ public sealed class AddressSpacePlannerTests
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>An equipment Tag with the same id but a toggled <c>IsArray</c> flag (and otherwise
/// identical fields) must route to ChangedEquipmentTags. This pins that <c>IsArray</c> is part of
/// <see cref="EquipmentTagPlan.Equals"/> (record value-equality) so an array-flag-only deploy is not
/// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag.</summary>
[Fact]
public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue();
plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with
@@ -223,8 +223,10 @@ public sealed class HistoryUpdateTests : IDisposable
}
/// <summary>
/// An updater removed AFTER materialisation (AccessLevel is not retro-updated) still yields unsupported
/// rather than a null dereference — the override re-checks the updater per request.
/// An updater removed AFTER materialisation retracts the node's HistoryWrite bit (review 01 U3 / B7), so
/// the SDK base refuses the insert with BadHistoryOperationUnsupported before our override runs and the
/// old updater is never reached. (Only <c>errors</c> is asserted: on the SDK-base refusal path the
/// per-node result is left for the master node manager to fill from it.)
/// </summary>
[Fact]
public async Task An_updater_cleared_after_materialisation_is_unsupported()
@@ -233,15 +235,53 @@ public sealed class HistoryUpdateTests : IDisposable
try
{
var nm = server.NodeManager!;
nm.HistorianDataUpdater = new FakeUpdater();
var updater = new FakeUpdater();
nm.HistorianDataUpdater = updater;
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
.ShouldNotBe((byte)0, "granted while the updater was wired (non-vacuity)");
nm.HistorianDataUpdater = null;
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
.ShouldBe((byte)0, "clearing the updater retracts the bit");
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
updater.UpdateCalls.ShouldBeEmpty();
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// Review fix I4 — the override's OWN per-request updater check, which the bit retraction above now hides.
/// It is the B7 race window: a request the SDK base admitted on the HistoryWrite bit just before the updater
/// was cleared. Reconstructed by restoring the bit after the clear, so the request reaches the override with
/// no updater; it must answer unsupported on both slots (the override sets results itself) and never reach the
/// old updater.
/// </summary>
[Fact]
public async Task The_override_rechecks_the_updater_per_request_when_the_bit_is_still_set()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var updater = new FakeUpdater();
nm.HistorianDataUpdater = updater;
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
nm.HistorianDataUpdater = null;
var variable = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!;
variable.AccessLevel |= AccessLevels.HistoryWrite;
variable.UserAccessLevel |= AccessLevels.HistoryWrite;
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override, not left for the master node manager");
updater.UpdateCalls.ShouldBeEmpty();
}
finally { await host.DisposeAsync(); }
}
@@ -12,8 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the
/// first poll cycle's force-raise-everything batch from stalling every SDK service thread).
/// <para>These tests pin the <b>observable</b> contract rather than the locking: every item lands with
/// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly
/// as <see cref="OtOpcUaNodeManager.WriteValue"/> does, and an empty batch does nothing at all.
/// its own Value / StatusCode / Timestamp, the realm travels with every item (the same <c>s=</c> id in the
/// two namespaces is two nodes), and an empty batch does nothing at all. An item for an undeclared NodeId
/// is skipped, never created (review 01 L1 — <see cref="NodeManagerPhantomNodeTests"/>).
/// A non-Good status is included so the #519 verbatim-sub-code passthrough is proven on the batched
/// path too — a batch that re-projected status through a 3-state enum would collapse it to a bare
/// <c>Bad</c>.</para>
@@ -90,16 +91,16 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
/// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned
/// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup).</summary>
[Fact]
public async Task WriteValues_creates_missing_variables()
public async Task WriteValues_is_realm_qualified()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
const string SharedId = "Shared/lazy/id";
nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull();
nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull();
const string SharedId = "Shared/both/id";
nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
nm.WriteValues(new List<ValueWrite>
@@ -143,6 +144,8 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc);
nm.EnsureVariable("eq-iso/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-iso/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteValues(new List<ValueWrite>
{
@@ -187,6 +190,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
var items = new List<ValueWrite>(Count);
for (var i = 0; i < Count; i++)
{
nm.EnsureVariable($"eq-chunk/{i}", null, $"{i}", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns));
}
@@ -0,0 +1,125 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a
/// <see cref="OtOpcUaNodeManager.HistorianDataSource"/> or <see cref="OtOpcUaNodeManager.HistorianDataUpdater"/>
/// assigned after the first apply left existing folders without event history (and existing nodes
/// without HistoryUpdate) until a full rebuild, and a source reset to Null never took the bit back.
/// Both setters now re-evaluate the bits on every existing node.
/// </summary>
public sealed class NodeManagerCapabilityRefreshTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-caprefresh-{Guid.NewGuid():N}");
[Fact]
public async Task A_late_historian_source_grants_event_history_to_already_promoted_folders_and_a_reset_takes_it_back()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, AddressSpaceRealm.Uns);
var folder = nm.TryGetFolder("eq-1")!;
// Promoted while the source is Null: live events only (non-vacuity for the refresh).
(folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0);
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0);
nm.IsEventHistorySource("eq-1").ShouldBeFalse();
nm.HistorianDataSource = new StubSource();
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldNotBe((byte)0, "a late source must light the bit on the existing folder");
nm.IsEventHistorySource("eq-1").ShouldBeTrue();
nm.HistorianDataSource = null!; // the setter maps null to the Null source
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0, "a reset source must take the bit back");
(folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0, "live-event subscription is untouched");
nm.IsEventHistorySource("eq-1").ShouldBeFalse();
}
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task A_late_updater_grants_history_write_to_historized_nodes_only_and_a_reset_takes_it_back()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/dev1/hist", null, "hist", "Float", writable: true, realm: AddressSpaceRealm.Raw, historianTagname: "Hist.A");
nm.EnsureVariable("Plant/dev1/plain", null, "plain", "Float", writable: false, realm: AddressSpaceRealm.Raw);
var hist = nm.TryGetVariable("Plant/dev1/hist", AddressSpaceRealm.Raw)!;
var plain = nm.TryGetVariable("Plant/dev1/plain", AddressSpaceRealm.Raw)!;
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "no updater at materialise time");
nm.HistorianDataUpdater = new StubUpdater();
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0);
(hist.UserAccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0);
(hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0, "the authored Writable survives the refresh");
(hist.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0);
(plain.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "a non-historized node never gets HistoryWrite");
nm.HistorianDataUpdater = null;
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0);
(hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0);
}
finally { await host.DisposeAsync(); }
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.CapRefreshTest",
ApplicationUri = $"urn:OtOpcUa.CapRefreshTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
private sealed class StubSource : IHistorianDataSource
{
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
private sealed class StubUpdater : IHistorianDataUpdater
{
public Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct) =>
throw new NotSupportedException();
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) =>
throw new NotSupportedException();
public Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct) =>
throw new NotSupportedException();
}
}
@@ -292,6 +292,83 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05");
}
/// <summary>
/// #736 (H1 fuzz) — a backend row ceiling sizes the page BELOW NumValuesPerNode (RawPageLimit: ceiling 4 ⇒ 3 data
/// rows per page). When the data exactly fills that page, the end bound must go to the NEXT page through the
/// continuation point, not be appended to a page that is already at its effective cap: the fit used to be
/// judged against NumValuesPerNode (10 here), so the page came back with cap + 1 values.
/// </summary>
[Fact]
public async Task A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page()
{
var source = new SeriesSource(TableData(), reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: 4);
var (first, error, cp) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp: null);
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
// Start bound 5:00 + the three data rows the ceiling allows = the page's effective cap, exactly.
Render(first, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05");
cp.ShouldNotBeNull("the page is full, so the end bound is owed on the next page");
var (second, error2, cp2) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp);
error2.StatusCode.Code.ShouldBe(StatusCodes.Good);
Render(second, T("5:01"), T("5:06")).ShouldBe("5:06");
cp2.ShouldBeNull();
// Control: without a ceiling the same read fits on ONE page of NumValuesPerNode — the fit keys on the
// effective cap, not on a smaller constant.
var (whole, _, cpWhole) = await ReadAsync(Source(Shape.Reverse, TableData()), T("5:01"), T("5:06"), max: 10, bounds: true, cp: null);
Render(whole, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05, 5:06");
cpWhole.ShouldBeNull();
}
/// <summary>
/// Lane B review fix round 1 (minor): an UNCAPPED read (N = 0) against a backend with a row ceiling L pages at
/// L, and MaxReturnDataValues advertises L — so no page, the tie-cluster slices and their end bound included,
/// may carry more than L values; and the pages still concatenate to the uncapped single-shot answer. Swept
/// over ceilings and tie-cluster sizes up to what one over-fetch read can hold (L − 1).
/// </summary>
[Fact]
public async Task An_uncapped_read_never_returns_a_page_larger_than_the_backend_ceiling()
{
foreach (var ceiling in new[] { 2, 3, 4, 6 })
{
for (var ties = 1; ties < ceiling; ties++)
{
var series = new List<DataValueSnapshot> { Snap("5:00", 0) };
for (var i = 0; i < ties; i++) series.Add(Snap("5:02", 10 + i));
series.Add(Snap("5:03", 3));
for (var i = 0; i < ties; i++) series.Add(Snap("5:05", 50 + i));
series.Add(Snap("5:06", 6));
foreach (var (start, end) in new[] { ("5:01", "5:04"), ("5:00", "5:06"), ("5:01", "5:07"), ("5:06", "5:01") })
{
var expected = Render(
(await ReadAsync(new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1), T(start), T(end), 0, true, null)).Values,
T(start), T(end));
var capped = new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: ceiling);
var all = new List<DataValue>();
byte[]? cp = null;
var pages = 0;
do
{
var (values, error, next) = await ReadAsync(capped, T(start), T(end), 0, true, cp);
error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"L={ceiling} ties={ties} {start}->{end}");
values.Count.ShouldBeLessThanOrEqualTo(ceiling, $"L={ceiling} ties={ties} {start}->{end} page {pages}");
all.AddRange(values);
cp = next;
(++pages).ShouldBeLessThan(60, "paging must terminate");
}
while (cp is not null);
Render(all, T(start), T(end)).ShouldBe(expected, $"L={ceiling} ties={ties} {start}->{end}");
}
}
}
static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good, T(at), T(at));
}
/// <summary>
/// #691 review: no data AND no bound found ⇒ <c>GoodNoData</c> with no values — the answer a tag with no history (and a
/// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only
@@ -468,7 +545,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
/// a point read is the instant. <paramref name="resolution"/> &gt; 1 truncates every window bound to it first (QuestDB
/// µs); the series must already be stored at that resolution.
/// </summary>
private sealed class SeriesSource(IReadOnlyList<DataValueSnapshot> series, bool reverse, bool inclusiveForwardEnd, long resolution)
private sealed class SeriesSource(IReadOnlyList<DataValueSnapshot> series, bool reverse, bool inclusiveForwardEnd, long resolution, int ceiling = 0)
: IHistorianDataSource
{
public int ReverseWindowsOnForwardOnly;
@@ -477,6 +554,9 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
public long TimestampResolutionTicks => resolution;
/// <summary>A strict row ceiling (0 = none), so a page is sized below NumValuesPerNode (RawPageLimit).</summary>
public int MaxRawValuesPerRead => ceiling;
private DateTime Trunc(DateTime t) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc);
public Task<HistorianRead> ReadRawAsync(
@@ -211,25 +211,21 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
}
/// <summary>A folder promoted to an event notifier while NO historian was wired (Null source at
/// materialise time) is NOT registered as an event-history source ⇒ a HistoryReadEvents over it yields
/// BadHistoryOperationUnsupported, and the (later-wired) source is never invoked.</summary>
/// materialise time) is not an event-history source ⇒ a HistoryReadEvents over it yields
/// BadHistoryOperationUnsupported. Wiring a real source AFTER promotion re-evaluates the folder
/// (review 01 U3 / B7 — before B7 it stayed unsupported until a rebuild), so the same read then reaches
/// the source.</summary>
[Fact]
public async Task Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported()
public async Task Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
// Materialise the alarm while the source is still the Null default — the folder is promoted to
// SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
const string equipmentId = "eq-nosrc";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
// Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
var fake = new RecordingHistorianDataSource();
nm.HistorianDataSource = fake;
var details = new ReadEventDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
@@ -238,10 +234,55 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
Filter = SelectFilter("EventId"),
};
var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId);
unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
var fake = new RecordingHistorianDataSource();
nm.HistorianDataSource = fake;
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
fake.EventsCalled.ShouldBeTrue();
fake.LastSourceName.ShouldBe(equipmentId);
await host.DisposeAsync();
}
/// <summary>
/// Review fix I4 — the HistoryReadEvents override's OWN source guard. Every other "unsupported" case here is
/// answered by the SDK base, which admits only notifiers carrying the <c>EventNotifier.HistoryRead</c> bit —
/// and since B7 the bit and the source registration move together. So the guard is reached only by a request
/// the base admitted on the bit just before a source reset took the registration back; reconstructed by
/// restoring the bit after the reset. The override must answer unsupported on both slots and reach no source.
/// </summary>
[Fact]
public async Task The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource();
nm.HistorianDataSource = fake;
nm.EnsureFolder("eq-guard", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-g", "eq-guard", "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
nm.IsEventHistorySource("eq-guard").ShouldBeTrue("non-vacuity: registered while the source was wired");
nm.HistorianDataSource = null!;
var folder = nm.TryGetFolder("eq-guard")!;
nm.IsEventHistorySource("eq-guard").ShouldBeFalse();
folder.EventNotifier |= EventNotifiers.HistoryRead;
var details = new ReadEventDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
Filter = SelectFilter("EventId"),
};
var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
fake.LastSourceName.ShouldBeNull(); // source never reached
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard");
fake.EventsCalled.ShouldBeFalse();
await host.DisposeAsync();
@@ -257,8 +298,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
/// SDK base therefore rejects it and does NOT pass it to our <c>HistoryReadEvents</c> override;
/// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins
/// that base-level rejection of variable nodes for event reads.
/// The override's own source-guard (miss in <c>_eventNotifierSources</c>) is exercised by the
/// <c>Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported</c> test instead.
/// The override's own source-guard (a miss in <c>_eventNotifierSources</c>) is exercised by
/// <c>The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source</c>.
/// </para>
/// </summary>
[Fact]
@@ -0,0 +1,196 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the
/// manager-wide SDK <c>Lock</c> on the pinned publish thread, where every Read / Browse / Publish waits.
/// <list type="bullet">
/// <item>P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because
/// <c>NodeState.RemoveChild</c> is a linear scan + <c>RemoveAt</c> per leaf (~50M compares).</item>
/// <item>P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)).</item>
/// <item>P3: every surgical condition removal re-walked every condition for the Server-object
/// monitoring bookkeeping (O(C) per removal) while any <c>i=2253</c> item existed.</item>
/// </list>
/// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit
/// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine.
/// Tagged <c>Category=Benchmark</c>, but no filter excludes that category (CI drops only E2E + LiveIntegration),
/// so it runs by default — on purpose:
/// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget —
/// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also
/// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the
/// machine-independent P3 visit counter is the strict pin.
/// </summary>
[Trait("Category", "Benchmark")]
public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(1);
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-bench-{Guid.NewGuid():N}");
/// <summary>P1 — a rebuild that discards one device folder holding 10k tags completes within the budget.</summary>
[Fact]
public async Task Rebuild_of_a_10k_tag_folder_stays_within_budget()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
const string device = "Plant/Modbus/dev1";
nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw);
for (var i = 0; i < 10_000; i++)
nm.EnsureVariable($"{device}/T{i}", device, $"T{i}", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.VariableCount.ShouldBe(10_000);
var sw = Stopwatch.StartNew();
nm.RebuildAddressSpace();
sw.Stop();
nm.CompleteAddressSpaceApply();
nm.VariableCount.ShouldBe(0);
nm.FolderCount.ShouldBe(0);
// Positive control: the space is really empty for the SDK too, and can be re-populated.
nm.GetManagerHandle(new NodeId($"{device}/T0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))).ShouldBeNull();
nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw);
nm.EnsureVariable($"{device}/T0", device, "T0", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.TryGetVariable($"{device}/T0", AddressSpaceRealm.Raw)!.Parent.ShouldBeSameAs(nm.TryGetFolder(device, AddressSpaceRealm.Raw));
output.WriteLine($"BENCH rebuild 10k-tag folder: {sw.ElapsedMilliseconds} ms");
sw.Elapsed.ShouldBeLessThan(Budget, $"rebuild of a 10k-tag folder took {sw.ElapsedMilliseconds} ms");
}
finally { await host.DisposeAsync(); }
}
/// <summary>P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget.</summary>
[Fact]
public async Task Removing_200_equipment_from_a_50k_node_space_stays_within_budget()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
const int equipmentCount = 200, perEquipment = 250;
for (var e = 0; e < equipmentCount; e++)
{
var equip = $"area/line/eq{e}";
nm.EnsureFolder(equip, null, $"eq{e}", AddressSpaceRealm.Uns);
for (var i = 0; i < perEquipment; i++)
nm.EnsureVariable($"{equip}/S{i}", equip, $"S{i}", "Float", writable: false, realm: AddressSpaceRealm.Uns);
}
// A survivor the removals must not touch.
nm.EnsureFolder("area/line/keep", null, "keep", AddressSpaceRealm.Uns);
nm.EnsureVariable("area/line/keep/S0", "area/line/keep", "S0", "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.VariableCount.ShouldBe(equipmentCount * perEquipment + 1);
var sw = Stopwatch.StartNew();
for (var e = 0; e < equipmentCount; e++)
nm.RemoveEquipmentSubtree($"area/line/eq{e}", AddressSpaceRealm.Uns).ShouldBeTrue();
sw.Stop();
nm.VariableCount.ShouldBe(1);
nm.FolderCount.ShouldBe(1);
nm.TryGetVariable("area/line/keep/S0").ShouldNotBeNull();
nm.GetManagerHandle(new NodeId("area/line/eq0/S0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))).ShouldBeNull();
output.WriteLine($"BENCH remove 200 equipment / 50k nodes: {sw.ElapsedMilliseconds} ms");
sw.Elapsed.ShouldBeLessThan(Budget, $"removing 200 equipment took {sw.ElapsedMilliseconds} ms");
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// P3 — with a Server-object item live, a surgical condition removal lowers only that condition's
/// monitoring unit instead of re-walking every condition: the bookkeeping visits at most one
/// condition per removal. (A full reconcile visits every condition twice — desired + held.)
/// </summary>
[Fact]
public async Task A_surgical_condition_removal_visits_only_its_own_condition()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
const int conditions = 50;
for (var i = 0; i < conditions; i++)
nm.MaterialiseAlarmCondition($"alm-{i}", "eq-1", $"Alarm {i}", "OffNormalAlarm", 500, AddressSpaceRealm.Uns);
// A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play.
nm.SubscribeToAllEvents(
new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null),
1, new StubEventItem(), unsubscribe: false);
nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised");
var before = nm.AllEventsMonitoringConditionVisits;
for (var i = 0; i < 10; i++)
nm.RemoveAlarmConditionNode($"alm-{i}", AddressSpaceRealm.Uns).ShouldBeTrue();
var visits = nm.AllEventsMonitoringConditionVisits - before;
output.WriteLine($"BENCH all-events visits for 10 condition removals over {conditions} conditions: {visits}");
visits.ShouldBeLessThanOrEqualTo(10, $"ten removals visited {visits} conditions; a full reconcile per removal would visit ~{10 * 2 * conditions}");
// The survivors keep their unit.
nm.ConditionEventsMonitored("alm-49", AddressSpaceRealm.Uns).ShouldBeTrue();
}
finally { await host.DisposeAsync(); }
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.BenchTest",
ApplicationUri = $"urn:OtOpcUa.BenchTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
/// <summary>The minimum of a Server-object event item the all-events bookkeeping touches: identity only.</summary>
private sealed class StubEventItem : IEventMonitoredItem
{
public bool MonitoringAllEvents => true;
public NodeId NodeId => ObjectIds.Server;
public IUserIdentity EffectiveIdentity => null!;
public object ManagerHandle => null!;
public void QueueEvent(IFilterTarget instance) { }
public void QueueEvent(IFilterTarget instance, bool bypassFilter) { }
public void Dispose() { }
public INodeManager NodeManager => throw new NotSupportedException();
public ISession Session => throw new NotSupportedException();
public uint Id => 1;
public uint SubscriptionId => 1;
public bool IsDurable => false;
public uint ClientHandle => 1;
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public int MonitoredItemType => MonitoredItemTypeMask.Events;
public bool IsReadyToPublish => false;
public bool IsReadyToTrigger { get => false; set { } }
public bool IsResendData => false;
public MonitoringMode MonitoringMode => MonitoringMode.Reporting;
public double SamplingInterval => 0;
public EventFilter EventFilter => null!;
public void SetupResendDataTrigger() { }
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
public bool Publish(OperationContext context, Queue<EventFieldList> notifications, uint maxNotificationsPerPublish) => throw new NotSupportedException();
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
}
}
@@ -0,0 +1,67 @@
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// #736 — assigning <see cref="OtOpcUaNodeManager.HistorianDataSource"/> re-syncs
/// <c>HistoryServerCapabilities/MaxReturnDataValues</c> (i=11273) to the source's
/// <see cref="IHistorianDataSource.MaxRawValuesPerRead"/>, read back the way a client would. The Null
/// source (the historian-disabled default) advertises Part 11's "no limit".
/// </summary>
public sealed class NodeManagerMaxReturnDataValuesSyncTests(BoundsServerFixture fx) : IClassFixture<BoundsServerFixture>
{
[Fact]
public async Task Setting_the_historian_source_advertises_its_raw_cap()
{
await fx.Gate.WaitAsync(TestContext.Current.CancellationToken);
try
{
fx.Nm.HistorianDataSource = new CappedSource(7_000);
(await ReadAdvertisedAsync()).ShouldBe(7_000u);
fx.Nm.HistorianDataSource = new CappedSource(250);
(await ReadAdvertisedAsync()).ShouldBe(250u);
fx.Nm.HistorianDataSource = null!; // the Null default: no cap
(await ReadAdvertisedAsync()).ShouldBe(0u);
}
finally
{
fx.Nm.HistorianDataSource = null!;
fx.Gate.Release();
}
}
private async Task<uint> ReadAdvertisedAsync()
{
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null);
var nodesToRead = new ReadValueIdCollection
{
new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value },
};
var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken);
values.Count.ShouldBe(1);
StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString());
return (uint)values[0].Value;
}
private sealed class CappedSource(int cap) : IHistorianDataSource
{
public int MaxRawValuesPerRead => cap;
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
}
@@ -0,0 +1,231 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Review 01 L1 / L4 / H2 — the node manager never materialises a node from a VALUE or ALARM write, and
/// never lets one <c>(realm, id)</c> be held by two different node kinds.
/// <para>
/// The lazy create-on-first-write path predates <see cref="OtOpcUaNodeManager.EnsureVariable"/> and had
/// no production producer left — but it had a production <i>consumer</i> by accident: a driver publish
/// (or the UNS fan-out) racing a surgical remove re-created the removed id as a flat, read-only,
/// <c>BaseDataType</c> variable under the shared root, and because <c>EnsureVariable</c> early-returns
/// on an existing id, a later re-add of the same tag silently kept the phantom's shape.
/// </para>
/// </summary>
public sealed class NodeManagerPhantomNodeTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-phantom-{Guid.NewGuid():N}");
/// <summary>A value write to an id nothing declared creates nothing.</summary>
[Fact]
public async Task WriteValue_for_an_undeclared_id_creates_no_node()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var before = nm.VariableCount;
nm.WriteValue("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.VariableCount.ShouldBe(before);
nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull();
nm.GetManagerHandle(new NodeId("Plant/Modbus/dev1/Ghost", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw)))
.ShouldBeNull("the SDK must not resolve a node that was never declared");
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A batch naming a removed id skips it (no node, no exception) while the declared ids in the same batch
/// land — the positive control that proves the skip is a skip and not a dropped batch.
/// </summary>
[Fact]
public async Task WriteValues_skips_undeclared_ids_and_still_applies_the_declared_ones()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/Modbus/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
var ts = DateTime.UtcNow;
nm.WriteValues(
[
new ValueWrite("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
]);
nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull();
nm.TryGetVariable("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw)!.Value.ShouldBe(42);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// The L1 scenario end to end: a publish racing a surgical remove leaves NO node, and the re-add that
/// follows gets the AUTHORED DataType, parent and writability rather than inheriting a phantom's.
/// </summary>
[Fact]
public async Task A_publish_racing_a_surgical_remove_leaves_no_node_and_the_readd_gets_the_authored_shape()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
const string id = "Plant/Modbus/dev1/Speed";
nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw);
nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Float", writable: false, realm: AddressSpaceRealm.Raw);
nm.RemoveVariableNode(id, AddressSpaceRealm.Raw).ShouldBeTrue();
// The race: the driver's fan-out publishes the id the surgical remove just dropped.
nm.WriteValue(id, 1.5f, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.TryGetVariable(id, AddressSpaceRealm.Raw).ShouldBeNull("the racing publish must not resurrect the node");
// The re-add (a later deploy re-authors the tag with a different shape).
nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Int32", writable: true, realm: AddressSpaceRealm.Raw);
var v = nm.TryGetVariable(id, AddressSpaceRealm.Raw);
v.ShouldNotBeNull();
v!.DataType.ShouldBe(DataTypeIds.Int32);
v.Parent.ShouldBeSameAs(nm.TryGetFolder("Plant/Modbus/dev1", AddressSpaceRealm.Raw));
((int)v.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe(0, "the re-added node is writable as authored");
v.OnWriteValue.ShouldNotBeNull("a writable node carries the inbound-write handler");
// Positive control: the re-added node is live — a publish lands on it.
nm.WriteValue(id, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
v.Value.ShouldBe(7);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A condition write for an id that was never materialised is a logged no-op (L4): no placeholder variable.</summary>
[Fact]
public async Task WriteAlarmCondition_for_an_unknown_id_creates_nothing()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
var before = nm.VariableCount;
var snapshot = new AlarmConditionSnapshot(
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 0, Message: string.Empty);
// The applier used to write this against the EQUIPMENT id on every removal — a folder, never a condition.
nm.WriteAlarmCondition("eq-1", snapshot, DateTime.UtcNow, AddressSpaceRealm.Uns);
nm.VariableCount.ShouldBe(before);
nm.TryGetVariable("eq-1").ShouldBeNull();
// The folder's SDK lookup entry survives (the phantom used to REPLACE it — AddPredefinedNode is AddOrUpdate).
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns)));
handle.ShouldNotBeNull();
handle!.Node.ShouldBeOfType<FolderState>();
}
finally { await host.DisposeAsync(); }
}
/// <summary>H2 — a variable may not take an id a folder already holds in the same realm.</summary>
[Fact]
public async Task EnsureVariable_refuses_an_id_held_by_a_folder()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1", null, "eq-1", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.TryGetVariable("eq-1").ShouldBeNull();
nm.TryGetFolder("eq-1").ShouldNotBeNull();
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns)));
handle!.Node.ShouldBeSameAs(nm.TryGetFolder("eq-1"));
}
finally { await host.DisposeAsync(); }
}
/// <summary>H2 — a folder may not take an id a variable already holds in the same realm.</summary>
[Fact]
public async Task EnsureFolder_refuses_an_id_held_by_a_variable()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/X", null, "X", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureFolder("Plant/X", null, "X", AddressSpaceRealm.Raw);
nm.TryGetFolder("Plant/X", AddressSpaceRealm.Raw).ShouldBeNull();
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("Plant/X", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw)));
handle!.Node.ShouldBeSameAs(nm.TryGetVariable("Plant/X", AddressSpaceRealm.Raw));
}
finally { await host.DisposeAsync(); }
}
/// <summary>H2 — a condition may not take an id a variable already holds in the same realm.</summary>
[Fact]
public async Task MaterialiseAlarmCondition_refuses_an_id_held_by_a_variable()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw);
nm.EnsureVariable("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true);
nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldBeNull();
nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
}
finally { await host.DisposeAsync(); }
}
/// <summary>The same bare id in the OTHER realm is a different node and is still allowed (the guard is realm-qualified).</summary>
[Fact]
public async Task The_guard_is_realm_qualified()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("shared", null, "shared", AddressSpaceRealm.Uns);
nm.EnsureVariable("shared", null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.TryGetVariable("shared", AddressSpaceRealm.Raw).ShouldNotBeNull();
nm.TryGetFolder("shared", AddressSpaceRealm.Uns).ShouldNotBeNull();
}
finally { await host.DisposeAsync(); }
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.PhantomTest",
ApplicationUri = $"urn:OtOpcUa.PhantomTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
}
@@ -0,0 +1,230 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Review 01 L2, the half the wire tests (<c>StaleMonitoredNodeWireTests</c>) cannot reach: an item the
/// ACL revalidation pass has STOOD DOWN is on no SDK fan-out list, so the tombstone a removal runs must find
/// it through the revalidation map. Otherwise it keeps a component-cache reference to the dead node — and
/// the cache hands that node to the next item created on the NodeId.
/// </summary>
public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
{
private const string Cluster = "SITE-A";
private const string Principal = "svc";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{Guid.NewGuid():N}");
/// <summary>A surgical remove tells a stood-down item and drops it from the revalidation map.</summary>
[Fact]
public async Task A_surgical_remove_tells_and_forgets_a_stood_down_item()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var (item, nodeId) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
nm.RemoveVariableNode("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw).ShouldBeTrue();
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
nm.LiveItemCount.ShouldBe(0, "the item's node is gone; the map must not keep re-authorizing it");
nm.IsNodeMonitored(nodeId).ShouldBeFalse();
}
finally { await host.DisposeAsync(); }
}
/// <summary>A rebuild sweeps every stood-down item.</summary>
[Fact]
public async Task A_rebuild_tells_and_forgets_every_stood_down_item()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
nm.RebuildAddressSpace();
nm.CompleteAddressSpaceApply();
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
nm.LiveItemCount.ShouldBe(0);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// Positive control for the sweep: a stood-down item on a SURVIVING node is left alone by a surgical
/// remove of a sibling, and the next permitted pass restores it.
/// </summary>
[Fact]
public async Task A_stood_down_item_on_a_surviving_node_is_untouched_and_still_restorable()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/Modbus/dev1/Other", null, "Other", "Float", writable: false, realm: AddressSpaceRealm.Raw);
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
nm.RemoveVariableNode("Plant/Modbus/dev1/Other", AddressSpaceRealm.Raw).ShouldBeTrue();
item.Statuses.ShouldNotContain((StatusCode)StatusCodes.BadNodeIdUnknown);
nm.LiveItemCount.ShouldBe(1);
// Grant restored ⇒ the pass re-attaches it (a second stand-down proves the restore happened).
nm.Authorizer = Authorizer(permits: true);
nm.RevalidateMonitoredItems();
nm.Authorizer = Authorizer(permits: false);
nm.RevalidateMonitoredItems();
item.Statuses.Count(s => s == StatusCodes.BadUserAccessDenied).ShouldBe(2);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// Review fix round 1 (minor): the same-id kind swap (scripted ⇄ native) in <c>MaterialiseAlarmCondition</c>
/// drops the old condition instance — one more removal path, so it tombstones like the others. Believed
/// unreachable from a composition (native ids are RawPaths in Raw, scripted ids live in Uns), so it is driven
/// directly.
/// </summary>
[Fact]
public async Task A_condition_kind_swap_tells_and_forgets_items_on_the_dropped_instance()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false);
var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex);
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
handle.ShouldNotBeNull("non-vacuity: the condition is materialised");
var item = new StubItem(handle, Identity());
nm.TrackLiveMonitoredItem(handle, item);
nm.Authorizer = Authorizer(permits: false);
nm.RevalidateMonitoredItems();
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: stood down");
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: true);
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told");
nm.LiveItemCount.ShouldBe(0);
((NodeHandle)nm.GetManagerHandle(nodeId)).ShouldNotBeNull("positive control: the swapped-in condition exists");
}
finally { await host.DisposeAsync(); }
}
/// <summary>Registers a stub item on <paramref name="rawPath"/> and stands it down through a denying pass.</summary>
private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath)
{
nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw);
var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
var item = new StubItem(handle, Identity());
nm.TrackLiveMonitoredItem(handle, item);
nm.Authorizer = Authorizer(permits: false);
nm.RevalidateMonitoredItems();
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: the item really was stood down");
await Task.CompletedTask;
return (item, nodeId);
}
private static DataPlaneAuthorizer Authorizer(bool permits)
{
var state = new DataPlaneAuthorityState();
var grants = permits
? new[]
{
new NodeAcl
{
NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal,
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Subscribe,
},
}
: [];
state.Apply(Cluster, "rev-1", [], grants, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
}
private static RoleCarryingUserIdentity Identity() =>
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.StaleTest",
ApplicationUri = $"urn:OtOpcUa.StaleTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
/// <summary>The minimum of a sampled item the removal path touches: its handle, identity and value queue.</summary>
private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem
{
public List<StatusCode> Statuses { get; } = [];
public IUserIdentity EffectiveIdentity => identity;
public object ManagerHandle => handle;
public NodeId NodeId => handle.NodeId;
public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode);
public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false);
public void Dispose() { }
public INodeManager NodeManager => throw new NotSupportedException();
public ISession Session => throw new NotSupportedException();
public uint Id => throw new NotSupportedException();
public uint SubscriptionId => throw new NotSupportedException();
public bool IsDurable => throw new NotSupportedException();
public uint ClientHandle => throw new NotSupportedException();
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public int MonitoredItemType => throw new NotSupportedException();
public bool IsReadyToPublish => throw new NotSupportedException();
public bool IsReadyToTrigger { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public bool IsResendData => throw new NotSupportedException();
public MonitoringMode MonitoringMode => throw new NotSupportedException();
public double SamplingInterval => throw new NotSupportedException();
public DataChangeFilter DataChangeFilter => throw new NotSupportedException();
public uint AttributeId => throw new NotSupportedException();
public NumericRange IndexRange => throw new NotSupportedException();
public QualifiedName DataEncoding => throw new NotSupportedException();
public bool AlwaysReportUpdates { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public DiagnosticsMasks DiagnosticsMasks => throw new NotSupportedException();
public uint QueueSize => throw new NotSupportedException();
public double MinimumSamplingInterval => throw new NotSupportedException();
public int TimeToNextSample => throw new NotSupportedException();
public void SetupResendDataTrigger() => throw new NotSupportedException();
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
public bool Publish(OperationContext context, Queue<MonitoredItemNotification> notifications, Queue<DiagnosticInfo> diagnostics, uint maxNotificationsPerPublish, ILogger logger) => throw new NotSupportedException();
public void SetSemanticsChanged() => throw new NotSupportedException();
public void SetStructureChanged() => throw new NotSupportedException();
public bool SamplingIntervalExpired() => throw new NotSupportedException();
public ReadValueId GetReadValueId() => throw new NotSupportedException();
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException();
}
}
@@ -0,0 +1,160 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Review 01 L3 / H5 / H4 — the device-write dispatch behind <see cref="OtOpcUaNodeManager.OnEquipmentTagWrite"/>
/// and the manager's clock: a gateway that throws synchronously is refused inline (the handler runs under
/// the SDK <c>Lock</c>, and the throw used to escape it), a faulted dispatch task is observed and still
/// reverts the optimistic value, and the timestamps the manager stamps come from the injected
/// <see cref="OtOpcUaNodeManager.Clock"/>.
/// </summary>
public sealed class NodeManagerWriteDispatchTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dispatch-{Guid.NewGuid():N}");
/// <summary>H5 — a synchronous throw out of the gateway is a Bad result, never an escaped exception.</summary>
[Fact]
public async Task A_gateway_that_throws_synchronously_is_refused_inline()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var result = nm.DispatchDeviceWrite(
new ThrowingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
ServiceResult.IsBad(result).ShouldBeTrue();
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadDeviceFailure);
result.ToString().ShouldContain("boom");
nm.TryGetVariable("eq-1/sp")!.Value.ShouldBe(7, "nothing was applied, so nothing needs reverting");
}
finally { await host.DisposeAsync(); }
}
/// <summary>L3 — a faulted dispatch task is observed and the optimistic value is reverted with its reason.</summary>
[Fact]
public async Task A_faulted_dispatch_is_observed_and_reverts_the_optimistic_value()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var result = nm.DispatchDeviceWrite(
new FaultingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
ServiceResult.IsGood(result).ShouldBeTrue("the dispatch itself started; the SDK applies the value optimistically");
node.Value = 42; // what the SDK does on Good
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && !Equals(node.Value, 7)) await Task.Delay(20, Ct);
node.Value.ShouldBe(7, "the faulted dispatch must revert the optimistic value");
node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
}
finally { await host.DisposeAsync(); }
}
/// <summary>Positive control for the two above: a gateway that succeeds leaves the optimistic value standing.</summary>
[Fact]
public async Task A_successful_dispatch_leaves_the_optimistic_value()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
nm.DispatchDeviceWrite(
new SucceedingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
node.Value = 42;
await Task.Delay(200, Ct);
node.Value.ShouldBe(42);
}
finally { await host.DisposeAsync(); }
}
/// <summary>H4 — the revert stamps the manager's clock, not the wall clock.</summary>
[Fact]
public async Task The_revert_timestamps_come_from_the_injected_clock()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var fixedNow = new DateTime(2030, 1, 2, 3, 4, 5, DateTimeKind.Utc);
nm.Clock = new FixedClock(fixedNow);
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42;
nm.RevertOptimisticWriteIfNeeded(
"eq-1/sp", new NodeWriteOutcome(false, "rejected"), optimisticValue: 42, priorValue: 7,
priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Timestamp.ShouldBe(fixedNow);
OtOpcUaNodeManager.RemainingShelveMs(fixedNow.AddSeconds(30), fixedNow).ShouldBe(30_000);
}
finally { await host.DisposeAsync(); }
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.DispatchTest",
ApplicationUri = $"urn:OtOpcUa.DispatchTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
private sealed class ThrowingGateway : IOpcUaNodeWriteGateway
{
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
throw new InvalidOperationException("boom");
}
private sealed class FaultingGateway : IOpcUaNodeWriteGateway
{
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromException<NodeWriteOutcome>(new TimeoutException("gateway ask timed out"));
}
private sealed class SucceedingGateway : IOpcUaNodeWriteGateway
{
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(true, null));
}
private sealed class FixedClock(DateTime utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero);
}
}
@@ -0,0 +1,74 @@
using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>Review 01 P6 — the SDK request and transport quotas are configurable (<c>OpcUa:Quotas:*</c>) and
/// default to exactly what used to be hard-coded.</summary>
public sealed class OpcUaApplicationHostQuotaTests
{
[Fact]
public void Default_request_quotas_are_the_former_hard_coded_values()
{
var config = new ServerConfiguration();
OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions());
config.MinRequestThreadCount.ShouldBe(5);
config.MaxRequestThreadCount.ShouldBe(100);
config.MaxQueuedRequestCount.ShouldBe(200);
}
[Fact]
public void Configured_request_quotas_are_applied()
{
var config = new ServerConfiguration();
OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions
{
MinRequestThreadCount = 8, MaxRequestThreadCount = 250, MaxQueuedRequestCount = 1000,
});
config.MinRequestThreadCount.ShouldBe(8);
config.MaxRequestThreadCount.ShouldBe(250);
config.MaxQueuedRequestCount.ShouldBe(1000);
}
[Fact]
public void Unset_transport_quotas_keep_the_sdk_defaults()
{
var sdkDefault = new TransportQuotas();
var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions());
built.OperationTimeout.ShouldBe(sdkDefault.OperationTimeout);
built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength);
built.MaxByteStringLength.ShouldBe(sdkDefault.MaxByteStringLength);
built.MaxArrayLength.ShouldBe(sdkDefault.MaxArrayLength);
built.MaxMessageSize.ShouldBe(sdkDefault.MaxMessageSize);
built.MaxBufferSize.ShouldBe(sdkDefault.MaxBufferSize);
built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime);
built.SecurityTokenLifetime.ShouldBe(sdkDefault.SecurityTokenLifetime);
}
[Fact]
public void Configured_transport_quotas_replace_only_what_is_set()
{
var sdkDefault = new TransportQuotas();
var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions
{
MaxMessageSize = 8 * 1024 * 1024, OperationTimeoutMs = 45_000, MaxArrayLength = 1_000_000,
});
built.MaxMessageSize.ShouldBe(8 * 1024 * 1024);
built.OperationTimeout.ShouldBe(45_000);
built.MaxArrayLength.ShouldBe(1_000_000);
built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength);
built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime);
}
[Fact]
public void History_read_batch_cap_default_scales_with_the_machine()
{
OtOpcUaNodeManager.DefaultMaxConcurrentHistoryReadBatches
.ShouldBe(Math.Clamp(Environment.ProcessorCount * 2, 4, 16));
}
}
@@ -19,8 +19,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// <para><b>Never creates.</b> A status carries nothing to create a node from, and the caller's
/// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id
/// is skipped, not materialised — the opposite of <see cref="OtOpcUaNodeManager.WriteValues"/>,
/// which lazily creates.</para>
/// is skipped, not materialised — the same contract <see cref="OtOpcUaNodeManager.WriteValues"/>
/// carries since review 01 L1 (it used to lazily create).</para>
///
/// <para>Boots a real <see cref="OtOpcUaSdkServer"/>, like
/// <see cref="NodeManagerBatchWriteTests"/>, so the assertions read the SDK node state a client
@@ -81,6 +81,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
const string sharedId = "Shared/both/realms";
nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
@@ -110,6 +112,7 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
nm.EnsureVariable("Plant/known", null, "known", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
var before = nm.VariableCount;
@@ -143,6 +146,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
nm.EnsureVariable("Plant/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("Plant/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
@@ -20,18 +20,25 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
Path.GetTempPath(),
$"otopcua-sink-{Guid.NewGuid():N}");
/// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
/// <summary>Verifies that WriteValue updates declared variables in the OPC UA node manager (and never
/// creates one — review 01 L1, pinned by <see cref="NodeManagerPhantomNodeTests"/>).</summary>
[Fact]
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
public async Task WriteValue_updates_declared_variables_in_node_manager()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("eq-1/pressure", null, "pressure", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/undeclared", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
nm.VariableCount.ShouldBe(2);
nm.TryGetVariable("eq-1/temp")!.Value.ShouldBe(23.1);
nm.TryGetVariable("eq-1/pressure")!.StatusCode.ShouldBe((StatusCode)StatusCodes.Uncertain);
await host.DisposeAsync();
}
@@ -50,6 +57,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// (a concrete-typed call would simply stop compiling, which is not the regression being pinned).
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
sink.EnsureVariable("Plant/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
sink.EnsureVariable("eq-1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValues(new List<ValueWrite>
{
@@ -91,6 +100,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc);
sink.EnsureVariable("eq-fwd/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("eq-fwd/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
var ex = Should.Throw<ValueWriteBatchException>(() => sink.WriteValues(new List<ValueWrite>
{
@@ -113,18 +124,22 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
}
}
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
/// distinct from value writes.</summary>
/// <summary>A WriteAlarmCondition for an id no condition was materialised under is a no-op (review 01 L4):
/// it creates no placeholder node, and the value write beside it is unaffected.</summary>
[Fact]
public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
public async Task WriteAlarmCondition_for_an_unmaterialised_id_creates_no_node()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
nm.VariableCount.ShouldBe(1);
nm.TryGetVariable("alarm-7").ShouldBeNull();
nm.TryGetAlarmCondition("alarm-7").ShouldBeNull();
await host.DisposeAsync();
}
@@ -242,17 +257,21 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("b", null, "b", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("c", null, "c", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(3);
sink.RebuildAddressSpace();
server.NodeManager.VariableCount.ShouldBe(0);
// After rebuild, subsequent writes start fresh.
// After rebuild, subsequent materialisation starts fresh.
sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager.VariableCount.ShouldBe(1);
server.NodeManager.TryGetVariable("a")!.Value.ShouldBe(99);
await host.DisposeAsync();
}
@@ -0,0 +1,64 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Lane B review N4 — <see cref="VariableShape.TryFit"/> decides whether a value deferred during a plan window may
/// be replayed onto a node an in-place edit re-shaped: never an old-typed value the new DataType contradicts.
/// </summary>
public sealed class VariableShapeTests
{
[Fact]
public void A_value_of_the_new_type_fits_unchanged()
{
new VariableShape("Int32", false, null).TryFit(7, out var fitted).ShouldBeTrue();
fitted.ShouldBe(7);
}
[Fact]
public void A_convertible_old_typed_value_is_converted_to_the_new_type()
{
new VariableShape("Int32", false, null).TryFit(5.0f, out var fitted).ShouldBeTrue();
fitted.ShouldBeOfType<int>().ShouldBe(5);
new VariableShape("Double", false, null).TryFit((short)3, out var d).ShouldBeTrue();
d.ShouldBeOfType<double>().ShouldBe(3d);
}
[Theory]
[InlineData("Int32", "fast")] // not a number
[InlineData("Byte", 300)] // overflow
[InlineData("DateTime", 12)] // no conversion
public void A_value_the_new_type_cannot_hold_does_not_fit(string dataType, object value)
{
new VariableShape(dataType, false, null).TryFit(value, out var fitted).ShouldBeFalse();
fitted.ShouldBeNull();
}
[Fact]
public void Scalar_and_array_never_cross()
{
new VariableShape("Int32", false, null).TryFit(new[] { 1, 2 }, out _).ShouldBeFalse();
new VariableShape("Int32", true, 2).TryFit(1, out _).ShouldBeFalse();
new VariableShape("String", true, null).TryFit("ab", out _).ShouldBeFalse("a string is not an array of strings");
}
[Fact]
public void An_array_is_converted_element_wise_and_its_fixed_length_enforced()
{
new VariableShape("Int16", true, 3).TryFit(new[] { 1, 2, 3 }, out var fitted).ShouldBeTrue();
fitted.ShouldBeOfType<short[]>().ShouldBe(new short[] { 1, 2, 3 });
new VariableShape("Int16", true, 4).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeFalse("wrong length");
new VariableShape("Int16", true, null).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeTrue("no fixed length");
new VariableShape("Byte", true, null).TryFit(new[] { 1, 999 }, out _).ShouldBeFalse("an element overflows");
}
[Fact]
public void Null_and_unknown_types_always_fit()
{
new VariableShape("Int32", false, null).TryFit(null, out var n).ShouldBeTrue();
n.ShouldBeNull();
new VariableShape("Structure", false, null).TryFit("anything", out var any).ShouldBeTrue();
any.ShouldBe("anything");
}
}
@@ -0,0 +1,123 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using S7DataItem = global::S7.Net.Types.DataItem;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Lane S7 review I-1, end to end: the REAL <see cref="S7Driver"/> under the REAL
/// <see cref="DriverInstanceActor"/> comms-loss watchdog. A CPU with PUT/GET disabled denies every
/// read but is reachable and answering; the driver reports <c>Faulted</c> (the config alert) and must
/// still advance <c>LastSuccessfulRead</c>, so the watchdog's "non-Healthy AND silent for the
/// threshold" conjunction is never met and the actor does NOT reconnect + re-subscribe. The
/// positive control is the same rig with a real comms loss, which DOES trip it.
/// </summary>
public sealed class S7AccessDeniedWatchdogTests : RuntimeActorTestBase
{
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50);
/// <summary>The watchdog threshold — an order of magnitude above the poll so the window is a decision, not a race.</summary>
private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(1500);
private sealed class ScriptedPlcFactory(Func<Exception> readFault) : IS7PlcFactory
{
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
=> new ScriptedPlc(readFault);
}
private sealed class ScriptedPlc(Func<Exception> readFault) : IS7Plc
{
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken cancellationToken) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken) => Task.FromException<object?>(readFault());
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
=> Task.FromException<byte[]>(readFault());
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
=> Task.FromException<IReadOnlyList<S7DataItem>>(readFault());
public Task WriteAsync(string address, object value, CancellationToken cancellationToken) => Task.CompletedTask;
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
private (S7Driver Driver, Akka.TestKit.TestProbe Parent) Connect(Func<Exception> readFault)
{
var driver = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
SpanReadMaxGapBytes = -1,
RawTags =
[
new RawTagEntry("W0", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), false),
new RawTagEntry("W2", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W2", "DB1.DBW2", S7DataType.UInt16, Writable: false)), false),
],
}, "s7-watchdog", new ScriptedPlcFactory(readFault));
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromSeconds(30),
healthPollInterval: FastPoll,
commsLossTimeout: Threshold));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(TimeSpan.FromSeconds(5)).Connected.ShouldBeTrue();
return (driver, parent);
}
/// <summary>Drives read passes (the poll the actor would schedule) until <paramref name="until"/>.</summary>
private static async Task<(int Passes, int FaultedPasses)> PollUntilAsync(S7Driver driver, DateTime until)
{
var passes = 0;
var faulted = 0;
while (DateTime.UtcNow < until)
{
try
{
#pragma warning disable OTOPCUA0001 // the watchdog's reading of driver health is under test, not resilience wrapping
await driver.ReadAsync(["W0", "W2"], CancellationToken.None);
#pragma warning restore OTOPCUA0001
passes++;
if (driver.GetHealth().State == DriverState.Faulted) faulted++;
}
catch (InvalidOperationException) { /* the actor tore the driver down (control case) */ }
await Task.Delay(100); // pacing the simulated poll, not an assertion
}
return (passes, faulted);
}
/// <summary>An all-access-denied CPU is left connected across more than twice the watchdog threshold.</summary>
[Fact]
public async Task An_access_denied_cpu_is_not_reconnected_by_the_watchdog()
{
var (driver, parent) = Connect(() => new Exception("Received error from PLC: Accessing object not allowed."));
var (passes, faulted) = await PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500));
// Positive controls: the window really ran past the threshold with the driver Faulted throughout,
// so "no reconnect" below cannot pass merely because nothing happened.
passes.ShouldBeGreaterThanOrEqualTo(10);
faulted.ShouldBe(passes, "every pass reports the PUT/GET config alert");
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>The control: a real comms loss (every read times out) on the same rig IS reconnected.</summary>
[Fact]
public async Task A_real_comms_loss_is_reconnected_by_the_watchdog()
{
var (driver, parent) = Connect(() => new TimeoutException("frozen peer"));
var poll = PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
await poll;
}
}

Some files were not shown because too many files have changed in this diff Show More