fix(opcuaclient): #676 live gate A1 — a sustained closed connection is a definitive loss

Live gate A1 (docker-dev, opc-plc restart / SIGINT / kill): the fast path never fired —
a real process stop delivers no session status; the keep-alives report
BadSecureChannelClosed (0x80860000) once, then BadConnectionClosed (0x80AE0000)
interleaved with BadNoCommunication for the whole outage.

Reproduced in-process with TcpStallProxy.HardClose (reset every connection, refuse new
ones; server and session alive behind it): the identical sequence, and the SDK 1.5.378
session never opens a new connection on its own (48 s with the proxy accepting again,
zero attempts) — a closed channel cannot recover in place.

The driver now treats BadSecureChannelClosed/BadConnectionClosed as definitive on the
SECOND closed-channel keep-alive failure in one outage (a good keep-alive resets the
count), raising ConnectionLost ~1 keep-alive after the drop. BadNoCommunication /
BadRequestTimeout / BadNotConnected (stall/pause) stay with the watchdog; the stall test
still sees no ConnectionLost. New wire test (hard close + reopen ⇒ raised once, SDK never
reconnects, reinit brings the identical value back Good) and unit tests (sustained raises
once; a single blip that recovers does not) — all red on the previous code. Docs updated.
This commit is contained in:
Joseph Doherty
2026-10-11 12:34:52 -04:00
parent 945ec2c069
commit beb1c583b1
7 changed files with 218 additions and 17 deletions
@@ -105,11 +105,9 @@ public sealed class OpcUaClientConnectionLossSignalTests
public static TheoryData<uint> AmbiguousStatuses =>
[
StatusCodes.BadNoCommunication,
StatusCodes.BadConnectionClosed,
StatusCodes.BadNotConnected,
StatusCodes.BadRequestTimeout,
StatusCodes.BadTimeout,
StatusCodes.BadSecureChannelClosed,
];
[Theory]
@@ -153,6 +151,54 @@ public sealed class OpcUaClientConnectionLossSignalTests
raised.ShouldBe(0, "an ambiguous loss may yet resume in place — it belongs to the #527 watchdog");
}
/// <summary>
/// #676 live gate A1: a real upstream process stop (docker restart / SIGINT / docker kill) never produced a
/// session status — the keep-alives carried <c>BadSecureChannelClosed</c> once, then <c>BadConnectionClosed</c>
/// (interleaved with <c>BadNoCommunication</c>) for the whole outage. Measured in-process (hard-closing proxy, the
/// server and its session still alive): the SDK 1.5.378 session NEVER opens a new connection on its own, so a
/// closed channel is dead for good. Sustained — a second closed-channel failure in the same outage — it raises.
/// </summary>
[Fact]
public void A_sustained_closed_channel_raises_ConnectionLost_once()
{
var (drv, _, _, _) = NewConnectedDriver();
using var _ = drv;
var raised = new List<string>();
drv.ConnectionLost += (_, e) => raised.Add(e.Reason);
drv.RaiseKeepAliveForTest(StatusCodes.BadSecureChannelClosed); // the live sequence …
raised.ShouldBeEmpty("one closed-channel failure may be a transient");
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.BadConnectionClosed); // … sustained
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.BadConnectionClosed);
raised.ShouldHaveSingleItem().ShouldContain("BadConnectionClosed");
}
[Theory]
[InlineData(0x80860000u)] // BadSecureChannelClosed
[InlineData(0x80AE0000u)] // BadConnectionClosed
public void A_single_closed_channel_blip_that_recovers_does_not_raise(uint closed)
{
var (drv, _, _, _) = NewConnectedDriver();
using var _ = drv;
var raised = 0;
drv.ConnectionLost += (_, _) => raised++;
drv.RaiseKeepAliveForTest(closed);
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.Good); // recovered — the count starts again
drv.RaiseKeepAliveForTest(closed);
drv.RaiseKeepAliveForTest(StatusCodes.Good);
raised.ShouldBe(0);
// Positive control: the same driver DOES raise once a closed channel is sustained.
drv.RaiseKeepAliveForTest(closed);
drv.RaiseKeepAliveForTest(closed);
raised.ShouldBe(1);
}
[Fact]
public void An_ambiguous_outage_that_turns_definitive_raises_once()
{