fix(ablegacy): opaque replies are neither answers nor faults; round-2 minors
Review r1 N1 (Important): the I1 fix made IsDeviceRefusal default-open and mapped libplctag's ERR_REMOTE_ERR to an answer. libplctag folds EVERY non-zero PCCC STS and CIP status into that code, including a routing hop's "unconnected send timed out" / "connection lost" / "remote node missing", and native BadReply covers truncated / mismatched / unmapped replies - so a powered-off PLC behind a bridge read as reachable: Healthy, LastSuccessfulRead advancing, the comms-loss watchdog unable to fire. Three classes now, each pinned both ways: - no reply (Timeout/BadConnection/Abort/Pending/unknown): comms fault; - answered - an ALLOW-LIST again (NotFound/NotAllowed/OutOfBounds/TooLarge/ TooSmall/TypeMismatch/DeviceStateConflict): proves reachability; - OPAQUE (BadReply/Partial; ERR_REMOTE_ERR/ERR_BAD_STATUS now translate to the existing BadReply, OPC UA BadCommunicationError as before r0): per-tag Bad, handle kept, coalescing fallback, never fail-fast, never an answer. Option (a): a device whose pass/call produced only opaque outcomes is Degraded with LastSuccessfulRead not advanced, so the watchdog reaches a dead routed PLC. The probe and index reads classify the same way. Minors: - N2: a mismatched array (type vs data file) READS BadTypeMismatch instead of decoding the file at the wrong width as Good. - N3: a swept indirect resolution is never reconnected under a forgotten gate; that one call answers neutrally. - N4: an index read answered Unsupported is neutral, not a transport fault. - N5: ArrayFileMismatch checks indirect addresses (the file letter is static). - Test nit: the evicted-while-waiting test signals gate arrival via a seam instead of sleeping. Live: 51/51 on ab_server (slc500/micrologix/plc5 x both transports), incl. an opaque-only libplctag device staying Degraded with LastSuccessfulRead frozen. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+3
-1
@@ -289,7 +289,9 @@ public static class AbLegacyTagDefinitionFactory
|
||||
public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
|
||||
{
|
||||
if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
|
||||
if (AbLegacyAddress.TryParse(address) is not { } a || a.IsIndirect) return null;
|
||||
// The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move —
|
||||
// so the width rule applies to it too (review 07 r1 N5).
|
||||
if (AbLegacyAddress.TryParse(address) is not { } a) return null;
|
||||
var file = ArrayFileElementType(a);
|
||||
var ok = type switch
|
||||
{
|
||||
|
||||
@@ -468,7 +468,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// evidence for the backoff); <see cref="Neutral"/> = nothing reached the wire or it says nothing about
|
||||
/// reachability (a local refusal, a bad index value).
|
||||
/// </summary>
|
||||
private enum OpVerdict { Neutral, Answered, Fault, Throttled }
|
||||
/// <remarks><see cref="Opaque"/> = a reply arrived that cannot be classified (libplctag <c>ERR_REMOTE_ERR</c>, native
|
||||
/// <c>BadReply</c>/<c>Partial</c>): it may be the device refusing OR a routing hop reporting that the end device is
|
||||
/// gone, so it is NOT an answer (no backoff reset, no <c>LastSuccessfulRead</c>) and NOT a fault (no fail-fast);
|
||||
/// a pass that produced only opaque outcomes for a device degrades (review 07 r1 N1).</remarks>
|
||||
private enum OpVerdict { Neutral, Answered, Fault, Throttled, Opaque }
|
||||
|
||||
/// <summary>The health evidence one device pass produced (collected, then written once per read).</summary>
|
||||
private struct DeviceReadOutcome
|
||||
@@ -476,6 +480,18 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
public bool AnyGood;
|
||||
public bool AnyAnswered;
|
||||
public string? FaultMessage;
|
||||
public string? OpaqueMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Folds an opaque-only device into the fault class for STATE (Degraded, <c>LastSuccessfulRead</c> not advanced
|
||||
/// by it) — option (a) of review 07 r1 N1: a dead PLC behind a bridge answers every request with a fast remote
|
||||
/// error, and only this lets the comms-loss watchdog (non-Healthy AND frozen <c>LastSuccessfulRead</c>) reach
|
||||
/// it. Cost: a single-tag device whose only tag draws an opaque refusal is re-initialised every 90 s.
|
||||
/// </summary>
|
||||
public void FoldOpaque()
|
||||
{
|
||||
if (OpaqueMessage is not null && !AnyAnswered) FaultMessage ??= OpaqueMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -641,8 +657,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
case OpVerdict.Throttled:
|
||||
outcome.FaultMessage ??= message;
|
||||
break;
|
||||
case OpVerdict.Opaque:
|
||||
outcome.OpaqueMessage ??= message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
outcome.FoldOpaque();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
@@ -664,11 +684,22 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
results[item.Index] = new DataValueSnapshot(null, res.Status, null, now);
|
||||
return res.TransportFault
|
||||
? (OpVerdict.Fault, $"transport fault resolving indirect address for {reference}")
|
||||
: (OpVerdict.Neutral, null);
|
||||
: res.Opaque
|
||||
? (OpVerdict.Opaque, $"unclassifiable reply resolving indirect address for {reference}")
|
||||
: (OpVerdict.Neutral, null);
|
||||
}
|
||||
def = res.Def;
|
||||
}
|
||||
|
||||
// A mis-authored array (its type does not match its data file) would decode the file at the wrong width
|
||||
// and serve garbage as Good (review 07 r1 N2): answered BadTypeMismatch, warned once, no health write.
|
||||
if (IsArrayTag(def) && AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address) is { } widthError)
|
||||
{
|
||||
WarnRefusalOnce(device, def, "read", widthError, AbLegacyStatusMapper.BadTypeMismatch);
|
||||
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadTypeMismatch, null, now);
|
||||
return (OpVerdict.Neutral, null);
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
|
||||
int status;
|
||||
@@ -677,10 +708,17 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
// runtime — the server read path and the poll loop both call ReadAsync against
|
||||
// the same cached libplctag Tag handle, which is not concurrency-safe.
|
||||
var opLock = device.GetRuntimeLock(def.Name);
|
||||
device.BeforeOperationGate?.Invoke(def.Name);
|
||||
await opLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false);
|
||||
if (await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false) is not { } live)
|
||||
{
|
||||
// A swept indirect resolution (the index moved under us): never resurrect it under a forgotten gate.
|
||||
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
return (OpVerdict.Neutral, null);
|
||||
}
|
||||
runtime = live;
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
@@ -713,6 +751,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
WarnRefusalOnce(device, def, "read", "status Unsupported", AbLegacyStatusMapper.BadNotSupported);
|
||||
return (OpVerdict.Neutral, null);
|
||||
}
|
||||
if (IsOpaque((AbStatus)status))
|
||||
{
|
||||
// A reply we cannot classify: per-tag Bad, keep the handle, warn once — neither an answer nor a fault.
|
||||
WarnRefusalOnce(device, def, "read", $"unclassifiable reply {(AbStatus)status}", results[item.Index].StatusCode);
|
||||
return (OpVerdict.Opaque, $"unclassifiable reply {(AbStatus)status} reading {reference}");
|
||||
}
|
||||
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
|
||||
EvictRuntime(device, def.Name);
|
||||
if (!device.FirstNonZeroStatusLogged)
|
||||
@@ -752,6 +796,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
|
||||
return (OpVerdict.Answered, null);
|
||||
}
|
||||
catch (AbTransportException ex) when (IsOpaque(ex.Status))
|
||||
{
|
||||
// A truncated / mismatched / unmapped reply (native BadReply, Partial): not proof the device answered.
|
||||
WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
|
||||
return (OpVerdict.Opaque, ex.Message);
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
// A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep
|
||||
@@ -781,23 +832,34 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
!Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// True for a non-zero status that proves the DEVICE answered and refused this one operation — the file or
|
||||
/// element does not exist, the access is not allowed, a size / type is wrong, a remote error (libplctag
|
||||
/// <c>ERR_REMOTE_ERR</c>, translated to <see cref="AbStatus.DeviceStateConflict"/>), or a reply the transport
|
||||
/// could not map (<see cref="AbStatus.BadReply"/>, <see cref="AbStatus.Partial"/>). Such a status is per-tag Bad,
|
||||
/// keeps the handle, and is evidence of reachability. <see cref="AbStatus.Unsupported"/> is classified
|
||||
/// separately (<see cref="IsLocalRefusal"/>: it says nothing about reachability).
|
||||
/// True for a status that PROVES the device answered and refused this one operation — an ALLOW-LIST (review 07
|
||||
/// r1 N1): the file or element does not exist, the access is not allowed, it is out of bounds, a size or type is
|
||||
/// wrong, or the processor is in a conflicting state. Per-tag Bad, keeps the handle, and is evidence of
|
||||
/// reachability (backoff reset, <c>LastSuccessfulRead</c>, Healthy).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Depends on the transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>, Lane K): today it maps STS 0x30
|
||||
/// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to answered values, so on the
|
||||
/// NATIVE transport such a dead node reads as answered and never trips the backoff or the comms-loss watchdog.
|
||||
/// The remap is follow-up F8 (review 07 r0 I4); this classification needs no change once it lands.
|
||||
/// ⚠️ Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>, Lane K): it maps STS 0x30
|
||||
/// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to <c>NotAllowed</c>/<c>NotFound</c>,
|
||||
/// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this
|
||||
/// classification needs no change once it lands.
|
||||
/// </remarks>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns><see langword="true"/> for a device refusal.</returns>
|
||||
internal static bool IsDeviceRefusal(AbStatus status) =>
|
||||
status is not (AbStatus.Ok or AbStatus.Unsupported) && !IsNoReply(status);
|
||||
internal static bool IsDeviceRefusal(AbStatus status) => status is
|
||||
AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall
|
||||
or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict;
|
||||
|
||||
/// <summary>
|
||||
/// True for a reply that arrived but cannot be classified (review 07 r1 N1): <see cref="AbStatus.BadReply"/> —
|
||||
/// libplctag's opaque <c>ERR_REMOTE_ERR</c>/<c>ERR_BAD_STATUS</c>, which folds EVERY non-zero CIP general status
|
||||
/// and PCCC STS together (including a routing hop's "unconnected send timed out", "connection lost" and "remote
|
||||
/// node missing"), and the native transport's unmapped / truncated / mismatched reply — and
|
||||
/// <see cref="AbStatus.Partial"/>. Opaque statuses are per-tag Bad and trigger the coalescing fallback, but are
|
||||
/// neither an answer nor a comms fault; see <see cref="OpVerdict.Opaque"/> for the health rule.
|
||||
/// </summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns><see langword="true"/> for an unclassifiable reply.</returns>
|
||||
internal static bool IsOpaque(AbStatus status) => status is AbStatus.BadReply or AbStatus.Partial;
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
@@ -866,6 +928,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
device.RecordCommsFault(Clock());
|
||||
outcome.FaultMessage ??= $"transport fault resolving indirect address for {w.FullReference}";
|
||||
}
|
||||
else if (res.Opaque)
|
||||
outcome.OpaqueMessage ??= $"unclassifiable reply resolving indirect address for {w.FullReference}";
|
||||
continue;
|
||||
}
|
||||
def = res.Def;
|
||||
@@ -943,7 +1007,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false);
|
||||
if (await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false) is not { } live)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
continue; // the index moved under us (N3): not written, no health evidence
|
||||
}
|
||||
runtime = live;
|
||||
if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue);
|
||||
else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -973,6 +1042,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
}
|
||||
catch (AbTransportException ex) when (IsOpaque(ex.Status))
|
||||
{
|
||||
outcome.OpaqueMessage ??= ex.Message;
|
||||
WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
// Deterministic (review 07 H16): nothing reached the wire, so it says nothing about the device.
|
||||
@@ -1004,6 +1079,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
}
|
||||
}
|
||||
|
||||
outcome.FoldOpaque();
|
||||
ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow);
|
||||
return results;
|
||||
}
|
||||
@@ -1028,6 +1104,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
device.RecordCommsFault(Clock());
|
||||
outcome.FaultMessage ??= $"transport status {status} writing {def.Name}";
|
||||
}
|
||||
else if (IsOpaque(s))
|
||||
{
|
||||
// Neither an answer nor a fault (review 07 r1 N1).
|
||||
WarnRefusalOnce(device, def, "write", $"unclassifiable reply {s}", AbLegacyStatusMapper.MapStatus(status));
|
||||
outcome.OpaqueMessage ??= $"unclassifiable reply {s} writing {def.Name}";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
@@ -1120,7 +1202,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
// The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a
|
||||
// recovered device is read again at once; a comms fault counts toward marking it down.
|
||||
if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered();
|
||||
else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(Clock());
|
||||
else if (IsNoReply((AbStatus)status)) state.RecordCommsFault(Clock()); // opaque / Unsupported: neutral
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
@@ -1129,7 +1211,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered();
|
||||
else if (!IsLocalRefusal(ex)) state.RecordCommsFault(Clock());
|
||||
else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock());
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
@@ -1320,7 +1402,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
if (parsed.IndirectFileSource is { } fileSrc)
|
||||
{
|
||||
var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque);
|
||||
if (r.Value is < 0 or > MaxIndirectFile)
|
||||
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
fileNumber = (int)r.Value!;
|
||||
@@ -1328,7 +1410,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
if (parsed.IndirectWordSource is { } wordSrc)
|
||||
{
|
||||
var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque);
|
||||
if (r.Value < 0 || r.Value > maxElement)
|
||||
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
word = (int)r.Value!;
|
||||
@@ -1369,13 +1451,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
}
|
||||
|
||||
/// <summary>Reads one direct index address as an integer through the shared parent-runtime cache.</summary>
|
||||
private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync(
|
||||
private async Task<(long? Value, uint Status, bool TransportFault, bool Opaque)> ReadIndexAsync(
|
||||
DeviceState device, AbLegacyAddress inner, CancellationToken ct)
|
||||
{
|
||||
AbLegacyDataType type;
|
||||
if (inner.BitIndex is not null) type = AbLegacyDataType.Bit;
|
||||
else if (inner.FileLetter == "L") type = AbLegacyDataType.Long;
|
||||
else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false, false);
|
||||
else type = AbLegacyDataType.Int;
|
||||
|
||||
// A bit-addressed index reads its parent word and decodes the bit, like the RMW path does.
|
||||
@@ -1394,40 +1476,49 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
catch (ConnectionThrottledException) { throw; } // the caller answers it as a fail-fast
|
||||
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.BadNotSupported, false);
|
||||
return (null, AbLegacyStatusMapper.BadNotSupported, false, false);
|
||||
}
|
||||
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
|
||||
{
|
||||
// The device answered: the index file/element does not exist. Per-tag, not a comms fault.
|
||||
return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false);
|
||||
return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, false);
|
||||
}
|
||||
catch (AbTransportException ex) when (IsOpaque(ex.Status))
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
EvictParentRuntime(device, runtimeName);
|
||||
return (null, AbLegacyStatusMapper.BadCommunicationError, true);
|
||||
return (null, AbLegacyStatusMapper.BadCommunicationError, true, false);
|
||||
}
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0 && IsDeviceRefusal((AbStatus)status))
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), false);
|
||||
if (status != 0)
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), false, false);
|
||||
if (status != 0 && IsOpaque((AbStatus)status))
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), false, true);
|
||||
// Only a NO-REPLY status is a transport fault (review 07 r1 N4: Unsupported used to fall in here).
|
||||
if (status != 0 && IsNoReply((AbStatus)status))
|
||||
{
|
||||
EvictParentRuntime(device, runtimeName);
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), true);
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), true, false);
|
||||
}
|
||||
if (status != 0)
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), false, false);
|
||||
var raw = runtime.DecodeValue(type, inner.BitIndex);
|
||||
try
|
||||
{
|
||||
return raw switch
|
||||
{
|
||||
bool b => (b ? 1L : 0L, 0u, false),
|
||||
null => (null, AbLegacyStatusMapper.BadOutOfRange, false),
|
||||
_ => (Convert.ToInt64(raw), 0u, false),
|
||||
bool b => (b ? 1L : 0L, 0u, false, false),
|
||||
null => (null, AbLegacyStatusMapper.BadOutOfRange, false, false),
|
||||
_ => (Convert.ToInt64(raw), 0u, false, false),
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
return (null, AbLegacyStatusMapper.BadOutOfRange, false, false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -1444,7 +1535,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault);
|
||||
private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault, bool Opaque = false);
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
||||
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
||||
@@ -1611,11 +1702,15 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// the wait may since have been evicted and disposed (a failed sibling call, or an indirect index sweep), and
|
||||
/// using it would fail spuriously as a comms fault. Returns the live cached handle, or connects a fresh one.
|
||||
/// </summary>
|
||||
private async Task<IAbLegacyTagRuntime> CurrentRuntimeUnderLockAsync(
|
||||
private async Task<IAbLegacyTagRuntime?> CurrentRuntimeUnderLockAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, IAbLegacyTagRuntime fetched, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var live))
|
||||
return live;
|
||||
// A resolved INDIRECT key that is gone was swept because the index moved (review 07 r1 N3). Reconnecting it
|
||||
// would resurrect a stale resolution under a gate the sweep already forgot (a later caller would get a fresh
|
||||
// gate for the same handle). Null = the caller answers this one call neutrally; the next poll re-resolves.
|
||||
if (def.Name.Contains("=>", StringComparison.Ordinal)) return null;
|
||||
// Evicted between the fetch and the lock: a native runtime refused at init is cached too, so absence means
|
||||
// it really was released. Connect again (single-flight per device).
|
||||
// A retired (torn-down) state never reconnects: the in-flight call finishes on what it has.
|
||||
@@ -1886,6 +1981,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// <summary>The number of per-key gates currently held (runtime + RMW). Tests only.</summary>
|
||||
internal int GateCount => _runtimeLocks.Count + _rmwLocks.Count;
|
||||
|
||||
/// <summary>Test seam: invoked with the runtime key just before an operation waits on its gate (after the
|
||||
/// handle was fetched), so a test can act at exactly that point instead of sleeping.</summary>
|
||||
internal Action<string>? BeforeOperationGate { get; set; }
|
||||
|
||||
private volatile bool _retired;
|
||||
|
||||
/// <summary>True once this state has been unpublished and released; nothing may be cached on it any more.</summary>
|
||||
|
||||
@@ -23,14 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
Status.ErrorBadReply => AbStatus.BadReply,
|
||||
// The PLC (or a CIP hop) ANSWERED with an error: libplctag's PCCC layer turns a non-zero STS — a file or
|
||||
// element that does not exist, a write the processor refuses — into PLCTAG_ERR_REMOTE_ERR (and a non-zero
|
||||
// CIP general status into ERR_REMOTE_ERR / ERR_BAD_STATUS). AbStatus has no dedicated "remote error" value and
|
||||
// Driver.Ab.Transport is not this driver's to extend (Lane K), so this maps to the existing value whose meaning
|
||||
// is closest — "the device is reachable but refused the operation" — DeviceStateConflict (OPC UA
|
||||
// BadDeviceFailure). It MUST NOT fall into the BadConnection default: that is a no-reply comms fault, which
|
||||
// evicts handles, degrades health and counts toward marking the device down (review 07 r0 I1).
|
||||
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.DeviceStateConflict,
|
||||
// A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP
|
||||
// general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the
|
||||
// end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed
|
||||
// out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault
|
||||
// (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and
|
||||
// trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error"
|
||||
// value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot
|
||||
// classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError.
|
||||
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply,
|
||||
_ => AbStatus.BadConnection,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user