diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs index 172e1dea..ac46d8a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs @@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } if (opts is null) return new(false, "Config JSON deserialized to null.", null); + // The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by + // nothing before: Test Connect always ran on the caller's budget alone. + timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds); + var (parsed, firstDevice) = ExtractTarget(opts); if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0) return new(false, "Config has no host/port to probe.", null); @@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe /// Whether the status proves the controller answered. internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound; + /// + /// The deadline the probe runs under: the caller's budget, tightened by the config's + /// when that is positive and shorter (same rule as the + /// Galaxy probe). A non-positive caller budget is floored to 1 s first. + /// + /// 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; + } + private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts) { // Parse the first device's ab:// host address to extract the gateway IP + EIP port. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs index f58536d7..c9c3aa51 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs @@ -192,6 +192,40 @@ public sealed class AbLegacyDriverProbeTests LibplctagLegacyName.Translate(status!).ShouldBe("S2:0"); } + private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) + { + public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct); + } + + /// + /// The authored probeTimeoutSeconds ("Admin UI probe timeout") was parsed by nothing — Test Connect ran + /// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps + /// the caller's budget (the positive control). + /// + [Theory] + [InlineData(",\"probeTimeoutSeconds\":1", "1s")] + [InlineData(",\"probeTimeoutSeconds\":30", "2s")] + [InlineData("", "2s")] + [InlineData(",\"probeTimeoutSeconds\":0", "2s")] + public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) }; + var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}"; + + var result = await new AbLegacyDriverProbe(factory) + .ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldBe($"Probe timed out after {expectedBudget}."); + } + finally { listener.Stop(); } + } + [Fact] public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception() {