review(Driver.TwinCAT.Cli): clean parse errors + FlushLogging() in finally
Re-review at 7286d320. -008 (Low): ParseValue maps FormatException/OverflowException to a
clean CommandException (was raw stack trace) + tests. -009: FlushLogging() in all 5 commands'
finally blocks (parity with AbCip.Cli).
This commit is contained in:
@@ -4,8 +4,8 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli` |
|
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli` |
|
||||||
| Reviewer | Claude Code |
|
| Reviewer | Claude Code |
|
||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-06-19 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `111d6983` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 0 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
@@ -251,3 +251,85 @@ accessor required by the abstract base property is intentionally a no-op, and th
|
|||||||
backing field would cause the two to drift on every refactor. The inner-block comment was
|
backing field would cause the two to drift on every refactor. The inner-block comment was
|
||||||
tightened to point at the XML summary so the design intent survives whichever doc surface a
|
tightened to point at the XML summary so the design intent survives whichever doc surface a
|
||||||
future maintainer reads first.
|
future maintainer reads first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Re-review 2026-06-19 (commit 111d6983)
|
||||||
|
|
||||||
|
All seven prior findings remain Resolved. The re-review covers the same 8 source files and 4
|
||||||
|
test files at the current HEAD. No new findings were added to categories 2, 3, 5, 6, 7, 8;
|
||||||
|
two new Low findings were identified in categories 4 and 1/4, both fixed in-session.
|
||||||
|
|
||||||
|
#### 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 | Driver.TwinCAT.Cli-008, Driver.TwinCAT.Cli-009 |
|
||||||
|
| 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 | No new issues found |
|
||||||
|
| 9 | Testing coverage | No new issues found |
|
||||||
|
| 10 | Documentation & comments | No new issues found |
|
||||||
|
|
||||||
|
### Driver.TwinCAT.Cli-008
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Error handling & resilience |
|
||||||
|
| Location | `Commands/WriteCommand.cs:73-93` |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** `ParseValue` delegates to the BCL numeric parse methods (`int.Parse`,
|
||||||
|
`sbyte.Parse`, etc.) which throw raw `FormatException` or `OverflowException` on bad
|
||||||
|
input. Those exceptions propagate all the way through `ExecuteAsync` and are caught by
|
||||||
|
CliFx's top-level handler, but CliFx prints a full stack trace for unrecognised exception
|
||||||
|
types. An operator passing `--value xyz --type DInt` sees several lines of .NET internals
|
||||||
|
instead of the single-line "Cannot parse 'xyz' as DInt" message that `CommandException` would
|
||||||
|
produce. The existing test `ParseValue_non_numeric_for_numeric_types_throws` confirmed and
|
||||||
|
preserved the broken behaviour by asserting `FormatException`, so the gap survived review.
|
||||||
|
|
||||||
|
**Recommendation:** Wrap the switch body in a `try/catch` that re-throws `CommandException`
|
||||||
|
(which is already thrown by `ParseBool`) and maps `FormatException`/`OverflowException` to a
|
||||||
|
`CommandException` carrying the raw value and type name.
|
||||||
|
|
||||||
|
**Resolution:** Wrapped the `ParseValue` switch in `try/catch`; `FormatException` and
|
||||||
|
`OverflowException` are caught (via `when` pattern) and re-thrown as `CommandException`
|
||||||
|
with `"Cannot parse '{raw}' as {type}: {message}"`. Pre-existing `CommandException` from
|
||||||
|
`ParseBool` / the unsupported-type arm / the `Structure` guard are re-thrown unchanged.
|
||||||
|
Updated the test: renamed `ParseValue_non_numeric_for_numeric_types_throws` →
|
||||||
|
`ParseValue_non_numeric_for_numeric_types_throws_CommandException` and asserted the message
|
||||||
|
contains both the value and type; added a second test
|
||||||
|
`ParseValue_overflow_for_numeric_types_throws_CommandException` (300 as SInt). 70 tests
|
||||||
|
pass (was 69). Date: 2026-06-19. SHA: blank (not committed).
|
||||||
|
|
||||||
|
### Driver.TwinCAT.Cli-009
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Error handling & resilience |
|
||||||
|
| Location | `Commands/BrowseCommand.cs:62-65`, `Commands/ProbeCommand.cs:59-62`, `Commands/ReadCommand.cs:50-53`, `Commands/WriteCommand.cs:63-67`, `Commands/SubscribeCommand.cs:103-111` |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** `DriverCommandBase` documents that `FlushLogging()` must be called in a
|
||||||
|
`finally` block to flush any buffered Serilog output before the process exits (see its XML
|
||||||
|
`<summary>` on `FlushLogging`). None of the five TwinCAT CLI commands called it. The sibling
|
||||||
|
`AbCip.Cli` correctly calls `FlushLogging()` in each command's `finally`; TwinCAT, Modbus,
|
||||||
|
S7, AbLegacy, and FOCAS CLIs all omit it. The impact is that any `--verbose` debug lines
|
||||||
|
buffered by Serilog's default asynchronous sink may be lost on process exit — a diagnostic
|
||||||
|
CLI that drops diagnostic output on crash is self-defeating.
|
||||||
|
|
||||||
|
**Recommendation:** Add `FlushLogging()` as the last statement in every command's outer
|
||||||
|
`finally` block, after `ShutdownAsync` (mirroring the AbCip pattern).
|
||||||
|
|
||||||
|
**Resolution:** Added `FlushLogging()` as the last call in the `finally` block of every
|
||||||
|
`ExecuteAsync` in `BrowseCommand`, `ProbeCommand`, `ReadCommand`, `WriteCommand`, and
|
||||||
|
`SubscribeCommand`. No test change needed (the call is not unit-testable without a real ADS
|
||||||
|
router and a real Serilog sink; the fix is verified structurally at build time). The systemic
|
||||||
|
omission in the Modbus/S7/AbLegacy/FOCAS sibling CLIs is out of this module's scope and left
|
||||||
|
for their respective re-reviews. Date: 2026-06-19. SHA: blank (not committed).
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
|
|
||||||
var matched = FilterByPrefix(builder.Variables, Prefix);
|
var matched = FilterByPrefix(builder.Variables, Prefix);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public sealed class ProbeCommand : TwinCATTagCommandBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public sealed class ReadCommand : TwinCATTagCommandBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
|
|||||||
catch { /* teardown best-effort */ }
|
catch { /* teardown best-effort */ }
|
||||||
}
|
}
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,33 +63,54 @@ public sealed class WriteCommand : TwinCATTagCommandBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
|
/// <summary>
|
||||||
|
/// Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture. Wraps
|
||||||
|
/// <see cref="FormatException"/> and <see cref="OverflowException"/> in a
|
||||||
|
/// <see cref="CliFx.Exceptions.CommandException"/> so the operator sees a clean one-line
|
||||||
|
/// error instead of a raw stack trace (Driver.TwinCAT.Cli-008).
|
||||||
|
/// </summary>
|
||||||
/// <param name="raw">The raw string value to parse.</param>
|
/// <param name="raw">The raw string value to parse.</param>
|
||||||
/// <param name="type">The target TwinCAT data type.</param>
|
/// <param name="type">The target TwinCAT data type.</param>
|
||||||
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
|
internal static object ParseValue(string raw, TwinCATDataType type)
|
||||||
{
|
{
|
||||||
TwinCATDataType.Bool => ParseBool(raw),
|
try
|
||||||
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
{
|
||||||
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
return type switch
|
||||||
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
{
|
||||||
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
TwinCATDataType.Bool => ParseBool(raw),
|
||||||
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
|
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
TwinCATDataType.String or TwinCATDataType.WString => raw,
|
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
|
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
// value + let the caller handle the encoding semantics.
|
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
TwinCATDataType.Time or TwinCATDataType.Date
|
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay
|
TwinCATDataType.String or TwinCATDataType.WString => raw,
|
||||||
=> uint.Parse(raw, CultureInfo.InvariantCulture),
|
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
|
||||||
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
// value + let the caller handle the encoding semantics.
|
||||||
};
|
TwinCATDataType.Time or TwinCATDataType.Date
|
||||||
|
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay
|
||||||
|
=> uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (CliFx.Exceptions.CommandException)
|
||||||
|
{
|
||||||
|
throw; // already a clean user-facing error (Bool parse, Structure, unsupported type)
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or OverflowException)
|
||||||
|
{
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Cannot parse '{raw}' as {type}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
|
|||||||
+24
-3
@@ -141,12 +141,33 @@ public sealed class WriteCommandParseValueTests
|
|||||||
() => WriteCommand.ParseValue("42", TwinCATDataType.Structure));
|
() => WriteCommand.ParseValue("42", TwinCATDataType.Structure));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that ParseValue non-numeric for numeric types throws.</summary>
|
/// <summary>
|
||||||
|
/// Verifies that ParseValue wraps raw parse errors in a CommandException so the operator
|
||||||
|
/// sees a clean one-line message (Driver.TwinCAT.Cli-008).
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseValue_non_numeric_for_numeric_types_throws()
|
public void ParseValue_non_numeric_for_numeric_types_throws_CommandException()
|
||||||
{
|
{
|
||||||
Should.Throw<FormatException>(
|
// Previously threw raw FormatException. After the fix, FormatException + OverflowException
|
||||||
|
// are caught and re-thrown as CommandException so CliFx prints a single error line.
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
() => WriteCommand.ParseValue("xyz", TwinCATDataType.DInt));
|
() => WriteCommand.ParseValue("xyz", TwinCATDataType.DInt));
|
||||||
|
ex.Message.ShouldContain("xyz");
|
||||||
|
ex.Message.ShouldContain("DInt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ParseValue wraps OverflowException (value out of type range) in a
|
||||||
|
/// CommandException (Driver.TwinCAT.Cli-008).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_overflow_for_numeric_types_throws_CommandException()
|
||||||
|
{
|
||||||
|
// 300 is out of range for SInt (sbyte: -128..127).
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("300", TwinCATDataType.SInt));
|
||||||
|
ex.Message.ShouldContain("300");
|
||||||
|
ex.Message.ShouldContain("SInt");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that SynthesiseTagName preserves symbolic path verbatim.</summary>
|
/// <summary>Verifies that SynthesiseTagName preserves symbolic path verbatim.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user