# 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` 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 `DeviceState`s. **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`.