fix(ablegacy-cli): Ctrl-C prints Cancelled instead of a stack trace

Review 07 U7 (AbLegacy part): probe/read/write had no OperationCanceledException
arm, so cancelling a slow read printed an unhandled-exception stack trace. The
driver work now runs through AbLegacyCommandBase.RunCancellableAsync, which
reports an operator cancellation as "Cancelled." and still propagates anything
else. (The AbCip subscribe lock and SnapshotFormatter array rendering are
other lanes' files.)

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:44:51 -04:00
parent 66927a3faa
commit ffa5b2a4fa
5 changed files with 98 additions and 16 deletions
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
/// Any other exception — and an OCE the operator did not ask for — still propagates.
/// </summary>
/// <param name="console">The CLI console.</param>
/// <param name="ct">The console's Ctrl-C token.</param>
/// <param name="body">The command body.</param>
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func<Task> body)
{
try
{
await body();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
await console.Error.WriteLineAsync("Cancelled.");
}
}
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -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
{
@@ -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
{
@@ -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
{
@@ -0,0 +1,52 @@
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
/// <summary>
/// Review 07 U7: the AbLegacy probe/read/write commands had no <see cref="OperationCanceledException"/> arm, so a
/// Ctrl-C printed a stack trace. They now run their driver work through
/// <see cref="AbLegacyCommandBase.RunCancellableAsync"/>.
/// </summary>
[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<OperationCanceledException>(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();
}
}