Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs
T
Joseph Doherty 081fa693af fix(s7): access-denied is an answer; review round-1 minors (R07:H4 follow-through)
I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with
LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and
re-subscribed it every 90 s forever. Both access-denied arms now also record
an answer: Faulted is kept (worst state wins), the stamp advances, and the
sticky write fault still clears only on a real success. Pinned end to end in
Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog
is not reconnected across 2x the threshold; the control (every read timing
out) is.

Minors:
- m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now
  asserted strictly, including the write-refusal case.
- m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list.
- m-3: a release that races an init publish also cancels the probe loop the
  publish started after the release took its snapshot.
- m-4: a retained poll failing during a lifecycle operation writes no health.
- m-5: a multi-var chunk that fell back memoises its members refused alone.
- m-7: access-denied needs S7.Net's exact wording and no transport fault in
  the chain.
- m-8: sync teardown clears the write-side Degraded owner; a sync Dispose
  racing a running shutdown is tested.

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:57:53 -04:00

214 lines
9.9 KiB
C#

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)");
}
}