Files
lmxopcua/code-reviews/Driver.Modbus/findings.md
Joseph Doherty d5322b0f9a fix(driver-modbus): resolve Low code-review findings (Driver.Modbus-003,007,008,009,010,011,012)
- Driver.Modbus-003: route every _health access through ReadHealth /
  WriteHealth helpers backed by Volatile.Read / Volatile.Write so a
  burst of concurrent ReadAsync callers always sees a complete snapshot.
- Driver.Modbus-007: promoted the Int64 / UInt64 → Int32 surfacing
  caveat to a full <remarks> block; rewrote DisableFC23's doc to flag it
  as reserved / no-op.
- Driver.Modbus-008: deleted stale duplicate doc, rewrote the
  prohibition-block summaries to credit the shipped re-probe loop, and
  removed the unused 'status' local in the ModbusException catch arm.
- Driver.Modbus-009: bind-time validation rejects StringLength < 1 for
  String tags; ModbusTcpTransport clamps keep-alive intervals to whole
  seconds (>=1).
- Driver.Modbus-010: documented WriteOnChangeOnly's cache-invalidation
  policy (reads-only) and the write-only-tag caveat.
- Driver.Modbus-011: collected the scattered instance fields into a
  single contiguous block at the top of ModbusDriver.
- Driver.Modbus-012: covered the previously-uncovered Reinitialize
  state-hygiene, malformed/truncated/empty-bitmap response, and
  DisposeAsync teardown paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:17:51 -04:00

19 KiB

Code Review — Driver.Modbus

Field Value
Module src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus
Reviewer Claude Code
Review date 2026-05-22
Commit reviewed 76d35d1
Status Reviewed
Open findings 0

Checklist coverage

# Category Result
1 Correctness & logic bugs Driver.Modbus-002, Driver.Modbus-005, Driver.Modbus-009
2 OtOpcUa conventions No issues found
3 Concurrency & thread safety Driver.Modbus-001, Driver.Modbus-003
4 Error handling & resilience Driver.Modbus-006, Driver.Modbus-010
5 Security No issues found
6 Performance & resource management Driver.Modbus-004
7 Design-document adherence Driver.Modbus-007
8 Code organization & conventions Driver.Modbus-011
9 Testing coverage Driver.Modbus-012
10 Documentation & comments Driver.Modbus-008

Findings

Driver.Modbus-001

Field Value
Severity High
Category Concurrency & thread safety
Location ModbusDriver.cs:92,99-122
Status Resolved

Description: _lastPublishedByRef is a plain Dictionary<string, object> mutated inside ShouldPublish, which runs on the PollGroupEngine.onChange callback. PollGroupEngine runs one background Task per subscription (PollGroupEngine.cs:64), so a driver with two or more subscriptions invokes onChange — and therefore ShouldPublish — concurrently on separate threads. ShouldPublish does TryGetValue and indexer writes on the unsynchronized dictionary (ModbusDriver.cs:108, 112, 120). Concurrent reads/writes of a non-thread-safe Dictionary can corrupt internal state, drop entries, or throw IndexOutOfRangeException/InvalidOperationException, crashing the poll loop. The sibling cache _lastWrittenByRef is correctly guarded by _lastWrittenLock — only the deadband cache was left unprotected.

Recommendation: Guard _lastPublishedByRef with a dedicated lock around every access in ShouldPublish, or switch it to ConcurrentDictionary<string, object> and use AddOrUpdate/TryGetValue.

Resolution: Resolved 2026-05-22 — switched _lastPublishedByRef to ConcurrentDictionary<string, object> so the TryGetValue/indexer-write accesses in ShouldPublish are thread-safe under concurrent multi-subscription onChange callbacks; added a concurrent-deadband-subscription regression test.

Driver.Modbus-002

Field Value
Severity Medium
Category Correctness & logic bugs
Location ModbusDriver.cs:127-186
Status Resolved

Description: ShutdownAsync never clears _tagsByName, and InitializeAsync repopulates it with _tagsByName[t.Name] = t (ModbusDriver.cs:134) without clearing first. ReinitializeAsync calls ShutdownAsync then InitializeAsync. Because _options.Tags is fixed for a driver instance, the same set re-inserts harmlessly today — but the asymmetry is a latent bug: any future path that re-runs init with a different tag set leaves stale tag entries that resolve reads/writes against deleted nodes. _lastPublishedByRef and _lastWrittenByRef similarly survive a Reinitialize, retaining deadband/write-suppression baselines against the old config, while _autoProhibited is deliberately cleared (ModbusDriver.cs:179) — the inconsistency shows the clearing was simply overlooked.

Recommendation: Clear _tagsByName, _lastPublishedByRef, and _lastWrittenByRef in ShutdownAsync (or at the top of InitializeAsync) so a Reinitialize starts from a clean state, consistent with the existing _autoProhibited.Clear().

Resolution: Resolved 2026-05-22 — added _tagsByName.Clear(), _lastPublishedByRef.Clear(), and _lastWrittenByRef.Clear() to ShutdownAsync (via the new shared TeardownAsync helper) so a ReinitializeAsync cycle always starts from a clean state, consistent with the existing _autoProhibited.Clear().

Driver.Modbus-003

Field Value
Severity Low
Category Concurrency & thread safety
Location ModbusDriver.cs:59,188,241,259,266,726,745,759
Status Resolved

Description: _health is a non-volatile reference field written from multiple threads (concurrent ReadAsync callers, the coalesced-read path, WriteAsync indirectly, and ProbeLoopAsync) and read by GetHealth(). Reference assignment is atomic on .NET so a torn read cannot occur, but there is no happens-before ordering: a stale DriverHealth can be observed on another core, and concurrent writers race so "last write wins" is non-deterministic (a Degraded write from a failed read can clobber a just-published Healthy, or vice versa).

Recommendation: Mark _health volatile, or assign via Volatile.Write and read with Volatile.Read, to give GetHealth() a defined ordering guarantee.

Resolution: Resolved 2026-05-23 — routed every _health access through new ReadHealth() (Volatile.Read) and WriteHealth() (Volatile.Write) helpers, giving GetHealth() a defined ordering guarantee on every core. Stress-test (ModbusLifecycleHygieneTests.GetHealth_under_concurrent_pressure_always_returns_a_complete_snapshot) confirms the read path never sees a torn / half-constructed snapshot under concurrent reader + writer pressure.

Driver.Modbus-004

Field Value
Severity Medium
Category Performance & resource management
Location ModbusDriver.cs:1468-1473
Status Resolved

Description: DisposeAsync() only disposes _transport. Unlike ShutdownAsync, it does not cancel/dispose _probeCts or _reprobeCts, nor dispose _poll (the PollGroupEngine). A caller that uses await using or using without first calling ShutdownAsync leaks the probe loop, the re-probe loop, and every active polled subscription background Task/CancellationTokenSource. The two Task.Run loops keep running against a disposed transport, throwing on every tick. Dispose() (sync) has the same gap and additionally blocks on the async path via GetAwaiter().GetResult().

Recommendation: Make DisposeAsync perform the same teardown as ShutdownAsync (cancel both CTSs, dispose them, dispose _poll) before disposing _transport. Have ShutdownAsync and DisposeAsync share a private TeardownAsync helper.

Resolution: Resolved 2026-05-22 — refactored teardown into a shared TeardownAsync helper; DisposeAsync now delegates to it, cancelling both CTS objects, disposing _poll, and disposing _transport — matching ShutdownAsync and eliminating the probe/re-probe/poll-engine leak on await using callers.

Driver.Modbus-005

Field Value
Severity Medium
Category Correctness & logic bugs
Location ModbusDriver.cs:777-798,323-330
Status Resolved

Description: ReadRegisterBlockAsync and ReadBitBlockAsync index resp[1] and call Buffer.BlockCopy(resp, 2, ..., resp[1]) with no bounds validation. ModbusTcpTransport.SendOnceAsync validates only the MBAP length field and the exception high-bit — it does not guarantee a non-exception response PDU is long enough to hold function-code + byte-count + the claimed data. A device (or buggy server) returning a 1-byte PDU, or a byte-count larger than the actual payload, produces an IndexOutOfRangeException/ArgumentException rather than a clean comms error. DecodeBitArray similarly indexes bitmap[0] (ModbusDriver.cs:325) without checking the bitmap is non-empty. In ReadAsync these are caught by the catch-all and mapped to BadCommunicationError, so impact is limited; in ReadCoalescedAsync the exception is opaque to the narrower catch arms.

Recommendation: In ReadRegisterBlockAsync/ReadBitBlockAsync, validate resp.Length >= 2 and resp.Length >= 2 + resp[1] before slicing, throwing a descriptive InvalidDataException. Validate the decoded byte/bit count matches the request quantity.

Resolution: Resolved 2026-05-22 — added resp.Length >= 2, resp.Length >= 2 + resp[1], and byte-count-vs-quantity checks in both ReadRegisterBlockAsync and ReadBitBlockAsync, throwing InvalidDataException with precise diagnostics; added an empty-bitmap guard in DecodeBitArray.

Driver.Modbus-006

Field Value
Severity Medium
Category Error handling & resilience
Location ModbusDriver.cs:514-524,532-550
Status Resolved

Description: RunReprobeOnceForTestAsync reads _transport once at the top (var transport = _transport ?? throw ...). If ShutdownAsync runs (setting _transport = null and disposing it) while a re-probe pass is mid-iteration, the loop keeps issuing reads against the captured, disposed transport. ReprobeLoopAsync only catches OperationCanceledException when (ct.IsCancellationRequested) — an ObjectDisposedException from the disposed transport escapes RunReprobeOnceForTestAsync and faults the fire-and-forget background Task, silently killing the re-probe loop with the wrong failure mode.

Recommendation: Re-check _transport/cancellation inside the per-candidate loop, or broaden the ReprobeLoopAsync catch to also swallow ObjectDisposedException when ct.IsCancellationRequested.

Resolution: Resolved 2026-05-22 — broadened ReprobeLoopAsync to catch ObjectDisposedException when (ct.IsCancellationRequested) and return cleanly, so a transport disposal race during shutdown exits the background task rather than faulting it.

Driver.Modbus-007

Field Value
Severity Low
Category Design-document adherence
Location ModbusDriver.cs:1392, ModbusDriverOptions.cs:74-80
Status Resolved

Description: Two design-vs-code drifts. (1) MapDataType maps Int64/UInt64 to DriverDataType.Int32 with the inline comment "widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType". The address-space node for a 64-bit Modbus tag is declared Int32, misrepresenting the OPC UA variable's DataType even though DecodeRegister produces a correct long/ulong value — clients see a type/value mismatch. (2) DisableFC23 is documented and bound from JSON but is a confirmed no-op ("The driver does not currently emit FC23"). Both are acknowledged-but-unfinished items worth tracking.

Recommendation: Track the PR 25 DriverDataType.Int64 follow-up; until then document the Int32 surfacing limitation in docs/v2/modbus-addressing.md so operators configuring I_64/UI_64 tags understand the node type. Mark DisableFC23 clearly as reserved/unimplemented or gate it once FC23 ships.

Resolution: Resolved 2026-05-23 — promoted the inline Int64/UInt64 caveat into a full <remarks> block on MapDataType calling out the surfacing limitation and the tracked follow-up, and rewrote the DisableFC23 XML doc to flag the option as "Reserved / no-op" with a Driver.Modbus-007 tracking reference. (The cross-module doc update in docs/v2/modbus-addressing.md is out of scope for this module's edits — code is now self-documenting.)

Driver.Modbus-008

Field Value
Severity Low
Category Documentation & comments
Location ModbusDriver.cs:411-417,700-703,737-744
Status Resolved

Description: Stale/misleading comments. (1) The <summary> block at ModbusDriver.cs:411-417 says auto-prohibited ranges are "Cleared by ReinitializeAsync ... or by an explicit re-probe API (not yet shipped)" — the re-probe loop has shipped (#151, ReprobeLoopAsync), so the parenthetical is wrong. (2) The comment at ModbusDriver.cs:700-703 ("On block-level failure mark every member Bad — caller's per-tag fallback won't re-try since handled-set already includes them; auto-split-on-failure is a follow-up") contradicts the actual catch (ModbusException) arm below it, which deliberately does not add members to handled and does defer to per-tag fallback (and auto-split has shipped via bisection). The empty foreach (var (idx, _) in block.Members) { } loop at ModbusDriver.cs:737-744, with only a comment body, is dead code from that superseded design.

Recommendation: Update the two comments to match the shipped #148/#150/#151 behaviour and delete the empty foreach loop in the catch (ModbusException) arm.

Resolution: Resolved 2026-05-23 — deleted the duplicate <summary> orphaned at the top of the prohibition block, rewrote the surviving one to credit the shipped #151 re-probe loop, replaced the "auto-split-on-failure is a follow-up" comment above the block loop with the actual #148/#150 behaviour (per-tag fallback + bisection), and removed the empty foreach (var (idx, _) in block.Members) { } plus its unused status local from the catch (ModbusException) arm.

Driver.Modbus-009

Field Value
Severity Low
Category Correctness & logic bugs
Location ModbusDriver.cs:1160-1167, ModbusTcpTransport.cs:94-95
Status Resolved

Description: Two edge cases. (1) RegisterCount for ModbusDataType.String computes (tag.StringLength + 1) / 2; a tag configured with StringLength = 0 yields a register count of 0, flowing into ReadOneAsync as totalRegs = 0 and producing an FC03/FC04 with quantity 0 — a spec-illegal request the PLC rejects with exception 03. The factory does not reject StringLength = 0 for String tags. (2) EnableKeepAlive casts opts.Time.TotalSeconds/opts.Interval.TotalSeconds to int; a sub-second configured TimeSpan (e.g. 500 ms) truncates to 0, which most OSes reject or interpret as "use default", silently defeating the configured keep-alive timing.

Recommendation: Validate StringLength >= 1 for String tags in ModbusDriverFactoryExtensions.BuildTag. For keep-alive, round up to a minimum of 1 second or validate the configured TimeSpan is a whole number of seconds.

Resolution: Resolved 2026-05-23 — added ValidateStringLength in ModbusDriverFactoryExtensions.BuildTag so String-typed tags with StringLength < 1 throw at bind time with a clear diagnostic (both AddressString + structured DTO paths), and introduced ModbusTcpTransport.ClampToWholeSeconds which rounds the configured keep-alive TimeSpan up to a minimum of 1 second so sub-second values no longer truncate to 0. Regression coverage in ModbusEdgeCaseValidationTests.

Driver.Modbus-010

Field Value
Severity Low
Category Error handling & resilience
Location ModbusDriver.cs:864-868, ModbusDriverOptions.cs:116-125
Status Resolved

Description: When WriteOnChangeOnly is enabled and IsRedundantWrite returns true, WriteAsync returns WriteResult(0u) (Good) without touching the wire. The suppression baseline (_lastWrittenByRef) is only invalidated by a read that returns a divergent value. If a driver instance has WriteOnChangeOnly = true but a tag is never subscribed/read (write-only setpoint), a value the operator believes was re-asserted is silently suppressed forever after the first write — no time- or count-based expiry exists. The option XML doc describes the read-invalidation path but does not warn about write-only tags.

Recommendation: Document the write-only-tag caveat on the WriteOnChangeOnly option, or add an optional TTL to the suppression cache so a periodic re-write still reaches the PLC.

Resolution: Resolved 2026-05-23 — added a <remarks> block on ModbusDriverOptions.WriteOnChangeOnly that calls out the write-only-tag caveat explicitly: the cache is only invalidated by reads, so a tag that is never subscribed/polled stays suppressed forever after the first write. Operators choosing this option are directed to either subscribe every affected tag or leave WriteOnChangeOnly = false. Adding a TTL was considered but the safer option for an OPC UA driver is to make the limitation discoverable in the documentation surface (no behaviour change for existing deployments).

Driver.Modbus-011

Field Value
Severity Low
Category Code organization & conventions
Location ModbusDriver.cs:23-43,89-97,408-432
Status Resolved

Description: Field and member declarations are interleaved with methods throughout ModbusDriver. ResolveHost (a public method) is the first member of the class, followed by BuildSlaveHostName, then a block of fields; _lastPublishedByRef/_lastWrittenByRef are declared after the constructor; ProhibitionState, _autoProhibited, and _reprobeCts are declared mid-file between DecodeRegisterArray and RangeIsAutoProhibited. There are also two near-identical <summary> blocks stacked back-to-back at ModbusDriver.cs:411-423. This hurts readability of a 1400-line file and makes the field inventory hard to audit (relevant to the thread-safety findings above).

Recommendation: Group all instance fields at the top of the class, move nested types together, and remove the orphaned first <summary> at lines 411-417 that no longer precedes a member.

Resolution: Resolved 2026-05-23 — reorganized ModbusDriver so every instance field (including the _autoProhibited / _autoProhibitedLock / _reprobeCts / _rmwLocks / _lastPublishedByRef / _lastWrittenByRef fields that were declared mid-file) lives in a single contiguous block at the top of the class, followed by the ProhibitionState nested type, the constructor, and then methods. Removed the duplicate orphan <summary> and the now-redundant field declarations that had been scattered through the file. The full 263-test suite passes with no behavioural change.

Driver.Modbus-012

Field Value
Severity Low
Category Testing coverage
Location tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/
Status Resolved

Description: The unit suite is broad (coalescing, bisection, auto-recovery, byte order, arrays, BCD, RMW, caps, multi-unit, probe, reconnect, subscription). Gaps relative to the findings above: (1) no test exercises concurrent multi-subscription publishing, so the _lastPublishedByRef race (Driver.Modbus-001) is uncaught; (2) no test covers ReinitializeAsync state hygiene for stale _tagsByName/caches (Driver.Modbus-002); (3) no test feeds a malformed/short response PDU through ReadRegisterBlockAsync/DecodeBitArray to confirm a clean BadCommunicationError rather than an index-range crash (Driver.Modbus-005); (4) no test asserts DisposeAsync (vs ShutdownAsync) tears down the probe/re-probe loops and _poll (Driver.Modbus-004).

Recommendation: Add unit tests for concurrent deadband publishing across two subscriptions, ReinitializeAsync state hygiene, malformed-response handling in the register/bit block readers, and DisposeAsync loop teardown.

Resolution: Resolved 2026-05-23 — gap (1) was already covered by ModbusSubscriptionTests.Concurrent_deadband_subscriptions_do_not_corrupt_the_publish_cache from the Driver.Modbus-001 fix. Added the remaining three in a new ModbusLifecycleHygieneTests file: Reinitialize_clears_stale_tagsByName_entries + Reinitialize_clears_lastPublished_and_lastWritten_caches (gap 2), Short_response_PDU_surfaces_as_BadCommunicationError_not_an_IndexOutOfRangeException + Response_payload_truncated_below_declared_byteCount_surfaces_as_BadCommunicationError + DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException (gap 3), DisposeAsync_without_explicit_Shutdown_tears_down_probe_loop_and_transport + DisposeAsync_disposes_the_pollEngine_so_subscriptions_stop (gap 4). All 12 new tests pass (full suite: 263/263 green).