diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs index f567d9ec..f05e354a 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs @@ -1,21 +1,38 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// -/// Late-binding adapter that holds an inner reference -/// swappable at runtime. Mirrors : Akka actors resolve -/// the publisher at DI time, but the production SdkServiceLevelPublisher only exists -/// after StandardServer.Start. The Host's hosted service swaps the inner once the SDK -/// is up; until then writes route through . +/// Late-binding adapter that accepts and retains the latest requested ServiceLevel until +/// the SDK publisher is available. Binding synchronously replays that request, including zero. /// +/// +/// Publishing and binding are serialized through the synchronous inner call. Inner publishers +/// must not call back into this adapter. Acceptance while unbound is not an SDK write confirmation. +/// A failed call retains both the requested level and the installed publisher for later recovery. +/// public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher { - private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance; + private readonly object _gate = new(); + private IServiceLevelPublisher? _inner; + private byte? _latest; - /// Swap the underlying publisher. Pass null to revert to the Null no-op. - /// The publisher implementation to use, or null to use the null publisher. - public void SetInner(IServiceLevelPublisher? inner) => - _inner = inner ?? NullServiceLevelPublisher.Instance; + /// Bind and replay the latest request, or unbind while retaining that request. + /// The publisher implementation to use, or null to unbind. + public void SetInner(IServiceLevelPublisher? inner) + { + lock (_gate) + { + _inner = inner; + if (_latest is { } latest) _inner?.Publish(latest); + } + } /// - public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel); + public void Publish(byte serviceLevel) + { + lock (_gate) + { + _latest = serviceLevel; + _inner?.Publish(serviceLevel); + } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/FailedServiceLevelBindingCleanup.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/FailedServiceLevelBindingCleanup.cs new file mode 100644 index 00000000..71ef2e61 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/FailedServiceLevelBindingCleanup.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// Coordinates endpoint cleanup after ServiceLevel binding fails during startup. +internal static class FailedServiceLevelBindingCleanup +{ + /// Stops and disposes the endpoint, returning whether ownership can be released. + /// The SDK stop operation. + /// The application-host disposal operation. + /// The cleanup logger. + /// Whether cleanup completed successfully. + internal static async Task StopAndDisposeAsync( + Func stop, Func dispose, ILogger logger) + { + var completed = true; + try + { + await stop().ConfigureAwait(false); + } + catch (Exception ex) + { + completed = false; + logger.LogError(ex, "OtOpcUaServerHostedService: SDK stop failed after binding failure; retaining endpoint ownership for shutdown retry"); + } + // Attempt application cleanup even when stopping faults; keep ownership on either failure. + try + { + await dispose().ConfigureAwait(false); + } + catch (Exception ex) + { + completed = false; + logger.LogError(ex, "OtOpcUaServerHostedService: endpoint disposal failed after binding failure; retaining endpoint ownership for shutdown retry"); + } + return completed; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index 5a2362d5..209370ce 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -339,11 +339,36 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _server.NodeManager.Clock = historyUpdate.Clock; // ServiceLevel publisher needs IServerInternal — only available after Start. - if (_server.CurrentInstance is { } serverInternal) + try { - _deferredServiceLevel.SetInner(new SdkServiceLevelPublisher( - serverInternal, - _loggerFactory.CreateLogger())); + if (_server.CurrentInstance is { } serverInternal) + { + _deferredServiceLevel.SetInner(new SdkServiceLevelPublisher( + serverInternal, + _loggerFactory.CreateLogger())); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "OtOpcUaServerHostedService: ServiceLevel binding failed; stopping the OPC UA endpoint while the admin host continues"); + try + { + await StopAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception cleanupError) + { + _logger.LogError(cleanupError, "OtOpcUaServerHostedService: failed to detach OPC UA bindings after startup failure"); + } + var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync( + async () => await _server.StopAsync(CancellationToken.None).ConfigureAwait(false), + () => _appHost.DisposeAsync(), _logger).ConfigureAwait(false); + if (released) + { + _appHost = null; + _server = null; + } + return; } // arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index ebc7a6d8..13e19a15 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -560,10 +560,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { - if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager) + try { - try { sessionManager.ImpersonateUser -= _impersonateHandler; } - catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); } + // An explicit SDK stop may already have halted CurrentInstance; still continue disposal. + if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager) + sessionManager.ImpersonateUser -= _impersonateHandler; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); } _impersonateHandler = null; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs index e3d8f708..de984308 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs @@ -38,8 +38,8 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher var node = _serverInternal.ServerObject?.ServiceLevel; if (node is null) { - _logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write"); - return; + _logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable"); + throw new InvalidOperationException("Server.ServiceLevel is unavailable."); } try @@ -55,6 +55,7 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher catch (Exception ex) { _logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw"); + throw; } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index eb28bdf7..52e0a57c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -979,10 +979,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // node starting Detached/role-less (first level = 0) would be dedup'd away, leaving the SDK's // built-in default (255 = full service) standing — a degraded node wrongly advertising 255. if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return; - _lastServiceLevel = msg.ServiceLevel; try { _serviceLevel.Publish(msg.ServiceLevel); + _lastServiceLevel = msg.ServiceLevel; _publishedAtLeastOnce = true; OtOpcUaTelemetry.ServiceLevelChange.Add(1, new KeyValuePair("level", msg.ServiceLevel)); @@ -990,6 +990,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } catch (Exception ex) { + // A failed write may be partially applied; even a previous successful value must retry. + _publishedAtLeastOnce = false; _log.Warning(ex, "OpcUaPublish: ServiceLevel publisher threw at level {Level}", msg.ServiceLevel); } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredServiceLevelPublisherTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredServiceLevelPublisherTests.cs index b5b5dfe2..242496cf 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredServiceLevelPublisherTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredServiceLevelPublisherTests.cs @@ -10,10 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa; public class DeferredServiceLevelPublisherTests { [Fact] - public void Before_SetInner_Publish_is_a_noop() + public void Before_SetInner_Publish_accepts_a_cached_request() { var publisher = new DeferredServiceLevelPublisher(); - // Must not throw; the NullServiceLevelPublisher absorbs it. + // Accept the request before an SDK publisher exists. publisher.Publish(200); } @@ -30,7 +30,7 @@ public class DeferredServiceLevelPublisherTests } [Fact] - public void SetInner_null_reverts_to_null_publisher() + public void SetInner_null_detaches_the_previous_publisher() { var spy = new SpyPublisher(); var publisher = new DeferredServiceLevelPublisher(); @@ -39,8 +39,139 @@ public class DeferredServiceLevelPublisherTests publisher.Publish(100); - // The spy should NOT have received the publish after revert. - spy.LastPublished.ShouldBe((byte)0, "spy should not have been updated after revert to null"); + // The previous publisher must not receive an unbound request. + spy.LastPublished.ShouldBe((byte)0, "the previous publisher should not be updated after unbinding"); + } + + [Fact] + public void Binding_without_a_request_does_not_invent_a_level() + { + var received = new List(); + new DeferredServiceLevelPublisher().SetInner(new CallbackPublisher(received.Add)); + received.ShouldBeEmpty(); + } + + [Theory] + [InlineData((byte)100)] + [InlineData((byte)0)] + public void Binding_replays_the_latest_request_including_zero(byte latest) + { + var publisher = new DeferredServiceLevelPublisher(); + publisher.Publish(240); + publisher.Publish(latest); + var received = new List(); + publisher.SetInner(new CallbackPublisher(received.Add)); + received.ShouldBe(new[] { latest }); + } + + [Fact] + public void Unbinding_retains_latest_for_rebinding() + { + var publisher = new DeferredServiceLevelPublisher(); + var old = new List(); + publisher.SetInner(new CallbackPublisher(old.Add)); + publisher.Publish(240); + publisher.SetInner(null); + publisher.Publish(0); + var replacement = new List(); + publisher.SetInner(new CallbackPublisher(replacement.Add)); + old.ShouldBe(new byte[] { 240 }); + replacement.ShouldBe(new byte[] { 0 }); + } + + [Fact] + public void Failed_publish_retains_the_requested_value_for_replay() + { + var publisher = new DeferredServiceLevelPublisher(); + publisher.SetInner(new CallbackPublisher(_ => throw new InvalidOperationException())); + Should.Throw(() => publisher.Publish(0)); + var received = new List(); + publisher.SetInner(new CallbackPublisher(received.Add)); + received.ShouldBe(new byte[] { 0 }); + } + + [Fact] + public void Failed_replay_keeps_candidate_bound_for_later_publish() + { + var publisher = new DeferredServiceLevelPublisher(); + publisher.Publish(100); + var fail = true; + var received = new List(); + var candidate = new CallbackPublisher(value => + { + if (fail) throw new InvalidOperationException(); + received.Add(value); + }); + Should.Throw(() => publisher.SetInner(candidate)); + fail = false; + publisher.Publish(0); + publisher.SetInner(null); + publisher.SetInner(candidate); + received.ShouldBe(new byte[] { 0, 0 }); + } + + [Fact] + public void Failed_replay_retains_latest_for_a_replacement_publisher() + { + var publisher = new DeferredServiceLevelPublisher(); + publisher.Publish(100); + Should.Throw(() => publisher.SetInner( + new CallbackPublisher(_ => throw new InvalidOperationException()))); + var received = new List(); + publisher.SetInner(new CallbackPublisher(received.Add)); + received.ShouldBe(new byte[] { 100 }); + } + + [Fact] + public async Task Binding_waits_for_inflight_publish_then_replays_latest() + { + var publisher = new DeferredServiceLevelPublisher(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + publisher.SetInner(new CallbackPublisher(_ => + { + entered.Set(); + release.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue(); + })); + var publish = Task.Run(() => publisher.Publish(100), TestContext.Current.CancellationToken); + var received = new List(); + using var bindingStarted = new ManualResetEventSlim(); + Exception? bindingError = null; + var binding = new Thread(() => + { + bindingStarted.Set(); + try { publisher.SetInner(new CallbackPublisher(received.Add)); } + catch (Exception ex) { bindingError = ex; } + }); + var bindingWasStarted = false; + try + { + entered.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue(); + binding.Start(); + bindingWasStarted = true; + bindingStarted.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue(); + // Past the marker, SetInner is the thread's only potentially blocking operation. + // Observe actual contention rather than assuming Task.Run has entered the adapter. + SpinWait.SpinUntil(() => !binding.IsAlive || + (binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(3)).ShouldBeTrue(); + (binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin).ShouldNotBe( + (System.Threading.ThreadState)0, "binding must wait for the in-flight delivery"); + } + finally + { + release.Set(); + if (bindingWasStarted) binding.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue(); + await publish.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + } + bindingError.ShouldBeNull(); + publisher.Publish(0); + received.ShouldBe(new byte[] { 100, 0 }); + } + + private sealed class CallbackPublisher(Action publish) : IServiceLevelPublisher + { + public void Publish(byte serviceLevel) => publish(serviceLevel); } // ---- test double ---- diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/FailedServiceLevelBindingCleanupTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/FailedServiceLevelBindingCleanupTests.cs new file mode 100644 index 00000000..958a70df --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/FailedServiceLevelBindingCleanupTests.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; + +public sealed class FailedServiceLevelBindingCleanupTests +{ + [Fact] + public async Task Successful_cleanup_observes_stop_before_disposal_and_releases_ownership() + { + var events = new List(); + var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync( + () => { events.Add("stop"); return Task.CompletedTask; }, + () => { events.Add("dispose"); return ValueTask.CompletedTask; }, + NullLogger.Instance); + events.ShouldBe(new[] { "stop", "dispose" }); + released.ShouldBeTrue(); + } + + [Fact] + public async Task Failed_stop_still_disposes_and_retains_ownership_for_retry() + { + var events = new List(); + var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync( + () => { events.Add("stop"); throw new InvalidOperationException("controlled stop failure"); }, + () => { events.Add("dispose"); return ValueTask.CompletedTask; }, + NullLogger.Instance); + events.ShouldBe(new[] { "stop", "dispose" }); + released.ShouldBeFalse(); + } + + [Fact] + public async Task Failed_disposal_retains_ownership_without_escaping_the_binding_failure_handler() + { + var events = new List(); + var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync( + () => { events.Add("stop"); return Task.CompletedTask; }, + () => { events.Add("dispose"); throw new InvalidOperationException("controlled disposal failure"); }, + NullLogger.Instance); + events.ShouldBe(new[] { "stop", "dispose" }); + released.ShouldBeFalse(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceBindingFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceBindingFailureTests.cs new file mode 100644 index 00000000..f9fdf3e2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceBindingFailureTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Net.Sockets; +using Akka.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; + +public sealed class OtOpcUaServerHostedServiceBindingFailureTests +{ + [Fact] + public async Task Binding_failure_closes_started_endpoint_and_allows_other_host_services_to_start() + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + var pki = Path.Combine(Path.GetTempPath(), $"otopcua-binding-{Guid.NewGuid():N}"); + var deferred = new DeferredServiceLevelPublisher(); + deferred.Publish(0); + var bindingReached = false; + var listenerObserved = false; + OtOpcUaServerHostedService? service = null; + var factory = new FaultInjectingLoggerFactory(() => + { + using var connected = new TcpClient(); + connected.Connect(IPAddress.Loopback, port); + listenerObserved = connected.Connected; + bindingReached = true; + throw new InvalidOperationException("controlled binding factory failure"); + }); + service = new OtOpcUaServerHostedService( + Options.Create(new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.BindingFailure", + ApplicationUri = $"urn:OtOpcUa.BindingFailure:{Guid.NewGuid():N}", + OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = pki, + }), new DeferredAddressSpaceSink(), deferred, + NullOpcUaUserAuthenticator.Instance, NullHistorianDataSource.Instance, + EmptyDataPlaneAuthoritySource.Instance, new ConfigurationBuilder().Build(), + () => throw new NotSupportedException("no actor system required"), new ActorRegistry(), + null, Options.Create(new AclOptions()), TimeProvider.System, factory); + var admin = new AdminBootSentinel(); + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddSingleton(service); + services.AddSingleton(admin); + }).Build(); + try + { + await host.StartAsync(deadline.Token); + listenerObserved.ShouldBeTrue("the SDK endpoint was actually listening before replay"); + bindingReached.ShouldBeTrue(); + admin.Started.ShouldBeTrue(); + using var client = new TcpClient(); + await Should.ThrowAsync(async () => + await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token)); + // After construction failure, subsequent requests are accepted into the unbound cache. + deferred.Publish(100); + await host.StopAsync(deadline.Token); + } + finally + { + await service.DisposeAsync(); + if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true); + } + } + + private sealed class FaultInjectingLoggerFactory(Action installFault) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) + { + if (categoryName == typeof(SdkServiceLevelPublisher).FullName) installFault(); + return NullLogger.Instance; + } + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + } + + private sealed class AdminBootSentinel : IHostedService + { + public bool Started { get; private set; } + public Task StartAsync(CancellationToken cancellationToken) + { + Started = true; + return Task.CompletedTask; + } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs new file mode 100644 index 00000000..d93917d7 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// Local loopback evidence that late binding replaces the SDK default on the wire. +public sealed class ServiceLevelBindingReplayTests +{ + [Theory] + [InlineData((byte)100)] + [InlineData((byte)0)] + public async Task Late_binding_replays_latest_request_to_standard_ServiceLevel_read(byte latest) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(15)); + var ct = deadline.Token; + var pki = Path.Combine(Path.GetTempPath(), $"otopcua-replay-{Guid.NewGuid():N}"); + var clientPki = TestClientSecurity.AllocatePkiRoot(); + var port = 0; + try + { + var deferred = new DeferredServiceLevelPublisher(); + deferred.Publish(240); + deferred.Publish(latest); + var started = await OpcUaHostStart.StartAsync(p => + { + port = p; + return new OpcUaApplicationHost(new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Replay", + ApplicationUri = $"urn:OtOpcUa.Replay:{Path.GetFileName(pki)}", + OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki, + }, NullLogger.Instance); + }, () => new StandardServer(), ct); + await using var host = started.Host; + deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance, + NullLogger.Instance)); + var config = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.ReplayClient", + ApplicationUri = $"urn:OtOpcUa.ReplayClient:{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); + config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + var endpoint = await CoreClientUtils.SelectEndpointAsync(config, + $"opc.tcp://localhost:{port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct); + using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + config, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), + updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelReplay", + sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: null, ct: ct); + var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct); + read.StatusCode.ShouldBe(StatusCodes.Good); + read.Value.ShouldBe(latest); + } + finally + { + if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true); + if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs index 473124d9..5ad22ad8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs @@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class DeferredServiceLevelPublisherTests { - /// Verifies that publish before SetInner is a safe no-op. + /// Verifies that publish before SetInner safely accepts a cached request. [Fact] - public void Publish_before_SetInner_is_a_safe_noop() + public void Publish_before_SetInner_accepts_a_cached_request() { var deferred = new DeferredServiceLevelPublisher(); @@ -28,9 +28,9 @@ public sealed class DeferredServiceLevelPublisherTests recording.LastValue.ShouldBe((byte)200); } - /// Verifies that SetInner with null reverts to the null publisher. + /// Verifies that SetInner with null detaches the previous publisher. [Fact] - public void SetInner_null_reverts_to_Null_publisher() + public void SetInner_null_detaches_the_previous_publisher() { var recording = new RecordingPublisher(); var deferred = new DeferredServiceLevelPublisher(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs index ad1f216a..13fdaf85 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs @@ -19,6 +19,40 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}"); + [Fact] + public void Missing_ServiceLevel_is_reported_as_failure() + { + var server = System.Reflection.DispatchProxy.Create(); + var publisher = new SdkServiceLevelPublisher(server, NullLogger.Instance); + Should.Throw(() => publisher.Publish(0)); + } + + [Fact] + public void Sdk_write_failure_propagates_the_original_exception() + { + var server = System.Reflection.DispatchProxy.Create(); + var proxy = (FaultingServer)(object)server; + proxy.Node = new Opc.Ua.ServerObjectState(null) + { + ServiceLevel = new Opc.Ua.PropertyState(null), + }; + proxy.Fault = new InvalidOperationException("controlled diagnostics failure"); + var publisher = new SdkServiceLevelPublisher(server, NullLogger.Instance); + Should.Throw(() => publisher.Publish(0)).ShouldBeSameAs(proxy.Fault); + } + + public class FaultingServer : System.Reflection.DispatchProxy + { + public Opc.Ua.ServerObjectState? Node { get; set; } + public Exception? Fault { get; set; } + protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args) + { + if (method?.Name == "get_ServerObject") return Node; + if (method?.Name == "get_DiagnosticsLock") throw Fault!; + throw new NotSupportedException(method?.Name); + } + } + /// Verifies that the publisher writes values to the standard Server.ServiceLevel variable. [Fact] public async Task Publish_writes_value_to_Server_ServiceLevel_variable() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index 3da4d951..2b0d4b59 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -15,6 +15,49 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; public sealed class OpcUaPublishActorTests : RuntimeActorTestBase { + [Fact] + public void Failed_value_is_retried_and_last_level_only_tracks_success() + { + var sink = new RecoveringPublisher { Fail = false }; + var actor = ActorOfAsTestActorRef(OpcUaPublishActor.PropsForTests(serviceLevel: sink)); + actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); + sink.Fail = true; + actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0)); + actor.UnderlyingActor.LastServiceLevel.ShouldBe((byte)100); + sink.Fail = false; + actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0)); + sink.Delivered.ShouldBe(new byte[] { 100, 0 }); + } + + [Fact] + public void Requesting_prior_success_after_failure_updates_delivery_and_replay() + { + var sink = new RecoveringPublisher(); + var deferred = new DeferredServiceLevelPublisher(); + deferred.SetInner(sink); + var actor = ActorOfAsTestActorRef(OpcUaPublishActor.PropsForTests(serviceLevel: deferred)); + actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); + sink.Fail = true; + actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0)); + sink.Fail = false; + actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); + sink.Delivered.ShouldBe(new byte[] { 100, 100 }); + var rebound = new RecoveringPublisher(); + deferred.SetInner(rebound); + rebound.Delivered.ShouldBe(new byte[] { 100 }); + } + + private sealed class RecoveringPublisher : IServiceLevelPublisher + { + public bool Fail { get; set; } + public List Delivered { get; } = new(); + public void Publish(byte level) + { + if (Fail) throw new InvalidOperationException("controlled failure"); + Delivered.Add(level); + } + } + /// Verifies that message contracts are accepted without a pinned dispatcher in tests. [Fact] public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()