fix(adminui): Test Connect honours the configured probe timeout; drain its clear timer (R04 L2, G1-F5, F-FU2)

DriverTestConnectButton always sent a fixed 10 s, and the Galaxy/OpcUaClient
probes run under min(caller, configured), so ProbeTimeoutSeconds - documented
on every driver as the Test Connect timeout - could shorten a probe but never
lengthen it (Galaxy's 30 s default was silently 10 s). TestConnectBudget reads
the root ProbeTimeoutSeconds from the merged config at click time, clamped to
the actor's [1, 60] s, falling back to TimeoutSeconds. DeviceModal (the only
host) needs no change. The Galaxy/OpcUaClient form hints are reworded.

The 30 s result-clear timer moves from System.Timers.Timer + an async void
Elapsed lambda to System.Threading.Timer, drained in DisposeAsync, with a
generation so a superseded fire cannot clear a newer result.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:45:39 -04:00
parent b0959f697b
commit 4b594ec525
5 changed files with 170 additions and 21 deletions
@@ -1,10 +1,11 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@inject AdminProbeService Probe
@implements IDisposable
@implements IAsyncDisposable
<div class="d-inline-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_busy" @onclick="OnClickAsync">
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_busy" @onclick="OnClickAsync"
title="@($"Probe budget: {_lastBudgetSeconds?.ToString() ?? "the driver's configured probe timeout"} s")">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Test Connect
</button>
@@ -20,7 +21,7 @@
else
{
<span class="chip chip-bad" title="@_result.Message">
Failed &middot; @TruncatedMessage()
Failed &middot; @DisplayText.Abbreviate(_result.Message, 60)
</span>
}
}
@@ -33,12 +34,25 @@
/// <summary>Callback that returns the current form config as JSON. Called at click time.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>Per-probe timeout forwarded to the actor (actor clamps to [1, 60] s). Default 10 s.</summary>
[Parameter] public int TimeoutSeconds { get; set; } = 10;
/// <summary>
/// Fallback probe budget, used only when the config JSON carries no positive
/// <c>ProbeTimeoutSeconds</c>. The configured value wins (it is every driver's documented "Test Connect
/// probe timeout"), so it can lengthen the budget as well as shorten it. Clamped to [1, 60] s.
/// </summary>
[Parameter] public int TimeoutSeconds { get; set; } = TestConnectBudget.DefaultSeconds;
private const int ClearAfterMs = 30_000;
private bool _busy;
private int? _lastBudgetSeconds;
private TestDriverConnectResult? _result;
private System.Timers.Timer? _clearTimer;
// System.Threading.Timer (it replaced an Elapsed-event timer with an async void lambda): DisposeAsync awaits an
// in-flight callback, and the callback body never throws onto the thread pool (R04 L2). The generation
// makes a superseded timer's late fire a no-op, so it cannot clear a newer result.
private System.Threading.Timer? _clearTimer;
private int _resultGeneration;
private volatile bool _disposed;
private async Task OnClickAsync()
{
@@ -48,7 +62,9 @@
try
{
var json = GetConfigJson() ?? "{}";
_result = await Probe.TestAsync(DriverType, json, TimeoutSeconds);
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds);
_lastBudgetSeconds = budget;
_result = await Probe.TestAsync(DriverType, json, budget);
}
catch (Exception ex)
{
@@ -58,25 +74,39 @@
{
_busy = false;
ScheduleClear();
StateHasChanged();
if (!_disposed) StateHasChanged();
}
}
private void ScheduleClear()
{
if (_disposed) return;
var generation = ++_resultGeneration;
_clearTimer?.Dispose();
_clearTimer = new System.Timers.Timer(30_000) { AutoReset = false };
_clearTimer.Elapsed += async (_, _) =>
{
_result = null;
await InvokeAsync(StateHasChanged);
};
_clearTimer.Start();
_clearTimer = new System.Threading.Timer(_ => _ = ClearAsync(generation), null, ClearAfterMs, Timeout.Infinite);
}
private string TruncatedMessage()
=> _result?.Message is null ? "" :
(_result.Message.Length > 60 ? _result.Message[..60] + "…" : _result.Message);
private async Task ClearAsync(int generation)
{
try
{
await InvokeAsync(() =>
{
if (_disposed || generation != _resultGeneration) return;
_result = null;
StateHasChanged();
});
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException)
{
// The circuit went away between the fire and the render — nothing to clear.
}
}
public void Dispose() => _clearTimer?.Dispose();
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_disposed = true;
if (_clearTimer is not null) await _clearTimer.DisposeAsync();
}
}
@@ -138,7 +138,7 @@
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Caps the Test Connect probe deadline (the probe's own budget still applies, so this only shortens it). Max 60. Default 30.</div>
<div class="form-text">The Test Connect probe budget (1–60 s). Default 30.</div>
</div>
</div>
</div>
@@ -219,7 +219,7 @@
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Caps Test Connect: it can only shorten the 10 s Test Connect budget, never extend it.</div>
<div class="form-text">The Test Connect probe budget (1–60 s). Default 15.</div>
</div>
</div>
</div>
@@ -0,0 +1,64 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
/// <summary>
/// Resolves the Test Connect probe budget (G1-F5 / F-FU2). Every driver's options document
/// <c>ProbeTimeoutSeconds</c> as "Timeout for the AdminUI Test Connect probe", but the button always sent
/// a fixed 10 s, and the Galaxy and OpcUaClient probes take <c>min(caller budget, configured)</c> — so the
/// knob could shorten a probe but never lengthen it (Galaxy's default of 30 s was silently 10 s). The
/// button now sends the configured value, read from the root of the config JSON it is about to probe,
/// clamped to the probe actor's own [1, 60] s range.
/// </summary>
public static class TestConnectBudget
{
/// <summary>The budget used when the config carries no (valid) <c>ProbeTimeoutSeconds</c>.</summary>
public const int DefaultSeconds = 10;
/// <summary>The probe actor's lower clamp.</summary>
public const int MinSeconds = 1;
/// <summary>The probe actor's upper clamp.</summary>
public const int MaxSeconds = 60;
/// <summary>The config property that carries the budget (matched case-insensitively at the JSON root).</summary>
public const string PropertyName = "ProbeTimeoutSeconds";
/// <summary>Returns the configured budget, or <paramref name="fallbackSeconds"/> when there is none.</summary>
/// <param name="configJson">The (merged) config JSON the probe will run against.</param>
/// <param name="fallbackSeconds">The budget when the config has no positive <c>ProbeTimeoutSeconds</c>.</param>
/// <returns>The budget in seconds, within [<see cref="MinSeconds"/>, <see cref="MaxSeconds"/>].</returns>
public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds)
{
var configured = TryReadConfigured(configJson);
var seconds = configured is > 0 ? configured.Value : fallbackSeconds;
return Math.Clamp(seconds, MinSeconds, MaxSeconds);
}
private static int? TryReadConfigured(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return null;
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase)) continue;
return prop.Value.ValueKind switch
{
JsonValueKind.Number when prop.Value.TryGetInt32(out var i) => i,
JsonValueKind.Number when prop.Value.TryGetDouble(out var d) => (int)Math.Min(Math.Ceiling(d), int.MaxValue),
JsonValueKind.String when int.TryParse(prop.Value.GetString(), out var s) => s,
_ => null,
};
}
}
catch (JsonException)
{
// An unparseable config is the probe's problem to report; the budget just falls back.
}
return null;
}
}
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
/// <summary>
/// G1-F5 / F-FU2: Test Connect honours the driver's configured <c>ProbeTimeoutSeconds</c> (so it can
/// lengthen the budget, not only shorten it), aligned across Galaxy and OpcUaClient, and the button
/// drains its clear timer (R04 L2).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TestConnectBudgetTests
{
[Theory]
[InlineData("{\"ProbeTimeoutSeconds\":30}", 30)] // Galaxy default — was silently 10 s
[InlineData("{\"probeTimeoutSeconds\":15}", 15)] // OpcUaClient default, camelCase
[InlineData("{\"ProbeTimeoutSeconds\":\"25\"}", 25)]
[InlineData("{\"ProbeTimeoutSeconds\":2.5}", 3)]
[InlineData("{\"ProbeTimeoutSeconds\":600}", 60)] // clamped to the actor's max
public void A_configured_timeout_is_the_budget(string json, int expected)
=> TestConnectBudget.Resolve(json).ShouldBe(expected);
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("{}")]
[InlineData("{\"ProbeTimeoutSeconds\":0}")]
[InlineData("{\"ProbeTimeoutSeconds\":-5}")]
[InlineData("{\"ProbeTimeoutSeconds\":null}")]
[InlineData("not json")]
[InlineData("[1,2]")]
[InlineData("{\"Nested\":{\"ProbeTimeoutSeconds\":40}}")] // only the root key counts
public void No_valid_configured_timeout_falls_back(string? json)
=> TestConnectBudget.Resolve(json, fallbackSeconds: 10).ShouldBe(10);
[Fact]
public void The_fallback_is_clamped_too()
{
TestConnectBudget.Resolve("{}", fallbackSeconds: 0).ShouldBe(TestConnectBudget.MinSeconds);
TestConnectBudget.Resolve("{}", fallbackSeconds: 999).ShouldBe(TestConnectBudget.MaxSeconds);
}
[Fact]
public void Button_resolves_its_budget_from_the_config_and_drains_a_threading_timer()
{
var src = AdminUiSource.Read("Components", "Shared", "Drivers", "DriverTestConnectButton.razor");
src.ShouldContain("TestConnectBudget.Resolve(json, TimeoutSeconds)");
src.ShouldContain("@implements IAsyncDisposable");
src.ShouldContain("System.Threading.Timer");
src.ShouldNotContain("System.Timers.Timer");
src.ShouldNotContain("async (_, _)");
}
}