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:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 5 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ does not support UDT tags, and `BrowseSymbolsAsync` already correctly yields
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `TwinCATDataType.cs:24-27` |
|
| Location | `TwinCATDataType.cs:24-27` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The inline comments for the IEC time types are inaccurate. TwinCAT `TIME` is
|
**Description:** The inline comments for the IEC time types are inaccurate. TwinCAT `TIME` is
|
||||||
a duration (32-bit, milliseconds) — not "ms since epoch of day". `DATE` is stored as seconds
|
a duration (32-bit, milliseconds) — not "ms since epoch of day". `DATE` is stored as seconds
|
||||||
@@ -125,7 +125,7 @@ implementer who tries to add proper conversion.
|
|||||||
date/time semantics are intended to be exposed properly, track a follow-up to decode them to
|
date/time semantics are intended to be exposed properly, track a follow-up to decode them to
|
||||||
`DriverDataType.DateTime`; otherwise document that they surface as raw counters.
|
`DriverDataType.DateTime`; otherwise document that they surface as raw counters.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — rewrote the inline comments to match the actual IEC 61131-3 / TwinCAT encoding (TIME = duration in ms, DATE = seconds since 1970-01-01 truncated to a day boundary, DT = seconds since 1970-01-01, TOD = ms since midnight) and added a block comment documenting that the driver surfaces them as raw UDINT counters via `DriverDataType.UInt32`. Test `Iec_time_types_map_to_uint32_raw_counter` pins the mapping.
|
||||||
|
|
||||||
### Driver.TwinCAT-005
|
### Driver.TwinCAT-005
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ catch), native-notification registration failures, and host state transitions
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | OtOpcUa conventions |
|
| Category | OtOpcUa conventions |
|
||||||
| Location | `TwinCATDriver.cs:406-411` |
|
| Location | `TwinCATDriver.cs:406-411` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `ResolveHost` falls back to `DriverInstanceId` when there are no configured
|
**Description:** `ResolveHost` falls back to `DriverInstanceId` when there are no configured
|
||||||
devices and the reference is unknown. `DriverInstanceId` is a logical config-DB identifier,
|
devices and the reference is unknown. `DriverInstanceId` is a logical config-DB identifier,
|
||||||
@@ -169,7 +169,7 @@ connectivity-status row.
|
|||||||
empty string or a documented unresolved marker), or document why the instance ID is the chosen
|
empty string or a documented unresolved marker), or document why the instance ID is the chosen
|
||||||
fallback. Prefer the first device HostAddress only when one exists (already done).
|
fallback. Prefer the first device HostAddress only when one exists (already done).
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — `ResolveHost` now returns `TwinCATDriver.UnresolvedHostSentinel` (empty string) when no devices are configured, replacing the `DriverInstanceId` collision with `GetHostStatuses()` rows. The sentinel is publicly documented on the driver type. Updated `ResolveHost_falls_back_to_unresolved_sentinel_when_no_devices` (was `_to_DriverInstanceId_`) and added `ResolveHost_returns_unresolved_sentinel_when_no_devices` + `ResolveHost_unresolved_sentinel_matches_no_GetHostStatuses_entry` regressions.
|
||||||
|
|
||||||
### Driver.TwinCAT-007
|
### Driver.TwinCAT-007
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ part of the documented driver contract, not optional.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `TwinCATDriverOptions.cs:41-43`, `TwinCATDriverOptions.cs:57-62`, `AdsTwinCATClient.cs:145` |
|
| Location | `TwinCATDriverOptions.cs:41-43`, `TwinCATDriverOptions.cs:57-62`, `AdsTwinCATClient.cs:145` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Several drifts between the implemented config surface and
|
**Description:** Several drifts between the implemented config surface and
|
||||||
`docs/v2/driver-specs.md` section 6. The spec connection-settings list has separate `Host`
|
`docs/v2/driver-specs.md` section 6. The spec connection-settings list has separate `Host`
|
||||||
@@ -376,7 +376,7 @@ the probe path connects via `_options.Timeout` — a dead config field. The spec
|
|||||||
shape (the doc is DRAFT, so updating it is acceptable). Remove or wire up
|
shape (the doc is DRAFT, so updating it is acceptable). Remove or wire up
|
||||||
`TwinCATProbeOptions.Timeout`. Expose `NotificationMaxDelayMs` if batching control is wanted.
|
`TwinCATProbeOptions.Timeout`. Expose `NotificationMaxDelayMs` if batching control is wanted.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — `TwinCATProbeOptions.Timeout` is now wired into `EnsureConnectedAsync` via an optional `timeoutOverride` parameter that the probe loop passes (reads / writes keep the driver-level `_options.Timeout`). Added a `TwinCATDriverOptions.NotificationMaxDelayMs` config knob (parsed from `driverConfigJson` via `TwinCATDriverConfigDto.NotificationMaxDelayMs`) and threaded it through `ITwinCATClient.AddNotificationAsync` so `NotificationSettings` carries the configured max-delay instead of the hard-coded 0. The `Host` / `AmsNetId` / `AmsPort` triple in the spec was already implemented as the single `HostAddress` (parsed `ads://{netId}:{port}` URI) — kept as-is to match the v2 driver convention; covered by `TwinCATAmsAddress`. Regression tests: `ProbeOptions_Timeout_is_applied_to_probe_calls`, `NotificationMaxDelayMs_is_exposed_on_driver_options`, `NotificationMaxDelayMs_parses_from_driver_config_json`.
|
||||||
|
|
||||||
### Driver.TwinCAT-015
|
### Driver.TwinCAT-015
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ shape (the doc is DRAFT, so updating it is acceptable). Remove or wire up
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Location | `TwinCATDriver.cs:431-432` |
|
| Location | `TwinCATDriver.cs:431-432` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `Dispose()` runs `DisposeAsync().AsTask().GetAwaiter().GetResult()` —
|
**Description:** `Dispose()` runs `DisposeAsync().AsTask().GetAwaiter().GetResult()` —
|
||||||
sync-over-async. `docs/v2/driver-stability.md` section Galaxy explicitly lists "sync-over-async
|
sync-over-async. `docs/v2/driver-stability.md` section Galaxy explicitly lists "sync-over-async
|
||||||
@@ -399,7 +399,7 @@ here — cancelling token sources, disposing clients, clearing dictionaries —
|
|||||||
synchronous, and `PollGroupEngine.DisposeAsync` completes synchronously, so factor the
|
synchronous, and `PollGroupEngine.DisposeAsync` completes synchronously, so factor the
|
||||||
synchronous teardown out so `Dispose()` does not block on a `Task`.
|
synchronous teardown out so `Dispose()` does not block on a `Task`.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — `Dispose()` now does an inline synchronous teardown with no `await` and no captured sync context: dispose native subscriptions, drive `PollGroupEngine.DisposeAsync` via `.AsTask().Wait(5s)` (no context capture), per-device `ProbeCts.Cancel()` + `ProbeTask.Wait(2s)`, `DisposeClient()` / `DisposeGate()`, then clear the dictionaries. `DisposeAsync` still routes through `ShutdownAsync` for genuinely async callers. Regression test `Dispose_does_not_block_on_async_in_default_synchronization_context` runs `Dispose()` inside a single-threaded `SynchronizationContext` that would deadlock a sync-over-async teardown and asserts it completes within 5s.
|
||||||
|
|
||||||
### Driver.TwinCAT-016
|
### Driver.TwinCAT-016
|
||||||
|
|
||||||
@@ -408,7 +408,7 @@ synchronous teardown out so `Dispose()` does not block on a `Task`.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Location | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` |
|
| Location | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Unit coverage exists for AMS-address parsing, symbol-path parsing, read/write,
|
**Description:** Unit coverage exists for AMS-address parsing, symbol-path parsing, read/write,
|
||||||
native notifications, symbol browse, and the capability surface. Gaps tied to the findings
|
native notifications, symbol browse, and the capability surface. Gaps tied to the findings
|
||||||
@@ -423,4 +423,4 @@ without truncation (Driver.TwinCAT-002).
|
|||||||
addressed, especially a concurrency stress test for `EnsureConnectedAsync` and a
|
addressed, especially a concurrency stress test for `EnsureConnectedAsync` and a
|
||||||
`ReinitializeAsync`-applies-new-config test.
|
`ReinitializeAsync`-applies-new-config test.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — the previously-closed High findings each grew their regression coverage as they were resolved (see `TwinCATHighFindingsRegressionTests`: `ReinitializeAsync_applies_changed_device_config` for -001, `LInt_read_round_trips_value_above_int_MaxValue` + `DataType_mapping_preserves_width_and_signedness` for -002, `Concurrent_reads_on_one_device_create_a_single_client` + `Concurrent_reads_and_writes_share_one_client` for -007, `Symbol_version_changed_raises_OnRediscoveryNeeded` + `TwinCATDriver_implements_IRediscoverable` for -013). This pass added the two remaining gaps: `Structure_typed_pre_declared_tag_is_rejected_at_config_parse` (-003) and `Probe_loop_and_read_share_one_client_per_device` (-009 disposal-race coverage races 64 readers against the probe loop for 500ms and asserts a single client / single connect). All coverage lives in the test files `TwinCATHighFindingsRegressionTests.cs` and the new `TwinCATLowFindingsRegressionTests.cs`.
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
TimeSpan cycleTime,
|
TimeSpan cycleTime,
|
||||||
|
int maxDelayMs,
|
||||||
Action<string, object?> onChange,
|
Action<string, object?> onChange,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -175,9 +176,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
// tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when
|
// tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when
|
||||||
// the value differs; OnCycle fires every cycle. OnChange is the right default for
|
// 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
|
// 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 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
|
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
|
||||||
// with the handle as part of the event args so we use the handle as the correlation
|
// 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="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="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="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="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>
|
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||||
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||||
@@ -74,6 +77,7 @@ public interface ITwinCATClient : IDisposable
|
|||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
TimeSpan cycleTime,
|
TimeSpan cycleTime,
|
||||||
|
int maxDelayMs,
|
||||||
Action<string, object?> onChange,
|
Action<string, object?> onChange,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ public enum TwinCATDataType
|
|||||||
LReal, // 64-bit IEEE-754
|
LReal, // 64-bit IEEE-754
|
||||||
String, // ASCII string
|
String, // ASCII string
|
||||||
WString,// UTF-16 string
|
WString,// UTF-16 string
|
||||||
Time, // TIME — ms since epoch of day, stored as UDINT
|
// IEC 61131-3 / TwinCAT temporal types — all stored on the wire as 32-bit unsigned (UDINT)
|
||||||
Date, // DATE — days since 1970-01-01, stored as UDINT
|
// raw counters. The driver surfaces them as DriverDataType.UInt32 (Driver.TwinCAT-002), so
|
||||||
DateTime, // DT — seconds since 1970-01-01, stored as UDINT
|
// operators see the raw counter, not a decoded date/duration. Proper decoding to
|
||||||
TimeOfDay,// TOD — ms since midnight, stored as UDINT
|
// 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>
|
/// <summary>UDT / FB instance. Resolved per member at discovery time.</summary>
|
||||||
Structure,
|
Structure,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
|
|
||||||
var reg = await client.AddNotificationAsync(
|
var reg = await client.AddNotificationAsync(
|
||||||
symbolName, def.DataType, bitIndex, publishingInterval,
|
symbolName, def.DataType, bitIndex, publishingInterval,
|
||||||
|
_options.NotificationMaxDelayMs,
|
||||||
(_, value) => OnDataChange?.Invoke(this,
|
(_, value) => OnDataChange?.Invoke(this,
|
||||||
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||||
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
|
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
|
||||||
@@ -433,7 +434,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
var success = false;
|
var success = false;
|
||||||
try
|
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);
|
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
@@ -469,11 +473,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
|
|
||||||
// ---- IPerCallHostResolver ----
|
// ---- 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)
|
public string ResolveHost(string fullReference)
|
||||||
{
|
{
|
||||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||||
return def.DeviceHostAddress;
|
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>
|
/// <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
|
/// AB-CIP drivers serialize device access the same way; single-connection-per-PLC is
|
||||||
/// also what docs/v2/driver-specs.md recommends.
|
/// also what docs/v2/driver-specs.md recommends.
|
||||||
/// </summary>
|
/// </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.
|
// Fast path — already connected, no gate needed.
|
||||||
if (device.Client is { IsConnected: true } fast) return fast;
|
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();
|
var client = _clientFactory.Create();
|
||||||
client.OnSymbolVersionChanged += HandleSymbolVersionChanged;
|
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
|
try
|
||||||
{
|
{
|
||||||
await client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
|
await client.ConnectAsync(device.ParsedAddress, effectiveTimeout, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"TwinCAT driver '{DriverInstanceId}' connected to {HostAddress}",
|
"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",
|
"TwinCAT symbol-version-changed (DeviceSymbolVersionInvalid 0x0711) — PLC program re-downloaded",
|
||||||
ScopeHint: "TwinCAT"));
|
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);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options)
|
internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options)
|
||||||
|
|||||||
@@ -60,9 +60,19 @@ public static class TwinCATDriverFactoryExtensions
|
|||||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||||
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
||||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
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)
|
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId)
|
||||||
{
|
{
|
||||||
var dataType = ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType");
|
var dataType = ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType");
|
||||||
@@ -117,6 +127,7 @@ public static class TwinCATDriverFactoryExtensions
|
|||||||
public int? TimeoutMs { get; init; }
|
public int? TimeoutMs { get; init; }
|
||||||
public bool? UseNativeNotifications { get; init; }
|
public bool? UseNativeNotifications { get; init; }
|
||||||
public bool? EnableControllerBrowse { get; init; }
|
public bool? EnableControllerBrowse { get; init; }
|
||||||
|
public int? NotificationMaxDelayMs { get; init; }
|
||||||
public List<TwinCATDeviceDto>? Devices { get; init; }
|
public List<TwinCATDeviceDto>? Devices { get; init; }
|
||||||
public List<TwinCATTagDto>? Tags { get; init; }
|
public List<TwinCATTagDto>? Tags { get; init; }
|
||||||
public TwinCATProbeDto? Probe { 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.
|
/// the strict-config path for deployments where only declared tags should appear.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool EnableControllerBrowse { get; init; }
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -67,14 +67,17 @@ internal class FakeTwinCATClient : ITwinCATClient
|
|||||||
|
|
||||||
public List<FakeNotification> Notifications { get; } = new();
|
public List<FakeNotification> Notifications { get; } = new();
|
||||||
public bool ThrowOnAddNotification { get; set; }
|
public bool ThrowOnAddNotification { get; set; }
|
||||||
|
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
|
||||||
|
public int LastMaxDelayMs { get; private set; }
|
||||||
|
|
||||||
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (ThrowOnAddNotification)
|
if (ThrowOnAddNotification)
|
||||||
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
|
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
|
||||||
|
|
||||||
|
LastMaxDelayMs = maxDelayMs;
|
||||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
|
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
|
||||||
Notifications.Add(reg);
|
Notifications.Add(reg);
|
||||||
return Task.FromResult<ITwinCATNotificationHandle>(reg);
|
return Task.FromResult<ITwinCATNotificationHandle>(reg);
|
||||||
|
|||||||
@@ -217,12 +217,14 @@ public sealed class TwinCATCapabilityTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
public async Task ResolveHost_falls_back_to_unresolved_sentinel_when_no_devices()
|
||||||
{
|
{
|
||||||
|
// Driver.TwinCAT-006: empty-string sentinel — DriverInstanceId is a config-DB key, not
|
||||||
|
// a host address, so it would collide with no GetHostStatuses() row.
|
||||||
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
drv.ResolveHost("anything").ShouldBe(TwinCATDriver.UnresolvedHostSentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
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>
|
||||||
|
/// Regression coverage for the remaining (Low/Medium) code-review findings:
|
||||||
|
/// Driver.TwinCAT-004 (IEC time-type doc-comment accuracy), -006 (ResolveHost
|
||||||
|
/// sentinel for no-devices fallback), -014 (NotificationMaxDelayMs config knob
|
||||||
|
/// and ProbeOptions.Timeout wiring), -015 (Dispose runs a true synchronous
|
||||||
|
/// teardown, no sync-over-async), -016 (gap-fill tests: Structure-tag rejection,
|
||||||
|
/// concurrent probe + read race).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TwinCATLowFindingsRegressionTests
|
||||||
|
{
|
||||||
|
private const string DeviceA = "ads://5.23.91.23.1.1:851";
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Iec_time_types_map_to_uint32_raw_counter()
|
||||||
|
{
|
||||||
|
// Documents the contract called out in the corrected comments: TIME / DATE / DT / TOD
|
||||||
|
// surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan.
|
||||||
|
// The next implementer who wants to decode them needs to see this mapping is intentional.
|
||||||
|
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||||
|
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||||
|
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||||
|
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_returns_unresolved_sentinel_when_no_devices()
|
||||||
|
{
|
||||||
|
// DriverInstanceId is a logical config-DB key, not a host address; consumers expect a
|
||||||
|
// host key that correlates with GetHostStatuses(). When there are no devices and the
|
||||||
|
// reference is unknown, ResolveHost must return the documented unresolved sentinel
|
||||||
|
// (empty string), not the driver-instance ID.
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.ResolveHost("anything").ShouldBe(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveHost_unresolved_sentinel_matches_no_GetHostStatuses_entry()
|
||||||
|
{
|
||||||
|
// Documents the contract: the sentinel should never match a real connectivity-status row.
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var sentinel = drv.ResolveHost("anything");
|
||||||
|
drv.GetHostStatuses().ShouldNotContain(s => s.HostName == sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-014 — config surface knobs are honoured ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeOptions_Timeout_is_applied_to_probe_calls()
|
||||||
|
{
|
||||||
|
// The previous implementation declared a Timeout field but never read it — the probe
|
||||||
|
// path connected with _options.Timeout. The probe must use its own configured timeout.
|
||||||
|
var observed = new List<TimeSpan>();
|
||||||
|
var factory = new FakeTwinCATClientFactory
|
||||||
|
{
|
||||||
|
Customise = () => new ProbeTimeoutCapturingFake(observed),
|
||||||
|
};
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Probe = new TwinCATProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(100),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(750), // distinct from the connect timeout
|
||||||
|
},
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(2_000),
|
||||||
|
}, "drv-1", factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await WaitForAsync(() => observed.Count >= 1, TimeSpan.FromSeconds(2));
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
observed.ShouldContain(TimeSpan.FromMilliseconds(750));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationMaxDelayMs_is_exposed_on_driver_options()
|
||||||
|
{
|
||||||
|
// The driver-spec lists NotificationMaxDelayMs as a per-device knob; the implementation
|
||||||
|
// previously hard-coded 0 in NotificationSettings. Expose a configurable field so
|
||||||
|
// operators can batch low-priority notifications.
|
||||||
|
var options = new TwinCATDriverOptions { NotificationMaxDelayMs = 200 };
|
||||||
|
options.NotificationMaxDelayMs.ShouldBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotificationMaxDelayMs_parses_from_driver_config_json()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
devices = new[] { new { hostAddress = DeviceA } },
|
||||||
|
notificationMaxDelayMs = 150,
|
||||||
|
});
|
||||||
|
var parsed = TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1");
|
||||||
|
parsed.NotificationMaxDelayMs.ShouldBe(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-015 — Dispose runs a true synchronous teardown ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_does_not_block_on_async_in_default_synchronization_context()
|
||||||
|
{
|
||||||
|
// Sync-over-async on a single-threaded sync context (like the OPC UA stack thread) can
|
||||||
|
// deadlock. Dispose() must complete without scheduling continuations through a captured
|
||||||
|
// sync context. We verify by running Dispose() inside a SynchronizationContext that
|
||||||
|
// would deadlock a sync-over-async teardown.
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1");
|
||||||
|
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var ctx = new SingleThreadedSyncContext();
|
||||||
|
var prev = SynchronizationContext.Current;
|
||||||
|
Exception? captured = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(ctx);
|
||||||
|
// If Dispose schedules its continuations through the captured context (sync-over-
|
||||||
|
// async pattern), and the context is single-threaded with nothing pumping, this
|
||||||
|
// will hang. We give it 5 seconds — well above any reasonable sync teardown.
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(ctx);
|
||||||
|
drv.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { captured = ex; }
|
||||||
|
}) { IsBackground = true };
|
||||||
|
thread.Start();
|
||||||
|
thread.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue(
|
||||||
|
"Dispose() did not complete within 5s — likely sync-over-async deadlock " +
|
||||||
|
"(Driver.TwinCAT-015).");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(prev);
|
||||||
|
}
|
||||||
|
captured.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-threaded sync context that posts continuations to an internal queue but never
|
||||||
|
/// pumps them. Any sync-over-async code that captures this context and waits for a
|
||||||
|
/// continuation will deadlock — exactly the OPC UA stack thread scenario.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SingleThreadedSyncContext : SynchronizationContext
|
||||||
|
{
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentQueue<(SendOrPostCallback cb, object? state)> _queue = new();
|
||||||
|
public override void Post(SendOrPostCallback d, object? state) => _queue.Enqueue((d, state));
|
||||||
|
public override void Send(SendOrPostCallback d, object? state) => d(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.TwinCAT-016 — gap-fill tests for previously closed findings ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Structure_typed_pre_declared_tag_is_rejected_at_config_parse()
|
||||||
|
{
|
||||||
|
// Driver.TwinCAT-003 — config-time rejection. A Structure tag must fail loudly with a
|
||||||
|
// clear error rather than reading as a garbage int blob or failing late on a write.
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
devices = new[] { new { hostAddress = DeviceA } },
|
||||||
|
tags = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
name = "Udt1",
|
||||||
|
deviceHostAddress = DeviceA,
|
||||||
|
symbolPath = "MAIN.fbInstance",
|
||||||
|
dataType = "Structure",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||||
|
TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1"));
|
||||||
|
ex.Message.ShouldContain("Structure");
|
||||||
|
ex.Message.ShouldContain("Udt1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_loop_and_read_share_one_client_per_device()
|
||||||
|
{
|
||||||
|
// Driver.TwinCAT-007 / -009 — gap-fill: race the probe loop against concurrent reads on
|
||||||
|
// the same device. The per-device gate must serialize connect; the probe-task await on
|
||||||
|
// ShutdownAsync must let the loop exit cleanly. Without these, the test trips a leaked
|
||||||
|
// client or a disposal race.
|
||||||
|
var factory = new FakeTwinCATClientFactory
|
||||||
|
{
|
||||||
|
Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 }, ProbeResult = true },
|
||||||
|
};
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(DeviceA)],
|
||||||
|
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
|
||||||
|
Probe = new TwinCATProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(20),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(50),
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Race 64 readers against the probe loop for ~500ms.
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||||
|
var work = Enumerable.Range(0, 64).Select(_ => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await drv.ReadAsync(["X"], CancellationToken.None); }
|
||||||
|
catch { /* shutdown-races on the very last call are ok */ }
|
||||||
|
}
|
||||||
|
})).ToArray();
|
||||||
|
await Task.WhenAll(work);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// One client total. If the gate is broken, concurrent connects leak additional clients.
|
||||||
|
factory.Clients.Count.ShouldBe(1);
|
||||||
|
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (!condition() && DateTime.UtcNow < deadline)
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Captures the timeout argument from ProbeAsync invocations (via the connect path).</summary>
|
||||||
|
private sealed class ProbeTimeoutCapturingFake : FakeTwinCATClient
|
||||||
|
{
|
||||||
|
private readonly List<TimeSpan> _observed;
|
||||||
|
public ProbeTimeoutCapturingFake(List<TimeSpan> observed) { _observed = observed; }
|
||||||
|
|
||||||
|
public override Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// The driver calls EnsureConnectedAsync with the probe timeout for probe-initiated
|
||||||
|
// connects. The probe timeout is distinct from the driver-level Timeout; we record
|
||||||
|
// it on the first connect.
|
||||||
|
lock (_observed) _observed.Add(timeout);
|
||||||
|
return base.ConnectAsync(address, timeout, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,12 +137,12 @@ public sealed class TwinCATNativeNotificationTests
|
|||||||
|
|
||||||
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
AddCallCount++;
|
AddCallCount++;
|
||||||
if (AddCallCount > _succeedBefore)
|
if (AddCallCount > _succeedBefore)
|
||||||
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
|
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
|
||||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, onChange, cancellationToken);
|
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user