From fef4af7296f74a8d39135d95585d9a56478dc06d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Oct 2025 04:12:36 +0000 Subject: [PATCH 1/2] Initial plan From 9a1960a0e3e80af6c73e1adb0efaaed2092bee45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Oct 2025 04:29:17 +0000 Subject: [PATCH 2/2] Implement backchannel reconnection in watch mode - Add IsWatchMode property to DotNetCliRunnerInvocationOptions - Track watch mode state in RunAsync and pass to ExecuteAsync - Refactor StartBackchannelAsync to support reconnection based on watch mode - Add HandleBackchannelDisconnectInWatchModeAsync for reconnection logic - In watch mode: attempt reconnection up to 30 times over 30 seconds - In non-watch mode: maintain existing behavior (exit on disconnect) - Add logging for reconnect attempts, success, and failure - Add tests to verify IsWatchMode property is set correctly Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 100 ++++++++++++++-- .../Commands/RunCommandTests.cs | 108 ++++++++++++++++++ 2 files changed, 201 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index d267234b9a4..9b126d92f8e 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -21,6 +21,7 @@ using Microsoft.Extensions.Logging; using NuGetPackage = Aspire.Shared.NuGetPackageCli; using System.Security.Cryptography; +using StreamJsonRpc; namespace Aspire.Cli.DotNet; @@ -51,6 +52,7 @@ internal sealed class DotNetCliRunnerInvocationOptions public bool StartDebugSession { get; set; } public bool NoExtensionLaunch { get; set; } public bool Debug { get; set; } + public bool IsWatchMode { get; set; } } internal class DotNetCliRunner(ILogger logger, IServiceProvider serviceProvider, AspireCliTelemetry telemetry, IConfiguration configuration, IFeatures features, IInteractionService interactionService, CliExecutionContext executionContext, IDiskCache diskCache) : IDotNetCliRunner @@ -296,6 +298,9 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, } } + // Store watch mode state for backchannel reconnection + options.IsWatchMode = watch; + return await ExecuteAsync( args: cliArgs, env: finalEnv, @@ -557,7 +562,7 @@ await extensionInteractionService.LaunchAppHostAsync( startInfo.Environment.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(), options.StartDebugSession); - _ = StartBackchannelAsync(null, socketPath, backchannelCompletionSource, cancellationToken); + _ = StartBackchannelAsync(null, socketPath, backchannelCompletionSource, options, cancellationToken); return ExitCodeConstants.Success; } @@ -571,7 +576,7 @@ await extensionInteractionService.LaunchAppHostAsync( if (backchannelCompletionSource is not null) { - _ = StartBackchannelAsync(process, socketPath, backchannelCompletionSource, cancellationToken); + _ = StartBackchannelAsync(process, socketPath, backchannelCompletionSource, options, cancellationToken); } var pendingStdoutStreamForwarder = Task.Run(async () => { @@ -643,7 +648,7 @@ async Task ForwardStreamToLoggerAsync(StreamReader reader, string identifier, Pr } } - private async Task StartBackchannelAsync(Process? process, string socketPath, TaskCompletionSource backchannelCompletionSource, CancellationToken cancellationToken) + private async Task StartBackchannelAsync(Process? process, string socketPath, TaskCompletionSource backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.ActivitySource.StartActivity(); @@ -663,11 +668,28 @@ private async Task StartBackchannelAsync(Process? process, string socketPath, Ta logger.LogTrace("Attempting to connect to AppHost backchannel at {SocketPath} (attempt {Attempt})", socketPath, connectionAttempts++); await backchannel.ConnectAsync(socketPath, cancellationToken).ConfigureAwait(false); backchannelCompletionSource.SetResult(backchannel); - backchannel.AddDisconnectHandler((_, _) => + + // Set up disconnect handler based on watch mode + if (options.IsWatchMode) { - // If the backchannel disconnects, we want to stop the CLI process - Environment.Exit(ExitCodeConstants.Success); - }); + backchannel.AddDisconnectHandler((_, args) => + { + // Fire and forget the reconnection attempt + _ = Task.Run(async () => + { + await HandleBackchannelDisconnectInWatchModeAsync(process, socketPath, args, cancellationToken); + }); + }); + } + else + { + backchannel.AddDisconnectHandler((_, _) => + { + // In non-watch mode, exit immediately on disconnect + logger.LogInformation("Backchannel disconnected. Exiting CLI."); + Environment.Exit(ExitCodeConstants.Success); + }); + } logger.LogDebug("Connected to AppHost backchannel at {SocketPath}", socketPath); return; @@ -723,6 +745,70 @@ private async Task StartBackchannelAsync(Process? process, string socketPath, Ta } while (await timer.WaitForNextTickAsync(cancellationToken)); } + private async Task HandleBackchannelDisconnectInWatchModeAsync(Process? process, string socketPath, JsonRpcDisconnectedEventArgs args, CancellationToken cancellationToken) + { + logger.LogInformation("Backchannel disconnected in watch mode. Reason: {Reason}. Attempting to reconnect...", args.Reason); + + // Check if the process is still running or if watch restarted it + if (process is not null && process.HasExited && process.ExitCode != 0) + { + logger.LogWarning("AppHost process exited with non-zero exit code {ExitCode}. Cannot reconnect.", process.ExitCode); + Environment.Exit(ExitCodeConstants.FailedToDotnetRunAppHost); + return; + } + + // Attempt to reconnect with retries + const int maxReconnectAttempts = 30; // 30 attempts over ~30 seconds + const int retryDelayMs = 1000; + + for (int attempt = 1; attempt <= maxReconnectAttempts; attempt++) + { + try + { + logger.LogDebug("Reconnection attempt {Attempt} of {MaxAttempts}", attempt, maxReconnectAttempts); + + // Wait before attempting reconnection to give the apphost time to restart + await Task.Delay(retryDelayMs, cancellationToken); + + // Try to reconnect using a new backchannel instance + var newBackchannel = serviceProvider.GetRequiredService(); + await newBackchannel.ConnectAsync(socketPath, cancellationToken); + + logger.LogInformation("Successfully reconnected to AppHost backchannel after {Attempts} attempts.", attempt); + + // Set up the disconnect handler again for future disconnects + newBackchannel.AddDisconnectHandler((_, disconnectArgs) => + { + _ = Task.Run(async () => + { + await HandleBackchannelDisconnectInWatchModeAsync(process, socketPath, disconnectArgs, cancellationToken); + }); + }); + + return; + } + catch (SocketException ex) when (attempt < maxReconnectAttempts) + { + logger.LogTrace(ex, "Reconnection attempt {Attempt} failed. Will retry.", attempt); + // Continue to next attempt + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation("Reconnection cancelled by user."); + Environment.Exit(ExitCodeConstants.Success); + return; + } + catch (Exception ex) when (attempt < maxReconnectAttempts) + { + logger.LogWarning(ex, "Unexpected error during reconnection attempt {Attempt}. Will retry.", attempt); + // Continue to next attempt + } + } + + logger.LogError("Failed to reconnect to AppHost backchannel after {MaxAttempts} attempts. Exiting.", maxReconnectAttempts); + Environment.Exit(ExitCodeConstants.FailedToDotnetRunAppHost); + } + public async Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.ActivitySource.StartActivity(); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index e661e06fe50..f789be8da1c 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -1155,6 +1155,114 @@ public Task> FindExecutableProjectsAsync(string searchDi return Task.FromResult>(new List { new("/tmp/apphost.cs") }); } } + + [Fact] + public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_SetsIsWatchModeOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, ""); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = new CliExecutionContext( + workingDirectory: workspace.WorkspaceRoot, + hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), + cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache") + ); + + var optionsCaptured = false; + var capturedIsWatchMode = false; + + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + executionContext, + new NullDiskCache(), + (args, env, workingDirectory, projectFile, backchannelCompletionSource, capturedOptions) => + { + optionsCaptured = true; + capturedIsWatchMode = capturedOptions.IsWatchMode; + }, + 0 + ); + + await runner.RunAsync( + projectFile: projectFile, + watch: true, + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.True(optionsCaptured, "Options should have been passed to ExecuteAsync"); + Assert.True(capturedIsWatchMode, "IsWatchMode should be true when watch is enabled"); + } + + [Fact] + public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotSetIsWatchModeOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, ""); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = new CliExecutionContext( + workingDirectory: workspace.WorkspaceRoot, + hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), + cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache") + ); + + var optionsCaptured = false; + var capturedIsWatchMode = false; + + var runner = new AssertingDotNetCliRunner( + logger, + provider, + new AspireCliTelemetry(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + executionContext, + new NullDiskCache(), + (args, env, workingDirectory, projectFile, backchannelCompletionSource, capturedOptions) => + { + optionsCaptured = true; + capturedIsWatchMode = capturedOptions.IsWatchMode; + }, + 0 + ); + + await runner.RunAsync( + projectFile: projectFile, + watch: false, + noBuild: false, + args: [], + env: new Dictionary(), + null, + options, + CancellationToken.None + ); + + Assert.True(optionsCaptured, "Options should have been passed to ExecuteAsync"); + Assert.False(capturedIsWatchMode, "IsWatchMode should be false when watch is disabled"); + } } internal sealed class AssertingDotNetCliRunner(