diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs index 0daae292..75550740 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs @@ -60,6 +60,12 @@ public sealed class GalaxyMxSession : IAsyncDisposable /// internal Func? OpenAndRegisterOverrideForTests { get; set; } + /// + /// Test seam — replaces the gateway CloseSession round-trip in + /// so the bounded-close behaviour can be exercised against a never-answering fake. + /// + internal Func? CloseOverrideForTests { get; set; } + /// /// Connect the underlying gateway client + open an MXAccess session + register the /// configured client name. Idempotent — second calls are no-ops while a connect has @@ -96,7 +102,7 @@ public sealed class GalaxyMxSession : IAsyncDisposable { // A partial open (e.g. OpenSession succeeded but Register threw) must not leave a half-open // handle that blocks the next reconnect. Tear the partial state down so a retry rebuilds cleanly. - await TeardownSessionAsync().ConfigureAwait(false); + await TeardownSessionAsync(CancellationToken.None).ConfigureAwait(false); throw; } } @@ -112,10 +118,23 @@ public sealed class GalaxyMxSession : IAsyncDisposable public async Task RecreateAsync(MxGatewayClientOptions clientOptions, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); - await TeardownSessionAsync().ConfigureAwait(false); + await TeardownSessionAsync(cancellationToken).ConfigureAwait(false); await ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false); } + /// + /// Close the gateway session and release the owned client under + /// . The SDK's DisposeAsync closes with NO token, + /// i.e. under the 30 s DefaultCallTimeout — against a blackholed gateway that parks + /// the driver's ShutdownAsync (and the host's PostStop) for the full timeout. + /// A close that is cancelled or fails is NOT retried through the SDK dispose (which would + /// park again); the channel is dropped and the gateway reaps the session on its idle lease. + /// The session can be re-opened afterwards (this does not set _disposed). + /// + /// Bounds the gateway CloseSession round-trip. + /// A task that completes once the session and client are released. + public Task CloseAsync(CancellationToken cancellationToken) => TeardownSessionAsync(cancellationToken); + /// /// Test seam — attach a session opened externally (e.g. against an in-process gw /// fake). Skips the gateway-client construction so tests can drive the session @@ -143,24 +162,45 @@ public sealed class GalaxyMxSession : IAsyncDisposable { if (_disposed) return; _disposed = true; - await TeardownSessionAsync().ConfigureAwait(false); + await TeardownSessionAsync(CancellationToken.None).ConfigureAwait(false); } /// - /// Resets the connect flag and best-effort disposes + nulls the owned session and client. - /// Shared by (final teardown) and / - /// the partial-open recovery path (rebuild teardown) — it does NOT set _disposed, so - /// a torn-down session can be re-opened. + /// Resets the connect flag and best-effort closes + nulls the owned session and client. + /// Shared by (final teardown), (bounded + /// teardown) and / the partial-open recovery path (rebuild + /// teardown) — it does NOT set _disposed, so a torn-down session can be re-opened. /// - private async Task TeardownSessionAsync() + private async Task TeardownSessionAsync(CancellationToken cancellationToken) { + var wasConnected = _connected; _connected = false; + // The override models the gateway round-trip, so it runs only when there was a live + // session to close — a second teardown (CloseAsync then DisposeAsync) must not close twice. + var closeOverride = CloseOverrideForTests; + if (closeOverride is not null && wasConnected) + { + try { await closeOverride(cancellationToken).ConfigureAwait(false); } + catch (Exception ex) { _logger.LogWarning(ex, "GalaxyMxSession close failed or timed out (best-effort)"); } + } + if (_session is not null) { - try { await _session.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) { _logger.LogWarning(ex, "GalaxyMxSession session dispose failed (best-effort)"); } + var session = _session; _session = null; + try + { + // CloseAsync(ct) is the bounded gateway round-trip; DisposeAsync only re-runs an + // un-replied close (with no token), so it is reached only after a successful one. + await session.CloseAsync(cancellationToken).ConfigureAwait(false); + await session.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "GalaxyMxSession close failed or timed out (best-effort) — dropping the channel; the gateway reaps the session on its idle lease."); + } } if (_ownedClient is not null) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyMxSessionCloseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyMxSessionCloseTests.cs new file mode 100644 index 00000000..aa6219c3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyMxSessionCloseTests.cs @@ -0,0 +1,90 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; + +/// +/// bounds the gateway CloseSession round-trip by +/// the caller's token (the SDK dispose closes under its 30 s call timeout) and leaves the session +/// re-openable. +/// +public sealed class GalaxyMxSessionCloseTests +{ + /// A close against a never-answering gateway returns when the token fires, without throwing. + [Fact] + public async Task CloseAsync_returns_at_the_token_deadline_against_a_never_answering_gateway() + { + var session = new GalaxyMxSession(new GalaxyMxAccessOptions("CloseTest")) + { + OpenAndRegisterOverrideForTests = _ => Task.CompletedTask, + CloseOverrideForTests = ct => Task.Delay(Timeout.Infinite, ct), + }; + await session.ConnectAsync(null!, CancellationToken.None); + session.IsConnected.ShouldBeTrue(); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + var sw = System.Diagnostics.Stopwatch.StartNew(); + await session.CloseAsync(cts.Token).WaitAsync(TimeSpan.FromSeconds(10)); + sw.Stop(); + + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); + session.IsConnected.ShouldBeFalse(); + + // Still usable: a close is not a dispose. + await session.ConnectAsync(null!, CancellationToken.None); + session.IsConnected.ShouldBeTrue(); + } + + /// Positive control: a close that answers completes and reports the token it was handed. + [Fact] + public async Task CloseAsync_completes_when_the_gateway_answers() + { + CancellationToken seen = default; + var session = new GalaxyMxSession(new GalaxyMxAccessOptions("CloseTest")) + { + OpenAndRegisterOverrideForTests = _ => Task.CompletedTask, + CloseOverrideForTests = ct => { seen = ct; return Task.CompletedTask; }, + }; + await session.ConnectAsync(null!, CancellationToken.None); + + using var cts = new CancellationTokenSource(); + await session.CloseAsync(cts.Token); + + seen.ShouldBe(cts.Token, "the caller's token must reach the gateway round-trip"); + session.IsConnected.ShouldBeFalse(); + } + + /// + /// The driver's teardown is CloseAsync (bounded) then DisposeAsync (final). The dispose must + /// not issue a second gateway close; a fresh connect after a close must close again (positive + /// control: the guard keys on "was connected", not on "ever closed"). + /// + [Fact] + public async Task CloseAsync_then_DisposeAsync_closes_the_gateway_session_once() + { + var closes = 0; + var session = new GalaxyMxSession(new GalaxyMxAccessOptions("CloseTest")) + { + OpenAndRegisterOverrideForTests = _ => Task.CompletedTask, + CloseOverrideForTests = _ => { Interlocked.Increment(ref closes); return Task.CompletedTask; }, + }; + await session.ConnectAsync(null!, CancellationToken.None); + + await session.CloseAsync(CancellationToken.None); + await session.DisposeAsync(); + closes.ShouldBe(1, "dispose after a close must not close the gateway session again"); + + var reopened = new GalaxyMxSession(new GalaxyMxAccessOptions("CloseTest")) + { + OpenAndRegisterOverrideForTests = _ => Task.CompletedTask, + CloseOverrideForTests = _ => { Interlocked.Increment(ref closes); return Task.CompletedTask; }, + }; + await reopened.ConnectAsync(null!, CancellationToken.None); + await reopened.CloseAsync(CancellationToken.None); + await reopened.ConnectAsync(null!, CancellationToken.None); + await reopened.DisposeAsync(); + closes.ShouldBe(3, "each live session closes exactly once"); + } +}