Skip to content
Draft
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
100 changes: 93 additions & 7 deletions src/Aspire.Cli/DotNet/DotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using Microsoft.Extensions.Logging;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
using System.Security.Cryptography;
using StreamJsonRpc;

namespace Aspire.Cli.DotNet;

Expand Down Expand Up @@ -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<DotNetCliRunner> logger, IServiceProvider serviceProvider, AspireCliTelemetry telemetry, IConfiguration configuration, IFeatures features, IInteractionService interactionService, CliExecutionContext executionContext, IDiskCache diskCache) : IDotNetCliRunner
Expand Down Expand Up @@ -296,6 +298,9 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
}
}

// Store watch mode state for backchannel reconnection
options.IsWatchMode = watch;

return await ExecuteAsync(
args: cliArgs,
env: finalEnv,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 () => {
Expand Down Expand Up @@ -643,7 +648,7 @@ async Task ForwardStreamToLoggerAsync(StreamReader reader, string identifier, Pr
}
}

private async Task StartBackchannelAsync(Process? process, string socketPath, TaskCompletionSource<IAppHostBackchannel> backchannelCompletionSource, CancellationToken cancellationToken)
private async Task StartBackchannelAsync(Process? process, string socketPath, TaskCompletionSource<IAppHostBackchannel> backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();

Expand All @@ -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;
Expand Down Expand Up @@ -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<IAppHostBackchannel>();
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<int> BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity();
Expand Down
108 changes: 108 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,114 @@ public Task<IReadOnlyList<FileInfo>> FindExecutableProjectsAsync(string searchDi
return Task.FromResult<IReadOnlyList<FileInfo>>(new List<FileInfo> { 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, "<Project></Project>");

var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();

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<IConfiguration>(),
provider.GetRequiredService<IFeatures>(),
provider.GetRequiredService<IInteractionService>(),
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<string, string>(),
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, "<Project></Project>");

var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();

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<IConfiguration>(),
provider.GetRequiredService<IFeatures>(),
provider.GetRequiredService<IInteractionService>(),
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<string, string>(),
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(
Expand Down
Loading