Fix macOS unicast loopback UDP PubSub transport failures (#4285) (#4290)

## Summary

Fixes #4285 — on macOS the `Opc.Ua.Tools.Tests` PubSub runtime-tool
tests failed with `SocketException` ("Socket is already connected" on
the publisher side, "No route to host" on the subscriber side) when
using unicast loopback UDP (`opc.udp://127.0.0.1:{port}`).

## Root cause

`UdpDatagramTransport.BindForUnicast` bound sockets to `IPAddress.Any`
(`0.0.0.0` / `::`) and then either `Connect`'d or `SendTo`'d a loopback
destination. macOS (Darwin) is stricter than Windows/Linux about routing
loopback traffic from a wildcard-bound socket:

- **Publisher (connected send):** `Connect(127.0.0.1)` after
`Bind(0.0.0.0:0)` throws `EISCONN` ("Socket is already connected").
- **Subscriber (receive / bidirectional):** binding to `0.0.0.0` and
exchanging datagrams with `127.0.0.1` fails with "No route to host".

## Fix

In `src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs`:

- Add `SelectUnicastBindAddress()` — when the endpoint address is a
loopback address, bind to the matching loopback address (`127.0.0.1` /
`::1`) so traffic is routed over the loopback interface. Non-loopback
endpoints keep binding to the wildcard address so datagrams can still
arrive on any interface (no behavior change off loopback).
- Guard `socket.Connect(...)` against `SocketError.IsConnected`
(EISCONN) and treat it as a benign already-connected socket (logged at
Debug), as suggested in the issue.

This is platform-agnostic: on Windows/Linux the loopback bind behaves
identically, and on macOS it removes both socket errors.

## Testing

- `dotnet build src/Opc.Ua.PubSub.Udp` — clean (0 warnings, 0 errors).
- `dotnet test tests/Opc.Ua.PubSub.Udp.Tests`
(`UdpDatagramTransportUnicastTests`) — the existing loopback unicast
publish/receive integration tests pass on net10.0.

macOS validation still relies on CI (change authored on Windows), but
the binding change directly targets the reported Darwin behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
romanett
2026-08-20 17:01:01 +00:00
committed by GitHub
co-authored by Copilot App
parent 2a4da11a9f
commit b7331a11f2
+59 -10
View File
@@ -558,7 +558,9 @@ namespace Opc.Ua.PubSub.Udp
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken)
{
if (socket.AddressFamily == AddressFamily.InterNetwork)
if (socket.AddressFamily == AddressFamily.InterNetwork &&
socket.LocalEndPoint is IPEndPoint localEndPoint &&
!IPAddress.IsLoopback(localEndPoint.Address))
{
await SendOnceAsync(
socket,
@@ -569,6 +571,9 @@ namespace Opc.Ua.PubSub.Udp
return;
}
// A socket bound to loopback cannot route the standard multicast discovery endpoint
// on Windows. Use a wildcard-bound IPv4 socket for discovery while retaining the
// loopback binding that unicast data traffic requires on macOS.
using Socket discoverySocket = new(
AddressFamily.InterNetwork,
SocketType.Dgram,
@@ -941,27 +946,63 @@ namespace Opc.Ua.PubSub.Udp
private void BindForUnicast(Socket socket)
{
IPAddress localAddress = SelectUnicastBindAddress();
if ((HasSendDirection && !HasReceiveDirection) || (m_useConnectedUnicastClient && HasSendDirection))
{
EndPoint bindEndPoint = Endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6
? new IPEndPoint(IPAddress.IPv6Any, 0)
: new IPEndPoint(IPAddress.Any, 0);
socket.Bind(bindEndPoint);
socket.Bind(new IPEndPoint(localAddress, 0));
IPEndPoint remote = new(Endpoint.Address, Endpoint.Port);
socket.Connect(remote);
try
{
socket.Connect(remote);
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.IsConnected)
{
if (socket.RemoteEndPoint is IPEndPoint existing && existing.Equals(remote))
{
// macOS (Darwin) can report EISCONN when connecting a datagram socket
// that the OS already associated with the destination. The socket is
// connected to the intended peer, so treat this as success.
if (m_logger.IsEnabled(LogLevel.Debug))
{
m_logger.UdpUnicastAlreadyConnected(ex, m_connection.Name, remote.ToString());
}
}
else
{
throw;
}
}
m_sendDestination = remote;
m_socketIsConnected = true;
}
else
{
EndPoint bindEndPoint = Endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6
? new IPEndPoint(IPAddress.IPv6Any, Endpoint.Port)
: new IPEndPoint(IPAddress.Any, Endpoint.Port);
socket.Bind(bindEndPoint);
socket.Bind(new IPEndPoint(localAddress, Endpoint.Port));
m_sendDestination = new IPEndPoint(Endpoint.Address, Endpoint.Port);
}
}
/// <summary>
/// Selects the local address a unicast socket binds to. Loopback endpoints bind to the
/// matching loopback address so that traffic is routed over the loopback interface. On
/// macOS binding to <see cref="IPAddress.Any"/> and then sending to / connecting a loopback
/// destination fails with "No route to host" / "Socket is already connected"; binding to the
/// loopback address avoids both. Non-loopback endpoints keep binding to the wildcard address
/// so datagrams can arrive on any interface.
/// </summary>
private IPAddress SelectUnicastBindAddress()
{
if (Endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6)
{
return IPAddress.IsLoopback(Endpoint.Address)
? IPAddress.IPv6Loopback
: IPAddress.IPv6Any;
}
return IPAddress.IsLoopback(Endpoint.Address)
? IPAddress.Loopback
: IPAddress.Any;
}
private void JoinMulticastGroup(Socket socket)
{
if (Endpoint.Address.AddressFamily == AddressFamily.InterNetwork)
@@ -1338,6 +1379,14 @@ namespace Opc.Ua.PubSub.Udp
this ILogger logger,
Exception exception,
string? connection);
[LoggerMessage(EventId = PubSubUdpEventIds.UdpDatagramTransport + 18, Level = LogLevel.Debug,
Message = "Unicast socket for connection '{Connection}' already connected to {Endpoint}; continuing.")]
public static partial void UdpUnicastAlreadyConnected(
this ILogger logger,
Exception exception,
string? connection,
string endpoint);
}
}