fix(dcl): MxGateway adapter treats a graceful end of the event stream as a disconnect — a dashboard 'close session' (or graceful gateway shutdown) completes the stream with OK, leaving the adapter Connected on a dead session: values frozen at last GOOD quality, every write 'Session … was not found' until restart (QA vq03 Z28061 MoveIn, 2026-08-20). Pinned: signals once while connected, silent for superseded loops / own teardown

This commit is contained in:
Joseph Doherty
2026-08-20 16:51:13 -04:00
parent f5a74db3de
commit da0bfe2be1
3 changed files with 81 additions and 1 deletions
@@ -1,6 +1,8 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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;
@@ -93,6 +95,67 @@ public class MxGatewayDataConnectionReconnectTests
Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value));
}
/// <summary>
/// QA incident 2026-08-20 (vq03 Z28061 MoveIn): an operator closed ScadaBridge's session
/// from the MxAccess Gateway dashboard. The gateway completes the session's event stream
/// GRACEFULLY (<c>CompleteAllSubscribers(error: null)</c>, OK status) — the same path a
/// graceful gateway shutdown takes — so <c>RunEventLoopAsync</c> returned normally and the
/// adapter never raised <c>Disconnected</c>: values froze at their last GOOD quality and
/// every write went to the dead session (<c>Session … was not found</c>) for an hour, until
/// a process restart. A non-cancelled end of the event stream IS a disconnect.
/// </summary>
[Fact]
public async Task EventStreamGracefulCompletion_WhileConnected_SignalsDisconnected()
{
var loops = new List<TaskCompletionSource>();
var loopTokens = new List<CancellationToken>();
var adapter = CreateAdapterWithControllableLoops(loops, loopTokens, out var disconnectsRef);
var details = new Dictionary<string, string> { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };
await adapter.ConnectAsync(details);
await WaitUntilAsync(() => loopTokens.Count == 1);
loops[0].SetResult(); // server ended the stream with OK — session closed underneath us
await WaitUntilAsync(() => Volatile.Read(ref disconnectsRef.Value) == 1);
Assert.Equal(ConnectionHealth.Disconnected, adapter.Status);
}
[Fact]
public async Task EventStreamGracefulCompletion_OfSupersededLoop_DoesNotSignalDisconnected()
{
var loops = new List<TaskCompletionSource>();
var loopTokens = new List<CancellationToken>();
var adapter = CreateAdapterWithControllableLoops(loops, loopTokens, out var disconnectsRef);
var details = new Dictionary<string, string> { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };
await adapter.ConnectAsync(details);
await WaitUntilAsync(() => loopTokens.Count == 1);
await adapter.ConnectAsync(details); // reconnect: loop #0 is cancelled
await WaitUntilAsync(() => loopTokens.Count == 2);
loops[0].SetResult(); // the OLD loop winds down normally after its cancel
await Task.Delay(200);
Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value)); // must not flap the new session
}
[Fact]
public async Task EventStreamGracefulCompletion_AfterDisconnectAsync_DoesNotSignalDisconnected()
{
var loops = new List<TaskCompletionSource>();
var loopTokens = new List<CancellationToken>();
var adapter = CreateAdapterWithControllableLoops(loops, loopTokens, out var disconnectsRef);
var details = new Dictionary<string, string> { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "k" };
await adapter.ConnectAsync(details);
await WaitUntilAsync(() => loopTokens.Count == 1);
await adapter.DisconnectAsync(); // our own teardown cancels the loop
loops[0].SetResult();
await Task.Delay(200);
Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value));
}
private sealed class IntRef { public int Value; }
private static MxGatewayDataConnection CreateAdapterWithControllableLoops(