Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaHostStart.cs
T
Joseph Doherty f02067d295 test: retry OPC UA host start on a port bind race (TOCTOU in AllocateFreePort)
The tests probe a free port (bind 0, release) and start the SDK server later; under a
highly parallel run another test can take the port in between and the SDK fails with
'Failed to establish tcp listener sockets for Ipv4 and IPv6.'. OpcUaHostStart.StartAsync
builds a fresh host + server on a fresh port per attempt (up to 5) when the failure is a
bind failure; all 45 start sites in OpcUaServer.Tests, OpcUaServer.IntegrationTests and
Runtime.Tests use it. Per-attempt GUID ApplicationUris are hoisted out of the host factory
because a changed URI against a reused PKI store fails with 'Cannot access private key'.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 22:30:55 -04:00

82 lines
3.4 KiB
C#

using System.Net;
using System.Net.Sockets;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa;
/// <summary>
/// Starts an <see cref="OpcUaApplicationHost"/> on a freshly probed port, retrying on a bind race.
/// Probing a free port (bind 0, read, release) and handing it to the SDK later is a TOCTOU: under a
/// highly parallel test run another test can take the port in between, and the SDK then fails with
/// <c>Failed to establish tcp listener sockets for Ipv4 and IPv6.</c>. A <see cref="StandardServer"/>
/// that failed to start is not safe to start again, so every attempt builds a NEW host and server on
/// a NEW port and the caller reads the port off the options it builds, never from a cached value.
/// This file is compiled into each test project that boots a real SDK server (linked, not a library).
/// </summary>
internal static class OpcUaHostStart
{
private const int DefaultAttempts = 5;
/// <summary>Probes a currently free loopback TCP port. Racy by nature - pair with <see cref="StartAsync{TServer}"/>.</summary>
public static int AllocateFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
/// <summary>
/// Builds a host (via <paramref name="createHost"/>, given the port to bind) and a server (via
/// <paramref name="createServer"/>) and starts them, retrying with a new port, host and server when
/// the bind fails. Any other failure, or the last bind failure, propagates.
/// </summary>
/// <returns>The started host and server. The caller owns disposal of the host.</returns>
public static async Task<(OpcUaApplicationHost Host, TServer Server)> StartAsync<TServer>(
Func<int, OpcUaApplicationHost> createHost,
Func<TServer> createServer,
CancellationToken ct,
int maxAttempts = DefaultAttempts)
where TServer : StandardServer
{
for (var attempt = 1; ; attempt++)
{
var host = createHost(AllocateFreePort());
var server = createServer();
try
{
await host.StartAsync(server, ct);
return (host, server);
}
catch (Exception ex) when (attempt < maxAttempts && IsBindFailure(ex))
{
try { await host.DisposeAsync(); }
catch { /* the half-started host is being discarded */ }
}
catch
{
try { await host.DisposeAsync(); }
catch { /* surface the original failure */ }
throw;
}
}
}
/// <summary>True when the exception chain says the OPC UA listener could not bind its port.</summary>
public static bool IsBindFailure(Exception? ex)
{
for (; ex is not null; ex = ex.InnerException)
{
if (ex is SocketException { SocketErrorCode: SocketError.AddressAlreadyInUse }) return true;
if (ex is ServiceResultException sre
&& (sre.StatusCode == StatusCodes.BadNoCommunication
|| sre.Message.Contains("Failed to establish tcp listener", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
}