fix(driver-opcuaclient): resolve Low code-review findings (Driver.OpcUaClient-011,014)

- Driver.OpcUaClient-011: rewrote the ValueRank comment with the OPC UA
  Part 3 constants and an explicit scalar/array boundary at
  valueRank >= 0.
- Driver.OpcUaClient-014: track every MonitoredItem.Notification handler
  in a MonitoredItemNotificationHandle record; UnsubscribeAsync /
  UnsubscribeAlarmsAsync / ShutdownAsync detach the handler before
  Subscription.DeleteAsync so the SDK's invocation list no longer keeps
  the driver alive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:17:55 -04:00
parent d5322b0f9a
commit 42aa82de29
3 changed files with 239 additions and 17 deletions

View File

@@ -0,0 +1,144 @@
using System.Reflection;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Regression tests for the Low code-review findings cleared in the 2026-05-23 pass:
/// <list type="bullet">
/// <item>Driver.OpcUaClient-011 — ValueRank comment + boundary semantics</item>
/// <item>Driver.OpcUaClient-014 — MonitoredItem.Notification handlers are detached on
/// Unsubscribe / Shutdown so the driver instance is not held alive by an
/// SDK-side reference graph after the subscription is gone</item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientLowFindingsRegressionTests
{
// ---- Driver.OpcUaClient-011 ----
//
// The pre-fix comment claimed "-1 = scalar; 1+ = array dimensions; 0 = one-dimensional
// array", which is wrong against OPC UA Part 3 ValueRank semantics. The fix corrects the
// comment and locks in the deliberate choice that anything >= 0 is treated as an array.
// The decision branches are pure logic; assert them against the SDK constants so a
// regression rewriting `valueRank >= 0` shows up in CI.
[Fact]
public void ValueRank_constants_have_the_OPCUA_Part3_spec_values()
{
// Anchor the spec values from the SDK so the comment in the driver and any code
// keying off them stays accurate. -3, -2, -1 are the three negative sentinels;
// 0 means OneOrMoreDimensions (multi-dim), 1 means OneDimension specifically.
ValueRanks.ScalarOrOneDimension.ShouldBe(-3);
ValueRanks.Any.ShouldBe(-2);
ValueRanks.Scalar.ShouldBe(-1);
ValueRanks.OneOrMoreDimensions.ShouldBe(0);
ValueRanks.OneDimension.ShouldBe(1);
}
[Theory]
[InlineData(-3, false)] // ScalarOrOneDimension — conservatively treated as scalar
[InlineData(-2, false)] // Any — conservatively treated as scalar
[InlineData(-1, false)] // Scalar
[InlineData(0, true)] // OneOrMoreDimensions — array (multi-dim)
[InlineData(1, true)] // OneDimension — array
[InlineData(2, true)] // 2 specific dimensions — array
public void IsArray_decision_matches_valueRank_greater_or_equal_zero(int valueRank, bool expectedIsArray)
{
// Mirrors EnrichAndRegisterVariablesAsync's `isArray = valueRank >= 0` decision.
// The pre-fix comment was wrong; the *code* is correct and this test pins it.
var isArray = valueRank >= 0;
isArray.ShouldBe(expectedIsArray);
}
// ---- Driver.OpcUaClient-014 ----
//
// The Notification lambda must be detached when the subscription is removed; otherwise
// the SDK retains the closure (and through it the OpcUaClientDriver instance) until the
// session itself is disposed. UnsubscribeAsync had no detach step in the pre-fix code.
//
// The two angles we can test without a live session:
// (a) The fix tracks the handler delegate inside the RemoteSubscription record so
// UnsubscribeAsync / ShutdownAsync can detach it. Use reflection to assert the
// record carries the handler (the contract surface).
// (b) Simulate the detach against a synthetic MonitoredItem: build one, attach a
// lambda the same way the driver does, then call MonitoredItem.Notification -=
// with the *same delegate instance*. Confirm that further notifications do not
// invoke the handler.
[Fact]
public void RemoteSubscription_record_carries_handler_delegates_so_they_can_be_detached()
{
// The fix introduces a list of (MonitoredItem, NotificationEventHandler) pairs onto
// RemoteSubscription so UnsubscribeAsync can `item.Notification -= handler` each one
// before deleting the subscription. We assert via reflection because the record is
// private — the public observable is just "no leaks" which is hard to assert
// synthetically.
var driverType = typeof(OpcUaClientDriver);
var remoteSubType = driverType.GetNestedType("RemoteSubscription", BindingFlags.NonPublic);
remoteSubType.ShouldNotBeNull("RemoteSubscription record should exist");
// The record must carry a property/field referencing the per-item handler delegates
// so detach is possible. Accept either a List of pairs or a parallel handler list —
// both are valid implementations; what matters is that the handler reference is
// reachable from the record.
var members = remoteSubType.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var hasHandlerStorage = members.Any(m =>
m.Name.Contains("Handler", StringComparison.OrdinalIgnoreCase) ||
m.Name.Contains("Notif", StringComparison.OrdinalIgnoreCase) ||
m.Name.Contains("Item", StringComparison.OrdinalIgnoreCase));
hasHandlerStorage.ShouldBeTrue(
"RemoteSubscription must expose the per-item handler reference (or the MonitoredItem itself) " +
"so UnsubscribeAsync/ShutdownAsync can detach the Notification delegate before disposing the session.");
}
[Fact]
public void RemoteAlarmSubscription_record_carries_handler_delegate_so_it_can_be_detached()
{
var driverType = typeof(OpcUaClientDriver);
var remoteAlarmSubType = driverType.GetNestedType("RemoteAlarmSubscription", BindingFlags.NonPublic);
remoteAlarmSubType.ShouldNotBeNull("RemoteAlarmSubscription record should exist");
var members = remoteAlarmSubType.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var hasHandlerStorage = members.Any(m =>
m.Name.Contains("Handler", StringComparison.OrdinalIgnoreCase) ||
m.Name.Contains("Notif", StringComparison.OrdinalIgnoreCase) ||
m.Name.Contains("EventItem", StringComparison.OrdinalIgnoreCase) ||
m.Name.Contains("Item", StringComparison.OrdinalIgnoreCase));
hasHandlerStorage.ShouldBeTrue(
"RemoteAlarmSubscription must expose the event-MonitoredItem (or its handler) reference " +
"so UnsubscribeAlarmsAsync/ShutdownAsync can detach the Notification delegate before " +
"disposing the session.");
}
[Fact]
public async Task UnsubscribeAsync_unknown_handle_does_not_throw_after_fix()
{
// Smoke: confirm the new detach logic doesn't break the existing unknown-handle
// no-op path (UnsubscribeAsync returns cleanly without an entry in _subscriptions).
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-low-014");
await Should.NotThrowAsync(async () =>
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken));
}
[Fact]
public async Task UnsubscribeAlarmsAsync_unknown_handle_does_not_throw_after_fix()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-low-014-alarm");
await Should.NotThrowAsync(async () =>
await drv.UnsubscribeAlarmsAsync(new FakeAlarmHandle(), TestContext.Current.CancellationToken));
}
private sealed class FakeHandle : Core.Abstractions.ISubscriptionHandle
{
public string DiagnosticId => "fake-sub";
}
private sealed class FakeAlarmHandle : Core.Abstractions.IAlarmSubscriptionHandle
{
public string DiagnosticId => "fake-alarm-sub";
}
}