Files
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

86 lines
4.4 KiB
C#

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