@@ -31,6 +31,26 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181.
private readonly ConcurrentDictionary < string , SemaphoreSlim > _bitWriteLocks = new ( ) ;
// PR 2.2 — handle cache. Per-tag read/write resolves a symbolic path to an ADS
// variable handle once, then issues every subsequent op against the handle. Smaller
// AMS payloads (4-byte handle vs N-byte path) + skips name resolution in the runtime.
// Lifetime is process-scoped: cleared on reconnect (EnsureConnected path), wiped on
// a Symbol-Version-Invalid retry, and disposed on Dispose. PR 2.3 will wire a
// proactive Symbol Version invalidation listener so stale handles after an online
// change get evicted before the next read fails — until then, operators can call
// FlushOptionalCachesAsync to wipe manually.
private readonly ConcurrentDictionary < string , uint > _handleCache = new ( ) ;
private bool _wasConnected ;
private readonly object _connectionStateGate = new ( ) ;
// 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.
internal int HandleCreateCount ;
/// <summary>Test-only — current size of the handle cache.</summary>
internal int HandleCacheCount = > _handleCache . Count ;
public AdsTwinCATClient ( )
{
_client . AdsNotificationEx + = OnAdsNotificationEx ;
@@ -43,7 +63,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
if ( _client . IsConnected ) return Task . CompletedTask ;
_client . Timeout = ( int ) Math . Max ( 1_000 , timeout . TotalMilliseconds ) ;
var netId = AmsNetId . Parse ( address . NetId ) ;
// PR 2.2 — a fresh AMS session invalidates every cached handle (handle space is
// per-session in the ADS device server). Clear before reconnect so any read that
// raced with a transient drop never reuses a stale handle from the prior session.
// Note: the handles for the prior session are gone with that session — no need to
// call DeleteVariableHandleAsync, which would just fail with a transport error.
var wasConnected = false ;
lock ( _connectionStateGate )
{
wasConnected = _wasConnected ;
_wasConnected = false ;
}
if ( wasConnected | | ! _handleCache . IsEmpty )
_handleCache . Clear ( ) ;
_client . Connect ( netId , address . Port ) ;
lock ( _connectionStateGate ) _wasConnected = _client . IsConnected ;
return Task . CompletedTask ;
}
@@ -59,13 +96,14 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var clrType = MapToClrType ( type ) ;
var readType = IsWholeArray ( arrayDimensions ) ? clrType . MakeArrayType ( ) : clrType ;
var result = await _client . ReadValueAsync ( symbolPath , readType , cancellationToken )
// PR 2.2 — handle-based read. EnsureHandleAsync resolves through the cache;
// SymbolVersionInvalid evicts + retries once with a fresh handle.
var ( rawValue , errorCode ) = await ReadByHandleWithRetryAsync ( symbolPath , readType , cancellationToken )
. ConfigureAwait ( false ) ;
if ( errorCode ! = AdsErrorCode . NoError )
return ( null , TwinCATStatusMapper . MapAdsError ( ( uint ) errorCode ) ) ;
if ( result . ErrorCod e ! = AdsErrorCode . NoError )
return ( null , TwinCATStatusMapper . MapAdsError ( ( uint ) result . ErrorCode ) ) ;
var value = result . Value ;
var valu e = rawValue ;
if ( IsWholeArray ( arrayDimensions ) )
{
value = PostProcessArray ( type , value ) ;
@@ -84,6 +122,92 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <summary>
/// Resolve <paramref name="symbolPath"/> to a cached ADS variable handle (or create one
/// on first use) and dispatch a <see cref="AdsClient.ReadAnyAsync(uint, Type, CancellationToken)"/>.
/// On <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evicts the cached handle
/// + retries once with a freshly-created handle — covers the online-change race where
/// the symbol survives but its descriptor moves.
/// </summary>
private async Task < ( object? value , AdsErrorCode errorCode ) > ReadByHandleWithRetryAsync (
string symbolPath , Type readType , CancellationToken cancellationToken )
{
var handle = await EnsureHandleAsync ( symbolPath , cancellationToken ) . ConfigureAwait ( false ) ;
var result = await _client . ReadAnyAsync ( handle , readType , cancellationToken ) . ConfigureAwait ( false ) ;
if ( result . ErrorCode = = AdsErrorCode . DeviceSymbolVersionInvalid )
{
EvictHandle ( symbolPath ) ;
handle = await EnsureHandleAsync ( symbolPath , cancellationToken ) . ConfigureAwait ( false ) ;
result = await _client . ReadAnyAsync ( handle , readType , cancellationToken ) . ConfigureAwait ( false ) ;
}
return ( result . Value , result . ErrorCode ) ;
}
/// <summary>
/// Mirror of <see cref="ReadByHandleWithRetryAsync"/> for writes. Returns the final
/// <see cref="AdsErrorCode"/>; the caller maps that to an OPC UA status.
/// </summary>
private async Task < AdsErrorCode > WriteByHandleWithRetryAsync (
string symbolPath , object value , CancellationToken cancellationToken )
{
var handle = await EnsureHandleAsync ( symbolPath , cancellationToken ) . ConfigureAwait ( false ) ;
var result = await _client . WriteAnyAsync ( handle , value , cancellationToken ) . ConfigureAwait ( false ) ;
if ( result . ErrorCode = = AdsErrorCode . DeviceSymbolVersionInvalid )
{
EvictHandle ( symbolPath ) ;
handle = await EnsureHandleAsync ( symbolPath , cancellationToken ) . ConfigureAwait ( false ) ;
result = await _client . WriteAnyAsync ( handle , value , cancellationToken ) . ConfigureAwait ( false ) ;
}
return result . ErrorCode ;
}
/// <summary>
/// Lookup-or-create the cached ADS handle for <paramref name="symbolPath"/>. The
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> guarantees publication safety,
/// but two concurrent callers on a cold key may both call
/// <see cref="AdsClient.CreateVariableHandleAsync(string, CancellationToken)"/>.
/// The loser's handle leaks for the lifetime of the process — acceptable cost
/// given how narrow the race window is, and matched by the libplctag / S7 driver
/// handle-cache patterns.
/// </summary>
internal async ValueTask < uint > EnsureHandleAsync ( string symbolPath , CancellationToken cancellationToken )
{
if ( _handleCache . TryGetValue ( symbolPath , out var existing ) )
return existing ;
Interlocked . Increment ( ref HandleCreateCount ) ;
var result = await _client . CreateVariableHandleAsync ( symbolPath , cancellationToken ) . ConfigureAwait ( false ) ;
if ( result . ErrorCode ! = AdsErrorCode . NoError )
throw new AdsErrorException (
$"CreateVariableHandleAsync failed for '{symbolPath}'" , result . ErrorCode ) ;
// GetOrAdd on a hit returns the winning handle; a loser-side DeleteVariableHandle here
// would race against an in-flight read using that handle elsewhere in this method, so
// we accept the small leak (one-time, per cold key) instead.
return _handleCache . GetOrAdd ( symbolPath , result . Handle ) ;
}
/// <summary>
/// Evict a single cached handle. Best-effort delete on the wire — the runtime may
/// already have invalidated the handle (Symbol-Version-Invalid path), so we swallow
/// transport / ADS errors here.
/// </summary>
private void EvictHandle ( string symbolPath )
{
if ( ! _handleCache . TryRemove ( symbolPath , out var handle ) ) return ;
try
{
// Fire-and-forget delete — the cache key is gone, the wire-side cleanup is
// strictly courtesy. If the device server is in a state where the handle is
// already dead, the delete will fail and we don't care.
_ = _client . DeleteVariableHandleAsync ( handle , CancellationToken . None ) ;
}
catch
{
// Best-effort.
}
}
private static bool IsWholeArray ( int [ ] ? arrayDimensions ) = >
arrayDimensions is { Length : > 0 } & & arrayDimensions . All ( d = > d > 0 ) ;
@@ -125,11 +249,12 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
try
{
var converted = ConvertForWrite ( type , value ) ;
var result = await _client . WriteValueAsync ( symbolPath , converted , cancellationToken )
// PR 2.2 — handle-based write with SymbolVersionInvalid evict-and-retry.
var errorCode = await WriteByHandleWithRetryAsync ( symbolPath , converted , cancellationToken )
. ConfigureAwait ( false ) ;
return result . E rrorCode = = AdsErrorCode . NoError
return e rrorCode = = AdsErrorCode . NoError
? TwinCATStatusMapper . Good
: TwinCATStatusMapper . MapAdsError ( ( uint ) result . E rrorCode) ;
: TwinCATStatusMapper . MapAdsError ( ( uint ) e rrorCode) ;
}
catch ( AdsErrorException ex )
{
@@ -159,19 +284,21 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
await rmwLock . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
try
{
var read = await _client . ReadValueAsync ( parentPath , typeof ( uint ) , cancellationToken )
// PR 2.2 — RMW round-trip flows through the same handle cache so that the
// parent word's resolved handle is reused on subsequent bit writes too.
var ( rawCurrent , readErr ) = await ReadByHandleWithRetryAsync ( parentPath , typeof ( uint ) , cancellationToken )
. ConfigureAwait ( false ) ;
if ( read. ErrorCode ! = AdsErrorCode . NoError )
return TwinCATStatusMapper . MapAdsError ( ( uint ) read. ErrorCode ) ;
if ( readErr ! = AdsErrorCode . NoError )
return TwinCATStatusMapper . MapAdsError ( ( uint ) readErr ) ;
var current = Convert . ToUInt32 ( read . Value ? ? 0 u ) ;
var current = Convert . ToUInt32 ( rawCurrent ? ? 0 u ) ;
var updated = ApplyBit ( current , bit , setBit ) ;
var write = await _client . WriteValue Async( parentPath , updated , cancellationToken )
var writeErr = await WriteByHandleWithRetry Async( parentPath , updated , cancellationToken )
. ConfigureAwait ( false ) ;
return write. ErrorCode = = AdsErrorCode . NoError
return writeErr = = AdsErrorCode . NoError
? TwinCATStatusMapper . Good
: TwinCATStatusMapper . MapAdsError ( ( uint ) write. ErrorCode ) ;
: TwinCATStatusMapper . MapAdsError ( ( uint ) writeErr ) ;
}
catch ( AdsErrorException ex )
{
@@ -354,6 +481,12 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{
if ( reads . Count = = 0 ) return Array . Empty < ( object? , uint ) > ( ) ;
// PR 2.2 deviation: bulk path stays on symbolic Sum-command (SumInstancePathAnyTypeRead /
// SumWriteBySymbolPath). Beckhoff also exposes SumHandleRead / SumWriteByHandle, but
// wiring the cached handles into them changes the request layout substantially +
// would either need to reuse handles created on the per-tag path (tying lifetimes)
// or maintain a parallel handle batch — neither pulls weight against PR 2.1's already
// 10-20× win. Tracked as a follow-up for the Phase-2 perf sweep.
// Build the (path, AnyTypeSpecifier) request envelope. SumInstancePathAnyTypeRead
// batches all paths into a single ADS Sum-read round-trip (IndexGroup 0xF080 = read
// multiple items by symbol name with ANY-type marshalling).
@@ -458,9 +591,53 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{
_client . AdsNotificationEx - = OnAdsNotificationEx ;
_notifications . Clear ( ) ;
// PR 2.2 — release every cached handle on the wire as a good citizen. Best-effort
// and bounded to a short window so a hung router doesn't block process shutdown:
// each delete is fire-and-forget, errors swallowed. The session itself is about to
// tear down anyway, so the device server will reclaim everything regardless.
foreach ( var kv in _handleCache )
{
try
{
_ = _client . DeleteVariableHandleAsync ( kv . Value , CancellationToken . None ) ;
}
catch
{
// Per-entry failures are expected on a closing connection.
}
}
_handleCache . Clear ( ) ;
_client . Dispose ( ) ;
}
/// <summary>
/// PR 2.2 — flush all process-scoped optional caches (handle cache today). A
/// proactive Symbol Version invalidation listener arrives in PR 2.3 — until then,
/// operators / 2.3-aware callers can wipe the cache manually after a known online
/// change.
/// </summary>
public Task FlushOptionalCachesAsync ( )
{
// Best-effort delete on the wire — a held handle won't survive a redeploy anyway,
// but cleaning up matches the Dispose convention.
var snapshot = _handleCache . ToArray ( ) ;
_handleCache . Clear ( ) ;
foreach ( var kv in snapshot )
{
try
{
_ = _client . DeleteVariableHandleAsync ( kv . Value , CancellationToken . None ) ;
}
catch
{
// Best-effort.
}
}
return Task . CompletedTask ;
}
private sealed class NotificationRegistration (
string symbolPath ,
TwinCATDataType type ,