Skip to content
Open
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
156 changes: 156 additions & 0 deletions DevProxy.Tests/StateManagerPidReuseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// 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 System.Diagnostics;
using System.Text.Json;
using DevProxy.Commands;
using DevProxy.State;
using Xunit;

namespace DevProxy.Tests;

[Collection("management-environment")]
public class StateManagerPidReuseTests
{
[Fact]
public void ProcessStartTime_RoundTripsThroughJson()
{
var startTime = new DateTimeOffset(2026, 1, 2, 3, 4, 5, 678, TimeSpan.Zero);
var json = JsonSerializer.Serialize(new ProxyInstanceState { ProcessStartTime = startTime });

Assert.Contains("\"processStartTime\"", json, StringComparison.Ordinal);
Assert.Equal(startTime, JsonSerializer.Deserialize<ProxyInstanceState>(json)!.ProcessStartTime);
}

[Fact]
public void ProcessStartTime_MissingInLegacyState_IsNull()
{
var state = JsonSerializer.Deserialize<ProxyInstanceState>("""{"pid":1234,"apiUrl":"http://127.0.0.1:8897"}""");

Assert.NotNull(state);
Assert.Null(state.ProcessStartTime);
}

[Fact]
public void GetCurrentProcessStartTime_MatchesProcessStartTime()
{
using var process = Process.GetCurrentProcess();

var startTime = StateManager.GetCurrentProcessStartTime();

Assert.NotNull(startTime);
Assert.Equal(TimeSpan.Zero, startTime.Value.Offset);
Assert.True((startTime.Value - process.StartTime.ToUniversalTime()).Duration() <= TimeSpan.FromSeconds(1));
}

[Fact]
public void IsInstanceAlive_MatchingStartTime_ReturnsTrue()
{
var state = new ProxyInstanceState
{
Pid = Environment.ProcessId,
ProcessStartTime = StateManager.GetCurrentProcessStartTime()
};

Assert.True(StateManager.IsInstanceAlive(state));
}

[Fact]
public void IsInstanceAlive_PidReusedByDifferentProcess_ReturnsFalse()
{
var state = new ProxyInstanceState
{
Pid = Environment.ProcessId,
ProcessStartTime = StateManager.GetCurrentProcessStartTime()!.Value.AddMinutes(-30)
};

Assert.False(StateManager.IsInstanceAlive(state));
}

[Fact]
public void IsInstanceAlive_LegacyStateWithoutStartTime_FallsBackToPidCheck()
{
var state = new ProxyInstanceState { Pid = Environment.ProcessId };

Assert.True(StateManager.IsInstanceAlive(state));
}

[Fact]
public void IsInstanceAlive_ExitedProcess_ReturnsFalse()
{
using var process = Process.Start(new ProcessStartInfo("dotnet", "--version")
{
RedirectStandardOutput = true,
UseShellExecute = false
})!;
process.WaitForExit();
var pid = process.Id;

Assert.False(StateManager.IsInstanceAlive(new ProxyInstanceState { Pid = pid }));
}

[Fact]
public void IsSameProcess_StartTimeMismatch_ReturnsFalse()
{
using var process = Process.GetCurrentProcess();
var state = new ProxyInstanceState
{
Pid = process.Id,
ProcessStartTime = StateManager.GetCurrentProcessStartTime()!.Value.AddSeconds(5)
};

Assert.False(StateManager.IsSameProcess(process, state));
}

[Fact]
public void VerifyProcessIdentity_ReportsMatchMismatchAndNotRecorded()
{
using var process = Process.GetCurrentProcess();
var startTime = StateManager.GetCurrentProcessStartTime()!.Value;

Assert.Equal(ProcessIdentity.Match,
StateManager.VerifyProcessIdentity(process, new ProxyInstanceState { Pid = process.Id, ProcessStartTime = startTime }));
Assert.Equal(ProcessIdentity.Mismatch,
StateManager.VerifyProcessIdentity(process, new ProxyInstanceState { Pid = process.Id, ProcessStartTime = startTime.AddMinutes(-30) }));
Assert.Equal(ProcessIdentity.NotRecorded,
StateManager.VerifyProcessIdentity(process, new ProxyInstanceState { Pid = process.Id }));
}

[Fact]
public async Task DeleteStateAsync_RecordReplacedByNewInstance_KeepsNewRecord()
{
const int pid = int.MaxValue - 7;
var path = StateManager.GetInstanceStateFilePath(pid);
var stale = new ProxyInstanceState { Pid = pid, ProcessStartTime = DateTimeOffset.UtcNow.AddHours(-1) };
var replacement = new ProxyInstanceState { Pid = pid, ProcessStartTime = DateTimeOffset.UtcNow };
try
{
await StateManager.SaveStateAsync(replacement);

await StateManager.DeleteStateAsync(stale);
Assert.True(File.Exists(path));

await StateManager.DeleteStateAsync(replacement);
Assert.False(File.Exists(path));
}
finally
{
File.Delete(path);
}
}

[Fact]
public void GetLogStartTime_UsesRecordedProcessStartTime()
{
var recorded = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero);
var state = new ProxyInstanceState
{
// Points at a live, unrelated process to simulate PID reuse
Pid = Environment.ProcessId,
ProcessStartTime = recorded
};

Assert.Equal(recorded, LogsCommand.GetLogStartTime(state));
}
}
1 change: 1 addition & 0 deletions DevProxy/Commands/DevProxyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ private async Task WriteInstanceStateAsync(string apiUrl)
ProxyUrl = SystemProxyAddress.ToHttpAuthority(_proxyConfiguration.IPAddress, _proxyConfiguration.Port),
LogFile = IsInternalDaemon ? DetachedLogFilePath : string.Empty,
StartedAt = DateTimeOffset.UtcNow,
ProcessStartTime = StateManager.GetCurrentProcessStartTime(),
ConfigFile = _proxyConfiguration.ConfigFile,
Port = _proxyConfiguration.Port,
AsSystemProxy = _proxyConfiguration.AsSystemProxy,
Expand Down
5 changes: 5 additions & 0 deletions DevProxy/Commands/LogsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ private async Task<int> RunAsync(ParseResult parseResult, CancellationToken canc

internal static DateTimeOffset GetLogStartTime(ProxyInstanceState state)
{
if (state.ProcessStartTime is { } recorded)
{
return recorded;
}

using var process = System.Diagnostics.Process.GetProcessById(state.Pid);
return process.StartTime;
}
Expand Down
111 changes: 74 additions & 37 deletions DevProxy/Commands/StopCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ private static async Task<int> StopByPidAsync(int pid, bool force, CancellationT
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);
await StateManager.DeleteStateAsync(orphan, cancellationToken);
return 0;
}

Expand Down Expand Up @@ -129,45 +129,22 @@ private static async Task<int> StopInstanceAsync(ProxyInstanceState state, bool

// Wait for process to exit
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < TimeSpan.FromSeconds(10))
while (stopwatch.Elapsed < TimeSpan.FromSeconds(10) &&
StateManager.IsInstanceAlive(state))
{
try
{
var process = Process.GetProcessById(state.Pid);
if (process.HasExited)
{
break;
}
}
catch (ArgumentException)
{
// Process has exited
break;
}

await Task.Delay(200, cancellationToken);
}

// Verify the process actually exited before cleaning up state
var exited = false;
try
{
var proc = Process.GetProcessById(state.Pid);
exited = proc.HasExited;
}
catch (ArgumentException)
{
exited = true;
}

if (!exited)
// Verify the process actually exited before cleaning up state.
// A PID reused by an unrelated process counts as exited.
if (StateManager.IsInstanceAlive(state))
{
Console.WriteLine($"Dev Proxy (PID: {state.Pid}) did not stop in time.");
Console.WriteLine($"Use --force --pid {state.Pid} to forcefully terminate the process.");
return 1;
}

await StateManager.DeleteStateAsync(state.Pid, cancellationToken);
await StateManager.DeleteStateAsync(state, cancellationToken);
Console.WriteLine($"Dev Proxy (PID: {state.Pid}) stopped.");
return 0;
}
Expand All @@ -192,19 +169,66 @@ private static async Task<int> StopInstanceAsync(ProxyInstanceState state, bool

private static async Task<int> ForceStopAsync(ProxyInstanceState state, CancellationToken cancellationToken)
{
// A killed process can't run its own cleanup, so restore the system proxy
// on its behalf before terminating it.
var identity = GetProcessIdentity(state);
if (identity == ProcessIdentity.Unverifiable)
{
Console.WriteLine($"Cannot verify that PID {state.Pid} is Dev Proxy; refusing to terminate it.");
return 1;
}

// null: process gone; Mismatch: PID reused by an unrelated process.
var isAlive = identity is ProcessIdentity.Match or ProcessIdentity.NotRecorded;

if (state.AsSystemProxy)
{
new SystemProxyManager(NullLogger<SystemProxyManager>.Instance).Disable();
// A killed process can't run its own cleanup, so restore the system
// proxy on its behalf before terminating it. If the instance is
// already gone, treat it as an orphan and leave the system proxy
// alone when another live instance owns it.
if (isAlive || await StateManager.FindSystemProxyInstanceAsync(cancellationToken) is null)
{
new SystemProxyManager(NullLogger<SystemProxyManager>.Instance).Disable();
}
}

if (!isAlive)
{
Console.WriteLine("Dev Proxy process not found (already stopped).");
await StateManager.DeleteStateAsync(state, cancellationToken);
return 0;
}

try
{
var process = Process.GetProcessById(state.Pid);
process.Kill();
using var process = Process.GetProcessById(state.Pid);
// Re-verify on the handle we're about to kill.
identity = StateManager.VerifyProcessIdentity(process, state);
if (identity == ProcessIdentity.Unverifiable)
{
Console.WriteLine($"Cannot verify that PID {state.Pid} is Dev Proxy; refusing to terminate it.");
return 1;
}

Console.WriteLine($"Forcefully terminated Dev Proxy (PID: {state.Pid}).");
if (identity == ProcessIdentity.Mismatch)
{
Console.WriteLine("Dev Proxy process not found (already stopped).");
}
else
{
process.Kill();
// Let the process exit so its PID-keyed token can be removed.
using var exitTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
exitTimeout.CancelAfter(TimeSpan.FromSeconds(5));
try
{
await process.WaitForExitAsync(exitTimeout.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Token is cleaned up later by the stale-credential sweep
}
Console.WriteLine($"Forcefully terminated Dev Proxy (PID: {state.Pid}).");
}
}
catch (ArgumentException)
{
Expand All @@ -220,7 +244,20 @@ private static async Task<int> ForceStopAsync(ProxyInstanceState state, Cancella
return 1;
}

await StateManager.DeleteStateAsync(state.Pid, cancellationToken);
await StateManager.DeleteStateAsync(state, cancellationToken);
return 0;
}

private static ProcessIdentity? GetProcessIdentity(ProxyInstanceState state)
{
try
{
using var process = Process.GetProcessById(state.Pid);
return StateManager.VerifyProcessIdentity(process, state);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return null;
}
}
}
2 changes: 1 addition & 1 deletion DevProxy/Proxy/SystemProxyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ public static async Task<OrphanReconciliation> ReconcileOrphanedSystemProxiesAsy

foreach (var orphan in orphans)
{
await StateManager.DeleteStateAsync(orphan.Pid, cancellationToken);
await StateManager.DeleteStateAsync(orphan, cancellationToken);
}

return new(orphans, disabled);
Expand Down
20 changes: 20 additions & 0 deletions DevProxy/State/ProcessIdentity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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.

namespace DevProxy.State;

/// <summary>
/// Result of comparing a running process with the identity recorded in a state file.
/// </summary>
internal enum ProcessIdentity
{
/// <summary>The state has no recorded start time (written by an older version).</summary>
NotRecorded,
/// <summary>The process start time matches the recorded value.</summary>
Match,
/// <summary>The process start time differs; the PID was reused.</summary>
Mismatch,
/// <summary>The process start time couldn't be read.</summary>
Unverifiable
}
7 changes: 7 additions & 0 deletions DevProxy/State/ProxyInstanceState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ internal sealed class ProxyInstanceState
[JsonPropertyName("startedAt")]
public DateTimeOffset StartedAt { get; set; }

/// <summary>
/// Start time (UTC) of the process identified by <see cref="Pid"/>.
/// Used to detect PID reuse. Null for state written by older versions.
/// </summary>
[JsonPropertyName("processStartTime")]
public DateTimeOffset? ProcessStartTime { get; set; }

[JsonPropertyName("configFile")]
public string? ConfigFile { get; set; }

Expand Down
Loading
Loading