- 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>
27 KiB
Code Review — Driver.TwinCAT
| Field | Value |
|---|---|
| Module | src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT |
| Reviewer | Claude Code |
| Review date | 2026-05-22 |
| Commit reviewed | 76d35d1 |
| Status | Reviewed |
| Open findings | 0 |
Checklist coverage
A comprehensive review completes every category, recording "No issues found" where a category produced nothing rather than leaving it blank.
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.TwinCAT-001, Driver.TwinCAT-002, Driver.TwinCAT-003, Driver.TwinCAT-004 |
| 2 | OtOpcUa conventions | Driver.TwinCAT-005, Driver.TwinCAT-006 |
| 3 | Concurrency & thread safety | Driver.TwinCAT-007, Driver.TwinCAT-008, Driver.TwinCAT-009 |
| 4 | Error handling & resilience | Driver.TwinCAT-010, Driver.TwinCAT-011 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Driver.TwinCAT-012 |
| 7 | Design-document adherence | Driver.TwinCAT-013, Driver.TwinCAT-014 |
| 8 | Code organization & conventions | Driver.TwinCAT-015 |
| 9 | Testing coverage | Driver.TwinCAT-016 |
| 10 | Documentation & comments | Driver.TwinCAT-004 (data-type comment), Driver.TwinCAT-014 |
Findings
Driver.TwinCAT-001
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | TwinCATDriver.cs:41-78 |
| Status | Resolved |
Description: InitializeAsync and ReinitializeAsync both ignore their driverConfigJson
parameter entirely. InitializeAsync builds device/tag state exclusively from _options,
captured once in the constructor. ReinitializeAsync calls ShutdownAsync then
InitializeAsync(driverConfigJson, ...) — but since InitializeAsync never reads
driverConfigJson, a ReinitializeAsync with a changed config silently re-applies the
original constructor-time options. Per IDriver.ReinitializeAsync docs and
docs/v2/driver-stability.md section "In-process only (Tier A/B)", Reinitialize is the only
Core-initiated path to apply a new config generation without a process restart. As written,
config changes (added/removed devices, tags, probe settings) to a TwinCAT driver instance
are never picked up at runtime.
Recommendation: Parse driverConfigJson in InitializeAsync (reuse
TwinCATDriverFactoryExtensions DTO + option-builder logic — extract it to a shared static
parser) and assign the resulting options to a mutable field, rather than relying on the
constructor-captured _options. Alternatively, document explicitly that the constructor is
the sole config source and have the Core recreate the driver instance on config change.
Resolution: Resolved 2026-05-22 — extracted the DTO→options parse into a shared TwinCATDriverFactoryExtensions.ParseOptions; InitializeAsync re-parses driverConfigJson into a now-mutable _options field, so ReinitializeAsync applies a changed config generation.
Driver.TwinCAT-002
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | TwinCATDataType.cs:34-48, AdsTwinCATClient.cs:264-281 |
| Status | Resolved |
Description: TwinCATDataTypeExtensions.ToDriverDataType maps LInt and ULInt (signed/
unsigned 64-bit) to DriverDataType.Int32 (comment: "matches Int64 gap"). The address-space
layer therefore creates a 32-bit OPC UA node for a 64-bit PLC value. Meanwhile
AdsTwinCATClient.MapToClrType reads LInt/ULInt as long/ulong (64-bit), so the read
path returns a boxed long/ulong into a node typed Int32. DriverDataType already has an
Int64/UInt64 member (DriverDataType.cs:16-19), so the "gap" the comment refers to does
not exist. Values above int.MaxValue are silently truncated or produce a type mismatch at
the OPC UA encode layer; UDInt is also folded into Int32, so unsigned 32-bit values in
the range 0x80000000 to 0xFFFFFFFF surface as negative.
Recommendation: Map LInt to Int64, ULInt to UInt64, UDInt to UInt32, UInt
to UInt16, and USInt/SInt to their natural widths. Remove the stale "Int64 gap" comment.
Resolution: Resolved 2026-05-22 — ToDriverDataType now maps LInt→Int64, ULInt→UInt64, UDInt→UInt32, UInt/USInt→UInt16, Int/SInt→Int16, and the IEC time types→UInt32; removed the stale Int64-gap comment.
Driver.TwinCAT-003
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | AdsTwinCATClient.cs:264-281, 283-300 |
| Status | Resolved |
Description: MapToClrType has a _ => typeof(int) fallthrough and ConvertForWrite has
a _ => throw NotSupportedException fallthrough. TwinCATDataType.Structure is a declared
enum member, and a config-supplied tag can carry DataType: "Structure" because ParseEnum
in the factory accepts any enum name case-insensitively. A Structure tag therefore reads as
a 4-byte int against whatever the symbol actually is (a UDT blob) — a garbage/out-of-bounds
read rather than a clean rejection — while a write fails late with NotSupportedException.
Discovery ToDriverDataType maps Structure to String, compounding the inconsistency.
Recommendation: Reject Structure-typed pre-declared tags at InitializeAsync /
TwinCATDriverFactoryExtensions.BuildTag time with a clear error — the driver atomic surface
does not support UDT tags, and BrowseSymbolsAsync already correctly yields
DataType = null for them.
Resolution: Resolved 2026-05-22 — BuildTag now parses the DataType field first and rejects TwinCATDataType.Structure with an InvalidOperationException that names the tag and explains the limitation; configuration-time failure replaces the previous silent garbage read or late NotSupportedException.
Driver.TwinCAT-004
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | TwinCATDataType.cs:24-27 |
| Status | Resolved |
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
since 1970-01-01 (truncated to a day boundary), not "days since 1970-01-01". These types are
also all read/written as raw uint and mapped to DriverDataType.Int32 — the operator sees
a raw counter, not a usable date/duration. Misleading comments will mislead the next
implementer who tries to add proper conversion.
Recommendation: Correct the comments to match the TwinCAT/IEC 61131-3 representation. If
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.
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
| Field | Value |
|---|---|
| Severity | Medium |
| Category | OtOpcUa conventions |
| Location | TwinCATDriver.cs (whole file), AdsTwinCATClient.cs (whole file) |
| Status | Resolved |
Description: The driver performs no logging. CLAUDE.md Library Preferences mandate
Serilog with a rolling daily file sink. Connect failures, ADS error codes, symbol-browse
failures (DiscoverAsync swallows them in a bare catch), notification-registration
failures, and probe state transitions all vanish into status fields or are swallowed
silently. Operators get a Degraded health string with no correlatable log trail.
Recommendation: Inject an ILogger/Serilog logger and log at minimum: connect
success/failure per device, ADS errors with code, symbol-browse fallback (the DiscoverAsync
catch), native-notification registration failures, and host state transitions
(TransitionDeviceState).
Resolution: Resolved 2026-05-22 — added optional ILogger<TwinCATDriver> constructor parameter (defaults to NullLogger); logs connect success/failure in EnsureConnectedAsync, ADS read errors in ReadAsync, symbol-browse fallback in DiscoverAsync, notification-registration failures in SubscribeAsync, and host-state transitions in TransitionDeviceState.
Driver.TwinCAT-006
| Field | Value |
|---|---|
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | TwinCATDriver.cs:406-411 |
| Status | Resolved |
Description: ResolveHost falls back to DriverInstanceId when there are no configured
devices and the reference is unknown. DriverInstanceId is a logical config-DB identifier,
not a host address; IPerCallHostResolver consumers expect a host key that correlates with
GetHostStatuses() entries (HostConnectivityStatus.HostName equals
device.Options.HostAddress). Returning the instance ID produces a host key that matches no
connectivity-status row.
Recommendation: Return a stable sentinel that will not be confused with a real host (an 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).
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
| Field | Value |
|---|---|
| Severity | High |
| Category | Concurrency & thread safety |
| Location | TwinCATDriver.cs:413-429 |
| Status | Resolved |
Description: EnsureConnectedAsync is not thread-safe. ReadAsync, WriteAsync,
SubscribeAsync, and the per-device ProbeLoopAsync background task can all call it
concurrently for the same DeviceState. The sequence device.Client ??= _clientFactory.Create()
followed by await device.Client.ConnectAsync(...) has no lock: two threads can both observe
device.Client null-or-disconnected, each create/connect a client, and one
AdsTwinCATClient is leaked (its AdsClient + AdsNotificationEx handler never disposed).
Worse, on the connect-failure path one thread does device.Client.Dispose(); device.Client = null;
while another thread is mid-ConnectAsync on that same client instance — a disposal race that
can throw ObjectDisposedException or corrupt the AdsClient. The probe loop runs
continuously, so this race is not hypothetical under any concurrent read/write load.
Recommendation: Guard EnsureConnectedAsync per-device with a SemaphoreSlim (one per
DeviceState), or use an async-lazy connect with proper invalidation. The S7/AB-CIP drivers
serialize device access with a SemaphoreSlim — follow that pattern. Note this also
serializes the wire, which docs/v2/driver-specs.md recommends for single-connection-per-PLC.
Resolution: Resolved 2026-05-22 — EnsureConnectedAsync is now serialized per device by a SemaphoreSlim (DeviceState.ConnectGate) with a double-checked connect, mirroring the S7 driver; no client is leaked and no disposal race remains.
Driver.TwinCAT-008
| Field | Value |
|---|---|
| Severity | High |
| Category | Concurrency & thread safety |
| Location | AdsTwinCATClient.cs:162-169, TwinCATDriver.cs:319-324 |
| Status | Resolved |
Description: Native ADS notification callbacks (OnAdsNotificationEx) run on the
AdsClient AMS router thread. docs/v2/driver-specs.md section 6 explicitly calls this out
as a code-review checklist item: "Callbacks must marshal to a managed work queue immediately
(no driver logic on the router thread) — blocking the router thread blocks every ADS
notification across the process." The current path invokes reg.OnChange(...) directly on the
router thread, and OnChange is the driver lambda that calls OnDataChange?.Invoke(this, ...)
— i.e. every downstream Core/OPC UA subscriber handler executes synchronously on the AMS
router thread. A single slow consumer stalls ADS notification delivery for every tag on every
device in the process.
Recommendation: Marshal notification values onto a bounded Channel/work queue drained by
a dedicated managed task before invoking OnChange/OnDataChange, exactly as the Galaxy
EventPump does. Keep the router-thread callback to a non-blocking enqueue only.
Resolution: Resolved 2026-05-22 — AdsTwinCATClient now enqueues native AdsNotificationEx callbacks onto a bounded Channel drained by a dedicated managed task; the AMS router thread only does a non-blocking TryWrite, so a slow consumer cannot stall ADS delivery.
Driver.TwinCAT-009
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | TwinCATDriver.cs:80-99, 41-72, 366-388 |
| Status | Resolved |
Description: ShutdownAsync mutates _devices, _tagsByName, and _nativeSubs with no
synchronization while ReadAsync/WriteAsync/SubscribeAsync may be iterating or indexing
those same plain Dictionary<> instances on other threads (_devices and _tagsByName are
non-concurrent dictionaries). ShutdownAsync calls _devices.Clear()/_tagsByName.Clear()
concurrently with _devices.TryGetValue in a read — Dictionary<> is not safe for concurrent
read+write and can throw or corrupt internal state. ReinitializeAsync makes this worse: it
runs ShutdownAsync then InitializeAsync (which re-populates the same dictionaries) while
in-flight reads continue. The probe loop EnsureConnectedAsync also touches DeviceState
objects that ShutdownAsync is disposing — ShutdownAsync cancels ProbeCts but does not
await the probe task before calling DisposeClient().
Recommendation: Either swap _devices/_tagsByName to ConcurrentDictionary and snapshot
them on rebuild, or introduce a lifecycle lock / volatile running guard so reads fail fast
with BadServerHalted/BadNodeIdUnknown once shutdown begins. Cancel and await the probe
tasks before disposing DeviceStates.
Resolution: Resolved 2026-05-22 — swapped _devices and _tagsByName to ConcurrentDictionary so concurrent TryGetValue / Clear calls are safe; added DeviceState.ProbeTask and updated ShutdownAsync to cancel then await each probe task before disposing the client and gate, eliminating the disposal race.
Driver.TwinCAT-010
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | AdsTwinCATClient.cs:178-195 |
| Status | Resolved |
Description: BrowseSymbolsAsync checks cancellationToken.IsCancellationRequested and
does yield break (a clean completion) rather than throwing OperationCanceledException.
DiscoverAsync (TwinCATDriver.cs:274) explicitly has catch (OperationCanceledException) { throw; } to propagate cancellation distinctly from a genuine browse failure. Because the
client never throws on cancellation, a cancelled discovery silently completes as if the
symbol table were fully and successfully walked — the address space is built from a partial
symbol set with no indication it was truncated. The SymbolLoaderFactory.Create /
loader.Symbols enumeration itself is also not cancellable.
Recommendation: Call cancellationToken.ThrowIfCancellationRequested() instead of
yield break so a cancelled browse surfaces as cancellation, not as a successful but partial
discovery.
Resolution: Resolved 2026-05-22 — replaced yield break with cancellationToken.ThrowIfCancellationRequested() in the foreach loop so a cancelled browse propagates as OperationCanceledException, matching the DiscoverAsync expectation.
Driver.TwinCAT-011
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | TwinCATStatusMapper.cs:29-42 |
| Status | Resolved |
Description: ADS error-code mapping has gaps and an inconsistency versus
docs/v2/driver-specs.md section 6. The spec documents symbol-not-found as 0x0701
(1793 decimal) and symbol-version-changed as 0x0702 (1794 decimal). MapAdsError maps
decimal 1798 to BadNodeIdUnknown (symbol not found) and 1793/1794 to BadOutOfRange
(invalid index group/offset). The decimal-vs-hex interpretation of the documented codes does
not line up, so the mapper appears to treat the symbol-version-changed code as a generic range
error. 0x0710 "Not ready / PLC in Config mode" has no entry and falls through to
BadCommunicationError; the driver-spec recommends distinguishing it. And 0x0702
symbol-version-changed is never routed to rediscovery (see Driver.TwinCAT-013).
Recommendation: Confirm the actual AdsErrorCode numeric values from
Beckhoff.TwinCAT.Ads (the SDK enum, not the doc hex shorthand) and align the mapper. Add an
explicit case for symbol-version-changed routed to rediscovery, and for PLC-in-Config mapped
to BadOutOfService/BadInvalidState.
Resolution: Resolved 2026-05-22 — confirmed all codes from Beckhoff.TwinCAT.Ads 7.0.172 AdsErrorCode enum. Rewrote MapAdsError with 20 explicit cases keyed to the correct decimal values. Fixed the critical bug: AdsSymbolVersionChanged was 0x0702u (= DeviceInvalidGroup) but the actual DeviceSymbolVersionInvalid is 1809 (0x0711); corrected constant and updated all comments. Added BadOutOfService for DeviceNotReady (PLC not running) and BadInvalidState for DeviceInvalidState (PLC in Config mode, 0x0712) and DeviceSymbolVersionInvalid (0x0711). Added BadOutOfService/BadInvalidState OPC UA StatusCode constants to the mapper.
Driver.TwinCAT-012
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Performance & resource management |
| Location | TwinCATDriver.cs:102, AdsTwinCATClient.cs:178-195 |
| Status | Resolved |
Description: GetMemoryFootprint() returns a hard-coded 0. docs/v2/driver-stability.md
section "In-process only (Tier A/B) — driver-instance allocation tracking" requires the
footprint to reflect "bytes attributable to their own caches (symbol cache, subscription
items, queued operations)", and section 6 of driver-specs.md explicitly identifies cached
symbol info as "the largest in-driver allocation" for TwinCAT and ties FlushOptionalCachesAsync
to flushing it. Reporting 0 means Core allocation-slope detection and cache-budget enforcement
are blind to this driver, and FlushOptionalCachesAsync is a no-op. (Note: the current
BrowseSymbolsAsync does not retain a symbol cache — it streams and discards — so
re-discovery re-downloads the whole symbol table each time, itself a performance concern for
EnableControllerBrowse deployments.)
Recommendation: Either implement an actual symbol cache + report its size via
GetMemoryFootprint() and flush it in FlushOptionalCachesAsync, or, if the
stream-and-discard design is intentional, report the real footprint of _nativeSubs /
_tagsByName and document that the driver holds no flushable cache.
Resolution: Resolved 2026-05-22 — GetMemoryFootprint() now returns (_tagsByName.Count * 256L) + (_nativeSubs.Count * 512L); documented that the driver has no flushable symbol cache (stream-and-discard design) so FlushOptionalCachesAsync remains a documented no-op.
Driver.TwinCAT-013
| Field | Value |
|---|---|
| Severity | High |
| Category | Design-document adherence |
| Location | TwinCATDriver.cs:11-12 (capability list), whole file |
| Status | Resolved |
Description: TwinCATDriver does not implement IRediscoverable. Both
docs/v2/driver-specs.md section 6 and docs/v2/driver-stability.md section "TwinCAT — Deep
Dive" state this as the defining TwinCAT failure mode: "Symbol-version-changed (0x0702) is
the unique TwinCAT failure mode... The driver must catch 0x0702, mark its symbol cache
invalid, re-upload symbols, rebuild the address space subtree... Treat this as a
IRediscoverable invocation, not as a connection error." The IRediscoverable XML doc names
TwinCAT symbol-version-changed as a canonical example. The current driver maps the error to a
generic BadOutOfRange/BadCommunicationError quality code and never re-runs discovery, so
after a PLC program re-download every symbol handle and notification silently goes stale with
no address-space rebuild.
Recommendation: Implement IRediscoverable; detect the symbol-version-changed ADS error
on read/write/notification paths, raise OnRediscoveryNeeded with a scoped reason string, and
re-establish native notifications after the Core re-runs DiscoverAsync. This is explicitly
part of the documented driver contract, not optional.
Resolution: Resolved 2026-05-22 — TwinCATDriver implements IRediscoverable; AdsTwinCATClient detects ADS 0x0702 on read/write paths and raises OnSymbolVersionChanged, which the driver forwards as OnRediscoveryNeeded so Core rebuilds the address space.
Driver.TwinCAT-014
| Field | Value |
|---|---|
| Severity | Low |
| Category | Design-document adherence |
| Location | TwinCATDriverOptions.cs:41-43, TwinCATDriverOptions.cs:57-62, AdsTwinCATClient.cs:145 |
| Status | Resolved |
Description: Several drifts between the implemented config surface and
docs/v2/driver-specs.md section 6. The spec connection-settings list has separate Host
(IP), AmsNetId, and AmsPort fields; the implementation collapses these into a single
HostAddress string parsed as ads://{netId}:{port}, so the target device IP has no home
field. TwinCATProbeOptions.Timeout (TwinCATDriverOptions.cs:61) is never read anywhere —
the probe path connects via _options.Timeout — a dead config field. The spec lists
NotificationMaxDelayMs; the code hard-codes max-delay 0 in NotificationSettings
(AdsTwinCATClient.cs:145) with no config knob.
Recommendation: Reconcile the driver-spec doc with the implemented TwinCATDriverOptions
shape (the doc is DRAFT, so updating it is acceptable). Remove or wire up
TwinCATProbeOptions.Timeout. Expose NotificationMaxDelayMs if batching control is wanted.
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
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | TwinCATDriver.cs:431-432 |
| Status | Resolved |
Description: Dispose() runs DisposeAsync().AsTask().GetAwaiter().GetResult() —
sync-over-async. docs/v2/driver-stability.md section Galaxy explicitly lists "sync-over-async
on the OPC UA stack thread" among the four 2026-04-13 stability findings that had to be
closed. DisposeAsync calls ShutdownAsync, which awaits _poll.DisposeAsync() and disposes
clients; if Dispose() is ever called on a thread with a single-threaded synchronization
context (the OPC UA stack), GetResult() can deadlock.
Recommendation: Make Dispose() perform a genuinely synchronous teardown. The operations
here — cancelling token sources, disposing clients, clearing dictionaries — are all
synchronous, and PollGroupEngine.DisposeAsync completes synchronously, so factor the
synchronous teardown out so Dispose() does not block on a Task.
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
| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ |
| Status | Resolved |
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
above: no test exercises ReinitializeAsync with a changed config (Driver.TwinCAT-001 would
have been caught); no concurrency test drives ReadAsync/WriteAsync/probe against one
device simultaneously (Driver.TwinCAT-007/009); no test covers the symbol-version-changed to
rediscovery path (Driver.TwinCAT-013, currently unimplemented); no test covers a Structure-
typed pre-declared tag (Driver.TwinCAT-003); no test asserts 64-bit LInt/ULInt round-trip
without truncation (Driver.TwinCAT-002).
Recommendation: Add unit tests for the above paths once the corresponding findings are
addressed, especially a concurrency stress test for EnsureConnectedAsync and a
ReinitializeAsync-applies-new-config test.
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.