fix(historian-sidecar): close active TCP client on cancel so RunAsync unwinds
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

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.
This commit is contained in:
Joseph Doherty
2026-06-12 13:34:45 -04:00
parent 6218512365
commit db2e4777dd
@@ -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(); }
}
}