From 3c894a536c8a725a361d7bb2a8267ea476a7f916 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:05:36 +0100 Subject: [PATCH 1/4] Improve port conflict handling for proxy and API ports Check whether the proxy and API ports are available before starting and show an actionable error instead of a Kestrel AddressInUseException stack trace. Identify conflicts with other Dev Proxy instances via their state files and suggest 'devproxy stop --pid' or '--port 0'/'--api-port 0'. Also handle bind races without stack traces, exit with code 1 when the proxy port is taken (previously 0), and surface the daemon's failure reason when starting in detached mode. Closes #1273 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 32 ++++- DevProxy.Tests/PortConflictTests.cs | 109 +++++++++++++++ DevProxy/Commands/DevProxyCommand.cs | 25 ++++ DevProxy/Program.cs | 56 ++++++-- DevProxy/Proxy/PortConflict.cs | 134 +++++++++++++++++++ 5 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 DevProxy.Tests/PortConflictTests.cs create mode 100644 DevProxy/Proxy/PortConflict.cs diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index d32f3914..6ab7645b 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -35,6 +35,23 @@ public sealed class KestrelProxyEngine( { private readonly ILogger _logger = loggerFactory.CreateLogger(); + /// + /// True when the engine stopped the host because the proxy port was already in use. + /// + public bool PortInUse { get; private set; } + + private static bool IsAddressInUse(Exception? ex) + { + for (var current = ex; current is not null; current = current.InnerException) + { + if (current is AddressInUseException) + { + return true; + } + } + return false; + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var ipAddress = string.IsNullOrWhiteSpace(configuration.IPAddress) @@ -94,7 +111,20 @@ configuration is IProcessTreeProxyConfiguration processTreeConfiguration && await using var app = builder.Build(); - await app.StartAsync(stoppingToken).ConfigureAwait(false); + try + { + await app.StartAsync(stoppingToken).ConfigureAwait(false); + } + catch (IOException ex) when (hostApplicationLifetime is not null && IsAddressInUse(ex)) + { + // Let the host report a friendly message instead of crashing with a stack trace. + PortInUse = true; + _logger.LogDebug(ex, "Failed to bind the proxy to {Address}:{Port}", ipAddress, port); + // Stopping while the host is still starting surfaces as a cancellation + // instead of a clean shutdown, so wait until it has started. + _ = hostApplicationLifetime.ApplicationStarted.Register(hostApplicationLifetime.StopApplication); + return; + } inactivityTimer?.Reset(); // When --port 0 is used the OS assigns a free port; Kestrel rewrites the // ListenOptions endpoint to the bound port after StartAsync, so log THAT diff --git a/DevProxy.Tests/PortConflictTests.cs b/DevProxy.Tests/PortConflictTests.cs new file mode 100644 index 00000000..9a8a616d --- /dev/null +++ b/DevProxy.Tests/PortConflictTests.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Proxy; +using DevProxy.State; +using Microsoft.AspNetCore.Connections; +using System.Net; +using System.Net.Sockets; +using Xunit; + +namespace DevProxy.Tests; + +public class PortConflictTests +{ + [Fact] + public void IsPortAvailable_ReturnsFalse_WhenPortIsInUse() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + Assert.False(PortConflict.IsPortAvailable("127.0.0.1", port)); + } + + [Fact] + public void IsPortAvailable_ReturnsTrue_WhenPortIsFree() + { + int port; + using (var listener = new TcpListener(IPAddress.Loopback, 0)) + { + listener.Start(); + port = ((IPEndPoint)listener.LocalEndpoint).Port; + } + + Assert.True(PortConflict.IsPortAvailable("127.0.0.1", port)); + } + + [Theory] + [InlineData("127.0.0.1", 0)] + [InlineData("not-an-ip", 8000)] + public void IsPortAvailable_LeavesUncheckableInputToKestrel(string ipAddress, int port) + { + Assert.True(PortConflict.IsPortAvailable(ipAddress, port)); + } + + [Fact] + public void FindOwningInstance_MatchesProxyPort() + { + var states = new[] + { + new ProxyInstanceState { Pid = 1, Port = 8001, ApiUrl = "http://127.0.0.1:8898/" }, + new ProxyInstanceState { Pid = 2, Port = 8000, ApiUrl = "http://127.0.0.1:8897/" } + }; + + Assert.Equal(2, PortConflict.FindOwningInstance(states, PortKind.Proxy, 8000, excludePid: 0)?.Pid); + } + + [Fact] + public void FindOwningInstance_MatchesApiPort() + { + var states = new[] + { + new ProxyInstanceState { Pid = 1, Port = 8001, ApiUrl = "http://127.0.0.1:8898/" }, + new ProxyInstanceState { Pid = 2, Port = 8000, ApiUrl = "http://127.0.0.1:8897" } + }; + + Assert.Equal(2, PortConflict.FindOwningInstance(states, PortKind.Api, 8897, excludePid: 0)?.Pid); + } + + [Fact] + public void FindOwningInstance_IgnoresCurrentProcessAndPortZero() + { + var states = new[] { new ProxyInstanceState { Pid = 5, Port = 8000, ApiUrl = "http://127.0.0.1:0" } }; + + Assert.Null(PortConflict.FindOwningInstance(states, PortKind.Proxy, 8000, excludePid: 5)); + Assert.Null(PortConflict.FindOwningInstance(states, PortKind.Api, 0, excludePid: 0)); + } + + [Fact] + public void FormatMessage_DevProxyInstance_SuggestsStopAndPortZero() + { + var message = PortConflict.FormatMessage(PortKind.Api, 8897, new ProxyInstanceState { Pid = 42 }); + + Assert.Contains("API port 8897 is already in use by another Dev Proxy instance (PID: 42).", message, StringComparison.Ordinal); + Assert.Contains("devproxy stop --pid 42", message, StringComparison.Ordinal); + Assert.Contains("devproxy --api-port 0", message, StringComparison.Ordinal); + } + + [Fact] + public void FormatMessage_OtherApplication_SuggestsPortZeroAndConfig() + { + var message = PortConflict.FormatMessage(PortKind.Proxy, 8000, owner: null); + + Assert.Contains("Port 8000 is already in use by another application.", message, StringComparison.Ordinal); + Assert.Contains("devproxy --port 0", message, StringComparison.Ordinal); + Assert.Contains("'port' in your devproxyrc.json", message, StringComparison.Ordinal); + Assert.DoesNotContain("devproxy stop", message, StringComparison.Ordinal); + } + + [Fact] + public void IsAddressInUse_DetectsWrappedKestrelException() + { + var ex = new IOException("Failed to bind", new AddressInUseException("Address already in use")); + + Assert.True(PortConflict.IsAddressInUse(ex)); + Assert.False(PortConflict.IsAddressInUse(new IOException("other"))); + } +} diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 6577c0bc..8287e86e 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -2,6 +2,7 @@ using DevProxy.Abstractions.Proxy; using DevProxy.Abstractions.Utils; using DevProxy.Proxy; +using DevProxy.Proxy.Kestrel; using DevProxy.State; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; @@ -303,6 +304,16 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c } ConfigureFromOptions(parseResult); + + var portConflict = + await PortConflict.CheckAsync(PortKind.Proxy, _proxyConfiguration.IPAddress, _proxyConfiguration.Port, cancellationToken) ?? + await PortConflict.CheckAsync(PortKind.Api, _proxyConfiguration.ApiIpAddress, _proxyConfiguration.ApiPort, cancellationToken); + if (portConflict is not null) + { + _logger.LogError("{PortConflict}", portConflict); + return 1; + } + var optionsLoadedArgs = new OptionsLoadedArgs(parseResult); foreach (var plugin in _plugins.Where(p => p.Enabled)) { @@ -338,12 +349,26 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c await _app.WaitForShutdownAsync(cancellationToken); + if (_app.Services.GetServices().OfType().Any(e => e.PortInUse)) + { + _logger.LogError("{PortConflict}", + await PortConflict.GetMessageAsync(PortKind.Proxy, _proxyConfiguration.Port, CancellationToken.None)); + return 1; + } + return 0; } catch (TaskCanceledException) { throw; } + catch (Exception ex) when (PortConflict.IsAddressInUse(ex)) + { + // The API port was taken between the pre-flight check and binding. + _logger.LogError("{PortConflict}", + await PortConflict.GetMessageAsync(PortKind.Api, _proxyConfiguration.ApiPort, CancellationToken.None)); + return 1; + } catch (Exception ex) { _logger.LogError(ex, "An error occurred while running Dev Proxy"); diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index ec32588f..91941e27 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -101,21 +101,17 @@ await Console.Error.WriteLineAsync( var requestedPort = earlyOptions.Port ?? 8000; var requestedApiPort = earlyOptions.ApiPort ?? 8897; - foreach (var existing in existingStates) + var proxyPortOwner = PortConflict.FindOwningInstance(existingStates, PortKind.Proxy, requestedPort, Environment.ProcessId); + var apiPortOwner = PortConflict.FindOwningInstance(existingStates, PortKind.Api, requestedApiPort, Environment.ProcessId); + var conflictMessage = proxyPortOwner is not null + ? PortConflict.FormatMessage(PortKind.Proxy, requestedPort, proxyPortOwner) + : apiPortOwner is not null + ? PortConflict.FormatMessage(PortKind.Api, requestedApiPort, apiPortOwner) + : null; + if (conflictMessage is not null) { - if (requestedPort != 0 && existing.Port == requestedPort) - { - await Console.Error.WriteLineAsync($"Port {requestedPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); - await Console.Error.WriteLineAsync("Use a different --port or stop the existing instance first."); - return 1; - } - - if (requestedApiPort != 0 && existing.ApiUrl.EndsWith($":{requestedApiPort}", StringComparison.Ordinal)) - { - await Console.Error.WriteLineAsync($"API port {requestedApiPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); - await Console.Error.WriteLineAsync("Use a different --api-port or stop the existing instance first."); - return 1; - } + await Console.Error.WriteLineAsync(isJsonOutput ? FormatJsonLogEntry("error", conflictMessage) : conflictMessage); + return 1; } } @@ -234,9 +230,17 @@ await Console.Out.WriteLineAsync(FormatJsonResultEntry(new { var errorOutput = await process.StandardError.ReadToEndAsync(); var standardOutput = await process.StandardOutput.ReadToEndAsync(); + // The daemon redirects its console to its log file, so that's where + // the reason for the failure (like a port conflict) ends up. + var logOutput = await ReadDaemonLogAsync(process.Id); if (isJsonOutput) { + // The daemon's log already consists of JSONL entries; pass them through as-is. + if (!string.IsNullOrEmpty(logOutput)) + { + await Console.Error.WriteLineAsync(logOutput.TrimEnd()); + } var message = "Dev Proxy failed to start."; if (!string.IsNullOrEmpty(errorOutput)) { @@ -251,6 +255,10 @@ await Console.Out.WriteLineAsync(FormatJsonResultEntry(new else { await Console.Error.WriteLineAsync("Dev Proxy failed to start."); + if (!string.IsNullOrEmpty(logOutput)) + { + await Console.Error.WriteLineAsync(logOutput.TrimEnd()); + } if (!string.IsNullOrEmpty(errorOutput)) { await Console.Error.WriteLineAsync(errorOutput); @@ -336,6 +344,26 @@ static string FormatJsonLogEntry(string level, string message) return JsonSerializer.Serialize(entry, jsonlOptions); } +static async Task ReadDaemonLogAsync(int pid) +{ + try + { + var logsFolder = StateManager.GetLogsFolder(); + if (!Directory.Exists(logsFolder)) + { + return null; + } + var logFile = Directory.GetFiles(logsFolder, $"devproxy-{pid}-*.log") + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + return logFile is null ? null : await File.ReadAllTextAsync(logFile); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } +} + static bool IsJsonOutputRequested(string[] args) { for (var i = 0; i < args.Length; i++) diff --git a/DevProxy/Proxy/PortConflict.cs b/DevProxy/Proxy/PortConflict.cs new file mode 100644 index 00000000..0827308b --- /dev/null +++ b/DevProxy/Proxy/PortConflict.cs @@ -0,0 +1,134 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Commands; +using DevProxy.State; +using Microsoft.AspNetCore.Connections; +using System.Net; +using System.Net.Sockets; + +namespace DevProxy.Proxy; + +internal enum PortKind +{ + Proxy, + Api +} + +/// +/// Detects port conflicts for the proxy and API ports and produces +/// actionable error messages instead of raw socket exceptions. +/// +internal static class PortConflict +{ + /// + /// Returns false only when binding the address fails because it's already in use. + /// Any other failure (invalid address, permissions) is left for Kestrel to report. + /// + public static bool IsPortAvailable(string? ipAddress, int port) + { + if (port <= 0) + { + return true; + } + + var address = string.IsNullOrWhiteSpace(ipAddress) ? IPAddress.Loopback : null; + if (address is null && !IPAddress.TryParse(ipAddress, out address)) + { + return true; + } + + using var listener = new TcpListener(address, port); + try + { + listener.Start(); + return true; + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + return false; + } + catch (SocketException) + { + return true; + } + } + + public static ProxyInstanceState? FindOwningInstance( + IEnumerable states, PortKind kind, int port, int excludePid) + { + if (port <= 0) + { + return null; + } + + return states.FirstOrDefault(s => s.Pid != excludePid && GetPort(s, kind) == port); + } + + public static string FormatMessage(PortKind kind, int port, ProxyInstanceState? owner) + { + var option = kind == PortKind.Api ? DevProxyCommand.ApiPortOptionName : DevProxyCommand.PortOptionName; + var configProperty = kind == PortKind.Api ? "apiPort" : "port"; + var portLabel = kind == PortKind.Api ? "API port" : "Port"; + var nl = Environment.NewLine; + + return owner is not null + ? $"{portLabel} {port} is already in use by another Dev Proxy instance (PID: {owner.Pid}).{nl}" + + $"Stop the existing instance with 'devproxy stop --pid {owner.Pid}' or use a different port:{nl}" + + $" devproxy {option} 0 (lets the OS assign a free port)" + : $"{portLabel} {port} is already in use by another application.{nl}" + + $"Use a different port:{nl}" + + $" devproxy {option} 0 (lets the OS assign a free port){nl}" + + $"You can also set '{configProperty}' in your devproxyrc.json config file."; + } + + /// + /// Checks the given port and returns an error message if it's in use, or null if it's available. + /// + public static async Task CheckAsync( + PortKind kind, string? ipAddress, int port, CancellationToken cancellationToken = default) + { + if (IsPortAvailable(ipAddress, port)) + { + return null; + } + + var message = await GetMessageAsync(kind, port, cancellationToken); + return message; + } + + public static async Task GetMessageAsync(PortKind kind, int port, CancellationToken cancellationToken = default) + { + ProxyInstanceState? owner = null; + try + { + var states = await StateManager.LoadAllStatesAsync(cancellationToken); + owner = FindOwningInstance(states, kind, port, Environment.ProcessId); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // State files are best-effort; fall back to the generic message. + } + + return FormatMessage(kind, port, owner); + } + + public static bool IsAddressInUse(Exception? ex) + { + for (var current = ex; current is not null; current = current.InnerException) + { + if (current is AddressInUseException || + current is SocketException { SocketErrorCode: SocketError.AddressAlreadyInUse }) + { + return true; + } + } + return false; + } + + private static int GetPort(ProxyInstanceState state, PortKind kind) => + kind == PortKind.Proxy + ? state.Port + : Uri.TryCreate(state.ApiUrl, UriKind.Absolute, out var apiUri) ? apiUri.Port : -1; +} From 4f230d59fe5f194bcf0262ad742f25732018a242 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:15:39 +0100 Subject: [PATCH 2/4] Address review feedback on port conflict handling - Publish instance state only after the proxy listener has started - Keep detached JSON output valid by wrapping non-JSON log lines - Match ownership across proxy/API ports and require overlapping bind address - Use a non-pruning state lookup for port conflict diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 22 ++++++++ DevProxy.Tests/PortConflictTests.cs | 50 +++++++++++++---- DevProxy/Commands/DevProxyCommand.cs | 25 ++++++--- DevProxy/Program.cs | 56 +++++++++++++++++-- DevProxy/Proxy/PortConflict.cs | 57 +++++++++++++++----- DevProxy/State/StateManager.cs | 34 ++++++++++++ 6 files changed, 209 insertions(+), 35 deletions(-) diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 6ab7645b..769780b3 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -40,6 +40,14 @@ public sealed class KestrelProxyEngine( /// public bool PortInUse { get; private set; } + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Completes with true once the proxy is listening, or with false if it + /// stopped before it could start listening (for example, port in use). + /// + public Task Started => _started.Task; + private static bool IsAddressInUse(Exception? ex) { for (var current = ex; current is not null; current = current.InnerException) @@ -53,6 +61,18 @@ private static bool IsAddressInUse(Exception? ex) } protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await RunAsync(stoppingToken).ConfigureAwait(false); + } + finally + { + _ = _started.TrySetResult(false); + } + } + + private async Task RunAsync(CancellationToken stoppingToken) { var ipAddress = string.IsNullOrWhiteSpace(configuration.IPAddress) ? IPAddress.Loopback @@ -148,6 +168,8 @@ configuration is IProcessTreeProxyConfiguration processTreeConfiguration && systemProxyEnabled = true; } + _ = _started.TrySetResult(true); + try { await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false); diff --git a/DevProxy.Tests/PortConflictTests.cs b/DevProxy.Tests/PortConflictTests.cs index 9a8a616d..42f78c4f 100644 --- a/DevProxy.Tests/PortConflictTests.cs +++ b/DevProxy.Tests/PortConflictTests.cs @@ -44,16 +44,19 @@ public void IsPortAvailable_LeavesUncheckableInputToKestrel(string ipAddress, in Assert.True(PortConflict.IsPortAvailable(ipAddress, port)); } + private static ProxyInstanceState State(int pid, string proxyUrl, int port, string apiUrl) => + new() { Pid = pid, ProxyUrl = proxyUrl, Port = port, ApiUrl = apiUrl }; + [Fact] public void FindOwningInstance_MatchesProxyPort() { var states = new[] { - new ProxyInstanceState { Pid = 1, Port = 8001, ApiUrl = "http://127.0.0.1:8898/" }, - new ProxyInstanceState { Pid = 2, Port = 8000, ApiUrl = "http://127.0.0.1:8897/" } + State(1, "http://127.0.0.1:8001", 8001, "http://127.0.0.1:8898/"), + State(2, "http://127.0.0.1:8000", 8000, "http://127.0.0.1:8897/") }; - Assert.Equal(2, PortConflict.FindOwningInstance(states, PortKind.Proxy, 8000, excludePid: 0)?.Pid); + Assert.Equal(2, PortConflict.FindOwningInstance(states, "127.0.0.1", 8000, excludePid: 0)?.Pid); } [Fact] @@ -61,20 +64,49 @@ public void FindOwningInstance_MatchesApiPort() { var states = new[] { - new ProxyInstanceState { Pid = 1, Port = 8001, ApiUrl = "http://127.0.0.1:8898/" }, - new ProxyInstanceState { Pid = 2, Port = 8000, ApiUrl = "http://127.0.0.1:8897" } + State(1, "http://127.0.0.1:8001", 8001, "http://127.0.0.1:8898/"), + State(2, "http://127.0.0.1:8000", 8000, "http://127.0.0.1:8897") }; - Assert.Equal(2, PortConflict.FindOwningInstance(states, PortKind.Api, 8897, excludePid: 0)?.Pid); + Assert.Equal(2, PortConflict.FindOwningInstance(states, "127.0.0.1", 8897, excludePid: 0)?.Pid); + } + + [Fact] + public void FindOwningInstance_MatchesAcrossPortKinds() + { + // Another instance's API listener can hold the port requested for the proxy, and vice versa. + var states = new[] { State(2, "http://127.0.0.1:8000", 8000, "http://127.0.0.1:8897") }; + + Assert.Equal(2, PortConflict.FindOwningInstance(states, "127.0.0.1", 8897, excludePid: 0)?.Pid); + Assert.Equal(2, PortConflict.FindOwningInstance(states, null, 8000, excludePid: 0)?.Pid); + } + + [Theory] + [InlineData("http://127.0.0.1:8000", "127.0.0.1", true)] + [InlineData("http://0.0.0.0:8000", "127.0.0.1", true)] + [InlineData("http://127.0.0.1:8000", "0.0.0.0", true)] + [InlineData("http://[::]:8000", "::1", true)] + [InlineData("http://[::1]:8000", "[::1]", true)] + [InlineData("http://127.0.0.1:8000", "192.168.1.10", false)] + [InlineData("http://[::1]:8000", "127.0.0.1", false)] + [InlineData("http://localhost:8000", "127.0.0.1", false)] + [InlineData("http://127.0.0.1:8000", "not-an-ip", false)] + public void FindOwningInstance_RequiresOverlappingAddress(string proxyUrl, string requestedAddress, bool expectedMatch) + { + var states = new[] { State(2, proxyUrl, 8000, "http://127.0.0.1:8897") }; + + var owner = PortConflict.FindOwningInstance(states, requestedAddress, 8000, excludePid: 0); + + Assert.Equal(expectedMatch, owner is not null); } [Fact] public void FindOwningInstance_IgnoresCurrentProcessAndPortZero() { - var states = new[] { new ProxyInstanceState { Pid = 5, Port = 8000, ApiUrl = "http://127.0.0.1:0" } }; + var states = new[] { State(5, "http://127.0.0.1:8000", 8000, "http://127.0.0.1:0") }; - Assert.Null(PortConflict.FindOwningInstance(states, PortKind.Proxy, 8000, excludePid: 5)); - Assert.Null(PortConflict.FindOwningInstance(states, PortKind.Api, 0, excludePid: 0)); + Assert.Null(PortConflict.FindOwningInstance(states, "127.0.0.1", 8000, excludePid: 5)); + Assert.Null(PortConflict.FindOwningInstance(states, "127.0.0.1", 0, excludePid: 0)); } [Fact] diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 8287e86e..39ae460d 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -327,6 +327,22 @@ await PortConflict.CheckAsync(PortKind.Proxy, _proxyConfiguration.IPAddress, _pr await ApiSecurity.SaveTokenAsync(cancellationToken); await _app.StartAsync(cancellationToken); + // The proxy binds in a background service, so wait for it before + // publishing readiness. Otherwise a detached launcher could report a + // successful start for a proxy that failed to bind. + var proxyEngines = _app.Services.GetServices().OfType().ToArray(); + var proxyStarted = (await Task.WhenAll(proxyEngines.Select(e => e.Started)).WaitAsync(cancellationToken)).All(started => started); + if (!proxyStarted) + { + await _app.WaitForShutdownAsync(cancellationToken); + if (proxyEngines.Any(e => e.PortInUse)) + { + _logger.LogError("{PortConflict}", + await PortConflict.GetMessageAsync(PortKind.Proxy, _proxyConfiguration.IPAddress, _proxyConfiguration.Port, CancellationToken.None)); + } + return 1; + } + var serverAddresses = _app.Services.GetRequiredService().Features.Get(); var serverAddress = serverAddresses?.Addresses.FirstOrDefault(); var address = ApiSecurity.GetApiUrl(serverAddress ?? @@ -349,13 +365,6 @@ await PortConflict.CheckAsync(PortKind.Proxy, _proxyConfiguration.IPAddress, _pr await _app.WaitForShutdownAsync(cancellationToken); - if (_app.Services.GetServices().OfType().Any(e => e.PortInUse)) - { - _logger.LogError("{PortConflict}", - await PortConflict.GetMessageAsync(PortKind.Proxy, _proxyConfiguration.Port, CancellationToken.None)); - return 1; - } - return 0; } catch (TaskCanceledException) @@ -366,7 +375,7 @@ await PortConflict.CheckAsync(PortKind.Proxy, _proxyConfiguration.IPAddress, _pr { // The API port was taken between the pre-flight check and binding. _logger.LogError("{PortConflict}", - await PortConflict.GetMessageAsync(PortKind.Api, _proxyConfiguration.ApiPort, CancellationToken.None)); + await PortConflict.GetMessageAsync(PortKind.Api, _proxyConfiguration.ApiIpAddress, _proxyConfiguration.ApiPort, CancellationToken.None)); return 1; } catch (Exception ex) diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index 91941e27..a24bad87 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -101,8 +101,8 @@ await Console.Error.WriteLineAsync( var requestedPort = earlyOptions.Port ?? 8000; var requestedApiPort = earlyOptions.ApiPort ?? 8897; - var proxyPortOwner = PortConflict.FindOwningInstance(existingStates, PortKind.Proxy, requestedPort, Environment.ProcessId); - var apiPortOwner = PortConflict.FindOwningInstance(existingStates, PortKind.Api, requestedApiPort, Environment.ProcessId); + var proxyPortOwner = PortConflict.FindOwningInstance(existingStates, earlyOptions.IPAddress, requestedPort, Environment.ProcessId); + var apiPortOwner = PortConflict.FindOwningInstance(existingStates, earlyOptions.ApiIpAddress, requestedApiPort, Environment.ProcessId); var conflictMessage = proxyPortOwner is not null ? PortConflict.FormatMessage(PortKind.Proxy, requestedPort, proxyPortOwner) : apiPortOwner is not null @@ -236,10 +236,12 @@ await Console.Out.WriteLineAsync(FormatJsonResultEntry(new if (isJsonOutput) { - // The daemon's log already consists of JSONL entries; pass them through as-is. if (!string.IsNullOrEmpty(logOutput)) { - await Console.Error.WriteLineAsync(logOutput.TrimEnd()); + foreach (var entry in ToJsonLogEntries(logOutput)) + { + await Console.Error.WriteLineAsync(entry); + } } var message = "Dev Proxy failed to start."; if (!string.IsNullOrEmpty(errorOutput)) @@ -344,6 +346,52 @@ static string FormatJsonLogEntry(string level, string message) return JsonSerializer.Serialize(entry, jsonlOptions); } +// The daemon logs JSONL, but output it doesn't control (like unhandled +// exception stack traces) can end up in its log too. Keep JSON entries as-is +// and wrap other runs of lines so the output stays valid JSONL. +static IEnumerable ToJsonLogEntries(string log) +{ + var pending = new List(); + foreach (var line in log.TrimEnd().Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + if (IsJsonObject(trimmed)) + { + if (pending.Count > 0) + { + yield return FormatJsonLogEntry("error", string.Join(Environment.NewLine, pending)); + pending.Clear(); + } + yield return trimmed; + } + else if (pending.Count > 0 || !string.IsNullOrWhiteSpace(trimmed)) + { + pending.Add(trimmed); + } + } + if (pending.Count > 0) + { + yield return FormatJsonLogEntry("error", string.Join(Environment.NewLine, pending).TrimEnd()); + } +} + +static bool IsJsonObject(string line) +{ + if (!line.StartsWith('{')) + { + return false; + } + try + { + using var document = JsonDocument.Parse(line); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } +} + static async Task ReadDaemonLogAsync(int pid) { try diff --git a/DevProxy/Proxy/PortConflict.cs b/DevProxy/Proxy/PortConflict.cs index 0827308b..33b301d0 100644 --- a/DevProxy/Proxy/PortConflict.cs +++ b/DevProxy/Proxy/PortConflict.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -33,8 +33,7 @@ public static bool IsPortAvailable(string? ipAddress, int port) return true; } - var address = string.IsNullOrWhiteSpace(ipAddress) ? IPAddress.Loopback : null; - if (address is null && !IPAddress.TryParse(ipAddress, out address)) + if (!TryParseAddress(ipAddress, out var address)) { return true; } @@ -55,15 +54,21 @@ public static bool IsPortAvailable(string? ipAddress, int port) } } + /// + /// Finds another Dev Proxy instance with a proxy or API listener on the given + /// port whose bind address overlaps the requested one. Returns null when + /// ownership can't be established. + /// public static ProxyInstanceState? FindOwningInstance( - IEnumerable states, PortKind kind, int port, int excludePid) + IEnumerable states, string? ipAddress, int port, int excludePid) { - if (port <= 0) + if (port <= 0 || !TryParseAddress(ipAddress, out var requested)) { return null; } - return states.FirstOrDefault(s => s.Pid != excludePid && GetPort(s, kind) == port); + return states.FirstOrDefault(s => s.Pid != excludePid && + (ListensOn(s.ProxyUrl, s.Port, requested, port) || ListensOn(s.ApiUrl, null, requested, port))); } public static string FormatMessage(PortKind kind, int port, ProxyInstanceState? owner) @@ -94,17 +99,20 @@ public static string FormatMessage(PortKind kind, int port, ProxyInstanceState? return null; } - var message = await GetMessageAsync(kind, port, cancellationToken); + var message = await GetMessageAsync(kind, ipAddress, port, cancellationToken); return message; } - public static async Task GetMessageAsync(PortKind kind, int port, CancellationToken cancellationToken = default) + public static async Task GetMessageAsync( + PortKind kind, string? ipAddress, int port, CancellationToken cancellationToken = default) { ProxyInstanceState? owner = null; try { - var states = await StateManager.LoadAllStatesAsync(cancellationToken); - owner = FindOwningInstance(states, kind, port, Environment.ProcessId); + // Don't prune state files here: they may be needed to recover an + // orphaned system-proxy registration. + var states = await StateManager.LoadRunningStatesAsync(cancellationToken); + owner = FindOwningInstance(states, ipAddress, port, Environment.ProcessId); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -127,8 +135,29 @@ public static bool IsAddressInUse(Exception? ex) return false; } - private static int GetPort(ProxyInstanceState state, PortKind kind) => - kind == PortKind.Proxy - ? state.Port - : Uri.TryCreate(state.ApiUrl, UriKind.Absolute, out var apiUri) ? apiUri.Port : -1; + private static bool ListensOn(string url, int? port, IPAddress requestedAddress, int requestedPort) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + (port ?? uri.Port) != requestedPort || + !TryParseAddress(uri.Host, out var address)) + { + return false; + } + + return address.Equals(requestedAddress) || IsWildcard(address) || IsWildcard(requestedAddress); + } + + private static bool IsWildcard(IPAddress address) => + address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any); + + private static bool TryParseAddress(string? ipAddress, out IPAddress address) + { + if (string.IsNullOrWhiteSpace(ipAddress)) + { + address = IPAddress.Loopback; + return true; + } + + return IPAddress.TryParse(ipAddress.Trim('[', ']'), out address!); + } } diff --git a/DevProxy/State/StateManager.cs b/DevProxy/State/StateManager.cs index 5ca80d53..9ac4d3b3 100644 --- a/DevProxy/State/StateManager.cs +++ b/DevProxy/State/StateManager.cs @@ -195,6 +195,40 @@ public static async Task> LoadAllStatesAsync(Cancellati return states.Find(s => s.AsSystemProxy); } + /// + /// Loads states of running instances without deleting any state or credential + /// files, so it's safe for diagnostics that run before orphaned system-proxy + /// registrations have been reconciled. + /// + public static async Task> LoadRunningStatesAsync(CancellationToken cancellationToken = default) + { + var configFolder = GetConfigFolder(); + if (!Directory.Exists(configFolder)) + { + return []; + } + + var states = new List(); + var seenPids = new HashSet(); + + var stateFiles = Directory.GetFiles(configFolder, $"{StateFilePrefix}*{StateFileExtension}"); + var legacyPath = GetStateFilePath(); + var paths = File.Exists(legacyPath) ? [.. stateFiles, legacyPath] : stateFiles; + + foreach (var filePath in paths) + { + var state = await ReadStateFromFileAsync(filePath, cancellationToken); + if (state is not null && + IsProcessRunning(state.Pid) && + seenPids.Add(state.Pid)) + { + states.Add(state); + } + } + + return states; + } + /// /// Finds state records left behind by instances that registered as the /// system proxy but whose process is no longer alive (e.g. crashed or killed From 7d5fcb053a5164b46286eef5ce1bc4aff55ffa77 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:23:48 +0100 Subject: [PATCH 3/4] Don't cancel proxy startup wait when a bind failure stops the host Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy/Commands/DevProxyCommand.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 39ae460d..a55439b4 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -329,9 +329,12 @@ await PortConflict.CheckAsync(PortKind.Proxy, _proxyConfiguration.IPAddress, _pr // The proxy binds in a background service, so wait for it before // publishing readiness. Otherwise a detached launcher could report a - // successful start for a proxy that failed to bind. + // successful start for a proxy that failed to bind. Don't pass the + // cancellation token: a bind failure stops the host, which would cancel + // the wait before the failure is reported. The engine always completes + // Started once the host stops. var proxyEngines = _app.Services.GetServices().OfType().ToArray(); - var proxyStarted = (await Task.WhenAll(proxyEngines.Select(e => e.Started)).WaitAsync(cancellationToken)).All(started => started); + var proxyStarted = (await Task.WhenAll(proxyEngines.Select(e => e.Started))).All(started => started); if (!proxyStarted) { await _app.WaitForShutdownAsync(cancellationToken); From 446b9fc8ac40bffaff22b00c9afc29863516b406 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:15:20 +0100 Subject: [PATCH 4/4] Address further review feedback on port conflict handling - Drop the detached launcher's CLI-only conflict pre-check; the daemon's config-aware check reports conflicts instead - Skip the listener probe for ports above 65535 and validate --port and --api-port to the 0-65535 range - Treat 0.0.0.0 as overlapping IPv4 addresses only; :: stays dual-stack Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/PortConflictTests.cs | 25 +++++++++++++++++++++++++ DevProxy/Commands/DevProxyCommand.cs | 11 +++++++++++ DevProxy/Program.cs | 23 ----------------------- DevProxy/Proxy/PortConflict.cs | 10 ++++++---- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/DevProxy.Tests/PortConflictTests.cs b/DevProxy.Tests/PortConflictTests.cs index 42f78c4f..9abfff2d 100644 --- a/DevProxy.Tests/PortConflictTests.cs +++ b/DevProxy.Tests/PortConflictTests.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using DevProxy.Commands; using DevProxy.Proxy; using DevProxy.State; using Microsoft.AspNetCore.Connections; +using System.CommandLine; using System.Net; using System.Net.Sockets; using Xunit; @@ -38,6 +40,7 @@ public void IsPortAvailable_ReturnsTrue_WhenPortIsFree() [Theory] [InlineData("127.0.0.1", 0)] + [InlineData("127.0.0.1", 65536)] [InlineData("not-an-ip", 8000)] public void IsPortAvailable_LeavesUncheckableInputToKestrel(string ipAddress, int port) { @@ -91,6 +94,10 @@ public void FindOwningInstance_MatchesAcrossPortKinds() [InlineData("http://[::1]:8000", "127.0.0.1", false)] [InlineData("http://localhost:8000", "127.0.0.1", false)] [InlineData("http://127.0.0.1:8000", "not-an-ip", false)] + [InlineData("http://0.0.0.0:8000", "[::1]", false)] + [InlineData("http://[::1]:8000", "0.0.0.0", false)] + [InlineData("http://[::]:8000", "127.0.0.1", true)] + [InlineData("http://127.0.0.1:8000", "::", true)] public void FindOwningInstance_RequiresOverlappingAddress(string proxyUrl, string requestedAddress, bool expectedMatch) { var states = new[] { State(2, proxyUrl, 8000, "http://127.0.0.1:8897") }; @@ -138,4 +145,22 @@ public void IsAddressInUse_DetectsWrappedKestrelException() Assert.True(PortConflict.IsAddressInUse(ex)); Assert.False(PortConflict.IsAddressInUse(new IOException("other"))); } + + [Theory] + [InlineData("0", true)] + [InlineData("8000", true)] + [InlineData("65535", true)] + [InlineData("65536", false)] + [InlineData("-1", false)] + [InlineData("abc", false)] + public void PortOptions_ValidateRange(string value, bool valid) + { + var option = new Option(DevProxyCommand.PortOptionName); + option.Validators.Add(DevProxyCommand.ValidatePort); + var command = new RootCommand { option }; + + var result = command.Parse([DevProxyCommand.PortOptionName, value]); + + Assert.Equal(valid, result.Errors.Count == 0); + } } diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index a55439b4..2c78f0e7 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -279,6 +279,15 @@ public async Task InvokeAsync(string[] args, WebApplication app) return exitCode; } + internal static void ValidatePort(OptionResult input) + { + var value = input.Tokens.Count > 0 ? input.Tokens[0].Value : null; + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var port) || port > System.Net.IPEndPoint.MaxPort) + { + input.AddError($"'{value}' is not a valid port. Specify a number between 0 and {System.Net.IPEndPoint.MaxPort}."); + } + } + private async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) { if (_app is null) @@ -456,12 +465,14 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions) Description = "The port for the proxy to listen on", HelpName = "port" }; + portOption.Validators.Add(ValidatePort); var apiPortOption = new Option(ApiPortOptionName) { Description = "The port for the Dev Proxy API to listen on", HelpName = "api-port" }; + apiPortOption.Validators.Add(ValidatePort); var apiIpAddressOption = new Option(ApiIpAddressOptionName) { Description = "The API bind address (default: 127.0.0.1). Use ::1 for IPv6 loopback. Non-loopback binding requires a trusted network", diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index a24bad87..3a7d795d 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -92,29 +92,6 @@ await Console.Error.WriteLineAsync( return 1; } - // Check for port conflicts with existing instances - var existingStates = await StateManager.LoadAllStatesAsync(); - if (existingStates.Count > 0) - { - var earlyOptions = new DevProxyConfigOptions(); - earlyOptions.ParseOptions(args); - var requestedPort = earlyOptions.Port ?? 8000; - var requestedApiPort = earlyOptions.ApiPort ?? 8897; - - var proxyPortOwner = PortConflict.FindOwningInstance(existingStates, earlyOptions.IPAddress, requestedPort, Environment.ProcessId); - var apiPortOwner = PortConflict.FindOwningInstance(existingStates, earlyOptions.ApiIpAddress, requestedApiPort, Environment.ProcessId); - var conflictMessage = proxyPortOwner is not null - ? PortConflict.FormatMessage(PortKind.Proxy, requestedPort, proxyPortOwner) - : apiPortOwner is not null - ? PortConflict.FormatMessage(PortKind.Api, requestedApiPort, apiPortOwner) - : null; - if (conflictMessage is not null) - { - await Console.Error.WriteLineAsync(isJsonOutput ? FormatJsonLogEntry("error", conflictMessage) : conflictMessage); - return 1; - } - } - // Clean up old log files StateManager.CleanupOldLogs(); diff --git a/DevProxy/Proxy/PortConflict.cs b/DevProxy/Proxy/PortConflict.cs index 33b301d0..1c9a3fb6 100644 --- a/DevProxy/Proxy/PortConflict.cs +++ b/DevProxy/Proxy/PortConflict.cs @@ -28,7 +28,7 @@ internal static class PortConflict /// public static bool IsPortAvailable(string? ipAddress, int port) { - if (port <= 0) + if (port <= 0 || port > IPEndPoint.MaxPort) { return true; } @@ -144,11 +144,13 @@ private static bool ListensOn(string url, int? port, IPAddress requestedAddress, return false; } - return address.Equals(requestedAddress) || IsWildcard(address) || IsWildcard(requestedAddress); + return address.Equals(requestedAddress) || Covers(address, requestedAddress) || Covers(requestedAddress, address); } - private static bool IsWildcard(IPAddress address) => - address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any); + // An IPv6 wildcard listener is dual-stack, but an IPv4 wildcard only covers IPv4 addresses. + private static bool Covers(IPAddress wildcard, IPAddress address) => + wildcard.Equals(IPAddress.IPv6Any) || + (wildcard.Equals(IPAddress.Any) && address.AddressFamily == AddressFamily.InterNetwork); private static bool TryParseAddress(string? ipAddress, out IPAddress address) {