Auto: twincat-2.3 — symbol-version invalidation listener

Closes #312
This commit is contained in:
Joseph Doherty
2026-04-25 22:16:05 -04:00
parent 569001364f
commit 4098d72bbb
6 changed files with 518 additions and 14 deletions

View File

@@ -43,6 +43,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private bool _wasConnected;
private readonly object _connectionStateGate = new();
// PR 2.3 — proactive Symbol-Version invalidation listener. The Beckhoff stack
// surfaces a high-level <see cref="AdsClient.AdsSymbolVersionChanged"/> event
// (built on top of the SymbolVersion ADS notification, IndexGroup 0xF008) that
// fires when the PLC's symbol table version counter increments — i.e. on full
// re-initialisations after a download / activate. Registered after the AMS
// session is up so the device server actually accepts the registration; we
// unregister + clear the handle on Dispose. _symbolVersionRegistered guards
// against double-registration if EnsureSymbolVersionListenerAsync is called
// re-entrantly through ConnectAsync on a reconnect.
//
// Spec deviation: the original PR 2.3 plan called for a raw
// AddDeviceNotificationAsync(AdsReservedIndexGroup.SymbolVersion, ...). Beckhoff
// wrap that in IAdsSymbolChangedProvider on AdsClient so we get a typed
// <see cref="AdsSymbolVersionChangedEventArgs"/> + Dispose-aware unregister
// for free — same wire effect, smaller surface area.
private bool _symbolVersionRegistered;
private long _symbolVersionBumps;
// Test-only counter — number of CreateVariableHandleAsync calls actually issued
// (i.e. cache misses). Integration tests assert this stays at the unique-symbol
// count after a second pass over the same set.
@@ -51,16 +69,26 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// <summary>Test-only — current size of the handle cache.</summary>
internal int HandleCacheCount => _handleCache.Count;
/// <summary>Test-only — total Symbol-Version bumps observed since process start.</summary>
internal long SymbolVersionBumps => Interlocked.Read(ref _symbolVersionBumps);
public AdsTwinCATClient()
{
_client.AdsNotificationEx += OnAdsNotificationEx;
_client.AdsSymbolVersionChanged += OnAdsSymbolVersionChanged;
}
public bool IsConnected => _client.IsConnected;
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
public async Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_client.IsConnected) return Task.CompletedTask;
if (_client.IsConnected)
{
// Idempotent. Still ensure the Symbol-Version listener is registered — first
// ConnectAsync may have lost the registration if the AMS session dropped.
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
return;
}
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
var netId = AmsNetId.Parse(address.NetId);
@@ -78,10 +106,71 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
if (wasConnected || !_handleCache.IsEmpty)
_handleCache.Clear();
// PR 2.3 — a reconnect drops the device-side notification registration. Mark
// the listener as needing re-registration so EnsureSymbolVersionListenerAsync
// re-arms it against the new session.
_symbolVersionRegistered = false;
_client.Connect(netId, address.Port);
lock (_connectionStateGate) _wasConnected = _client.IsConnected;
return Task.CompletedTask;
// PR 2.3 — register the Symbol-Version listener now that the AMS session is up.
// Best-effort: a registration failure here doesn't fail the connect (the
// DeviceSymbolVersionInvalid evict-and-retry path from PR 2.2 stays as the safety
// net), it just means we won't get proactive cache invalidation until next reconnect.
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// PR 2.3 — register the Beckhoff <c>AdsSymbolVersionChanged</c> event listener
/// against the current AMS session. Idempotent: a second call while
/// <see cref="_symbolVersionRegistered"/> is <c>true</c> is a no-op so reconnect
/// paths can call this freely without double-arming. Failures swallowed because
/// the PR 2.2 reactive evict-and-retry path is still in place — proactive
/// invalidation is an optimisation, not a correctness requirement.
/// </summary>
private async Task EnsureSymbolVersionListenerAsync(CancellationToken cancellationToken)
{
if (_symbolVersionRegistered) return;
try
{
await _client.RegisterSymbolVersionChangedAsync(OnAdsSymbolVersionChanged, cancellationToken)
.ConfigureAwait(false);
_symbolVersionRegistered = true;
}
catch (OperationCanceledException) { throw; }
catch
{
// Best-effort. The reactive evict-and-retry path (PR 2.2) catches the same
// staleness; this is just an optimisation that lets us preempt the wasted
// request that would otherwise come back DeviceSymbolVersionInvalid.
}
}
/// <summary>
/// PR 2.3 — Beckhoff fires this when the PLC's symbol-version counter increments,
/// which happens on every full re-initialisation (download, activate-config, etc.).
/// Every cached handle is invalid against the new symbol table, so we wipe the
/// cache here. In-flight reads that already hold a handle will fall through to the
/// PR 2.2 <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evict-and-retry path,
/// which is exactly what we want — the proactive wipe just preempts the wasted
/// round-trip on the next read for any symbol that didn't already have an in-flight op.
/// </summary>
private void OnAdsSymbolVersionChanged(object? sender, AdsSymbolVersionChangedEventArgs e)
{
Interlocked.Increment(ref _symbolVersionBumps);
// Snapshot cache for best-effort wire-side cleanup, then clear so the next
// EnsureHandleAsync re-resolves. Wire deletes are fire-and-forget — the device
// server has already invalidated these handles, so the deletes typically just
// bounce back with an error code we don't care about.
var snapshot = _handleCache.ToArray();
_handleCache.Clear();
foreach (var kv in snapshot)
{
try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); }
catch { /* best-effort; the new symbol-table version makes these handles dead anyway */ }
}
}
public async Task<(object? value, uint status)> ReadValueAsync(
@@ -590,6 +679,18 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
public void Dispose()
{
_client.AdsNotificationEx -= OnAdsNotificationEx;
// PR 2.3 — unregister the Symbol-Version listener. Best-effort: by the time we're
// disposing, the AMS session is already shutting down so the device server may
// refuse the unregister. Either way, AdsClient.Dispose tears the underlying
// notification subscription down regardless.
if (_symbolVersionRegistered)
{
try { _client.UnregisterSymbolVersionChanged(OnAdsSymbolVersionChanged); }
catch { /* best-effort */ }
_symbolVersionRegistered = false;
}
_client.AdsSymbolVersionChanged -= OnAdsSymbolVersionChanged;
_notifications.Clear();
// PR 2.2 — release every cached handle on the wire as a good citizen. Best-effort