feat(browse): Wave-0 Batch E — UntilStable settle loop + BrowserSessionService fallback

Task 9: DiscoverTreeAsync UntilStable settle loop (FOCAS) — re-capture on a 1s interval until
        the node set is non-empty and stable across two passes, bounded by open-timeout; on
        timeout with a prior non-empty capture, return it (best-effort) instead of failing.
Task 11: BrowserSessionService resolves bespoke-first, falls back to IUniversalDriverBrowser
         when no bespoke browser matches and CanBrowse is true; new CanBrowse on the service.
16 unit tests green (4 settle + 12 service).
This commit is contained in:
Joseph Doherty
2026-07-15 17:31:50 -04:00
parent fa339a5565
commit 698703744f
7 changed files with 266 additions and 11 deletions
@@ -13,7 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger) : IBrowserSessionService
ILogger<BrowserSessionService> logger,
IUniversalDriverBrowser universalBrowser) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
@@ -25,7 +26,7 @@ public sealed class BrowserSessionService(
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
if (!_browsersByType.TryGetValue(driverType, out var browser))
return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
try
{
var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false);
@@ -40,6 +41,28 @@ public sealed class BrowserSessionService(
}
}
/// <inheritdoc />
public bool CanBrowse(string driverType, string configJson) =>
_browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson);
private async Task<BrowseOpenResult> OpenUniversalAsync(string driverType, string configJson, CancellationToken ct)
{
if (!universalBrowser.CanBrowse(driverType, configJson))
return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
try
{
var session = await universalBrowser.OpenAsync(driverType, configJson, ct).ConfigureAwait(false);
registry.Register(session);
return new(true, null, session.Token);
}
catch (Exception ex)
{
logger.LogInformation(ex,
"Universal browse open failed for driverType={DriverType}: {Message}", driverType, ex.Message);
return new(false, ex.Message, Guid.Empty);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
@@ -54,6 +54,14 @@ public interface IBrowserSessionService
/// <param name="token">Registry handle for the browse session to close.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task CloseAsync(Guid token);
/// <summary>True when a browse affordance exists for this driver type: a bespoke browser
/// is registered, or the universal browser can serve it. Cheap (no connect); never throws.
/// Drives the picker's Browse-button visibility.</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="configJson">The authoring DriverConfig JSON (may be mid-edit).</param>
/// <returns>True when the picker should offer a Browse affordance for this driver.</returns>
bool CanBrowse(string driverType, string configJson);
}
/// <summary>