Merge pull request '[twincat] TwinCAT — Symbol-version invalidation listener' (#366) from auto/twincat/2.3 into auto/driver-gaps
This commit was merged in pull request #366.
This commit is contained in:
@@ -172,17 +172,21 @@ the second pass issues zero new `CreateVariableHandleAsync` calls. It runs
|
||||
under the standard `[TwinCATFact]` gate (XAR reachable; no `TWINCAT_PERF`
|
||||
opt-in needed because 50 symbols is cheap).
|
||||
|
||||
**Staleness caveat**: handles can go stale after a TwinCAT online change
|
||||
(POU edit + activate). Until PR 2.3 ships the proactive Symbol-Version
|
||||
invalidation listener, the safety net is twofold: (1) the
|
||||
`DeviceSymbolVersionInvalid` evict-and-retry path catches cases where the
|
||||
descriptor moves but the symbol survives, and (2) operators can call
|
||||
`ITwinCATClient.FlushOptionalCachesAsync` manually after a known online
|
||||
change to wipe the cache without forcing a full reconnect. The bulk
|
||||
Sum-read / Sum-write path remains on symbolic paths in PR 2.2 (the bulk
|
||||
path's per-call symbol resolution is already amortised across N tags;
|
||||
the perf delta vs. handle-batched bulk is marginal — tracked as a
|
||||
follow-up for the Phase-2 perf sweep).
|
||||
**Self-invalidation (PR 2.3)**: handle cache is now self-invalidating on
|
||||
TwinCAT online changes. `AdsTwinCATClient` registers an
|
||||
`AdsSymbolVersionChanged` event listener (Beckhoff's high-level wrapper
|
||||
around the SymbolVersion ADS notification, IndexGroup `0xF008`) on connect;
|
||||
when the PLC's symbol-version counter increments — full re-init after a
|
||||
download / activate-config — the listener fires and wipes the handle cache
|
||||
proactively. Three-layered defence in depth: (1) proactive listener
|
||||
preempts the next read entirely on full re-inits, (2) the
|
||||
`DeviceSymbolVersionInvalid` evict-and-retry path from PR 2.2 catches the
|
||||
narrower "symbol survives but its descriptor moved" race, and (3)
|
||||
operators can still call `ITwinCATClient.FlushOptionalCachesAsync` manually
|
||||
for the truly-paranoid case. The bulk Sum-read / Sum-write path remains
|
||||
on symbolic paths in PR 2.2 (the bulk path's per-call symbol resolution
|
||||
is already amortised across N tags; the perf delta vs. handle-batched
|
||||
bulk is marginal — tracked as a follow-up for the Phase-2 perf sweep).
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 2.3 integration test — exercises the proactive Symbol-Version invalidation
|
||||
/// listener against a real XAR runtime. Reads 5 symbols to populate the handle
|
||||
/// cache, polls until the operator triggers an online change in TwinCAT XAE
|
||||
/// (which bumps the PLC symbol-version counter), then asserts the cache wiped
|
||||
/// and a follow-up read recreates handles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Manual gating</b>: this test requires an operator to trigger the
|
||||
/// online change from XAE while the test is polling — there's no programmatic ADS
|
||||
/// surface for "edit + activate". Gated on env <c>TWINCAT_MANUAL_ONLINE_CHANGE=1</c>
|
||||
/// so the default integration pass skips it; operators flip it on when running
|
||||
/// the scenario manually per <c>TwinCatProject/README.md §Online-change test scenario</c>.</para>
|
||||
///
|
||||
/// <para><b>How to run</b>: set <c>TWINCAT_TARGET_HOST</c> + <c>TWINCAT_TARGET_NETID</c>
|
||||
/// + <c>TWINCAT_MANUAL_ONLINE_CHANGE=1</c>, kick off the test, then within ~60 s
|
||||
/// open the project in XAE → add a dummy variable to <c>GVL_Perf</c> → Login +
|
||||
/// Activate Configuration. The PLC re-initialises, the symbol-version counter
|
||||
/// bumps, the listener fires, and the test passes.</para>
|
||||
/// </remarks>
|
||||
[Collection("TwinCATXar")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "TwinCAT-XAR")]
|
||||
public sealed class TwinCATSymbolVersionTests(TwinCATXarFixture sim)
|
||||
{
|
||||
private const string ManualOnlineChangeEnv = "TWINCAT_MANUAL_ONLINE_CHANGE";
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_invalidates_handle_cache_on_symbol_version_bump()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (Environment.GetEnvironmentVariable(ManualOnlineChangeEnv) != "1")
|
||||
Assert.Skip(
|
||||
$"Manual online-change scenario disabled. Set {ManualOnlineChangeEnv}=1 + " +
|
||||
"follow TwinCatProject/README.md §Online-change test scenario to run.");
|
||||
|
||||
var deviceAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
|
||||
|
||||
// Use 5 symbols out of GVL_Perf.aTags — same fixture state as the perf tests.
|
||||
// ArrayDimensions = [1] forces the per-tag (handle-cached) path so the test
|
||||
// actually exercises the cache the listener is meant to invalidate.
|
||||
var tags = new TwinCATTagDefinition[5];
|
||||
var refs = new string[5];
|
||||
for (var i = 0; i < tags.Length; i++)
|
||||
{
|
||||
var name = $"Perf{i + 1}";
|
||||
refs[i] = name;
|
||||
tags[i] = new TwinCATTagDefinition(
|
||||
Name: name,
|
||||
DeviceHostAddress: deviceAddress,
|
||||
SymbolPath: $"GVL_Perf.aTags[{i + 1}]",
|
||||
DataType: TwinCATDataType.DInt,
|
||||
ArrayDimensions: [1]);
|
||||
}
|
||||
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(deviceAddress, "XAR-VM")],
|
||||
Tags = tags,
|
||||
UseNativeNotifications = false,
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
var capture = new CapturingFactory();
|
||||
await using var drv = new TwinCATDriver(options, "tc3-symbol-version", capture);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// First pass populates the cache.
|
||||
var firstResults = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
|
||||
firstResults.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good);
|
||||
capture.Client.ShouldNotBeNull();
|
||||
var initialCreates = capture.Client!.HandleCreateCount;
|
||||
initialCreates.ShouldBe(tags.Length);
|
||||
capture.Client!.HandleCacheCount.ShouldBe(tags.Length);
|
||||
var initialBumps = capture.Client!.SymbolVersionBumps;
|
||||
|
||||
// Wait for an operator-triggered online change. Poll the bump counter at 500 ms
|
||||
// intervals up to 60 s — long enough to cover the manual XAE workflow (open
|
||||
// project → add var → Login → Activate). When the counter ticks, the listener
|
||||
// has fired + wiped the cache.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(60);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (capture.Client!.SymbolVersionBumps > initialBumps) break;
|
||||
await Task.Delay(500, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
capture.Client!.SymbolVersionBumps.ShouldBeGreaterThan(initialBumps,
|
||||
"expected operator to trigger an online change within 60 s; " +
|
||||
"see TwinCatProject/README.md §Online-change test scenario");
|
||||
capture.Client!.HandleCacheCount.ShouldBe(0,
|
||||
"Symbol-Version listener should have wiped the handle cache on bump");
|
||||
|
||||
// Subsequent reads recreate handles — total CreateVariableHandle count grows.
|
||||
var secondResults = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
|
||||
secondResults.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good);
|
||||
capture.Client!.HandleCreateCount.ShouldBe(initialCreates + tags.Length,
|
||||
"post-bump reads must recreate every handle from cold");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes <see cref="ITwinCATClientFactory.Create"/> through the production
|
||||
/// <see cref="AdsTwinCATClientFactory"/> and snapshots the produced client so the
|
||||
/// test can read its internal handle-cache + symbol-version counters. Mirror of
|
||||
/// the <c>CapturingFactory</c> in <see cref="TwinCATHandleCachePerfTests"/>.
|
||||
/// </summary>
|
||||
private sealed class CapturingFactory : ITwinCATClientFactory
|
||||
{
|
||||
public AdsTwinCATClient? Client { get; private set; }
|
||||
|
||||
public ITwinCATClient Create()
|
||||
{
|
||||
var c = new AdsTwinCATClient();
|
||||
Client ??= c;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,47 @@ Options to eliminate the manual step:
|
||||
the rotation permanently, worth it if the integration host is
|
||||
long-lived.
|
||||
|
||||
## Online-change test scenario
|
||||
|
||||
PR 2.3 (proactive Symbol-Version invalidation listener) ships an
|
||||
operator-gated integration test
|
||||
(`TwinCATSymbolVersionTests.Driver_invalidates_handle_cache_on_symbol_version_bump`)
|
||||
that verifies `AdsTwinCATClient`'s `AdsSymbolVersionChanged` listener
|
||||
wipes the handle cache when the PLC re-initialises after an online
|
||||
change. The test polls for up to 60 s waiting for the operator to
|
||||
trigger the change from XAE.
|
||||
|
||||
The fixture state (`GVL_Perf` + `aTags[1..1000]`) is the same one used by
|
||||
the Sum-read perf test — no new project state required.
|
||||
|
||||
### Manual workflow
|
||||
|
||||
With the XAR runtime live + the test process polling:
|
||||
|
||||
1. **Open the project in XAE** on the dev box (or wherever XAE runs).
|
||||
2. **Add a dummy variable to `GVL_Perf`** — any new declaration triggers
|
||||
a symbol-table rebuild. Example: append
|
||||
`bSymVerProbe : BOOL := FALSE;` to the GVL.
|
||||
3. **Login** (`Ctrl+F8`) — XAE prompts to load the change.
|
||||
4. **Activate Configuration** (Yellow-arrow button, or `TwinCAT → Activate Configuration`).
|
||||
The runtime re-initialises; the symbol-version counter increments;
|
||||
`AdsTwinCATClient.OnAdsSymbolVersionChanged` fires; the handle cache
|
||||
wipes; the test polls observe `SymbolVersionBumps > 0` + asserts the
|
||||
post-bump read recreates handles.
|
||||
|
||||
The test skips by default — opt in by setting
|
||||
`TWINCAT_MANUAL_ONLINE_CHANGE=1` alongside the standard
|
||||
`TWINCAT_TARGET_HOST` / `TWINCAT_TARGET_NETID` env vars before kicking
|
||||
off the test run.
|
||||
|
||||
```powershell
|
||||
$env:TWINCAT_TARGET_HOST = '10.0.0.42'
|
||||
$env:TWINCAT_TARGET_NETID = '5.23.91.23.1.1'
|
||||
$env:TWINCAT_MANUAL_ONLINE_CHANGE = '1'
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests `
|
||||
--filter "FullyQualifiedName~TwinCATSymbolVersionTests"
|
||||
```
|
||||
|
||||
## How to run the TwinCAT-tier tests
|
||||
|
||||
On the dev box:
|
||||
|
||||
@@ -71,15 +71,32 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
// wire-side delete needed.
|
||||
if (IsConnected) _handleCache.Clear();
|
||||
IsConnected = true;
|
||||
// PR 2.3 — production arms the Symbol-Version listener after the AMS session is up.
|
||||
// Mirror so unit tests can assert "ConnectAsync registered the version listener".
|
||||
// Default cache-wipe handler matches AdsTwinCATClient.OnAdsSymbolVersionChanged.
|
||||
if (!SymbolVersionRegistered)
|
||||
RegisterSymbolVersionListener(WipeHandleCacheOnVersionBump);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void WipeHandleCacheOnVersionBump()
|
||||
{
|
||||
// Note: kv-snapshot delete already happened in FireSymbolVersionChange before
|
||||
// this callback fires, so the cache is already empty. Kept as the default
|
||||
// listener so the wiring contract is still observable through tests that pass
|
||||
// a custom action via RegisterSymbolVersionListener.
|
||||
}
|
||||
|
||||
/// <summary>Test helper — simulate a reconnect (ConnectAsync after the connection drops).</summary>
|
||||
public void SimulateReconnect()
|
||||
{
|
||||
IsConnected = false;
|
||||
_handleCache.Clear();
|
||||
// Production marks the listener as needing re-registration on reconnect.
|
||||
UnregisterSymbolVersionListener();
|
||||
IsConnected = true;
|
||||
if (!SymbolVersionRegistered)
|
||||
RegisterSymbolVersionListener(WipeHandleCacheOnVersionBump);
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadValueAsync(
|
||||
@@ -164,6 +181,57 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- PR 2.3: Symbol-Version invalidation listener ----
|
||||
//
|
||||
// Mirror of the Beckhoff AdsClient.AdsSymbolVersionChanged surface. ConnectAsync
|
||||
// arms the listener so test asserts can verify the production driver registered
|
||||
// it on connect; FireSymbolVersionChange() drives the same handle-cache-wipe path
|
||||
// AdsTwinCATClient runs on a real PLC online change.
|
||||
public bool SymbolVersionRegistered { get; private set; }
|
||||
public int SymbolVersionRegistrationCount { get; private set; }
|
||||
public int SymbolVersionUnregistrationCount { get; private set; }
|
||||
public long SymbolVersionBumps { get; private set; }
|
||||
/// <summary>Externally-supplied callback (production wires this to the cache wipe).</summary>
|
||||
private Action? _onSymbolVersionChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Test helper exposed in lieu of the Beckhoff event surface — the production
|
||||
/// <c>AdsTwinCATClient</c> registers via <c>RegisterSymbolVersionChangedAsync</c>
|
||||
/// after connect; the fake records the registration here so tests can assert
|
||||
/// "subscribed on connect". The callback (an <see cref="Action"/> rather than the
|
||||
/// full <see cref="EventHandler{T}"/> shape) is the cache-wipe entry point.
|
||||
/// </summary>
|
||||
public void RegisterSymbolVersionListener(Action onChange)
|
||||
{
|
||||
_onSymbolVersionChanged = onChange;
|
||||
SymbolVersionRegistered = true;
|
||||
SymbolVersionRegistrationCount++;
|
||||
}
|
||||
|
||||
public void UnregisterSymbolVersionListener()
|
||||
{
|
||||
if (!SymbolVersionRegistered) return;
|
||||
_onSymbolVersionChanged = null;
|
||||
SymbolVersionRegistered = false;
|
||||
SymbolVersionUnregistrationCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drive the Symbol-Version-changed callback path. Production wipes the handle
|
||||
/// cache + bumps the diagnostic counter; mirror so unit tests can assert
|
||||
/// post-bump state without standing up a real ADS device. Safe to call when no
|
||||
/// listener is registered (no-op + still bumps the counter so test code can
|
||||
/// assert "we tried but no-one was listening").
|
||||
/// </summary>
|
||||
public void FireSymbolVersionChange()
|
||||
{
|
||||
SymbolVersionBumps++;
|
||||
// Mirror production cache-wipe semantics: snapshot, clear, emit per-entry deletes.
|
||||
foreach (var kv in _handleCache) HandleDeleteInvocations.Add(kv.Value);
|
||||
_handleCache.Clear();
|
||||
_onSymbolVersionChanged?.Invoke();
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnProbe) return Task.FromResult(false);
|
||||
@@ -236,6 +304,8 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
// the fan-out delete count matches.
|
||||
foreach (var kv in _handleCache) HandleDeleteInvocations.Add(kv.Value);
|
||||
_handleCache.Clear();
|
||||
// PR 2.3 — production unregisters the Symbol-Version listener on Dispose.
|
||||
UnregisterSymbolVersionListener();
|
||||
}
|
||||
|
||||
// ---- notification fake ----
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 2.3 — proactive Symbol-Version invalidation listener. Verifies the contract
|
||||
/// mirrored on <see cref="FakeTwinCATClient"/>: <c>ConnectAsync</c> registers a
|
||||
/// listener, <see cref="FakeTwinCATClient.FireSymbolVersionChange"/> wipes the
|
||||
/// handle cache + bumps the diagnostic counter, the next read recreates the
|
||||
/// handle, and <c>Dispose</c> unregisters cleanly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fake's state machine is a high-fidelity mirror of <c>AdsTwinCATClient</c>'s
|
||||
/// <c>RegisterSymbolVersionChangedAsync</c> / <c>OnAdsSymbolVersionChanged</c>
|
||||
/// wiring; the production class is exercised end-to-end on a real PLC by the
|
||||
/// integration-tier <see cref="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests"/>
|
||||
/// online-change scenario, which is gated on the operator triggering an actual
|
||||
/// activate-config from XAE.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATSymbolVersionTests
|
||||
{
|
||||
private const string DevA = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_registers_symbol_version_listener()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
fake.SymbolVersionRegistered.ShouldBeFalse("listener should not be armed before connect");
|
||||
|
||||
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
fake.SymbolVersionRegistered.ShouldBeTrue();
|
||||
fake.SymbolVersionRegistrationCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FireSymbolVersionChange_clears_handle_cache()
|
||||
{
|
||||
var fake = new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2, ["MAIN.C"] = 3 } };
|
||||
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
await fake.ReadValueAsync("MAIN.A", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
|
||||
await fake.ReadValueAsync("MAIN.B", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
|
||||
await fake.ReadValueAsync("MAIN.C", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
|
||||
fake.HandleCacheCount.ShouldBe(3);
|
||||
|
||||
fake.FireSymbolVersionChange();
|
||||
|
||||
fake.HandleCacheCount.ShouldBe(0);
|
||||
// Each cached handle gets a delete record (mirror of AdsTwinCATClient's
|
||||
// best-effort DeleteVariableHandleAsync fan-out on cache-wipe).
|
||||
fake.HandleDeleteInvocations.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_bump_next_read_recreates_handle()
|
||||
{
|
||||
var fake = new FakeTwinCATClient { Values = { ["MAIN.X"] = 42 } };
|
||||
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
await fake.ReadValueAsync("MAIN.X", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
|
||||
fake.HandleCreateInvocations.Count.ShouldBe(1);
|
||||
|
||||
fake.FireSymbolVersionChange();
|
||||
|
||||
// Cache is cold — next read pays the CreateVariableHandle cost again.
|
||||
await fake.ReadValueAsync("MAIN.X", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
|
||||
fake.HandleCreateInvocations.Count.ShouldBe(2);
|
||||
fake.HandleCacheCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SymbolVersionBumps_counter_increments_per_bump()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
fake.SymbolVersionBumps.ShouldBe(0);
|
||||
|
||||
fake.FireSymbolVersionChange();
|
||||
fake.SymbolVersionBumps.ShouldBe(1);
|
||||
|
||||
fake.FireSymbolVersionChange();
|
||||
fake.FireSymbolVersionChange();
|
||||
fake.SymbolVersionBumps.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_unregisters_listener()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
fake.SymbolVersionRegistered.ShouldBeTrue();
|
||||
|
||||
fake.Dispose();
|
||||
|
||||
fake.SymbolVersionRegistered.ShouldBeFalse();
|
||||
fake.SymbolVersionUnregistrationCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_re_registers_listener()
|
||||
{
|
||||
// Production marks the listener as needing re-registration on every (re)connect
|
||||
// because the device-side notification subscription is per-AMS-session — same
|
||||
// lifecycle as the handle cache itself.
|
||||
var fake = new FakeTwinCATClient();
|
||||
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
fake.SymbolVersionRegistrationCount.ShouldBe(1);
|
||||
|
||||
fake.SimulateReconnect();
|
||||
|
||||
fake.SymbolVersionRegistered.ShouldBeTrue();
|
||||
fake.SymbolVersionRegistrationCount.ShouldBe(2);
|
||||
fake.SymbolVersionUnregistrationCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bump_through_driver_invalidates_handle_cache_for_subsequent_per_tag_reads()
|
||||
{
|
||||
// Drive the bump from outside the driver and verify the per-tag (handle-cached)
|
||||
// path resumes with a fresh handle on the next read. Whole-array reads route
|
||||
// through the per-tag path, so they're the cleanest way to assert the contract
|
||||
// through the public driver surface.
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var captured = new FakeTwinCATClient
|
||||
{
|
||||
Values = { ["MAIN.Recipe"] = new int[] { 1, 2, 3, 4 } },
|
||||
};
|
||||
factory.Customise = () => captured;
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(DevA)],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition("Recipe", DevA, "MAIN.Recipe", TwinCATDataType.DInt,
|
||||
ArrayDimensions: [4]),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-symver", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.ReadAsync(["Recipe"], TestContext.Current.CancellationToken);
|
||||
captured.HandleCreateInvocations.Count.ShouldBe(1);
|
||||
|
||||
// Simulate the PLC publishing a Symbol-Version-changed event mid-flight.
|
||||
captured.FireSymbolVersionChange();
|
||||
captured.SymbolVersionBumps.ShouldBe(1);
|
||||
captured.HandleCacheCount.ShouldBe(0);
|
||||
|
||||
// Next read recreates the handle from cold.
|
||||
await drv.ReadAsync(["Recipe"], TestContext.Current.CancellationToken);
|
||||
captured.HandleCreateInvocations.Count.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user