Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc76249a69 |
@@ -17,11 +17,21 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// automatically during <see cref="DiagnosticsNodeManager"/> initialization. Writes are
|
||||
/// guarded by <see cref="IServerContext.UpdateServerDiagnostics"/> so concurrent diagnostics scans
|
||||
/// from the SDK don't fight with our update.
|
||||
/// <para>
|
||||
/// SDK 2.0 (lmxopcua#801): the server now recomputes <c>Server.ServiceLevel</c> 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 <see cref="IServerServiceLevelControl.ClaimServiceLevelControl"/>, 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
||||
{
|
||||
private readonly IServerInternal _serverInternal;
|
||||
private readonly ILogger<SdkServiceLevelPublisher> _logger;
|
||||
private readonly Action<byte>? _claimedWriter;
|
||||
|
||||
/// <summary>Initializes a new instance of the SdkServiceLevelPublisher class.</summary>
|
||||
/// <param name="serverInternal">The OPC UA server internal interface.</param>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
|
||||
+81
@@ -68,4 +68,85 @@ public sealed class ServiceLevelBindingReplayTests
|
||||
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<OpcUaApplicationHost>.Instance);
|
||||
}, () => new OtOpcUaSdkServer(), ct);
|
||||
await using var host = started.Host;
|
||||
var publisher = new SdkServiceLevelPublisher(started.Server.CurrentInstance,
|
||||
NullLogger<SdkServiceLevelPublisher>.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<Opc.Ua.Client.ISession> 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<byte> 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()!;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user