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:
+63
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user