using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
///
/// DataConnectionLayer-S1 (MxGateway half): the adapter instance is reused across
/// reconnects. Before the fix, ConnectAsync overwrote _client and
/// _eventLoopCts without cancelling the previous event loop or disposing the
/// previous client — every gateway flap accumulated an orphaned stream/socket, and
/// the dying old loop's fault path could raise Disconnected against the new
/// session after the guard was reset.
///
[Collection("DataConnectionManagerActor")]
public class MxGatewayDataConnectionReconnectTests
{
[Fact]
public async Task Reconnect_DisposesPreviousClient_AndCancelsPreviousEventLoop()
{
var clients = new List();
var loopTokens = new List();
var factory = Substitute.For();
factory.Create().Returns(_ =>
{
var c = Substitute.For();
c.RunEventLoopAsync(Arg.Any>(), Arg.Do(t => loopTokens.Add(t)))
.Returns(Task.Delay(Timeout.Infinite));
clients.Add(c);
return c;
});
var adapter = new MxGatewayDataConnection(factory, NullLogger.Instance);
var details = new Dictionary { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };
// The event loop is pumped fire-and-forget (ConnectAsync → Task.Run →
// RunEventLoopAsync), so the token is recorded on a thread-pool continuation.
// Barrier on each recorded token before proceeding.
await adapter.ConnectAsync(details);
await WaitUntilAsync(() => loopTokens.Count == 1);
await adapter.ConnectAsync(details); // reconnect
await WaitUntilAsync(() => loopTokens.Count == 2);
Assert.Equal(2, clients.Count);
await clients[0].Received(1).DisposeAsync();
Assert.True(loopTokens[0].IsCancellationRequested); // old loop cancelled
Assert.False(loopTokens[1].IsCancellationRequested); // new loop live
}
[Fact]
public async Task StaleEventLoopRpcFault_AfterReconnect_DoesNotSignalDisconnected()
{
var loops = new List();
var loopTokens = new List();
var adapter = CreateAdapterWithControllableLoops(loops, loopTokens, out var disconnectsRef);
var details = new Dictionary { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };
await adapter.ConnectAsync(details);
await WaitUntilAsync(() => loopTokens.Count == 1);
await adapter.ConnectAsync(details); // reconnect: cancels loop #0, resets _disconnectFired
await WaitUntilAsync(() => loopTokens.Count == 2);
// The OLD loop observes its cancellation as a gRPC fault, not an OCE —
// the Grpc.Net default without ThrowOperationCanceledOnCancellation.
loops[0].SetException(new RpcException(new Status(StatusCode.Cancelled, "call cancelled")));
await Task.Delay(200);
Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value)); // stale fault absorbed — the fresh connection must not flap
// The CURRENT loop's genuine fault must still signal, exactly once.
loops[1].SetException(new RpcException(new Status(StatusCode.Unavailable, "gateway gone")));
await WaitUntilAsync(() => Volatile.Read(ref disconnectsRef.Value) == 1);
}
[Fact]
public async Task StaleEventLoopObjectDisposedFault_AfterReconnect_DoesNotSignalDisconnected()
{
var loops = new List();
var loopTokens = new List();
var adapter = CreateAdapterWithControllableLoops(loops, loopTokens, out var disconnectsRef);
var details = new Dictionary { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };
await adapter.ConnectAsync(details);
await WaitUntilAsync(() => loopTokens.Count == 1);
await adapter.ConnectAsync(details); // reconnect
await WaitUntilAsync(() => loopTokens.Count == 2);
// The old loop faults with the concurrent-DisposeAsync shape.
loops[0].SetException(new ObjectDisposedException("MxGatewayClient"));
await Task.Delay(200);
Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value));
}
private sealed class IntRef { public int Value; }
private static MxGatewayDataConnection CreateAdapterWithControllableLoops(
List loops, List loopTokens, out IntRef disconnectsRef)
{
var factory = Substitute.For();
factory.Create().Returns(_ =>
{
var c = Substitute.For();
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
loops.Add(tcs);
c.RunEventLoopAsync(Arg.Any>(), Arg.Do(t => loopTokens.Add(t)))
.Returns(tcs.Task);
return c;
});
var adapter = new MxGatewayDataConnection(factory, NullLogger.Instance);
var refHolder = new IntRef();
adapter.Disconnected += () => Interlocked.Increment(ref refHolder.Value);
disconnectsRef = refHolder;
return adapter;
}
private static async Task WaitUntilAsync(Func condition)
{
for (var i = 0; i < 100 && !condition(); i++)
await Task.Delay(50);
Assert.True(condition(), "condition not met within timeout");
}
}