754c5a3684
Re-review at 7286d320. -006 (Low): FlushLogging() in all command finally blocks + tests.
-007: rewrite the inaccurate handler-detach comment (cleanup is via await using disposal).
312 lines
16 KiB
Markdown
312 lines
16 KiB
Markdown
# Code Review — Driver.FOCAS.Cli
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli` |
|
||
| Reviewer | Claude Code |
|
||
| Review date | 2026-06-19 |
|
||
| Commit reviewed | `7286d320` |
|
||
| 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.FOCAS.Cli-001 |
|
||
| 2 | OtOpcUa conventions | No issues found |
|
||
| 3 | Concurrency & thread safety | Driver.FOCAS.Cli-002 |
|
||
| 4 | Error handling & resilience | Driver.FOCAS.Cli-001, Driver.FOCAS.Cli-003 |
|
||
| 5 | Security | No issues found |
|
||
| 6 | Performance & resource management | Driver.FOCAS.Cli-004 |
|
||
| 7 | Design-document adherence | Driver.FOCAS.Cli-005 |
|
||
| 8 | Code organization & conventions | No issues found |
|
||
| 9 | Testing coverage | No issues found (see note) |
|
||
| 10 | Documentation & comments | No issues found |
|
||
|
||
> Category 9 note: per `docs/DriverClis.md` the FOCAS CLI deliberately ships
|
||
> with no CLI-level test project (hardware-gated, followed the Tier-C isolation
|
||
> work on task #220). The four command classes are thin pass-throughs to the
|
||
> already-tested `FocasDriver`; the only CLI-local logic is `ParseValue` /
|
||
> `ParseBool` / `SynthesiseTagName`, which the sibling CLIs cover with unit
|
||
> tests. The absence of a `*.Cli.Tests` project is an intentional, documented
|
||
> gap rather than a review finding — but see Driver.FOCAS.Cli-001 for the parse
|
||
> path that would benefit most from coverage.
|
||
|
||
## Findings
|
||
|
||
### Driver.FOCAS.Cli-001
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Severity | Low |
|
||
| Category | Error handling & resilience |
|
||
| Location | `Commands/WriteCommand.cs:58-68` |
|
||
| Status | Resolved |
|
||
|
||
**Description:** `WriteCommand.ParseValue` parses the numeric `--value` types
|
||
(`Byte`/`Int16`/`Int32`/`Float32`/`Float64`) with `sbyte.Parse` / `short.Parse`
|
||
/ etc. These throw raw `FormatException` or `OverflowException` for malformed or
|
||
out-of-range input. Only the `Bit` case and the unsupported-type case throw
|
||
`CliFx.Exceptions.CommandException`. CliFx renders a `CommandException` as a
|
||
clean one-line error, but an uncaught `FormatException`/`OverflowException`
|
||
surfaces as a full .NET stack trace — a poor experience for an operator who
|
||
simply mistyped a value (e.g. `write -a R100 -t Int16 -v abc`). The parse
|
||
failure occurs before any driver work, so the redundant stack trace also
|
||
obscures that the write never reached the CNC.
|
||
|
||
**Recommendation:** Wrap the numeric parses (e.g. via `TryParse` per type, or a
|
||
`try`/`catch` that rethrows as `CommandException`) so malformed `--value` input
|
||
produces a clean, actionable message naming the expected type and the rejected
|
||
literal — consistent with how `ParseBool` already handles bad boolean input.
|
||
The same pattern exists in the sibling S7 CLI; a shared helper in
|
||
`Driver.Cli.Common` would fix both.
|
||
|
||
**Resolution:** Resolved 2026-05-23 — wrapped the `ParseValue` numeric switch in
|
||
`try/catch (FormatException)` and `try/catch (OverflowException)` that rethrow as
|
||
`CliFx.Exceptions.CommandException` with a message naming the `--type` and the
|
||
offending value, mirroring the friendly text the `Bit` path already produced.
|
||
Added `WriteCommandParseValueTests` with [Theory] cases covering non-numeric
|
||
input across `Byte`/`Int16`/`Int32`/`Float32`/`Float64`, overflow edges
|
||
(sbyte ±1, short max+1, > int.MaxValue), and an assertion that the exception
|
||
message names both the type and the offending value. A shared `Driver.Cli.Common`
|
||
helper is the cleaner long-term fix (cross-CLI duplication remains) but is left
|
||
to the Driver.Cli.Common review per this module's edit scope.
|
||
|
||
### Driver.FOCAS.Cli-002
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Severity | Low |
|
||
| Category | Concurrency & thread safety |
|
||
| Location | `Commands/SubscribeCommand.cs:45-51` |
|
||
| Status | Resolved |
|
||
|
||
**Description:** The `subscribe` command attaches an `OnDataChange` handler that
|
||
calls the synchronous `console.Output.WriteLine`. `OnDataChange` is raised from
|
||
the driver's `PollGroupEngine` tick thread, while the command's main flow writes
|
||
the "Subscribed to ..." banner from the CliFx invocation thread. The CliFx
|
||
`IConsole.Output` `TextWriter` is not documented as thread-safe; with a single
|
||
poll group the change events are serialised, but the banner write at line 55-56
|
||
can interleave with the first poll-driven change line. The handler is also never
|
||
detached from the event before driver disposal — benign here because the driver
|
||
is disposed in the same `finally`, but it leaves a dangling subscription if the
|
||
command is ever refactored to reuse the driver.
|
||
|
||
**Recommendation:** Write the "Subscribed" banner before wiring the
|
||
`OnDataChange` handler (it is informational and ordering-sensitive), or guard
|
||
console writes with a lock shared between the banner and the handler. Optionally
|
||
detach the handler in the `finally` block before `ShutdownAsync` for symmetry
|
||
with the `handle` teardown already present there.
|
||
|
||
**Resolution:** Resolved 2026-05-23 — introduced a `writeLock` shared between the
|
||
`OnDataChange` handler and the banner write so the poll-engine background thread
|
||
and the CliFx invocation thread can't interleave partial lines. Added an
|
||
explanatory comment above the handler explaining the CliFx-`IConsole` rationale
|
||
and the synchronous-on-background-thread design — mirroring the Modbus / S7
|
||
copies of this command. Also added a try/catch around the handler body so a
|
||
transient stdout error cannot tear down the poll loop, and Serilog-warn-logs the
|
||
swallowed exception. Added `SubscribeCommandConsoleHandlerTests` to guard the
|
||
`writeLock` + CliFx-`IConsole` rationale against future copy-paste regressions.
|
||
|
||
### Driver.FOCAS.Cli-003
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Severity | Low |
|
||
| Category | Error handling & resilience |
|
||
| Location | `FocasCommandBase.cs:19` (`CncPort`), `FocasCommandBase.cs:27` (`TimeoutMs`), `Commands/SubscribeCommand.cs:23` (`IntervalMs`) |
|
||
| Status | Resolved |
|
||
|
||
**Description:** The numeric command options `--cnc-port`, `--timeout-ms`, and
|
||
`--interval-ms` are accepted without range validation. A zero or negative
|
||
`--cnc-port` produces an invalid `focas://host:<n>` string; `--timeout-ms 0`
|
||
yields a zero `TimeSpan` operation timeout; a zero/negative `--interval-ms`
|
||
produces a non-positive `publishingInterval` passed straight into
|
||
`PollGroupEngine.Subscribe`. Depending on the engine tolerance these surface
|
||
either as an opaque downstream exception or as a tight-spinning poll loop rather
|
||
than a clear "value must be positive" message at the CLI boundary.
|
||
|
||
**Recommendation:** Validate the three numeric options at the top of
|
||
`ExecuteAsync` (or in `FocasCommandBase`) and throw a
|
||
`CliFx.Exceptions.CommandException` when out of range — port in `1..65535`,
|
||
timeout and interval strictly positive. The same gap exists across the sibling
|
||
driver CLIs, so a shared validation helper in `Driver.Cli.Common` is the
|
||
cleaner fix.
|
||
|
||
**Resolution:** Resolved 2026-05-23 — added a protected `ValidateOptions(int?
|
||
intervalMs = null)` helper on `FocasCommandBase` that rejects `--cnc-port`
|
||
outside `1..65535`, non-positive `--timeout-ms`, and non-positive
|
||
`--interval-ms` (when the caller passes one) with a `CliFx.Exceptions.CommandException`
|
||
naming the option and the rejected value. `ProbeCommand` / `ReadCommand` /
|
||
`WriteCommand` call `ValidateOptions()` without an interval, `SubscribeCommand`
|
||
calls `ValidateOptions(IntervalMs)`. Added `FocasCommandBaseValidationTests`
|
||
covering accept-defaults, reject out-of-range port (0, -1, 65536), reject
|
||
non-positive timeout / interval, and skip-interval-when-omitted. A shared
|
||
helper in `Driver.Cli.Common` is the cleaner cross-CLI fix and is recorded
|
||
against that module's review.
|
||
|
||
### Driver.FOCAS.Cli-004
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Severity | Low |
|
||
| Category | Performance & resource management |
|
||
| Location | `Commands/ProbeCommand.cs:37,54`; `Commands/ReadCommand.cs:37,46`; `Commands/WriteCommand.cs:45,54`; `Commands/SubscribeCommand.cs:39,73` |
|
||
| Status | Resolved |
|
||
|
||
**Description:** Every command declares `await using var driver = new FocasDriver(...)`
|
||
**and** explicitly calls `await driver.ShutdownAsync(CancellationToken.None)` in
|
||
the `finally` block. `FocasDriver.DisposeAsync()` itself calls `ShutdownAsync`,
|
||
so shutdown runs twice per command invocation. `FocasDriver.ShutdownAsync` is
|
||
idempotent (it clears `_devices` / `_tagsByName`, and the second pass iterates
|
||
an empty collection), so there is no functional bug — but the redundant call is
|
||
dead weight and obscures intent: a reader cannot tell whether the explicit
|
||
`ShutdownAsync` or the `await using` is the real teardown.
|
||
|
||
**Recommendation:** Drop the explicit `ShutdownAsync` from the `finally` blocks
|
||
and rely on `await using` for disposal, or drop `await using` and keep the
|
||
explicit teardown — but not both. The same redundancy exists in the sibling CLIs.
|
||
|
||
**Resolution:** Resolved 2026-05-23 — dropped the explicit
|
||
`await driver.ShutdownAsync(CancellationToken.None)` calls from the `finally`
|
||
blocks of `ProbeCommand`, `ReadCommand`, `WriteCommand`, and `SubscribeCommand`;
|
||
`await using` is now the sole driver-disposal mechanism per command
|
||
(`FocasDriver.DisposeAsync` itself runs `ShutdownAsync`). The subscribe command
|
||
keeps `UnsubscribeAsync` in its finally because that is a subscription-lifecycle
|
||
concern, not driver disposal. Added `CommandDisposalConventionsTests` to guard
|
||
the source-level convention against regression.
|
||
|
||
### Driver.FOCAS.Cli-005
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Severity | Low |
|
||
| Category | Design-document adherence |
|
||
| Location | `Commands/WriteCommand.cs:50`, `Commands/ProbeCommand.cs:50` (via `SnapshotFormatter.FormatStatus`) |
|
||
| Status | Resolved |
|
||
|
||
**Description:** `docs/Driver.FOCAS.Cli.md` documents `BadDeviceFailure` and
|
||
`BadCommunicationError` as the key diagnostic signals an operator reads off
|
||
`probe` / `write` output ("A `BadCommunicationError` means ... `BadDeviceFailure`
|
||
after a successful connect means ..."). The FOCAS driver `FocasStatusMapper`
|
||
also emits `BadNotWritable` (0x803B0000), `BadOutOfRange` (0x803C0000),
|
||
`BadNotSupported` (0x803D0000), `BadDeviceFailure` (0x80550000),
|
||
`BadInternalError` (0x80020000), and `BadTimeout` (0x800A0000). The shared
|
||
`SnapshotFormatter.FormatStatus` shortlist only names `Good`, `Bad`,
|
||
`BadCommunicationError`, `BadTimeout` (0x80060000 — note this is a *different*
|
||
code than the mapper `BadTimeout` 0x800A0000), `BadNoCommunication`,
|
||
`BadWaitingForInitialData`, `BadNodeIdUnknown`, `BadNodeIdInvalid`,
|
||
`BadTypeMismatch`, and `Uncertain`. Consequently a FOCAS `write` to a
|
||
non-writable address, a parameter-write rejected by the CNC, or a
|
||
`BadDeviceFailure` session-setup rejection renders as a bare hex code
|
||
(`0x803B0000`, `0x80550000`, …) with no name — directly contradicting the
|
||
documented workflow where the operator is told to read those status names.
|
||
|
||
**Recommendation:** Extend `SnapshotFormatter.FormatStatus` (in
|
||
`Driver.Cli.Common`) to name the `Bad*` codes the native-protocol drivers
|
||
actually emit — at minimum `BadNotWritable`, `BadOutOfRange`, `BadNotSupported`,
|
||
`BadDeviceFailure`, `BadInternalError`, and the mapper `BadTimeout`
|
||
(0x800A0000). The fix belongs in the shared library, but it is recorded here
|
||
because the gap defeats this module documented `probe`/`write` diagnostic
|
||
workflow; cross-reference the `Driver.Cli.Common` review.
|
||
|
||
**Resolution:** Resolved 2026-05-23 — the cross-CLI fix landed in `Driver.Cli.Common`:
|
||
`SnapshotFormatter.FormatStatus` now names `BadInternalError` (0x80020000),
|
||
`BadNotWritable` (0x803B0000), `BadOutOfRange` (0x803C0000), `BadNotSupported`
|
||
(0x803D0000), and `BadDeviceFailure` (0x80550000) — the five codes the FOCAS /
|
||
AbCip / AbLegacy native-protocol mappers all emit but the shortlist previously
|
||
left unnamed (the canonical `BadTimeout` 0x800A0000 was already added under
|
||
Driver.Cli.Common-001). FOCAS `probe` / `write` against a non-writable parameter,
|
||
out-of-range address, unsupported function, busy device, or CNC-handle failure
|
||
now renders with the named status the `docs/Driver.FOCAS.Cli.md` workflow
|
||
promises, restoring parity between the docs and the shipped behaviour. Regression
|
||
`[Theory]` `FormatStatus_names_native_driver_emitted_codes` added to
|
||
`SnapshotFormatterTests` so the five names can't silently drop out of the
|
||
shortlist again; the existing well-known shortlist `[Theory]` was extended with
|
||
the same five entries to enforce the exact `0x... (Name)` rendering. Suite now
|
||
47 green (was 42).
|
||
|
||
## Re-review 2026-06-19 (commit 7286d320)
|
||
|
||
All five prior findings (001–005) remain Resolved. The test suite now has 52 tests
|
||
(48 pre-existing + 4 added in this re-review). Two new findings were identified.
|
||
|
||
#### Checklist coverage (re-review)
|
||
|
||
| # | Category | Result |
|
||
|---|---|---|
|
||
| 1 | Correctness & logic bugs | No new issues found |
|
||
| 2 | OtOpcUa conventions | No new issues found |
|
||
| 3 | Concurrency & thread safety | No new issues found |
|
||
| 4 | Error handling & resilience | No new issues found |
|
||
| 5 | Security | No new issues found |
|
||
| 6 | Performance & resource management | No new issues found |
|
||
| 7 | Design-document adherence | No new issues found |
|
||
| 8 | Code organization & conventions | Driver.FOCAS.Cli-006, Driver.FOCAS.Cli-007 |
|
||
| 9 | Testing coverage | No new issues found |
|
||
| 10 | Documentation & comments | Driver.FOCAS.Cli-007 |
|
||
|
||
### Driver.FOCAS.Cli-006
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Severity | Low |
|
||
| Category | Code organization & conventions |
|
||
| Location | `Commands/ProbeCommand.cs`, `Commands/ReadCommand.cs`, `Commands/WriteCommand.cs`, `Commands/SubscribeCommand.cs` |
|
||
| Status | Resolved |
|
||
|
||
**Description:** `DriverCommandBase.ConfigureLogging` was extended between review
|
||
commits to document that callers must call `FlushLogging()` in a `finally` block
|
||
to ensure buffered Serilog output is flushed before process exit. Every sibling
|
||
driver CLI (Modbus, AbCip, AbLegacy, TwinCAT) honours this contract, but all four
|
||
FOCAS CLI commands were missing the call. With the Console sink (Serilog 6.0.0)
|
||
there is no actual data loss today because `System.Console.Out` auto-flushes;
|
||
however the gap is a maintenance hazard: if a file sink or an async sink is ever
|
||
added to the logger configuration, the omission becomes data-losing. The
|
||
inconsistency with sibling CLIs also makes the codebase misleading for reviewers.
|
||
|
||
**Recommendation:** Add `try/finally` wrappers in ProbeCommand, ReadCommand, and
|
||
WriteCommand (which had no finally blocks since the -004 cleanup) and extend
|
||
SubscribeCommand's existing finally block, calling `FlushLogging()` in each.
|
||
Add a convention test (parallel to `CommandDisposalConventionsTests`) that
|
||
scans source files and asserts the call is present, preventing silent regressions.
|
||
|
||
**Resolution:** Resolved 2026-06-19 — added `try/finally` wrappers with
|
||
`FlushLogging()` to `ProbeCommand`, `ReadCommand`, and `WriteCommand`; extended
|
||
SubscribeCommand's existing `finally` block with the same call. Added
|
||
`FlushLoggingConventionsTests` with a `[Theory]` over all four command files so
|
||
the call cannot be silently dropped in future refactors. Suite now 52 green (was 48).
|
||
|
||
### Driver.FOCAS.Cli-007
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Severity | Low |
|
||
| Category | Documentation & comments |
|
||
| Location | `Commands/SubscribeCommand.cs:113-120` |
|
||
| Status | Resolved |
|
||
|
||
**Description:** The comment in the `SubscribeCommand.finally` block (added by
|
||
the -002 resolution) said "detach the OnDataChange handler before unsubscribe +
|
||
disposal" — but no `-=` operator call exists in the block. The comment's claim
|
||
was factually incorrect: handler cleanup is achieved by driver disposal (via
|
||
`await using`), which tears down the `PollGroupEngine` and prevents further
|
||
`OnDataChange` events; not by explicit unsubscription from the event. A
|
||
parenthetical note at the end of the original comment partially explained the
|
||
real mechanism, but the leading sentence was still misleading for future readers.
|
||
|
||
**Recommendation:** Reword the comment to accurately describe what the code does:
|
||
`UnsubscribeAsync` stops the poll-group ticker (a subscription lifecycle step);
|
||
the anonymous handler is never explicitly removed via `-=`; driver disposal via
|
||
`await using` is the real cleanup.
|
||
|
||
**Resolution:** Resolved 2026-06-19 — rewrote the comment to correctly describe
|
||
the two-step teardown: `UnsubscribeAsync` halts the poll ticker (subscription
|
||
lifecycle), then `await using` disposes the driver and tears down
|
||
`PollGroupEngine` (real cleanup). Removed the incorrect claim about `-=`
|
||
detachment. No behavioral change; documentation only.
|