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
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user