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.
This commit is contained in:
@@ -75,7 +75,10 @@ measurements; they are not fresh SDK 2.0.1 interoperability evidence.
|
||||
`EndpointUrls` (a list) takes precedence over the single-URL `EndpointUrl` and
|
||||
provides ordered **failover** — the driver tries each candidate in turn at every
|
||||
`InitializeAsync` (including the comms-loss watchdog's re-initialise), and the first to connect wins (e.g. a hot-standby pair on
|
||||
4840 / 4841). See
|
||||
4840 / 4841). Each candidate gets `PerEndpointConnectTimeout` (default 3 s) for discovery + secure channel + session. That
|
||||
budget includes **client-side** work (certificate load/validation, the asymmetric handshake), so a cold process on a busy
|
||||
host can exceed it against a healthy server; an expiry is reported as `TimeoutException: … PerEndpointConnectTimeout (3 s)`
|
||||
(#910 — the SDK alone says `BadRequestInterrupted`, which reads like a server fault). See
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs`
|
||||
for every field (security policy/mode, auth type, session timeout, keep-alive,
|
||||
browse root, node/depth caps). `ReconnectPeriod` was **deleted** (#676 — unused since #670 removed the SDK reconnect
|
||||
|
||||
@@ -1053,8 +1053,33 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_options.PerEndpointConnectTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
return await OpenSessionWithinBudgetAsync(appConfig, endpointUrl, policy, mode, identity, cts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (cts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
// Gitea #910: the SDK reports a wait cut short by this budget as BadRequestInterrupted (or a bare
|
||||
// TaskCanceledException), which reads like a server or network fault. Name the budget instead. It also
|
||||
// covers CLIENT-side work (loading the application certificate, validating the server's, the asymmetric
|
||||
// crypto of OpenSecureChannel), so a cold process on a busy host can exceed it with a healthy server.
|
||||
throw new TimeoutException(
|
||||
"The connect did not complete within PerEndpointConnectTimeout " +
|
||||
$"({_options.PerEndpointConnectTimeout.TotalSeconds:0.###} s); the SDK reported: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionWithinBudgetAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
string endpointUrl,
|
||||
OpcUaSecurityPolicy policy,
|
||||
OpcUaSecurityMode mode,
|
||||
UserIdentity identity,
|
||||
CancellationToken budget)
|
||||
{
|
||||
var selected = await SelectMatchingEndpointAsync(
|
||||
appConfig, endpointUrl, policy, mode, cts.Token).ConfigureAwait(false);
|
||||
appConfig, endpointUrl, policy, mode, budget).ConfigureAwait(false);
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
@@ -1073,7 +1098,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
default, // preferredLocales
|
||||
cts.Token).ConfigureAwait(false);
|
||||
budget).ConfigureAwait(false);
|
||||
|
||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||
return session;
|
||||
|
||||
+6
@@ -35,6 +35,12 @@ public sealed class ServerHostNameValidationTests(OpcPlcFixture sim) : IDisposab
|
||||
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
|
||||
AutoAcceptCertificates = false,
|
||||
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = validateHostName },
|
||||
// Gitea #910: the default 3 s per-endpoint budget covers client-side work too — loading the fresh throwaway
|
||||
// certificate, validating the server's, the first Basic256Sha256 handshake in a cold test process. The first
|
||||
// test in this assembly (the validate:null case) paid that cold cost, and in the full-solution run it ran out:
|
||||
// opc-plc answered OpenSecureChannel at once, but the client reached it only after 3 s of local work and
|
||||
// cancelled 48 ms later (BadRequestInterrupted). What is under test is the host check, not connect latency.
|
||||
PerEndpointConnectTimeout = TimeSpan.FromSeconds(15),
|
||||
};
|
||||
|
||||
/// <summary>Trust the fixture's server certificate (as advertised on its secure endpoint) in the throwaway PKI.</summary>
|
||||
|
||||
@@ -83,4 +83,30 @@ public sealed class OpcUaClientFailoverTests
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user