826312befe
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns, normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping the rationale they annotated. Comment-only: comment-stripped sources are identical to the parent commit.
228 lines
11 KiB
C#
228 lines
11 KiB
C#
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;
|
|
|
|
/// <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 (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>Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot.</summary>
|
|
public const int MaxConcurrentCaptures = 4;
|
|
|
|
/// <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;
|
|
private readonly ConcurrentDictionary<string, Lazy<Task<CapturedTree>>> _inflight = new();
|
|
private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures);
|
|
|
|
/// <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) { }
|
|
|
|
/// <summary>Test seam: constructor with explicit open and shutdown bounds.</summary>
|
|
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
|
|
/// <param name="logger">Logger for shutdown-timeout warnings.</param>
|
|
/// <param name="openTimeout">Upper bound on opening a capture driver and running discovery.</param>
|
|
/// <param name="shutdownTimeout">Upper bound on the cleanup shutdown of a capture driver.</param>
|
|
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 key = CaptureKey(driverType, configJson);
|
|
// Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races.
|
|
var capture = _inflight.GetOrAdd(key,
|
|
_ => new Lazy<Task<CapturedTree>>(() => 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<CapturedTree> 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)
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds the in-flight de-duplication key for a capture request.</summary>
|
|
/// <param name="driverType">The driver type name.</param>
|
|
/// <param name="configJson">The driver configuration JSON.</param>
|
|
/// <returns>A key combining the lower-cased driver type and a hash of the configuration.</returns>
|
|
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<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, driverType, openCts.Token).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1);
|
|
|
|
private async Task<CapturedTree> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Applies the driver-specific browse-time patch (if any) to the configuration JSON.</summary>
|
|
/// <param name="driverType">The driver type name.</param>
|
|
/// <param name="configJson">The driver configuration JSON.</param>
|
|
/// <returns>The patched configuration JSON, or the input unchanged when the driver type has no patch.</returns>
|
|
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();
|
|
}
|
|
}
|
|
}
|