Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,10 @@ public void WriteConsoleLog(string message, int? lineNumber = null, string? type
var style = isErrorMessage ? s_errorMessageStyle
: type switch
{
"waiting" => s_waitingMessageStyle,
"running" => s_infoMessageStyle,
"exitCode" => s_exitCodeMessageStyle,
"failedToStart" => s_errorMessageStyle,
ConsoleLogTypes.Waiting => s_waitingMessageStyle,
ConsoleLogTypes.Running => s_infoMessageStyle,
ConsoleLogTypes.ExitCode => s_exitCodeMessageStyle,
ConsoleLogTypes.FailedToStart => s_errorMessageStyle,
_ => s_infoMessageStyle
};

Expand All @@ -302,11 +302,11 @@ public void DisplaySuccess(string message, bool allowMarkup = false)
DisplayMessage(KnownEmojis.CheckMark, message, allowMarkup);
}

public void DisplayLines(IEnumerable<(string Stream, string Line)> lines)
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines)
{
foreach (var (stream, line) in lines)
{
if (stream == "stdout")
if (stream == OutputLineStream.StdOut)
{
MessageConsole.MarkupLineInterpolated($"{line.EscapeMarkup()}");
}
Expand Down
15 changes: 15 additions & 0 deletions src/Aspire.Cli/Interaction/ConsoleLogTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Cli.Interaction;

/// <summary>
/// Known semantic types for console log messages emitted directly by the CLI.
/// </summary>
internal static class ConsoleLogTypes
{
public const string Waiting = "waiting";
public const string Running = "running";
public const string ExitCode = "exitCode";
public const string FailedToStart = "failedToStart";
}
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Interaction/ExtensionInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,11 @@ public void DisplayDashboardUrls(DashboardUrlsState dashboardUrls)
Debug.Assert(result);
}

public void DisplayLines(IEnumerable<(string Stream, string Line)> lines)
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines)
{
var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayLinesAsync(lines.Select(line => new DisplayLineState(line.Stream.RemoveSpectreFormatting(), line.Line.RemoveSpectreFormatting())), _cancellationToken));
var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayLinesAsync(lines.Select(line => new DisplayLineState(
line.Stream == OutputLineStream.StdOut ? "stdout" : "stderr",
line.Line.RemoveSpectreFormatting())), _cancellationToken));
Debug.Assert(result);
_consoleInteractionService.DisplayLines(lines);
}
Expand Down
5 changes: 4 additions & 1 deletion src/Aspire.Cli/Interaction/IInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Backchannel;
using Aspire.Cli.Utils;
using Spectre.Console;
using Spectre.Console.Rendering;

Expand All @@ -25,7 +26,7 @@ internal interface IInteractionService
void DisplayMarkupLine(string markup);
void DisplaySuccess(string message, bool allowMarkup = false);
void DisplaySubtleMessage(string message, bool allowMarkup = false);
void DisplayLines(IEnumerable<(string Stream, string Line)> lines);
void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines);
void DisplayRenderable(IRenderable renderable);
Task DisplayLiveAsync(IRenderable initialRenderable, Func<Action<IRenderable>, Task> callback);
void DisplayCancellationMessage();
Expand All @@ -39,5 +40,7 @@ internal interface IInteractionService
ConsoleOutput Console { get; set; }

void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null);
// The semantic type is stringly-typed because some values originate from backchannel payloads.
// Use ConsoleLogTypes for CLI-defined values.
void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false);
}
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Projects/DotNetAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,8 @@ public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken
"AppHost",
(stream, line) => _interactionService.WriteConsoleLog(
line,
type: "running",
isErrorMessage: stream == "stderr"));
type: ConsoleLogTypes.Running,
isErrorMessage: stream == OutputLineStream.StdErr));
context.OutputCollector = runOutputCollector;

// Signal that build/preparation is complete
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,8 @@ await GenerateCodeViaRpcAsync(
{
launcher = _guestRuntime.CreateDefaultLauncher((stream, line) => _interactionService.WriteConsoleLog(
line,
type: "running",
isErrorMessage: stream == "stderr"));
type: ConsoleLogTypes.Running,
isErrorMessage: stream == OutputLineStream.StdErr));
}

// Start guest apphost - it will connect to AppHost server, define resources.
Expand All @@ -527,7 +527,7 @@ await GenerateCodeViaRpcAsync(
{
_logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode);

// Display the output (same pattern as DotNetCliRunner)
// Replay buffered output only when the process failed before live streaming began.
if (guestOutput is { HasLiveOutputCallback: false })
{
_interactionService.DisplayLines(guestOutput.GetLines());
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Projects/GuestRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,10 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func<string, string?>? com

/// <summary>
/// Creates the default process-based launcher for this runtime.
/// Pass a callback only for interactive run flows that should stream output live.
/// Install and publish flows leave this <see langword="null"/> and replay buffered output later if needed.
/// </summary>
public ProcessGuestLauncher CreateDefaultLauncher(Action<string, string>? liveOutputCallback = null) => new(_spec.Language, _logger, _commandResolver, liveOutputCallback);
public ProcessGuestLauncher CreateDefaultLauncher(Action<OutputLineStream, string>? liveOutputCallback = null) => new(_spec.Language, _logger, _commandResolver, liveOutputCallback);

/// <summary>
/// Replaces placeholders in command arguments with actual values.
Expand Down
7 changes: 5 additions & 2 deletions src/Aspire.Cli/Projects/ProcessGuestLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher
private readonly string _language;
private readonly ILogger _logger;
private readonly Func<string, string?> _commandResolver;
private readonly Action<string, string>? _liveOutputCallback;
private readonly Action<OutputLineStream, string>? _liveOutputCallback;

public ProcessGuestLauncher(string language, ILogger logger, Func<string, string?>? commandResolver = null, Action<string, string>? liveOutputCallback = null)
public ProcessGuestLauncher(string language, ILogger logger, Func<string, string?>? commandResolver = null, Action<OutputLineStream, string>? liveOutputCallback = null)
{
_language = language;
_logger = logger;
Expand Down Expand Up @@ -72,6 +72,7 @@ public ProcessGuestLauncher(string language, ILogger logger, Func<string, string
{
if (e.Data is null)
{
// ProcessDataReceivedEventArgs.Data is null when the redirected stdout stream closes.
stdoutCompleted.TrySetResult();
}
else
Expand All @@ -85,6 +86,7 @@ public ProcessGuestLauncher(string language, ILogger logger, Func<string, string
{
if (e.Data is null)
{
// ProcessDataReceivedEventArgs.Data is null when the redirected stderr stream closes.
stderrCompleted.TrySetResult();
}
else
Expand All @@ -99,6 +101,7 @@ public ProcessGuestLauncher(string language, ILogger logger, Func<string, string
process.BeginErrorReadLine();

await process.WaitForExitAsync(cancellationToken);
// Wait for the redirected streams to finish draining so no trailing lines are lost.
await Task.WhenAll(stdoutCompleted.Task, stderrCompleted.Task).WaitAsync(cancellationToken);
Comment on lines 103 to 105
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, might be worth having a timeout just in case something goes wrong.

return (process.ExitCode, outputCollector);
}
Expand Down
27 changes: 18 additions & 9 deletions src/Aspire.Cli/Utils/OutputCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@

namespace Aspire.Cli.Utils;

internal enum OutputLineStream
{
StdOut,
StdErr
}

internal sealed class OutputCollector
{
private readonly CircularBuffer<(string Stream, string Line)> _lines = new(10000); // 10k lines.
private readonly CircularBuffer<(OutputLineStream Stream, string Line)> _lines = new(10000); // 10k lines.
private readonly object _lock = new object();
private readonly FileLoggerProvider? _fileLogger;
private readonly string _category;
private readonly Action<string, string>? _liveOutputCallback;
private readonly Action<OutputLineStream, string>? _liveOutputCallback;

/// <summary>
/// Creates an OutputCollector that only buffers output in memory.
Expand All @@ -26,8 +32,11 @@ public OutputCollector() : this(null, "AppHost")
/// </summary>
/// <param name="fileLogger">Optional file logger for writing output to disk.</param>
/// <param name="category">Category for log entries (e.g., "Build", "AppHost").</param>
/// <param name="liveOutputCallback">Optional callback invoked immediately when a line is appended.</param>
public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHost", Action<string, string>? liveOutputCallback = null)
/// <param name="liveOutputCallback">
/// Optional callback used by interactive run flows that need lines as they arrive.
/// Leave this <see langword="null"/> for buffer-only flows that replay output later on failure.
/// </param>
public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHost", Action<OutputLineStream, string>? liveOutputCallback = null)
{
_fileLogger = fileLogger;
_category = category;
Expand All @@ -38,28 +47,28 @@ public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHos

public void AppendOutput(string line)
{
AppendLine("stdout", line);
AppendLine(OutputLineStream.StdOut, line);
}

public void AppendError(string line)
{
AppendLine("stderr", line);
AppendLine(OutputLineStream.StdErr, line);
}

public IEnumerable<(string Stream, string Line)> GetLines()
public IEnumerable<(OutputLineStream Stream, string Line)> GetLines()
{
lock (_lock)
{
return _lines.ToArray();
}
}

private void AppendLine(string stream, string line)
private void AppendLine(OutputLineStream stream, string line)
{
lock (_lock)
{
_lines.Add((stream, line));
_fileLogger?.WriteLog(DateTimeOffset.UtcNow, stream == "stderr" ? LogLevel.Error : LogLevel.Information, _category, line);
_fileLogger?.WriteLog(DateTimeOffset.UtcNow, stream == OutputLineStream.StdErr ? LogLevel.Error : LogLevel.Information, _category, line);
}

_liveOutputCallback?.Invoke(stream, line);
Expand Down
2 changes: 1 addition & 1 deletion tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1574,7 +1574,7 @@ public Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEn
public void DisplayError(string errorMessage) { }
public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { }
public void DisplaySuccess(string message, bool allowMarkup = false) { }
public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { }
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { }
public void DisplayCancellationMessage() { }
public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,7 @@ public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, Canc
public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { }
public void DisplaySuccess(string message, bool allowMarkup = false) { }
public void DisplaySubtleMessage(string message, bool allowMarkup = false) { }
public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { }
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { }
public void DisplayCancellationMessage() { }
public void DisplayEmptyLine() { }
public void DisplayPlainText(string text) { }
Expand Down
3 changes: 2 additions & 1 deletion tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
using Spectre.Console.Rendering;
Expand Down Expand Up @@ -1062,7 +1063,7 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri
public void DisplayMarkupLine(string markup) => _innerService.DisplayMarkupLine(markup);
public void DisplaySuccess(string message, bool allowMarkup = false) => _innerService.DisplaySuccess(message, allowMarkup);
public void DisplaySubtleMessage(string message, bool allowMarkup = false) => _innerService.DisplaySubtleMessage(message, allowMarkup);
public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) => _innerService.DisplayLines(lines);
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) => _innerService.DisplayLines(lines);
public void DisplayCancellationMessage()
{
OnCancellationMessageDisplayed?.Invoke();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ public void DisplayLines_WithMarkupCharacters_DoesNotCauseMarkupParsingError()
var interactionService = CreateInteractionService(console, executionContext);
var lines = new[]
{
("stdout", "Command output with <angle> brackets"),
("stderr", "Error output with [square] brackets")
(OutputLineStream.StdOut, "Command output with <angle> brackets"),
(OutputLineStream.StdErr, "Error output with [square] brackets")
};

// Act - this should not throw an exception due to markup parsing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;

namespace Aspire.Cli.Tests.Projects;

Expand Down Expand Up @@ -165,7 +166,7 @@ public Task LaunchAppHostAsync(string projectFile, List<string> arguments, List<
public void DisplayError(string errorMessage) => throw new NotImplementedException();
public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) => throw new NotImplementedException();
public void DisplaySuccess(string message, bool allowMarkup = false) => throw new NotImplementedException();
public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) => throw new NotImplementedException();
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) => throw new NotImplementedException();
public void DisplayCancellationMessage() => throw new NotImplementedException();
public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public void DisplaySubtleMessage(string message, bool allowMarkup = false) => throw new NotImplementedException();
Expand Down
4 changes: 2 additions & 2 deletions tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ public async Task InstallDependenciesAsync_WhenNpmIsMissing_ReturnsNodeInstallMe
output.GetLines(),
line =>
{
Assert.Equal("stderr", line.Stream);
Assert.Equal(OutputLineStream.StdErr, line.Stream);
Assert.Equal("npm is not installed or not found in PATH. Please install Node.js and try again.", line.Line);
});
}
Expand Down Expand Up @@ -354,7 +354,7 @@ public async Task RunAsync_WhenNpxIsMissing_ReturnsNodeInstallMessage()
resolvedOutput.GetLines(),
line =>
{
Assert.Equal("stderr", line.Stream);
Assert.Equal(OutputLineStream.StdErr, line.Stream);
Assert.Equal("npx is not installed or not found in PATH. Please install Node.js and try again.", line.Line);
});
}
Expand Down
5 changes: 3 additions & 2 deletions tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging.Abstractions;

namespace Aspire.Cli.Tests.Projects;
Expand All @@ -12,7 +13,7 @@ public class ProcessGuestLauncherTests
public async Task LaunchAsync_ForwardsStdoutToLiveCallback()
{
// Arrange
var forwardedLines = new List<(string Stream, string Line)>();
var forwardedLines = new List<(OutputLineStream Stream, string Line)>();
var launcher = new ProcessGuestLauncher(
"typescript",
NullLogger.Instance,
Expand All @@ -29,6 +30,6 @@ public async Task LaunchAsync_ForwardsStdoutToLiveCallback()
// Assert
Assert.Equal(0, exitCode);
Assert.NotNull(output);
Assert.Contains(forwardedLines, line => line.Stream == "stdout" && !string.IsNullOrWhiteSpace(line.Line));
Assert.Contains(forwardedLines, line => line.Stream == OutputLineStream.StdOut && !string.IsNullOrWhiteSpace(line.Line));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ public void ShowStatus(string message, Action work, KnownEmoji? emoji = null, bo
public void DisplaySuccess(string message, bool allowMarkup = false) { }
public void DisplayError(string message) { }
public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { }
public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { }
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) { }
public void DisplayCancellationMessage() { }
public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0;
public void DisplayPlainText(string text) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
Expand Down Expand Up @@ -112,7 +113,7 @@ public void WriteDebugSessionMessage(string message, bool stdout, string? textSt
{
}

public void DisplayLines(IEnumerable<(string Stream, string Line)> lines)
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines)
{
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Utils;
using Spectre.Console;
using Spectre.Console.Rendering;

Expand Down Expand Up @@ -153,7 +154,7 @@ public void DisplaySuccess(string message, bool allowMarkup = false)
{
}

public void DisplayLines(IEnumerable<(string Stream, string Line)> lines)
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines)
{
}

Expand Down
Loading
Loading