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 a2aa76a7..e71ee3e5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
@@ -558,10 +558,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 dfd1de73..05db8ecd 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -970,10 +970,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));
@@ -981,6 +981,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()