From 303b081295f5cd665a405a7af6c148427d8282cc Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Mon, 4 May 2026 22:07:06 -0600 Subject: [PATCH 1/2] fix: honor LocalInterfaceAddress when connecting to discovered WiFi devices WiFiDeviceFinder already records the discovering NIC in IDeviceInfo.LocalInterfaceAddress, but the TCP connect path ignored it and let the OS routing table pick the egress interface. On multi-homed hosts that meant a device discovered on one NIC could be unconnectable because the follow-up TCP connection went out a different NIC. TcpStreamTransport now accepts an optional localInterface and, when supplied, binds the outbound socket to it before connecting. DaqifiDeviceFactory.ConnectFromDeviceInfoAsync threads deviceInfo.LocalInterfaceAddress through for the WiFi path. Behavior is unchanged when no local interface is supplied. Closes #147 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Transport/TcpStreamTransportTests.cs | 59 +++++++++++++++++++ .../Device/DaqifiDeviceFactoryTests.cs | 42 +++++++++++++ .../Transport/TcpStreamTransport.cs | 26 +++++++- src/Daqifi.Core/Device/DaqifiDeviceFactory.cs | 10 +++- 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs b/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs index 7e4709b..b1e328a 100644 --- a/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs +++ b/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs @@ -136,6 +136,65 @@ public void TcpStreamTransport_ConnectionInfo_ShouldReflectCurrentState() Assert.Contains("127.0.0.1:5000", disconnectedInfo); } + [Fact] + public void TcpStreamTransport_Constructor_WithLocalInterface_ExposesIt() + { + // Arrange & Act + using var transport = new TcpStreamTransport(IPAddress.Loopback, 5000, IPAddress.Loopback); + + // Assert + Assert.Equal(IPAddress.Loopback, transport.LocalInterface); + } + + [Fact] + public void TcpStreamTransport_Constructor_WithoutLocalInterface_LocalInterfaceIsNull() + { + // Arrange & Act + using var transport = new TcpStreamTransport(IPAddress.Loopback, 5000); + + // Assert + Assert.Null(transport.LocalInterface); + } + + [Fact] + public async Task TcpStreamTransport_ConnectAsync_WithUnassignedLocalInterface_ThrowsSocketException() + { + // Arrange - 192.0.2.1 is in TEST-NET-1 (RFC 5737) and is not assigned to any local + // interface, so binding the outbound socket to it must fail with EADDRNOTAVAIL. + // This proves the local-interface argument actually drives the socket bind. + var bogusLocal = IPAddress.Parse("192.0.2.1"); + using var transport = new TcpStreamTransport(IPAddress.Loopback, 1, bogusLocal); + + // Act & Assert + await Assert.ThrowsAsync(() => transport.ConnectAsync()); + Assert.False(transport.IsConnected); + } + + [Fact] + public async Task TcpStreamTransport_ConnectAsync_WithLoopbackLocalInterface_ConnectsAndReportsBoundLocal() + { + // Arrange - real listener on loopback so the connection actually completes + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var transport = new TcpStreamTransport(IPAddress.Loopback, port, IPAddress.Loopback); + + // Act + await transport.ConnectAsync(); + + // Assert + Assert.True(transport.IsConnected); + Assert.Contains("127.0.0.1", transport.ConnectionInfo); + await transport.DisconnectAsync(); + } + finally + { + listener.Stop(); + } + } + // Integration test that requires a real server - marked as integration test [Fact(Skip = "Integration test - requires external server")] public async Task TcpStreamTransport_RealConnection_ShouldWorkEndToEnd() diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs index 4a5108f..140ef13 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using Daqifi.Core.Communication.Transport; using Daqifi.Core.Device; using Daqifi.Core.Device.Discovery; @@ -479,6 +480,47 @@ await Assert.ThrowsAsync( () => DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(deviceInfo, null, cts.Token)); } + [Fact] + public async Task ConnectFromDeviceInfoAsync_WiFiWithLocalInterface_BindsOutboundSocketToIt() + { + // Arrange - 192.0.2.1 is in TEST-NET-1 (RFC 5737) and is not assigned to any local + // interface. If the factory threads LocalInterfaceAddress into the TCP transport, + // the bind will fail with EADDRNOTAVAIL (SocketException) before connect is attempted. + // If the factory ignores LocalInterfaceAddress, the connection to 127.0.0.1 would + // proceed and fail with a different error (or succeed against the listener), so a + // SocketException at bind time is the signal that the wiring is correct. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var deviceInfo = new TestDeviceInfo + { + ConnectionType = ConnectionType.WiFi, + IPAddress = IPAddress.Loopback, + Port = port, + LocalInterfaceAddress = IPAddress.Parse("192.0.2.1") + }; + var options = new DeviceConnectionOptions + { + ConnectionRetry = new ConnectionRetryOptions + { + Enabled = false, + ConnectionTimeout = TimeSpan.FromSeconds(1) + }, + InitializeDevice = false + }; + + // Act & Assert + await Assert.ThrowsAsync( + () => DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(deviceInfo, options)); + } + finally + { + listener.Stop(); + } + } + #endregion #region ConnectFromDeviceInfo Sync Tests diff --git a/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs b/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs index 30ec53e..b3ffde0 100644 --- a/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs +++ b/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs @@ -11,6 +11,7 @@ namespace Daqifi.Core.Communication.Transport; public class TcpStreamTransport : IStreamTransport { private readonly IPEndPoint _endPoint; + private readonly IPAddress? _localInterface; private TcpClient? _tcpClient; private NetworkStream? _networkStream; private bool _disposed; @@ -20,9 +21,15 @@ public class TcpStreamTransport : IStreamTransport /// /// The IP address to connect to. /// The port to connect to. - public TcpStreamTransport(IPAddress ipAddress, int port) + /// + /// Optional local interface address to bind the outbound socket to. When supplied, the TCP + /// connection egresses on the specified NIC; required for multi-homed hosts where the OS + /// would otherwise pick the wrong interface for the route. + /// + public TcpStreamTransport(IPAddress ipAddress, int port, IPAddress? localInterface = null) { _endPoint = new IPEndPoint(ipAddress, port); + _localInterface = localInterface; } /// @@ -30,7 +37,12 @@ public TcpStreamTransport(IPAddress ipAddress, int port) /// /// The hostname to connect to. /// The port to connect to. - public TcpStreamTransport(string host, int port) + /// + /// Optional local interface address to bind the outbound socket to. When supplied, the TCP + /// connection egresses on the specified NIC; required for multi-homed hosts where the OS + /// would otherwise pick the wrong interface for the route. + /// + public TcpStreamTransport(string host, int port, IPAddress? localInterface = null) { if (IPAddress.TryParse(host, out var ipAddress)) { @@ -42,6 +54,7 @@ public TcpStreamTransport(string host, int port) _endPoint = new IPEndPoint(IPAddress.None, port); Hostname = host; } + _localInterface = localInterface; } /// @@ -49,6 +62,11 @@ public TcpStreamTransport(string host, int port) /// public string? Hostname { get; } + /// + /// Gets the local interface address the outbound socket will bind to, or null to let the OS choose. + /// + public IPAddress? LocalInterface => _localInterface; + /// /// Gets the underlying stream for read/write operations. /// @@ -126,7 +144,9 @@ public async Task ConnectAsync(ConnectionRetryOptions? retryOptions) } } - _tcpClient = new TcpClient(); + _tcpClient = _localInterface != null + ? new TcpClient(new IPEndPoint(_localInterface, 0)) + : new TcpClient(); // Set timeouts from retry options or use defaults var timeout = (int)options.ConnectionTimeout.TotalMilliseconds; diff --git a/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs b/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs index ecd491d..2513939 100644 --- a/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs +++ b/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs @@ -306,11 +306,15 @@ private static async Task ConnectWiFiDeviceAsync( InitializeDevice = effectiveOptions.InitializeDevice }; - return await ConnectTcpAsync( + // Honor LocalInterfaceAddress so multi-homed hosts egress on the NIC that + // discovered the device, not whichever NIC the OS routing table prefers. + var transport = new TcpStreamTransport( deviceInfo.IPAddress, deviceInfo.Port.Value, - modifiedOptions, - cancellationToken).ConfigureAwait(false); + deviceInfo.LocalInterfaceAddress); + + return await ConnectWithTransportAsync(transport, modifiedOptions, cancellationToken) + .ConfigureAwait(false); } /// From 44a40ae8228d553c0397b814a694f46f4cb77ed8 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Tue, 5 May 2026 09:53:00 -0600 Subject: [PATCH 2/2] fix: restore ValidatePort in WiFi connect path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier refactor of ConnectWiFiDeviceAsync to construct TcpStreamTransport directly bypassed ConnectTcpAsync's ValidatePort call. IPEndPoint validates 0-65535, but the original behavior rejected port 0 with an explicit ArgumentOutOfRangeException named "port" — keep that for parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Daqifi.Core/Device/DaqifiDeviceFactory.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs b/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs index 2513939..d58b25c 100644 --- a/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs +++ b/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs @@ -291,6 +291,8 @@ private static async Task ConnectWiFiDeviceAsync( nameof(deviceInfo)); } + ValidatePort(deviceInfo.Port.Value); + var effectiveOptions = options ?? DeviceConnectionOptions.Default; // Use the device name from the discovery info if not overridden in options