Skip to content
Open
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: 12 additions & 0 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ public async Task<string> PromptForStringAsync(string promptText, string? defaul
throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported);
}

// Buffer console logs while interactive prompts are active so
// background debug output doesn't drown the prompt UI.
using var promptScope = SpectreConsoleLoggerProvider.BeginInteractivePromptScope();

MessageLogger.LogInformation("Prompt: {PromptText} (default: {DefaultValue}, secret: {IsSecret})", promptText, isSecret ? "****" : defaultValue ?? "(none)", isSecret);

var prompt = new TextPrompt<string>(promptText)
Expand Down Expand Up @@ -197,6 +201,10 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T
throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NoItemsAvailableForSelection, promptText));
}

// Buffer console logs while interactive prompts are active so
// background debug output doesn't drown the prompt UI.
using var promptScope = SpectreConsoleLoggerProvider.BeginInteractivePromptScope();

// Wrap the caller's formatter to produce safe plain text for Spectre.Console.
// Spectre's SelectionPrompt treats converter output as markup and its search
// highlighting manipulates the markup string directly, which breaks escaped
Expand Down Expand Up @@ -238,6 +246,10 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex
throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NoItemsAvailableForSelection, promptText));
}

// Buffer console logs while interactive prompts are active so
// background debug output doesn't drown the prompt UI.
using var promptScope = SpectreConsoleLoggerProvider.BeginInteractivePromptScope();

var preSelectedSet = preSelected is not null ? new HashSet<T>(preSelected) : null;

var safeFormatter = MakeSafeFormatter(choiceFormatter);
Expand Down
77 changes: 76 additions & 1 deletion src/Aspire.Cli/Interaction/SpectreConsoleLoggerProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ namespace Aspire.Cli.Interaction;

internal class SpectreConsoleLoggerProvider : ILoggerProvider
{
// Shared buffering state for console logs emitted while interactive prompts are active.
private static readonly object s_logBufferLock = new();
private static readonly Queue<(TextWriter Writer, string Message)> s_bufferedMessages = new();
private static int s_interactivePromptDepth;

private readonly TextWriter _output;

/// <summary>
Expand All @@ -19,6 +24,76 @@ public SpectreConsoleLoggerProvider(TextWriter output)
_output = output;
}

// Depth-based scope to support nested prompts. Logs flush when the outermost scope ends.
internal static IDisposable BeginInteractivePromptScope()
{
lock (s_logBufferLock)
{
s_interactivePromptDepth++;
}

return new InteractivePromptScope();
}

internal static void WriteOrBuffer(TextWriter output, string message)
{
lock (s_logBufferLock)
{
// During an active prompt, queue log lines instead of writing immediately.
if (s_interactivePromptDepth > 0)
{
s_bufferedMessages.Enqueue((output, message));
return;
}
}

output.WriteLine(message);
}

private static void EndInteractivePromptScope()
{
List<(TextWriter Writer, string Message)> messagesToFlush = [];

lock (s_logBufferLock)
{
if (s_interactivePromptDepth > 0)
{
s_interactivePromptDepth--;
}

if (s_interactivePromptDepth > 0)
{
return;
}

// Drain under lock to preserve ordering across concurrent writers.
while (s_bufferedMessages.Count > 0)
{
messagesToFlush.Add(s_bufferedMessages.Dequeue());
}
}

// Write outside the lock to avoid holding the global lock during I/O.
foreach (var (writer, message) in messagesToFlush)
{
writer.WriteLine(message);
}
}

private sealed class InteractivePromptScope : IDisposable
{
private int _disposed;

public void Dispose()
{
// Ensure scope close is applied only once for idempotent disposal.
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
EndInteractivePromptScope();
}
}
}

public ILogger CreateLogger(string categoryName)
{
return new SpectreConsoleLogger(_output, categoryName);
Expand Down Expand Up @@ -61,7 +136,7 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
var logMessage = $"[{timestamp}] [{GetLogLevelString(logLevel)}] {shortCategoryName}: {formattedMessage}";

// Write to the configured output (stderr by default)
output.WriteLine(logMessage);
SpectreConsoleLoggerProvider.WriteOrBuffer(output, logMessage);
}

private static string GetLogLevelString(LogLevel logLevel) => logLevel switch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,52 @@ public void SpectreConsoleLogger_Log_IncludesTimestampInHHmmssFormat()
// The format should be: [HH:mm:ss] [dbug] Test: Test debug message
Assert.Matches(@"\[\d{2}:\d{2}:\d{2}\] \[dbug\] Test: Test debug message", outputString);
}

[Fact]
public void SpectreConsoleLogger_Log_BuffersWhileInteractivePromptScopeIsActive()
{
// Arrange
var output = new StringWriter();
var logger = new SpectreConsoleLogger(output, "Aspire.Cli.Test");

// Act
using (SpectreConsoleLoggerProvider.BeginInteractivePromptScope())
{
logger.LogInformation("buffered while prompting");

// Assert
Assert.DoesNotContain("buffered while prompting", output.ToString());
}

// Assert
Assert.Contains("[info] Test: buffered while prompting", output.ToString());
}

[Fact]
public void SpectreConsoleLogger_Log_FlushesOnlyAfterOuterPromptScopeEnds()
{
// Arrange
var output = new StringWriter();
var logger = new SpectreConsoleLogger(output, "Aspire.Cli.Test");

// Act
using (SpectreConsoleLoggerProvider.BeginInteractivePromptScope())
{
logger.LogInformation("first");

using (SpectreConsoleLoggerProvider.BeginInteractivePromptScope())
{
logger.LogInformation("second");
}

Assert.DoesNotContain("first", output.ToString());
Assert.DoesNotContain("second", output.ToString());
}

// Assert
var flushedOutput = output.ToString();
Assert.Contains("[info] Test: first", flushedOutput);
Assert.Contains("[info] Test: second", flushedOutput);
Assert.True(flushedOutput.IndexOf("first", StringComparison.Ordinal) < flushedOutput.IndexOf("second", StringComparison.Ordinal));
}
}
Loading