bbbda99719
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.
178 lines
7.5 KiB
C#
178 lines
7.5 KiB
C#
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);
|
|
}
|
|
}
|