diff --git a/.gitignore b/.gitignore
index 36fda64..2f5890b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -254,3 +254,6 @@ paket-files/
#SoundCloud
*.sonarqube/
.sonarlint
+
+# Auto-generated test configuration (produced from .runsettings at build time)
+Tests/SocketTests/TestConfiguration.cs
diff --git a/.runsettings b/.runsettings
index ed33e90..0acfbb0 100644
--- a/.runsettings
+++ b/.runsettings
@@ -7,6 +7,12 @@
net48
x64
+
+
+
+
+
None
False
diff --git a/Tests/NetworkTestCompanion/CommandServer.cs b/Tests/NetworkTestCompanion/CommandServer.cs
new file mode 100644
index 0000000..9c06674
--- /dev/null
+++ b/Tests/NetworkTestCompanion/CommandServer.cs
@@ -0,0 +1,275 @@
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace NetworkTestCompanion;
+
+///
+/// Control channel (default port 11000). Accepts newline-delimited JSON commands from MCU tests
+/// and manages the lifecycle of TCP/UDP echo servers on demand.
+///
+/// Supported commands:
+/// { "cmd": "ping" }
+/// { "cmd": "start_tcp_echo", "port": N }
+/// { "cmd": "start_udp_echo", "port": N }
+/// { "cmd": "stop", "port": N }
+/// { "cmd": "stop_all" }
+/// { "cmd": "connect_to", "host": "...", "port": N }
+///
+internal sealed class CommandServer : IDisposable
+{
+ private readonly TcpListener _listener;
+ private readonly IPAddress _bindAddress;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Dictionary _activeServers = [];
+ private readonly Lock _lock = new();
+ private Task? _acceptLoop;
+
+ internal CommandServer(IPAddress bindAddress, int port)
+ {
+ _bindAddress = bindAddress;
+ _listener = new TcpListener(bindAddress, port);
+ }
+
+ internal void Start()
+ {
+ _listener.Start();
+ _acceptLoop = AcceptLoopAsync(_cts.Token);
+ }
+
+ private async Task AcceptLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync(ct);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[CMD] Accept error: {ex.Message}");
+ continue;
+ }
+
+ _ = HandleClientAsync(client, ct);
+ }
+ }
+
+ private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
+ {
+ using (client)
+ {
+ using var reader = new StreamReader(client.GetStream(), Encoding.UTF8, leaveOpen: true);
+ using var writer = new StreamWriter(client.GetStream(), Encoding.UTF8, leaveOpen: true) { AutoFlush = true };
+
+ try
+ {
+ string? line;
+ while ((line = await reader.ReadLineAsync(ct)) != null)
+ {
+ var response = ProcessCommand(line.Trim());
+ await writer.WriteLineAsync(response);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (IOException) { }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[CMD] Handler error: {ex.Message}");
+ }
+ }
+ }
+
+ private string ProcessCommand(string json)
+ {
+ JsonNode? node;
+ try
+ {
+ node = JsonNode.Parse(json);
+ if (node == null) return Error("empty command");
+ }
+ catch
+ {
+ return Error("invalid JSON");
+ }
+
+ var cmd = node["cmd"]?.GetValue();
+ return cmd switch
+ {
+ "ping" => Ok(new { ip = _bindAddress.ToString() }),
+ "start_tcp_echo" => StartTcpEcho(node),
+ "start_udp_echo" => StartUdpEcho(node),
+ "stop" => Stop(node),
+ "stop_all" => StopAll(),
+ "connect_to" => ConnectTo(node),
+ _ => Error($"unknown command: {cmd}")
+ };
+ }
+
+ private string StartTcpEcho(JsonNode node)
+ {
+ if (!TryGetPort(node, out var port, out var err)) return err!;
+
+ lock (_lock)
+ {
+ if (_activeServers.ContainsKey(port))
+ return Error($"port {port} already in use");
+
+ var server = new TcpEchoServer(_bindAddress, port);
+ try
+ {
+ server.Start();
+ }
+ catch (Exception ex)
+ {
+ server.Dispose();
+ return Error(ex.Message);
+ }
+
+ _activeServers[port] = server;
+ }
+
+ Console.WriteLine($"[CMD] TCP echo started on port {port}");
+ return Ok();
+ }
+
+ private string StartUdpEcho(JsonNode node)
+ {
+ if (!TryGetPort(node, out var port, out var err)) return err!;
+
+ lock (_lock)
+ {
+ if (_activeServers.ContainsKey(port))
+ return Error($"port {port} already in use");
+
+ var server = new UdpEchoServer(_bindAddress, port);
+ try
+ {
+ server.Start();
+ }
+ catch (Exception ex)
+ {
+ server.Dispose();
+ return Error(ex.Message);
+ }
+
+ _activeServers[port] = server;
+ }
+
+ Console.WriteLine($"[CMD] UDP echo started on port {port}");
+ return Ok();
+ }
+
+ private string Stop(JsonNode node)
+ {
+ if (!TryGetPort(node, out var port, out var err)) return err!;
+
+ lock (_lock)
+ {
+ if (!_activeServers.TryGetValue(port, out var server))
+ return Error($"no server on port {port}");
+
+ server.Dispose();
+ _activeServers.Remove(port);
+ }
+
+ Console.WriteLine($"[CMD] Stopped server on port {port}");
+ return Ok();
+ }
+
+ private string StopAll()
+ {
+ lock (_lock)
+ {
+ foreach (var server in _activeServers.Values)
+ server.Dispose();
+ _activeServers.Clear();
+ }
+
+ Console.WriteLine("[CMD] All servers stopped");
+ return Ok();
+ }
+
+ private string ConnectTo(JsonNode node)
+ {
+ var host = node["host"]?.GetValue();
+ if (string.IsNullOrEmpty(host)) return Error("missing 'host'");
+ if (!TryGetPort(node, out var port, out var err)) return err!;
+
+ // Connect synchronously so the connection is in the MCU's listen backlog
+ // before we return ok and the MCU calls Accept().
+ // The client is kept alive asynchronously so the connection isn't torn down
+ // before the MCU has had time to accept - a successful Accept() is sufficient
+ // proof of connectivity; no probe exchange is needed.
+ TcpClient? connectClient = null;
+ try
+ {
+ connectClient = new TcpClient();
+ if (!connectClient.ConnectAsync(host, port).Wait(TimeSpan.FromSeconds(5)))
+ {
+ connectClient.Dispose();
+ return Error($"connect to {host}:{port} timed out");
+ }
+
+ Console.WriteLine($"[CMD] connect_to {host}:{port} succeeded");
+
+ // Close after the MCU has had time to call Accept().
+ var clientToClose = connectClient;
+ _ = Task.Delay(2000).ContinueWith(_ => clientToClose.Dispose());
+
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ connectClient?.Dispose();
+ Console.Error.WriteLine($"[CMD] connect_to {host}:{port} failed: {ex.Message}");
+ return Error(ex.Message);
+ }
+ }
+
+ private static bool TryGetPort(JsonNode node, out int port, out string? error)
+ {
+ port = 0;
+ error = null;
+ var portNode = node["port"];
+ if (portNode == null) { error = Error("missing 'port'"); return false; }
+ try { port = portNode.GetValue(); return true; }
+ catch { error = Error("'port' must be an integer"); return false; }
+ }
+
+ private static string Ok(object? extra = null)
+ {
+ if (extra == null) return "{\"ok\":true}";
+ var extraJson = JsonSerializer.Serialize(extra);
+ // Merge { "ok": true } with the extra object
+ var merged = $"{{\"ok\":true,{extraJson.TrimStart('{').TrimEnd('}')}}}";
+ return merged.Replace(",}", "}");
+ }
+
+ private static string Error(string message) =>
+ JsonSerializer.Serialize(new { ok = false, error = message });
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _listener.Stop();
+ lock (_lock)
+ {
+ foreach (var server in _activeServers.Values)
+ server.Dispose();
+ _activeServers.Clear();
+ }
+ _acceptLoop?.Wait(TimeSpan.FromSeconds(2));
+ _cts.Dispose();
+ }
+}
diff --git a/Tests/NetworkTestCompanion/FirewallHelper.cs b/Tests/NetworkTestCompanion/FirewallHelper.cs
new file mode 100644
index 0000000..4d3f832
--- /dev/null
+++ b/Tests/NetworkTestCompanion/FirewallHelper.cs
@@ -0,0 +1,146 @@
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+
+namespace NetworkTestCompanion;
+
+internal static class FirewallHelper
+{
+ private const string RulePrefix = "nF-TestCompanion";
+
+ internal static void Setup(int[] tcpPorts, int[] udpPorts)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ SetupWindows(tcpPorts, udpPorts);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ PrintMacOsGuidance();
+ }
+ else
+ {
+ SetupLinux(tcpPorts, udpPorts);
+ }
+ }
+
+ internal static void Remove(int[] tcpPorts, int[] udpPorts)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ RemoveWindows(tcpPorts, udpPorts);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ Console.WriteLine("macOS: no rules were added automatically - nothing to remove.");
+ }
+ else
+ {
+ Console.WriteLine("Linux: remove any ufw rules you added manually with 'sudo ufw delete allow /tcp'.");
+ }
+ }
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private static void SetupWindows(int[] tcpPorts, int[] udpPorts)
+ {
+ if (!IsElevatedWindows())
+ {
+ Console.Error.WriteLine("ERROR: --setup-firewall requires administrator privileges on Windows.");
+ Console.Error.WriteLine("Re-run this command from an elevated prompt:");
+ Console.Error.WriteLine($" runas /user:Administrator \"{Environment.ProcessPath}\" --setup-firewall");
+ Environment.Exit(1);
+ }
+
+ foreach (var port in tcpPorts)
+ {
+ RunNetsh($"advfirewall firewall add rule name=\"{RulePrefix}-TCP-{port}\" dir=in action=allow protocol=TCP localport={port}");
+ }
+
+ foreach (var port in udpPorts)
+ {
+ RunNetsh($"advfirewall firewall add rule name=\"{RulePrefix}-UDP-{port}\" dir=in action=allow protocol=UDP localport={port}");
+ }
+
+ Console.WriteLine($"Windows firewall rules added for TCP ports [{string.Join(", ", tcpPorts)}] and UDP ports [{string.Join(", ", udpPorts)}].");
+ Console.WriteLine($"Remove them later with: --remove-firewall");
+ }
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private static void RemoveWindows(int[] tcpPorts, int[] udpPorts)
+ {
+ if (!IsElevatedWindows())
+ {
+ Console.Error.WriteLine("ERROR: --remove-firewall requires administrator privileges on Windows.");
+ Environment.Exit(1);
+ }
+
+ foreach (var port in tcpPorts)
+ {
+ RunNetsh($"advfirewall firewall delete rule name=\"{RulePrefix}-TCP-{port}\"");
+ }
+
+ foreach (var port in udpPorts)
+ {
+ RunNetsh($"advfirewall firewall delete rule name=\"{RulePrefix}-UDP-{port}\"");
+ }
+
+ Console.WriteLine($"Windows firewall rules removed for TCP ports [{string.Join(", ", tcpPorts)}] and UDP ports [{string.Join(", ", udpPorts)}].");
+ }
+
+ private static void PrintMacOsGuidance()
+ {
+ Console.WriteLine("macOS: The Application Firewall will prompt you to Allow or Deny when the companion");
+ Console.WriteLine("first starts listening. Click 'Allow' to permit inbound connections from MCU devices.");
+ Console.WriteLine("No automated action is needed.");
+ }
+
+ private static void SetupLinux(int[] tcpPorts, int[] udpPorts)
+ {
+ // Print ufw instructions unconditionally: 'ufw status' requires elevated
+ // permissions and silently returns no output when run as a normal user,
+ // which would suppress necessary guidance even when ufw is active.
+ Console.WriteLine("Linux: if ufw is active, run the following commands to open the required ports:");
+
+ foreach (var port in tcpPorts)
+ {
+ Console.WriteLine($" sudo ufw allow {port}/tcp");
+ }
+
+ foreach (var port in udpPorts)
+ {
+ Console.WriteLine($" sudo ufw allow {port}/udp");
+ }
+
+ Console.WriteLine("NOTE: for iptables/nftables environments, see Tests/README.md for equivalent rules.");
+ }
+
+ private static void RunNetsh(string args)
+ {
+ var psi = new ProcessStartInfo("netsh", args)
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+
+ using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start netsh.");
+ proc.WaitForExit();
+
+ if (proc.ExitCode != 0)
+ {
+ var err = proc.StandardError.ReadToEnd().Trim();
+ throw new InvalidOperationException($"netsh failed (exit {proc.ExitCode}): {err}");
+ }
+ }
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private static bool IsElevatedWindows()
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var principal = new WindowsPrincipal(identity);
+ return principal.IsInRole(WindowsBuiltInRole.Administrator);
+ }
+}
diff --git a/Tests/NetworkTestCompanion/NetworkTestCompanion.csproj b/Tests/NetworkTestCompanion/NetworkTestCompanion.csproj
new file mode 100644
index 0000000..7d7040b
--- /dev/null
+++ b/Tests/NetworkTestCompanion/NetworkTestCompanion.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ NetworkTestCompanion
+ NetworkTestCompanion
+ false
+ false
+
+
+
diff --git a/Tests/NetworkTestCompanion/Program.cs b/Tests/NetworkTestCompanion/Program.cs
new file mode 100644
index 0000000..fc93d72
--- /dev/null
+++ b/Tests/NetworkTestCompanion/Program.cs
@@ -0,0 +1,95 @@
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using NetworkTestCompanion;
+
+const int DefaultControlPort = 11000;
+int[] WellKnownTcpPorts = [DefaultControlPort, 7, 8, 9, 10, 80, 8080];
+int[] WellKnownUdpPorts = [7, 8, 9];
+
+// Argument parsing
+var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray();
+bool setupFirewall = cliArgs.Contains("--setup-firewall");
+bool removeFirewall = cliArgs.Contains("--remove-firewall");
+int controlPort = DefaultControlPort;
+
+// Listener binds to Any so it accepts both LAN (MCU) and loopback (emulator) connections.
+// --ip overrides the bind address AND the reported LAN IP.
+IPAddress bindAddress = IPAddress.Any;
+IPAddress lanIp = ResolveLanIp();
+
+for (int i = 0; i < cliArgs.Length; i++)
+{
+ if (cliArgs[i] == "--control-port" && i + 1 < cliArgs.Length)
+ {
+ controlPort = int.Parse(cliArgs[++i]);
+ }
+ else if (cliArgs[i] == "--ip" && i + 1 < cliArgs.Length)
+ {
+ bindAddress = IPAddress.Parse(cliArgs[++i]);
+ lanIp = bindAddress;
+ }
+}
+
+// Firewall helpers
+if (setupFirewall)
+{
+ FirewallHelper.Setup(WellKnownTcpPorts, WellKnownUdpPorts);
+ return;
+}
+
+if (removeFirewall)
+{
+ FirewallHelper.Remove(WellKnownTcpPorts, WellKnownUdpPorts);
+ return;
+}
+
+// Normal run: start control channel
+Console.WriteLine($".NET nanoFramework Network Test Companion");
+Console.WriteLine($" LAN IP : {lanIp} (use this in TestConfiguration.CompanionIP for real hardware)");
+Console.WriteLine($" Control port: {controlPort}");
+Console.WriteLine($" Press Ctrl+C to stop.");
+Console.WriteLine();
+
+using var cts = new CancellationTokenSource();
+Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
+
+using var commandServer = new CommandServer(bindAddress, controlPort);
+commandServer.Start();
+
+Console.WriteLine($"READY ip={lanIp} port={controlPort}");
+
+try
+{
+ await Task.Delay(Timeout.Infinite, cts.Token);
+}
+catch (OperationCanceledException) { }
+
+Console.WriteLine("Shutting down.");
+
+static IPAddress ResolveLanIp()
+{
+ foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (iface.OperationalStatus != OperationalStatus.Up)
+ {
+ continue;
+ }
+
+ if (iface.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) continue;
+ {
+ foreach (var addr in iface.GetIPProperties().UnicastAddresses)
+ {
+ if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ return addr.Address;
+ }
+ }
+ }
+ }
+
+ return IPAddress.Loopback;
+}
diff --git a/Tests/NetworkTestCompanion/TcpEchoServer.cs b/Tests/NetworkTestCompanion/TcpEchoServer.cs
new file mode 100644
index 0000000..3ed931c
--- /dev/null
+++ b/Tests/NetworkTestCompanion/TcpEchoServer.cs
@@ -0,0 +1,82 @@
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Net;
+using System.Net.Sockets;
+
+namespace NetworkTestCompanion;
+
+///
+/// Listens on a TCP port and echoes all received bytes back to the sender.
+///
+internal sealed class TcpEchoServer : IDisposable
+{
+ private readonly TcpListener _listener;
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _acceptLoop;
+
+ internal int Port { get; }
+
+ internal TcpEchoServer(IPAddress bindAddress, int port)
+ {
+ Port = port;
+ _listener = new TcpListener(bindAddress, port);
+ }
+
+ internal void Start()
+ {
+ _listener.Start();
+ _acceptLoop = AcceptLoopAsync(_cts.Token);
+ }
+
+ private async Task AcceptLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync(ct);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[TCP:{Port}] Accept error: {ex.Message}");
+ continue;
+ }
+
+ _ = HandleClientAsync(client, ct);
+ }
+ }
+
+ private static async Task HandleClientAsync(TcpClient client, CancellationToken ct)
+ {
+ using (client)
+ {
+ var stream = client.GetStream();
+ var buf = new byte[4096];
+ try
+ {
+ int read;
+
+ while ((read = await stream.ReadAsync(buf, ct)) > 0)
+ {
+ await stream.WriteAsync(buf.AsMemory(0, read), ct);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (IOException) { }
+ }
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _listener.Stop();
+ _acceptLoop?.Wait(TimeSpan.FromSeconds(2));
+ _cts.Dispose();
+ }
+}
diff --git a/Tests/NetworkTestCompanion/UdpEchoServer.cs b/Tests/NetworkTestCompanion/UdpEchoServer.cs
new file mode 100644
index 0000000..7dabf41
--- /dev/null
+++ b/Tests/NetworkTestCompanion/UdpEchoServer.cs
@@ -0,0 +1,68 @@
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Net;
+using System.Net.Sockets;
+
+namespace NetworkTestCompanion;
+
+///
+/// Listens on a UDP port and echoes each received datagram back to its sender.
+///
+internal sealed class UdpEchoServer : IDisposable
+{
+ private readonly UdpClient _udpClient;
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _receiveLoop;
+
+ internal int Port { get; }
+
+ internal UdpEchoServer(IPAddress bindAddress, int port)
+ {
+ Port = port;
+ _udpClient = new UdpClient(new IPEndPoint(bindAddress, port));
+ }
+
+ internal void Start()
+ {
+ _receiveLoop = ReceiveLoopAsync(_cts.Token);
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ UdpReceiveResult result;
+ try
+ {
+ result = await _udpClient.ReceiveAsync(ct);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[UDP:{Port}] Receive error: {ex.Message}");
+ continue;
+ }
+
+ try
+ {
+ await _udpClient.SendAsync(result.Buffer.AsMemory(), result.RemoteEndPoint, ct);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[UDP:{Port}] Send error: {ex.Message}");
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _udpClient.Close();
+ _receiveLoop?.Wait(TimeSpan.FromSeconds(2));
+ _cts.Dispose();
+ }
+}
diff --git a/Tests/README.md b/Tests/README.md
new file mode 100644
index 0000000..728128a
--- /dev/null
+++ b/Tests/README.md
@@ -0,0 +1,133 @@
+# Running the Unit Tests
+
+## Overview
+
+The tests target nanoFramework MCU devices (or the Win32 nanoCLR emulator). Some tests require a host-side peer - the **Network Test Companion** - which acts as a TCP/UDP echo server and accepts connections from the MCU under test.
+
+## Projects
+
+| Project | Needs companion |
+|---|---|
+| `IPAddressTests` | No - self-contained data-structure tests |
+| `NetworkHelperTests` | No - tests IP acquisition only |
+| `SocketTests` | Loopback-only tests: No. Round-trip tests (`SocketRoundTripTests`): Yes (requires companion) |
+
+---
+
+## Network Test Companion
+
+The companion is a .NET 10 console app in `Tests/NetworkTestCompanion/`. It runs on the developer's PC and exposes:
+
+- A **control channel** (TCP, default port **11000**) that accepts newline-delimited JSON commands from MCU tests.
+- Dynamic **TCP/UDP echo servers** spun up per test request.
+
+### Build
+
+```sh
+dotnet build Tests/NetworkTestCompanion
+```
+
+### Run
+
+```sh
+dotnet run --project Tests/NetworkTestCompanion
+```
+
+The companion prints its bound IP and port on startup:
+
+```
+READY ip=192.168.1.10 port=11000
+```
+
+Use `--ip ` to override the bind address or `--control-port ` to change the control port.
+
+### Control channel commands
+
+Send newline-terminated JSON; receive a JSON response on the same connection.
+
+| Command | Description |
+|---|---|
+| `{"cmd":"ping"}` | Health check - returns `{"ok":true,"ip":""}` |
+| `{"cmd":"start_tcp_echo","port":N}` | Start a TCP echo server on port N |
+| `{"cmd":"start_udp_echo","port":N}` | Start a UDP echo server on port N |
+| `{"cmd":"stop","port":N}` | Stop the server on port N |
+| `{"cmd":"stop_all"}` | Stop all active servers |
+| `{"cmd":"connect_to","host":"...","port":N}` | Companion connects as TCP client (MCU acts as server) |
+
+---
+
+## Firewall configuration
+
+The companion must be reachable from MCU devices on the local network. Run the following **once** to open the required ports.
+
+### Windows (requires an elevated prompt)
+
+```bat
+dotnet run --project Tests/NetworkTestCompanion -- --setup-firewall
+```
+
+This adds inbound firewall rules named `nF-TestCompanion-TCP-*` / `nF-TestCompanion-UDP-*`. To remove them:
+
+```bat
+dotnet run --project Tests/NetworkTestCompanion -- --remove-firewall
+```
+
+### macOS
+
+The macOS Application Firewall will prompt you to **Allow** the companion when it first starts listening. Click **Allow**. No further action is needed.
+
+### Linux (ufw)
+
+If `ufw` is active, open the ports manually:
+
+```sh
+sudo ufw allow 11000/tcp # control channel
+sudo ufw allow 7001/tcp # TCP echo (RoundTrip_Tcp_SendReceive_Echo)
+sudo ufw allow 7002/tcp # TCP echo (RoundTrip_Tcp_LargeBuffer_Echo)
+sudo ufw allow 7003/udp # UDP echo (RoundTrip_Udp_SendReceive_Echo)
+sudo ufw allow 7004/tcp # MCU-as-server (RoundTrip_Tcp_McuAsServer_CompanionConnects)
+```
+
+### Linux (iptables / nftables)
+
+For environments without ufw, add equivalent rules:
+
+```sh
+# iptables example
+sudo iptables -A INPUT -p tcp --dport 11000 -j ACCEPT
+sudo iptables -A INPUT -p tcp --dport 7001 -j ACCEPT
+sudo iptables -A INPUT -p tcp --dport 7002 -j ACCEPT
+sudo iptables -A INPUT -p udp --dport 7003 -j ACCEPT
+sudo iptables -A INPUT -p tcp --dport 7004 -j ACCEPT
+
+# nftables example
+sudo nft add rule inet filter input tcp dport 11000 accept
+sudo nft add rule inet filter input tcp dport 7001 accept
+sudo nft add rule inet filter input tcp dport 7002 accept
+sudo nft add rule inet filter input udp dport 7003 accept
+sudo nft add rule inet filter input tcp dport 7004 accept
+```
+
+CI pipeline authors must open the required ports in their runner configuration.
+
+---
+
+## `.runsettings` parameters
+
+The `.runsettings` file at the repo root contains two parameters for the companion:
+
+| Parameter | Default | Description |
+|---|---|---|
+| `CompanionIP` | `127.0.0.1` | IP address of the PC running the companion |
+| `CompanionControlPort` | `11000` | TCP port of the control channel |
+
+For **virtual device** (emulator) runs the defaults work as-is.
+
+For **real hardware** runs, set `CompanionIP` to the PC's LAN address - the address printed by the companion on startup:
+
+```xml
+
+
+
+
+```
diff --git a/Tests/SocketTests/CompanionClient.cs b/Tests/SocketTests/CompanionClient.cs
new file mode 100644
index 0000000..b93c03c
--- /dev/null
+++ b/Tests/SocketTests/CompanionClient.cs
@@ -0,0 +1,84 @@
+//
+// Copyright (c) .NET Foundation and Contributors
+// See LICENSE file in the project root for full license information.
+//
+
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+
+namespace NFUnitTestSocketTests
+{
+ ///
+ /// Thin client for the Network Test Companion control channel.
+ /// Sends newline-terminated JSON commands and reads the JSON response.
+ /// Must use synchronous Socket calls (nanoFramework has no Task/async).
+ ///
+ internal sealed class CompanionClient : IDisposable
+ {
+ private readonly Socket _socket;
+ private readonly byte[] _recvBuf = new byte[128];
+
+ internal CompanionClient()
+ {
+ _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ _socket.Connect(new IPEndPoint(
+ IPAddress.Parse(TestConfiguration.CompanionIP),
+ TestConfiguration.CompanionControlPort));
+
+ // Brief settle time so the companion is ready to read
+ Thread.Sleep(50);
+ }
+
+ /// Returns true when the companion responds with {"ok":true}.
+ internal bool Ping()
+ {
+ var response = SendCommand("{\"cmd\":\"ping\"}");
+ return response.IndexOf("\"ok\":true") >= 0;
+ }
+
+ /// Asks the companion to start a TCP echo server on the given port.
+ internal bool StartTcpEcho(int port)
+ {
+ var response = SendCommand("{\"cmd\":\"start_tcp_echo\",\"port\":" + port + "}");
+ return response.IndexOf("\"ok\":true") >= 0;
+ }
+
+ /// Asks the companion to start a UDP echo server on the given port.
+ internal bool StartUdpEcho(int port)
+ {
+ var response = SendCommand("{\"cmd\":\"start_udp_echo\",\"port\":" + port + "}");
+ return response.IndexOf("\"ok\":true") >= 0;
+ }
+
+ /// Stops the echo server the companion is running on the given port.
+ internal bool Stop(int port)
+ {
+ var response = SendCommand("{\"cmd\":\"stop\",\"port\":" + port + "}");
+ return response.IndexOf("\"ok\":true") >= 0;
+ }
+
+ /// Asks the companion to open a TCP connection to the MCU acting as server.
+ internal bool ConnectTo(string host, int port)
+ {
+ var response = SendCommand("{\"cmd\":\"connect_to\",\"host\":\"" + host + "\",\"port\":" + port + "}");
+ return response.IndexOf("\"ok\":true") >= 0;
+ }
+
+ private string SendCommand(string json)
+ {
+ byte[] cmd = Encoding.UTF8.GetBytes(json + "\n");
+ _socket.Send(cmd);
+
+ // Give the companion time to act and respond
+ Thread.Sleep(100);
+
+ int received = _socket.Receive(_recvBuf);
+ return new string(Encoding.UTF8.GetChars(_recvBuf, 0, received));
+ }
+
+ public void Dispose() => _socket.Close();
+ }
+}
diff --git a/Tests/SocketTests/SocketPair.cs b/Tests/SocketTests/SocketPair.cs
index c16e05e..71c8bb4 100644
--- a/Tests/SocketTests/SocketPair.cs
+++ b/Tests/SocketTests/SocketPair.cs
@@ -79,12 +79,16 @@ private void CloseSocket(ref Socket socket)
public void AssertDataReceived(int cBytes)
{
if (cBytes != bufSend.Length)
+ {
throw new Exception("Recieve failed, wrong size " + cBytes + " " + bufSend.Length);
+ }
for (int i = 0; i < bufReceive.Length; i++)
{
if (bufSend[i] != bufReceive[i])
+ {
throw new Exception("Receive failed, wrong data");
+ }
}
}
}
diff --git a/Tests/SocketTests/SocketRoundTripTests.cs b/Tests/SocketTests/SocketRoundTripTests.cs
new file mode 100644
index 0000000..c0e9476
--- /dev/null
+++ b/Tests/SocketTests/SocketRoundTripTests.cs
@@ -0,0 +1,240 @@
+//
+// Copyright (c) .NET Foundation and Contributors
+// See LICENSE file in the project root for full license information.
+//
+
+using nanoFramework.Networking;
+using nanoFramework.TestFramework;
+using System;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading;
+
+namespace NFUnitTestSocketTests
+{
+ ///
+ /// Round-trip socket tests that require the Network Test Companion to be running
+ /// on the host PC. Start it with:
+ /// dotnet run --project Tests/NetworkTestCompanion
+ /// and update TestConfiguration.CompanionIP when testing on real hardware.
+ ///
+ [TestClass]
+ public class SocketRoundTripTests
+ {
+ private static bool _networkInitialized = false;
+
+ [Setup]
+ public void Setup()
+ {
+ // Comment the next line to run these tests on real hardware with the companion running
+ Assert.SkipTest("Skipping round-trip tests: companion required - run on real hardware only");
+
+ // Bring up the network before any socket operation.
+ CancellationTokenSource cs = new CancellationTokenSource(30000);
+ bool connected = NetworkHelper.SetupAndConnectNetwork(requiresDateTime: false, token: cs.Token);
+
+ if (!connected)
+ {
+ Assert.SkipTest($"Network not available ({NetworkHelper.Status}) - skipping round-trip tests");
+ }
+
+ _networkInitialized = true;
+ OutputHelper.WriteLine($"Network ready, status: {NetworkHelper.Status}");
+ }
+
+ [Cleanup]
+ public void Cleanup()
+ {
+ if (_networkInitialized)
+ {
+ NetworkHelper.Reset();
+ _networkInitialized = false;
+ }
+ }
+
+ [TestMethod]
+ public void RoundTrip_Tcp_SendReceive_Echo()
+ {
+ const int echoPort = 7001;
+
+ using (var companion = new CompanionClient())
+ {
+ Assert.IsTrue(companion.Ping(), "Companion not reachable");
+ Assert.IsTrue(companion.StartTcpEcho(echoPort), "Failed to start TCP echo");
+
+ // Give the companion time to bind the listener
+ Thread.Sleep(100);
+
+ Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ client.Connect(new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort));
+
+ try
+ {
+ byte[] sent = new byte[] { 0xAB, 0xCD, 0xEF };
+ client.Send(sent);
+
+ byte[] received = new byte[sent.Length];
+ int totalRead = 0;
+ while (totalRead < sent.Length)
+ {
+ int n = client.Receive(received, totalRead, sent.Length - totalRead, SocketFlags.None);
+ Assert.IsTrue(n > 0, "Connection closed before all bytes received");
+ totalRead += n;
+ }
+
+ Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Echoed bytes do not match sent bytes");
+ }
+ finally
+ {
+ client.Close();
+ companion.Stop(echoPort);
+ }
+ }
+ }
+
+ [TestMethod]
+ public void RoundTrip_Tcp_LargeBuffer_Echo()
+ {
+ const int echoPort = 7002;
+ const int bufferSize = 1024;
+
+ using (var companion = new CompanionClient())
+ {
+ Assert.IsTrue(companion.Ping(), "Companion not reachable");
+ Assert.IsTrue(companion.StartTcpEcho(echoPort), "Failed to start TCP echo");
+
+ Thread.Sleep(100);
+
+ Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ client.Connect(new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort));
+
+ try
+ {
+ byte[] sent = new byte[bufferSize];
+
+ for (int i = 0; i < bufferSize; i++)
+ {
+ sent[i] = (byte)(i & 0xFF);
+ }
+
+ client.Send(sent);
+
+ byte[] received = new byte[bufferSize];
+ int totalRead = 0;
+ while (totalRead < bufferSize)
+ {
+ int n = client.Receive(received, totalRead, bufferSize - totalRead, SocketFlags.None);
+ Assert.IsTrue(n > 0, "Connection closed before all bytes received");
+ totalRead += n;
+ }
+
+ Assert.IsTrue(SocketTools.ArrayEquals(sent, received), "Large buffer echo mismatch");
+ }
+ finally
+ {
+ client.Close();
+ companion.Stop(echoPort);
+ }
+ }
+ }
+
+ [TestMethod]
+ public void RoundTrip_Udp_SendReceive_Echo()
+ {
+ const int echoPort = 7003;
+
+ using (var companion = new CompanionClient())
+ {
+ Assert.IsTrue(companion.Ping(), "Companion not reachable");
+ Assert.IsTrue(companion.StartUdpEcho(echoPort), "Failed to start UDP echo");
+
+ Thread.Sleep(100);
+
+ Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+ client.Bind(new IPEndPoint(IPAddress.Any, 0));
+
+ try
+ {
+ EndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse(TestConfiguration.CompanionIP), echoPort);
+
+ byte[] sent = new byte[] { 0x11, 0x22, 0x33 };
+ client.SendTo(sent, serverEndPoint);
+
+ byte[] received = new byte[16];
+ EndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0);
+ int n = client.ReceiveFrom(received, ref remoteEndPoint);
+
+ Assert.AreEqual(sent.Length, n, "UDP echo returned wrong byte count");
+ for (int i = 0; i < sent.Length; i++)
+ {
+ Assert.AreEqual(sent[i], received[i], $"UDP echo mismatch at byte {i}");
+ }
+ }
+ finally
+ {
+ client.Close();
+ companion.Stop(echoPort);
+ }
+ }
+ }
+
+ [TestMethod]
+ public void RoundTrip_Tcp_McuAsServer_CompanionConnects()
+ {
+ const int mcuListenPort = 7004;
+
+ Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ server.Bind(new IPEndPoint(IPAddress.Any, mcuListenPort));
+ server.Listen(1);
+
+ try
+ {
+ using (var companion = new CompanionClient())
+ {
+ Assert.IsTrue(companion.Ping(), "Companion not reachable");
+
+ string mcuIp = GetLocalIp();
+ Assert.IsTrue(companion.ConnectTo(mcuIp, mcuListenPort), "ConnectTo command failed");
+
+ Assert.IsTrue(server.Poll(10 * 1000 * 1000, SelectMode.SelectRead), "No connection received from companion within timeout");
+ Socket accepted = server.Accept();
+
+ try
+ {
+ Assert.IsNotNull(accepted, "Accept returned null");
+ }
+ finally
+ {
+ accepted.Close();
+ }
+ }
+ }
+ finally
+ {
+ server.Close();
+ }
+ }
+
+ private static string GetLocalIp()
+ {
+ foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (ni.IPv4Address == null || ni.IPv4Address == "0.0.0.0")
+ {
+ continue;
+ }
+
+ // Skip loopback, disconnected, and virtual (link-local) addresses.
+ if (ni.IPv4Address.StartsWith("127.") || ni.IPv4Address.StartsWith("169.254."))
+ {
+ continue;
+ }
+
+ return ni.IPv4Address;
+ }
+
+ return "0.0.0.0";
+ }
+ }
+}
diff --git a/Tests/SocketTests/SocketTests.nfproj b/Tests/SocketTests/SocketTests.nfproj
index 20a6ebb..fbc60d1 100644
--- a/Tests/SocketTests/SocketTests.nfproj
+++ b/Tests/SocketTests/SocketTests.nfproj
@@ -26,11 +26,14 @@
+
+
+
@@ -48,6 +51,9 @@
..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll
+
+ ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll
+
@@ -56,6 +62,54 @@
+
+
+
+
+
+
+
+
+
+
+
+ <_CompanionIP>@(_CompanionIPItems)
+ <_CompanionPort>@(_CompanionPortItems)
+ <_CompanionIP Condition="'$(_CompanionIP)' == ''">127.0.0.1
+ <_CompanionPort Condition="'$(_CompanionPort)' == ''">11000
+
+
+
+ <_TestConfigLines Include="// Auto-generated from .runsettings - do not edit directly." />
+ <_TestConfigLines Include="//" />
+ <_TestConfigLines Include="// Copyright (c) .NET Foundation and Contributors" />
+ <_TestConfigLines Include="// See LICENSE file in the project root for full license information." />
+ <_TestConfigLines Include="//" />
+ <_TestConfigLines Include="namespace NFUnitTestSocketTests" />
+ <_TestConfigLines Include="{" />
+ <_TestConfigLines Include="%20%20%20%20internal static class TestConfiguration" />
+ <_TestConfigLines Include="%20%20%20%20{" />
+ <_TestConfigLines Include="%20%20%20%20%20%20%20%20internal const string CompanionIP = "$(_CompanionIP)"%3B" />
+ <_TestConfigLines Include="%20%20%20%20%20%20%20%20internal const int CompanionControlPort = $(_CompanionPort)%3B" />
+ <_TestConfigLines Include="%20%20%20%20}" />
+ <_TestConfigLines Include="}" />
+
+
+
+
+
+
+
diff --git a/Tests/SocketTests/SocketTools.cs b/Tests/SocketTests/SocketTools.cs
index b822c5c..c0b4711 100644
--- a/Tests/SocketTests/SocketTools.cs
+++ b/Tests/SocketTests/SocketTools.cs
@@ -32,7 +32,9 @@ static public long DottedDecimalToIp(byte a1, byte a2, byte a3, byte a4)
static public IPAddress ParseAddress(string ipString)
{
if (ipString == null)
+ {
throw new ArgumentNullException("WsdIPAddress.ipString must not be null.");
+ }
ulong ipAddress = 0L;
int lastIndex = 0;
@@ -45,6 +47,7 @@ static public IPAddress ParseAddress(string ipString)
{
// Parse to '.' or end of IP address
if (ipString[i] == '.' || i == length - 1)
+ {
// If the IP starts with a '.'
// or a segment is longer than 3 characters or shiftIndex >
// last bit position throw.
@@ -62,6 +65,7 @@ static public IPAddress ParseAddress(string ipString)
shiftIndex += 8;
mask <<= 8;
}
+ }
}
return new IPAddress((long)ipAddress);
@@ -77,7 +81,9 @@ static public bool ArrayEquals(bool[] array1, bool[] array2)
for (int i = 0; i < array1.Length; i++)
{
if (array1[i] != array2[i])
+ {
return false;
+ }
}
return true;
@@ -93,7 +99,9 @@ static public bool ArrayEquals(byte[] array1, byte[] array2)
for (int i = 0; i < array1.Length; i++)
{
if (array1[i] != array2[i])
+ {
return false;
+ }
}
return true;
diff --git a/Tests/SocketTests/packages.config b/Tests/SocketTests/packages.config
index d98ca01..440a5fd 100644
--- a/Tests/SocketTests/packages.config
+++ b/Tests/SocketTests/packages.config
@@ -3,5 +3,6 @@
+
\ No newline at end of file
diff --git a/Tests/SocketTests/packages.lock.json b/Tests/SocketTests/packages.lock.json
index 2e75651..07b3059 100644
--- a/Tests/SocketTests/packages.lock.json
+++ b/Tests/SocketTests/packages.lock.json
@@ -20,6 +20,12 @@
"resolved": "1.3.42",
"contentHash": "68HPjhersNpssbmEMUHdMw3073MHfGTfrkbRk9eILKbNPFfPFck7m4y9BlAi6DaguUJaeKxgyIojXF3SQrF8/A=="
},
+ "nanoFramework.System.Threading": {
+ "type": "Direct",
+ "requested": "[1.1.52, 1.1.52]",
+ "resolved": "1.1.52",
+ "contentHash": "kv+US/+7QKV1iT/snxBh032vwZ+3krJ4vujlSsvmS2nNj/nK64R3bq/ST3bCFquxHDD0mog8irtCBCsFazr4kA=="
+ },
"nanoFramework.TestFramework": {
"type": "Direct",
"requested": "[3.0.80, 3.0.80]",
diff --git a/nanoFramework.System.Net.sln b/nanoFramework.System.Net.sln
index c1615af..e0b8384 100644
--- a/nanoFramework.System.Net.sln
+++ b/nanoFramework.System.Net.sln
@@ -14,6 +14,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C9AF0EE0-09CD-4836-9881-9E62766D1CC4}"
+ ProjectSection(SolutionItems) = preProject
+ Tests\README.md = Tests\README.md
+ EndProjectSection
EndProject
Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "NetworkHelperTests", "Tests\NetworkHelperTests\NetworkHelperTests.nfproj", "{07D7468C-F619-4E73-A431-0B47D450462B}"
EndProject