From a3008e954d530aec5ad0bf8e956fa9030b209174 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 09:55:46 +0100 Subject: [PATCH 1/3] Detect PID reuse in detached instance state. Closes #1755 Persist the process start time in the instance state file and verify it before treating a PID as a live Dev Proxy instance. A mismatch means the OS recycled the PID and the record is stale. State files written by older versions (without the start time) keep the previous PID-only behavior. stop no longer waits on or force-kills an unrelated process that reused the PID, and logs uses the recorded start time. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/StateManagerPidReuseTests.cs | 118 ++++++++++++++++++++ DevProxy/Commands/DevProxyCommand.cs | 1 + DevProxy/Commands/LogsCommand.cs | 5 + DevProxy/Commands/StopCommand.cs | 48 +++----- DevProxy/State/ProxyInstanceState.cs | 7 ++ DevProxy/State/StateManager.cs | 81 +++++++++++++- 6 files changed, 224 insertions(+), 36 deletions(-) create mode 100644 DevProxy.Tests/StateManagerPidReuseTests.cs diff --git a/DevProxy.Tests/StateManagerPidReuseTests.cs b/DevProxy.Tests/StateManagerPidReuseTests.cs new file mode 100644 index 00000000..5b12a052 --- /dev/null +++ b/DevProxy.Tests/StateManagerPidReuseTests.cs @@ -0,0 +1,118 @@ +// 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; + +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(json)!.ProcessStartTime); + } + + [Fact] + public void ProcessStartTime_MissingInLegacyState_IsNull() + { + var state = JsonSerializer.Deserialize("""{"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 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)); + } +} diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 6577c0bc..e8bfea77 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -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, diff --git a/DevProxy/Commands/LogsCommand.cs b/DevProxy/Commands/LogsCommand.cs index f9367a87..db3eca34 100644 --- a/DevProxy/Commands/LogsCommand.cs +++ b/DevProxy/Commands/LogsCommand.cs @@ -103,6 +103,11 @@ private async Task 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; } diff --git a/DevProxy/Commands/StopCommand.cs b/DevProxy/Commands/StopCommand.cs index f2ae0303..8b8c48d4 100644 --- a/DevProxy/Commands/StopCommand.cs +++ b/DevProxy/Commands/StopCommand.cs @@ -129,38 +129,15 @@ private static async Task 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."); @@ -201,10 +178,17 @@ private static async Task ForceStopAsync(ProxyInstanceState state, Cancella try { - var process = Process.GetProcessById(state.Pid); - process.Kill(); - - Console.WriteLine($"Forcefully terminated Dev Proxy (PID: {state.Pid})."); + using var process = Process.GetProcessById(state.Pid); + // Never kill an unrelated process that reused the PID. + if (!StateManager.IsSameProcess(process, state)) + { + Console.WriteLine("Dev Proxy process not found (already stopped)."); + } + else + { + process.Kill(); + Console.WriteLine($"Forcefully terminated Dev Proxy (PID: {state.Pid})."); + } } catch (ArgumentException) { diff --git a/DevProxy/State/ProxyInstanceState.cs b/DevProxy/State/ProxyInstanceState.cs index d709a86c..3f1d03df 100644 --- a/DevProxy/State/ProxyInstanceState.cs +++ b/DevProxy/State/ProxyInstanceState.cs @@ -27,6 +27,13 @@ internal sealed class ProxyInstanceState [JsonPropertyName("startedAt")] public DateTimeOffset StartedAt { get; set; } + /// + /// Start time (UTC) of the process identified by . + /// Used to detect PID reuse. Null for state written by older versions. + /// + [JsonPropertyName("processStartTime")] + public DateTimeOffset? ProcessStartTime { get; set; } + [JsonPropertyName("configFile")] public string? ConfigFile { get; set; } diff --git a/DevProxy/State/StateManager.cs b/DevProxy/State/StateManager.cs index 5ca80d53..3638053d 100644 --- a/DevProxy/State/StateManager.cs +++ b/DevProxy/State/StateManager.cs @@ -23,6 +23,10 @@ internal static class StateManager private static readonly string _configFolder = ResolveConfigFolder(); + // Some platforms (e.g. Linux) derive the process start time from the boot + // time, which can yield slightly different values between reads. + private static readonly TimeSpan _startTimeTolerance = TimeSpan.FromSeconds(1); + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true @@ -225,7 +229,7 @@ public static async Task> GetOrphanedSystemProxyStatesA var state = await ReadStateFromFileAsync(filePath, cancellationToken); if (state is not null && state.AsSystemProxy && - !IsProcessRunning(state.Pid) && + !IsInstanceAlive(state) && seenPids.Add(state.Pid)) { orphaned.Add(state); @@ -285,6 +289,74 @@ public static async Task IsInstanceRunningAsync(CancellationToken cancella return state is not null; } + /// + /// Gets the start time (UTC) of the current process, or null if it can't be determined. + /// + public static DateTimeOffset? GetCurrentProcessStartTime() + { + using var process = Process.GetCurrentProcess(); + return GetProcessStartTime(process); + } + + /// + /// Checks whether the instance described by is still running. + /// Besides the PID, compares the recorded process start time (when available) + /// so that a PID recycled by the OS for an unrelated process isn't mistaken + /// for the original instance. + /// + internal static bool IsInstanceAlive(ProxyInstanceState state) + { + try + { + using var process = Process.GetProcessById(state.Pid); + return !process.HasExited && IsSameProcess(process, state); + } + catch (ArgumentException) + { + // Process doesn't exist + return false; + } + catch (InvalidOperationException) + { + // Process has exited + return false; + } + } + + /// + /// Checks whether is the process recorded in + /// , by comparing start times. Returns true when the + /// state has no recorded start time (written by an older version) or the + /// process start time can't be read, preserving PID-only behavior. + /// + internal static bool IsSameProcess(Process process, ProxyInstanceState state) + { + if (state.ProcessStartTime is not { } recorded) + { + return true; + } + + var actual = GetProcessStartTime(process); + if (actual is null) + { + return true; + } + + return (actual.Value - recorded).Duration() <= _startTimeTolerance; + } + + private static DateTimeOffset? GetProcessStartTime(Process process) + { + try + { + return new DateTimeOffset(process.StartTime.ToUniversalTime(), TimeSpan.Zero); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException) + { + return null; + } + } + /// /// Checks if a process with the given PID is running. /// @@ -292,7 +364,7 @@ private static bool IsProcessRunning(int pid) { try { - var process = Process.GetProcessById(pid); + using var process = Process.GetProcessById(pid); return !process.HasExited; } catch (ArgumentException) @@ -323,8 +395,9 @@ private static bool IsProcessRunning(int pid) return null; } - // Verify the process is still running - if (!IsProcessRunning(state.Pid)) + // Verify the process is still running and wasn't replaced by + // an unrelated process that reused the PID + if (!IsInstanceAlive(state)) { // Clean up stale state file DeleteFile(filePath); From b34e78a9359844789c42ec66fc26349d35164444 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:16:47 +0100 Subject: [PATCH 2/3] Address review: refuse unverifiable force-kill, keep live PID tokens - stop --force refuses to terminate a process whose start time can't be read when the state recorded one. - PID-keyed API tokens are only deleted when no process with that PID is running, so a new instance that reused the PID keeps its token. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/StateManagerPidReuseTests.cs | 14 +++++++ DevProxy/Commands/StopCommand.cs | 42 ++++++++++++++++++++- DevProxy/State/ProcessIdentity.cs | 20 ++++++++++ DevProxy/State/StateManager.cs | 39 ++++++++++++++++--- 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 DevProxy/State/ProcessIdentity.cs diff --git a/DevProxy.Tests/StateManagerPidReuseTests.cs b/DevProxy.Tests/StateManagerPidReuseTests.cs index 5b12a052..69647605 100644 --- a/DevProxy.Tests/StateManagerPidReuseTests.cs +++ b/DevProxy.Tests/StateManagerPidReuseTests.cs @@ -102,6 +102,20 @@ public void IsSameProcess_StartTimeMismatch_ReturnsFalse() 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 void GetLogStartTime_UsesRecordedProcessStartTime() { diff --git a/DevProxy/Commands/StopCommand.cs b/DevProxy/Commands/StopCommand.cs index 8b8c48d4..2795a13d 100644 --- a/DevProxy/Commands/StopCommand.cs +++ b/DevProxy/Commands/StopCommand.cs @@ -169,6 +169,12 @@ private static async Task StopInstanceAsync(ProxyInstanceState state, bool private static async Task ForceStopAsync(ProxyInstanceState state, CancellationToken cancellationToken) { + if (GetProcessIdentity(state) == ProcessIdentity.Unverifiable) + { + Console.WriteLine($"Cannot verify that PID {state.Pid} is Dev Proxy; refusing to terminate it."); + return 1; + } + // A killed process can't run its own cleanup, so restore the system proxy // on its behalf before terminating it. if (state.AsSystemProxy) @@ -179,14 +185,33 @@ private static async Task ForceStopAsync(ProxyInstanceState state, Cancella try { using var process = Process.GetProcessById(state.Pid); - // Never kill an unrelated process that reused the PID. - if (!StateManager.IsSameProcess(process, state)) + var identity = StateManager.VerifyProcessIdentity(process, state); + // Never kill an unrelated process that reused the PID, or one + // whose identity can't be verified. + if (identity == ProcessIdentity.Unverifiable) + { + Console.WriteLine($"Cannot verify that PID {state.Pid} is Dev Proxy; refusing to terminate it."); + return 1; + } + + 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})."); } } @@ -207,4 +232,17 @@ private static async Task ForceStopAsync(ProxyInstanceState state, Cancella await StateManager.DeleteStateAsync(state.Pid, 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; + } + } } \ No newline at end of file diff --git a/DevProxy/State/ProcessIdentity.cs b/DevProxy/State/ProcessIdentity.cs new file mode 100644 index 00000000..bd07801c --- /dev/null +++ b/DevProxy/State/ProcessIdentity.cs @@ -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; + +/// +/// Result of comparing a running process with the identity recorded in a state file. +/// +internal enum ProcessIdentity +{ + /// The state has no recorded start time (written by an older version). + NotRecorded, + /// The process start time matches the recorded value. + Match, + /// The process start time differs; the PID was reused. + Mismatch, + /// The process start time couldn't be read. + Unverifiable +} diff --git a/DevProxy/State/StateManager.cs b/DevProxy/State/StateManager.cs index 3638053d..e0407a24 100644 --- a/DevProxy/State/StateManager.cs +++ b/DevProxy/State/StateManager.cs @@ -246,7 +246,14 @@ public static async Task> GetOrphanedSystemProxyStatesA public static async Task DeleteStateAsync(int pid, CancellationToken cancellationToken = default) { DeleteFile(GetInstanceStateFilePath(pid)); - DeleteFile(ApiSecurity.GetTokenFilePath(pid)); + if (pid == Environment.ProcessId) + { + DeleteFile(ApiSecurity.GetTokenFilePath(pid)); + } + else + { + DeleteTokenIfProcessGone(pid); + } // Also clean up legacy state file if it belongs to this PID var legacyPath = GetStateFilePath(); @@ -329,20 +336,29 @@ internal static bool IsInstanceAlive(ProxyInstanceState state) /// state has no recorded start time (written by an older version) or the /// process start time can't be read, preserving PID-only behavior. /// - internal static bool IsSameProcess(Process process, ProxyInstanceState state) + internal static bool IsSameProcess(Process process, ProxyInstanceState state) => + VerifyProcessIdentity(process, state) != ProcessIdentity.Mismatch; + + /// + /// Compares against the identity recorded in + /// . + /// + internal static ProcessIdentity VerifyProcessIdentity(Process process, ProxyInstanceState state) { if (state.ProcessStartTime is not { } recorded) { - return true; + return ProcessIdentity.NotRecorded; } var actual = GetProcessStartTime(process); if (actual is null) { - return true; + return ProcessIdentity.Unverifiable; } - return (actual.Value - recorded).Duration() <= _startTimeTolerance; + return (actual.Value - recorded).Duration() <= _startTimeTolerance + ? ProcessIdentity.Match + : ProcessIdentity.Mismatch; } private static DateTimeOffset? GetProcessStartTime(Process process) @@ -401,7 +417,7 @@ private static bool IsProcessRunning(int pid) { // Clean up stale state file DeleteFile(filePath); - DeleteFile(ApiSecurity.GetTokenFilePath(state.Pid)); + DeleteTokenIfProcessGone(state.Pid); return null; } @@ -438,6 +454,17 @@ private static bool IsProcessRunning(int pid) } } + // Tokens are keyed by PID only. If the PID was reused by a new Dev Proxy + // instance, the token belongs to it, so keep it while the PID is alive. + // Leftover tokens are swept by LoadAllStatesAsync once the PID exits. + private static void DeleteTokenIfProcessGone(int pid) + { + if (!IsProcessRunning(pid)) + { + DeleteFile(ApiSecurity.GetTokenFilePath(pid)); + } + } + private static void DeleteFile(string path) { try From 27c2886749c9b7fcb8c29b8a2e33527d71805142 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:56:16 +0100 Subject: [PATCH 3/3] Address review: guard system proxy and state deletes against PID reuse - stop --force on an instance that's already gone (exited or PID reused) restores the system proxy only when no live instance owns it. - State records are re-read before deletion and removed only if they still describe the same instance (PID + start time). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/StateManagerPidReuseTests.cs | 24 ++++++++++++++ DevProxy/Commands/StopCommand.cs | 35 +++++++++++++++------ DevProxy/Proxy/SystemProxyManager.cs | 2 +- DevProxy/State/StateManager.cs | 30 +++++++++++++++++- 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/DevProxy.Tests/StateManagerPidReuseTests.cs b/DevProxy.Tests/StateManagerPidReuseTests.cs index 69647605..2a413c27 100644 --- a/DevProxy.Tests/StateManagerPidReuseTests.cs +++ b/DevProxy.Tests/StateManagerPidReuseTests.cs @@ -10,6 +10,7 @@ namespace DevProxy.Tests; +[Collection("management-environment")] public class StateManagerPidReuseTests { [Fact] @@ -116,6 +117,29 @@ public void VerifyProcessIdentity_ReportsMatchMismatchAndNotRecorded() 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() { diff --git a/DevProxy/Commands/StopCommand.cs b/DevProxy/Commands/StopCommand.cs index 2795a13d..f2abc6e6 100644 --- a/DevProxy/Commands/StopCommand.cs +++ b/DevProxy/Commands/StopCommand.cs @@ -92,7 +92,7 @@ private static async Task 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; } @@ -144,7 +144,7 @@ private static async Task StopInstanceAsync(ProxyInstanceState state, bool return 1; } - await StateManager.DeleteStateAsync(state.Pid, cancellationToken); + await StateManager.DeleteStateAsync(state, cancellationToken); Console.WriteLine($"Dev Proxy (PID: {state.Pid}) stopped."); return 0; } @@ -169,25 +169,40 @@ private static async Task StopInstanceAsync(ProxyInstanceState state, bool private static async Task ForceStopAsync(ProxyInstanceState state, CancellationToken cancellationToken) { - if (GetProcessIdentity(state) == ProcessIdentity.Unverifiable) + 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; } - // A killed process can't run its own cleanup, so restore the system proxy - // on its behalf before terminating it. + // 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.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.Instance).Disable(); + } + } + + if (!isAlive) + { + Console.WriteLine("Dev Proxy process not found (already stopped)."); + await StateManager.DeleteStateAsync(state, cancellationToken); + return 0; } try { using var process = Process.GetProcessById(state.Pid); - var identity = StateManager.VerifyProcessIdentity(process, state); - // Never kill an unrelated process that reused the PID, or one - // whose identity can't be verified. + // 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."); @@ -229,7 +244,7 @@ private static async Task ForceStopAsync(ProxyInstanceState state, Cancella return 1; } - await StateManager.DeleteStateAsync(state.Pid, cancellationToken); + await StateManager.DeleteStateAsync(state, cancellationToken); return 0; } diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs index 3571ac42..89e37f0b 100644 --- a/DevProxy/Proxy/SystemProxyManager.cs +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -211,7 +211,7 @@ public static async Task ReconcileOrphanedSystemProxiesAsy foreach (var orphan in orphans) { - await StateManager.DeleteStateAsync(orphan.Pid, cancellationToken); + await StateManager.DeleteStateAsync(orphan, cancellationToken); } return new(orphans, disabled); diff --git a/DevProxy/State/StateManager.cs b/DevProxy/State/StateManager.cs index e0407a24..f9776c16 100644 --- a/DevProxy/State/StateManager.cs +++ b/DevProxy/State/StateManager.cs @@ -279,6 +279,18 @@ public static async Task DeleteStateAsync(int pid, CancellationToken cancellatio } } + /// + /// Deletes the state record of the given instance. The on-disk record is + /// re-read and only removed if it still describes the same instance, so a + /// newer instance that reused the PID and published its own state keeps it. + /// + public static async Task DeleteStateAsync(ProxyInstanceState state, CancellationToken cancellationToken = default) + { + await DeleteStateFileIfMatchesAsync(GetInstanceStateFilePath(state.Pid), state, cancellationToken); + await DeleteStateFileIfMatchesAsync(GetStateFilePath(), state, cancellationToken); + DeleteTokenIfProcessGone(state.Pid); + } + /// /// Deletes the state file for the current process. /// @@ -416,7 +428,7 @@ private static bool IsProcessRunning(int pid) if (!IsInstanceAlive(state)) { // Clean up stale state file - DeleteFile(filePath); + await DeleteStateFileIfMatchesAsync(filePath, state, cancellationToken); DeleteTokenIfProcessGone(state.Pid); return null; } @@ -454,6 +466,22 @@ private static bool IsProcessRunning(int pid) } } + // Re-reads the file right before deleting to avoid removing a record that + // a newer instance reusing the PID has written since it was loaded. + private static async Task DeleteStateFileIfMatchesAsync( + string filePath, + ProxyInstanceState expected, + CancellationToken cancellationToken) + { + var current = await ReadStateFromFileAsync(filePath, cancellationToken); + if (current is not null && + current.Pid == expected.Pid && + current.ProcessStartTime == expected.ProcessStartTime) + { + DeleteFile(filePath); + } + } + // Tokens are keyed by PID only. If the PID was reused by a new Dev Proxy // instance, the token belongs to it, so keep it while the PID is alive. // Leftover tokens are swept by LoadAllStatesAsync once the PID exits.