feat(browse): Wave-0 Batch F — capture coalescing/cap + AddAdminUI DI registration

Task 10: in-flight coalescing (Lazy<Task<CapturedTree>> keyed on driverType+SHA256(config))
         so repeat Browse clicks share one device walk; global SemaphoreSlim cap
         (MaxConcurrentCaptures=4) queues excess; per-caller ct via WaitAsync leaves the shared
         capture (bounded by open-timeout, CancellationToken.None) unaffected; in-flight-only map
         (Refresh = fresh capture). 7 concurrency tests green (158 total in Commons.Tests).
Task 12: AddAdminUI registers IUniversalDriverBrowser->DiscoveryDriverBrowser with
         NullDriverFactory fallback (standalone AdminUI => CanBrowse false => manual entry).
         AdminUI.Tests 542 green.
This commit is contained in:
Joseph Doherty
2026-07-15 17:37:02 -04:00
parent 698703744f
commit bbbda99719
4 changed files with 225 additions and 3 deletions
@@ -0,0 +1,177 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
public class DiscoveryDriverBrowserConcurrencyTests
{
private static readonly TimeSpan LongOpen = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
new(factory, NullLogger<DiscoveryDriverBrowser>.Instance, LongOpen, ShortShutdown);
/// <summary>A driver gate: signals when Initialize is entered, blocks until released.</summary>
private sealed class Gate
{
public readonly SemaphoreSlim Entered = new(0);
public readonly TaskCompletionSource Release = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
private static FakeDiscoveryDriver Gated(Gate gate) => new()
{
OnInitialize = async ct =>
{
gate.Entered.Release();
await gate.Release.Task.WaitAsync(ct);
},
};
[Fact]
public async Task SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens()
{
var gate = new Gate();
var factory = FakeDriverFactory.Of(() => Gated(gate));
var browser = Browser(factory);
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken); // one driver entered Initialize
factory.CreateCalls.ShouldBe(1); // coalesced — only one driver constructed
gate.Release.SetResult();
var s1 = await open1;
var s2 = await open2;
s1.Token.ShouldNotBe(s2.Token); // distinct sessions...
(await s1.RootAsync(CancellationToken.None)).Count
.ShouldBe((await s2.RootAsync(CancellationToken.None)).Count); // ...over the same tree
((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
}
[Fact]
public async Task SameKey_DisposingOneSession_LeavesOtherBrowsable()
{
var gate = new Gate();
var browser = Browser(FakeDriverFactory.Of(() => Gated(gate)));
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
gate.Release.SetResult();
var s1 = await open1;
var s2 = await open2;
await s1.DisposeAsync();
(await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); // shared immutable tree survives
}
[Fact]
public async Task SameKey_FailedCapture_FailsBothAwaiters()
{
var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver
{
OnDiscover = (_, _) => throw new InvalidOperationException("shared-boom"),
});
var browser = Browser(factory);
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
(await Should.ThrowAsync<InvalidOperationException>(() => open1)).Message.ShouldBe("shared-boom");
(await Should.ThrowAsync<InvalidOperationException>(() => open2)).Message.ShouldBe("shared-boom");
}
[Fact]
public async Task SameKey_OpenAfterCompletion_RunsFreshCapture()
{
var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver()); // ungated, completes fast
var browser = Browser(factory);
await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
factory.CreateCalls.ShouldBe(2); // in-flight-only map: second open is a fresh capture (Refresh path)
}
[Fact]
public async Task DistinctKeys_CapLimitsInflightCaptures()
{
const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
const int total = cap + 2; // 6
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = 0;
var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
{
OnInitialize = async ct => { Interlocked.Increment(ref entered); await release.Task.WaitAsync(ct); },
});
var browser = Browser(factory);
var opens = Enumerable.Range(0, total)
.Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
.ToArray();
// Give the semaphore time to admit exactly `cap` captures into Initialize.
await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
Volatile.Read(ref entered).ShouldBe(cap); // at most 4 in flight; the other 2 queue on the semaphore
release.SetResult();
var sessions = await Task.WhenAll(opens);
sessions.Length.ShouldBe(total);
factory.CreateCalls.ShouldBe(total); // all eventually run once a slot frees
}
[Fact]
public async Task QueuedOpen_ObservesCallerCancellation()
{
const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
{
OnInitialize = async ct => { await release.Task.WaitAsync(ct); },
});
var browser = Browser(factory);
// Fill all slots with distinct-key captures.
var inflight = Enumerable.Range(0, cap)
.Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
.ToArray();
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
// A queued open with a cancelable token — it waits for a slot, then the caller cancels.
using var cts = new CancellationTokenSource();
var queued = browser.OpenAsync("Fake", """{"i":"queued"}""", cts.Token);
cts.Cancel();
await Should.ThrowAsync<OperationCanceledException>(() => queued);
// The in-flight captures are unaffected.
release.SetResult();
var done = await Task.WhenAll(inflight);
done.Length.ShouldBe(cap);
}
[Fact]
public async Task CoalescedAwaiter_CancellationDoesNotCancelSharedCapture()
{
var gate = new Gate();
var factory = FakeDriverFactory.Of(() => Gated(gate));
var browser = Browser(factory);
using var cts1 = new CancellationTokenSource();
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", cts1.Token);
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
cts1.Cancel(); // first caller abandons
await Should.ThrowAsync<OperationCanceledException>(() => open1);
gate.Release.SetResult(); // shared capture proceeds regardless
var s2 = await open2;
(await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty();
((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
}
}