From db2e4777dd3dfadbb1ae9321b960bd11561936c1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 12 Jun 2026 13:34:45 -0400 Subject: [PATCH] fix(historian-sidecar): close active TCP client on cancel so RunAsync unwinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The net48 sidecar's TcpFrameServer.RunOneConnectionAsync registered the cancellation token to Stop() only the listener (to unblock a parked AcceptTcpClientAsync), but never closed the active client. On net48 NetworkStream.ReadAsync ignores the CancellationToken, so while the frame loop is parked reading an idle connected client, cancelling the token cannot unblock it — only closing the socket can. RunAsync therefore never returned on Ctrl-C/service-stop while a connection was open (Program.Main's RunAsync().GetAwaiter().GetResult() would hang until NSSM force-killed). Register the cancel to Close() the active client, and convert the resulting cancel-time read/handshake exception to OperationCanceledException so RunAsync unwinds cleanly without logging it as a connection failure or counting it toward MaxConsecutiveFailures. Caught by the first-ever net48 execution of TcpRoundTripTests on the Windows VM (these only compile on macOS): SingleActive_SecondClientHelloCompletesOnly AfterFirstCloses deadlocked in teardown. Full net48 historian suite now green (122 passed, 0 failed, 2 skipped); all 6 TcpRoundTrip tests pass. --- .../Ipc/TcpFrameServer.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs index 78161047..a253c9f6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs @@ -81,6 +81,13 @@ public sealed class TcpFrameServer : IDisposable using (client) { + // net48's NetworkStream.ReadAsync ignores the CancellationToken, so cancelling the + // token alone cannot unblock the frame loop when it's parked reading an idle client — + // only closing the socket does. Register the cancel to Close() the active client so + // RunAsync actually unwinds on shutdown (mirrors the listener.Stop() above that + // unblocks a parked AcceptTcpClientAsync). Without this, RunAsync().GetAwaiter() in + // Program.Main never returns on Ctrl-C/service-stop while a connection is open. + using var clientReg = linked.Token.Register(() => { try { client.Close(); } catch { /* ignore */ } }); client.NoDelay = true; Stream stream = client.GetStream(); SslStream? ssl = null; @@ -129,6 +136,14 @@ public sealed class TcpFrameServer : IDisposable await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false); } } + catch (Exception) when (linked.Token.IsCancellationRequested) + { + // The clientReg cancel callback closed the socket mid-read/handshake (net48 read + // doesn't observe the token); surface it as cancellation so RunAsync's + // OperationCanceledException path unwinds cleanly instead of logging a connection + // failure and counting it toward MaxConsecutiveFailures. + throw new OperationCanceledException(linked.Token); + } finally { ssl?.Dispose(); } } }