diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index d32f3914..769780b3 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -35,7 +35,44 @@ 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 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) + { + if (current is AddressInUseException) + { + return true; + } + } + return false; + } + 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 @@ -94,7 +131,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 @@ -118,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 new file mode 100644 index 00000000..9abfff2d --- /dev/null +++ b/DevProxy.Tests/PortConflictTests.cs @@ -0,0 +1,166 @@ +// 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.Proxy; +using DevProxy.State; +using Microsoft.AspNetCore.Connections; +using System.CommandLine; +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("127.0.0.1", 65536)] + [InlineData("not-an-ip", 8000)] + public void IsPortAvailable_LeavesUncheckableInputToKestrel(string ipAddress, int port) + { + 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[] + { + 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, "127.0.0.1", 8000, excludePid: 0)?.Pid); + } + + [Fact] + public void FindOwningInstance_MatchesApiPort() + { + var states = new[] + { + 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, "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)] + [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") }; + + var owner = PortConflict.FindOwningInstance(states, requestedAddress, 8000, excludePid: 0); + + Assert.Equal(expectedMatch, owner is not null); + } + + [Fact] + public void FindOwningInstance_IgnoresCurrentProcessAndPortZero() + { + var states = new[] { State(5, "http://127.0.0.1:8000", 8000, "http://127.0.0.1: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] + 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"))); + } + + [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 6577c0bc..2c78f0e7 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; @@ -278,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) @@ -303,6 +313,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)) { @@ -316,6 +336,25 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c 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. 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))).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 ?? @@ -344,6 +383,13 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c { 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.ApiIpAddress, _proxyConfiguration.ApiPort, CancellationToken.None)); + return 1; + } catch (Exception ex) { _logger.LogError(ex, "An error occurred while running Dev Proxy"); @@ -419,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 ec32588f..3a7d795d 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -92,33 +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; - - foreach (var existing in existingStates) - { - 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; - } - } - } - // Clean up old log files StateManager.CleanupOldLogs(); @@ -234,9 +207,19 @@ 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) { + if (!string.IsNullOrEmpty(logOutput)) + { + foreach (var entry in ToJsonLogEntries(logOutput)) + { + await Console.Error.WriteLineAsync(entry); + } + } var message = "Dev Proxy failed to start."; if (!string.IsNullOrEmpty(errorOutput)) { @@ -251,6 +234,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 +323,72 @@ 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 + { + 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..1c9a3fb6 --- /dev/null +++ b/DevProxy/Proxy/PortConflict.cs @@ -0,0 +1,165 @@ +// 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 || port > IPEndPoint.MaxPort) + { + return true; + } + + if (!TryParseAddress(ipAddress, out var 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; + } + } + + /// + /// 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, string? ipAddress, int port, int excludePid) + { + if (port <= 0 || !TryParseAddress(ipAddress, out var requested)) + { + return null; + } + + 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) + { + 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, ipAddress, port, cancellationToken); + return message; + } + + public static async Task GetMessageAsync( + PortKind kind, string? ipAddress, int port, CancellationToken cancellationToken = default) + { + ProxyInstanceState? owner = null; + try + { + // 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) + { + // 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 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) || Covers(address, requestedAddress) || Covers(requestedAddress, address); + } + + // 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) + { + 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