Merge ServiceLevel replay fixes for freshness integration
This commit is contained in:
@@ -1,21 +1,38 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference
|
/// Late-binding adapter that accepts and retains the latest requested ServiceLevel until
|
||||||
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve
|
/// the SDK publisher is available. Binding synchronously replays that request, including zero.
|
||||||
/// the publisher at DI time, but the production <c>SdkServiceLevelPublisher</c> only exists
|
|
||||||
/// after <c>StandardServer.Start</c>. The Host's hosted service swaps the inner once the SDK
|
|
||||||
/// is up; until then writes route through <see cref="NullServiceLevelPublisher"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
||||||
{
|
{
|
||||||
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
|
private readonly object _gate = new();
|
||||||
|
private IServiceLevelPublisher? _inner;
|
||||||
|
private byte? _latest;
|
||||||
|
|
||||||
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
|
/// <summary>Bind and replay the latest request, or unbind while retaining that request.</summary>
|
||||||
/// <param name="inner">The publisher implementation to use, or null to use the null publisher.</param>
|
/// <param name="inner">The publisher implementation to use, or null to unbind.</param>
|
||||||
public void SetInner(IServiceLevelPublisher? inner) =>
|
public void SetInner(IServiceLevelPublisher? inner)
|
||||||
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
if (_latest is { } latest) _inner?.Publish(latest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
public void Publish(byte serviceLevel)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_latest = serviceLevel;
|
||||||
|
_inner?.Publish(serviceLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>Coordinates endpoint cleanup after ServiceLevel binding fails during startup.</summary>
|
||||||
|
internal static class FailedServiceLevelBindingCleanup
|
||||||
|
{
|
||||||
|
/// <summary>Stops and disposes the endpoint, returning whether ownership can be released.</summary>
|
||||||
|
/// <param name="stop">The SDK stop operation.</param>
|
||||||
|
/// <param name="dispose">The application-host disposal operation.</param>
|
||||||
|
/// <param name="logger">The cleanup logger.</param>
|
||||||
|
/// <returns>Whether cleanup completed successfully.</returns>
|
||||||
|
internal static async Task<bool> StopAndDisposeAsync(
|
||||||
|
Func<Task> stop, Func<ValueTask> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -339,11 +339,36 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
_server.NodeManager.Clock = historyUpdate.Clock;
|
_server.NodeManager.Clock = historyUpdate.Clock;
|
||||||
|
|
||||||
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
||||||
if (_server.CurrentInstance is { } serverInternal)
|
try
|
||||||
{
|
{
|
||||||
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
|
if (_server.CurrentInstance is { } serverInternal)
|
||||||
serverInternal,
|
{
|
||||||
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
|
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
|
||||||
|
serverInternal,
|
||||||
|
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
// arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is
|
||||||
|
|||||||
@@ -560,10 +560,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
|
try
|
||||||
{
|
{
|
||||||
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
|
// An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
|
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;
|
_impersonateHandler = null;
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
|||||||
var node = _serverInternal.ServerObject?.ServiceLevel;
|
var node = _serverInternal.ServerObject?.ServiceLevel;
|
||||||
if (node is null)
|
if (node is null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write");
|
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable");
|
||||||
return;
|
throw new InvalidOperationException("Server.ServiceLevel is unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -55,6 +55,7 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
|
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// built-in default (255 = full service) standing — a degraded node wrongly advertising 255.
|
||||||
if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return;
|
if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return;
|
||||||
_lastServiceLevel = msg.ServiceLevel;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_serviceLevel.Publish(msg.ServiceLevel);
|
_serviceLevel.Publish(msg.ServiceLevel);
|
||||||
|
_lastServiceLevel = msg.ServiceLevel;
|
||||||
_publishedAtLeastOnce = true;
|
_publishedAtLeastOnce = true;
|
||||||
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
|
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
|
||||||
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
|
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
|
||||||
@@ -990,6 +990,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
_log.Warning(ex, "OpcUaPublish: ServiceLevel publisher threw at level {Level}", msg.ServiceLevel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-5
@@ -10,10 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
|
|||||||
public class DeferredServiceLevelPublisherTests
|
public class DeferredServiceLevelPublisherTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Before_SetInner_Publish_is_a_noop()
|
public void Before_SetInner_Publish_accepts_a_cached_request()
|
||||||
{
|
{
|
||||||
var publisher = new DeferredServiceLevelPublisher();
|
var publisher = new DeferredServiceLevelPublisher();
|
||||||
// Must not throw; the NullServiceLevelPublisher absorbs it.
|
// Accept the request before an SDK publisher exists.
|
||||||
publisher.Publish(200);
|
publisher.Publish(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ public class DeferredServiceLevelPublisherTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetInner_null_reverts_to_null_publisher()
|
public void SetInner_null_detaches_the_previous_publisher()
|
||||||
{
|
{
|
||||||
var spy = new SpyPublisher();
|
var spy = new SpyPublisher();
|
||||||
var publisher = new DeferredServiceLevelPublisher();
|
var publisher = new DeferredServiceLevelPublisher();
|
||||||
@@ -39,8 +39,139 @@ public class DeferredServiceLevelPublisherTests
|
|||||||
|
|
||||||
publisher.Publish(100);
|
publisher.Publish(100);
|
||||||
|
|
||||||
// The spy should NOT have received the publish after revert.
|
// The previous publisher must not receive an unbound request.
|
||||||
spy.LastPublished.ShouldBe((byte)0, "spy should not have been updated after revert to null");
|
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<byte>();
|
||||||
|
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<byte>();
|
||||||
|
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<byte>();
|
||||||
|
publisher.SetInner(new CallbackPublisher(old.Add));
|
||||||
|
publisher.Publish(240);
|
||||||
|
publisher.SetInner(null);
|
||||||
|
publisher.Publish(0);
|
||||||
|
var replacement = new List<byte>();
|
||||||
|
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<InvalidOperationException>(() => publisher.Publish(0));
|
||||||
|
var received = new List<byte>();
|
||||||
|
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<byte>();
|
||||||
|
var candidate = new CallbackPublisher(value =>
|
||||||
|
{
|
||||||
|
if (fail) throw new InvalidOperationException();
|
||||||
|
received.Add(value);
|
||||||
|
});
|
||||||
|
Should.Throw<InvalidOperationException>(() => 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<InvalidOperationException>(() => publisher.SetInner(
|
||||||
|
new CallbackPublisher(_ => throw new InvalidOperationException())));
|
||||||
|
var received = new List<byte>();
|
||||||
|
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<byte>();
|
||||||
|
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<byte> publish) : IServiceLevelPublisher
|
||||||
|
{
|
||||||
|
public void Publish(byte serviceLevel) => publish(serviceLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- test double ----
|
// ---- test double ----
|
||||||
|
|||||||
+45
@@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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<IHostedService>(service);
|
||||||
|
services.AddSingleton<IHostedService>(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<SocketException>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Local loopback evidence that late binding replaces the SDK default on the wire.</summary>
|
||||||
|
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<OpcUaApplicationHost>.Instance);
|
||||||
|
}, () => new StandardServer(), ct);
|
||||||
|
await using var host = started.Host;
|
||||||
|
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
|
||||||
|
NullLogger<SdkServiceLevelPublisher>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|||||||
|
|
||||||
public sealed class DeferredServiceLevelPublisherTests
|
public sealed class DeferredServiceLevelPublisherTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that publish before SetInner is a safe no-op.</summary>
|
/// <summary>Verifies that publish before SetInner safely accepts a cached request.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_before_SetInner_is_a_safe_noop()
|
public void Publish_before_SetInner_accepts_a_cached_request()
|
||||||
{
|
{
|
||||||
var deferred = new DeferredServiceLevelPublisher();
|
var deferred = new DeferredServiceLevelPublisher();
|
||||||
|
|
||||||
@@ -28,9 +28,9 @@ public sealed class DeferredServiceLevelPublisherTests
|
|||||||
recording.LastValue.ShouldBe((byte)200);
|
recording.LastValue.ShouldBe((byte)200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that SetInner with null reverts to the null publisher.</summary>
|
/// <summary>Verifies that SetInner with null detaches the previous publisher.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetInner_null_reverts_to_Null_publisher()
|
public void SetInner_null_detaches_the_previous_publisher()
|
||||||
{
|
{
|
||||||
var recording = new RecordingPublisher();
|
var recording = new RecordingPublisher();
|
||||||
var deferred = new DeferredServiceLevelPublisher();
|
var deferred = new DeferredServiceLevelPublisher();
|
||||||
|
|||||||
@@ -19,6 +19,40 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
|
|||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
$"otopcua-pki-{Guid.NewGuid():N}");
|
$"otopcua-pki-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_ServiceLevel_is_reported_as_failure()
|
||||||
|
{
|
||||||
|
var server = System.Reflection.DispatchProxy.Create<IServerInternal, FaultingServer>();
|
||||||
|
var publisher = new SdkServiceLevelPublisher(server, NullLogger<SdkServiceLevelPublisher>.Instance);
|
||||||
|
Should.Throw<InvalidOperationException>(() => publisher.Publish(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sdk_write_failure_propagates_the_original_exception()
|
||||||
|
{
|
||||||
|
var server = System.Reflection.DispatchProxy.Create<IServerInternal, FaultingServer>();
|
||||||
|
var proxy = (FaultingServer)(object)server;
|
||||||
|
proxy.Node = new Opc.Ua.ServerObjectState(null)
|
||||||
|
{
|
||||||
|
ServiceLevel = new Opc.Ua.PropertyState<byte>(null),
|
||||||
|
};
|
||||||
|
proxy.Fault = new InvalidOperationException("controlled diagnostics failure");
|
||||||
|
var publisher = new SdkServiceLevelPublisher(server, NullLogger<SdkServiceLevelPublisher>.Instance);
|
||||||
|
Should.Throw<InvalidOperationException>(() => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the publisher writes values to the standard Server.ServiceLevel variable.</summary>
|
/// <summary>Verifies that the publisher writes values to the standard Server.ServiceLevel variable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
|
public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
|
||||||
|
|||||||
@@ -15,6 +15,49 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
|||||||
|
|
||||||
public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
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>(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>(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<byte> Delivered { get; } = new();
|
||||||
|
public void Publish(byte level)
|
||||||
|
{
|
||||||
|
if (Fail) throw new InvalidOperationException("controlled failure");
|
||||||
|
Delivered.Add(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
|
/// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
|
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
|
||||||
|
|||||||
Reference in New Issue
Block a user