fix(driver-abcip-cli): resolve Low code-review findings (Driver.AbCip.Cli-003,004,005,006,007,008)

- Driver.AbCip.Cli-003: SubscribeCommand prints the 'Subscribed' banner
  BEFORE wiring OnDataChange so the main thread can't interleave its
  write with the poll-thread handler.
- Driver.AbCip.Cli-004: AbCipCommandBase.Timeout and SubscribeCommand
  validate TimeoutMs / IntervalMs and throw CommandException on
  non-positive values.
- Driver.AbCip.Cli-005: every command now calls FlushLogging() in its
  finally block.
- Driver.AbCip.Cli-006: Timeout init throws NotSupportedException with a
  pointer at TimeoutMs instead of silently swallowing assignments.
- Driver.AbCip.Cli-007: added AbCipCommandBaseTests covering BuildOptions
  shape, probe / controller-browse / alarm toggles, host address, family
  selection, tag list passthrough.
- Driver.AbCip.Cli-008: rewrote the opening paragraph in
  docs/Driver.AbCip.Cli.md to credit the six-CLI roster with a pointer
  at docs/DriverClis.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:34:25 -04:00
parent 61c0311938
commit 759af8c1bb
9 changed files with 348 additions and 20 deletions

View File

@@ -27,10 +27,28 @@ public abstract class AbCipCommandBase : DriverCommandBase
public int TimeoutMs { get; init; } = 5000;
/// <inheritdoc />
/// <remarks>
/// The getter validates <see cref="TimeoutMs"/> (Driver.AbCip.Cli-004) — a zero or
/// negative <c>--timeout-ms</c> would otherwise propagate as a non-positive
/// <see cref="TimeSpan"/> into the driver. The <c>init</c> accessor is unreachable
/// because CliFx binds <see cref="TimeoutMs"/> rather than <c>Timeout</c>; it throws
/// <see cref="NotSupportedException"/> so an object-initializer assignment
/// (<c>new ReadCommand { Timeout = ... }</c>) fails fast instead of being silently
/// discarded (Driver.AbCip.Cli-006).
/// </remarks>
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
init { /* driven by TimeoutMs */ }
get
{
if (TimeoutMs <= 0)
throw new CliFx.Exceptions.CommandException(
$"--timeout-ms must be > 0 (got {TimeoutMs}). " +
"Pick a positive number of milliseconds for the per-operation timeout.");
return TimeSpan.FromMilliseconds(TimeoutMs);
}
init => throw new NotSupportedException(
$"{nameof(AbCipCommandBase)}.{nameof(Timeout)} is derived from {nameof(TimeoutMs)} " +
"and cannot be assigned directly. Set TimeoutMs instead.");
}
/// <summary>

View File

@@ -54,6 +54,9 @@ public sealed class ProbeCommand : AbCipCommandBase
finally
{
await driver.ShutdownAsync(CancellationToken.None);
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
// output emitted during driver shutdown is not lost.
FlushLogging();
}
}
}

View File

@@ -49,6 +49,9 @@ public sealed class ReadCommand : AbCipCommandBase
finally
{
await driver.ShutdownAsync(CancellationToken.None);
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
// output emitted during driver shutdown is not lost.
FlushLogging();
}
}

View File

@@ -31,6 +31,10 @@ public sealed class SubscribeCommand : AbCipCommandBase
{
ConfigureLogging();
RejectStructure(DataType);
ValidateInterval(IntervalMs);
// Touch Timeout to surface the --timeout-ms guard (Driver.AbCip.Cli-004) before
// we open a driver — fast-fail with a clean CommandException on bad operator input.
_ = Timeout;
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
@@ -48,6 +52,13 @@ public sealed class SubscribeCommand : AbCipCommandBase
{
await driver.InitializeAsync("{}", ct);
// Driver.AbCip.Cli-003 — emit the banner BEFORE wiring OnDataChange so the
// main-thread write cannot interleave with poll-thread change-event writes.
// TextWriter.WriteLine is not guaranteed thread-safe; once the handler is
// attached and SubscribeAsync starts, change events run on the poll thread.
await console.Output.WriteLineAsync(
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
driver.OnDataChange += (_, e) =>
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
@@ -58,8 +69,6 @@ public sealed class SubscribeCommand : AbCipCommandBase
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
await console.Output.WriteLineAsync(
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
@@ -77,6 +86,23 @@ public sealed class SubscribeCommand : AbCipCommandBase
catch { /* teardown best-effort */ }
}
await driver.ShutdownAsync(CancellationToken.None);
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
// lines emitted just before Ctrl+C are not lost on abrupt termination.
FlushLogging();
}
}
/// <summary>
/// Guards <c>--interval-ms</c> against zero or negative values (Driver.AbCip.Cli-004).
/// A non-positive interval would produce a non-positive <see cref="TimeSpan"/> into
/// <c>SubscribeAsync</c>; the CLI should fail fast with an actionable error rather
/// than relying on the downstream <c>PollGroupEngine</c> to clamp the value.
/// </summary>
internal static void ValidateInterval(int intervalMs)
{
if (intervalMs <= 0)
throw new CliFx.Exceptions.CommandException(
$"--interval-ms must be > 0 (got {intervalMs}). " +
"PollGroupEngine floors sub-250ms values, but accepts any positive integer.");
}
}

View File

@@ -60,6 +60,9 @@ public sealed class WriteCommand : AbCipCommandBase
finally
{
await driver.ShutdownAsync(CancellationToken.None);
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
// output emitted during driver shutdown is not lost.
FlushLogging();
}
}