Skip to content

Commit c8ddbaf

Browse files
authored
Backport CLI AppHost debug console output fixes (#16795) (#16816)
Backports the CLI portions of #16795 to release/13.3. Includes AppHost summary formatting for extension-host output, serialized extension interaction output, and guest AppHost pre-launch failure surfacing.
1 parent 4fff8a1 commit c8ddbaf

3 files changed

Lines changed: 68 additions & 30 deletions

File tree

src/Aspire.Cli/Commands/RunCommand.cs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -515,19 +515,30 @@ internal static int RenderAppHostSummary(
515515

516516
grid.Columns[0].Width = longestLabelLength;
517517

518+
// In the extension's debug console, right-aligned labels and the surrounding padding
519+
// render as visible left indentation, and the empty separator rows show up as blank
520+
// lines that just push real content further down. Use a flush, single-spaced layout
521+
// for the extension and keep the spaced-out look only for direct terminal output.
522+
IRenderable LabelMarkup(string label)
523+
{
524+
var markup = new Markup($"[bold green]{label}[/]:");
525+
return isExtensionHost ? markup : new Align(markup, HorizontalAlignment.Right);
526+
}
527+
518528
// AppHost row
519-
grid.AddRow(
520-
new Align(new Markup($"[bold green]{appHostLabel}[/]:"), HorizontalAlignment.Right),
521-
new Text(appHostRelativePath));
522-
grid.AddRow(Text.Empty, Text.Empty);
529+
grid.AddRow(LabelMarkup(appHostLabel), new Text(appHostRelativePath));
530+
if (!isExtensionHost)
531+
{
532+
grid.AddRow(Text.Empty, Text.Empty);
533+
}
523534

524535
if (!isExtensionHost)
525536
{
526537
// Dashboard row
527538
if (!string.IsNullOrEmpty(dashboardUrl))
528539
{
529540
grid.AddRow(
530-
new Align(new Markup($"[bold green]{dashboardLabel}[/]:"), HorizontalAlignment.Right),
541+
LabelMarkup(dashboardLabel),
531542
new Markup($"[link={dashboardUrl}]{dashboardUrl}[/]"));
532543

533544
// Codespaces URL (if available)
@@ -539,28 +550,27 @@ internal static int RenderAppHostSummary(
539550
else
540551
{
541552
grid.AddRow(
542-
new Align(new Markup($"[bold green]{dashboardLabel}[/]:"), HorizontalAlignment.Right),
553+
LabelMarkup(dashboardLabel),
543554
new Markup("[dim]N/A[/]"));
544555
}
545556
grid.AddRow(Text.Empty, Text.Empty);
546557
}
547558

548559
// Logs row
549-
grid.AddRow(
550-
new Align(new Markup($"[bold green]{logsLabel}[/]:"), HorizontalAlignment.Right),
551-
new Text(logFilePath));
560+
grid.AddRow(LabelMarkup(logsLabel), new Text(logFilePath));
552561

553562
// PID row (if provided)
554563
if (pid.HasValue)
555564
{
556-
grid.AddRow(Text.Empty, Text.Empty);
557-
grid.AddRow(
558-
new Align(new Markup($"[bold green]{pidLabel}[/]:"), HorizontalAlignment.Right),
559-
new Text(pid.Value.ToString(CultureInfo.InvariantCulture)));
565+
if (!isExtensionHost)
566+
{
567+
grid.AddRow(Text.Empty, Text.Empty);
568+
}
569+
grid.AddRow(LabelMarkup(pidLabel), new Text(pid.Value.ToString(CultureInfo.InvariantCulture)));
560570
}
561571

562-
var padder = new Padder(grid, new Padding(3, 0));
563-
console.DisplayRenderable(padder);
572+
IRenderable summary = isExtensionHost ? grid : new Padder(grid, new Padding(3, 0));
573+
console.DisplayRenderable(summary);
564574

565575
return longestLabelLength;
566576
}

src/Aspire.Cli/Interaction/ExtensionInteractionService.cs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,16 +339,27 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri
339339

340340
public void DisplayError(string errorMessage)
341341
{
342-
var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayErrorAsync(errorMessage.RemoveSpectreFormatting(), _cancellationToken));
342+
// Serialize the local console write onto the same channel as the backchannel call so
343+
// it stays ordered with prior queued operations (e.g. DisplayLines). Otherwise the
344+
// synchronous Spectre write would land in the IDE debug console (via stdout/stderr
345+
// capture) before earlier asynchronous DisplayLines RPCs had flushed, producing
346+
// out-of-order output like an error message preceding the lines that explain it.
347+
var result = _extensionTaskChannel.Writer.TryWrite(async () =>
348+
{
349+
await Backchannel.DisplayErrorAsync(errorMessage.RemoveSpectreFormatting(), _cancellationToken);
350+
_consoleInteractionService.DisplayError(errorMessage);
351+
});
343352
Debug.Assert(result);
344-
_consoleInteractionService.DisplayError(errorMessage);
345353
}
346354

347355
public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false)
348356
{
349-
var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayMessageAsync(emoji.Name, message.RemoveSpectreFormatting(), _cancellationToken));
357+
var result = _extensionTaskChannel.Writer.TryWrite(async () =>
358+
{
359+
await Backchannel.DisplayMessageAsync(emoji.Name, message.RemoveSpectreFormatting(), _cancellationToken);
360+
_consoleInteractionService.DisplayMessage(emoji, message, allowMarkup);
361+
});
350362
Debug.Assert(result);
351-
_consoleInteractionService.DisplayMessage(emoji, message, allowMarkup);
352363
}
353364

354365
public void DisplaySuccess(string message, bool allowMarkup = false)
@@ -378,11 +389,20 @@ public void DisplayDashboardUrls(DashboardUrlsState dashboardUrls)
378389

379390
public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines)
380391
{
381-
var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayLinesAsync(lines.Select(line => new DisplayLineState(
392+
// Materialize so we can iterate twice without re-enumerating a possibly lazy/one-shot source.
393+
var materialized = lines as IReadOnlyCollection<(OutputLineStream Stream, string Line)> ?? lines.ToList();
394+
395+
var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayLinesAsync(materialized.Select(line => new DisplayLineState(
382396
line.Stream == OutputLineStream.StdOut ? "stdout" : "stderr",
383397
line.Line.RemoveSpectreFormatting())), _cancellationToken));
384398
Debug.Assert(result);
385-
_consoleInteractionService.DisplayLines(lines);
399+
400+
// Intentionally do NOT also write to the local console here. Unlike most Display* methods
401+
// (whose backchannel sinks are distinct from the debug console — popups, status bar, log
402+
// channel, etc.), the extension's `displayLines` RPC routes the lines into the active
403+
// AppHost debug console. The CLI's stdout/stderr is also captured by the extension and
404+
// forwarded into that same debug console, so calling _consoleInteractionService.DisplayLines
405+
// here would surface every line twice.
386406
}
387407

388408
public void DisplayCancellationMessage()

src/Aspire.Cli/Projects/GuestAppHostProject.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -523,19 +523,19 @@ await GenerateCodeViaRpcAsync(
523523
var (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync(
524524
appHostFile, directory, environmentVariables, enableHotReload, rpcClient, launcher, cancellationToken);
525525

526-
if (launcher is ExtensionGuestLauncher)
527-
{
528-
// Extension manages the guest app host lifecycle via VS Code debug session.
529-
// Wait for the AppHost server to exit (Ctrl+C or extension termination).
530-
await appHostServerProcess.WaitForExitAsync(cancellationToken);
531-
return appHostServerProcess.ExitCode;
532-
}
533-
526+
// A non-zero exit code at this point means the in-CLI portion of the guest run
527+
// (typically a PreExecute step like `tsc --noEmit` for TypeScript) failed before
528+
// the actual AppHost was launched. Surface the failure regardless of launcher
529+
// type, otherwise the extension flow would silently hang in
530+
// appHostServerProcess.WaitForExitAsync waiting for an apphost that was never started.
534531
if (guestExitCode != 0)
535532
{
536533
_logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode);
537534

538-
// Display the output (same pattern as DotNetCliRunner)
535+
// Surface the captured output (e.g. tsc errors from a TypeScript PreExecute step)
536+
// so the user can see why the apphost failed. In the extension flow,
537+
// ExtensionInteractionService.DisplayLines routes these lines through the
538+
// backchannel without also writing them to the CLI's captured stdout/stderr.
539539
if (guestOutput is not null)
540540
{
541541
_interactionService.DisplayLines(guestOutput.GetLines());
@@ -561,6 +561,14 @@ await GenerateCodeViaRpcAsync(
561561
return guestExitCode;
562562
}
563563

564+
if (launcher is ExtensionGuestLauncher)
565+
{
566+
// Extension manages the guest app host lifecycle via VS Code debug session.
567+
// Wait for the AppHost server to exit (Ctrl+C or extension termination).
568+
await appHostServerProcess.WaitForExitAsync(cancellationToken);
569+
return appHostServerProcess.ExitCode;
570+
}
571+
564572
// In watch mode, wait for server to exit (Ctrl+C or orphan detection)
565573
// In non-watch mode, kill the server now that the apphost has exited
566574
if (!enableHotReload && !appHostServerProcess.HasExited)

0 commit comments

Comments
 (0)