Merge #570/#579: native AbCip per-tag config errors, no mistype churn, STRING 82, LWORD type checks; non-blocking lease release (AbCip + AbLegacy)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe # Conflicts: # src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs
This commit is contained in:
@@ -158,4 +158,29 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
|
||||
public ValueTask DisposeAsync() =>
|
||||
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Releases this lease without waiting for the teardown — the one release shape both native AB tag
|
||||
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock
|
||||
/// (Gitea #579). Idempotent, like <see cref="DisposeAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Nothing is lost by not waiting. The release's bookkeeping — the ref-count drop and, for the
|
||||
/// last lease, the entry's removal and the registration of its close in the pool's closing chain — runs
|
||||
/// synchronously, before this returns. So an acquire made the very next instant builds a connection that
|
||||
/// does not open until this one's Forward Close + UnRegisterSession have finished (the guarantee the old
|
||||
/// blocking 2 s wait in the AbCip runtime existed for), and the close itself is bounded by
|
||||
/// <see cref="CipConnection"/>'s own budgets (the open budget for an in-flight open, 1 s for the Forward
|
||||
/// Close), so it always completes and the socket is always released.</para>
|
||||
/// <para>The returned task is the teardown; it never faults (the pool logs its own close failures), so
|
||||
/// a caller may drop it.</para>
|
||||
/// </remarks>
|
||||
/// <returns>The teardown, for tests and callers that want to observe it.</returns>
|
||||
public Task ReleaseInBackground() => Observe(DisposeAsync());
|
||||
|
||||
private static async Task Observe(ValueTask release)
|
||||
{
|
||||
try { await release.ConfigureAwait(false); }
|
||||
catch (Exception) { /* ReleaseAsync logs and swallows its own failures; never fault the observer */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
// Tags whose malformed-name Warning has already been logged (Gitea #570) — polled forever, warned once.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _configErrorWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Logs the Warning for a tag whose own configuration cannot become a request
|
||||
/// (<see cref="AbCipTagConfigurationException"/>), the first time <paramref name="tagName"/> reports
|
||||
/// one on this instance.
|
||||
/// </summary>
|
||||
private void WarnConfigurationErrorOnce(string tagName, string reference, string device, AbCipTagConfigurationException ex)
|
||||
{
|
||||
if (_configErrorWarned.TryAdd(tagName, 0))
|
||||
_logger.LogWarning(
|
||||
"AbCip tag {Tag} on device {Device} is misconfigured: {Reason}; reporting BadConfigurationError " +
|
||||
"until it is corrected — the device and its other tags are unaffected (logged once per tag)",
|
||||
reference, device, ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -585,7 +603,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
backoffVerdict = false; // 05/STAB-8: keep the data-path window open
|
||||
// 05/STAB-8: keep the data-path window open — unless the probe tag's own name is malformed
|
||||
// (Gitea #570): that never reached the device, so it is no evidence either way.
|
||||
backoffVerdict = ex is AbCipTagConfigurationException ? null : false;
|
||||
}
|
||||
|
||||
// ConnectionBackoff is caller-serialized, and the data path now records under the
|
||||
@@ -901,6 +921,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: this tag's name/path cannot become a request — nothing reached the device, so
|
||||
// no evict (nothing was cached), no health signal, no backoff; siblings keep reading.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadConfigurationError, null, now);
|
||||
WarnConfigurationErrorOnce(def.Name, reference, def.DeviceHostAddress, ace);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
|
||||
@@ -998,6 +1026,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: the parent's name cannot become a request — per-group config error, device untouched.
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadConfigurationError);
|
||||
WarnConfigurationErrorOnce(parent.Name, group.ParentName, parent.DeviceHostAddress, ace);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
|
||||
@@ -1159,6 +1194,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: a config error on this tag — no evict, no health signal, no backoff.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadConfigurationError);
|
||||
WarnConfigurationErrorOnce(def.Name, w.FullReference, def.DeviceHostAddress, ace);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
|
||||
@@ -1275,7 +1316,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)
|
||||
?? throw new InvalidOperationException(
|
||||
?? throw new AbCipTagConfigurationException(
|
||||
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
|
||||
|
||||
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
@@ -1304,6 +1345,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Gitea #570: the controller ANSWERED with a type refusal — the tag exists and the session
|
||||
// works. Keep the handle (disposing it re-creates it, and for a sole lease holder
|
||||
// re-Forward-Opens, on every poll) and let the caller report BadTypeMismatch. The native
|
||||
// runtime completes its initialization instead of throwing this; the cache keeps any runtime
|
||||
// that does throw it from churning.
|
||||
device.Backoff.RecordSuccess();
|
||||
if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (AbCipTagConfigurationException)
|
||||
{
|
||||
// Gitea #570: the tag's own name cannot become a request — no I/O happened, so it says nothing
|
||||
// about the device: leave the backoff window exactly as it was.
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
runtime.Dispose();
|
||||
|
||||
@@ -22,6 +22,7 @@ public static class AbCipStatusMapper
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a transport status code to an OPC UA StatusCode. The integer is
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// A tag's own configuration cannot be turned into a request at all — a malformed tag path, or a
|
||||
/// symbolic name the native CIP encoder refuses (Gitea #570). Raised before any I/O, so it says nothing
|
||||
/// about the device: the driver reports it per tag as <c>BadConfigurationError</c> and never counts it
|
||||
/// against the device's connect backoff or its health, so healthy tags on the same device keep reading.
|
||||
/// </summary>
|
||||
internal sealed class AbCipTagConfigurationException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception.</summary>
|
||||
/// <param name="message">What is wrong with the tag's configuration.</param>
|
||||
/// <param name="innerException">The encoder's own exception, if any.</param>
|
||||
public AbCipTagConfigurationException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </remarks>
|
||||
internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
/// <summary>Upper bound on waiting for the lease release (and its Forward Close) in <see cref="Dispose"/>.</summary>
|
||||
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Bytes of a Logix STRING's length word.</summary>
|
||||
private const int StringLengthBytes = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Read size of the built-in Logix STRING: <c>LEN</c> (4) + <c>DATA</c> SINT[82] + 2 bytes of
|
||||
/// alignment padding to the structure's 4-byte boundary.
|
||||
/// </summary>
|
||||
private const int BuiltInStringBytes = 88;
|
||||
|
||||
/// <summary>Characters the built-in Logix STRING's <c>DATA</c> member holds.</summary>
|
||||
private const int BuiltInStringCapacity = 82;
|
||||
|
||||
private readonly AbCipTagCreateParams _p;
|
||||
private readonly DeviceSessionPool? _pool;
|
||||
private readonly ushort _elements;
|
||||
@@ -90,9 +96,16 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
|
||||
/// <para>Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
|
||||
/// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
|
||||
/// <see cref="AbStatus.NotFound"/>.
|
||||
/// <see cref="AbStatus.NotFound"/>.</para>
|
||||
/// <para>A name the encoder refuses throws <see cref="AbCipTagConfigurationException"/> before any
|
||||
/// lease (Gitea #570): it is this tag's configuration, not the device, and must not black out the
|
||||
/// device's other tags. A read the controller answers with a deterministic refusal
|
||||
/// (<see cref="AbStatus.TypeMismatch"/> / <see cref="AbStatus.Unsupported"/>) proves the tag exists,
|
||||
/// so it completes the initialization with that status rather than throwing: a throw here makes the
|
||||
/// driver dispose the runtime and re-create it — and, for a sole lease holder, re-Forward-Open — on
|
||||
/// every poll (the mistype churn of #570). The driver's own read reports the refusal.</para>
|
||||
/// </remarks>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -100,9 +113,19 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (_path is null)
|
||||
{
|
||||
// Before any lease: a malformed name costs no connection.
|
||||
var (name, bit) = CipPath.SplitBitSelector(_p.TagName);
|
||||
var path = CipPath.EncodeSymbolic(name);
|
||||
_implicitBit = bit;
|
||||
(string Name, int? Bit) split;
|
||||
byte[] path;
|
||||
try
|
||||
{
|
||||
split = CipPath.SplitBitSelector(_p.TagName);
|
||||
path = CipPath.EncodeSymbolic(split.Name);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
|
||||
{
|
||||
throw new AbCipTagConfigurationException(
|
||||
$"AbCip tag name '{_p.TagName}' cannot be encoded as a CIP symbolic path: {ex.Message}", ex);
|
||||
}
|
||||
_implicitBit = split.Bit;
|
||||
_path = path;
|
||||
}
|
||||
if (_client is null)
|
||||
@@ -112,7 +135,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
// The lease's shared scheduler — never a private one (B1: MSP coalescing across tags).
|
||||
_client = new LogixTagClient(lease.Scheduler, lease.Channel);
|
||||
}
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
|
||||
{
|
||||
// The controller answered — the tag exists. _status already holds the refusal.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -354,29 +384,32 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the connection lease, waiting at most 2 s so an immediate re-init does not open a
|
||||
/// second session to the device while this one's Forward Close is still in flight (review
|
||||
/// finding 22). Never disposes the shared scheduler; the pool does that on the last release.
|
||||
/// Releases the connection lease without blocking (Gitea #579) — the driver calls this under the
|
||||
/// tag's lock, which used to be held for up to 2 s while the last release's Forward Close ran. An
|
||||
/// immediate re-init still cannot open a second session while that close is in flight (review
|
||||
/// finding 22): the pool registers the close synchronously and holds any successor connection
|
||||
/// behind it — see <see cref="ConnectionLease.ReleaseInBackground"/>. Never disposes the shared
|
||||
/// scheduler; the pool does that on the last release. Same release shape as the native AbLegacy runtime.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_lease is { } lease)
|
||||
lease.DisposeAsync().AsTask().Wait(DisposeWait);
|
||||
else if (_privateScheduler is { } scheduler)
|
||||
scheduler.DisposeAsync().AsTask().Wait(DisposeWait);
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
// A faulted close is the pool's problem to log; Dispose must not throw.
|
||||
}
|
||||
TeardownTask = _lease?.ReleaseInBackground() ?? DisposePrivateScheduler(_privateScheduler);
|
||||
_lease = null;
|
||||
_privateScheduler = null;
|
||||
}
|
||||
|
||||
/// <summary>The background teardown <see cref="Dispose"/> started, for tests; null before Dispose.</summary>
|
||||
internal Task? TeardownTask { get; private set; }
|
||||
|
||||
private static async Task DisposePrivateScheduler(RequestScheduler? scheduler)
|
||||
{
|
||||
if (scheduler is null) return;
|
||||
try { await scheduler.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception) { /* ForTest only; Dispose must not throw */ }
|
||||
}
|
||||
|
||||
private byte[] Path() =>
|
||||
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
|
||||
|
||||
@@ -398,7 +431,12 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
private bool IsTypeMismatch(ushort returned)
|
||||
{
|
||||
if (AtomicCode(_p.AuthoredType) is not { } expected) return false;
|
||||
if (LogixTypeCode.ByteWidth(returned) < 0 || returned == expected) return false;
|
||||
if (returned == expected) return false;
|
||||
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's width table but are
|
||||
// integers of a known width: they are checked like any integer, so a REAL / LREAL / narrower
|
||||
// integer authored over an LWORD is a mismatch rather than garbage decoded as Good (Gitea #570).
|
||||
// Any other code without a known width (DATE, DATE_AND_TIME, structures) stays exempt.
|
||||
if (LogixTypeCode.ByteWidth(returned) < 0 && IntegerWidth(returned) is null) return false;
|
||||
if (_p.AuthoredType == AbCipDataType.Bool)
|
||||
{
|
||||
if (returned == LogixTypeCode.BoolArray) return false;
|
||||
@@ -473,27 +511,40 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
return _data.AsSpan(offset, length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Characters a STRING of <paramref name="structureBytes"/> holds. The built-in 88-byte STRING holds
|
||||
/// 82 — its last 2 bytes are alignment padding, not DATA (Gitea #570). Any other size is a user
|
||||
/// string type whose DATA length the read alone cannot separate from its padding (up to 3 bytes),
|
||||
/// so it keeps the buffer-derived <c>size − 4</c>.
|
||||
/// </summary>
|
||||
private static int StringCapacity(int structureBytes) => structureBytes == BuiltInStringBytes
|
||||
? BuiltInStringCapacity
|
||||
: structureBytes - StringLengthBytes;
|
||||
|
||||
/// <summary>Logix STRING: <c>u32 LEN</c> then ASCII DATA, LEN clamped to what the buffer holds.</summary>
|
||||
private string DecodeString(int offset)
|
||||
{
|
||||
var len = BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, StringLengthBytes));
|
||||
var available = _data.Length - offset - StringLengthBytes;
|
||||
// A scalar built-in STRING never yields its alignment padding as characters.
|
||||
if (offset == 0 && _data.Length == BuiltInStringBytes) available = BuiltInStringCapacity;
|
||||
var count = (int)Math.Min(len, (uint)available);
|
||||
return Encoding.ASCII.GetString(_data, offset + StringLengthBytes, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>u32 len</c> + DATA padded to the capacity learned from the last read (data length − 4), so the
|
||||
/// write carries the whole structure as the controller sized it.
|
||||
/// <c>u32 len</c> + DATA, zero-padded to the size learned from the last read, so the write carries the
|
||||
/// whole structure as the controller sized it (padding included, as libplctag sends it). The text
|
||||
/// must fit the DATA capacity, which excludes the padding (<see cref="StringCapacity"/>).
|
||||
/// </summary>
|
||||
private byte[] EncodeStringPayload(string text)
|
||||
{
|
||||
var capacity = _data.Length - StringLengthBytes;
|
||||
var capacity = StringCapacity(_data.Length);
|
||||
var bytes = Encoding.ASCII.GetBytes(text);
|
||||
if (capacity < 0 || bytes.Length > capacity)
|
||||
throw new AbTransportException(AbStatus.TooLarge,
|
||||
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-byte capacity.");
|
||||
var payload = new byte[StringLengthBytes + capacity];
|
||||
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-character capacity.");
|
||||
var payload = new byte[_data.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)bytes.Length);
|
||||
bytes.CopyTo(payload, StringLengthBytes);
|
||||
return payload;
|
||||
|
||||
@@ -385,12 +385,11 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases the lease on the shared device connection (the last release closes it).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_lease is null) return;
|
||||
// Closing does I/O (Forward Close / UnRegisterSession); never block the caller on it.
|
||||
_ = _lease.DisposeAsync().AsTask().ContinueWith(
|
||||
static t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
/// <summary>
|
||||
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
|
||||
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
|
||||
/// The same release shape as the native AbCip runtime — see <see cref="ConnectionLease.ReleaseInBackground"/>
|
||||
/// for why a re-create the next instant still cannot overlap the close (Gitea #579).
|
||||
/// </summary>
|
||||
public void Dispose() => _lease?.ReleaseInBackground();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user