Skip to content
Merged
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);
}
8 changes: 1 addition & 7 deletions src/Aspire.Cli/Projects/DotNetAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,7 @@ public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken

// Create collector and store in context for exception handling
// This must be set BEFORE signaling build completion to avoid a race condition
var runOutputCollector = new OutputCollector(
_fileLoggerProvider,
"AppHost",
(stream, line) => _interactionService.WriteConsoleLog(
line,
type: "running",
isErrorMessage: stream == "stderr"));
var runOutputCollector = new OutputCollector(_fileLoggerProvider, "AppHost");
context.OutputCollector = runOutputCollector;

// Signal that build/preparation is complete
Expand Down
13 changes: 7 additions & 6 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
Expand Down Expand Up @@ -40,6 +41,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen
private readonly IFeatures _features;
private readonly ILanguageDiscovery _languageDiscovery;
private readonly ILogger<GuestAppHostProject> _logger;
private readonly FileLoggerProvider _fileLoggerProvider;
private readonly TimeProvider _timeProvider;
private readonly RunningInstanceManager _runningInstanceManager;

Expand All @@ -60,6 +62,7 @@ public GuestAppHostProject(
IFeatures features,
ILanguageDiscovery languageDiscovery,
ILogger<GuestAppHostProject> logger,
FileLoggerProvider fileLoggerProvider,
TimeProvider? timeProvider = null)
{
_resolvedLanguage = language;
Expand All @@ -74,6 +77,7 @@ public GuestAppHostProject(
_features = features;
_languageDiscovery = languageDiscovery;
_logger = logger;
_fileLoggerProvider = fileLoggerProvider;
_timeProvider = timeProvider ?? TimeProvider.System;
_runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider);
}
Expand Down Expand Up @@ -504,10 +508,7 @@ await GenerateCodeViaRpcAsync(
}
else
{
launcher = _guestRuntime.CreateDefaultLauncher((stream, line) => _interactionService.WriteConsoleLog(
line,
type: "running",
isErrorMessage: stream == "stderr"));
launcher = _guestRuntime.CreateDefaultLauncher();
}

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

// Display the output (same pattern as DotNetCliRunner)
if (guestOutput is { HasLiveOutputCallback: false })
if (guestOutput is not null)
{
_interactionService.DisplayLines(guestOutput.GetLines());
}
Expand Down Expand Up @@ -1278,7 +1279,7 @@ private async Task EnsureRuntimeCreatedAsync(
if (_guestRuntime is null)
{
var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(_resolvedLanguage.LanguageId, cancellationToken);
_guestRuntime = new GuestRuntime(runtimeSpec, _logger);
_guestRuntime = new GuestRuntime(runtimeSpec, _logger, _fileLoggerProvider);

_logger.LogDebug("Created GuestRuntime for {Language}: Execute={Command} {Args}",
_resolvedLanguage.LanguageId,
Expand Down
8 changes: 6 additions & 2 deletions src/Aspire.Cli/Projects/GuestRuntime.cs
Original file line number Diff line number Diff line change
@@ -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 Aspire.Cli.Diagnostics;
using Aspire.Cli.Utils;
using Aspire.TypeSystem;
using Microsoft.Extensions.Logging;
Expand All @@ -15,18 +16,21 @@ internal sealed class GuestRuntime
{
private readonly RuntimeSpec _spec;
private readonly ILogger _logger;
private readonly FileLoggerProvider? _fileLoggerProvider;
private readonly Func<string, string?> _commandResolver;

/// <summary>
/// Creates a new GuestRuntime for the given runtime specification.
/// </summary>
/// <param name="spec">The runtime specification describing how to execute the guest language.</param>
/// <param name="logger">Logger for debugging output.</param>
/// <param name="fileLoggerProvider">Optional file logger for writing output to disk.</param>
/// <param name="commandResolver">Optional command resolver used to locate executables on PATH.</param>
public GuestRuntime(RuntimeSpec spec, ILogger logger, Func<string, string?>? commandResolver = null)
public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func<string, string?>? commandResolver = null)
{
_spec = spec;
_logger = logger;
_fileLoggerProvider = fileLoggerProvider;
_commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath;
}

Expand Down Expand Up @@ -151,7 +155,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func<string, string?>? com
/// <summary>
/// Creates the default process-based launcher for this runtime.
/// </summary>
public ProcessGuestLauncher CreateDefaultLauncher(Action<string, string>? liveOutputCallback = null) => new(_spec.Language, _logger, _commandResolver, liveOutputCallback);
public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _fileLoggerProvider, _commandResolver);

/// <summary>
/// Replaces placeholders in command arguments with actual values.
Expand Down
34 changes: 29 additions & 5 deletions src/Aspire.Cli/Projects/ProcessGuestLauncher.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 System.Diagnostics;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;

Expand All @@ -14,15 +15,15 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher
{
private readonly string _language;
private readonly ILogger _logger;
private readonly FileLoggerProvider? _fileLoggerProvider;
private readonly Func<string, string?> _commandResolver;
private readonly Action<string, string>? _liveOutputCallback;

public ProcessGuestLauncher(string language, ILogger logger, Func<string, string?>? commandResolver = null, Action<string, string>? liveOutputCallback = null)
public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func<string, string?>? commandResolver = null)
{
_language = language;
_logger = logger;
_fileLoggerProvider = fileLoggerProvider;
_commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath;
_liveOutputCallback = liveOutputCallback;
}

public async Task<(int ExitCode, OutputCollector? Output)> LaunchAsync(
Expand Down Expand Up @@ -64,14 +65,15 @@ public ProcessGuestLauncher(string language, ILogger logger, Func<string, string

using var process = new Process { StartInfo = startInfo };

var outputCollector = new OutputCollector(fileLogger: null, liveOutputCallback: _liveOutputCallback);
var outputCollector = new OutputCollector(_fileLoggerProvider, "AppHost");
var stdoutCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var stderrCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

process.OutputDataReceived += (sender, e) =>
{
if (e.Data is null)
{
// ProcessDataReceivedEventArgs.Data is null when the redirected stdout stream closes.
stdoutCompleted.TrySetResult();
}
else
Expand All @@ -85,6 +87,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,7 +102,28 @@ public ProcessGuestLauncher(string language, ILogger logger, Func<string, string
process.BeginErrorReadLine();

await process.WaitForExitAsync(cancellationToken);
await Task.WhenAll(stdoutCompleted.Task, stderrCompleted.Task).WaitAsync(cancellationToken);

// Wait for the redirected streams to finish draining so no trailing lines are lost.
if (!await WaitForDrainAsync(Task.WhenAll(stdoutCompleted.Task, stderrCompleted.Task), cancellationToken))
{
_logger.LogWarning("{Language}({ProcessId}): Timed out waiting for output streams to drain after process exit", _language, process.Id);
}

return (process.ExitCode, outputCollector);
}

private static async Task<bool> WaitForDrainAsync(Task drainTask, CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
try
{
await drainTask.WaitAsync(timeoutCts.Token);
return true;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
}
}
27 changes: 13 additions & 14 deletions src/Aspire.Cli/Utils/OutputCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@

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;

/// <summary>
/// Creates an OutputCollector that only buffers output in memory.
Expand All @@ -26,42 +31,36 @@ 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)
public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHost")
{
_fileLogger = fileLogger;
_category = category;
_liveOutputCallback = liveOutputCallback;
}

public bool HasLiveOutputCallback => _liveOutputCallback is not null;

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);
}
}
Loading
Loading