fix(opcuaclient): don't count idle-session BadNoSubscription as a publish error (lmxopcua#764)
v2-ci / unit-tests (push) Has been cancelled

The SDK keeps sending publish requests on a session that holds no subscription,
and the server answers each with an anonymous (id 0) BadNoSubscription. With
nothing tracked there is nothing to lose, but every answer was counted on
publish_error, so the counter climbed on any idle driver. HandlePublishError now
returns before counting for an anonymous BadNoSubscription while neither the data
nor the alarm subscription set tracks anything. Any other PublishError, and an
anonymous one while something is tracked, is counted and handled as before.

Not addressed from #764: the Republish/state probe for a delete slower than the
5 s grace, and the #728 wire test's fixed delays.

Tests: OpcUaClientIdlePublishErrorTests (idle session: not counted, with a
BadTooManyPublishRequests positive control; tracked subscription: still counted).
The idle test fails without the fix. Driver.OpcUaClient.Tests 295/0.
Docs: drivers/OpcUaClient.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 13:47:14 -05:00
parent d59e76dd9d
commit 90db63cfaf
3 changed files with 85 additions and 2 deletions
@@ -0,0 +1,70 @@
using System.Text.Json;
using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Gitea #764 — the SDK keeps sending publish requests on a session that holds no subscription, and the server
/// answers each with an anonymous (id 0) <c>BadNoSubscription</c>. With nothing tracked there is nothing to lose,
/// so those answers must not inflate the <c>publish_error</c> counter. Any other PublishError still counts.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientIdlePublishErrorTests
{
private const string SpeedRef = "Plant/dev/Speed";
private const string AlarmRef = "Plant/dev/TempHi";
private static readonly IReadOnlyList<RawTagEntry> Tags =
[
new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true),
];
private static OpcUaClientDriver NewConnectedDriver(string instance)
{
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, instance);
var session = new Mock<ISession>(MockBehavior.Loose);
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
drv.SetSessionForTest(session.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
return drv;
}
[Fact]
public async Task An_anonymous_BadNoSubscription_on_an_idle_session_is_not_counted()
{
var instance = $"opcua-764-{Guid.NewGuid():N}";
using var cap = new OpcUaClientDiagnosticsTests.Capture(instance);
var drv = NewConnectedDriver(instance);
await using var _ = drv;
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(0, "idle-session publish answers are not errors");
// Positive control: any other publish error on the same idle driver still counts.
drv.RaisePublishErrorForTest(StatusCodes.BadTooManyPublishRequests);
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task An_anonymous_BadNoSubscription_with_a_tracked_subscription_is_still_counted()
{
var instance = $"opcua-764-{Guid.NewGuid():N}";
using var cap = new OpcUaClientDiagnosticsTests.Capture(instance);
var drv = NewConnectedDriver(instance);
await using var _ = drv;
drv.AddAlarmSubscriptionForTest([AlarmRef]);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
}
}