diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index d189f57c..85f6168d 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -53,6 +53,8 @@ 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 handler, and the AdminUI no longer shows it); stored configs that still carry `reconnectPeriod` keep parsing in the factory, the Test-Connect probe and the browse picker (unknown keys are skipped), and the form drops it on the next save. +`ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten +the budget the host hands the probe, never extend it (review 2026-10-21 O-4; it used to be authored and consumed by nothing). ### Endpoint address: the configured host and port win (#676) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs index 748ff0c3..e4695e7a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs @@ -51,7 +51,8 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe // Bound the whole probe (both phases) by the caller timeout, independent of `ct` — a // stalled OPC UA endpoint that accepts TCP but never answers GetEndpoints must not block - // the probe when the caller passes CancellationToken.None. + // the probe when the caller passes CancellationToken.None. The config's ProbeTimeoutSeconds caps it (O-4). + timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout); @@ -106,6 +107,23 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe } } + /// + /// R06:O-4 — the deadline the probe actually runs under: the caller's budget, tightened by the config's + /// when that is positive and shorter (it can only + /// shorten the budget — the caller's own deadline fires regardless). A non-positive caller budget is floored + /// to 1 s so the probe runs briefly rather than cancelling before it starts. Same rule as the Galaxy probe. + /// + /// The budget the host handed the probe. + /// The config's ProbeTimeoutSeconds; ignored when not positive. + /// The effective probe deadline. + internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds) + { + var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1); + if (configuredSeconds <= 0) return caller; + var configured = TimeSpan.FromSeconds(configuredSeconds); + return configured < caller ? configured : caller; + } + /// /// Builds a minimal suitable for unauthenticated /// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientProbeTimeoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientProbeTimeoutTests.cs new file mode 100644 index 00000000..584952b1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientProbeTimeoutTests.cs @@ -0,0 +1,54 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Review 2026-10-21 R06:O-4 — ProbeTimeoutSeconds was authored by the AdminUI and parsed, but consumed +/// by nothing: the Test Connect probe ran on the caller's budget alone. It now caps the probe deadline (it can +/// shorten the caller's budget, never extend it) — the same rule as the Galaxy probe. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientProbeTimeoutTests +{ + /// The effective deadline is the shorter of the caller's budget and the configured cap. + [Theory] + [InlineData(10, 30, 10)] // caller shorter → caller wins + [InlineData(10, 2, 2)] // config shorter → config caps + [InlineData(10, 0, 10)] // unset config → caller + [InlineData(10, -1, 10)] // nonsense config → caller + [InlineData(0, 5, 1)] // no caller budget → 1 s floor beats a longer config + public void EffectiveTimeout_is_the_shorter_of_caller_and_config(int callerSeconds, int configSeconds, int expectedSeconds) + => OpcUaClientDriverProbe.EffectiveTimeout(TimeSpan.FromSeconds(callerSeconds), configSeconds) + .ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); + + /// + /// Against a port that accepts TCP but never speaks OPC UA, a 1 s configured cap returns long before the + /// caller's 30 s budget — the discovery handshake does the waiting, so this exercises the real deadline path. + /// + [Fact] + public async Task Configured_cap_shortens_the_probe_against_a_silent_listener() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var config = $$"""{ "endpointUrl": "opc.tcp://127.0.0.1:{{port}}", "probeTimeoutSeconds": 1 }"""; + + var sw = Stopwatch.StartNew(); + var result = await new OpcUaClientDriverProbe().ProbeAsync(config, TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + sw.Stop(); + + result.Ok.ShouldBeFalse(); + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(15), $"the 1 s configured cap must bound the probe (message: {result.Message})"); + } + finally + { + listener.Stop(); + } + } +}