73132b7d78
Cause: the client-side PerEndpointConnectTimeout (3 s), not opc-plc. The validate:null case is the first test the assembly runs, so it pays the cold-process cost of the first Basic256Sha256 connect: loading the fresh throwaway certificate, validating the server's, and the asymmetric handshake. In the G2 full-solution run that local work used up the budget. The opc-plc log at 13:13:50-53Z shows the discovery channel closing at 50.709, then nothing from the client for 3 s, then OpenSecureChannel answered at 53.775 and the client closing the channel 48 ms later. The SDK reports a wait cancelled by that budget as BadRequestInterrupted. opc-plc auto-accepted the certificate (--aa, "Trusting certificate ... command line option") with no reject and no store round-trip. - Driver: a per-endpoint budget expiry now surfaces as TimeoutException naming PerEndpointConnectTimeout instead of BadRequestInterrupted or a bare TaskCanceledException. There is no behaviour change. Unit test added against a silent TCP listener. - Test: the ServerHostNameValidationTests secure options use a 15 s budget, the same as the browser integration tests' 10 s. Connect latency is not what these tests check. - docs/drivers/OpcUaClient.md: documents what the budget covers.
113 lines
4.9 KiB
C#
113 lines
4.9 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientFailoverTests
|
|
{
|
|
/// <summary>Verifies that ResolveEndpointCandidates prefers EndpointUrls when provided.</summary>
|
|
[Fact]
|
|
public void ResolveEndpointCandidates_prefers_EndpointUrls_when_provided()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://fallback:4840",
|
|
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
|
};
|
|
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
|
list.Count.ShouldBe(2);
|
|
list[0].ShouldBe("opc.tcp://primary:4840");
|
|
list[1].ShouldBe("opc.tcp://backup:4841");
|
|
}
|
|
|
|
/// <summary>Verifies that ResolveEndpointCandidates falls back to single EndpointUrl when list is empty.</summary>
|
|
[Fact]
|
|
public void ResolveEndpointCandidates_falls_back_to_single_EndpointUrl_when_list_empty()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:4840" };
|
|
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
|
list.Count.ShouldBe(1);
|
|
list[0].ShouldBe("opc.tcp://only:4840");
|
|
}
|
|
|
|
/// <summary>Verifies that an empty EndpointUrls list is treated as a fallback to EndpointUrl.</summary>
|
|
[Fact]
|
|
public void ResolveEndpointCandidates_empty_list_treated_as_fallback_to_EndpointUrl()
|
|
{
|
|
// Explicit empty list should still fall back to the single-URL shortcut rather than
|
|
// producing a zero-candidate sweep that would immediately throw with no URLs tried.
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://single:4840",
|
|
EndpointUrls = [],
|
|
};
|
|
OpcUaClientDriver.ResolveEndpointCandidates(opts).Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that HostName uses the first candidate before connection.</summary>
|
|
[Fact]
|
|
public void HostName_uses_first_candidate_before_connect()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-host");
|
|
drv.HostName.ShouldBe("opc.tcp://primary:4840",
|
|
"pre-connect the dashboard should show the first candidate URL so operators can link back");
|
|
}
|
|
|
|
/// <summary>Verifies that initializing against all unreachable endpoints throws AggregateException listing each.</summary>
|
|
[Fact]
|
|
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
|
|
{
|
|
// Port 1 + port 2 + port 3 on loopback are all guaranteed closed (TCP RST immediate).
|
|
// Failover sweep should attempt all three and throw AggregateException naming each URL
|
|
// so operators see exactly which candidates were tried.
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrls = ["opc.tcp://127.0.0.1:1", "opc.tcp://127.0.0.1:2", "opc.tcp://127.0.0.1:3"],
|
|
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(500),
|
|
Timeout = TimeSpan.FromMilliseconds(500),
|
|
AutoAcceptCertificates = true,
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-failover");
|
|
|
|
var ex = await Should.ThrowAsync<AggregateException>(async () =>
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
|
|
ex.Message.ShouldContain("127.0.0.1:1");
|
|
ex.Message.ShouldContain("127.0.0.1:2");
|
|
ex.Message.ShouldContain("127.0.0.1:3");
|
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #910: an endpoint that accepts TCP but never answers runs out the per-endpoint budget. The SDK reports
|
|
/// that as BadRequestInterrupted / a bare cancellation; the failover aggregate must name the budget instead.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_per_endpoint_budget_expiry_is_reported_as_that_budget()
|
|
{
|
|
using var silent = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
silent.Start(); // accepts (kernel backlog) but never reads or replies
|
|
var port = ((System.Net.IPEndPoint)silent.LocalEndpoint).Port;
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = $"opc.tcp://127.0.0.1:{port}",
|
|
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(400),
|
|
Timeout = TimeSpan.FromSeconds(30),
|
|
AutoAcceptCertificates = true,
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-budget");
|
|
|
|
var ex = await Should.ThrowAsync<AggregateException>(async () =>
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
|
|
ex.Message.ShouldContain("TimeoutException");
|
|
ex.Message.ShouldContain("PerEndpointConnectTimeout (0.4 s)");
|
|
}
|
|
}
|