Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 60 additions & 52 deletions DevProxy/Commands/StopCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,18 +35,16 @@ private async Task<int> 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;
Expand All @@ -61,9 +60,55 @@ private async Task<int> RunAsync(ParseResult parseResult, CancellationToken canc
}
}

ReportReconciledOrphans(reconciliation);

return exitCode;
}

private static async Task<int> 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<int> StopInstanceAsync(ProxyInstanceState state, bool force, CancellationToken cancellationToken)
{
if (force)
Expand Down Expand Up @@ -146,7 +191,12 @@ private static async Task<int> StopInstanceAsync(ProxyInstanceState state, bool

private static async Task<int> 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
{
Expand All @@ -172,46 +222,4 @@ private static async Task<int> ForceStopAsync(ProxyInstanceState state, Cancella
await StateManager.DeleteStateAsync(state.Pid, cancellationToken);
return 0;
}

/// <summary>
/// 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).
/// </summary>
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
}
}
}
18 changes: 18 additions & 0 deletions DevProxy/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ static async Task<int> 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)
Expand Down
11 changes: 11 additions & 0 deletions DevProxy/Proxy/ProxyEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
125 changes: 125 additions & 0 deletions DevProxy/Proxy/SystemProxyManager.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
internal static class SystemProxyManager
{
/// <summary>
/// The outcome of reconciling orphaned system-proxy registrations.
/// </summary>
/// <param name="Orphans">The orphaned instance records that were reconciled.</param>
/// <param name="SystemProxyDisabled">
/// 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.
/// </param>
internal readonly record struct OrphanReconciliation(
IReadOnlyList<ProxyInstanceState> Orphans,
bool SystemProxyDisabled);

/// <summary>
/// Disables the operating system proxy.
/// On Windows this clears the WinINET system proxy settings; on macOS it
/// runs <c>toggle-proxy.sh off</c>. On Linux this is a no-op because Dev
/// Proxy never configures a system proxy there.
/// </summary>
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.
}
}

/// <summary>
/// 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.
/// </summary>
public static async Task<OrphanReconciliation> 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();
}
}
}
Loading
Loading