fix(galaxy): GalaxyMxSession.CloseAsync(ct) bounds the gateway CloseSession
The SDK's DisposeAsync closes with no token, i.e. under the 30 s DefaultCallTimeout, so a blackholed gateway parked the driver's teardown for the whole of it. CloseAsync(ct) runs the close under the caller's token, drops the channel on a cancelled/failed close (the gateway reaps the session on its idle lease) and leaves the session re-openable. A close followed by the final dispose closes the gateway session once. Refs #735 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -60,6 +60,12 @@ public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
/// </summary>
|
||||
internal Func<CancellationToken, Task>? OpenAndRegisterOverrideForTests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — replaces the gateway <c>CloseSession</c> round-trip in <see cref="CloseAsync"/>
|
||||
/// so the bounded-close behaviour can be exercised against a never-answering fake.
|
||||
/// </summary>
|
||||
internal Func<CancellationToken, Task>? CloseOverrideForTests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close the gateway session and release the owned client under
|
||||
/// <paramref name="cancellationToken"/>. The SDK's <c>DisposeAsync</c> closes with NO token,
|
||||
/// i.e. under the 30 s <c>DefaultCallTimeout</c> — against a blackholed gateway that parks
|
||||
/// the driver's <c>ShutdownAsync</c> (and the host's <c>PostStop</c>) 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 <c>_disposed</c>).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Bounds the gateway <c>CloseSession</c> round-trip.</param>
|
||||
/// <returns>A task that completes once the session and client are released.</returns>
|
||||
public Task CloseAsync(CancellationToken cancellationToken) => TeardownSessionAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the connect flag and best-effort disposes + nulls the owned session and client.
|
||||
/// Shared by <see cref="DisposeAsync"/> (final teardown) and <see cref="RecreateAsync"/> /
|
||||
/// the partial-open recovery path (rebuild teardown) — it does NOT set <c>_disposed</c>, 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 <see cref="DisposeAsync"/> (final teardown), <see cref="CloseAsync"/> (bounded
|
||||
/// teardown) and <see cref="RecreateAsync"/> / the partial-open recovery path (rebuild
|
||||
/// teardown) — it does NOT set <c>_disposed</c>, so a torn-down session can be re-opened.
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
+90
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="GalaxyMxSession.CloseAsync"/> bounds the gateway <c>CloseSession</c> round-trip by
|
||||
/// the caller's token (the SDK dispose closes under its 30 s call timeout) and leaves the session
|
||||
/// re-openable.
|
||||
/// </summary>
|
||||
public sealed class GalaxyMxSessionCloseTests
|
||||
{
|
||||
/// <summary>A close against a never-answering gateway returns when the token fires, without throwing.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a close that answers completes and reports the token it was handed.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user