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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@
|
||||
re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<!-- Lane S7 review I-1: the real S7 driver (over a scripted IS7Plc), so the comms-loss watchdog test proves an
|
||||
access-denied (PUT/GET disabled) CPU is NOT reconnected every 90 s, against the real actor. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user