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:
Joseph Doherty
2026-06-19 12:08:45 -04:00
parent f8bf067243
commit 7580e37807
7 changed files with 154 additions and 26 deletions
+84 -2
View File
@@ -4,8 +4,8 @@
|---|---|
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli` |
| Reviewer | Claude Code |
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Review date | 2026-06-19 |
| Commit reviewed | `111d6983` |
| Status | Reviewed |
| 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
tightened to point at the XML summary so the design intent survives whichever doc surface a
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
{
await driver.ShutdownAsync(CancellationToken.None);
FlushLogging();
}
var matched = FilterByPrefix(builder.Variables, Prefix);
@@ -59,6 +59,7 @@ public sealed class ProbeCommand : TwinCATTagCommandBase
finally
{
await driver.ShutdownAsync(CancellationToken.None);
FlushLogging();
}
}
}
@@ -50,6 +50,7 @@ public sealed class ReadCommand : TwinCATTagCommandBase
finally
{
await driver.ShutdownAsync(CancellationToken.None);
FlushLogging();
}
}
@@ -108,6 +108,7 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
catch { /* teardown best-effort */ }
}
await driver.ShutdownAsync(CancellationToken.None);
FlushLogging();
}
}
@@ -63,33 +63,54 @@ public sealed class WriteCommand : TwinCATTagCommandBase
finally
{
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="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),
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.String or TwinCATDataType.WString => raw,
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
// 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."),
};
try
{
return type switch
{
TwinCATDataType.Bool => ParseBool(raw),
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.String or TwinCATDataType.WString => raw,
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
// 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
{
@@ -141,12 +141,33 @@ public sealed class WriteCommandParseValueTests
() => 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]
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));
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>