feat(browse): Wave-0 Batch D — IUniversalDriverBrowser + DiscoveryDriverBrowser core

Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser open path — construct(+PatchForBrowse)
        -> connect -> one-shot capture -> bounded (10s) shutdown; CanBrowse gate (catch-throwing
        TryCreate, defensive teardown of the throwaway instance); deep-merge browse patch.
        Commons gains Microsoft.Extensions.Logging.Abstractions (ILogger doesn't flow transitively)
        + InternalsVisibleTo(Commons.Tests). OTOPCUA0001 suppressed on the deliberate one-shot
        browse-capture DiscoverAsync (not a runtime dispatch path). 12 unit tests green.
This commit is contained in:
Joseph Doherty
2026-07-15 17:27:04 -04:00
parent 15da8d4f0d
commit fa339a5565
6 changed files with 445 additions and 1 deletions
@@ -0,0 +1,148 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2.</summary>
public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
{
/// <summary>Whole-open bound: construct + connect + discover (+ settle). Separate from the
/// 20 s per-call browse timeout, which only covers serving from memory.</summary>
public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);
/// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
/// in discovery may be equally wedged in shutdown).</summary>
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);
/// <summary>Node-count cap the capturing builder enforces before marking the tree truncated.</summary>
public const int NodeCap = 50_000;
/// <summary>Per-type declarative config patch guaranteeing browse mode at open time (§5.1).
/// A dictionary entry per config-gated driver — not code.</summary>
internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["AbCip"] = """{ "EnableControllerBrowse": true }""",
["TwinCAT"] = """{ "EnableControllerBrowse": true }""",
["FOCAS"] = """{ "FixedTree": { "Enabled": true } }""",
// OpcUaClient/BACnet/MTConnect: no patch — their discovery is unconditional.
};
private readonly IDriverFactory _driverFactory;
private readonly ILogger<DiscoveryDriverBrowser> _logger;
private readonly TimeSpan _openTimeout;
private readonly TimeSpan _shutdownTimeout;
/// <summary>Production constructor: standard open + shutdown bounds.</summary>
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
/// <param name="logger">Logger for shutdown-timeout warnings.</param>
public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
: this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }
// Test seam: short timeouts.
internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
TimeSpan openTimeout, TimeSpan shutdownTimeout)
{
_driverFactory = driverFactory;
_logger = logger;
_openTimeout = openTimeout;
_shutdownTimeout = shutdownTimeout;
}
/// <inheritdoc />
public bool CanBrowse(string driverType, string configJson)
{
IDriver? probe = null;
try
{
probe = _driverFactory.TryCreate(driverType, $"browse-can-{Guid.NewGuid():N}",
PatchForBrowse(driverType, configJson));
return probe is ITagDiscovery { SupportsOnlineDiscovery: true };
}
catch
{
// TryCreate parses configJson and can throw on mid-authoring JSON. UI affordance
// gate — must never throw into the page (§4.2).
return false;
}
finally
{
if (probe is not null) _ = BoundedShutdownAsync(probe, driverType); // best-effort, never leak
}
}
/// <inheritdoc />
public async Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false);
return new CapturedTreeBrowseSession(tree);
}
private async Task<CapturedTree> CaptureAsync(string driverType, string configJson, CancellationToken ct)
{
var patched = PatchForBrowse(driverType, configJson);
var driver = _driverFactory.TryCreate(driverType, $"browse-{Guid.NewGuid():N}", patched)
?? throw new InvalidOperationException(
$"Driver type '{driverType}' is not available in this host.");
try
{
if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery)
throw new InvalidOperationException(
$"Driver type '{driverType}' has no online discovery — author tags manually.");
using var openCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
openCts.CancelAfter(_openTimeout);
await driver.InitializeAsync(patched, openCts.Token).ConfigureAwait(false);
return await DiscoverTreeAsync(disc, openCts.Token).ConfigureAwait(false);
}
finally
{
await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false);
}
}
// Task 9 replaces this with the UntilStable settle version.
private static async Task<CapturedTree> DiscoverTreeAsync(ITagDiscovery disc, CancellationToken ct)
{
var builder = new CapturingAddressSpaceBuilder(NodeCap);
#pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path: the driver is a throwaway instance we construct/connect/discover/discard for the picker. CapabilityInvoker (retry/breaker/telemetry) governs live polling, not this admin-side capture.
await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
return builder.Build();
}
private async Task BoundedShutdownAsync(IDriver driver, string driverType)
{
try
{
using var cts = new CancellationTokenSource(_shutdownTimeout);
// WaitAsync: a token-ignoring wedged driver is ABANDONED (dropped), never awaited forever.
await driver.ShutdownAsync(cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Browse-capture shutdown for {DriverType} failed/timed out; instance abandoned", driverType);
}
}
internal static string PatchForBrowse(string driverType, string configJson)
{
if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
var config = JsonNode.Parse(string.IsNullOrWhiteSpace(configJson) ? "{}" : configJson)
?.AsObject() ?? new JsonObject();
Merge(config, JsonNode.Parse(patch)!.AsObject());
return config.ToJsonString();
}
private static void Merge(JsonObject target, JsonObject patch)
{
foreach (var (key, value) in patch)
{
if (value is JsonObject po && target[key] is JsonObject to) Merge(to, po);
else target[key] = value?.DeepClone();
}
}
}
@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>
/// Universal fallback browser: captures any discovery-capable driver's DiscoverAsync
/// output and serves it as a browse session. Resolved by BrowserSessionService only
/// when no bespoke IDriverBrowser matches the driver type (bespoke-first). A separate
/// interface — NOT an IDriverBrowser — because BrowserSessionService indexes those
/// ToDictionary-by-DriverType and the universal browser has no single type.
/// </summary>
public interface IUniversalDriverBrowser
{
/// <summary>Cheap gate (construct + inspect, no connect): can this driver type + config be
/// browsed? 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 / malformed).</param>
/// <returns>True when a discovery-capable driver can be constructed for this type + config.</returns>
bool CanBrowse(string driverType, string configJson);
/// <summary>Construct the driver (with the per-type browse patch), connect, capture one
/// full discovery pass (with UntilStable settle), shut the driver down (bounded), and
/// return a session over the captured snapshot. Throws on failure — the caller
/// (BrowserSessionService) converts to BrowseOpenResult.</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="configJson">The DriverConfig JSON to connect with.</param>
/// <param name="ct">Cancellation token for the caller's open request.</param>
/// <returns>A browse session over the captured discovery snapshot.</returns>
Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct);
}
@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="Akka"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
<PackageReference Include="ZB.MOM.WW.Audit"/>
</ItemGroup>
@@ -14,4 +15,8 @@
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Commons.Tests"/>
</ItemGroup>
</Project>