fix(abcip): a refused probe tag proves the device reachable (no blackout)
H10: the probe loop recorded EVERY exception - including the controller answering NotFound for a typo'd or download-deleted Probe.ProbeTagPath - as a Forward-Open failure. At a 5 s tick against a 30 s backoff cap ShouldAttempt was never true again, so every cold or evicted data tag on a reachable device read BadCommunicationError indefinitely and the host showed Stopped. The probe now decides the backoff verdict and the host state separately: - a device refusal (thrown or as a status word) is backoff success + host Running + one Warning per device naming the probe tag; an initialised probe runtime is kept so its lease is not re-opened every tick; - a malformed probe tag name (AbCipTagConfigurationException) never reached the device: no backoff signal, no host transition, one Warning; - a genuine transport failure is unchanged (backoff failure, host Stopped). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H10: a typo'd (or download-deleted) <c>Probe.ProbeTagPath</c> used to record a Forward-Open
|
||||
/// FAILURE every probe tick, so the device's backoff window never closed and every cold / evicted data tag read
|
||||
/// <c>BadCommunicationError</c> forever — a permanent device blackout that a restart did not cure. The controller
|
||||
/// ANSWERED, so the probe now counts it as reachable: backoff success, host Running, one config Warning.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipProbeRefusalTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static (AbCipDriver Driver, CapturingLogger Logger) Build(Exception probeFault)
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "ProbeTagTypo"
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = probeFault }
|
||||
: new FakeAbCipTag(p) { Value = 7 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Motor1.Speed", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ProbeTagPath = "ProbeTagTypo",
|
||||
Interval = TimeSpan.FromMilliseconds(10),
|
||||
Timeout = TimeSpan.FromMilliseconds(100),
|
||||
},
|
||||
}, "abcip-probe-refusal", factory, logger: logger);
|
||||
return (drv, logger);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition never met");
|
||||
await Task.Delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A NotFound probe tag leaves the device reachable: data tags still connect, host is Running, warned once.</summary>
|
||||
[Fact]
|
||||
public async Task A_missing_probe_tag_does_not_black_out_the_device()
|
||||
{
|
||||
var (drv, logger) = Build(new AbTransportException(AbStatus.NotFound, "NotFound"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running);
|
||||
await Task.Delay(100); // many probe ticks
|
||||
|
||||
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); // a cold create, NOT throttled
|
||||
r[0].Value.ShouldBe(7);
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("ProbeTagTypo")).ShouldBe(1);
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a probe that genuinely cannot connect still stops the host and throttles cold creates.</summary>
|
||||
[Fact]
|
||||
public async Task An_unreachable_device_still_stops_the_host_and_throttles()
|
||||
{
|
||||
var (drv, _) = Build(new AbTransportException(AbStatus.Timeout, "Timeout"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Stopped);
|
||||
|
||||
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); // throttled by the open window
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user