fix(twincat): bounded, race-safe lifecycle; drop counter; post-unsubscribe purge (L8)

- Device and tag tables are build-and-swap: InitializeAsync builds a fresh
  generation and publishes it by one swap under a lifecycle lock, then
  releases the replaced generation; teardown swaps in empty tables. A
  retained poll loop never sees a table mid-Clear (it used to read
  _tagsByRawPath while a re-init only ever added to it), and each device
  set is released by exactly one caller.
- A lifecycle generation makes an InitializeAsync overtaken by a shutdown
  (or a newer init) release what it built instead of installing it - the
  Lane A abandoned-init-overlaps-shutdown case.
- ShutdownAsync joins all probe loops together within 2 s (it awaited each
  unbounded - a probe wedged in the blocking ADS Connect hung the actor's
  teardown) in parallel with the poll-engine dispose, the whole bounded at
  4.5 s; DisposeAsync may run concurrently and returns at once. A connect
  that completes on a released device disposes its client.
- Releasing a generation disposes its native registrations, so a direct
  re-init no longer leaves live notifications bound to dead DeviceStates.
- Connect gates and RMW semaphores are no longer disposed under in-flight
  users (no kernel handle is ever allocated); a fault on a released device
  is not recorded as driver health.
- The notification channel counts DropWrite overflows
  (ITwinCATClient.DroppedNotificationCount); the probe tick warns with the
  delta. A queued value whose registration was disposed is purged in the
  client dispatch loop and dropped driver-side for any client.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:24:10 -04:00
parent f17ba8e258
commit cbacaee4ee
5 changed files with 561 additions and 104 deletions
@@ -721,15 +721,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
if (!_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)))
Interlocked.Increment(ref _droppedNotifications); // queue full (DropWrite): count, never block
}
private long _droppedNotifications;
/// <inheritdoc />
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotifications);
private async Task DispatchLoopAsync(CancellationToken ct)
{
try
{
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
// A value queued before its registration was deleted is purged here rather than delivered
// after the unsubscribe.
if (pending.Registration.IsDeleted) continue;
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
}
@@ -980,9 +989,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// </summary>
public void Dispose()
{
IsDeleted = true;
// Fire-and-forget AMS call — caller has already committed to the tear-down.
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
}
/// <summary>Gets a value indicating whether the registration was disposed; queued values for it are dropped.</summary>
public bool IsDeleted { get => Volatile.Read(ref _deleted); private set => Volatile.Write(ref _deleted, value); }
private bool _deleted;
}
private static Type MapToClrType(TwinCATDataType type) => type switch
@@ -102,6 +102,12 @@ public interface ITwinCATClient : IDisposable
/// <summary>The overall ADS code of the most recent such fallback (0 = a malformed response).</summary>
uint LastSumFallbackCode => 0;
/// <summary>
/// Native notifications this client dropped because its bounded dispatch queue was full (the router
/// thread never blocks on a slow consumer). The driver warns when it grows. Default 0.
/// </summary>
long DroppedNotificationCount => 0;
/// <summary>
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
@@ -22,30 +22,60 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// Mutable so ReinitializeAsync can apply a new config generation. The constructor seeds
// it; InitializeAsync re-parses driverConfigJson over the top of it.
private TwinCATDriverOptions _options;
// it; InitializeAsync re-parses driverConfigJson over the top of it (committed under _lifecycleLock).
private volatile TwinCATDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ITwinCATClientFactory _clientFactory;
private readonly ILogger<TwinCATDriver> _logger;
private readonly PollGroupEngine _poll;
// ConcurrentDictionary so ShutdownAsync (Clear) and ReadAsync/WriteAsync/SubscribeAsync
// (TryGetValue) don't race — plain Dictionary is not safe for concurrent read+write.
private readonly ConcurrentDictionary<string, DeviceState> _devices =
new(StringComparer.OrdinalIgnoreCase);
// Device + tag tables are BUILD-AND-SWAP (#739 L8): InitializeAsync builds fresh tables and publishes
// them by reference under _lifecycleLock; ShutdownAsync / Dispose swap in empty ones and release what
// they took. A retained poll loop, the probe tick or an in-flight write therefore always sees one whole
// generation — never a table mid-Clear — and two overlapping teardowns can never release the same
// device twice (each device set is taken by exactly one swap).
private volatile ConcurrentDictionary<string, DeviceState> _devices = NewDeviceTable();
// v3: the authored RawPath → definition table. Keyed case-sensitive ordinal (the RawPath is
// the exact wire reference). Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _tagsByRawPath =
new(StringComparer.Ordinal);
private volatile IReadOnlyDictionary<string, TwinCATTagDefinition> _tagsByRawPath = EmptyTags;
private static readonly IReadOnlyDictionary<string, TwinCATTagDefinition> EmptyTags =
new Dictionary<string, TwinCATTagDefinition>(StringComparer.Ordinal);
private static ConcurrentDictionary<string, DeviceState> NewDeviceTable() => new(StringComparer.OrdinalIgnoreCase);
// Serialises the table swaps against each other. _lifecycleGeneration is bumped by every
// InitializeAsync and every teardown, so an InitializeAsync that was overtaken while it awaited
// (an abandoned init overlapping the runtime's next ShutdownAsync, or a newer init) sees a
// different generation at its commit and releases what it built instead of installing it.
private readonly object _lifecycleLock = new();
private long _lifecycleGeneration;
/// <summary>Test seam: awaited after the new generation is built and before it is committed, so a test can
/// interleave a teardown deterministically. Never set in production.</summary>
internal Func<Task>? BeforeCommitForTest { get; set; }
/// <summary>Upper bound on joining released devices' probe loops (one wait for all of them).</summary>
internal static readonly TimeSpan ProbeJoinTimeout = TimeSpan.FromSeconds(2);
/// <summary>
/// Upper bound on the whole of <see cref="ShutdownAsync"/>. The runtime gives a final stop 5 s, so
/// the driver stops waiting a little earlier; anything still running past it (a poll loop wedged in
/// a read) is cancelled already and finishes in the background.
/// </summary>
internal static readonly TimeSpan ShutdownBudget = TimeSpan.FromSeconds(4.5);
// References that already logged their bad-read Warning in the current bad episode (#739 P10).
// Removed when the tag reads Good; cleared on (re-)initialise and shutdown.
private readonly ConcurrentDictionary<string, byte> _warnedRefs = new(StringComparer.Ordinal);
// Per-parent-word RMW gate for BOOL-within-word writes: a single-bit write is a
// read-modify-write of the parent word (TwinCAT's symbol table doesn't expose "Word.N"),
// so concurrent bit-writers to the same word must serialise or they lose each other's
// updates. Keyed by device + parent symbol. (Cannot guard against the PLC program writing
// the word between our read and write — inherent to RMW.)
// References that already logged their bad-read Warning in the current bad episode (#739 P10).
// Removed when the tag reads Good; cleared on (re-)initialise and shutdown.
private readonly ConcurrentDictionary<string, byte> _warnedRefs = new(StringComparer.Ordinal);
// the word between our read and write — inherent to RMW.) Teardown only CLEARS it: a
// SemaphoreSlim owns no kernel handle unless AvailableWaitHandle is read, and disposing one an
// in-flight write still holds turns its Release into an ObjectDisposedException.
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitRmwLocks =
new(StringComparer.OrdinalIgnoreCase);
@@ -118,29 +148,40 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + clients before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
// allows it, and a reconnect retry can overlap a running attempt). The new generation is BUILT
// first and published by one swap; the previous device states (probe loops, clients, native
// registrations) are released right after the swap — never orphaned, and a retained poll loop
// never sees an empty table in between. Deliberately NOT ShutdownAsync: that also drops _poll and
// the subscriptions.
long generation;
lock (_lifecycleLock)
{
generation = ++_lifecycleGeneration;
_health = new DriverHealth(DriverState.Initializing, null, null);
}
_warnedRefs.Clear();
_health = new DriverHealth(DriverState.Initializing, null, null);
var devices = NewDeviceTable();
var committed = false;
try
{
// Apply the supplied config generation. A blank or content-free document keeps the
// constructor-seeded options — that path covers callers that have already
// materialised options up front (the factory passes both, in agreement).
var options = _options;
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId);
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
_options = parsed;
options = parsed;
}
foreach (var device in _options.Devices)
foreach (var device in options.Devices)
{
var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
devices[device.HostAddress] = new DeviceState(addr, device);
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
@@ -149,14 +190,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// DeviceName is resolved to the target's live host (def.DeviceHostAddress) via ResolveDeviceHost
// so the existing device routing (_devices keyed by HostAddress) is unchanged. A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
var tags = new Dictionary<string, TwinCATTagDefinition>(StringComparer.Ordinal);
_skippedTags.BeginRebuild(options.RawTags.Count);
foreach (var entry in options.RawTags)
{
if (TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with
tags[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
DeviceHostAddress = ResolveDeviceHost(options, entry.DeviceName),
};
else
{
@@ -168,47 +210,113 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
_skippedTags.Commit();
if (_options.Probe.Enabled)
if (BeforeCommitForTest is { } pause) await pause().ConfigureAwait(false);
ConcurrentDictionary<string, DeviceState> previous;
lock (_lifecycleLock)
{
foreach (var state in _devices.Values)
if (generation != _lifecycleGeneration)
throw new OperationCanceledException(
"TwinCAT InitializeAsync was superseded by a shutdown or a newer initialise while it ran.");
_options = options;
_tagsByRawPath = tags;
previous = _devices;
_devices = devices;
committed = true;
// Probe loops start INSIDE the commit so a teardown that swaps this generation out right
// after always finds (and cancels) them.
if (options.Probe.Enabled)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
// Store the task so ShutdownAsync can await it.
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
foreach (var state in devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
// Store the task so the teardown can join it.
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
// The replaced generation is now unreachable from the tables: release it (bounded).
await ReleaseDevicesAsync(previous.Values).ConfigureAwait(false);
}
catch (Exception ex) when (!committed)
{
// Nothing of this generation was published: release what was built. A superseded init leaves
// health to whoever overtook it.
await ReleaseDevicesAsync(devices.Values).ConfigureAwait(false);
if (ex is not OperationCanceledException)
{
lock (_lifecycleLock)
{
if (generation == _lifecycleGeneration)
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
/// <summary>
/// Cancels, awaits and disposes every existing device state's probe loop, client and gate, then
/// clears the device table. Device part of <see cref="ShutdownAsync"/> only.
/// Releases a set of device states that have already been swapped out of <see cref="_devices"/>:
/// marks them released (so a probe wedged in the blocking ADS connect disposes the client it
/// eventually gets), disposes their native registrations (a re-initialise must not leave live ADS
/// notifications bound to a dead generation), cancels every probe loop and joins them all together
/// within <see cref="ProbeJoinTimeout"/>, then disposes their clients. The connect gates are NOT
/// disposed — an in-flight read/write may still hold one.
/// </summary>
private async Task ReleaseDeviceStatesAsync()
/// <param name="states">The released device states (each owned by exactly one caller).</param>
private async Task ReleaseDevicesAsync(ICollection<DeviceState> states)
{
if (_devices.IsEmpty) return;
foreach (var state in _devices.Values)
if (states.Count == 0) return;
var probes = BeginRelease(states);
if (probes.Count > 0)
{
try { state.ProbeCts?.Cancel(); } catch { }
if (state.ProbeTask is Task pt)
{
try { await pt.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
catch { /* cancelled, faulted or wedged — teardown continues */ }
}
state.ProbeCts?.Dispose();
try { await Task.WhenAll(probes).WaitAsync(ProbeJoinTimeout).ConfigureAwait(false); }
catch { /* cancelled, faulted or wedged in the synchronous ADS connect — teardown continues */ }
}
EndRelease(states);
}
/// <summary>The non-blocking first half of a release: mark, retract registrations, cancel probes.</summary>
private static List<Task> BeginRelease(ICollection<DeviceState> states)
{
var probes = new List<Task>(states.Count);
foreach (var state in states)
{
state.Released = true;
foreach (var reg in state.NativeRegistrations.Values) { try { reg.Dispose(); } catch { } }
try { state.ProbeCts?.Cancel(); } catch { /* already disposed */ }
if (state.ProbeTask is Task pt) probes.Add(pt);
}
return probes;
}
/// <summary>The second half of a release, after the bounded probe join: dispose CTSs and clients.</summary>
private static void EndRelease(ICollection<DeviceState> states)
{
foreach (var state in states)
{
try { state.ProbeCts?.Dispose(); } catch { }
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeClient();
state.DisposeGate();
try { state.DisposeClient(); } catch { /* best-effort */ }
}
}
/// <summary>Swaps out every table under the lifecycle lock and returns the device set the caller now owns.</summary>
private ICollection<DeviceState> TakeAllForTeardown()
{
lock (_lifecycleLock)
{
_lifecycleGeneration++;
var taken = _devices;
_devices = NewDeviceTable();
_tagsByRawPath = EmptyTags;
return taken.Values;
}
_devices.Clear();
}
/// <inheritdoc />
@@ -219,6 +327,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
/// <inheritdoc />
/// <remarks>
/// Safe to run concurrently with itself, with <see cref="DisposeAsync"/>/<see cref="Dispose"/> and with
/// an abandoned <see cref="InitializeAsync"/>: the tables are taken by one atomic swap, so each device
/// is released exactly once and an overtaken init releases its own generation. Bounded by
/// <see cref="ShutdownBudget"/>.
/// </remarks>
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Native subs first — disposing the handles is cheap + lets the client close its
@@ -227,30 +341,22 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
_nativeSubs.Clear();
await _poll.DisposeAsync().ConfigureAwait(false);
var taken = TakeAllForTeardown();
// Cancel every probe loop and await its task before disposing the client + gate so the
// loop can never touch a disposed object.
foreach (var state in _devices.Values)
// The poll loops and the device release wind down in parallel (releasing a client can unblock a
// poll loop stuck in a read on it); the whole teardown is bounded.
var teardown = Task.WhenAll(_poll.DisposeAsync().AsTask(), ReleaseDevicesAsync(taken));
try { await teardown.WaitAsync(ShutdownBudget).ConfigureAwait(false); }
catch (TimeoutException)
{
try { state.ProbeCts?.Cancel(); } catch { }
if (state.ProbeTask is Task pt)
{
try { await pt.ConfigureAwait(false); }
catch (OperationCanceledException) { /* expected — probe loop exits on cancel */ }
catch { /* other probe errors are not fatal to shutdown */ }
}
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeClient();
state.DisposeGate();
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' shutdown exceeded {Budget}; the remaining teardown continues in the background",
_driverInstanceId, ShutdownBudget);
}
_devices.Clear();
_tagsByRawPath.Clear();
catch { /* best-effort teardown */ }
_warnedRefs.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
// Dispose + clear the per-parent-word RMW gates so ReinitializeAsync cycles don't orphan
// their SemaphoreSlim instances (each leaks a wait handle once contended).
foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
_bitRmwLocks.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -340,7 +446,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
MarkCommsFault(ex.Message);
MarkCommsFault(ex.Message, device);
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
}
}
@@ -463,7 +569,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
foreach (var e in chunk)
results[e.Index] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
MarkCommsFault(ex.Message);
MarkCommsFault(ex.Message, device);
// Once per device: the health message is overwritten by the next good read in the same poll, so
// without this a sum-read path failing on every poll left no trace (hw2 live gate, 2026-10-08).
if (connected && !device.SumChunkFailureWarned)
@@ -575,7 +681,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
catch (Exception ex)
{
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
MarkCommsFault(ex.Message);
MarkCommsFault(ex.Message, device);
}
}
@@ -771,7 +877,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// The device could not be connected (offline, router down, backoff window open).
// Every tag of this device goes Bad + dead for the retry; other devices carry on.
(unreachable ??= []).Add(device);
MarkCommsFault(unavailable.InnerException?.Message ?? unavailable.Message);
MarkCommsFault(unavailable.InnerException?.Message ?? unavailable.Message, device);
RaiseDataChange(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow));
}
@@ -783,7 +889,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
"TwinCAT driver '{DriverInstanceId}' native-notification registration failed for " +
"'{Reference}'; it will be retried on the next healthy probe tick",
_driverInstanceId, reference);
MarkCommsFault(ex.Message);
MarkCommsFault(ex.Message, device);
RaiseDataChange(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow));
}
@@ -801,6 +907,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return handle;
}
/// <summary>
/// The device state was released (re-initialise / teardown) while a connect on it ran. Not an
/// <see cref="OperationCanceledException"/>: the caller's token was not cancelled, so the data path maps
/// it like any failed request (Bad for this call) and <see cref="MarkCommsFault"/> ignores it.
/// </summary>
private sealed class DeviceReleasedException(string host)
: Exception($"TwinCAT device {host} was released during connect.");
/// <summary>The connect step of a native registration failed — the whole device is unreachable.</summary>
private sealed class DeviceUnavailableException(Exception inner)
: Exception("TwinCAT device unreachable: " + inner.Message, inner);
@@ -809,8 +923,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// Records a comms fault: <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
/// Never downgrades <see cref="DriverState.Faulted"/>.
/// </summary>
private void MarkCommsFault(string message)
/// <param name="message">The fault description.</param>
/// <param name="device">The device the fault was observed on; a fault on a device already swapped out of
/// the table (a read/write racing a re-initialise or teardown) belongs to a dead generation and is
/// not recorded.</param>
private void MarkCommsFault(string message, DeviceState? device = null)
{
if (device?.Released == true) return;
var current = _health;
if (current.State != DriverState.Faulted)
_health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, message);
@@ -1006,8 +1125,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
BitIndex = bitIndex;
ArrayLength = arrayLength;
Interval = interval;
OnChange = onChange;
OnUnavailableStatus = onUnavailable;
// A notification already queued in the client's dispatch channel can be delivered after this
// registration is disposed (unsubscribe / teardown): drop it here, for every client.
OnChange = (symbol, value) => { if (!IsDisposed) onChange(symbol, value); };
OnUnavailableStatus = status => { if (!IsDisposed) onUnavailable(status); };
}
/// <summary>Installs a freshly-registered handle and returns the previous one (null on first set).</summary>
@@ -1025,9 +1146,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public ITwinCATNotificationHandle? MarkHandleDead() =>
Interlocked.Exchange(ref _handle, null);
private volatile bool _disposed;
/// <summary>Gets a value indicating whether this registration has been disposed (its callbacks are dropped).</summary>
public bool IsDisposed => _disposed;
/// <summary>Removes this registration from its device and disposes its live handle.</summary>
public void Dispose()
{
_disposed = true;
Device.NativeRegistrations.TryRemove(Id, out _);
var h = Interlocked.Exchange(ref _handle, null);
try { h?.Dispose(); } catch { /* best-effort */ }
@@ -1085,6 +1212,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
state.ConsecutiveProbeFailures = 0;
RecordDeviceAnswered(DateTime.UtcNow, force: true);
if (probed is not null) ReportNotificationDrops(state, probed);
}
else if (++state.ConsecutiveProbeFailures >= ProbeFailuresBeforeDegraded)
{
@@ -1107,6 +1235,24 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
}
/// <summary>
/// Logs a Warning when the client's native-notification queue dropped values since the last probe
/// tick (the queue is bounded and drops on overflow — silently, before #739 L8). At most one
/// Warning per probe interval, carrying the delta and the total.
/// </summary>
private void ReportNotificationDrops(DeviceState state, ITwinCATClient client)
{
var total = client.DroppedNotificationCount;
var delta = total - state.ReportedNotificationDrops;
if (delta <= 0) return;
state.ReportedNotificationDrops = total;
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' device '{HostAddress}' dropped {Dropped} native notification(s) " +
"since the last probe ({Total} on this connection): the notification queue was full — the consumer " +
"is not keeping up with the PLC's change rate",
_driverInstanceId, state.Options.HostAddress, delta, total);
}
// ---- IDriverDiagnostics ----
// Well-known system symbols, exported by TC3 (and most TC2) PLC runtimes under TwinCAT_SystemInfoVarList.
@@ -1296,11 +1442,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// CLI, pass the host string as the DeviceName). An empty DeviceName routes to the sole device
/// on a single-device instance; an unmatched name leaves the def unrouted (→ BadNodeIdUnknown).
/// </summary>
/// <param name="options">The options generation being initialised.</param>
/// <param name="deviceName">The owning device's name (or host) from the raw-tag entry.</param>
/// <returns>The resolved device host address, or empty when unresolved.</returns>
private string ResolveDeviceHost(string deviceName)
private static string ResolveDeviceHost(TwinCATDriverOptions options, string deviceName)
{
var devices = _options.Devices;
var devices = options.Devices;
if (string.IsNullOrEmpty(deviceName))
return devices.Count == 1 ? devices[0].HostAddress : "";
foreach (var d in devices)
@@ -1372,6 +1519,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
// Re-check under the gate: another caller may have connected while we waited.
if (device.Client is { IsConnected: true } c) return c;
// A released device (swapped out by a re-initialise / teardown) never connects again.
if (device.Released) throw new DeviceReleasedException(device.Options.HostAddress);
// Inside an open backoff window a data-path caller fails fast — no client
// create, no wire connect. The probe loop bypasses this (its interval is the recovery
@@ -1412,7 +1561,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
device.Backoff.RecordFailure(DateTime.UtcNow); // open the backoff window
throw;
}
if (device.Released)
{
// This device was torn down while the (blocking) connect ran: never install a client on a
// released generation, where nothing would ever dispose it.
client.OnSymbolVersionChanged -= HandleSymbolVersionChanged;
try { client.Dispose(); } catch { /* best-effort */ }
throw new DeviceReleasedException(device.Options.HostAddress);
}
device.Client = client;
device.ReportedNotificationDrops = 0;
device.UnavailableDiagSymbols.Clear(); // a rebuilt client may face a PLC that now exports them
device.Backoff.RecordSuccess(); // recovery resets the window immediately
@@ -1608,29 +1766,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
_nativeSubs.Clear();
// PollGroupEngine.DisposeAsync awaits loop tasks; we drive that synchronously here
// (bounded wait — same 5s ceiling DisposeAsync uses internally) using Wait() on the
// returned ValueTask so no sync-context capture happens.
try { _poll.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { }
var taken = TakeAllForTeardown();
var probes = BeginRelease(taken);
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
if (state.ProbeTask is Task pt)
{
try { pt.Wait(TimeSpan.FromSeconds(2)); } catch { /* probe-cancel races are expected */ }
}
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeClient();
state.DisposeGate();
}
_devices.Clear();
_tagsByRawPath.Clear();
// PollGroupEngine.DisposeAsync awaits loop tasks; drive it synchronously with a bounded Wait() on
// the returned task (no sync-context capture), in parallel with the probe join.
var poll = _poll.DisposeAsync().AsTask();
try { Task.WhenAll([poll, .. probes]).Wait(ProbeJoinTimeout); } catch { /* cancel races are expected */ }
EndRelease(taken);
_warnedRefs.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
// Dispose + clear the per-parent-word RMW gates (mirrors ShutdownAsync) so the
// SemaphoreSlim instances aren't orphaned on disposal.
foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
_bitRmwLocks.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -1711,7 +1857,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
UnavailableDiagSymbols.Clear();
}
/// <summary>Disposes the connection gate semaphore.</summary>
public void DisposeGate() => ConnectGate.Dispose();
/// <summary>
/// Set once this device state has been swapped out of the driver's table. A connect that
/// completes afterwards (a probe wedged in the blocking ADS <c>Connect</c> past the bounded join)
/// disposes its client instead of installing it on a dead generation.
/// </summary>
public volatile bool Released;
/// <summary>Total notification drops last reported for this device's current client.</summary>
public long ReportedNotificationDrops;
}
}