diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
index 0ea04b889..ae482fcf1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
@@ -17,11 +17,21 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// automatically during initialization. Writes are
/// guarded by so concurrent diagnostics scans
/// from the SDK don't fight with our update.
+///
+/// SDK 2.0 (lmxopcua#801): the server now recomputes Server.ServiceLevel from session headroom on
+/// every session create/close (255 when lightly loaded) and overwrites any value ≥ 200 — so a Primary's
+/// 250, a Secondary's 240 and the Stale tier's 200 all read back as 255 after the next client connects,
+/// and both halves of a pair look identical. The SDK stops doing that only once a provider claims the
+/// value through , so this publisher
+/// claims it at construction and writes through the returned writer. A server that does not offer the
+/// claim (test doubles) keeps the direct diagnostics-guarded write.
+///
///
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
{
private readonly IServerInternal _serverInternal;
private readonly ILogger _logger;
+ private readonly Action? _claimedWriter;
/// Initializes a new instance of the SdkServiceLevelPublisher class.
/// The OPC UA server internal interface.
@@ -30,11 +40,30 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
{
_serverInternal = serverInternal;
_logger = logger;
+ // Claim ownership so the SDK's session-headroom calculator stops overwriting the redundancy value
+ // (lmxopcua#801). Throws if the server has no ServiceLevel or another provider already claimed it —
+ // the hosted service's binding catch turns that into a failed endpoint rather than a silent 255.
+ if (serverInternal is IServerServiceLevelControl control)
+ _claimedWriter = control.ClaimServiceLevelControl();
}
///
public void Publish(byte serviceLevel)
{
+ if (_claimedWriter is not null)
+ {
+ try
+ {
+ _claimedWriter(serviceLevel);
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
+ throw;
+ }
+ }
+
var node = _serverInternal.ServerObject?.ServiceLevel;
if (node is null)
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs
index e5fae2cc4..74beac046 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs
@@ -68,4 +68,85 @@ public sealed class ServiceLevelBindingReplayTests
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
}
}
+
+ ///
+ /// lmxopcua#801: SDK 2.0 recomputes Server.ServiceLevel from session headroom on every session create/close
+ /// and overwrites any value >= 200 unless a provider has claimed it. On the docker-dev pair that turned the
+ /// Primary's 250 and the Secondary's 240 into 255 on both nodes as soon as a client connected. A level published
+ /// BEFORE a session opens and one published while a session is open must both survive later sessions opening
+ /// and closing.
+ ///
+ [Theory]
+ [InlineData((byte)250)]
+ [InlineData((byte)240)]
+ [InlineData((byte)200)]
+ public async Task Published_level_survives_later_sessions(byte level)
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ deadline.CancelAfter(TimeSpan.FromSeconds(30));
+ var ct = deadline.Token;
+ var pki = Path.Combine(Path.GetTempPath(), $"otopcua-claim-{Guid.NewGuid():N}");
+ var clientPki = TestClientSecurity.AllocatePkiRoot();
+ var port = 0;
+ try
+ {
+ var started = await OpcUaHostStart.StartAsync(p =>
+ {
+ port = p;
+ return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.Claim",
+ ApplicationUri = $"urn:OtOpcUa.Claim:{Path.GetFileName(pki)}",
+ OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
+ }, NullLogger.Instance);
+ }, () => new OtOpcUaSdkServer(), ct);
+ await using var host = started.Host;
+ var publisher = new SdkServiceLevelPublisher(started.Server.CurrentInstance,
+ NullLogger.Instance);
+ publisher.Publish(level);
+ var url = $"opc.tcp://localhost:{port}/OtOpcUa";
+
+ using (var first = await OpenAsync(url, clientPki, ct))
+ (await ReadServiceLevelAsync(first, ct)).ShouldBe(level, "the first session opening must not displace the published level");
+
+ using var second = await OpenAsync(url, clientPki, ct);
+ (await ReadServiceLevelAsync(second, ct)).ShouldBe(level, "a session closing then another opening must not displace it");
+
+ publisher.Publish((byte)(level - 10));
+ using var third = await OpenAsync(url, clientPki, ct);
+ (await ReadServiceLevelAsync(third, ct)).ShouldBe((byte)(level - 10), "a level published while a session is open must survive the next one");
+ }
+ finally
+ {
+ if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
+ if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
+ }
+ }
+
+ private static async Task OpenAsync(string url, string clientPki, CancellationToken ct)
+ {
+ var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
+ {
+ ApplicationName = "OtOpcUa.ClaimClient",
+ ApplicationUri = $"urn:OtOpcUa.ClaimClient:{Guid.NewGuid():N}",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = TestClientSecurity.Build(clientPki),
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 },
+ TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
+ };
+ await config.ValidateAsync(ApplicationType.Client, ct);
+ var endpoint = await CoreClientUtils.SelectEndpointAsync(config, url, false, DefaultTelemetry.Create(_ => { }), ct);
+ return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
+ config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)),
+ updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelClaim",
+ sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
+ preferredLocales: default, ct: ct);
+ }
+
+ private static async Task ReadServiceLevelAsync(Opc.Ua.Client.ISession session, CancellationToken ct)
+ {
+ var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
+ read.StatusCode.ShouldBe(StatusCodes.Good);
+ return (byte)read.ClrValue()!;
+ }
}