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:
@@ -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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user