fix(client-cli): resolve Medium code-review findings (Client.CLI-001, Client.CLI-005)

Client.CLI-001: parse --start/--end with CultureInfo.InvariantCulture and
DateTimeStyles.AssumeUniversal|AdjustToUniversal so dates are culture-stable.
Client.CLI-005: SDK notification callbacks now hand off to an unbounded
channel drained on the main thread; handlers are unsubscribed before the
summary phase so no notification interleaves with console output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 08:08:25 -04:00
parent 9f5a5c9997
commit aa142f6dd4
4 changed files with 98 additions and 33 deletions

View File

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
| Open findings | 10 |
| Open findings | 8 |
## Checklist coverage
@@ -36,7 +36,7 @@ a category produced nothing rather than leaving it blank.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `Commands/HistoryReadCommand.cs:73`, `Commands/HistoryReadCommand.cs:76` |
| Status | Open |
| Status | Resolved |
**Description:** The start and end options are parsed with `DateTime.Parse(StartTime)` with
no `IFormatProvider` or `DateTimeStyles`. Parsing therefore depends on the current OS
@@ -53,7 +53,7 @@ ranges on machines in different time zones.
ISO 8601 via `DateTimeOffset.Parse`), and document the expected format and timezone
assumption precisely.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — `DateTime.Parse` replaced with `CultureInfo.InvariantCulture` + `DateTimeStyles.AssumeUniversal | AdjustToUniversal`; option descriptions updated to document ISO 8601 UTC format.
### Client.CLI-002
@@ -130,7 +130,7 @@ each of its option properties, matching the style used by the sibling commands.
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | `Commands/SubscribeCommand.cs:66-78`, `Commands/AlarmsCommand.cs:52-64` |
| Status | Open |
| Status | Resolved |
**Description:** The `DataChanged` and `AlarmEvent` handlers write to `console.Output`
(a `System.IO.TextWriter`) directly from the OPC UA SDK subscription/notification thread,
@@ -147,7 +147,7 @@ through a `Channel<T>` drained by the main thread, or guard every `console.Outpu
with a shared lock. At minimum, ensure handler exceptions cannot escape into the SDK
callback.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — notification handlers in `SubscribeCommand` and `AlarmsCommand` now enqueue lines to an `UnboundedChannel<string>` via `TryWrite`; the main thread drains the channel via `ReadAllAsync`. Handlers are named local functions so they can be unsubscribed before the summary phase; all handler exceptions are swallowed to protect the SDK callback.
### Client.CLI-006

View File

@@ -1,7 +1,9 @@
using System.Threading.Channels;
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
@@ -49,19 +51,33 @@ public class AlarmsCommand : CommandBase
var sourceNodeId = NodeIdParser.Parse(NodeId);
service.AlarmEvent += (_, e) =>
// Channel serialises SDK notification-thread writes to the main async loop so
// that concurrent alarm callbacks never interleave on the shared TextWriter.
var outputChannel = Channel.CreateUnbounded<string>(
new UnboundedChannelOptions { SingleReader = true });
void AlarmEventHandler(object? sender, AlarmEventArgs e)
{
console.Output.WriteLine($"[{e.Time:O}] ALARM {e.SourceName}");
console.Output.WriteLine($" Condition: {e.ConditionName}");
var activeStr = e.ActiveState ? "Active" : "Inactive";
var ackedStr = e.AckedState ? "Acknowledged" : "Unacknowledged";
console.Output.WriteLine($" State: {activeStr}, {ackedStr}");
console.Output.WriteLine($" Severity: {e.Severity}");
if (!string.IsNullOrEmpty(e.Message))
console.Output.WriteLine($" Message: {e.Message}");
console.Output.WriteLine($" Retain: {e.Retain}");
console.Output.WriteLine();
};
try
{
var activeStr = e.ActiveState ? "Active" : "Inactive";
var ackedStr = e.AckedState ? "Acknowledged" : "Unacknowledged";
outputChannel.Writer.TryWrite($"[{e.Time:O}] ALARM {e.SourceName}");
outputChannel.Writer.TryWrite($" Condition: {e.ConditionName}");
outputChannel.Writer.TryWrite($" State: {activeStr}, {ackedStr}");
outputChannel.Writer.TryWrite($" Severity: {e.Severity}");
if (!string.IsNullOrEmpty(e.Message))
outputChannel.Writer.TryWrite($" Message: {e.Message}");
outputChannel.Writer.TryWrite($" Retain: {e.Retain}");
outputChannel.Writer.TryWrite(string.Empty);
}
catch
{
// Never let handler exceptions escape into the SDK callback.
}
}
service.AlarmEvent += AlarmEventHandler;
await service.SubscribeAlarmsAsync(sourceNodeId, Interval, ct);
await console.Output.WriteLineAsync(
@@ -78,6 +94,14 @@ public class AlarmsCommand : CommandBase
await console.Output.WriteLineAsync($"Condition refresh not supported: {ex.Message}");
}
// Drain the output channel on the main thread until cancellation fires.
using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var drainTask = Task.Run(async () =>
{
await foreach (var line in outputChannel.Reader.ReadAllAsync(drainCts.Token))
await console.Output.WriteLineAsync(line);
}, CancellationToken.None);
// Wait until cancellation
try
{
@@ -88,6 +112,12 @@ public class AlarmsCommand : CommandBase
// Expected on Ctrl+C
}
// Stop accepting new notifications before writing final output.
service.AlarmEvent -= AlarmEventHandler;
outputChannel.Writer.Complete();
await drainCts.CancelAsync();
try { await drainTask; } catch (OperationCanceledException) { }
await service.UnsubscribeAlarmsAsync();
await console.Output.WriteLineAsync("Unsubscribed.");
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Opc.Ua;
@@ -27,13 +28,13 @@ public class HistoryReadCommand : CommandBase
/// <summary>
/// Gets the optional history start time string supplied by the operator.
/// </summary>
[CommandOption("start", Description = "Start time (ISO 8601 or date string, default: 24 hours ago)")]
[CommandOption("start", Description = "Start time in ISO 8601 UTC format, e.g. 2026-01-15T08:00:00Z (default: 24 hours ago)")]
public string? StartTime { get; init; }
/// <summary>
/// Gets the optional history end time string supplied by the operator.
/// </summary>
[CommandOption("end", Description = "End time (ISO 8601 or date string, default: now)")]
[CommandOption("end", Description = "End time in ISO 8601 UTC format, e.g. 2026-01-15T09:00:00Z (default: now)")]
public string? EndTime { get; init; }
/// <summary>
@@ -70,10 +71,12 @@ public class HistoryReadCommand : CommandBase
var nodeId = NodeIdParser.ParseRequired(NodeId);
var start = string.IsNullOrEmpty(StartTime)
? DateTime.UtcNow.AddHours(-24)
: DateTime.Parse(StartTime).ToUniversalTime();
: DateTime.Parse(StartTime, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
var end = string.IsNullOrEmpty(EndTime)
? DateTime.UtcNow
: DateTime.Parse(EndTime).ToUniversalTime();
: DateTime.Parse(EndTime, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
IReadOnlyList<DataValue> values;

View File

@@ -1,9 +1,11 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
@@ -63,19 +65,35 @@ public class SubscribeCommand : CommandBase
var everBad = new ConcurrentDictionary<string, byte>();
var displayNameByNodeId = targets.ToDictionary(t => t.nodeId.ToString(), t => t.displayPath);
service.DataChanged += (_, e) =>
// Channel serialises notification-thread writes to the main async loop so that
// concurrent SDK callbacks and main-thread summary output never interleave on
// the shared TextWriter.
var outputChannel = Channel.CreateUnbounded<string>(
new UnboundedChannelOptions { SingleReader = true });
void DataChangedHandler(object? sender, DataChangedEventArgs e)
{
var key = e.NodeId.ToString();
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
if (!StatusCode.IsGood(e.Value.StatusCode))
everBad.TryAdd(key, 0);
if (!Quiet)
try
{
console.Output.WriteLine(
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})");
var key = e.NodeId.ToString();
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
if (!StatusCode.IsGood(e.Value.StatusCode))
everBad.TryAdd(key, 0);
if (!Quiet)
{
var line =
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})";
outputChannel.Writer.TryWrite(line);
}
}
};
catch
{
// Never let handler exceptions escape into the SDK callback.
}
}
service.DataChanged += DataChangedHandler;
var subscribed = 0;
foreach (var (nodeId, _) in targets)
@@ -94,6 +112,14 @@ public class SubscribeCommand : CommandBase
await console.Output.WriteLineAsync(
$"Subscribed to {subscribed}/{targets.Count} nodes (interval: {Interval}ms). Press Ctrl+C to stop and print summary.");
// Drain the output channel on the main thread until cancellation fires.
using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var drainTask = Task.Run(async () =>
{
await foreach (var line in outputChannel.Reader.ReadAllAsync(drainCts.Token))
await console.Output.WriteLineAsync(line);
}, CancellationToken.None);
try
{
if (DurationSeconds > 0)
@@ -105,6 +131,12 @@ public class SubscribeCommand : CommandBase
{
}
// Stop accepting new notifications before writing the summary.
service.DataChanged -= DataChangedHandler;
outputChannel.Writer.Complete();
await drainCts.CancelAsync();
try { await drainTask; } catch (OperationCanceledException) { }
// Summary
var summary = new List<string>();
summary.Add("");
@@ -127,10 +159,10 @@ public class SubscribeCommand : CommandBase
}
var neverWentBad = targets
.Where(t => !everBad.ContainsKey(t.nodeId.ToString()))
.Where(t => lastStatus.ContainsKey(t.nodeId.ToString()) && !everBad.ContainsKey(t.nodeId.ToString()))
.Select(t => t.displayPath)
.ToList();
var didGoBad = targets.Count - neverWentBad.Count;
var didGoBad = targets.Count(t => everBad.ContainsKey(t.nodeId.ToString()));
summary.Add($"Total subscribed: {targets.Count}");
summary.Add($" Ever went BAD during window: {didGoBad}");