diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
index e96b1a75..157caf2d 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
+ ///
+ /// Runs a one-shot command's driver work so a Ctrl-C (cancellation of ) prints
+ /// Cancelled. instead of an unhandled stack trace (review 07 U7).
+ /// Any other exception — and an OCE the operator did not ask for — still propagates.
+ ///
+ /// The CLI console.
+ /// The console's Ctrl-C token.
+ /// The command body.
+ /// A task that completes when the body has finished or was cancelled.
+ internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func body)
+ {
+ try
+ {
+ await body();
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ await console.Error.WriteLineAsync("Cancelled.");
+ }
+ }
+
/// Gets the per-operation timeout in milliseconds.
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
index c1ae0737..f89d28d1 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
@@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var snapshot = await driver.ReadAsync(["__probe"], ct);
- var health = driver.GetHealth();
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var snapshot = await driver.ReadAsync(["__probe"], ct);
+ var health = driver.GetHealth();
- await console.Output.WriteLineAsync($"Gateway: {Gateway}");
- await console.Output.WriteLineAsync($"PLC type: {PlcType}");
- await console.Output.WriteLineAsync($"Health: {health.State}");
- if (health.LastError is { } err)
- await console.Output.WriteLineAsync($"Last error: {err}");
- await console.Output.WriteLineAsync();
- await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ await console.Output.WriteLineAsync($"Gateway: {Gateway}");
+ await console.Output.WriteLineAsync($"PLC type: {PlcType}");
+ await console.Output.WriteLineAsync($"Health: {health.State}");
+ if (health.LastError is { } err)
+ await console.Output.WriteLineAsync($"Last error: {err}");
+ await console.Output.WriteLineAsync();
+ await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ });
}
finally
{
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
index 48c381c5..89a1e071 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
@@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var snapshot = await driver.ReadAsync([tagName], ct);
- await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var snapshot = await driver.ReadAsync([tagName], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ });
}
finally
{
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
index 2a414bfc..d4c61ba4 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
@@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
- await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
+ });
}
finally
{
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs
new file mode 100644
index 00000000..bb42e5f6
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs
@@ -0,0 +1,52 @@
+using CliFx.Infrastructure;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
+
+///
+/// Review 07 U7: the AbLegacy probe/read/write commands had no arm, so a
+/// Ctrl-C printed a stack trace. They now run their driver work through
+/// .
+///
+[Trait("Category", "Unit")]
+public sealed class CancellationHandlingTests
+{
+ [Fact]
+ public async Task Operator_cancellation_prints_Cancelled_instead_of_throwing()
+ {
+ using var console = new FakeInMemoryConsole();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Should.NotThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync(
+ console, cts.Token, () => Task.FromCanceled(cts.Token)));
+
+ console.ReadErrorString().ShouldContain("Cancelled.");
+ }
+
+ [Fact]
+ public async Task A_cancellation_the_operator_did_not_request_still_propagates()
+ {
+ using var console = new FakeInMemoryConsole();
+
+ await Should.ThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync(
+ console, CancellationToken.None, () => throw new OperationCanceledException()));
+ }
+
+ [Fact]
+ public async Task A_completed_body_runs_to_completion()
+ {
+ using var console = new FakeInMemoryConsole();
+ var ran = false;
+
+ await AbLegacyCommandBase.RunCancellableAsync(console, CancellationToken.None, () =>
+ {
+ ran = true;
+ return Task.CompletedTask;
+ });
+
+ ran.ShouldBeTrue(); // positive control
+ console.ReadErrorString().ShouldBeEmpty();
+ }
+}