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:
@@ -712,12 +712,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
IAbCipTagRuntime? probeRuntime = null;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
// The probe's backoff verdict for this tick: true = record success, false = record
|
||||
// failure, null = no signal (a read that returned a non-zero status without throwing
|
||||
// never recorded either, and still doesn't). Recorded AFTER the try/catch so the
|
||||
// per-device connect gate is taken outside a catch block — see below.
|
||||
// The probe's verdicts for this tick, decided SEPARATELY (review 07 H10):
|
||||
// backoffVerdict — true = record success, false = record failure, null = no signal;
|
||||
// hostState — the host transition, null = no evidence either way (leave it).
|
||||
// Recorded AFTER the try/catch so the per-device connect gate is taken outside a catch block.
|
||||
bool? backoffVerdict = null;
|
||||
HostState? hostState = HostState.Stopped;
|
||||
try
|
||||
{
|
||||
probeRuntime ??= _tagFactory.Create(probeParams);
|
||||
@@ -729,14 +729,51 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
state.ProbeInitialized = true;
|
||||
}
|
||||
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
success = probeRuntime.GetStatus() == 0;
|
||||
if (success)
|
||||
var status = probeRuntime.GetStatus();
|
||||
if (status == 0)
|
||||
{
|
||||
backoffVerdict = true; // Device reachable — reset the data-path window
|
||||
hostState = HostState.Running;
|
||||
}
|
||||
else if (AbCipRefusals.IsDeviceRefusal(status))
|
||||
{
|
||||
// The controller answered, refusing the probe tag: reachable.
|
||||
backoffVerdict = true;
|
||||
hostState = HostState.Running;
|
||||
WarnProbeRefusedOnce(state, ((AbStatus)status).ToString());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (AbCipRefusals.IsDeviceRefusal(ex))
|
||||
{
|
||||
// H10: a missing / typo'd / download-deleted probe tag. The controller ANSWERED over a working session,
|
||||
// so this is a config error on the probe tag — not a Forward-Open failure. Recording a failure here
|
||||
// every tick saturated the backoff (5 s ticks vs a 30 s cap ⇒ ShouldAttempt never true) and blacked out
|
||||
// every cold / evicted data tag on a reachable device, indefinitely.
|
||||
backoffVerdict = true;
|
||||
hostState = HostState.Running;
|
||||
WarnProbeRefusedOnce(state, ex.Message);
|
||||
// Keep an initialised runtime (its lease stays up — no Forward Open/Close per tick); one whose
|
||||
// initialisation threw is re-created next tick, as before.
|
||||
if (!state.ProbeInitialized)
|
||||
{
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
}
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// The probe tag's own name cannot become a request — it never reached the device, so it is no evidence
|
||||
// either way: no backoff signal, no host transition, one Warning.
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
hostState = null;
|
||||
WarnProbeRefusedOnce(state, ace.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
|
||||
@@ -748,9 +785,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
// Keep the data-path window open — unless the probe tag's own name is malformed
|
||||
// — it never reached the device, so it is no evidence either way.
|
||||
backoffVerdict = ex is AbCipTagConfigurationException ? null : false;
|
||||
backoffVerdict = false; // the device did not answer — keep the data-path window open
|
||||
}
|
||||
|
||||
// ConnectionBackoff is caller-serialized, and the data path now records under the
|
||||
@@ -780,7 +815,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
if (hostState is { } host) TransitionDeviceState(state, host);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
@@ -789,6 +824,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>Logs, once per device state, that the controller answered but refused the configured probe tag.</summary>
|
||||
private void WarnProbeRefusedOnce(DeviceState state, string reason)
|
||||
{
|
||||
if (Interlocked.Exchange(ref state.ProbeRefusalWarned, 1) != 0) return;
|
||||
_logger.LogWarning(
|
||||
"AbCip probe tag {ProbeTag} on device {Device} cannot be read: {Reason}. The controller answered, so the device " +
|
||||
"is treated as reachable; fix Probe.ProbeTagPath for driver {DriverInstanceId} (logged once per device)",
|
||||
_options.Probe.ProbeTagPath, state.Options.HostAddress, reason, _driverInstanceId);
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -2096,6 +2141,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>Gets or sets whether the probe has been initialized.</summary>
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
/// <summary>1 once the probe-tag refusal Warning has been logged for this device state.</summary>
|
||||
public int ProbeRefusalWarned;
|
||||
|
||||
/// <summary>
|
||||
/// The fire-and-forget probe loop's <see cref="Task"/>. Stored so
|
||||
/// <see cref="AbCipDriver.ShutdownAsync"/> can await the loop's clean exit after
|
||||
|
||||
Reference in New Issue
Block a user