Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<SocketException>(() => 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()
Expand Down
42 changes: 42 additions & 0 deletions src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -479,6 +480,47 @@ await Assert.ThrowsAsync<OperationCanceledException>(
() => 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<SocketException>(
() => DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(deviceInfo, options));
}
finally
{
listener.Stop();
}
}

#endregion

#region ConnectFromDeviceInfo Sync Tests
Expand Down
26 changes: 23 additions & 3 deletions src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,17 +21,28 @@ public class TcpStreamTransport : IStreamTransport
/// </summary>
/// <param name="ipAddress">The IP address to connect to.</param>
/// <param name="port">The port to connect to.</param>
public TcpStreamTransport(IPAddress ipAddress, int port)
/// <param name="localInterface">
/// 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.
/// </param>
public TcpStreamTransport(IPAddress ipAddress, int port, IPAddress? localInterface = null)
{
_endPoint = new IPEndPoint(ipAddress, port);
_localInterface = localInterface;
}

/// <summary>
/// Initializes a new instance of the TcpStreamTransport class.
/// </summary>
/// <param name="host">The hostname to connect to.</param>
/// <param name="port">The port to connect to.</param>
public TcpStreamTransport(string host, int port)
/// <param name="localInterface">
/// 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.
/// </param>
public TcpStreamTransport(string host, int port, IPAddress? localInterface = null)
{
if (IPAddress.TryParse(host, out var ipAddress))
{
Expand All @@ -42,13 +54,19 @@ public TcpStreamTransport(string host, int port)
_endPoint = new IPEndPoint(IPAddress.None, port);
Hostname = host;
}
_localInterface = localInterface;
}

/// <summary>
/// Gets the hostname if provided instead of IP address.
/// </summary>
public string? Hostname { get; }

/// <summary>
/// Gets the local interface address the outbound socket will bind to, or null to let the OS choose.
/// </summary>
public IPAddress? LocalInterface => _localInterface;

/// <summary>
/// Gets the underlying stream for read/write operations.
/// </summary>
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 9 additions & 3 deletions src/Daqifi.Core/Device/DaqifiDeviceFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ private static async Task<DaqifiDevice> 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
Expand All @@ -306,11 +308,15 @@ private static async Task<DaqifiDevice> 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);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

return await ConnectWithTransportAsync(transport, modifiedOptions, cancellationToken)
.ConfigureAwait(false);
}

/// <summary>
Expand Down
Loading