# 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 `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`.
