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