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 a4c0c217..78161047 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 @@ -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; } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs index c405c01b..141e48b7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs @@ -264,4 +264,34 @@ public sealed class TcpRoundTripTests cts.Cancel(); try { await serverLoop; } catch (OperationCanceledException) { /* expected */ } } + + [Fact] + public async Task BindFailure_SurfacesBindError_NotPermanentNotListening() + { + // Regression (live-caught 2026-06-12): when TcpFrameServer's listener bind fails (port in a + // Windows excluded range → WSAEACCES, or already in use), the failure must surface as the + // bind SocketException on EVERY accept attempt — NOT a one-time bind error followed by a + // permanent InvalidOperationException "Not listening". The latter is the assign-before-Start + // wedge: a non-null-but-unstarted listener that EnsureListening's guard never re-Starts, + // which crash-looped the live sidecar on the reserved port 32569. + using var cts = new CancellationTokenSource(Timeout); + + // Occupy a loopback port exclusively so the server's Start() bind is forbidden. + var blocker = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true }; + blocker.Start(); + try + { + var takenPort = ((IPEndPoint)blocker.LocalEndpoint).Port; + using var server = new TcpFrameServer(IPAddress.Loopback, takenPort, "shh", tlsCert: null, Quiet); + + // First accept attempt: the bind fails with a SocketException. + await Should.ThrowAsync(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token)); + + // Second attempt MUST also be the bind SocketException — not InvalidOperationException + // "Not listening". This is the assertion that fails against the assign-before-Start bug. + var second = await Should.ThrowAsync(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token)); + second.ShouldBeOfType(); + } + finally { blocker.Stop(); } + } }