Files
lmxopcua/code-reviews/Driver.Modbus.Cli/findings.md
Joseph Doherty 80ef8806e0 fix(driver-modbus-cli): resolve Low code-review findings (Driver.Modbus.Cli-003,004,005,006,007,008)
- Driver.Modbus.Cli-003: ModbusCommandBase.ValidateEndpoint rejects
  --port outside 1..65535, non-positive --timeout-ms, and --unit-id
  outside 1..247.
- Driver.Modbus.Cli-004: wrapped SubscribeCommand's OnDataChange handler
  body in a try/catch (warn-and-swallow) and serialised the console
  write through a lock.
- Driver.Modbus.Cli-005: Probe / Read / Write now catch the
  cancellation-during-init OperationCanceledException and print
  'Cancelled.' instead of dumping a stack trace.
- Driver.Modbus.Cli-006: ProbeCommand.ComputeVerdict derives the headline
  from BOTH the driver state and the probe snapshot's OPC UA quality
  class so the headline can't disagree with the wire result.
- Driver.Modbus.Cli-007: docs/Driver.Modbus.Cli.md carries an explicit
  'CLI scope' callout — the address-string grammar is a DriverConfig
  JSON feature; the CLI takes the structured triple only.
- Driver.Modbus.Cli-008: pinned BuildOptions, ValidateEndpoint, the
  region-validation guards, ComputeVerdict, and the cancellation-during-
  initialize paths.

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

14 KiB

Code Review — Driver.Modbus.Cli

Field Value
Module src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli
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.Modbus.Cli-001, Driver.Modbus.Cli-002, Driver.Modbus.Cli-003
2 OtOpcUa conventions No issues found
3 Concurrency & thread safety Driver.Modbus.Cli-004
4 Error handling & resilience Driver.Modbus.Cli-005, Driver.Modbus.Cli-006
5 Security No issues found
6 Performance & resource management No issues found
7 Design-document adherence Driver.Modbus.Cli-007
8 Code organization & conventions No issues found
9 Testing coverage Driver.Modbus.Cli-008
10 Documentation & comments No issues found

Findings

Driver.Modbus.Cli-001

Field Value
Severity Medium
Category Correctness & logic bugs
Location src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:43-51
Status Resolved

Description: SubscribeCommand synthesises its ModbusTagDefinition with only Name, Region, Address, DataType, Writable, and ByteOrder — it never exposes or passes --bit-index, --string-length, or --string-byte-order. A user running subscribe -t BitInRegister always watches bit 0 regardless of intent, and subscribe -t String runs with StringLength = 0. The doc (docs/Driver.Modbus.Cli.md) lists BitInRegister, String, Bcd16, Bcd32 in the subscribe --type help text, so these types are advertised as supported but cannot be used correctly. read and write both expose all three flags; subscribe is the odd one out.

Recommendation: Add --bit-index, --string-length, and --string-byte-order options to SubscribeCommand (mirroring ReadCommand) and pass them into the ModbusTagDefinition, or trim the --type help text to the types subscribe actually supports and reject BitInRegister / String at command entry with a clear message.

Resolution: Resolved 2026-05-22 — added --bit-index, --string-length, and --string-byte-order options to SubscribeCommand, mirroring ReadCommand, and passed them through to ModbusTagDefinition so BitInRegister and String types subscribe correctly.

Driver.Modbus.Cli-002

Field Value
Severity Medium
Category Correctness & logic bugs
Location src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs:54-89
Status Resolved

Description: WriteCommand rejects read-only regions (DiscreteInputs / InputRegisters) but does not validate that --type is meaningful for the Coils region. write -r Coils -a 5 -t UInt16 -v 42 builds a Coils tag with DataType = UInt16; the value parses to a boxed ushort, and the driver's WriteOneAsync coil branch calls Convert.ToBoolean(value) which succeeds for any non-zero ushort (yields true). The write silently lands as a coil ON with no diagnostic, even though the operator asked for a 16-bit register write. A coil region only supports Bool-style boolean values.

Recommendation: After the read-only-region check, reject Region == Coils combined with any non-boolean --type (anything other than Bool), with a message explaining coils carry a single bit.

Resolution: Resolved 2026-05-22 — added a Region == Coils && DataType != Bool check immediately after the read-only-region guard, throwing CommandException with a message explaining that coils carry a single bit and only --type Bool is valid.

Driver.Modbus.Cli-003

Field Value
Severity Low
Category Correctness & logic bugs
Location src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs:14-24
Status Resolved

Description: Port (int) and TimeoutMs (int) accept any 32-bit value, including negatives and ports above 65535. UnitId is a byte, so it accepts 0-255 even though the option description and docs/Driver.Modbus.Cli.md both say the valid range is 1-247 (0 is the Modbus broadcast address; 248-255 are reserved). A negative --timeout-ms becomes a negative TimeSpan passed straight into the driver; an out-of-range --port fails later with an opaque socket error. None of these are validated at parse time.

Recommendation: Validate Port (1-65535), TimeoutMs (greater than 0), and UnitId (1-247) at the top of each command's ExecuteAsync (or in ModbusCommandBase), throwing CliFx.Exceptions.CommandException with a clear message — consistent with how WriteCommand already rejects bad regions and boolean strings.

Resolution: Resolved 2026-05-23 — added ModbusCommandBase.ValidateEndpoint() that throws CliFx.Exceptions.CommandException for --port outside 1..65535, non-positive --timeout-ms, and --unit-id outside 1..247 (Modbus spec unicast range — rejects the broadcast 0 and reserved 248-255). Each of ProbeCommand, ReadCommand, WriteCommand, and SubscribeCommand now calls it at the top of ExecuteAsync after ConfigureLogging(). Regression tests live in ModbusCommandBaseTests (range + boundary cases for all three knobs).

Driver.Modbus.Cli-004

Field Value
Severity Low
Category Concurrency & thread safety
Location src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:61-67
Status Resolved

Description: The OnDataChange handler is invoked from the driver's PollGroupEngine background thread and calls console.Output.WriteLine synchronously. An exception thrown inside this handler (e.g. an IOException on a redirected or closed stdout) propagates on the poll-engine thread and is not caught — it could fault the background loop. For a long-running subscribe this is a real, if low-probability, crash path. Output lines are also written without any synchronization, so overlapping poll ticks could interleave partial lines.

Recommendation: Wrap the handler body in a try/catch that swallows or logs write failures so a transient console-write error cannot tear down the poll loop. A single lock around the write also removes the interleave risk.

Resolution: Resolved 2026-05-23 — wrapped the OnDataChange handler in try/catch (Exception) that logs to Serilog at Warning and swallows the failure so a transient console-write error cannot fault the PollGroupEngine background loop. The console write is also serialized through a local lock object, removing the partial-line interleave risk when multiple poll ticks overlap.

Driver.Modbus.Cli-005

Field Value
Severity Low
Category Error handling & resilience
Location src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:21-54; Commands/ReadCommand.cs:46-75; Commands/WriteCommand.cs:54-89
Status Resolved

Description: All three commands call ConfigureLogging() then console.RegisterCancellationHandler(), but if the operator presses Ctrl+C before InitializeAsync completes, the resulting OperationCancelledException propagates out of ExecuteAsync unhandled. CliFx renders unhandled non- CommandException exceptions as a full stack trace, which is noisy for what is just a user-cancelled run. SubscribeCommand correctly catches OperationCancelledException around its Task.Delay, but the connect/read/write commands do not catch it around their driver calls.

Recommendation: Either let cancellation surface a clean message (catch OperationCancelledException in each command and exit quietly) or document that the noisy trace on Ctrl+C-during-connect is acceptable. Consistency with SubscribeCommand's handling is the cleaner choice.

Resolution: Resolved 2026-05-23 — added a catch (OperationCanceledException) when (ct.IsCancellationRequested) clause around the driver-call bodies in ProbeCommand, ReadCommand, and WriteCommand that prints Cancelled. and falls through to the existing finally-block shutdown. Matches SubscribeCommand's existing handling so all four commands now exit quietly on Ctrl+C. Regression tests in CommandCancellationTests pre-cancel the CliFx FakeInMemoryConsole before calling ExecuteAsync and assert no exception escapes.

Driver.Modbus.Cli-006

Field Value
Severity Low
Category Error handling & resilience
Location src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:35-53
Status Resolved

Description: probe reports Health: {health.State} from GetHealth(). After a successful InitializeAsync the driver sets state to Healthy regardless of whether the subsequent probe register read returns Good or a Bad status code. ReadAsync does not throw on a Modbus exception response — it returns a DataValueSnapshot with a Bad StatusCode. So probe against a host that accepts the TCP connection but rejects FC03 at the probe address prints Health: Healthy while the snapshot line below shows a Bad status. The two lines disagree, and the headline Health value (the thing an operator scans first) overstates success. The doc bills probe as the "is the PLC up + talking Modbus" check, which the bare Healthy does not actually confirm.

Recommendation: Have probe derive its headline verdict from the probe snapshot's StatusCode (Good vs Bad) rather than — or in addition to — the driver State, or print a single combined verdict line so the two cannot contradict each other.

Resolution: Resolved 2026-05-23 — ProbeCommand now prints a single combined Verdict: line above the bare driver-state line; the headline is computed by a new ProbeCommand.ComputeVerdict(DriverState, statusCode) helper that combines the driver state with the probe snapshot's OPC UA quality class (top 2 bits of the StatusCode). Verdict is FAIL whenever the driver is not Healthy OR the snapshot is Bad, DEGRADED when the driver is Healthy but the snapshot is Uncertain, and OK only when both are Good. Regression tests in ProbeCommandTests pin the verdict-grid behaviour.

Driver.Modbus.Cli-007

Field Value
Severity Low
Category Design-document adherence
Location docs/Driver.Modbus.Cli.md:124-156; src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs
Status Resolved

Description: docs/Driver.Modbus.Cli.md devotes a whole "v2 addressing grammar" section to the industry-standard tag-address strings (40001:F:CDAB, HR1:I, C100, V2000:F:CDAB, etc.) and says "set the per-tag addressString field instead of the structured region + address + dataType fields." None of the CLI commands expose an --address-string (or equivalent) flag — read, write, and subscribe only accept the structured --region + --address + --type triple. The documented address-string grammar is reachable only through a hand-written DriverConfig JSON, not through this CLI. The doc reads as if the CLI supports it.

Recommendation: Either add an --address-string option that feeds the driver's address-string parser (and --family for the DL205/MELSEC native syntax), or scope the "v2 addressing grammar" section of the doc to note it applies to DriverConfig JSON and is not a CLI flag.

Resolution: Resolved 2026-05-23 — chose the doc-scoping fix (an --address-string flag is a bigger feature that warrants its own design discussion). Added a "CLI scope" callout to the top of the docs/Driver.Modbus.Cli.md "v2 addressing grammar" section stating the CLI accepts only the structured --region + --address + --type triple and that the address-string grammar is a DriverConfig JSON feature. The pre-existing closing paragraph already said the same thing; the new callout makes it visible before the grammar examples instead of after them. Code surface left unchanged.

Driver.Modbus.Cli-008

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

Description: The test project covers only the two pure-function seams: ReadCommand.SynthesiseTagName and WriteCommand.ParseValue. There is no coverage for WriteCommand's read-only-region rejection (Region is not (Coils or HoldingRegisters)), no test for ModbusCommandBase.BuildOptions (e.g. that Probe.Enabled is false and AutoReconnect tracks --disable-reconnect), and no test asserting unsupported write types throw. The branch logic in WriteCommand.ExecuteAsync and ModbusCommandBase.BuildOptions is the part most likely to regress and is currently untested. The validation gaps in findings 002/003 are also untested precisely because no test exercises that path.

Recommendation: Add tests for WriteCommand's region-validation branch and for ModbusCommandBase.BuildOptions (construct a command instance via the init setters and assert the produced ModbusDriverOptions). Once findings 002/003 are fixed, add tests for the new validation paths.

Resolution: Resolved 2026-05-23 — added four new test classes covering the previously untested branch logic:

  • ModbusCommandBaseTests — six tests over BuildOptions (probe disabled, TimeoutMsTimeout mapping, AutoReconnect tracking --disable-reconnect in both directions, host/port/unit flow-through, tag pass-through) plus the new ValidateEndpoint range-check tests for finding 003 (port 1..65535, timeout-ms > 0, unit-id 1..247).
  • WriteCommandRegionValidationTests — read-only region rejection (DiscreteInputs / InputRegisters) and the Coils-non-Bool guard for finding 002.
  • ProbeCommandTests — the new ComputeVerdict helper for finding 006 (OK / DEGRADED / FAIL grid).
  • CommandCancellationTests — Ctrl+C-during-initialize for ProbeCommand / ReadCommand / WriteCommand for finding 005.

Total test count grew from 18 to 64; all pass.