From 68764bca8b77c62bd637edfdb823a1508feaf576 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 11 Jul 2026 12:21:56 +0200 Subject: [PATCH] Restore system proxy after a crashed instance When a detached instance running as system proxy is terminated uncleanly, the OS proxy was left pointing at a dead port and 'devproxy stop --force' could not recover it: StateManager prunes state files for dead PIDs before the stop command reads them, so no instance was found and the system proxy was never disabled. Reconcile orphaned system-proxy registrations independently of finding a live instance: - Add StateManager.GetOrphanedSystemProxyStatesAsync (reads records for dead PIDs with asSystemProxy=true without pruning them first). - Add cross-platform, idempotent SystemProxyManager.Disable (Windows WinINET via ProxyServer.DisableAllSystemProxies, macOS toggle-proxy.sh off) and ReconcileOrphanedSystemProxiesAsync, guarded so the OS proxy is only disabled when no live instance still owns it. - devproxy stop / stop --pid now restore the system proxy for crashed instances; self-heal also runs at startup (detached launcher and ProxyEngine) so a stale registration doesn't linger across runs. Refs dotnet/dev-proxy#1731. PID-reuse hardening tracked in dotnet/dev-proxy#1755. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b22cd4a-15a3-4a8c-8248-fe776339aa45 --- DevProxy/Commands/StopCommand.cs | 112 +++++++++++++----------- DevProxy/Program.cs | 18 ++++ DevProxy/Proxy/ProxyEngine.cs | 11 +++ DevProxy/Proxy/SystemProxyManager.cs | 125 +++++++++++++++++++++++++++ DevProxy/State/StateManager.cs | 84 ++++++++++++++---- 5 files changed, 282 insertions(+), 68 deletions(-) create mode 100644 DevProxy/Proxy/SystemProxyManager.cs diff --git a/DevProxy/Commands/StopCommand.cs b/DevProxy/Commands/StopCommand.cs index 5767f34de..8bb0f707d 100644 --- a/DevProxy/Commands/StopCommand.cs +++ b/DevProxy/Commands/StopCommand.cs @@ -2,6 +2,7 @@ // 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 System.CommandLine; using System.Diagnostics; @@ -34,18 +35,16 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc if (pid is not null) { - var state = await StateManager.LoadStateByPidAsync(pid.Value, cancellationToken); - if (state is null) - { - Console.WriteLine($"No running Dev Proxy instance with PID {pid.Value}."); - return 1; - } - - return await StopInstanceAsync(state, force, cancellationToken); + return await StopByPidAsync(pid.Value, force, cancellationToken); } + // Reconcile any system-proxy registrations left behind by crashed + // instances first — this must run before LoadAllStatesAsync, which prunes + // (deletes) the stale state files it depends on. + var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(cancellationToken); + var states = await StateManager.LoadAllStatesAsync(cancellationToken); - if (states.Count == 0) + if (states.Count == 0 && reconciliation.Orphans.Count == 0) { Console.WriteLine("Dev Proxy is not running."); return 1; @@ -61,9 +60,55 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc } } + ReportReconciledOrphans(reconciliation); + return exitCode; } + private static async Task StopByPidAsync(int pid, bool force, CancellationToken cancellationToken) + { + // Capture a potential orphaned system-proxy record before LoadStateByPidAsync + // prunes (deletes) the stale state file for a dead PID. + var orphan = (await StateManager.GetOrphanedSystemProxyStatesAsync(cancellationToken)) + .Find(o => o.Pid == pid); + + var state = await StateManager.LoadStateByPidAsync(pid, cancellationToken); + if (state is not null) + { + return await StopInstanceAsync(state, force, cancellationToken); + } + + if (orphan is not null) + { + var liveOwner = await StateManager.FindSystemProxyInstanceAsync(cancellationToken); + if (liveOwner is null) + { + SystemProxyManager.Disable(); + Console.WriteLine($"Restored system proxy left by crashed Dev Proxy (PID: {pid})."); + } + else + { + Console.WriteLine($"Removed stale record for crashed Dev Proxy (PID: {pid}); system proxy is owned by a running instance (PID: {liveOwner.Pid})."); + } + + await StateManager.DeleteStateAsync(pid, cancellationToken); + return 0; + } + + Console.WriteLine($"No running Dev Proxy instance with PID {pid}."); + return 1; + } + + private static void ReportReconciledOrphans(SystemProxyManager.OrphanReconciliation reconciliation) + { + foreach (var orphan in reconciliation.Orphans) + { + Console.WriteLine(reconciliation.SystemProxyDisabled + ? $"Restored system proxy left by crashed Dev Proxy (PID: {orphan.Pid})." + : $"Removed stale record for crashed Dev Proxy (PID: {orphan.Pid}); system proxy is owned by a running instance."); + } + } + private static async Task StopInstanceAsync(ProxyInstanceState state, bool force, CancellationToken cancellationToken) { if (force) @@ -146,7 +191,12 @@ private static async Task StopInstanceAsync(ProxyInstanceState state, bool private static async Task ForceStopAsync(ProxyInstanceState state, CancellationToken cancellationToken) { - DisableSystemProxy(); + // A killed process can't run its own cleanup, so restore the system proxy + // on its behalf before terminating it. + if (state.AsSystemProxy) + { + SystemProxyManager.Disable(); + } try { @@ -172,46 +222,4 @@ private static async Task ForceStopAsync(ProxyInstanceState state, Cancella await StateManager.DeleteStateAsync(state.Pid, cancellationToken); return 0; } - - /// - /// Disables the system proxy on macOS by calling toggle-proxy.sh off. - /// This ensures the system proxy settings are cleaned up even when the - /// daemon process is killed forcefully (SIGKILL cannot be caught). - /// - private static void DisableSystemProxy() - { - if (!OperatingSystem.IsMacOS()) - { - return; - } - - var bashScriptPath = Path.Join(AppContext.BaseDirectory, "toggle-proxy.sh"); - if (!File.Exists(bashScriptPath)) - { - return; - } - - var startInfo = new ProcessStartInfo - { - FileName = "/bin/bash", - Arguments = $"{bashScriptPath} off", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - try - { - using var process = new Process { StartInfo = startInfo }; - process.Start(); - if (!process.WaitForExit(TimeSpan.FromSeconds(10))) - { - process.Kill(); - } - } - catch - { - // Best-effort cleanup — don't block the stop flow - } - } } \ No newline at end of file diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index fc39b88f1..e3de0b897 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -51,6 +51,24 @@ static async Task StartDetachedProcessAsync(string[] args) { var isJsonOutput = IsJsonOutputRequested(args); + // Recover from a system-proxy registration left behind by a previous + // instance that crashed before restoring the OS proxy. This must run before + // any state-loading call below, which prunes (deletes) the stale state files + // it depends on without restoring the proxy. + var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(); + foreach (var orphan in reconciliation.Orphans) + { + var message = $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {orphan.Pid})."; + if (isJsonOutput) + { + await Console.Out.WriteLineAsync(FormatJsonLogEntry("info", message)); + } + else + { + await Console.Out.WriteLineAsync(message); + } + } + // Check if an instance is already running as system proxy var systemProxyInstance = await StateManager.FindSystemProxyInstanceAsync(); if (systemProxyInstance is not null) diff --git a/DevProxy/Proxy/ProxyEngine.cs b/DevProxy/Proxy/ProxyEngine.cs index c4c257517..c18afa557 100755 --- a/DevProxy/Proxy/ProxyEngine.cs +++ b/DevProxy/Proxy/ProxyEngine.cs @@ -159,6 +159,17 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation("Dev Proxy listening on {IPAddress}:{Port}...", endPoint.IpAddress, endPoint.Port); } + // Recover from a system-proxy registration left behind by a previous + // instance that crashed before restoring the OS proxy, so a stale + // registration doesn't linger across runs. + var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(stoppingToken); + foreach (var orphan in reconciliation.Orphans) + { + _logger.LogInformation( + "Recovered system proxy left by a crashed Dev Proxy instance (PID: {Pid}).", + orphan.Pid); + } + if (_config.AsSystemProxy) { if (RunTime.IsWindows) diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs new file mode 100644 index 000000000..0628135a7 --- /dev/null +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -0,0 +1,125 @@ +// 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.Abstractions.Utils; +using DevProxy.State; +using System.Diagnostics; +using Titanium.Web.Proxy; + +namespace DevProxy.Proxy; + +/// +/// Restores the operating system proxy settings out-of-process. +/// Used to recover from a Dev Proxy instance that registered itself as the +/// system proxy but was terminated without running its normal cleanup (crash, +/// SIGKILL, OOM, power loss). The operation is best-effort, idempotent, and +/// safe to call even when no system proxy is currently configured. +/// +internal static class SystemProxyManager +{ + /// + /// The outcome of reconciling orphaned system-proxy registrations. + /// + /// The orphaned instance records that were reconciled. + /// + /// True if the OS system proxy was disabled as part of reconciliation. This + /// is false when a live instance still owns the system proxy, in which case + /// only the stale state records are removed. + /// + internal readonly record struct OrphanReconciliation( + IReadOnlyList Orphans, + bool SystemProxyDisabled); + + /// + /// Disables the operating system proxy. + /// On Windows this clears the WinINET system proxy settings; on macOS it + /// runs toggle-proxy.sh off. On Linux this is a no-op because Dev + /// Proxy never configures a system proxy there. + /// + public static void Disable() + { + try + { + if (OperatingSystem.IsWindows()) + { + DisableWindows(); + } + else if (OperatingSystem.IsMacOS()) + { + DisableMacOS(); + } + // Linux: Dev Proxy never sets a system proxy, so there's nothing to restore. + } + catch + { + // Best-effort cleanup — never block the stop flow. + } + } + + /// + /// Reconciles system-proxy registrations left behind by crashed instances. + /// Restores the OS proxy (unless a live instance still owns it) and removes + /// the stale state records. Safe to call when there are no orphans. + /// + public static async Task ReconcileOrphanedSystemProxiesAsync(CancellationToken cancellationToken = default) + { + // Capture orphans before any liveness-pruning call deletes their state files. + var orphans = await StateManager.GetOrphanedSystemProxyStatesAsync(cancellationToken); + if (orphans.Count == 0) + { + return new([], false); + } + + // Only touch the global OS proxy setting if no live instance currently + // owns it — otherwise we'd disable a proxy a running instance depends on. + var liveOwner = await StateManager.FindSystemProxyInstanceAsync(cancellationToken); + var disabled = false; + if (liveOwner is null) + { + Disable(); + disabled = true; + } + + foreach (var orphan in orphans) + { + await StateManager.DeleteStateAsync(orphan.Pid, cancellationToken); + } + + return new(orphans, disabled); + } + + private static void DisableWindows() + { + // DisableAllSystemProxies clears the WinINET proxy settings directly and + // does not require a running proxy, so a fresh instance is enough to undo + // a registration left behind by a crashed process. + using var proxyServer = new ProxyServer(userTrustRootCertificate: false); + proxyServer.DisableAllSystemProxies(); + } + + private static void DisableMacOS() + { + var bashScriptPath = Path.Join(ProxyUtils.AppFolder ?? AppContext.BaseDirectory, "toggle-proxy.sh"); + if (!File.Exists(bashScriptPath)) + { + return; + } + + var startInfo = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"{bashScriptPath} off", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + _ = process.Start(); + if (!process.WaitForExit(TimeSpan.FromSeconds(10))) + { + process.Kill(); + } + } +} diff --git a/DevProxy/State/StateManager.cs b/DevProxy/State/StateManager.cs index c73b37851..39607d9d7 100644 --- a/DevProxy/State/StateManager.cs +++ b/DevProxy/State/StateManager.cs @@ -175,6 +175,46 @@ public static async Task> LoadAllStatesAsync(Cancellati return states.Find(s => s.AsSystemProxy); } + /// + /// 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 + /// before running cleanup). These represent orphaned system-proxy + /// registrations that outlived the process and should be reconciled by + /// restoring the OS proxy. + /// The state files are left in place so the caller can remove them (via + /// ) only after the + /// system proxy has been restored. + /// + public static async Task> GetOrphanedSystemProxyStatesAsync(CancellationToken cancellationToken = default) + { + var configFolder = GetConfigFolder(); + if (!Directory.Exists(configFolder)) + { + return []; + } + + var orphaned = 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 && + state.AsSystemProxy && + !IsProcessRunning(state.Pid) && + seenPids.Add(state.Pid)) + { + orphaned.Add(state); + } + } + + return orphaned; + } + /// /// Deletes the state file for a specific PID. /// Also cleans up the legacy state file if it belongs to this PID. @@ -256,30 +296,42 @@ private static bool IsProcessRunning(int pid) string filePath, CancellationToken cancellationToken) { - if (!File.Exists(filePath)) + var state = await ReadStateFromFileAsync(filePath, cancellationToken); + if (state is null) { return null; } - try + // Verify the process is still running + if (!IsProcessRunning(state.Pid)) { - var json = await File.ReadAllTextAsync(filePath, cancellationToken); - var state = JsonSerializer.Deserialize(json); + // Clean up stale state file + DeleteFile(filePath); + return null; + } - if (state is null) - { - return null; - } + return state; + } - // Verify the process is still running - if (!IsProcessRunning(state.Pid)) - { - // Clean up stale state file - DeleteFile(filePath); - return null; - } + /// + /// Reads and deserializes a state file without verifying process liveness or + /// pruning stale files. Returns null if the file doesn't exist or can't be + /// parsed. Use this when the caller needs to inspect records of processes that + /// may no longer be alive (e.g. crash recovery). + /// + private static async Task ReadStateFromFileAsync( + string filePath, + CancellationToken cancellationToken) + { + if (!File.Exists(filePath)) + { + return null; + } - return state; + try + { + var json = await File.ReadAllTextAsync(filePath, cancellationToken); + return JsonSerializer.Deserialize(json); } catch (JsonException) {