fix(driver-twincat): resolve Low code-review findings (Driver.TwinCAT-004,006,014,015,016)
- Driver.TwinCAT-004: corrected the IEC time-type inline comments; documented that the driver currently surfaces them as raw UInt32 counters. - Driver.TwinCAT-006: ResolveHost returns a documented UnresolvedHost sentinel when no devices are configured instead of returning the logical DriverInstanceId (which never matches GetHostStatuses). - Driver.TwinCAT-014: wired Probe.Timeout into the probe-loop call and added a NotificationMaxDelayMs config knob threaded through AddNotificationAsync. - Driver.TwinCAT-015: Dispose() runs a genuinely synchronous teardown with bounded waits (no sync-over-async deadlock pattern). - Driver.TwinCAT-016: pinned the Structure-tag rejection and the probe-loop vs read disposal race with regression tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -175,9 +176,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
// tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when
|
||||
// the value differs; OnCycle fires every cycle. OnChange is the right default for
|
||||
// OPC UA data-change semantics — the PLC already has the best view of "has this
|
||||
// changed" so we let it decide.
|
||||
// changed" so we let it decide. maxDelayMs > 0 lets TwinCAT batch notifications up
|
||||
// to that delay before pushing them — exposed via TwinCATDriverOptions
|
||||
// (Driver.TwinCAT-014).
|
||||
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, 0);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, Math.Max(0, maxDelayMs));
|
||||
|
||||
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
|
||||
@@ -67,6 +67,9 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
|
||||
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
|
||||
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
|
||||
/// <param name="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce
|
||||
/// notifications up to this delay before pushing them. <c>0</c> = no batching, push
|
||||
/// immediately (Driver.TwinCAT-014).</param>
|
||||
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
|
||||
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
@@ -74,6 +77,7 @@ public interface ITwinCATClient : IDisposable
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
|
||||
@@ -21,10 +21,16 @@ public enum TwinCATDataType
|
||||
LReal, // 64-bit IEEE-754
|
||||
String, // ASCII string
|
||||
WString,// UTF-16 string
|
||||
Time, // TIME — ms since epoch of day, stored as UDINT
|
||||
Date, // DATE — days since 1970-01-01, stored as UDINT
|
||||
DateTime, // DT — seconds since 1970-01-01, stored as UDINT
|
||||
TimeOfDay,// TOD — ms since midnight, stored as UDINT
|
||||
// IEC 61131-3 / TwinCAT temporal types — all stored on the wire as 32-bit unsigned (UDINT)
|
||||
// raw counters. The driver surfaces them as DriverDataType.UInt32 (Driver.TwinCAT-002), so
|
||||
// operators see the raw counter, not a decoded date/duration. Proper decoding to
|
||||
// DriverDataType.DateTime is a future enhancement; until then comments accurately describe
|
||||
// the on-wire encoding so the next implementer doesn't re-derive it wrong
|
||||
// (Driver.TwinCAT-004).
|
||||
Time, // TIME — duration in milliseconds, stored as UDINT (32-bit unsigned)
|
||||
Date, // DATE — seconds since 1970-01-01 truncated to a day boundary, stored as UDINT
|
||||
DateTime, // DT (DATE_AND_TIME) — seconds since 1970-01-01, stored as UDINT
|
||||
TimeOfDay,// TOD — milliseconds since midnight, stored as UDINT
|
||||
/// <summary>UDT / FB instance. Resolved per member at discovery time.</summary>
|
||||
Structure,
|
||||
}
|
||||
|
||||
@@ -377,6 +377,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
var reg = await client.AddNotificationAsync(
|
||||
symbolName, def.DataType, bitIndex, publishingInterval,
|
||||
_options.NotificationMaxDelayMs,
|
||||
(_, value) => OnDataChange?.Invoke(this,
|
||||
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
|
||||
@@ -433,7 +434,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
// Probe-initiated connects honor TwinCATProbeOptions.Timeout — distinct from
|
||||
// the driver-wide _options.Timeout used by reads/writes (Driver.TwinCAT-014).
|
||||
var client = await EnsureConnectedAsync(state, ct, _options.Probe.Timeout)
|
||||
.ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
@@ -469,11 +473,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Documented sentinel returned by <see cref="ResolveHost"/> when neither the tag nor a
|
||||
/// fallback device is configured. Empty-string never matches an
|
||||
/// <see cref="HostConnectivityStatus.HostName"/> emitted by this driver (every real
|
||||
/// host is an <c>ads://…</c> URI), so it cleanly signals "unresolved" without colliding
|
||||
/// with a real host key. Used to be <see cref="DriverInstanceId"/>, which is a logical
|
||||
/// config-DB identifier — that collided with consumers who expected the resolver and the
|
||||
/// connectivity-status table to share keys (Driver.TwinCAT-006).
|
||||
/// </summary>
|
||||
public const string UnresolvedHostSentinel = "";
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
// First device's HostAddress when one exists; otherwise the unresolved sentinel —
|
||||
// intentionally NOT DriverInstanceId, which is a config-DB key, not a host address
|
||||
// (Driver.TwinCAT-006).
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? UnresolvedHostSentinel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -484,7 +502,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
/// AB-CIP drivers serialize device access the same way; single-connection-per-PLC is
|
||||
/// also what docs/v2/driver-specs.md recommends.
|
||||
/// </summary>
|
||||
private async Task<ITwinCATClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||
private async Task<ITwinCATClient> EnsureConnectedAsync(
|
||||
DeviceState device, CancellationToken ct, TimeSpan? timeoutOverride = null)
|
||||
{
|
||||
// Fast path — already connected, no gate needed.
|
||||
if (device.Client is { IsConnected: true } fast) return fast;
|
||||
@@ -504,9 +523,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
var client = _clientFactory.Create();
|
||||
client.OnSymbolVersionChanged += HandleSymbolVersionChanged;
|
||||
// timeoutOverride lets the probe loop use TwinCATProbeOptions.Timeout for probe-
|
||||
// initiated connects rather than the driver-level _options.Timeout
|
||||
// (Driver.TwinCAT-014). Reads / writes pass null and get the driver default.
|
||||
var effectiveTimeout = timeoutOverride ?? _options.Timeout;
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
|
||||
await client.ConnectAsync(device.ParsedAddress, effectiveTimeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"TwinCAT driver '{DriverInstanceId}' connected to {HostAddress}",
|
||||
@@ -542,7 +565,43 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
"TwinCAT symbol-version-changed (DeviceSymbolVersionInvalid 0x0711) — PLC program re-downloaded",
|
||||
ScopeHint: "TwinCAT"));
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
/// <summary>
|
||||
/// Synchronous teardown — no <c>await</c>, no captured sync context. The OPC UA stack
|
||||
/// thread can call <see cref="Dispose"/>; routing through <c>DisposeAsync().GetResult()</c>
|
||||
/// can deadlock on a single-threaded sync context (Driver.TwinCAT-015,
|
||||
/// docs/v2/driver-stability.md). The operations here are all genuinely synchronous —
|
||||
/// cancel tokens, wait on task handles with a hard timeout, dispose clients — so a
|
||||
/// synchronous path does the right thing without re-entering the scheduler.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// Dispose native subscriptions first — handle disposal is sync.
|
||||
foreach (var sub in _nativeSubs.Values)
|
||||
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 { }
|
||||
|
||||
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();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options)
|
||||
|
||||
@@ -60,9 +60,19 @@ public static class TwinCATDriverFactoryExtensions
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
NotificationMaxDelayMs = dto.NotificationMaxDelayMs ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-visible wrapper around <see cref="ParseOptions"/> for the regression suite —
|
||||
/// keeps the public driver surface unchanged while letting tests assert that JSON
|
||||
/// fields like <c>NotificationMaxDelayMs</c> and <c>Structure</c>-tag rejection are
|
||||
/// honored end-to-end.
|
||||
/// </summary>
|
||||
public static TwinCATDriverOptions ParseOptionsForTests(string driverConfigJson, string driverInstanceId)
|
||||
=> ParseOptions(driverConfigJson, driverInstanceId);
|
||||
|
||||
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId)
|
||||
{
|
||||
var dataType = ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType");
|
||||
@@ -117,6 +127,7 @@ public static class TwinCATDriverFactoryExtensions
|
||||
public int? TimeoutMs { get; init; }
|
||||
public bool? UseNativeNotifications { get; init; }
|
||||
public bool? EnableControllerBrowse { get; init; }
|
||||
public int? NotificationMaxDelayMs { get; init; }
|
||||
public List<TwinCATDeviceDto>? Devices { get; init; }
|
||||
public List<TwinCATTagDto>? Tags { get; init; }
|
||||
public TwinCATProbeDto? Probe { get; init; }
|
||||
|
||||
@@ -32,6 +32,16 @@ public sealed class TwinCATDriverOptions
|
||||
/// the strict-config path for deployments where only declared tags should appear.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum batching delay in milliseconds for ADS device notifications. Passed to
|
||||
/// <c>NotificationSettings</c> as the max-delay value: TwinCAT may coalesce notifications
|
||||
/// up to this delay before pushing them. <c>0</c> (default) = no batching, push
|
||||
/// immediately. Useful for high-churn signals where the OPC UA subscriber tolerates a
|
||||
/// small delay in exchange for fewer wire round-trips. Listed in <c>docs/v2/driver-specs.md</c>
|
||||
/// section 6 — was previously hard-coded to 0 (Driver.TwinCAT-014).
|
||||
/// </summary>
|
||||
public int NotificationMaxDelayMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user