Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserDisposeTests.cs
T
Joseph Doherty eb23053700 fix(runtime): dispose drivers on final stop, bound PostStop shutdown (A1)
DriverInstanceActor.PostStop now bounds IDriver.ShutdownAsync by a
shutdownTimeout (default 5 s, logged on overrun) and then disposes the
driver (IAsyncDisposable preferred, else IDisposable) — but only on a
real stop. PreRestart flags the restart so the PostStop it triggers
skips the dispose, because a supervision restart re-uses the same
driver object (Props closure) and the host re-initialises it.

DiscoveryDriverBrowser disposes its throwaway capture/probe driver
after the bounded shutdown, including after a failed or timed-out one.

Review refs: 02/L1, 02/P4, 06/G-1 (host context), 07/L2.

Refs #731

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 06:23:39 -04:00

126 lines
5.4 KiB
C#

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;
/// <summary>
/// Review 02 L1 (host context, 06/G-1): the browser's throwaway capture driver must be <b>disposed</b>, not
/// only shut down. A Galaxy driver's <c>ShutdownAsync</c> left its whole gRPC runtime alive until
/// <c>DisposeAsync</c>, which nothing called — so every <c>/raw</c> browse leaked two channels, an MX session
/// and two stream loops.
/// </summary>
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<DiscoveryDriverBrowser>.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<InvalidOperationException>(
() => 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);
}
/// <summary>A <see cref="FakeDiscoveryDriver"/> wrapped with <see cref="IAsyncDisposable"/> + <see cref="IDisposable"/>
/// (both, so the "prefer the async one, never both" rule is observable: exactly ONE dispose call lands).</summary>
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();
}
}
}