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()!; + } }