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"); } }