fix(dcl): async OPC UA endpoint discovery bounded by OperationTimeoutMs/verify budget; adapter connect never runs on the actor thread (review 2026-10-21 02/P2)
This commit is contained in:
+36
@@ -126,4 +126,40 @@ public class DataConnectionHousekeepingTests : TestKit
|
||||
&& message.Contains("established", StringComparison.OrdinalIgnoreCase)),
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 02/P2: an adapter whose connect does synchronous work before its first yielding await
|
||||
/// (OPC UA discovery used to) must not park the actor's dispatcher thread — the actor keeps
|
||||
/// answering while the connect is in progress.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AdapterConnect_NeverRunsOnTheActorThread()
|
||||
{
|
||||
const string name = "connect-off-actor-thread";
|
||||
var adapter = Substitute.For<IDataConnection>();
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ =>
|
||||
{
|
||||
release.Wait(TimeSpan.FromSeconds(10)); // synchronous I/O before any await
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
adapter.Status.Returns(ConnectionHealth.Connecting);
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
name, adapter, Options(), _health, _factory, "OpcUa")), name);
|
||||
AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == nameof(IDataConnection.ConnectAsync)),
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
try
|
||||
{
|
||||
var report = await actor.Ask<DataConnectionHealthReport>(
|
||||
new DataConnectionActor.GetHealthReport(), TimeSpan.FromSeconds(2));
|
||||
Assert.Equal(name, report.ConnectionName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -8,19 +8,19 @@ public class OpcUaSecurityDowngradeTests
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void DC4_RequestedProtectionNeverFallsBackToNone(bool discoveryFails)
|
||||
public async Task DC4_RequestedProtectionNeverFallsBackToNone(bool discoveryFails)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => RealOpcUaClient.DiscoverEndpoint("opc.tcp://test:4840",
|
||||
MessageSecurityMode.SignAndEncrypt, () => discoveryFails
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => RealOpcUaClient.DiscoverEndpointAsync("opc.tcp://test:4840",
|
||||
MessageSecurityMode.SignAndEncrypt, _ => discoveryFails
|
||||
? throw new Exception("discovery unavailable")
|
||||
: new EndpointDescriptionCollection { new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None } }));
|
||||
: Task.FromResult(new EndpointDescriptionCollection { new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None } })));
|
||||
}
|
||||
/// <summary>Verifies that DC4: exact requested mode is selected.</summary>
|
||||
[Fact]
|
||||
public void DC4_ExactRequestedModeIsSelected()
|
||||
public async Task DC4_ExactRequestedModeIsSelected()
|
||||
{
|
||||
var encrypted = new EndpointDescription("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.SignAndEncrypt };
|
||||
Assert.Same(encrypted, RealOpcUaClient.DiscoverEndpoint("opc.tcp://test:4840", MessageSecurityMode.SignAndEncrypt,
|
||||
() => new EndpointDescriptionCollection { new("opc.tcp://test:4840"), encrypted }));
|
||||
Assert.Same(encrypted, await RealOpcUaClient.DiscoverEndpointAsync("opc.tcp://test:4840", MessageSecurityMode.SignAndEncrypt,
|
||||
_ => Task.FromResult(new EndpointDescriptionCollection { new("opc.tcp://test:4840"), encrypted })));
|
||||
}
|
||||
}
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 02/P2: OPC UA endpoint discovery ran SYNCHRONOUSLY on a discovery client
|
||||
/// built with the SDK's default configuration, so neither the connection's
|
||||
/// <c>OperationTimeoutMs</c> nor the verify probe's budget bounded it — a black-holed endpoint held
|
||||
/// the caller for the SDK's default operation timeout instead.
|
||||
/// </summary>
|
||||
public class RealOpcUaDiscoveryBoundTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A listener that accepts the TCP connection and then never speaks — the protocol-level
|
||||
/// black hole. The verify probe must give up within (about) its own budget.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Verify_AgainstABlackHoledEndpoint_ReturnsWithinItsBudget()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var held = new List<TcpClient>();
|
||||
using var stop = new CancellationTokenSource();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
held.Add(await listener.AcceptTcpClientAsync(stop.Token));
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = $"opc.tcp://127.0.0.1:{port}",
|
||||
OperationTimeoutMs = 60_000,
|
||||
};
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var verify = RealOpcUaClient.VerifyEndpointAsync(
|
||||
config, new OpcUaGlobalOptions(), NullLogger.Instance, TimeSpan.FromSeconds(2), CancellationToken.None);
|
||||
var result = await verify.WaitAsync(TimeSpan.FromSeconds(20));
|
||||
sw.Stop();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10), $"verify took {sw.Elapsed}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop.Cancel();
|
||||
listener.Stop();
|
||||
foreach (var c in held) c.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancellation of discovery is never mistaken for "discovery unavailable": the unsecured
|
||||
/// fallback endpoint is only for a genuine discovery failure, not for an abandoned attempt.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CancelledDiscovery_IsNotTreatedAsADiscoveryFailure()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => RealOpcUaClient.DiscoverEndpointAsync(
|
||||
"opc.tcp://test:4840", Opc.Ua.MessageSecurityMode.None,
|
||||
async ct =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return new Opc.Ua.EndpointDescriptionCollection();
|
||||
},
|
||||
cts.Token));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user