Add port availability check to aspire doctor#14378
Conversation
Add a new environment check that detects when configured ports in apphost.run.json or launchSettings.json are unavailable. On Windows, this checks against Hyper-V excluded port ranges (via netsh). On all platforms, it verifies ports can actually be bound. This addresses a confusing failure mode where the dashboard link displayed by 'aspire run' doesn't work because the configured port falls within a Windows excluded port range.
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14378Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14378" |
There was a problem hiding this comment.
Pull request overview
Adds a new aspire doctor environment check that detects when ports configured in apphost.run.json or launchSettings.json are unavailable, with special handling on Windows to detect Hyper-V excluded port ranges.
Changes:
- Introduces
PortAvailabilityCheckto read configured ports, detect Windows excluded port ranges, and attempt TCP binds. - Registers the new environment check with the CLI host.
- Adds unit tests for configured-port extraction from
apphost.run.jsonandlaunchSettings.json.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tests/Aspire.Cli.Tests/Utils/PortAvailabilityCheckTests.cs | Adds tests validating parsing of configured ports from config files. |
| src/Aspire.Cli/Utils/EnvironmentChecker/PortAvailabilityCheck.cs | Implements the new environment check logic (config parsing, netsh excluded-port query, bind test, doctor output). |
| src/Aspire.Cli/Program.cs | Registers PortAvailabilityCheck in DI so it runs as part of aspire doctor. |
| results.Add(new EnvironmentCheckResult | ||
| { | ||
| Category = "environment", | ||
| Name = "port-availability", | ||
| Status = EnvironmentCheckStatus.Warning, | ||
| Message = $"Configured ports unavailable: {portList}", | ||
| Details = "These ports fall within a Windows excluded port range (often reserved by Hyper-V) or are otherwise unavailable. The dashboard link displayed by 'aspire run' will not work.", | ||
| Fix = "Delete apphost.run.json (or launchSettings.json) and run 'aspire run' to auto-assign available ports, or update the file with ports outside excluded ranges.\nRun 'netsh interface ipv4 show excludedportrange protocol=tcp' to see reserved ranges." |
There was a problem hiding this comment.
The warning Details/Fix text is Windows-specific (Hyper-V excluded port ranges + netsh command) but it’s emitted on all platforms whenever a port can’t be bound. On macOS/Linux this guidance is incorrect/misleading. Consider tailoring Details/Fix based on OS and/or whether the port was blocked due to an excluded range vs a bind failure (e.g., omit the netsh instruction on non-Windows and adjust the wording when excludedRanges is empty).
| results.Add(new EnvironmentCheckResult | |
| { | |
| Category = "environment", | |
| Name = "port-availability", | |
| Status = EnvironmentCheckStatus.Warning, | |
| Message = $"Configured ports unavailable: {portList}", | |
| Details = "These ports fall within a Windows excluded port range (often reserved by Hyper-V) or are otherwise unavailable. The dashboard link displayed by 'aspire run' will not work.", | |
| Fix = "Delete apphost.run.json (or launchSettings.json) and run 'aspire run' to auto-assign available ports, or update the file with ports outside excluded ranges.\nRun 'netsh interface ipv4 show excludedportrange protocol=tcp' to see reserved ranges." | |
| var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); | |
| var hasExcludedPort = blockedPorts.Any(p => IsPortExcluded(p.Port, excludedRanges)); | |
| var hasExcludedRanges = excludedRanges.Count > 0; | |
| var details = (isWindows && hasExcludedPort) | |
| ? "Some configured ports fall within a Windows excluded port range (often reserved by Hyper-V) or are otherwise unavailable. The dashboard link displayed by 'aspire run' might not work." | |
| : "Some configured ports are unavailable on this machine. The dashboard link displayed by 'aspire run' might not work."; | |
| var fix = "Delete apphost.run.json (or launchSettings.json) and run 'aspire run' to auto-assign available ports, or update the file with ports that are currently available."; | |
| if (isWindows && hasExcludedRanges) | |
| { | |
| fix += "\nOn Windows, run 'netsh interface ipv4 show excludedportrange protocol=tcp' to see reserved port ranges and choose ports outside them."; | |
| } | |
| results.Add(new EnvironmentCheckResult | |
| { | |
| Category = "environment", | |
| Name = "port-availability", | |
| Status = EnvironmentCheckStatus.Warning, | |
| Message = $"Configured ports unavailable: {portList}", | |
| Details = details, | |
| Fix = fix |
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger.LogDebug(ex, "Failed to check port availability"); |
There was a problem hiding this comment.
The top-level try/catch in CheckAsync swallows all exceptions and returns whatever has been accumulated so far (often an empty list), which can make the check silently disappear from aspire doctor. Other environment checks return a Warning/Fail result when an exception occurs. Consider returning a Warning result like "Unable to check port availability" with Details = ex.Message so users know the check couldn’t be performed.
| logger.LogDebug(ex, "Failed to check port availability"); | |
| logger.LogDebug(ex, "Failed to check port availability"); | |
| results.Add(new EnvironmentCheckResult | |
| { | |
| Category = "environment", | |
| Name = "port-availability", | |
| Status = EnvironmentCheckStatus.Warning, | |
| Message = "Unable to check port availability", | |
| Details = ex.Message | |
| }); |
| using var process = new Process(); | ||
| process.StartInfo = new ProcessStartInfo | ||
| { | ||
| FileName = "netsh", | ||
| Arguments = "interface ipv4 show excludedportrange protocol=tcp", | ||
| RedirectStandardOutput = true, | ||
| UseShellExecute = false, | ||
| CreateNoWindow = true | ||
| }; | ||
| process.Start(); | ||
| var output = process.StandardOutput.ReadToEnd(); | ||
| process.WaitForExit(5000); | ||
|
|
There was a problem hiding this comment.
GetExcludedPortRanges() attempts to enforce a 5s timeout with WaitForExit(5000), but it calls StandardOutput.ReadToEnd() first. If netsh hangs or produces output slowly, ReadToEnd() can block indefinitely and the timeout won’t help, potentially hanging aspire doctor. Consider reading output asynchronously with a timeout/cancellation and killing the process if it exceeds the limit (similar to ContainerRuntimeCheck’s process handling).
| /// <summary> | ||
| /// Gets the Windows excluded port ranges using netsh. | ||
| /// </summary> | ||
| internal static List<(int Start, int End)> GetExcludedPortRanges() | ||
| { | ||
| var ranges = new List<(int Start, int End)>(); | ||
|
|
||
| try | ||
| { | ||
| using var process = new Process(); | ||
| process.StartInfo = new ProcessStartInfo | ||
| { | ||
| FileName = "netsh", | ||
| Arguments = "interface ipv4 show excludedportrange protocol=tcp", | ||
| RedirectStandardOutput = true, | ||
| UseShellExecute = false, | ||
| CreateNoWindow = true | ||
| }; | ||
| process.Start(); | ||
| var output = process.StandardOutput.ReadToEnd(); | ||
| process.WaitForExit(5000); | ||
|
|
||
| foreach (var line in output.Split('\n')) | ||
| { | ||
| var trimmed = line.Trim(); | ||
| var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); | ||
| if (parts.Length >= 2 && | ||
| int.TryParse(parts[0], out var start) && | ||
| int.TryParse(parts[1], out var end)) | ||
| { | ||
| ranges.Add((start, end)); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This new Windows-specific behavior (netsh excluded port range parsing + exclusion detection) isn’t unit-tested. Given the repo already tests parsing helpers for other environment checks, consider extracting the netsh-output parsing into a separate method that accepts the command output string and adding unit tests for typical netsh output (including headers and "* - Administered" lines). This will keep coverage stable without requiring netsh to run during tests.
🎬 CLI E2E Test RecordingsThe following terminal recordings are available for commit
📹 Recordings uploaded automatically from CI run #21787301944 |
|
We should check if the port is in the user's ephemeral port range as well. Ephemeral ports aren't necessarily reserved (although the hyper-v reserved block is usually in the ephemeral range), but there's a much higher chance of random conflicts as it's the range all random and outgoing ports are assigned from. |
- Extract shared LaunchProfileHelper for apphost.run.json / launchSettings.json parsing - Reuse shared helper in PortAvailabilityCheck and GuestAppHostProject - OS-specific warning text (Windows mentions Hyper-V/netsh, others are generic) - Return Warning result on exception instead of silently swallowing - Add async read with timeout for netsh to prevent hanging - Extract ParseExcludedPortRanges as testable static method - Add tests for netsh output parsing and port extraction from env vars
Warn when configured ports fall within the OS ephemeral/dynamic port range, which has a higher chance of random conflicts from outgoing connections. Cross-platform: Windows (netsh), Linux (/proc), macOS (sysctl), with IANA default fallback. Includes ParseDynamicPortRange tests.
965727f to
b6b9751
Compare
Link LaunchSettings.cs and LaunchProfile.cs from Aspire.Hosting and the LaunchSettingsSerializerContext from Shared/LaunchProfiles into Aspire.Cli. LaunchProfileHelper now deserializes into typed LaunchSettings/LaunchProfile models instead of using raw JsonDocument parsing.
b6b9751 to
062e8c1
Compare
The TcpListener bind test would report configured ports as 'unavailable' if an Aspire app was already running and bound to those ports — exactly when a user would run 'aspire doctor'. Removed the bind test entirely; the remaining checks (Windows excluded port ranges, ephemeral range) are static analysis that don't depend on runtime state.
Description
Add a new environment check to
aspire doctorthat detects when configured ports inapphost.run.jsonorlaunchSettings.jsonare unavailable. On Windows, this checks against Hyper-V excluded port ranges (vianetsh). On all platforms, it verifies ports can actually be bound via a TCP socket test.This addresses a confusing failure mode where the dashboard link displayed by
aspire rundoesn't work because the configured HTTPS port (e.g. 56279) silently falls within a Windows Hyper-V excluded port range (56224-56323). DCP can't bind the proxy, the CLI falls back to displaying the configured URL, and the user getsconnection refusedwith no indication of why.Example output when a port is blocked:
Checklist