From 6f1aeef533da2c357a728d380bbf1aeed18bc536 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 1 May 2026 16:51:59 -0700 Subject: [PATCH 1/6] Add startup timeout options to CLI Reuse the wait timeout option for start and run so slow AppHost builds or startups can wait longer than the default and show actionable timeout guidance. Cover start, run, detached startup, invalid timeout validation, and canceled process cleanup with CLI tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AppHostLauncher.cs | 32 ++++-- src/Aspire.Cli/Commands/RunCommand.cs | 64 +++++++++-- src/Aspire.Cli/Commands/StartCommand.cs | 9 +- src/Aspire.Cli/Commands/WaitCommand.cs | 23 +++- src/Aspire.Cli/DotNet/ProcessExecution.cs | 15 ++- .../Processes/IDetachedProcessLauncher.cs | 84 ++++++++++++++ src/Aspire.Cli/Program.cs | 2 + .../Resources/RunCommandStrings.resx | 2 +- .../Resources/xlf/RunCommandStrings.cs.xlf | 4 +- .../Resources/xlf/RunCommandStrings.de.xlf | 4 +- .../Resources/xlf/RunCommandStrings.es.xlf | 4 +- .../Resources/xlf/RunCommandStrings.fr.xlf | 4 +- .../Resources/xlf/RunCommandStrings.it.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ja.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ko.xlf | 4 +- .../Resources/xlf/RunCommandStrings.pl.xlf | 4 +- .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ru.xlf | 4 +- .../Resources/xlf/RunCommandStrings.tr.xlf | 4 +- .../xlf/RunCommandStrings.zh-Hans.xlf | 4 +- .../xlf/RunCommandStrings.zh-Hant.xlf | 4 +- .../Commands/RunCommandTests.cs | 108 ++++++++++++++++++ .../Commands/StartCommandTests.cs | 79 +++++++++++++ .../DotNet/ProcessExecutionTests.cs | 57 +++++++++ .../TestDetachedProcessLauncher.cs | 57 +++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 + 26 files changed, 530 insertions(+), 56 deletions(-) create mode 100644 src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index f24dc2dde09..c0c575ea136 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Diagnostics; using System.Globalization; using System.Text.Json; using Aspire.Cli.Backchannel; @@ -27,6 +26,7 @@ internal sealed class AppHostLauncher( CliExecutionContext executionContext, IInteractionService interactionService, IAuxiliaryBackchannelMonitor backchannelMonitor, + IDetachedProcessLauncher detachedProcessLauncher, ICliHostEnvironment hostEnvironment, AspireCliTelemetry telemetry, ILogger logger, @@ -54,15 +54,21 @@ internal sealed class AppHostLauncher( Description = SharedCommandStrings.IsolatedOptionDescription }; + internal static readonly Option s_timeoutOption = WaitCommand.CreateTimeoutOption(); + /// - /// Adds the detached launch options to a command so they appear in --help. - /// Called by both RunCommand and StartCommand to keep options in sync. + /// Adds the shared AppHost launch options to a command so they appear in --help. + /// Called by both RunCommand and StartCommand to keep shared options in sync. /// - internal static void AddLaunchOptions(Command command) + internal static void AddLaunchOptions(Command command, bool includeTimeout = false) { command.Options.Add(s_appHostOption); command.Options.Add(s_formatOption); command.Options.Add(s_isolatedOption); + if (includeTimeout) + { + command.Options.Add(s_timeoutOption); + } } /// @@ -73,6 +79,7 @@ internal static void AddLaunchOptions(Command command) /// Whether to run in isolated mode. /// Whether running inside VS Code extension. /// Whether the AppHost is waiting for a debugger to attach. + /// The maximum number of seconds to wait for the AppHost backchannel. /// Global CLI args to forward to child process. /// Additional unmatched args to forward. /// Cancellation token. @@ -83,6 +90,7 @@ public async Task LaunchDetachedAsync( bool isolated, bool isExtensionHost, bool waitForDebugger, + int timeoutSeconds, IEnumerable globalArgs, IEnumerable additionalArgs, CancellationToken cancellationToken) @@ -149,12 +157,12 @@ public async Task LaunchDetachedAsync( // Start the child process and wait for the backchannel var launchResult = await interactionService.ShowStatusAsync( RunCommandStrings.StartingAppHostInBackground, - () => LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, legacyHash, cancellationToken)); + () => LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, legacyHash, TimeSpan.FromSeconds(timeoutSeconds), cancellationToken)); // Handle failure cases if (launchResult.Backchannel is null || launchResult.ChildProcess is null) { - return HandleLaunchFailure(launchResult, childLogFile); + return HandleLaunchFailure(launchResult, childLogFile, timeoutSeconds); } // Display results @@ -245,20 +253,21 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can internal static bool IsExtensionEnvironmentVariable(string name) => name.StartsWith(ExtensionEnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase); - private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode); + private record LaunchResult(IDetachedProcess? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode); private async Task LaunchAndWaitForBackchannelAsync( string executablePath, List childArgs, string expectedHash, string? legacyHash, + TimeSpan timeout, CancellationToken cancellationToken) { - Process childProcess; + IDetachedProcess childProcess; try { - childProcess = DetachedProcessLauncher.Start( + childProcess = detachedProcessLauncher.Start( executablePath, childArgs, executionContext.WorkingDirectory.FullName, @@ -274,7 +283,6 @@ private async Task LaunchAndWaitForBackchannelAsync( logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.Id); var startTime = timeProvider.GetUtcNow(); - var timeout = TimeSpan.FromSeconds(120); while (timeProvider.GetUtcNow() - startTime < timeout) { @@ -319,7 +327,7 @@ private async Task LaunchAndWaitForBackchannelAsync( return new LaunchResult(childProcess, null, null, false, 0); } - private int HandleLaunchFailure(LaunchResult result, string childLogFile) + private int HandleLaunchFailure(LaunchResult result, string childLogFile, int timeoutSeconds) { if (result.ChildProcess is null) { @@ -333,7 +341,7 @@ private int HandleLaunchFailure(LaunchResult result, string childLogFile) } else { - interactionService.DisplayError(RunCommandStrings.TimeoutWaitingForAppHost); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds)); if (!result.ChildProcess.HasExited) { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 0437abf5091..a5de69f5be0 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -115,7 +115,7 @@ public RunCommand( Options.Add(s_detachOption); Options.Add(s_noBuildOption); - AppHostLauncher.AddLaunchOptions(this); + AppHostLauncher.AddLaunchOptions(this, includeTimeout: true); if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) { @@ -137,6 +137,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var noBuild = parseResult.GetValue(s_noBuildOption); var format = parseResult.GetValue(AppHostLauncher.s_formatOption); var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); + var timeoutSeconds = parseResult.GetValue(AppHostLauncher.s_timeoutOption); var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _); var startDebugSession = false; if (isExtensionHost) @@ -152,6 +153,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } + if (!WaitCommand.ValidateTimeout(timeoutSeconds, InteractionService)) + { + return ExitCodeConstants.InvalidCommand; + } + // Validate that --no-build is not used when watch mode would be enabled // Watch mode is enabled when DefaultWatchEnabled feature is true, or when running under extension host (not in debug session) var watchModeEnabled = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false) || (isExtensionHost && !startDebugSession); @@ -164,7 +170,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Handle detached mode - spawn child process and exit if (detach) { - return await ExecuteDetachedAsync(parseResult, passedAppHostProjectFile, isExtensionHost, cancellationToken); + return await ExecuteDetachedAsync(parseResult, passedAppHostProjectFile, isExtensionHost, timeoutSeconds, cancellationToken); } // A user may run `aspire run` in an Aspire terminal in VS Code. In this case, intercept and prompt @@ -255,11 +261,26 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell BackchannelCompletionSource = backchannelCompletionSource, }; + var startupTimeout = TimeSpan.FromSeconds(timeoutSeconds); + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + // Start the project run as a pending task - we'll handle UX while it runs - var pendingRun = project.RunAsync(context, cancellationToken); + var pendingRun = project.RunAsync(context, runCancellationTokenSource.Token); // Wait for the build to complete first (project handles its own build status spinners) - var buildSuccess = await buildCompletionSource.Task.WaitAsync(cancellationToken); + bool buildSuccess; + try + { + buildSuccess = await buildCompletionSource.Task.WaitAsync(startupTimeout, cancellationToken); + } + catch (TimeoutException) + { + runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout"); + CancelAppHostStartup(runCancellationTokenSource); + DisplayStartupTimeout(timeoutSeconds); + return ExitCodeConstants.FailedToDotnetRunAppHost; + } + if (!buildSuccess) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "build_failed"); @@ -279,9 +300,20 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } // Now wait for the backchannel to be established - var backchannel = await InteractionService.ShowStatusAsync( - RunCommandStrings.ConnectingToAppHost, - async () => await backchannelCompletionSource.Task.WaitAsync(cancellationToken)); + IAppHostCliBackchannel backchannel; + try + { + backchannel = await InteractionService.ShowStatusAsync( + RunCommandStrings.ConnectingToAppHost, + async () => await backchannelCompletionSource.Task.WaitAsync(startupTimeout, cancellationToken)); + } + catch (TimeoutException) + { + runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout"); + CancelAppHostStartup(runCancellationTokenSource); + DisplayStartupTimeout(timeoutSeconds); + return ExitCodeConstants.FailedToDotnetRunAppHost; + } // Set up log capture - writes to unified CLI log file var pendingLogCapture = CaptureAppHostLogsAsync(_fileLoggerProvider, backchannel, _interactionService, cancellationToken); @@ -392,7 +424,7 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar await pendingLogCapture; return await pendingRun; } - catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken || ex is ExtensionOperationCanceledException) + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested || ex.CancellationToken == cancellationToken || ex is ExtensionOperationCanceledException) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); InteractionService.DisplayCancellationMessage(); @@ -648,12 +680,12 @@ public void ProcessResourceState(RpcResourceState resourceState, Action. /// Timeout waiting for backchannel: The auxiliary backchannel socket doesn't appear - /// within 120 seconds. The child process is killed. Shows timeout message and log file path. + /// within the configured timeout. The child process is killed. Shows timeout message and log file path. /// Returns . /// /// On any failure, the log file path is displayed so the user can investigate. /// - private Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, CancellationToken cancellationToken) + private Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, int timeoutSeconds, CancellationToken cancellationToken) { var format = parseResult.GetValue(AppHostLauncher.s_formatOption); var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); @@ -673,9 +705,21 @@ private Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passed isolated, isExtensionHost, waitForDebugger, + timeoutSeconds, globalArgs, additionalArgs, cancellationToken); } + private static void CancelAppHostStartup(CancellationTokenSource runCancellationTokenSource) + { + runCancellationTokenSource.Cancel(); + } + + private void DisplayStartupTimeout(int timeoutSeconds) + { + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds)); + InteractionService.DisplayMessage(KnownEmojis.PageFacingUp, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); + } + } diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index 26bd747a9e3..73a7e67c071 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -34,7 +34,7 @@ public StartCommand( _appHostLauncher = appHostLauncher; Options.Add(s_noBuildOption); - AppHostLauncher.AddLaunchOptions(this); + AppHostLauncher.AddLaunchOptions(this, includeTimeout: true); TreatUnmatchedTokensAsErrors = false; } @@ -44,6 +44,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_appHostOption); var format = parseResult.GetValue(AppHostLauncher.s_formatOption); var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); + var timeoutSeconds = parseResult.GetValue(AppHostLauncher.s_timeoutOption); var noBuild = parseResult.GetValue(s_noBuildOption); // `aspire start` is always user-initiated — the VS Code extension only invokes @@ -59,12 +60,18 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell additionalArgs.Add("--no-build"); } + if (!WaitCommand.ValidateTimeout(timeoutSeconds, InteractionService)) + { + return ExitCodeConstants.InvalidCommand; + } + return await _appHostLauncher.LaunchDetachedAsync( passedAppHostProjectFile, format, isolated, isExtensionHost, waitForDebugger, + timeoutSeconds, globalArgs, additionalArgs, cancellationToken); diff --git a/src/Aspire.Cli/Commands/WaitCommand.cs b/src/Aspire.Cli/Commands/WaitCommand.cs index 725f600c54a..5978df9a98f 100644 --- a/src/Aspire.Cli/Commands/WaitCommand.cs +++ b/src/Aspire.Cli/Commands/WaitCommand.cs @@ -34,12 +34,27 @@ internal sealed class WaitCommand : BaseCommand DefaultValueFactory = _ => "healthy" }; - private static readonly Option s_timeoutOption = new("--timeout") + internal const int DefaultTimeoutSeconds = 120; + + private static readonly Option s_timeoutOption = CreateTimeoutOption(); + + internal static Option CreateTimeoutOption() => new("--timeout") { Description = WaitCommandStrings.TimeoutOptionDescription, - DefaultValueFactory = _ => 120 + DefaultValueFactory = _ => DefaultTimeoutSeconds }; + internal static bool ValidateTimeout(int timeoutSeconds, IInteractionService interactionService) + { + if (timeoutSeconds > 0) + { + return true; + } + + interactionService.DisplayError(WaitCommandStrings.TimeoutMustBePositive); + return false; + } + private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); public WaitCommand( @@ -81,10 +96,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - // Validate timeout - if (timeoutSeconds <= 0) + if (!ValidateTimeout(timeoutSeconds, _interactionService)) { - _interactionService.DisplayError(WaitCommandStrings.TimeoutMustBePositive); return ExitCodeConstants.InvalidCommand; } diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 6fe7d518800..3ca23111b12 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -83,7 +83,20 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) { _logger.LogDebug("{FileName}({ProcessId}) waiting for exit", FileName, _process.Id); - await _process.WaitForExitAsync(cancellationToken); + try + { + await _process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (!_process.HasExited) + { + _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, killing it", FileName, _process.Id); + _process.Kill(entireProcessTree: true); + } + + throw; + } if (!_process.HasExited) { diff --git a/src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs new file mode 100644 index 00000000000..3bec7b70894 --- /dev/null +++ b/src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs @@ -0,0 +1,84 @@ +// 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; + +namespace Aspire.Cli.Processes; + +/// +/// Starts detached child processes for commands that need to outlive the current CLI process. +/// +internal interface IDetachedProcessLauncher +{ + /// + /// Starts a detached child process. + /// + IDetachedProcess Start( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + Func? shouldRemoveEnvironmentVariable = null, + IReadOnlyDictionary? additionalEnvironmentVariables = null); +} + +/// +/// Represents a detached child process. +/// +internal interface IDetachedProcess +{ + /// + /// Gets the process ID. + /// + int Id { get; } + + /// + /// Gets a value indicating whether the process has exited. + /// + bool HasExited { get; } + + /// + /// Gets the process exit code. + /// + int ExitCode { get; } + + /// + /// Asynchronously waits for the process to exit. + /// + Task WaitForExitAsync(CancellationToken cancellationToken); + + /// + /// Kills the process. + /// + void Kill(); +} + +/// +/// Default implementation of . +/// +internal sealed class DefaultDetachedProcessLauncher : IDetachedProcessLauncher +{ + public IDetachedProcess Start( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + Func? shouldRemoveEnvironmentVariable = null, + IReadOnlyDictionary? additionalEnvironmentVariables = null) + { + var process = DetachedProcessLauncher.Start(fileName, arguments, workingDirectory, shouldRemoveEnvironmentVariable, additionalEnvironmentVariables); + + return new DetachedProcess(process); + } + + private sealed class DetachedProcess(Process process) : IDetachedProcess + { + public int Id => process.Id; + + public bool HasExited => process.HasExited; + + public int ExitCode => process.ExitCode; + + public Task WaitForExitAsync(CancellationToken cancellationToken) => process.WaitForExitAsync(cancellationToken); + + public void Kill() => process.Kill(); + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 9b79dbfb782..6c6e55f8a1d 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -33,6 +33,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; +using Aspire.Cli.Processes; using Aspire.Cli.Resources; using Aspire.Cli.Scaffolding; using Aspire.Cli.Telemetry; @@ -353,6 +354,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddTelemetryServices(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 9530bb92c78..a43904a425f 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -227,7 +227,7 @@ AppHost failed to build. - Timeout waiting for AppHost to start. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. Check logs for details: {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 3cea6276c76..069f254689a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Při čekání na spuštění hostitele aplikací (AppHost) vypršel časový limit. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index f2d2b66f3e5..e09542a4b6a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Timeout beim Warten auf den Start des AppHost. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 2bd2ed61c4d..9e23b81b907 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Tiempo de espera de inicio de AppHost. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index 9b080fc8b5c..b1f2c50de1d 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Délai d’attente pour le démarrage d’AppHost. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index 7d888ab319c..55b5a14da8d 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Timeout in attesa dell'avvio di AppHost. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 1293e16210a..8e271431657 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - AppHost の起動待機中にタイムアウトしました。 + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index 16dd8e33f2c..717b55c0340 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - AppHost가 시작될 때까지 기다리는 시간 제한입니다. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 9e028c05f54..dfc7d30ed78 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Przekroczono limit czasu podczas oczekiwania na uruchomienie hosta aplikacji. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index c6425b00424..d87518fd3cd 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Tempo limite de espera para o AppHost iniciar. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index 964d705163a..849df897a96 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - Превышено время ожидания запуска AppHost. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index abe6ce75fcf..8483644e475 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - AppHost'un başlamasını beklerken zaman aşımı. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index 860ee83cf2f..da4805c47c5 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - 等待 AppHost 启动超时。 + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index ddcc72172c8..f31259f86a5 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -203,8 +203,8 @@ The state of the resource, eg Running - Timeout waiting for AppHost to start. - 等候 AppHost 啟動時發生逾時。 + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index d2fd7edcb39..bd4c5042936 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; using Aspire.Cli.Backchannel; @@ -8,6 +9,7 @@ using Aspire.Cli.Diagnostics; using Aspire.Cli.DotNet; using Aspire.Cli.Projects; +using Aspire.Cli.Processes; using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -19,6 +21,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Tests.Commands; @@ -39,6 +42,111 @@ public async Task RunCommandWithHelpArgumentReturnsZero() Assert.Equal(0, exitCode); } + [Fact] + public async Task RunCommand_AcceptsTimeoutOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run --timeout 240 --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task RunCommand_RejectsInvalidTimeoutOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run --timeout 0"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.Equal(WaitCommandStrings.TimeoutMustBePositive, Assert.Single(interactionService.DisplayedErrors)); + } + + [Fact] + public async Task RunCommand_DetachedUsesTimeoutOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + var detachedProcessLauncher = new TestDetachedProcessLauncher(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + }); + services.RemoveAll(); + services.AddSingleton(detachedProcessLauncher); + services.RemoveAll(); + services.AddSingleton(new AdvancingTimeProvider()); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run --detach --timeout 37"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); + Assert.True(detachedProcessLauncher.Process.Killed); + Assert.DoesNotContain("--timeout", detachedProcessLauncher.Arguments); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37), + Assert.Single(interactionService.DisplayedErrors)); + } + + [Fact] + public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance() + { + var interactionService = new TestInteractionService(); + + var runnerFactory = (IServiceProvider sp) => + { + var runner = new TestDotNetCliRunner(); + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; + runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return 0; + }; + + return runner; + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + options.DotNetCliRunnerFactory = runnerFactory; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run --timeout 1"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 1), + Assert.Single(interactionService.DisplayedErrors)); + } + [Fact] public async Task RunCommand_WhenNoProjectFileFound_ReturnsNonZeroExitCode() { diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs index ea0bd89aa11..4df269a4db1 100644 --- a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs @@ -1,12 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using Aspire.Cli.Commands; +using Aspire.Cli.Processes; +using Aspire.Cli.Projects; +using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Cli.Tests.Commands; @@ -69,6 +75,78 @@ public async Task StartCommand_AcceptsIsolatedOption() Assert.Equal(ExitCodeConstants.Success, exitCode); } + [Fact] + public async Task StartCommand_AcceptsTimeoutOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start --timeout 240 --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task StartCommand_RejectsInvalidTimeoutOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start --timeout 0"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.Equal(WaitCommandStrings.TimeoutMustBePositive, Assert.Single(interactionService.DisplayedErrors)); + } + + [Fact] + public async Task StartCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + + var interactionService = new TestInteractionService(); + var detachedProcessLauncher = new TestDetachedProcessLauncher(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + }); + services.RemoveAll(); + services.AddSingleton(detachedProcessLauncher); + services.RemoveAll(); + services.AddSingleton(new AdvancingTimeProvider()); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start --timeout 37"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); + Assert.True(detachedProcessLauncher.Process.Killed); + Assert.DoesNotContain("--timeout", detachedProcessLauncher.Arguments); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37), + Assert.Single(interactionService.DisplayedErrors)); + } + [Fact] public void StartCommand_ForwardsUnmatchedTokensToAppHost() { @@ -144,4 +222,5 @@ public async Task StartCommand_WhenMultipleProjectFilesFound_JsonFormat_ReturnsN Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } + } diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index e2641fb04e3..be1fc85fb05 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -147,6 +147,34 @@ public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod() Assert.Equal("value-399", values[399].GetString()); } + [Fact] + public async Task WaitForExitAsync_KillsProcessWhenCanceled() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); + var startInfo = CreateStartInfo(scriptFile); + var process = new Process + { + StartInfo = startInfo + }; + + using var execution = new ProcessExecution( + process, + NullLogger.Instance, + new ProcessInvocationOptions()); + + Assert.True(execution.Start()); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(() => execution.WaitForExitAsync(cts.Token)); + await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.True(process.HasExited); + } + private static string CreateJsonPayload(int lineCount) { var builder = new StringBuilder(); @@ -226,6 +254,35 @@ private static async Task CreateDelayedOutputScriptAsync(DirectoryInfo } } + private static async Task CreateLongRunningScriptAsync(DirectoryInfo workspaceRoot) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "long-running.cmd")); + var content = + "@echo off" + Environment.NewLine + + "powershell -NoProfile -Command \"Start-Sleep -Seconds 60\"" + Environment.NewLine; + await File.WriteAllTextAsync(scriptFile.FullName, content); + return scriptFile; + } + else + { + var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "long-running.sh")); + var content = + "#!/usr/bin/env bash" + Environment.NewLine + + "sleep 60" + Environment.NewLine; + await File.WriteAllTextAsync(scriptFile.FullName, content); + + File.SetUnixFileMode( + scriptFile.FullName, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + + return scriptFile; + } + } + private static ProcessStartInfo CreateStartInfo(FileInfo scriptFile) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs b/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs new file mode 100644 index 00000000000..faa4b3640b7 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Processes; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class TestDetachedProcessLauncher : IDetachedProcessLauncher +{ + public TestDetachedProcess Process { get; } = new(); + + public IReadOnlyList Arguments { get; private set; } = []; + + public IDetachedProcess Start( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + Func? shouldRemoveEnvironmentVariable = null, + IReadOnlyDictionary? additionalEnvironmentVariables = null) + { + Arguments = arguments; + return Process; + } +} + +internal sealed class TestDetachedProcess : IDetachedProcess +{ + public int Id => 1; + + public bool HasExited => false; + + public int ExitCode => 0; + + public bool Killed { get; private set; } + + public async Task WaitForExitAsync(CancellationToken cancellationToken) + { + await Task.Delay(1, cancellationToken); + } + + public void Kill() + { + Killed = true; + } +} + +internal sealed class AdvancingTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow = new(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() + { + var current = _utcNow; + _utcNow += TimeSpan.FromSeconds(10); + return current; + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 5fbf637dd8d..f85a94b9229 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -17,6 +17,7 @@ using Aspire.Cli.Mcp; using Aspire.Cli.Documentation.Docs; using Aspire.Cli.NuGet; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Scaffolding; using Aspire.Cli.Secrets; @@ -112,6 +113,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.AddCommandPrompterFactory); services.AddSingleton(options.PublishCommandPrompterFactory); services.AddTransient(options.DotNetCliExecutionFactoryFactory); + services.AddTransient(); services.AddTransient(options.DotNetCliRunnerFactory); services.AddTransient(options.NuGetPackageCacheFactory); services.AddSingleton(options.TemplateProviderFactory); From aedb4cb33b8a79b051b29dc98ed7a8ef6f0d93a5 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 1 May 2026 16:57:21 -0700 Subject: [PATCH 2/6] Use FakeTimeProvider in CLI timeout tests Replace the custom advancing time provider with Microsoft.Extensions.Time.Testing.FakeTimeProvider configured with AutoAdvanceAmount. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs | 6 +++++- tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs | 6 +++++- .../TestServices/TestDetachedProcessLauncher.cs | 12 ------------ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index bd4c5042936..9408d289bd9 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -23,6 +23,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; namespace Aspire.Cli.Tests.Commands; @@ -90,7 +91,10 @@ public async Task RunCommand_DetachedUsesTimeoutOption() services.RemoveAll(); services.AddSingleton(detachedProcessLauncher); services.RemoveAll(); - services.AddSingleton(new AdvancingTimeProvider()); + services.AddSingleton(new FakeTimeProvider + { + AutoAdvanceAmount = TimeSpan.FromSeconds(10) + }); using var provider = services.BuildServiceProvider(); diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs index 4df269a4db1..bd8e56b6d10 100644 --- a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Time.Testing; namespace Aspire.Cli.Tests.Commands; @@ -130,7 +131,10 @@ public async Task StartCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidanc services.RemoveAll(); services.AddSingleton(detachedProcessLauncher); services.RemoveAll(); - services.AddSingleton(new AdvancingTimeProvider()); + services.AddSingleton(new FakeTimeProvider + { + AutoAdvanceAmount = TimeSpan.FromSeconds(10) + }); using var provider = services.BuildServiceProvider(); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs b/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs index faa4b3640b7..273c6f81d06 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs @@ -43,15 +43,3 @@ public void Kill() Killed = true; } } - -internal sealed class AdvancingTimeProvider : TimeProvider -{ - private DateTimeOffset _utcNow = new(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); - - public override DateTimeOffset GetUtcNow() - { - var current = _utcNow; - _utcNow += TimeSpan.FromSeconds(10); - return current; - } -} From 4d7c01a5be613f31b0395db567301c8698e8a825 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 4 May 2026 08:19:15 -0700 Subject: [PATCH 3/6] Address CLI startup timeout review feedback Use a single timeout budget for run startup, observe pending AppHost runs during timeout cleanup, and make detached process termination explicit about process-tree cleanup. Keep cancellation propagation reliable when process-tree termination fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AppHostLauncher.cs | 2 +- src/Aspire.Cli/Commands/RunCommand.cs | 53 +++++++++++++-- src/Aspire.Cli/DotNet/ProcessExecution.cs | 14 +++- .../Processes/IDetachedProcessLauncher.cs | 5 +- .../Commands/RunCommandTests.cs | 66 ++++++++++++++++++- .../Commands/StartCommandTests.cs | 1 + .../TestDetachedProcessLauncher.cs | 5 +- 7 files changed, 134 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index c0c575ea136..6a845c188b4 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -347,7 +347,7 @@ private int HandleLaunchFailure(LaunchResult result, string childLogFile, int ti { try { - result.ChildProcess.Kill(); + result.ChildProcess.Kill(entireProcessTree: true); } catch { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index a5de69f5be0..5b5a5e7f213 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -68,8 +68,11 @@ internal sealed class RunCommand : BaseCommand private readonly AppHostLauncher _appHostLauncher; private readonly FileLoggerProvider _fileLoggerProvider; private readonly ICliHostEnvironment _hostEnvironment; + private readonly TimeProvider _timeProvider; private bool _isDetachMode; + private static readonly TimeSpan s_appHostStartupCancellationTimeout = TimeSpan.FromSeconds(5); + protected override bool UpdateNotificationsEnabled => !_isDetachMode; private static readonly Option s_detachOption = new("--detach") @@ -97,7 +100,8 @@ public RunCommand( IAppHostProjectFactory projectFactory, AppHostLauncher appHostLauncher, FileLoggerProvider fileLoggerProvider, - ICliHostEnvironment hostEnvironment) + ICliHostEnvironment hostEnvironment, + TimeProvider timeProvider) : base("run", RunCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _runner = runner; @@ -112,6 +116,7 @@ public RunCommand( _appHostLauncher = appHostLauncher; _fileLoggerProvider = fileLoggerProvider; _hostEnvironment = hostEnvironment; + _timeProvider = timeProvider; Options.Add(s_detachOption); Options.Add(s_noBuildOption); @@ -262,6 +267,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell }; var startupTimeout = TimeSpan.FromSeconds(timeoutSeconds); + var startupStartTimestamp = _timeProvider.GetTimestamp(); using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Start the project run as a pending task - we'll handle UX while it runs @@ -271,13 +277,13 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell bool buildSuccess; try { - buildSuccess = await buildCompletionSource.Task.WaitAsync(startupTimeout, cancellationToken); + buildSuccess = await buildCompletionSource.Task.WaitAsync(GetRemainingStartupTimeout(startupStartTimestamp, startupTimeout), _timeProvider, cancellationToken); } catch (TimeoutException) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout"); - CancelAppHostStartup(runCancellationTokenSource); DisplayStartupTimeout(timeoutSeconds); + await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun); return ExitCodeConstants.FailedToDotnetRunAppHost; } @@ -305,13 +311,13 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { backchannel = await InteractionService.ShowStatusAsync( RunCommandStrings.ConnectingToAppHost, - async () => await backchannelCompletionSource.Task.WaitAsync(startupTimeout, cancellationToken)); + async () => await backchannelCompletionSource.Task.WaitAsync(GetRemainingStartupTimeout(startupStartTimestamp, startupTimeout), _timeProvider, cancellationToken)); } catch (TimeoutException) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout"); - CancelAppHostStartup(runCancellationTokenSource); DisplayStartupTimeout(timeoutSeconds); + await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun); return ExitCodeConstants.FailedToDotnetRunAppHost; } @@ -711,9 +717,44 @@ private Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passed cancellationToken); } - private static void CancelAppHostStartup(CancellationTokenSource runCancellationTokenSource) + private TimeSpan GetRemainingStartupTimeout(long startupStartTimestamp, TimeSpan startupTimeout) + { + var elapsed = _timeProvider.GetElapsedTime(startupStartTimestamp); + return elapsed >= startupTimeout ? TimeSpan.Zero : startupTimeout - elapsed; + } + + private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task pendingRun) { runCancellationTokenSource.Cancel(); + + try + { + await pendingRun.WaitAsync(s_appHostStartupCancellationTimeout, _timeProvider).ConfigureAwait(false); + } + catch (OperationCanceledException) when (runCancellationTokenSource.IsCancellationRequested) + { + } + catch (TimeoutException ex) + { + _logger.LogDebug(ex, "Timed out waiting for AppHost startup cancellation to complete."); + _ = ObserveAppHostRunFailureAsync(pendingRun); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "AppHost run failed after startup cancellation."); + } + } + + private async Task ObserveAppHostRunFailureAsync(Task pendingRun) + { + try + { + await pendingRun.ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "AppHost run failed after startup cancellation timeout."); + } } private void DisplayStartupTimeout(int timeoutSeconds) diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 3ca23111b12..ee4a6ea3fdd 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -92,7 +92,7 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) if (!_process.HasExited) { _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, killing it", FileName, _process.Id); - _process.Kill(entireProcessTree: true); + TryKillProcessTree(); } throw; @@ -214,4 +214,16 @@ private void RecordForwarderActivity() { Interlocked.Exchange(ref _lastForwarderActivityTimestamp, Stopwatch.GetTimestamp()); } + + private void TryKillProcessTree() + { + try + { + _process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "{FileName}({ProcessId}) failed to kill process tree after wait cancellation", FileName, _process.Id); + } + } } diff --git a/src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs index 3bec7b70894..8c7c985be2f 100644 --- a/src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs +++ b/src/Aspire.Cli/Processes/IDetachedProcessLauncher.cs @@ -49,7 +49,8 @@ internal interface IDetachedProcess /// /// Kills the process. /// - void Kill(); + /// When true, kills the entire process tree; otherwise kills only the root process. + void Kill(bool entireProcessTree); } /// @@ -79,6 +80,6 @@ private sealed class DetachedProcess(Process process) : IDetachedProcess public Task WaitForExitAsync(CancellationToken cancellationToken) => process.WaitForExitAsync(cancellationToken); - public void Kill() => process.Kill(); + public void Kill(bool entireProcessTree) => process.Kill(entireProcessTree); } } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 9408d289bd9..4f5a7e84390 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -1,6 +1,7 @@ // 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.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; @@ -105,6 +106,7 @@ public async Task RunCommand_DetachedUsesTimeoutOption() Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.True(detachedProcessLauncher.Process.Killed); + Assert.True(detachedProcessLauncher.Process.KilledEntireProcessTree); Assert.DoesNotContain("--timeout", detachedProcessLauncher.Arguments); Assert.Equal( string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37), @@ -115,6 +117,7 @@ public async Task RunCommand_DetachedUsesTimeoutOption() public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance() { var interactionService = new TestInteractionService(); + var runCancellationObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var runnerFactory = (IServiceProvider sp) => { @@ -123,7 +126,17 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { - await Task.Delay(Timeout.InfiniteTimeSpan, ct); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + await Task.Delay(50, CancellationToken.None); + runCancellationObserved.SetResult(); + throw; + } + return 0; }; @@ -149,6 +162,57 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( Assert.Equal( string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 1), Assert.Single(interactionService.DisplayedErrors)); + Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully); + } + + [Fact] + public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWaits() + { + var interactionService = new TestInteractionService(); + var timeProvider = new FakeTimeProvider(); + + var runnerFactory = (IServiceProvider sp) => + { + var runner = new TestDotNetCliRunner(); + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => + { + timeProvider.Advance(TimeSpan.FromSeconds(2)); + return 0; + }; + runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return 0; + }; + + return runner; + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + options.DotNetCliRunnerFactory = runnerFactory; + }); + services.RemoveAll(); + services.AddSingleton(timeProvider); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run --timeout 2"); + + var stopwatch = Stopwatch.StartNew(); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + stopwatch.Stop(); + + Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), $"Expected startup timeout to use the remaining budget, but the command took {stopwatch.Elapsed}."); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 2), + Assert.Single(interactionService.DisplayedErrors)); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs index bd8e56b6d10..c0cdb345c5e 100644 --- a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs @@ -145,6 +145,7 @@ public async Task StartCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidanc Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.True(detachedProcessLauncher.Process.Killed); + Assert.True(detachedProcessLauncher.Process.KilledEntireProcessTree); Assert.DoesNotContain("--timeout", detachedProcessLauncher.Arguments); Assert.Equal( string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37), diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs b/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs index 273c6f81d06..77bdb8bf4d6 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDetachedProcessLauncher.cs @@ -33,13 +33,16 @@ internal sealed class TestDetachedProcess : IDetachedProcess public bool Killed { get; private set; } + public bool KilledEntireProcessTree { get; private set; } + public async Task WaitForExitAsync(CancellationToken cancellationToken) { await Task.Delay(1, cancellationToken); } - public void Kill() + public void Kill(bool entireProcessTree) { Killed = true; + KilledEntireProcessTree = entireProcessTree; } } From 96247d6285112166ecc019d28d1a69d2ad218a25 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 4 May 2026 08:48:56 -0700 Subject: [PATCH 4/6] Use env var for AppHost startup timeout Replace the run/start timeout options with ASPIRE_CLI_START_TIMEOUT_SECONDS while keeping aspire wait --timeout unchanged. Update timeout guidance and validation to point users at the environment variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/CliConfigNames.cs | 1 + src/Aspire.Cli/Commands/AppHostLauncher.cs | 10 +--- .../Commands/AppHostStartupTimeout.cs | 36 +++++++++++++ src/Aspire.Cli/Commands/RunCommand.cs | 15 +++--- src/Aspire.Cli/Commands/StartCommand.cs | 11 ++-- src/Aspire.Cli/Commands/WaitCommand.cs | 2 +- .../Resources/RunCommandStrings.Designer.cs | 6 +++ .../Resources/RunCommandStrings.resx | 7 ++- .../Resources/xlf/RunCommandStrings.cs.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.de.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.es.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.fr.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.it.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.ja.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.ko.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.pl.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.ru.xlf | 11 ++-- .../Resources/xlf/RunCommandStrings.tr.xlf | 11 ++-- .../xlf/RunCommandStrings.zh-Hans.xlf | 11 ++-- .../xlf/RunCommandStrings.zh-Hant.xlf | 11 ++-- .../Commands/RunCommandTests.cs | 53 ++++++++++--------- .../Commands/StartCommandTests.cs | 35 ++++++------ 23 files changed, 213 insertions(+), 106 deletions(-) create mode 100644 src/Aspire.Cli/Commands/AppHostStartupTimeout.cs diff --git a/src/Aspire.Cli/CliConfigNames.cs b/src/Aspire.Cli/CliConfigNames.cs index dd73eeb6f5d..f2adaf79c69 100644 --- a/src/Aspire.Cli/CliConfigNames.cs +++ b/src/Aspire.Cli/CliConfigNames.cs @@ -7,4 +7,5 @@ namespace Aspire.Cli; internal static class CliConfigNames { public const string NoLogo = "ASPIRE_CLI_NOLOGO"; + public const string AppHostStartupTimeoutSeconds = "ASPIRE_CLI_START_TIMEOUT_SECONDS"; } diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 6a845c188b4..8d620a2b475 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -54,21 +54,15 @@ internal sealed class AppHostLauncher( Description = SharedCommandStrings.IsolatedOptionDescription }; - internal static readonly Option s_timeoutOption = WaitCommand.CreateTimeoutOption(); - /// /// Adds the shared AppHost launch options to a command so they appear in --help. /// Called by both RunCommand and StartCommand to keep shared options in sync. /// - internal static void AddLaunchOptions(Command command, bool includeTimeout = false) + internal static void AddLaunchOptions(Command command) { command.Options.Add(s_appHostOption); command.Options.Add(s_formatOption); command.Options.Add(s_isolatedOption); - if (includeTimeout) - { - command.Options.Add(s_timeoutOption); - } } /// @@ -341,7 +335,7 @@ private int HandleLaunchFailure(LaunchResult result, string childLogFile, int ti } else { - interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds)); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeoutSeconds)); if (!result.ChildProcess.HasExited) { diff --git a/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs b/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs new file mode 100644 index 00000000000..e6f517f0f19 --- /dev/null +++ b/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Microsoft.Extensions.Configuration; + +namespace Aspire.Cli.Commands; + +internal static class AppHostStartupTimeout +{ + public static bool TryGetTimeoutSeconds(IConfiguration configuration, IInteractionService interactionService, out int timeoutSeconds) + { + timeoutSeconds = WaitCommand.DefaultTimeoutSeconds; + + var configuredTimeout = configuration[CliConfigNames.AppHostStartupTimeoutSeconds]; + if (string.IsNullOrWhiteSpace(configuredTimeout)) + { + return true; + } + + if (int.TryParse(configuredTimeout, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedTimeout) && + parsedTimeout > 0) + { + timeoutSeconds = parsedTimeout; + return true; + } + + interactionService.DisplayError(string.Format( + CultureInfo.CurrentCulture, + RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, + CliConfigNames.AppHostStartupTimeoutSeconds)); + return false; + } +} diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 5b5a5e7f213..fcc5ee896db 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -120,7 +120,7 @@ public RunCommand( Options.Add(s_detachOption); Options.Add(s_noBuildOption); - AppHostLauncher.AddLaunchOptions(this, includeTimeout: true); + AppHostLauncher.AddLaunchOptions(this); if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) { @@ -142,7 +142,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var noBuild = parseResult.GetValue(s_noBuildOption); var format = parseResult.GetValue(AppHostLauncher.s_formatOption); var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); - var timeoutSeconds = parseResult.GetValue(AppHostLauncher.s_timeoutOption); var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _); var startDebugSession = false; if (isExtensionHost) @@ -158,11 +157,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - if (!WaitCommand.ValidateTimeout(timeoutSeconds, InteractionService)) - { - return ExitCodeConstants.InvalidCommand; - } - // Validate that --no-build is not used when watch mode would be enabled // Watch mode is enabled when DefaultWatchEnabled feature is true, or when running under extension host (not in debug session) var watchModeEnabled = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false) || (isExtensionHost && !startDebugSession); @@ -172,6 +166,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } + if (!AppHostStartupTimeout.TryGetTimeoutSeconds(_configuration, InteractionService, out var timeoutSeconds)) + { + return ExitCodeConstants.InvalidCommand; + } + // Handle detached mode - spawn child process and exit if (detach) { @@ -759,7 +758,7 @@ private async Task ObserveAppHostRunFailureAsync(Task pendingRun) private void DisplayStartupTimeout(int timeoutSeconds) { - InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds)); + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeoutSeconds)); InteractionService.DisplayMessage(KnownEmojis.PageFacingUp, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); } diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index 73a7e67c071..6ca56b7988b 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Microsoft.Extensions.Configuration; namespace Aspire.Cli.Commands; @@ -15,6 +16,7 @@ internal sealed class StartCommand : BaseCommand internal override HelpGroup HelpGroup => HelpGroup.AppCommands; private readonly AppHostLauncher _appHostLauncher; + private readonly IConfiguration _configuration; private static readonly Option s_noBuildOption = new("--no-build") { @@ -27,14 +29,16 @@ public StartCommand( ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry, - AppHostLauncher appHostLauncher) + AppHostLauncher appHostLauncher, + IConfiguration configuration) : base("start", StartCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _appHostLauncher = appHostLauncher; + _configuration = configuration; Options.Add(s_noBuildOption); - AppHostLauncher.AddLaunchOptions(this, includeTimeout: true); + AppHostLauncher.AddLaunchOptions(this); TreatUnmatchedTokensAsErrors = false; } @@ -44,7 +48,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_appHostOption); var format = parseResult.GetValue(AppHostLauncher.s_formatOption); var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); - var timeoutSeconds = parseResult.GetValue(AppHostLauncher.s_timeoutOption); var noBuild = parseResult.GetValue(s_noBuildOption); // `aspire start` is always user-initiated — the VS Code extension only invokes @@ -60,7 +63,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell additionalArgs.Add("--no-build"); } - if (!WaitCommand.ValidateTimeout(timeoutSeconds, InteractionService)) + if (!AppHostStartupTimeout.TryGetTimeoutSeconds(_configuration, InteractionService, out var timeoutSeconds)) { return ExitCodeConstants.InvalidCommand; } diff --git a/src/Aspire.Cli/Commands/WaitCommand.cs b/src/Aspire.Cli/Commands/WaitCommand.cs index 5978df9a98f..558bed8a996 100644 --- a/src/Aspire.Cli/Commands/WaitCommand.cs +++ b/src/Aspire.Cli/Commands/WaitCommand.cs @@ -38,7 +38,7 @@ internal sealed class WaitCommand : BaseCommand private static readonly Option s_timeoutOption = CreateTimeoutOption(); - internal static Option CreateTimeoutOption() => new("--timeout") + private static Option CreateTimeoutOption() => new("--timeout") { Description = WaitCommandStrings.TimeoutOptionDescription, DefaultValueFactory = _ => DefaultTimeoutSeconds diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs index 519c1ce461b..e94ab6bbe8a 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs @@ -260,6 +260,12 @@ public static string TimeoutWaitingForAppHost { return ResourceManager.GetString("TimeoutWaitingForAppHost", resourceCulture); } } + + public static string InvalidAppHostStartupTimeoutEnvironmentVariable { + get { + return ResourceManager.GetString("InvalidAppHostStartupTimeoutEnvironmentVariable", resourceCulture); + } + } public static string CheckLogsForDetails { get { diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index a43904a425f..20a8249351a 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -227,7 +227,12 @@ AppHost failed to build. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. + + + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. Check logs for details: {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 069f254689a..17803a728c1 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -107,6 +107,11 @@ Stav prostředku, např. V pořádku + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost má hodnotu null. @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index e09542a4b6a..3f6a144d696 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -107,6 +107,11 @@ Die Integrität der Ressource, z. B. fehlerfrei + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost ist NULL. @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 9e23b81b907..01022e5fd9e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -107,6 +107,11 @@ El estado del recurso, por ejemplo, Correcto + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost es null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index b1f2c50de1d..2bcbd3279a8 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -107,6 +107,11 @@ L’état de la ressource, par exemple, Sain + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost est nul @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index 55b5a14da8d..4b732bcf51a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -107,6 +107,11 @@ Integrità della risorsa, ad esempio Integro + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost è null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 8e271431657..391d08fe137 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -107,6 +107,11 @@ リソースの正常性 (正常など) + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost が null です @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index 717b55c0340..2ffaa8c9458 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -107,6 +107,11 @@ 리소스의 상태, 예: 정상 + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost가 null입니다 @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index dfc7d30ed78..d9452c06392 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -107,6 +107,11 @@ Kondycja zasobu, np. w dobrej kondycji + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null Element IsCompatibleAppHost ma wartość null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index d87518fd3cd..872c8004fcf 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -107,6 +107,11 @@ A integridade do recurso, por exemplo, Íntegro + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost is null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index 849df897a96..98b0b3f593e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -107,6 +107,11 @@ Работоспособность ресурса, например "Работоспособен" + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null Значение параметра IsCompatibleAppHost равно null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index 8483644e475..9f7a668bceb 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -107,6 +107,11 @@ Kaynağın durumu, örneğin İyi Durumda + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost öğesinin değeri null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index da4805c47c5..6190e1258dc 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -107,6 +107,11 @@ 资源的运行状况,例如“正常” + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost 为 null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index f31259f86a5..c7661c59fa5 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -107,6 +107,11 @@ 資源的健康狀況,例如:健康 + + The {0} environment variable must be a positive number of seconds. + The {0} environment variable must be a positive number of seconds. + Do not localize the {0} placeholder. It is an environment variable name. + IsCompatibleAppHost is null IsCompatibleAppHost 為 null @@ -203,9 +208,9 @@ The state of the resource, eg Running - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, try again with a higher --timeout value. - + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Timed out waiting {0}s for AppHost to start. If the AppHost is still building or starting, set {1} to a higher value and try again. + Do not localize the {1} placeholder. It is an environment variable name. Type diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 4f5a7e84390..b79c6c102ef 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -45,41 +45,33 @@ public async Task RunCommandWithHelpArgumentReturnsZero() } [Fact] - public async Task RunCommand_AcceptsTimeoutOption() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("run --timeout 240 --help"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Fact] - public async Task RunCommand_RejectsInvalidTimeoutOption() + public async Task RunCommand_RejectsInvalidStartupTimeoutEnvironmentVariable() { using var workspace = TemporaryWorkspace.Create(outputHelper); var interactionService = new TestInteractionService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => interactionService; + options.ConfigurationCallback += config => + { + config[CliConfigNames.AppHostStartupTimeoutSeconds] = "0"; + }; }); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("run --timeout 0"); + var result = command.Parse("run"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - Assert.Equal(WaitCommandStrings.TimeoutMustBePositive, Assert.Single(interactionService.DisplayedErrors)); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, CliConfigNames.AppHostStartupTimeoutSeconds), + Assert.Single(interactionService.DisplayedErrors)); } [Fact] - public async Task RunCommand_DetachedUsesTimeoutOption() + public async Task RunCommand_DetachedUsesStartupTimeoutEnvironmentVariable() { using var workspace = TemporaryWorkspace.Create(outputHelper); var interactionService = new TestInteractionService(); @@ -88,6 +80,10 @@ public async Task RunCommand_DetachedUsesTimeoutOption() { options.InteractionServiceFactory = _ => interactionService; options.ProjectLocatorFactory = _ => new TestProjectLocator(); + options.ConfigurationCallback += config => + { + config[CliConfigNames.AppHostStartupTimeoutSeconds] = "37"; + }; }); services.RemoveAll(); services.AddSingleton(detachedProcessLauncher); @@ -100,16 +96,15 @@ public async Task RunCommand_DetachedUsesTimeoutOption() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("run --detach --timeout 37"); + var result = command.Parse("run --detach"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.True(detachedProcessLauncher.Process.Killed); Assert.True(detachedProcessLauncher.Process.KilledEntireProcessTree); - Assert.DoesNotContain("--timeout", detachedProcessLauncher.Arguments); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37, CliConfigNames.AppHostStartupTimeoutSeconds), Assert.Single(interactionService.DisplayedErrors)); } @@ -149,18 +144,22 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( options.InteractionServiceFactory = _ => interactionService; options.ProjectLocatorFactory = _ => new TestProjectLocator(); options.DotNetCliRunnerFactory = runnerFactory; + options.ConfigurationCallback += config => + { + config[CliConfigNames.AppHostStartupTimeoutSeconds] = "1"; + }; }); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("run --timeout 1"); + var result = command.Parse("run"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 1), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 1, CliConfigNames.AppHostStartupTimeoutSeconds), Assert.Single(interactionService.DisplayedErrors)); Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully); } @@ -195,6 +194,10 @@ public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWait options.InteractionServiceFactory = _ => interactionService; options.ProjectLocatorFactory = _ => new TestProjectLocator(); options.DotNetCliRunnerFactory = runnerFactory; + options.ConfigurationCallback += config => + { + config[CliConfigNames.AppHostStartupTimeoutSeconds] = "2"; + }; }); services.RemoveAll(); services.AddSingleton(timeProvider); @@ -202,7 +205,7 @@ public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWait using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("run --timeout 2"); + var result = command.Parse("run"); var stopwatch = Stopwatch.StartNew(); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -211,7 +214,7 @@ public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWait Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), $"Expected startup timeout to use the remaining budget, but the command took {stopwatch.Elapsed}."); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 2), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 2, CliConfigNames.AppHostStartupTimeoutSeconds), Assert.Single(interactionService.DisplayedErrors)); } diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs index c0cdb345c5e..655bc0ccd4e 100644 --- a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs @@ -77,37 +77,29 @@ public async Task StartCommand_AcceptsIsolatedOption() } [Fact] - public async Task StartCommand_AcceptsTimeoutOption() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("start --timeout 240 --help"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Fact] - public async Task StartCommand_RejectsInvalidTimeoutOption() + public async Task StartCommand_RejectsInvalidStartupTimeoutEnvironmentVariable() { using var workspace = TemporaryWorkspace.Create(outputHelper); var interactionService = new TestInteractionService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => interactionService; + options.ConfigurationCallback += config => + { + config[CliConfigNames.AppHostStartupTimeoutSeconds] = "0"; + }; }); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("start --timeout 0"); + var result = command.Parse("start"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - Assert.Equal(WaitCommandStrings.TimeoutMustBePositive, Assert.Single(interactionService.DisplayedErrors)); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, CliConfigNames.AppHostStartupTimeoutSeconds), + Assert.Single(interactionService.DisplayedErrors)); } [Fact] @@ -122,6 +114,10 @@ public async Task StartCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidanc var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => interactionService; + options.ConfigurationCallback += config => + { + config[CliConfigNames.AppHostStartupTimeoutSeconds] = "37"; + }; options.ProjectLocatorFactory = _ => new TestProjectLocator { UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => @@ -139,16 +135,15 @@ public async Task StartCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidanc using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("start --timeout 37"); + var result = command.Parse("start"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.True(detachedProcessLauncher.Process.Killed); Assert.True(detachedProcessLauncher.Process.KilledEntireProcessTree); - Assert.DoesNotContain("--timeout", detachedProcessLauncher.Arguments); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37, CliConfigNames.AppHostStartupTimeoutSeconds), Assert.Single(interactionService.DisplayedErrors)); } From 44a3be16532250e699cd7608f34918c68f7b8f7f Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 4 May 2026 08:53:31 -0700 Subject: [PATCH 5/6] Rename AppHost startup timeout environment variable Use ASPIRE_CLI_START_TIMEOUT to match existing Aspire timeout environment variable naming. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/CliConfigNames.cs | 2 +- src/Aspire.Cli/Commands/AppHostLauncher.cs | 2 +- src/Aspire.Cli/Commands/AppHostStartupTimeout.cs | 4 ++-- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- .../Aspire.Cli.Tests/Commands/RunCommandTests.cs | 16 ++++++++-------- .../Commands/StartCommandTests.cs | 8 ++++---- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Cli/CliConfigNames.cs b/src/Aspire.Cli/CliConfigNames.cs index f2adaf79c69..438a7dadebb 100644 --- a/src/Aspire.Cli/CliConfigNames.cs +++ b/src/Aspire.Cli/CliConfigNames.cs @@ -7,5 +7,5 @@ namespace Aspire.Cli; internal static class CliConfigNames { public const string NoLogo = "ASPIRE_CLI_NOLOGO"; - public const string AppHostStartupTimeoutSeconds = "ASPIRE_CLI_START_TIMEOUT_SECONDS"; + public const string AppHostStartupTimeout = "ASPIRE_CLI_START_TIMEOUT"; } diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 8d620a2b475..291c1e8bc94 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -335,7 +335,7 @@ private int HandleLaunchFailure(LaunchResult result, string childLogFile, int ti } else { - interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeoutSeconds)); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeout)); if (!result.ChildProcess.HasExited) { diff --git a/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs b/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs index e6f517f0f19..5560413395b 100644 --- a/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs +++ b/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs @@ -14,7 +14,7 @@ public static bool TryGetTimeoutSeconds(IConfiguration configuration, IInteracti { timeoutSeconds = WaitCommand.DefaultTimeoutSeconds; - var configuredTimeout = configuration[CliConfigNames.AppHostStartupTimeoutSeconds]; + var configuredTimeout = configuration[CliConfigNames.AppHostStartupTimeout]; if (string.IsNullOrWhiteSpace(configuredTimeout)) { return true; @@ -30,7 +30,7 @@ public static bool TryGetTimeoutSeconds(IConfiguration configuration, IInteracti interactionService.DisplayError(string.Format( CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, - CliConfigNames.AppHostStartupTimeoutSeconds)); + CliConfigNames.AppHostStartupTimeout)); return false; } } diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index fcc5ee896db..44afb6eadd3 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -758,7 +758,7 @@ private async Task ObserveAppHostRunFailureAsync(Task pendingRun) private void DisplayStartupTimeout(int timeoutSeconds) { - InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeoutSeconds)); + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeout)); InteractionService.DisplayMessage(KnownEmojis.PageFacingUp, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index b79c6c102ef..ba8bca1ec33 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -54,7 +54,7 @@ public async Task RunCommand_RejectsInvalidStartupTimeoutEnvironmentVariable() options.InteractionServiceFactory = _ => interactionService; options.ConfigurationCallback += config => { - config[CliConfigNames.AppHostStartupTimeoutSeconds] = "0"; + config[CliConfigNames.AppHostStartupTimeout] = "0"; }; }); using var provider = services.BuildServiceProvider(); @@ -66,7 +66,7 @@ public async Task RunCommand_RejectsInvalidStartupTimeoutEnvironmentVariable() Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, CliConfigNames.AppHostStartupTimeoutSeconds), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, CliConfigNames.AppHostStartupTimeout), Assert.Single(interactionService.DisplayedErrors)); } @@ -82,7 +82,7 @@ public async Task RunCommand_DetachedUsesStartupTimeoutEnvironmentVariable() options.ProjectLocatorFactory = _ => new TestProjectLocator(); options.ConfigurationCallback += config => { - config[CliConfigNames.AppHostStartupTimeoutSeconds] = "37"; + config[CliConfigNames.AppHostStartupTimeout] = "37"; }; }); services.RemoveAll(); @@ -104,7 +104,7 @@ public async Task RunCommand_DetachedUsesStartupTimeoutEnvironmentVariable() Assert.True(detachedProcessLauncher.Process.Killed); Assert.True(detachedProcessLauncher.Process.KilledEntireProcessTree); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37, CliConfigNames.AppHostStartupTimeoutSeconds), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37, CliConfigNames.AppHostStartupTimeout), Assert.Single(interactionService.DisplayedErrors)); } @@ -146,7 +146,7 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( options.DotNetCliRunnerFactory = runnerFactory; options.ConfigurationCallback += config => { - config[CliConfigNames.AppHostStartupTimeoutSeconds] = "1"; + config[CliConfigNames.AppHostStartupTimeout] = "1"; }; }); @@ -159,7 +159,7 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 1, CliConfigNames.AppHostStartupTimeoutSeconds), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 1, CliConfigNames.AppHostStartupTimeout), Assert.Single(interactionService.DisplayedErrors)); Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully); } @@ -196,7 +196,7 @@ public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWait options.DotNetCliRunnerFactory = runnerFactory; options.ConfigurationCallback += config => { - config[CliConfigNames.AppHostStartupTimeoutSeconds] = "2"; + config[CliConfigNames.AppHostStartupTimeout] = "2"; }; }); services.RemoveAll(); @@ -214,7 +214,7 @@ public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWait Assert.Equal(ExitCodeConstants.FailedToDotnetRunAppHost, exitCode); Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), $"Expected startup timeout to use the remaining budget, but the command took {stopwatch.Elapsed}."); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 2, CliConfigNames.AppHostStartupTimeoutSeconds), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 2, CliConfigNames.AppHostStartupTimeout), Assert.Single(interactionService.DisplayedErrors)); } diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs index 655bc0ccd4e..72bde783ae8 100644 --- a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs @@ -86,7 +86,7 @@ public async Task StartCommand_RejectsInvalidStartupTimeoutEnvironmentVariable() options.InteractionServiceFactory = _ => interactionService; options.ConfigurationCallback += config => { - config[CliConfigNames.AppHostStartupTimeoutSeconds] = "0"; + config[CliConfigNames.AppHostStartupTimeout] = "0"; }; }); using var provider = services.BuildServiceProvider(); @@ -98,7 +98,7 @@ public async Task StartCommand_RejectsInvalidStartupTimeoutEnvironmentVariable() Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, CliConfigNames.AppHostStartupTimeoutSeconds), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, CliConfigNames.AppHostStartupTimeout), Assert.Single(interactionService.DisplayedErrors)); } @@ -116,7 +116,7 @@ public async Task StartCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidanc options.InteractionServiceFactory = _ => interactionService; options.ConfigurationCallback += config => { - config[CliConfigNames.AppHostStartupTimeoutSeconds] = "37"; + config[CliConfigNames.AppHostStartupTimeout] = "37"; }; options.ProjectLocatorFactory = _ => new TestProjectLocator { @@ -143,7 +143,7 @@ public async Task StartCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidanc Assert.True(detachedProcessLauncher.Process.Killed); Assert.True(detachedProcessLauncher.Process.KilledEntireProcessTree); Assert.Equal( - string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37, CliConfigNames.AppHostStartupTimeoutSeconds), + string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 37, CliConfigNames.AppHostStartupTimeout), Assert.Single(interactionService.DisplayedErrors)); } From ec0437e8c2157b7ff8e691ed4e6427fc546d4341 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 21 May 2026 14:57:24 -0700 Subject: [PATCH 6/6] Update AppHostLauncher tests for startup timeout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs index 2a123d1c4ec..b8a45cd36d4 100644 --- a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs @@ -133,6 +133,7 @@ public async Task LaunchDetachedAsync_WaitsForReadinessRpcBeforeReportingSuccess isolated: false, isExtensionHost: false, waitForDebugger: false, + timeoutSeconds: 120, globalArgs: [], additionalArgs: [], stopAfterLaunchDelay: null, @@ -177,6 +178,7 @@ public async Task LaunchDetachedAsync_ReportsFailureWhenReadinessWaitIsInterrupt isolated: false, isExtensionHost: false, waitForDebugger: false, + timeoutSeconds: 120, globalArgs: [], additionalArgs: [], stopAfterLaunchDelay: null, @@ -265,6 +267,7 @@ public async Task LaunchDetachedAsync_ReportsSuccessWhenLegacyV2ProbeSucceeds() isolated: false, isExtensionHost: false, waitForDebugger: false, + timeoutSeconds: 120, globalArgs: [], additionalArgs: [], stopAfterLaunchDelay: null, @@ -300,6 +303,7 @@ public async Task LaunchDetachedAsync_ReportsFailureWhenLegacyV2ProbeDoesNotSucc isolated: false, isExtensionHost: false, waitForDebugger: false, + timeoutSeconds: 120, globalArgs: [], additionalArgs: [], stopAfterLaunchDelay: null,