using System.Collections.Concurrent; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2. public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser { /// Whole-open bound: construct + connect + discover (+ settle). Separate from the /// 20 s per-call browse timeout, which only covers serving from memory. public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60); /// Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged /// in discovery may be equally wedged in shutdown). public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10); /// Node-count cap the capturing builder enforces before marking the tree truncated. public const int NodeCap = 50_000; /// Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot. public const int MaxConcurrentCaptures = 4; /// Per-type declarative config patch guaranteeing browse mode at open time (§5.1). /// A dictionary entry per config-gated driver — not code. internal static readonly IReadOnlyDictionary BrowsePatches = new Dictionary(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 _logger; private readonly TimeSpan _openTimeout; private readonly TimeSpan _shutdownTimeout; private readonly ConcurrentDictionary>> _inflight = new(); private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures); /// Production constructor: standard open + shutdown bounds. /// Factory used to construct throwaway/capture driver instances. /// Logger for shutdown-timeout warnings. public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger logger) : this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { } // Test seam: short timeouts. internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger logger, TimeSpan openTimeout, TimeSpan shutdownTimeout) { _driverFactory = driverFactory; _logger = logger; _openTimeout = openTimeout; _shutdownTimeout = shutdownTimeout; } /// 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 } } /// public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) { var key = CaptureKey(driverType, configJson); // Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races. var capture = _inflight.GetOrAdd(key, _ => new Lazy>(() => RunCaptureAsync(key, driverType, configJson))); // The shared capture runs independent of any single caller (its own OpenTimeout bounds it); // each caller — including a queued one — observes ITS OWN ct via WaitAsync. var tree = await capture.Value.WaitAsync(ct).ConfigureAwait(false); return new CapturedTreeBrowseSession(tree); } private async Task RunCaptureAsync(string key, string driverType, string configJson) { try { await _captureSlots.WaitAsync().ConfigureAwait(false); // FIFO-ish queue for a slot try { return await CaptureAsync(driverType, configJson, CancellationToken.None).ConfigureAwait(false); } finally { _captureSlots.Release(); } } finally { _inflight.TryRemove(key, out _); // in-flight only — no result caching (Refresh = fresh capture) } } internal static string CaptureKey(string driverType, string configJson) => driverType.ToLowerInvariant() + ":" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( System.Text.Encoding.UTF8.GetBytes(configJson ?? ""))); private async Task 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, driverType, openCts.Token).ConfigureAwait(false); } finally { await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false); } } private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1); private async Task DiscoverTreeAsync(ITagDiscovery disc, string driverType, CancellationToken ct) { if (disc.RediscoverPolicy != DiscoveryRediscoverPolicy.UntilStable) { 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(); } // UntilStable (FOCAS): the discovered shape fills in asynchronously after connect — // re-capture until non-empty and stable across two consecutive passes, bounded by the // open-timeout. Mirrors the deploy-time contract in DriverInstanceActor. CapturedTree? previous = null; while (true) { var builder = new CapturingAddressSpaceBuilder(NodeCap); try { #pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path (see above). await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); #pragma warning restore OTOPCUA0001 var current = builder.Build(); if (current.Count > 0 && previous?.Count == current.Count) return current; previous = current; await Task.Delay(SettleInterval, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (previous is { Count: > 0 }) { _logger.LogWarning( "UntilStable settle for {DriverType} hit the open-timeout before stabilising; returning the last capture ({Count} nodes)", driverType, previous.Count); return previous; // best effort: a fresh-but-unsettled tree beats a failed open } } } 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(); } } }