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:
Joseph Doherty
2026-10-22 14:17:03 -04:00
parent f087f01d2b
commit 22d4680e7b
2 changed files with 145 additions and 11 deletions
@@ -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