fix(historian-sidecar): don't wedge the TCP listener when Start() bind fails
v2-ci / build (push) Failing after 46s
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

Live verification on a Windows VM surfaced a crash loop: TcpFrameServer.EnsureListening
assigned _listener = new TcpListener(...) BEFORE calling Start(). When Start() throws —
e.g. the port is in a Windows excluded/reserved range (WSAEACCES) or already in use — the
field was left non-null-but-unstarted, so the `if (_listener is not null) return` guard
permanently skipped re-Start() and every subsequent AcceptTcpClientAsync() threw the
misleading InvalidOperationException "Not listening" → 20 failures → exit 2 → NSSM restart
→ loop. Now _listener is assigned only after Start() succeeds, so a transient bind failure
is retried and a permanent one surfaces the real bind error each iteration. Adds a
regression test that forces a bind conflict and asserts the SocketException persists.
This commit is contained in:
Joseph Doherty
2026-06-12 13:02:22 -04:00
parent 1be06502c7
commit 6218512365
2 changed files with 40 additions and 2 deletions
@@ -48,8 +48,16 @@ public sealed class TcpFrameServer : IDisposable
private void EnsureListening()
{
if (_listener is not null) return;
_listener = new TcpListener(_bind, _port);
_listener.Start();
// Assign _listener ONLY after Start() succeeds. If Start() throws (e.g. the port is in
// a Windows excluded/reserved range → WSAEACCES "access forbidden", or already in use),
// _listener must stay null so the next RunAsync iteration retries the full create+Start.
// Assigning before Start() leaves a non-null-but-unstarted listener that the
// `if (_listener is not null) return` guard would never re-Start, turning a one-time
// bind error into a permanent misleading "Not listening" crash loop.
var listener = new TcpListener(_bind, _port);
listener.Start();
_listener = listener;
}
/// <summary>