Skip to content
171 changes: 171 additions & 0 deletions src/Cli/Microsoft.Maui.Cli.UnitTests/PortCheckTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using Microsoft.Maui.Cli.Models;
using Microsoft.Maui.Cli.Output;
using Microsoft.Maui.Cli.Providers.Port;
using Xunit;

namespace Microsoft.Maui.Cli.UnitTests;

public class PortCheckTests
{
[Fact]
public void PortCheckResult_JsonShape_HasExpectedProperties()
{
var result = new PortCheckResult
{
Port = 8080,
InUse = true,
Listeners =
[
new PortListenerResult
{
Pid = 1234,
ProcessName = "dotnet",
Address = "0.0.0.0",
Family = "ipv4",
State = "listen",
}
]
};

var json = JsonSerializer.Serialize(result, MauiCliJsonContext.Default.PortCheckResult);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;

Assert.Equal(8080, root.GetProperty("port").GetInt32());
Assert.True(root.GetProperty("in_use").GetBoolean());

var listener = root.GetProperty("listeners")[0];
Assert.Equal(1234, listener.GetProperty("pid").GetInt32());
Assert.Equal("dotnet", listener.GetProperty("process_name").GetString());
Assert.Equal("0.0.0.0", listener.GetProperty("address").GetString());
Assert.Equal("ipv4", listener.GetProperty("family").GetString());
Assert.Equal("listen", listener.GetProperty("state").GetString());
}

[Fact]
public void PortCheckResult_Free_SerializesCorrectly()
{
var result = new PortCheckResult { Port = 80, InUse = false };
var json = JsonSerializer.Serialize(result, MauiCliJsonContext.Default.PortCheckResult);
using var doc = JsonDocument.Parse(json);

Assert.False(doc.RootElement.GetProperty("in_use").GetBoolean());
Assert.Equal(0, doc.RootElement.GetProperty("listeners").GetArrayLength());
}

[Fact]
public void ParseLsofOutput_SingleListener_ReturnsCorrectInfo()
{
var output = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" +
"dotnet 1234 user 10u IPv4 12345 0t0 TCP *:8080 (LISTEN)\n";
var result = UnixPortInspector.ParseLsofOutput(output, 8080);

Assert.Single(result);
Assert.Equal(1234, result[0].Pid);
Assert.Equal("dotnet", result[0].ProcessName);
Assert.Equal("0.0.0.0", result[0].Address);
}

[Fact]
public void ParseLsofOutput_IPv6_ReturnsIpv6Family()
{
var output = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" +
"nginx 999 root 5u IPv6 54321 0t0 TCP *:443 (LISTEN)\n";
var result = UnixPortInspector.ParseLsofOutput(output, 443);

Assert.Single(result);
Assert.Equal("ipv6", result[0].Family);
Assert.Equal("::", result[0].Address);
}

[Fact]
public void ParseSsOutput_IPv6Wildcard_ReturnsIpv6Address()
{
var output = "LISTEN 0 128 [::]:8080 [::]:* users:((\"dotnet\",pid=1234,fd=5))\n";
var result = UnixPortInspector.ParseSsOutput(output, 8080);

Assert.Single(result);
Assert.Equal("ipv6", result[0].Family);
Assert.Equal("::", result[0].Address);
}

[Fact]
public void ParseLsofOutput_WrongPort_ReturnsEmpty()
{
var output = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" +
"dotnet 1234 user 10u IPv4 12345 0t0 TCP *:9090 (LISTEN)\n";
var result = UnixPortInspector.ParseLsofOutput(output, 8080);

Assert.Empty(result);
}

[Fact]
public void ParseSsOutput_WithProcess_ExtractsPidAndName()
{
var output = "LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:((\"dotnet\",pid=1234,fd=5))\n";
var result = UnixPortInspector.ParseSsOutput(output, 8080);

Assert.Single(result);
Assert.Equal(1234, result[0].Pid);
Assert.Equal("dotnet", result[0].ProcessName);
Assert.Equal("0.0.0.0", result[0].Address);
}

[Fact]
public void ParseSsOutput_WrongPort_ReturnsEmpty()
{
var output = "LISTEN 0 128 0.0.0.0:9090 0.0.0.0:* users:((\"dotnet\",pid=1234,fd=5))\n";
var result = UnixPortInspector.ParseSsOutput(output, 8080);

Assert.Empty(result);
}

[Fact]
public void ParseNetstatOutput_Tcp4_ExtractsListener()
{
var output = "Proto Recv-Q Send-Q Local Address Foreign Address (state)\n" +
"tcp4 0 0 127.0.0.1.8080 *.* LISTEN\n";
var result = UnixPortInspector.ParseNetstatOutput(output, 8080);

Assert.Single(result);
Assert.Equal("127.0.0.1", result[0].Address);
Assert.Equal("ipv4", result[0].Family);
}

[Fact]
public void ParseNetstatOutput_Wildcard_MapsToAllInterfaces()
{
var output = "Proto Recv-Q Send-Q Local Address Foreign Address (state)\n" +
"tcp46 0 0 *.19223 *.* LISTEN\n";
var result = UnixPortInspector.ParseNetstatOutput(output, 19223);

Assert.Single(result);
Assert.Equal("::", result[0].Address);
Assert.Equal("ipv6", result[0].Family);
}

[Fact]
public void ParseNetstatOutput_WrongPort_ReturnsEmpty()
{
var output = "Proto Recv-Q Send-Q Local Address Foreign Address (state)\n" +
"tcp4 0 0 127.0.0.1.9090 *.* LISTEN\n";
var result = UnixPortInspector.ParseNetstatOutput(output, 8080);

Assert.Empty(result);
}

[Theory]
[InlineData(0x5000u, 80)]
[InlineData(0x901Fu, 8080)]
[InlineData(0xBB01u, 443)]
public void GetPortFromNetworkDword_ReturnsCorrectPort(uint networkDword, int expectedPort)
{
// The formula: ((dword & 0xFF) << 8) | ((dword >> 8) & 0xFF)
var actual = PortInspectorHelpers.GetPortFromNetworkDword(networkDword);
Assert.Equal(expectedPort, actual);
}
}
110 changes: 110 additions & 0 deletions src/Cli/Microsoft.Maui.Cli/Commands/PortCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.Maui.Cli.Errors;
using Microsoft.Maui.Cli.Models;
using Microsoft.Maui.Cli.Providers.Port;

namespace Microsoft.Maui.Cli.Commands;

internal static class PortCommands
{
public static Command Create()
{
var portCommand = new Command("port", "Diagnose TCP port usage.");
portCommand.Add(CreateCheckCommand());
return portCommand;
}

private static Command CreateCheckCommand()
{
var portArg = new Argument<int>("port") { Description = "TCP port number to check (1-65535)." };

var checkCommand = new Command("check", "Check which process holds a TCP port.")
{
portArg,
};

checkCommand.SetAction((ParseResult parseResult) =>
{
var port = parseResult.GetValue(portArg);
var formatter = Program.GetFormatter(parseResult);

if (port is < 1 or > 65535)
{
formatter.WriteError(new MauiToolException(ErrorCodes.InvalidArgument, $"Port must be between 1 and 65535, got {port}."));
return 2;
}

IPortInspector inspector;
try
{
if (OperatingSystem.IsWindows())
{
inspector = new WindowsPortInspector();
}
else
{
#pragma warning disable CA1416
inspector = new UnixPortInspector();
#pragma warning restore CA1416
}
}
catch (Exception ex)
{
formatter.WriteError(new MauiToolException(ErrorCodes.PortEnumerationFailed, ex.Message));
return 2;
}

List<PortListenerInfo> raw;
try
{
raw = [.. inspector.GetListeners(port)];
}
catch (Exception ex)
{
formatter.WriteError(new MauiToolException(ErrorCodes.PortEnumerationFailed, ex.Message));
return 2;
}

var listeners = raw.Select(l => new PortListenerResult
{
Pid = l.Pid,
ProcessName = l.ProcessName,
Address = l.Address,
Family = l.Family,
State = l.State,
}).ToList();

var result = new PortCheckResult { Port = port, InUse = listeners.Count > 0, Listeners = listeners };

var useJson = parseResult.GetValue(GlobalOptions.JsonOption);
if (useJson)
{
formatter.Write(result);
}
else
{
if (!result.InUse)
{
formatter.WriteSuccess($"Port {port} is free.");
}
else
{
formatter.WriteInfo($"Port {port} is in use:");
foreach (var l in result.Listeners)
{
var processInfo = l.Pid > 0 ? $"PID {l.Pid} ({(string.IsNullOrEmpty(l.ProcessName) ? "unknown" : l.ProcessName)})" : "unknown process";
formatter.WriteInfo($" {processInfo} {l.Address} [{l.Family}]");
}
}
}

return result.InUse ? 1 : 0;
});

return checkCommand;
}
}
1 change: 1 addition & 0 deletions src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static class ErrorCodes
public const string InvalidArgument = "E1004";
public const string DeviceNotFound = "E1006";
public const string PlatformNotSupported = "E1007";
public const string PortEnumerationFailed = "E1008";

// Platform/SDK errors - JDK (E20xx)
public const string JdkNotFound = "E2001";
Expand Down
36 changes: 36 additions & 0 deletions src/Cli/Microsoft.Maui.Cli/Models/PortCheckResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace Microsoft.Maui.Cli.Models;

public sealed record PortCheckResult
{
[JsonPropertyName("port")]
public int Port { get; init; }

[JsonPropertyName("in_use")]
public bool InUse { get; init; }

[JsonPropertyName("listeners")]
public List<PortListenerResult> Listeners { get; init; } = [];
}
Comment thread
rmarinho marked this conversation as resolved.

public sealed record PortListenerResult
{
[JsonPropertyName("pid")]
public int Pid { get; init; }

[JsonPropertyName("process_name")]
public string ProcessName { get; init; } = string.Empty;

[JsonPropertyName("address")]
public string Address { get; init; } = string.Empty;

[JsonPropertyName("family")]
public string Family { get; init; } = "ipv4";

[JsonPropertyName("state")]
public string State { get; init; } = "listen";
}
3 changes: 3 additions & 0 deletions src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,7 @@ namespace Microsoft.Maui.Cli.Output;
[JsonSerializable(typeof(SimulatorEraseResult))]
[JsonSerializable(typeof(SimulatorAppResult))]
[JsonSerializable(typeof(SimulatorAppContainerResult))]
[JsonSerializable(typeof(PortCheckResult))]
[JsonSerializable(typeof(PortListenerResult))]
[JsonSerializable(typeof(List<PortListenerResult>))]
internal sealed partial class MauiCliJsonContext : JsonSerializerContext;
3 changes: 3 additions & 0 deletions src/Cli/Microsoft.Maui.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ internal static RootCommand BuildRootCommand()
rootCommand.Add(AndroidCommands.Create());
rootCommand.Add(AppleCommands.Create());

// Port diagnostics
rootCommand.Add(PortCommands.Create());

// DevFlow automation commands (maui devflow ...)
rootCommand.Add(DevFlow.DevFlowCommands.CreateDevFlowCommand(GlobalOptions.JsonOption));

Expand Down
9 changes: 9 additions & 0 deletions src/Cli/Microsoft.Maui.Cli/Providers/Port/IPortInspector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.Maui.Cli.Providers.Port;

internal interface IPortInspector
{
IReadOnlyList<PortListenerInfo> GetListeners(int port);
}
10 changes: 10 additions & 0 deletions src/Cli/Microsoft.Maui.Cli/Providers/Port/PortInspectorHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.Maui.Cli.Providers.Port;

internal static class PortInspectorHelpers
{
internal static int GetPortFromNetworkDword(uint networkDword)
=> (int)(((networkDword & 0xFF) << 8) | ((networkDword >> 8) & 0xFF));
}
Comment thread
rmarinho marked this conversation as resolved.
6 changes: 6 additions & 0 deletions src/Cli/Microsoft.Maui.Cli/Providers/Port/PortListenerInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.Maui.Cli.Providers.Port;

internal sealed record PortListenerInfo(int Port, int Pid, string ProcessName, string Address, string Family, string State = "listen");
Loading
Loading