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

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
| Open findings | 9 |
| Open findings | 5 |
## Checklist coverage
@@ -33,13 +33,13 @@
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `OpcUaClientService.cs:552` |
| Status | Open |
| Status | Resolved |
**Description:** `OnAlarmEventNotification` returns early when `eventFields.EventFields` has fewer than 6 entries. The event filter built by `CreateAlarmEventFilter` always registers 13 select clauses, so a conforming server returns 13 fields. The `< 6` threshold is arbitrary and inconsistent: SourceName is index 2 and Severity index 5, but ConditionName (6), Retain (7), Acked/Active (8/9) and ConditionNodeId (12) are all needed for a usable alarm and are each guarded individually with `fields.Count > N`. A non-conforming server that returns a truncated list (or fewer fields than requested) makes the `< 6` early return silently drop the entire notification, including the EventId/SourceName/Severity that are present.
**Recommendation:** Drop the `< 6` early return (or lower it to `< 1`) and rely on the existing per-index `fields.Count > N` guards, which already default missing fields safely. If a hard floor is wanted, document why 6 and not 13.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — lowered the early-return threshold to `< 1` (null or empty guard only); per-index `fields.Count > N` guards already default missing fields safely for all higher indices.
### Client.Shared-002
@@ -48,13 +48,13 @@
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `OpcUaClientService.cs:351-355`, `OpcUaClientService.cs:373` |
| Status | Open |
| Status | Resolved |
**Description:** `GetRedundancyInfoAsync` performs unguarded unboxing casts on values read from the server: `(int)redundancySupportValue.Value` and `(byte)serviceLevelValue.Value`. Unlike the `ServerUriArray`/`ServerArray` reads below them, the `RedundancySupport` and `ServiceLevel` reads are not wrapped in try/catch. If the server returns the value boxed as a different numeric type than expected (e.g. `ServiceLevel` boxed as `int` instead of `byte`), or returns a null `Value` on a `Bad` DataValue, the cast throws `InvalidCastException`/`NullReferenceException` and the whole call fails instead of returning a sensible default.
**Recommendation:** Wrap the `RedundancySupport` and `ServiceLevel` reads in the same defensive pattern used for the array reads, using `Convert.ToInt32`/`Convert.ToByte` on the boxed value and falling back to `None`/`0` when the read status is bad or the value is null.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — replaced direct casts with `StatusCode.IsGood` guard + `Convert.ToInt32`/`Convert.ToByte` coercion; falls back to `None`/`0` when status is bad or value is null.
### Client.Shared-003
@@ -123,13 +123,13 @@
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | `OpcUaClientService.cs:581-622` |
| Status | Open |
| Status | Resolved |
**Description:** In the alarm fallback path, the `Task.Run` closure mutates the captured locals `activeState`, `ackedState`, `time`, and `capturedMessage`, then reads them when invoking `AlarmEvent`. Because the captured `_session` reference can be replaced by a concurrent failover (see Client.Shared-006), the supplemental `ReadValueAsync` calls may run against a session being disposed, throwing `ObjectDisposedException` — caught by the bare `catch`, after which the alarm is delivered with default (false/MinValue) states, silently misreporting it as inactive/unacknowledged. The notification callback also has no back-pressure: a burst of alarm events spawns an unbounded number of `Task.Run` continuations each doing 3-4 server round-trips.
**Recommendation:** Capture the session under the same lock proposed in Client.Shared-005 and skip the supplemental read if the session has changed or is disposed. Consider batching the four sequential `ReadValueAsync` calls into one `Read` request.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — added a `ReferenceEquals(session, _session)` guard at the top of the `Task.Run` body to skip reads if the session was replaced by failover; separated `ObjectDisposedException` from the general catch to drop rather than deliver the stale alarm.
### Client.Shared-008
@@ -138,13 +138,13 @@
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `OpcUaClientService.cs:170-180`, `Helpers/ValueConverter.cs:15-31` |
| Status | Open |
| Status | Resolved |
**Description:** `WriteValueAsync` coerces a string input to the target type by reading the node's current value and inferring the type from `currentDataValue.Value`. When the node has never been written, or the read returns a `Bad` status with a null `Value`, `ValueConverter.ConvertValue` falls through to the `_ => rawValue` default and writes a raw `string` into, for example, an `Int32` node — the server then rejects it with `BadTypeMismatch`, surfacing as a confusing failure unrelated to the operator's input. Separately, `ConvertValue` uses `bool.Parse`, which accepts only `true`/`false` — operator input of `1`/`0` throws `FormatException` that propagates raw to the caller. The read-before-write also doubles the round-trip cost of every string write.
**Recommendation:** Inspect `currentDataValue.StatusCode` before trusting `Value`; when the type cannot be inferred, surface a clear error rather than writing a mistyped value. Make boolean parsing accept `1`/`0`/`yes`/`no`, and wrap parse failures in a descriptive exception naming the node and target type.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — `WriteValueAsync` now checks `StatusCode.IsGood` and non-null `Value` before calling `ConvertValue`, throwing a descriptive `InvalidOperationException` on bad reads; `ValueConverter` now uses a `ParseBool` helper accepting `true/false/1/0/yes/no` (case-insensitive) and wraps all parse/overflow failures in a `FormatException` with the target type and input value in the message.
### Client.Shared-009

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(

View File

@@ -97,14 +97,43 @@ public class ValueConverterTests
}
[Fact]
public void ConvertValue_InvalidInt_Throws()
public void ConvertValue_InvalidInt_ThrowsWithDescription()
{
Should.Throw<FormatException>(() => ValueConverter.ConvertValue("notanint", 0));
var ex = Should.Throw<FormatException>(() => ValueConverter.ConvertValue("notanint", 0));
ex.Message.ShouldContain("Int32");
ex.Message.ShouldContain("notanint");
}
[Fact]
public void ConvertValue_Overflow_Throws()
public void ConvertValue_Overflow_ThrowsFormatException()
{
Should.Throw<OverflowException>(() => ValueConverter.ConvertValue("256", (byte)0));
// OverflowException is now wrapped in a descriptive FormatException
var ex = Should.Throw<FormatException>(() => ValueConverter.ConvertValue("256", (byte)0));
ex.InnerException.ShouldBeOfType<OverflowException>();
}
// --- Client.Shared-008: Boolean aliases ---
[Theory]
[InlineData("1", true)]
[InlineData("0", false)]
[InlineData("yes", true)]
[InlineData("no", false)]
[InlineData("YES", true)]
[InlineData("NO", false)]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("True", true)]
[InlineData("False", false)]
public void ConvertValue_Bool_AcceptsNumericAndWordAliases(string input, bool expected)
{
ValueConverter.ConvertValue(input, true).ShouldBe(expected);
}
[Fact]
public void ConvertValue_InvalidBool_ThrowsDescriptiveFormatException()
{
var ex = Should.Throw<FormatException>(() => ValueConverter.ConvertValue("maybe", false));
ex.Message.ShouldContain("Boolean");
}
}

View File

@@ -293,6 +293,46 @@ public class OpcUaClientServiceTests : IDisposable
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
}
/// <summary>
/// Verifies that writing a string to a node whose current read returns a bad status
/// surfaces a clear error instead of writing a mistyped string value (Client.Shared-008).
/// </summary>
[Fact]
public async Task WriteValueAsync_StringValueWithBadReadStatus_ThrowsInvalidOperationException()
{
var session = new FakeSessionAdapter
{
ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
ex.Message.ShouldContain("Cannot infer target type");
}
/// <summary>
/// Verifies that writing a string to a node whose read returns bad status and null Value
/// surfaces a clear error for both the bad-status case (Client.Shared-008).
/// </summary>
[Fact]
public async Task WriteValueAsync_StringValueWithBadStatus_MessageMentionsNode()
{
var session = new FakeSessionAdapter
{
ReadResponse = new DataValue(StatusCodes.BadNotReadable)
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
ex.Message.ShouldContain("ns=2;s=MyNode");
}
// --- Browse tests ---
/// <summary>
@@ -842,6 +882,120 @@ public class OpcUaClientServiceTests : IDisposable
_service.GetRedundancyInfoAsync());
}
/// <summary>
/// Verifies that RedundancySupport boxed as a different numeric type (e.g. short) is handled
/// without InvalidCastException — defensive Convert.ToInt32 coercion (Client.Shared-002).
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_RedundancySupportBoxedAsShort_DoesNotThrow()
{
var session = new FakeSessionAdapter
{
ReadResponseFunc = nodeId =>
{
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
// Boxed as short instead of int — simulates a non-conforming server
return new DataValue(new Variant((short)RedundancySupport.Warm), StatusCodes.Good);
if (nodeId == VariableIds.Server_ServiceLevel)
return new DataValue(new Variant((int)200), StatusCodes.Good); // int instead of byte
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
}
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var info = await _service.GetRedundancyInfoAsync();
info.Mode.ShouldBe("Warm");
info.ServiceLevel.ShouldBe((byte)200);
}
/// <summary>
/// Verifies that a bad-status response for RedundancySupport/ServiceLevel falls back to defaults
/// rather than throwing (Client.Shared-002).
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_BadStatusOnRequiredReads_ReturnsDefaults()
{
var session = new FakeSessionAdapter
{
ReadResponseFunc = nodeId =>
{
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value
if (nodeId == VariableIds.Server_ServiceLevel)
return new DataValue(StatusCodes.BadNotReadable);
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
}
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var info = await _service.GetRedundancyInfoAsync();
info.Mode.ShouldBe("None");
info.ServiceLevel.ShouldBe((byte)0);
}
// --- Alarm truncated-fields tests (Client.Shared-001) ---
/// <summary>
/// Verifies that an alarm event with fewer than 6 fields (but at least 1) is still raised
/// with available fields — the old hard &lt;6 early return silently dropped it (Client.Shared-001).
/// </summary>
[Fact]
public async Task OnAlarmEvent_TruncatedFields_StillRaisesEvent()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
AlarmEventArgs? received = null;
_service.AlarmEvent += (_, e) => received = e;
await _service.SubscribeAlarmsAsync();
var handle = fakeSub.ActiveHandles.First();
// Only 3 fields — EventId, EventType, SourceName — fewer than the old < 6 threshold
var fields = new EventFieldList
{
EventFields =
[
new Variant(new byte[] { 9, 8, 7 }), // 0: EventId
new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
new Variant("PartialSource") // 2: SourceName
]
};
fakeSub.SimulateEvent(handle, fields);
received.ShouldNotBeNull();
received!.SourceName.ShouldBe("PartialSource");
received.Severity.ShouldBe((ushort)0); // default — field 5 not present
}
/// <summary>
/// Verifies that a null or empty event field list is silently ignored (defensive guard).
/// </summary>
[Fact]
public async Task OnAlarmEvent_EmptyFields_DoesNotRaiseEvent()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var eventCount = 0;
_service.AlarmEvent += (_, _) => eventCount++;
await _service.SubscribeAlarmsAsync();
var handle = fakeSub.ActiveHandles.First();
fakeSub.SimulateEvent(handle, new EventFieldList { EventFields = [] });
eventCount.ShouldBe(0);
}
// --- Failover tests ---
/// <summary>