- Client.CLI-002: SubscribeCommand's neverWentBad list now requires the node to be present in lastStatus (i.e. received at least one update) so the 'suspect' bucket only contains observed nodes. - Client.CLI-003: every long-running command validates numeric option ranges (Interval / Depth / MaxDepth / Duration / Max) and throws CliFx CommandException on out-of-range values. - Client.CLI-004: SubscribeCommand carries XML summary docs on the type, ctor, every [CommandOption] property, and ExecuteAsync — matching the sibling commands' style. - Client.CLI-006: HistoryReadCommand parses --start / --end with InvariantCulture+UTC and surfaces FormatException as CommandException; every NodeIdParser.ParseRequired call wraps FormatException / ArgumentException as CommandException. - Client.CLI-007: CommandBase.ConfigureLogging calls Log.CloseAndFlush() before assigning a new Log.Logger so prior sinks are disposed. - Client.CLI-008: rewrote the subscribe and historyread sections of docs/Client.CLI.md (every flag documented, summary-bucket vocabulary, StandardDeviation aggregate, UTC --start/--end convention). - Client.CLI-009: SubscribeCommand / AlarmsCommand use named local handlers and detach them via -= after UnsubscribeAsync so no notification reaches the console after the command's output phase ends. - Client.CLI-010: added CommandRangeValidationTests, EventHandlerLifecycleTests, InputValidationErrorsTests, LoggerLifecycleTests, and SubscribeCommandSummaryTests pinning every Low fix; FakeOpcUaClientService gained AddDiscoveredVariable + RaiseDataChanged + BrowseResultsByParent helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
Code Review — Client.CLI
| Field | Value |
|---|---|
| Module | src/Client/ZB.MOM.WW.OtOpcUa.Client.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 | Client.CLI-001, Client.CLI-002, Client.CLI-003 |
| 2 | OtOpcUa conventions | Client.CLI-004 |
| 3 | Concurrency & thread safety | Client.CLI-005 |
| 4 | Error handling & resilience | Client.CLI-006 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Client.CLI-007 |
| 7 | Design-document adherence | Client.CLI-008 |
| 8 | Code organization & conventions | Client.CLI-009 |
| 9 | Testing coverage | Client.CLI-010 |
| 10 | Documentation & comments | Client.CLI-008 |
Findings
Client.CLI-001
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | Commands/HistoryReadCommand.cs:73, Commands/HistoryReadCommand.cs:76 |
| Status | Resolved |
Description: The start and end options are parsed with DateTime.Parse(StartTime) with
no IFormatProvider or DateTimeStyles. Parsing therefore depends on the current OS
culture: the same --start "03/04/2026" resolves to March 4 on an en-US box and April 3
on an en-GB box. The CLI is documented as cross-platform and the value silently produces a
different (wrong) history window rather than failing. The doc claims "ISO 8601 or date
string" but ISO interpretation is not guaranteed without DateTimeStyles.RoundtripKind or
CultureInfo.InvariantCulture. A bare date string is also assumed to be local time, then
.ToUniversalTime() shifts it by the host offset, so the same input yields different
ranges on machines in different time zones.
Recommendation: Parse with CultureInfo.InvariantCulture and
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal (or require explicit
ISO 8601 via DateTimeOffset.Parse), and document the expected format and timezone
assumption precisely.
Resolution: Resolved 2026-05-22 — DateTime.Parse replaced with CultureInfo.InvariantCulture + DateTimeStyles.AssumeUniversal | AdjustToUniversal; option descriptions updated to document ISO 8601 UTC format.
Client.CLI-002
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | Commands/SubscribeCommand.cs:129-137 |
| Status | Resolved |
Description: The summary computes neverWentBad as every target whose node-id key is
absent from the everBad dictionary. A node that received no update at all is also absent
from everBad, so it is counted in neverWentBad and printed under the heading
"--- Nodes that NEVER received a bad-quality update (suspect) ---". The same node is also
listed separately under never ("never received an update at all"). Labeling a node that
produced zero notifications as a "suspect that never went bad" is misleading — it has not
been observed at all, which is a different (and arguably worse) condition than a node that
streamed only good values.
Recommendation: Exclude no-update nodes from the neverWentBad set, e.g.
targets.Where(t => lastStatus.ContainsKey(key) && !everBad.ContainsKey(key)), so the
"suspect" list only contains nodes that were actually observed and never reported bad
quality.
Resolution: Resolved 2026-05-23 — neverWentBad now requires the node to be present in lastStatus (i.e. it received at least one update) before being counted, so the "suspect" bucket only contains nodes that were actually observed and never reported bad quality.
Client.CLI-003
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | Commands/BrowseCommand.cs:29-30, Commands/SubscribeCommand.cs:20-27, Commands/AlarmsCommand.cs:28-29, Commands/HistoryReadCommand.cs:42-43 |
| Status | Resolved |
Description: Numeric command options accept any value with no range validation.
--depth, --interval, --max-depth, --max, and the history --interval can all be
supplied as 0 or a negative number. A negative --depth/--max-depth silently disables
recursion or under-traverses; a zero/negative sampling --interval is passed straight
through to SubscribeAsync and depends on the SDK/server to reject it; a negative --max
is forwarded to HistoryReadRawAsync. None of these produce a clear operator-facing error.
Recommendation: Validate option ranges at the start of ExecuteAsync and throw
CliFx.Exceptions.CommandException with an actionable message when a value is out of
range.
Resolution: Resolved 2026-05-23 — every command's ExecuteAsync now validates the numeric option ranges (--interval, --depth, --max-depth, --max, --duration) and throws CliFx.Exceptions.CommandException with the offending value when a non-positive (or otherwise out-of-range) value is supplied. Pinned by CommandRangeValidationTests.
Client.CLI-004
| Field | Value |
|---|---|
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | Commands/SubscribeCommand.cs:13-37 |
| Status | Resolved |
Description: SubscribeCommand is the only command in the module whose constructor
and all [CommandOption] properties have no XML doc comments. Every other command
(ConnectCommand, ReadCommand, WriteCommand, BrowseCommand, AlarmsCommand,
HistoryReadCommand, RedundancyCommand) and CommandBase carry <summary> docs on the
type, constructor, and options. The inconsistency is visible in IDE tooltips and breaks the
otherwise-uniform documentation convention of the module.
Recommendation: Add <summary> XML docs to the SubscribeCommand constructor and to
each of its option properties, matching the style used by the sibling commands.
Resolution: Resolved 2026-05-23 — SubscribeCommand now carries <summary> XML docs on the type, the constructor, every [CommandOption] property, and ExecuteAsync, matching the style used by the sibling commands.
Client.CLI-005
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | Commands/SubscribeCommand.cs:66-78, Commands/AlarmsCommand.cs:52-64 |
| Status | Resolved |
Description: The DataChanged and AlarmEvent handlers write to console.Output
(a System.IO.TextWriter) directly from the OPC UA SDK subscription/notification thread,
while the command main flow is awaiting Task.Delay(Timeout.Infinite, ct) and the summary
block also writes to the same console.Output. TextWriter instances are not guaranteed
thread-safe; concurrent WriteLine calls from the notification thread and the main thread
(a data-change notification arriving while the summary is being printed, or two
notifications from different SDK threads) can interleave or corrupt output. The handler
also calls the synchronous WriteLine and discards any exception, which on a fault would
propagate into the SDK callback.
Recommendation: Serialize console writes from event handlers — funnel notifications
through a Channel<T> drained by the main thread, or guard every console.Output write
with a shared lock. At minimum, ensure handler exceptions cannot escape into the SDK
callback.
Resolution: Resolved 2026-05-22 — notification handlers in SubscribeCommand and AlarmsCommand now enqueue lines to an UnboundedChannel<string> via TryWrite; the main thread drains the channel via ReadAllAsync. Handlers are named local functions so they can be unsubscribed before the summary phase; all handler exceptions are swallowed to protect the SDK callback.
Client.CLI-006
| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | Commands/HistoryReadCommand.cs:73, Commands/HistoryReadCommand.cs:76, Helpers/NodeIdParser.cs:39 |
| Status | Resolved |
Description: Operator input-format errors surface as raw .NET exceptions rather than
clean CLI errors. An unparseable start/end value throws FormatException straight out of
DateTime.Parse; an invalid node id throws FormatException/ArgumentException from
NodeIdParser. CliFx renders unhandled exceptions with a stack trace, which is noisy for a
user-input mistake. Other tooling in this module already distinguishes operator errors
(ParseAggregateType throws ArgumentException with a helpful message) but none of these
is converted to a CliFx.Exceptions.CommandException with a clean exit code.
Recommendation: Catch the predictable input-validation exceptions and rethrow as
CommandException with a concise message and a non-zero exit code, so malformed input
yields a one-line error instead of a stack trace.
Resolution: Resolved 2026-05-23 — HistoryReadCommand parses --start/--end with CultureInfo.InvariantCulture + AssumeUniversal/AdjustToUniversal, catches FormatException, and rethrows as CommandException with the offending value. Every command's call to NodeIdParser.ParseRequired is wrapped in a catch (FormatException or ArgumentException) block that surfaces the underlying message as a clean CLI error. Pinned by InputValidationErrorsTests.
Client.CLI-007
| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | CommandBase.cs:112-123 |
| Status | Resolved |
Description: ConfigureLogging builds a new Serilog LoggerConfiguration, creates a
logger, and assigns it to the static Log.Logger without disposing the previously
assigned logger. For a single CLI invocation this leaks at most one logger and the process
exits shortly after, so impact is minimal — but CommandBase is also exercised repeatedly
in-process by the unit-test suite, where each ExecuteAsync replaces Log.Logger and
abandons the prior console sink without disposal. The pattern is incorrect:
Log.CloseAndFlush() (or disposing the prior logger) should run before reassignment.
Recommendation: Call Log.CloseAndFlush() before assigning a new Log.Logger, or
build the logger into a local ILogger the command owns and disposes, rather than mutating
global static state per command.
Resolution: Resolved 2026-05-23 — CommandBase.ConfigureLogging now calls Log.CloseAndFlush() before assigning a new Log.Logger, so a prior logger's console sink is disposed before the next one is installed. Pinned by LoggerLifecycleTests.
Client.CLI-008
| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | docs/Client.CLI.md:158-217 |
| Status | Resolved |
Description: docs/Client.CLI.md is stale relative to the code at this commit.
(1) The subscribe command section documents only -n and -i, but the code
(SubscribeCommand) also exposes -r/--recursive, --max-depth, -q/--quiet,
--duration, and --summary-file — none are documented, and the documented Ctrl+C-only
lifecycle no longer matches --duration auto-exit.
(2) The historyread "Aggregate mapping" table lists six aggregates but the code
(HistoryReadCommand.ParseAggregateType and AggregateType) also supports
StandardDeviation (aliases stddev/stdev); the doc option table omits it while the
code option description includes it.
Recommendation: Regenerate the subscribe and historyread sections of
docs/Client.CLI.md from the current option set, including the five new subscribe flags
and the StandardDeviation aggregate row.
Resolution: Resolved 2026-05-23 — rewrote the subscribe section of docs/Client.CLI.md to document every flag (-r/--recursive, --max-depth, -q/--quiet, --duration, --summary-file) plus the summary-bucket vocabulary, and added the StandardDeviation row plus the UTC --start/--end convention note to the historyread section.
Client.CLI-009
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | Commands/SubscribeCommand.cs:66-165, Commands/AlarmsCommand.cs:52-91 |
| Status | Resolved |
Description: Both long-running commands attach an event handler
(service.DataChanged += ..., service.AlarmEvent += ...) with a lambda and never detach
it. Because the handler closes over console, the captured console and the closure remain
referenced by the service until the service is disposed in the finally block. In
practice the service is per-command and disposed at the end, so this does not leak across
commands — but it is a latent footgun: a handler can still fire between UnsubscribeAsync
/ UnsubscribeAlarmsAsync and Dispose, writing to a console that the command considers
finished (overlapping with Client.CLI-005). The cleanup unsubscribes the monitored items
but never the .NET event.
Recommendation: Detach the handler explicitly (service.DataChanged -= handler) after
unsubscribing, using a named local delegate so it can be removed, ensuring no notification
is processed after the command output phase ends.
Resolution: Resolved 2026-05-23 — SubscribeCommand and AlarmsCommand declare named local handlers (DataChangedHandler / AlarmEventHandler) and detach them via service.DataChanged -= ... / service.AlarmEvent -= ... right after UnsubscribeAsync so no notification reaches the console once the command's output phase ends. Pinned by EventHandlerLifecycleTests.
Client.CLI-010
| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs |
| Status | Resolved |
Description: The new SubscribeCommand capabilities are largely untested. The four
SubscribeCommandTests cover only single-node subscribe, unsubscribe-on-cancel,
disconnect-in-finally, and the subscription message. There is no test for the --recursive
browse-and-collect path (CollectVariablesAsync), the --duration auto-exit path, the
summary classification logic (good/bad/never/neverWentBad, including the
mislabeling noted in Client.CLI-002), the --quiet flag, the --summary-file write, or
per-node subscribe-failure handling. The summary logic is the most behaviour-rich part of
the command and the part most likely to regress.
Recommendation: Add unit tests for recursive variable collection, the duration-based
exit, summary bucketing across good/bad/no-update nodes, and the --summary-file output.
The FakeOpcUaClientService already exposes RaiseDataChanged, so feeding good/bad values
and asserting the summary text is straightforward.
Resolution: Resolved 2026-05-23 — added SubscribeCommandSummaryTests (covering recursive collection via FakeOpcUaClientService.AddDiscoveredVariable, --duration auto-exit, summary bucketing for good/bad/never/never-went-bad, and the --summary-file write), CommandRangeValidationTests, EventHandlerLifecycleTests, InputValidationErrorsTests, and LoggerLifecycleTests to pin the other Low findings; FakeOpcUaClientService was extended with AddDiscoveredVariable / RaiseDataChanged helpers.