From 8fc5a72007f1cb7df54a32c69bb4f29a6966dc50 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 17 Jun 2026 13:55:12 +1000 Subject: [PATCH 1/5] Clean up socket file after stopping running AppHost instance Fixes issue where aspire stop leaves socket files behind, causing subsequent aspire add/describe commands to fail with connection timeouts. Resolves #17587: aspire add fails after aspire run --detach and aspire stop The socket file was not being deleted after successfully stopping an AppHost instance. Subsequent commands would attempt to connect to the stale socket path, resulting in timeout errors. Now we explicitly delete the socket file after the instance has been stopped and monitored for process termination. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/RunningInstanceManager.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Aspire.Cli/Projects/RunningInstanceManager.cs b/src/Aspire.Cli/Projects/RunningInstanceManager.cs index 4b5e8b4b9bb..e43ac210f8e 100644 --- a/src/Aspire.Cli/Projects/RunningInstanceManager.cs +++ b/src/Aspire.Cli/Projects/RunningInstanceManager.cs @@ -65,6 +65,20 @@ public async Task StopRunningInstanceAsync(string socketPath, Cancellation if (stopped) { _interactionService.DisplaySuccess(RunCommandStrings.RunningInstanceStopped); + // Clean up the socket file now that the instance has been stopped. + // Leaving it behind would cause subsequent operations to fail when trying to connect to a dead process. + try + { + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + _logger.LogDebug("Cleaned up socket file after stopping instance: {SocketPath}", socketPath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogDebug(ex, "Failed to clean up socket file after stopping instance (this may be safe to ignore)"); + } } else { From 33ff9427230485a47c8e96aadfff4ccbd777f8b7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 18 Jun 2026 12:13:19 +1000 Subject: [PATCH 2/5] Add regression test for socket cleanup after stop Adds a deterministic, cross-platform unit test for the #17587 fix in RunningInstanceManager.StopRunningInstanceAsync. The test stands up a real Unix-domain-socket auxiliary backchannel server whose AppHost process has already exited, drives a successful stop, and asserts the socket file is deleted afterward. Without the fix the file lingers and the test fails. An E2E test cannot guard this on Linux: the AppHost self-deletes its socket on graceful shutdown and the CLI prunes dead-PID sockets, so both paths self-heal. The regression only surfaces on Windows via PID reuse, hence the unit-level guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/RunningInstanceManagerTests.cs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs diff --git a/tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs b/tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs new file mode 100644 index 00000000000..1259b97448d --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Net.Sockets; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Projects; +using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; + +namespace Aspire.Cli.Tests.Projects; + +public class RunningInstanceManagerTests +{ + [Fact] + public async Task StopRunningInstanceAsync_DeletesSocketFile_WhenStopSucceeds() + { + // Regression test for https://github.com/microsoft/aspire/issues/17587. + // + // After a successful stop, the CLI must delete the auxiliary backchannel socket file. If the + // file is left behind, a later command (e.g. 'aspire add' or 'aspire stop') rediscovers it via + // FindMatchingNonOrphanedSockets and tries to connect to a now-defunct process, failing with + // "Unable to stop one or more running Aspire AppHost instances". This is most visible on Windows + // where the dead AppHost's PID can be reused (so the orphan-pruning heuristic believes the + // process is still alive), which is why a unit test is the deterministic, cross-platform guard. + + // The AppHost process reported by the fake backchannel must already be gone so that + // MonitorProcessesForTerminationAsync observes termination immediately and StopRunningInstanceAsync + // reaches the socket-cleanup branch. + var exitedProcessId = StartAndWaitForProcessToExit(); + + var socketDirectory = Directory.CreateTempSubdirectory("aspire-rim-"); + try + { + // Keep the socket path short: a UnixDomainSocketEndPoint is limited to ~104 bytes on macOS. + var socketPath = Path.Combine(socketDirectory.FullName, "a.sock"); + using var server = TestAuxiliaryBackchannelUdsServer.Start(socketPath, exitedProcessId); + + // Binding the server creates the socket file on disk. + Assert.True(File.Exists(socketPath)); + + var manager = new RunningInstanceManager(NullLogger.Instance, new TestInteractionService(), TimeProvider.System); + + var stopped = await manager.StopRunningInstanceAsync(socketPath, CancellationToken.None).DefaultTimeout(); + + Assert.True(stopped); + Assert.True(server.Target.StopRequested); + // The fix: the socket file must be removed once the instance has been stopped. + Assert.False(File.Exists(socketPath)); + } + finally + { + socketDirectory.Delete(recursive: true); + } + } + + private static int StartAndWaitForProcessToExit() + { + var startInfo = OperatingSystem.IsWindows() + ? new ProcessStartInfo("cmd.exe", "/c exit") + : new ProcessStartInfo("/bin/sh", "-c \"exit 0\""); + startInfo.CreateNoWindow = true; + startInfo.UseShellExecute = false; + + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.Id; + } + + private sealed class TestAuxiliaryBackchannelUdsServer : IDisposable + { + private readonly Socket _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly List _disposables = []; + + private TestAuxiliaryBackchannelUdsServer(string socketPath, int appHostProcessId) + { + Target = new StoppableAppHostRpcTarget(appHostProcessId); + _listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + _listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + _listener.Listen(1); + } + + public StoppableAppHostRpcTarget Target { get; } + + public static TestAuxiliaryBackchannelUdsServer Start(string socketPath, int appHostProcessId) + { + var server = new TestAuxiliaryBackchannelUdsServer(socketPath, appHostProcessId); + _ = server.AcceptConnectionAsync(); + return server; + } + + private async Task AcceptConnectionAsync() + { + try + { + var serverSocket = await _listener.AcceptAsync(_cts.Token).ConfigureAwait(false); + var serverStream = new NetworkStream(serverSocket, ownsSocket: true); + var messageHandler = new HeaderDelimitedMessageHandler(serverStream, serverStream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()); + var rpc = new JsonRpc(messageHandler, Target); + rpc.StartListening(); + _disposables.Add(rpc); + _disposables.Add(messageHandler); + _disposables.Add(serverStream); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Expected when the server is disposed before/while a connection is accepted. + } + } + + public void Dispose() + { + _cts.Cancel(); + foreach (var disposable in _disposables) + { + disposable.Dispose(); + } + + _listener.Dispose(); + _cts.Dispose(); + } + } + + private sealed class StoppableAppHostRpcTarget + { + private readonly int _processId; + private readonly string[] _capabilities = + [ + AuxiliaryBackchannelCapabilities.V1, + AuxiliaryBackchannelCapabilities.V2 + ]; + + public StoppableAppHostRpcTarget(int processId) + { + _processId = processId; + } + + public bool StopRequested { get; private set; } + + public Task GetAppHostInformationAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + + return Task.FromResult(new AppHostInformation + { + AppHostPath = "/path/to/AppHost.csproj", + ProcessId = _processId + }); + } + + public Task GetCapabilitiesAsync(GetCapabilitiesRequest? request = null, CancellationToken cancellationToken = default) + { + _ = request; + _ = cancellationToken; + + return Task.FromResult(new GetCapabilitiesResponse + { + Capabilities = _capabilities + }); + } + + public Task StopAppHostAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + StopRequested = true; + return Task.CompletedTask; + } + } +} From 90bf7bc966bbe7613a4eb8ecf8b44027338b9f70 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 23 Jun 2026 10:50:28 +1000 Subject: [PATCH 3/5] Clean up socket file in aspire stop and share cleanup helper Move the leaked-socket cleanup to the aspire stop command itself (the command that leaks the socket), deleting by exact path once the AppHost process has been confirmed stopped. Consolidate the best-effort delete into AppHostHelper.TryDeleteSocketFile so the stop command and RunningInstanceManager share one code path. The existing orphan-pruning logic remains as the backstop for hard crashes where aspire stop never runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/StopCommand.cs | 6 +++ .../Projects/RunningInstanceManager.cs | 18 ++------- src/Aspire.Cli/Utils/AppHostHelper.cs | 31 +++++++++++++++ .../Commands/StopCommandTests.cs | 38 +++++++++++++++++++ 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 3e4fca9766f..0931d6f2cca 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -209,6 +209,12 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection if (stopped) { + // ProcessShutdownService only reports success once it has confirmed the AppHost process has + // terminated, so the socket's owner is gone and the file is safe to remove by exact path. Doing + // it here is the primary guard against a stale socket tripping up later commands: the AppHost's own + // cleanup is skipped if it crashes hard, and the orphan-pruning backstop misfires on Windows when the + // dead PID is reused (https://github.com/microsoft/aspire/issues/17587). + AppHostHelper.TryDeleteSocketFile(connection.SocketPath, _logger); InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostStoppedSuccessfully, appHostIdentifier)); return CompleteStopActivity(activity, CliExitCodes.Success); } diff --git a/src/Aspire.Cli/Projects/RunningInstanceManager.cs b/src/Aspire.Cli/Projects/RunningInstanceManager.cs index e43ac210f8e..742fcd85a9e 100644 --- a/src/Aspire.Cli/Projects/RunningInstanceManager.cs +++ b/src/Aspire.Cli/Projects/RunningInstanceManager.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; @@ -65,20 +66,9 @@ public async Task StopRunningInstanceAsync(string socketPath, Cancellation if (stopped) { _interactionService.DisplaySuccess(RunCommandStrings.RunningInstanceStopped); - // Clean up the socket file now that the instance has been stopped. - // Leaving it behind would cause subsequent operations to fail when trying to connect to a dead process. - try - { - if (File.Exists(socketPath)) - { - File.Delete(socketPath); - _logger.LogDebug("Cleaned up socket file after stopping instance: {SocketPath}", socketPath); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - _logger.LogDebug(ex, "Failed to clean up socket file after stopping instance (this may be safe to ignore)"); - } + // Clean up the socket file now that the instance has been stopped so a later command does not + // rediscover it and try to connect to a dead process (https://github.com/microsoft/aspire/issues/17587). + AppHostHelper.TryDeleteSocketFile(socketPath, _logger); } else { diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index 5d51459c7cc..33af995ad0f 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -149,6 +149,37 @@ internal static string[] FindMatchingNonOrphanedSockets( return remainingSockets; } + /// + /// Best-effort deletion of an auxiliary backchannel socket file after its owning AppHost instance has been confirmed stopped. + /// + /// + /// Leaving the socket behind causes a later command (for example aspire add or aspire stop) to rediscover it + /// via and attempt to connect to a now-dead process. This is most visible on + /// Windows, where the dead AppHost's PID can be reused so the orphan-pruning heuristic in + /// still believes the process is alive. Deleting by exact path at stop time sidesteps that PID heuristic entirely. The + /// caller must only invoke this once it knows the owning process has terminated. See + /// https://github.com/microsoft/aspire/issues/17587. + /// + /// The path to the auxiliary backchannel socket file. + /// Logger used for diagnostic output. + internal static void TryDeleteSocketFile(string socketPath, ILogger logger) + { + try + { + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + logger.LogDebug("Cleaned up socket file after stopping instance: {SocketPath}", socketPath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A failed delete is non-fatal: the existing orphan-pruning backstop will still attempt cleanup on a + // later command. We swallow the same exception types as the other socket-cleanup sites for consistency. + logger.LogDebug(ex, "Failed to clean up socket file after stopping instance (this may be safe to ignore): {SocketPath}", socketPath); + } + } + /// /// Deletes PID-qualified socket files whose owning process has exited and returns sockets that should still be probed. /// diff --git a/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs index 5931b9254d4..1a14c79ab43 100644 --- a/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs @@ -317,6 +317,44 @@ public async Task StopCommand_NoRunningAppHosts_ReturnsSuccess(string commandLin Assert.Equal(SharedCommandStrings.AppHostNotRunning, displayedMessage.Message); } + [Fact] + public async Task StopCommand_DeletesSocketFile_AfterSuccessfulStop() + { + // Regression test for https://github.com/microsoft/aspire/issues/17587: 'aspire stop' is the command + // that leaks the socket, so it must delete the socket file once the AppHost has been confirmed stopped. + // Otherwise a later command rediscovers the stale socket and tries to connect to a dead process. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + + // The AppHost is reported with a process id that does not exist, so ProcessShutdownService observes + // termination immediately and the stop reaches the socket-cleanup branch. + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"); + var socketPath = Path.Combine(workspace.WorkspaceRoot.FullName, "a.sock"); + File.WriteAllText(socketPath, ""); + Assert.True(File.Exists(socketPath)); + + var connection = CreateConnection(appHostPath, int.MaxValue - 9); + connection.SocketPath = socketPath; + + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash1", socketPath, connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("stop"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(File.Exists(socketPath)); + } + private static TestAppHostAuxiliaryBackchannel CreateConnection(string appHostPath, int processId, bool isInScope = true) { return new TestAppHostAuxiliaryBackchannel From e97f22bf9cd0c0267f030d10cb379a2fa99fc7bf Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 23 Jun 2026 13:58:57 +1000 Subject: [PATCH 4/5] Route orphan-pruner through shared TryDeleteSocketFile helper Consolidate the remaining CLI-side socket delete (PruneOrphanedSockets) onto AppHostHelper.TryDeleteSocketFile so all CLI socket cleanup flows through one path. The helper now returns a bool so the pruner keeps an accurate deleted count, and the previously silent catch in the pruner now logs at Debug like the other cleanup sites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Utils/AppHostHelper.cs | 41 +++++++++++++++------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index 47e8c48f205..08250b7df5a 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -144,7 +144,7 @@ internal static string[] FindMatchingNonOrphanedSockets( // off the physical path (for example "/private/tmp/..." on macOS). var resolvedPath = PathNormalizer.ResolveSymlinks(appHostPath); var matchingSockets = BackchannelConstants.FindMatchingSockets(resolvedPath, homeDirectory); - var remainingSockets = PruneOrphanedSockets(matchingSockets, currentPid, out var deletedCount); + var remainingSockets = PruneOrphanedSockets(matchingSockets, currentPid, logger, out var deletedCount); if (deletedCount > 0) { logger.LogDebug("Cleaned up {Count} orphaned AppHost socket(s).", deletedCount); @@ -154,40 +154,49 @@ internal static string[] FindMatchingNonOrphanedSockets( } /// - /// Best-effort deletion of an auxiliary backchannel socket file after its owning AppHost instance has been confirmed stopped. + /// Best-effort deletion of an auxiliary backchannel socket file whose owning AppHost instance is no longer running. /// /// - /// Leaving the socket behind causes a later command (for example aspire add or aspire stop) to rediscover it - /// via and attempt to connect to a now-dead process. This is most visible on - /// Windows, where the dead AppHost's PID can be reused so the orphan-pruning heuristic in - /// still believes the process is alive. Deleting by exact path at stop time sidesteps that PID heuristic entirely. The - /// caller must only invoke this once it knows the owning process has terminated. See + /// This is the single CLI-side socket-cleanup path. It is used both at stop time (aspire stop and + /// , once the process is confirmed terminated) and by the orphan-pruning + /// backstop in (once the owning PID is observed to be dead). Leaving the socket + /// behind causes a later command (for example aspire add or aspire stop) to rediscover it via + /// and attempt to connect to a now-dead process. This is most visible on + /// Windows, where the dead AppHost's PID can be reused so the orphan-pruning heuristic still believes the process is + /// alive. Deleting by exact path at stop time sidesteps that PID heuristic entirely. The caller must only invoke this + /// once it knows the owning process has terminated. The AppHost-side socket cleanup in Aspire.Hosting + /// (BackchannelService/AuxiliaryBackchannelService) deliberately does not share this method: it lives in a + /// different assembly and deletes a socket the AppHost itself owns, so it is not exposed to the PID-reuse problem. See /// https://github.com/microsoft/aspire/issues/17587. /// /// The path to the auxiliary backchannel socket file. /// Logger used for diagnostic output. - internal static void TryDeleteSocketFile(string socketPath, ILogger logger) + /// if the socket file was deleted; otherwise . + internal static bool TryDeleteSocketFile(string socketPath, ILogger logger) { try { if (File.Exists(socketPath)) { File.Delete(socketPath); - logger.LogDebug("Cleaned up socket file after stopping instance: {SocketPath}", socketPath); + logger.LogDebug("Cleaned up backchannel socket file: {SocketPath}", socketPath); + return true; } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // A failed delete is non-fatal: the existing orphan-pruning backstop will still attempt cleanup on a - // later command. We swallow the same exception types as the other socket-cleanup sites for consistency. - logger.LogDebug(ex, "Failed to clean up socket file after stopping instance (this may be safe to ignore): {SocketPath}", socketPath); + // A failed delete is non-fatal: the next command's orphan-pruning pass will attempt cleanup again. We swallow + // the same exception types as the other socket-cleanup sites for consistency. + logger.LogDebug(ex, "Failed to clean up backchannel socket file (this may be safe to ignore): {SocketPath}", socketPath); } + + return false; } /// /// Deletes PID-qualified socket files whose owning process has exited and returns sockets that should still be probed. /// - private static string[] PruneOrphanedSockets(string[] socketPaths, int currentPid, out int deletedCount) + private static string[] PruneOrphanedSockets(string[] socketPaths, int currentPid, ILogger logger, out int deletedCount) { deletedCount = 0; var remainingSockets = new List(socketPaths.Length); @@ -204,14 +213,10 @@ private static string[] PruneOrphanedSockets(string[] socketPaths, int currentPi // auxi.sock.{hash}.{instanceHash}.{pid} // After a crash or reboot these files can remain, and connecting to them // reports "connection refused" even though there is no AppHost to stop. - try + if (TryDeleteSocketFile(socketPath, logger)) { - File.Delete(socketPath); deletedCount++; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - } continue; } From d9ca84ad51142e3218132ab54f612f3ea57aeb09 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 23 Jun 2026 14:02:20 +1000 Subject: [PATCH 5/5] Use fabricated PID in RunningInstanceManager socket-cleanup test Replace the real-process-then-reuse-its-exited-PID approach with a guaranteed-nonexistent PID (int.MaxValue - 9), matching the sibling StopCommandTests. Reusing a real exited process's PID is flaky: the OS can recycle that PID for an unrelated live process before the monitor checks, causing it to loop the full timeout and fail the cleanup assertion. This also removes the StartAndWaitForProcessToExit helper and the System.Diagnostics dependency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/RunningInstanceManagerTests.cs | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs b/tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs index f2e12e313c4..f948b5db2de 100644 --- a/tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/RunningInstanceManagerTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; using System.Net.Sockets; using Aspire.Cli.Backchannel; using Aspire.Cli.Projects; @@ -28,10 +27,13 @@ public async Task StopRunningInstanceAsync_DeletesSocketFile_WhenStopSucceeds() // where the dead AppHost's PID can be reused (so the orphan-pruning heuristic believes the // process is still alive), which is why a unit test is the deterministic, cross-platform guard. - // The AppHost process reported by the fake backchannel must already be gone so that - // MonitorProcessesForTerminationAsync observes termination immediately and StopRunningInstanceAsync - // reaches the socket-cleanup branch. - var exitedProcessId = StartAndWaitForProcessToExit(); + // The AppHost process reported by the fake backchannel must be one MonitorProcessesForTerminationAsync + // observes as already terminated so StopRunningInstanceAsync reaches the socket-cleanup branch. A fabricated, + // guaranteed-nonexistent PID makes Process.GetProcessById throw ArgumentException, which the monitor treats as + // "terminated". We deliberately do NOT start a real process and reuse its exited PID: the OS can recycle that + // exact PID for an unrelated live process before the monitor checks, which would make the monitor loop for the + // full timeout, return stopped == false, and flake the cleanup assertion. + const int exitedProcessId = int.MaxValue - 9; var socketDirectory = Directory.CreateTempSubdirectory("aspire-rim-"); try @@ -58,19 +60,6 @@ public async Task StopRunningInstanceAsync_DeletesSocketFile_WhenStopSucceeds() } } - private static int StartAndWaitForProcessToExit() - { - var startInfo = OperatingSystem.IsWindows() - ? new ProcessStartInfo("cmd.exe", "/c exit") - : new ProcessStartInfo("/bin/sh", "-c \"exit 0\""); - startInfo.CreateNoWindow = true; - startInfo.UseShellExecute = false; - - using var process = Process.Start(startInfo)!; - process.WaitForExit(); - return process.Id; - } - private sealed class TestAuxiliaryBackchannelUdsServer : IDisposable { private readonly Socket _listener;