fix(client-shared): resolve Medium code-review findings (Client.Shared-001, -002, -007, -008)

Client.Shared-001: lowered the OnAlarmEventNotification early-return guard
from <6 to <1; per-index field guards already default missing fields safely.
Client.Shared-002: GetRedundancyInfoAsync replaces unguarded unboxing casts
with StatusCode.IsGood + Convert.ToInt32/ToByte, defaulting on bad reads.
Client.Shared-007: alarm fallback Task.Run guards on ReferenceEquals(session,
_session) and drops stale alarms on ObjectDisposedException after failover.
Client.Shared-008: WriteValueAsync rejects type inference from bad/null reads;
ValueConverter wraps parse failures in a descriptive FormatException.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 08:11:23 -04:00
parent aa142f6dd4
commit 7e54e1e4a0
5 changed files with 267 additions and 30 deletions

View File

@@ -10,23 +10,53 @@ public static class ValueConverter
/// Converts a raw string value into the runtime type expected by the target node.
/// </summary>
/// <param name="rawValue">The raw string supplied by the user.</param>
/// <param name="currentValue">The current node value used to infer the target type. May be null.</param>
/// <param name="currentValue">
/// The current node value used to infer the target type. When <c>null</c> the raw string
/// is returned unchanged; callers should validate this case before writing.
/// </param>
/// <returns>A typed value suitable for an OPC UA write request.</returns>
/// <exception cref="FormatException">
/// Thrown with a descriptive message when <paramref name="rawValue"/> cannot be parsed
/// into the type inferred from <paramref name="currentValue"/>.
/// </exception>
public static object ConvertValue(string rawValue, object? currentValue)
{
return currentValue switch
try
{
bool => bool.Parse(rawValue),
byte => byte.Parse(rawValue),
short => short.Parse(rawValue),
ushort => ushort.Parse(rawValue),
int => int.Parse(rawValue),
uint => uint.Parse(rawValue),
long => long.Parse(rawValue),
ulong => ulong.Parse(rawValue),
float => float.Parse(rawValue),
double => double.Parse(rawValue),
_ => rawValue
return currentValue switch
{
bool => ParseBool(rawValue),
byte => byte.Parse(rawValue),
short => short.Parse(rawValue),
ushort => ushort.Parse(rawValue),
int => int.Parse(rawValue),
uint => uint.Parse(rawValue),
long => long.Parse(rawValue),
ulong => ulong.Parse(rawValue),
float => float.Parse(rawValue),
double => double.Parse(rawValue),
_ => rawValue
};
}
catch (Exception ex) when (ex is FormatException or OverflowException)
{
var targetType = currentValue?.GetType().Name ?? "unknown";
throw new FormatException(
$"Cannot convert value \"{rawValue}\" to target type {targetType}: {ex.Message}", ex);
}
}
/// <summary>
/// Parses a boolean from common string representations including numeric and word forms.
/// Accepts: <c>true</c>/<c>false</c>, <c>1</c>/<c>0</c>, <c>yes</c>/<c>no</c> (case-insensitive).
/// </summary>
private static bool ParseBool(string rawValue)
{
return rawValue.Trim().ToLowerInvariant() switch
{
"true" or "1" or "yes" => true,
"false" or "0" or "no" => false,
_ => throw new FormatException($"String '{rawValue}' was not recognized as a valid Boolean.")
};
}
}

View File

@@ -187,6 +187,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (value is string rawString)
{
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null)
throw new InvalidOperationException(
$"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string.");
typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
}
@@ -388,10 +391,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
var redundancySupportValue =
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
var redundancyMode = ((RedundancySupport)(int)redundancySupportValue.Value).ToString();
RedundancySupport redundancySupport;
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
else
redundancySupport = RedundancySupport.None;
var redundancyMode = redundancySupport.ToString();
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
var serviceLevel = (byte)serviceLevelValue.Value;
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
? Convert.ToByte(serviceLevelValue.Value)
: (byte)0;
string[] serverUris = [];
try
@@ -627,7 +637,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
private void OnAlarmEventNotification(EventFieldList eventFields)
{
var fields = eventFields.EventFields;
if (fields == null || fields.Count < 6)
if (fields == null || fields.Count < 1)
return;
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
@@ -656,6 +666,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
// Fallback: read InAlarm/Acked from condition node Galaxy attributes
// when the server doesn't populate standard event fields.
// Must run on a background thread to avoid deadlocking the notification thread.
// Capture the session reference now; skip supplemental reads if the session has
// been replaced by a concurrent failover before the Task.Run body executes.
if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
{
var session = _session;
@@ -663,6 +675,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
var capturedMessage = message;
_ = Task.Run(async () =>
{
// If the session was replaced by a failover before we started reading,
// skip the supplemental reads to avoid hitting a disposed session.
if (!ReferenceEquals(session, _session))
return;
try
{
var inAlarmValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.InAlarm"));
@@ -687,9 +704,16 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
catch { /* DescAttrName may not exist */ }
}
catch (ObjectDisposedException)
{
// Session was disposed during supplemental reads (concurrent failover);
// skip the event rather than delivering stale/default states.
Logger.Debug("Supplemental alarm read skipped — session disposed during failover for {ConditionNodeId}", capturedConditionNodeId);
return;
}
catch
{
// Supplemental read failed; use defaults
// Other supplemental read failure; deliver event with defaults
}
AlarmEvent?.Invoke(this, new AlarmEventArgs(