using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; /// /// Review 02 L1 (host context, 06/G-1): the browser's throwaway capture driver must be disposed, not /// only shut down. A Galaxy driver's ShutdownAsync left its whole gRPC runtime alive until /// DisposeAsync, which nothing called — so every /raw browse leaked two channels, an MX session /// and two stream loops. /// public sealed class DiscoveryDriverBrowserDisposeTests { private static readonly TimeSpan ShortOpen = TimeSpan.FromSeconds(5); private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(300); private static DiscoveryDriverBrowser Browser(IDriverFactory factory) => new(factory, NullLogger.Instance, ShortOpen, ShortShutdown); [Fact] public async Task OpenAsync_disposes_the_capture_driver_after_shutdown() { var driver = new DisposableFakeDiscoveryDriver(); var browser = Browser(new FakeDriverFactory((_, _, _) => driver)); var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None); session.ShouldNotBeNull(); driver.Inner.Trace.ShouldBe(new[] { "init", "discover", "shutdown" }); driver.DisposeCalls.ShouldBe(1); driver.DisposedAfterShutdown.ShouldBeTrue("dispose is the final teardown, after the shutdown"); } [Fact] public async Task OpenAsync_disposes_the_capture_driver_even_when_discovery_throws() { var driver = new DisposableFakeDiscoveryDriver(new FakeDiscoveryDriver { OnDiscover = (_, _) => throw new InvalidOperationException("boom-discovery"), }); var browser = Browser(new FakeDriverFactory((_, _, _) => driver)); await Should.ThrowAsync( () => browser.OpenAsync("Fake", "{}", CancellationToken.None)); driver.DisposeCalls.ShouldBe(1); } [Fact] public async Task OpenAsync_disposes_the_capture_driver_even_when_its_shutdown_hangs() { var never = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var driver = new DisposableFakeDiscoveryDriver(new FakeDiscoveryDriver { OnShutdown = _ => never.Task }); var browser = Browser(new FakeDriverFactory((_, _, _) => driver)); await browser.OpenAsync("Fake", "{}", CancellationToken.None); driver.DisposeCalls.ShouldBe(1, "a shutdown that timed out must not skip the dispose"); } [Fact] public async Task CanBrowse_disposes_its_probe_driver() { var driver = new DisposableFakeDiscoveryDriver(); var browser = Browser(new FakeDriverFactory((_, _, _) => driver)); browser.CanBrowse("Fake", "{}").ShouldBeTrue(); // The CanBrowse teardown is fire-and-forget; wait for it. await driver.Disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); driver.DisposeCalls.ShouldBe(1); } /// A wrapped with + /// (both, so the "prefer the async one, never both" rule is observable: exactly ONE dispose call lands). private sealed class DisposableFakeDiscoveryDriver : IDriver, ITagDiscovery, IAsyncDisposable, IDisposable { private int _disposeCalls; public DisposableFakeDiscoveryDriver(FakeDiscoveryDriver? inner = null) => Inner = inner ?? new FakeDiscoveryDriver(); public FakeDiscoveryDriver Inner { get; } public int DisposeCalls => Volatile.Read(ref _disposeCalls); public bool DisposedAfterShutdown { get; private set; } public TaskCompletionSource Disposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public string DriverInstanceId => Inner.DriverInstanceId; public string DriverType => Inner.DriverType; public bool SupportsOnlineDiscovery => Inner.SupportsOnlineDiscovery; public DiscoveryRediscoverPolicy RediscoverPolicy => Inner.RediscoverPolicy; public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Inner.InitializeAsync(driverConfigJson, cancellationToken); public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Inner.ReinitializeAsync(driverConfigJson, cancellationToken); public Task ShutdownAsync(CancellationToken cancellationToken) => Inner.ShutdownAsync(cancellationToken); public DriverHealth GetHealth() => Inner.GetHealth(); #pragma warning disable OTOPCUA0001 // test double delegating to a test double — no resilience pipeline applies public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) => Inner.DiscoverAsync(builder, cancellationToken); #pragma warning restore OTOPCUA0001 public ValueTask DisposeAsync() { Record(); return ValueTask.CompletedTask; } public void Dispose() => Record(); private void Record() { Interlocked.Increment(ref _disposeCalls); DisposedAfterShutdown = Inner.ShutdownCalls > 0; Disposed.TrySetResult(); } } }