review(Driver.S7.Cli): endpoint validation + cancellation/flush/write-lock consistency

Re-review at 7286d320. -008 (Medium): S7CommandBase.ValidateEndpoint (port range + timeout>0)
in all commands +tests. -009 clean OperationCanceledException handling; -010 FlushLogging()
in subscribe finally; -011 lock console writes in OnDataChange. -012 (Verdict headline) deferred.
This commit is contained in:
Joseph Doherty
2026-06-19 12:08:45 -04:00
parent b0f9b8016a
commit f8bf067243
10 changed files with 390 additions and 11 deletions
@@ -27,6 +27,7 @@ public sealed class ProbeCommand : S7CommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
var probeTag = new S7TagDefinition(
@@ -58,9 +59,12 @@ public sealed class ProbeCommand : S7CommandBase
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // Ctrl+C — let CliFx handle it normally.
// Ctrl+C — re-throw so CliFx exits cleanly. The when filter ensures a
// driver-internal timeout (different CancellationToken) is not mis-classified
// as user cancellation and falls through to the structured-report catch below.
throw;
}
catch
{
@@ -38,6 +38,7 @@ public sealed class ReadCommand : S7CommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Address, DataType);
@@ -53,9 +54,18 @@ public sealed class ReadCommand : S7CommandBase
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
// in a finally block ran shutdown twice. The await-using on the next line is enough.
await using var driver = new S7Driver(options, DriverInstanceId);
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.S7.Cli-009: Ctrl+C during driver connect/read — exit quietly so
// CliFx does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
}
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
@@ -33,6 +33,7 @@ public sealed class SubscribeCommand : S7CommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
@@ -48,6 +49,9 @@ public sealed class SubscribeCommand : S7CommandBase
// ran shutdown twice. Only UnsubscribeAsync stays in the finally block — that's a subscription
// lifecycle concern that is not part of driver disposal.
await using var driver = new S7Driver(options, DriverInstanceId);
// Driver.S7.Cli-011: serialize console writes from the PollGroupEngine background
// thread so overlapping poll ticks cannot interleave partial lines on the output.
var writeLock = new object();
ISubscriptionHandle? handle = null;
try
{
@@ -62,7 +66,10 @@ public sealed class SubscribeCommand : S7CommandBase
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
console.Output.WriteLine(line);
lock (writeLock)
{
console.Output.WriteLine(line);
}
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
@@ -85,6 +92,11 @@ public sealed class SubscribeCommand : S7CommandBase
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
catch { /* teardown best-effort */ }
}
// Driver.S7.Cli-010: flush Serilog before process exit so buffered log lines
// emitted just before Ctrl+C (e.g. reconnect warnings from the PollGroupEngine)
// are not lost on abrupt termination. DriverCommandBase.ConfigureLogging() docs
// require this call in a finally block.
FlushLogging();
}
}
}
@@ -43,6 +43,7 @@ public sealed class WriteCommand : S7CommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
@@ -60,9 +61,18 @@ public sealed class WriteCommand : S7CommandBase
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
// in a finally block ran shutdown twice. The await-using on the next line is enough.
await using var driver = new S7Driver(options, DriverInstanceId);
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.S7.Cli-009: Ctrl+C during driver connect/write — exit quietly so
// CliFx does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
}
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
@@ -1,4 +1,5 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
@@ -65,4 +66,25 @@ public abstract class S7CommandBase : DriverCommandBase
/// <summary>Gets the driver instance ID for this CLI session.</summary>
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
/// <summary>
/// Driver.S7.Cli-008: validate the endpoint flags at parse time so the operator
/// gets a clear <see cref="CommandException"/> instead of an opaque socket or
/// argument exception thrown deep inside the S7.Net stack.
/// <list type="bullet">
/// <item><c>--port</c>: 1..65535 (IANA TCP port space).</item>
/// <item><c>--timeout-ms</c>: strictly positive — a non-positive value would
/// propagate as an immediate-timeout into S7.Net and surface as a confusing
/// <see cref="TimeoutException"/>.</item>
/// </list>
/// </summary>
protected void ValidateEndpoint()
{
if (Port < 1 || Port > 65535)
throw new CommandException(
$"--port must be in 1..65535 (got {Port}).");
if (TimeoutMs <= 0)
throw new CommandException(
$"--timeout-ms must be strictly positive (got {TimeoutMs}).");
}
}