fix(opcuaclient): wire ProbeTimeoutSeconds into the Test Connect probe (R06:O-4)

The knob was authored by the AdminUI and parsed, but consumed by nothing. It now
caps the probe deadline (shorter of the caller's budget and the config; never
extends it; 1 s floor), the same rule as the Galaxy probe.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:28:20 -04:00
parent cba3b2e960
commit d98f4a5bf1
3 changed files with 75 additions and 1 deletions
+2
View File
@@ -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)
@@ -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
}
}
/// <summary>
/// R06:O-4 — the deadline the probe actually runs under: the caller's budget, tightened by the config's
/// <see cref="OpcUaClientDriverOptions.ProbeTimeoutSeconds"/> 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.
/// </summary>
/// <param name="callerTimeout">The budget the host handed the probe.</param>
/// <param name="configuredSeconds">The config's <c>ProbeTimeoutSeconds</c>; ignored when not positive.</param>
/// <returns>The effective probe deadline.</returns>
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;
}
/// <summary>
/// Builds a minimal <see cref="ApplicationConfiguration"/> suitable for unauthenticated
/// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic
@@ -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;
/// <summary>
/// Review 2026-10-21 R06:O-4 — <c>ProbeTimeoutSeconds</c> 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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientProbeTimeoutTests
{
/// <summary>The effective deadline is the shorter of the caller's budget and the configured cap.</summary>
[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));
/// <summary>
/// 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.
/// </summary>
[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();
}
}
}