081fa693af
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
214 lines
9.9 KiB
C#
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)");
|
|
}
|
|
}
|